import type { FC } from 'react'; import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package, RotateCcw, Keyboard, Lock, TrendingUp, LayoutGrid, List, Layers, } from 'lucide-react'; import Layout from '../components/Layout'; import { usePatientDrawer } from '../components/PatientDrawer'; import TicketPrint from '../components/TicketPrint'; import { Card, Button, Input, Select, Modal, EmptyState, Skeleton, toast, } from '../components/ui'; import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete } from '../services/odoo'; import type { PatientPackage } from '../services/odoo'; import { useAuth } from '../lib/auth'; import { useClinicSettings } from '../lib/clinic-settings'; const fmtMoney = (n: number) => (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 }); const fmtUsd = (n: number) => `$${(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} USD`; const METODO_OPTIONS = [ { value: 'cash', label: 'Efectivo' }, { value: 'card', label: 'Tarjeta' }, { value: 'transfer', label: 'Transferencia' }, { value: 'stripe', label: 'Stripe' }, { value: 'mercadopago', label: 'MercadoPago' }, { value: 'saldo_favor', label: 'Saldo a favor' }, ]; const metodoLabel = (m: string) => METODO_OPTIONS.find((o) => o.value === m)?.label || m; interface TicketLine { key: string; name: string; qty: number; price: number; service_id?: number; item_id?: number; paquete_id?: number; sesiones?: number; stock?: number; unit?: string; /** Tasa de impuesto (%) de la línea (0 con impuestos apagados) */ tax_rate?: number; } const Pos: FC = () => { const { openPatient } = usePatientDrawer(); const { can } = useAuth(); const impuestos = useClinicSettings().impuestos; const puedeImpuestos = can('ventas.impuestos'); const [searchParams, setSearchParams] = useSearchParams(); // Catálogo const [catMode, setCatMode] = useState<'servicios' | 'paquetes' | 'articulos'>('servicios'); // Catálogo de paquetes de sesiones (skeen.paquete) const [paqueteCatalogo, setPaqueteCatalogo] = useState([]); const [services, setServices] = useState([]); const [catalogLoading, setCatalogLoading] = useState(true); const [search, setSearch] = useState(''); const [categoria, setCategoria] = useState(''); const [soloFavoritos, setSoloFavoritos] = useState(false); const [ordenServicios, setOrdenServicios] = useState<'manual' | 'best_sellers'>('manual'); // Vista del catálogo: cuadrícula (tarjetas) o lista compacta, para catálogos // largos. Se recuerda entre sesiones como el resto de preferencias. const [vista, setVista] = useState<'grid' | 'lista'>(() => { try { return localStorage.getItem('skeen_pos_vista') === 'lista' ? 'lista' : 'grid'; } catch { return 'grid'; } }); const cambiarVista = (v: 'grid' | 'lista') => { setVista(v); try { localStorage.setItem('skeen_pos_vista', v); } catch { // localStorage no disponible: queda solo en memoria } }; // Orden de la lista: por nombre o por precio (click en el encabezado) const [ordenLista, setOrdenLista] = useState<{ campo: 'nombre' | 'precio'; dir: 1 | -1 }>({ campo: 'nombre', dir: 1 }); const alternarOrdenLista = (campo: 'nombre' | 'precio') => setOrdenLista((prev) => (prev.campo === campo ? { campo, dir: prev.dir === 1 ? -1 : 1 } : { campo, dir: 1 })); const [invItems, setInvItems] = useState([]); // Ticket const [lines, setLines] = useState([]); const [discount, setDiscount] = useState(''); const [paciente, setPaciente] = useState(null); const [pacienteAdeudo, setPacienteAdeudo] = useState(0); const [pacienteQuery, setPacienteQuery] = useState(''); const [pacienteResults, setPacienteResults] = useState([]); const [altaRapida, setAltaRapida] = useState(false); const [altaForm, setAltaForm] = useState({ name: '', phone: '' }); // Cobro const [cobroOpen, setCobroOpen] = useState(false); const [metodo, setMetodo] = useState('cash'); const [moneda, setMoneda] = useState<'MXN' | 'USD'>('MXN'); const [recibido, setRecibido] = useState(''); const [conPuntos, setConPuntos] = useState(false); const [cobrando, setCobrando] = useState(false); const [tcRate, setTcRate] = useState(null); // Doctor que atendió/recetó: se aplica como prescribed_by_id a todas las // líneas al cobrar (alimenta "Mis ventas" y comisiones). Opcional. const [doctors, setDoctors] = useState([]); const [doctorId, setDoctorId] = useState(''); // Resultado const [resultado, setResultado] = useState(null); // Corte de caja const [cajaAbierta, setCajaAbierta] = useState(true); const [cajaModalOpen, setCajaModalOpen] = useState(false); const [cajaFondo, setCajaFondo] = useState(''); const [cajaAbriendo, setCajaAbriendo] = useState(false); // Repetir última venta const [lastSaleLoading, setLastSaleLoading] = useState(false); // Paquetes del paciente con sesiones pendientes const [paquetes, setPaquetes] = useState([]); // Atajos de teclado const [shortcutsOpen, setShortcutsOpen] = useState(false); const [ticketFocused, setTicketFocused] = useState(false); const searchWrapRef = useRef(null); // Mobile const [ticketMovil, setTicketMovil] = useState(false); // ---- Catálogo ---- // Orden del catálogo de servicios: manual (sequence definida en Servicios) // o por más vendidos de los últimos 90 días. const loadServices = useCallback(async () => { try { setCatalogLoading(true); const res = await odooApi.getServices({ search: search || undefined, category: categoria || undefined, page_size: 200, sort: ordenServicios, }); if (res.status === 'success') setServices(res.services); } catch (err) { toast.error('Error al cargar servicios'); console.error(err); } finally { setCatalogLoading(false); } }, [search, categoria, ordenServicios]); const loadItems = useCallback(async () => { try { setCatalogLoading(true); const res = await odooApi.getInventarioItems(search || undefined); if (res.status === 'success') setInvItems(res.items); } catch (err) { toast.error('Error al cargar artículos'); console.error(err); } finally { setCatalogLoading(false); } }, [search]); // Paquetes de sesiones: catálogo corto, se carga completo y se filtra en local const loadPaquetes = useCallback(async () => { try { setCatalogLoading(true); const res = await odooApi.getPaqueteCatalogo(); if (res.status === 'success') setPaqueteCatalogo(res.paquetes); } catch (err) { toast.error('Error al cargar paquetes'); console.error(err); } finally { setCatalogLoading(false); } }, []); useEffect(() => { const t = setTimeout(() => { if (catMode === 'servicios') loadServices(); else if (catMode === 'articulos') loadItems(); }, 300); return () => clearTimeout(t); }, [loadServices, loadItems, catMode]); // El catálogo de paquetes es corto: se carga una vez y se filtra en local. useEffect(() => { loadPaquetes(); }, [loadPaquetes]); useEffect(() => { odooApi.getCashClosings(new Date().toISOString().split('T')[0]) .then((res) => { if (res.status === 'success') setCajaAbierta(res.cash_closings.some((c) => c.state === 'open')); }) .catch(() => {}); }, []); // Doctores activos para el selector "Doctor que atendió" useEffect(() => { odooApi.getDoctors() .then((res) => { if (res.status === 'success') setDoctors(res.doctors); }) .catch(() => {}); }, []); // Pre-carga "Cobrar a la cita" desde Agenda: // /pos?patient_id=X&service_id=Y&doctor_id=Z&appointment_id=N // Se re-ejecuta cuando cambia patient_id (llegar a /pos ya montado desde el // drawer no remonta el componente) y los params se consumen tras precargar. const preloadPatientId = searchParams.get('patient_id'); useEffect(() => { if (!preloadPatientId) return; const sid = searchParams.get('service_id'); const did = searchParams.get('doctor_id'); (async () => { try { const res = await odooApi.getPatient(parseInt(preloadPatientId, 10)); if (res.status === 'success') selectPaciente(res.patient); } catch { toast.error('No se pudo cargar el paciente de la cita'); } if (did) setDoctorId(did); if (sid) { try { const sRes = await odooApi.getServices({ page_size: 200 }); const svc = (sRes.services || []).find((s) => s.id === parseInt(sid, 10)); if (svc) { setLines((prev) => { const key = `s-${svc.id}`; if (prev.some((l) => l.key === key)) return prev; return [...prev, { key, name: svc.name, qty: 1, price: svc.price, service_id: svc.id }]; }); } else { toast.error('El servicio de la cita no está activo en el catálogo'); } } catch { toast.error('No se pudo cargar el servicio de la cita'); } } toast.success('Cita precargada en el ticket'); setSearchParams((prev) => { const next = new URLSearchParams(prev); ['patient_id', 'service_id', 'doctor_id', 'appointment_id'].forEach((k) => next.delete(k)); return next; }, { replace: true }); })(); }, [preloadPatientId, searchParams, setSearchParams]); // Tipo de cambio USD/MXN: al montar y refresco cada 5 min useEffect(() => { const fetchTc = () => { odooApi.getExchangeRate() .then((res) => { if (res.current) setTcRate(res.current.rate); }) .catch(() => {}); }; fetchTc(); const interval = setInterval(fetchTc, 5 * 60 * 1000); return () => clearInterval(interval); }, []); // Búsqueda server-side de paciente useEffect(() => { if (paciente || altaRapida) return; if (pacienteQuery.trim().length < 2) { setPacienteResults([]); return; } const t = setTimeout(() => { odooApi.getPatients({ search: pacienteQuery.trim(), page_size: 8 }) .then((res) => { if (res.status === 'success') setPacienteResults(res.patients); }) .catch(() => {}); }, 300); return () => clearTimeout(t); }, [pacienteQuery, paciente, altaRapida]); const categorias = useMemo(() => { const set = new Map(); services.forEach((s) => { if (s.category) set.set(s.category, s.category); }); return [...set.keys()]; }, [services]); const visibles = useMemo( () => (soloFavoritos ? services.filter((s) => s.is_favorite) : services), [services, soloFavoritos] ); // Los artículos ya vienen filtrados del server-side (search) const visiblesItems = invItems; // Lista ordenable por nombre o precio (click en el encabezado) const serviciosLista = useMemo(() => { const arr = [...visibles]; arr.sort((a, b) => { const v = ordenLista.campo === 'nombre' ? a.name.localeCompare(b.name, 'es') : (a.price || 0) - (b.price || 0); return v * ordenLista.dir; }); return arr; }, [visibles, ordenLista]); const itemsLista = useMemo(() => { const arr = [...visiblesItems]; arr.sort((a, b) => { const v = ordenLista.campo === 'nombre' ? a.name.localeCompare(b.name, 'es') : (a.price || 0) - (b.price || 0); return v * ordenLista.dir; }); return arr; }, [visiblesItems, ordenLista]); // Paquetes visibles: filtro local por nombre / servicio / código const paquetesVisibles = useMemo(() => { const q = (search || '').trim().toLowerCase(); const arr = !q ? [...paqueteCatalogo] : paqueteCatalogo.filter((p) => p.name.toLowerCase().includes(q) || (p.service || '').toLowerCase().includes(q) || (p.code || '').toLowerCase().includes(q)); arr.sort((a, b) => (a.sequence || 0) - (b.sequence || 0) || a.name.localeCompare(b.name, 'es')); return arr; }, [paqueteCatalogo, search]); // Un paquete solo puede venderse a un paciente: el saldo vive en él. const addPaqueteLine = (p: Paquete) => { if (!paciente) { toast.error('Selecciona primero el paciente: el saldo del paquete es por paciente'); return; } setLines((prev) => { const key = `p-${p.id}`; const found = prev.find((l) => l.key === key); if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l)); return [...prev, { key, name: `${p.name} · Paquete (${p.sessions} sesiones)`, qty: 1, price: p.price, service_id: p.service_id ?? undefined, paquete_id: p.id, sesiones: p.sessions, tax_rate: tasaDePaquete(p), }]; }); }; // ---- Ticket ---- // Tasa de impuesto que corresponde a un platillo del catálogo (0 si apagado). const tasaDeServicio = (s: Service) => impuestos.activo ? (s.tax_use_default !== false ? impuestos.tasa_default : (s.tax_rate ?? 0)) : 0; const tasaDeItem = (it: InventoryItem) => impuestos.activo ? (it.tax_use_default !== false ? impuestos.tasa_default : (it.tax_rate ?? 0)) : 0; const tasaDePaquete = (p: Paquete) => { if (!impuestos.activo) return 0; const servicio = services.find((s) => s.id === p.service_id); return servicio ? tasaDeServicio(servicio) : impuestos.tasa_default; }; const addLine = (service: Service) => { setLines((prev) => { const key = `s-${service.id}`; const found = prev.find((l) => l.key === key); if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l)); return [...prev, { key, name: service.name, qty: 1, price: service.price, service_id: service.id, tax_rate: tasaDeServicio(service) }]; }); }; const addItemLine = (item: InventoryItem) => { setLines((prev) => { const key = `i-${item.id}`; const found = prev.find((l) => l.key === key); if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l)); return [...prev, { key, name: item.name, qty: 1, price: item.price || 0, item_id: item.id, stock: item.qty, unit: item.unit, tax_rate: tasaDeItem(item) }]; }); }; const updateLine = (key: string, patch: Partial) => { setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))); }; const removeLine = (key: string) => setLines((prev) => prev.filter((l) => l.key !== key)); const limpiarTicket = () => { if (lines.length === 0) return; if (window.confirm('¿Vaciar el ticket?')) setLines([]); }; // Desglose del ticket. Con los impuestos apagados: bruto === base, impuesto 0 // y el total sale exactamente igual que antes. const bruto = lines.reduce((a, l) => a + l.qty * l.price, 0); const impuestoLinea = (l: TicketLine): number => { if (!impuestos.activo) return 0; const t = l.tax_rate ?? 0; if (t <= 0) return 0; const b = l.qty * l.price; return impuestos.precios_incluyen_impuesto ? b - b / (1 + t / 100) : (b * t) / 100; }; const impuesto = impuestos.activo ? lines.reduce((a, l) => a + impuestoLinea(l), 0) : 0; const subtotal = impuestos.activo && impuestos.precios_incluyen_impuesto ? bruto - impuesto : bruto; const discountNum = Math.min(parseFloat(discount) || 0, subtotal); const total = Math.max(0, subtotal - discountNum + impuesto); const numItems = lines.reduce((a, l) => a + l.qty, 0); const puntosPaciente = paciente?.wallet_points ?? 0; const puedePuntos = puntosPaciente >= total && total > 0; // Equivalentes USD (si hay tipo de cambio configurado) const toUsd = (mxn: number): number | null => (tcRate ? mxn / tcRate : null); const recibidoMxn = recibido ? moneda === 'USD' && tcRate ? (parseFloat(recibido) || 0) * tcRate : parseFloat(recibido) || 0 : 0; const cambio = metodo === 'cash' && recibido ? Math.max(0, recibidoMxn - total) : 0; const cambioUsd = moneda === 'USD' && tcRate ? cambio / tcRate : 0; const crearPacienteRapido = async () => { if (!altaForm.name.trim() || !altaForm.phone.trim()) { toast.error('Nombre y teléfono son obligatorios'); return; } try { const res = await odooApi.createPatient({ name: altaForm.name.trim(), phone: altaForm.phone.trim() }); if (res.status === 'success') { setPaciente(res.patient); setAltaRapida(false); setAltaForm({ name: '', phone: '' }); toast.success('Paciente creado'); } } catch (err) { toast.error('Error al crear paciente'); console.error(err); } }; const abrirCobro = () => { if (!paciente) { toast.error('Selecciona un paciente'); return; } if (lines.length === 0) { toast.error('El ticket está vacío'); return; } setMetodo('cash'); setMoneda('MXN'); setRecibido(''); setConPuntos(false); setCobroOpen(true); }; const cobrar = async () => { if (!paciente) return; try { setCobrando(true); const res = await odooApi.posCheckout({ partner_id: paciente.id, lines: lines.map((l) => ({ ...(l.service_id ? { service_id: l.service_id } : {}), ...(l.item_id ? { item_id: l.item_id } : {}), ...(l.paquete_id ? { paquete_id: l.paquete_id, description: l.name } : {}), quantity: l.qty, price_unit: l.price, ...(impuestos.activo ? { tax_rate: l.tax_rate ?? 0 } : {}), // Asignación a nivel venta: el doctor seleccionado aplica a todas las líneas ...(doctorId ? { prescribed_by_id: parseInt(doctorId, 10) } : {}), })), discount: discountNum, payment_method: metodo, amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined, pay_with_points: conPuntos, currency: moneda, }); if (res.status === 'success') { setResultado(res); setCobroOpen(false); toast.success(`Venta ${res.sale.name} cobrada`); if (res.stock_warnings && res.stock_warnings.length > 0) { res.stock_warnings.forEach((w) => toast.error(`Stock: ${w}`)); } } } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'Error al cobrar'); console.error(err); } finally { setCobrando(false); } }; // Selector "Doctor que atendió": se comparte entre el ticket y el modal de cobro const doctorBlock = ( setAltaForm({ ...altaForm, name: e.target.value })} /> setAltaForm({ ...altaForm, phone: e.target.value })} />
) : (
setPacienteQuery(e.target.value)} className="pl-10" /> {pacienteResults.length > 0 && (
{pacienteResults.map((p) => ( ))}
)} {pacienteQuery.trim().length >= 2 && pacienteResults.length === 0 && ( )}
); const linesBlock = lines.length === 0 ? (

Toca un servicio para agregarlo al ticket.

) : (
{lines.map((l) => (

{l.item_id && } {l.name} {l.item_id && l.stock !== undefined && ( {l.stock} {l.unit || 'pzas'} en existencia )}

{impuestos.activo && ( {impuestos.nombre} {puedeImpuestos ? ( ) : ( {l.tax_rate ?? 0}% )} {impuestoLinea(l) > 0 && ( {fmtMoney(impuestoLinea(l))} )} )}
{l.qty}
updateLine(l.key, { price: parseFloat(e.target.value) || 0 })} className="w-[70px] shrink-0 border border-theme-border-strong rounded-lg px-1.5 py-1 text-xs text-right" title="Precio unitario" /> {fmtMoney(l.qty * l.price)}
))}
); const totalsBlock = ( <>
Subtotal {fmtMoney(subtotal)} {toUsd(subtotal) !== null ? `≈ ${fmtUsd(toUsd(subtotal)!)}` : '—'}
{impuestos.activo && (
{impuestos.nombre}{impuestos.precios_incluyen_impuesto ? ' (incluido)' : ''} {fmtMoney(impuesto)} {toUsd(impuesto) !== null ? `≈ ${fmtUsd(toUsd(impuesto)!)}` : '—'}
)}
Descuento {discountNum > 0 && toUsd(discountNum) !== null && ( ≈ {fmtUsd(toUsd(discountNum)!)} )} setDiscount(e.target.value)} placeholder="0" disabled={!can('ventas.descuentos')} title={can('ventas.descuentos') ? undefined : 'Sin permiso para aplicar descuentos'} className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right disabled:opacity-50 disabled:cursor-not-allowed" />
Total {fmtMoney(total)} {toUsd(total) !== null ? `≈ ${fmtUsd(toUsd(total)!)}` : '—'}
{doctorBlock}

); return ( {!cajaAbierta ? (
Sin corte de caja abierto hoy Ir a Cortes de caja
) : (
Caja abierta Cerrar caja
)}
{/* Catálogo */}
setSearch(e.target.value)} className="pl-10" />
{/* Toggle Servicios / Paquetes / Artículos */}
{catMode === 'servicios' && (
{categorias.map((c) => ( ))}
)} {catMode !== 'paquetes' && (
)} {catalogLoading ? ( ) : catMode === 'paquetes' ? ( paquetesVisibles.length === 0 ? ( } /> ) : (
{paquetesVisibles.map((p) => { const ahorro = p.session_price && p.price_per_session ? Math.round((1 - p.price_per_session / p.session_price) * 100) : 0; return ( ); })}
) ) : vista === 'lista' ? (
{catMode === 'servicios' && } {catMode === 'servicios' ? ( serviciosLista.length === 0 ? ( ) : serviciosLista.map((sv) => ( )) ) : ( itemsLista.length === 0 ? ( ) : itemsLista.map((it) => ( )) )}
alternarOrdenLista('nombre')} title="Ordenar por nombre" > {catMode === 'servicios' ? 'Servicio' : 'Artículo'} {ordenLista.campo === 'nombre' && (ordenLista.dir === 1 ? ' ▲' : ' ▼')} CategoríaDuración alternarOrdenLista('precio')} title="Ordenar por precio" > Precio {ordenLista.campo === 'precio' && (ordenLista.dir === 1 ? ' ▲' : ' ▼')}
Sin servicios con estos filtros.

{sv.name}

{sv.code &&

{sv.code}

}
{sv.category || '—'} {sv.duration_min} min {fmtMoney(sv.price)}
Sin artículos con esta búsqueda.

{it.name}

{it.qty} {it.unit || 'pzas'} en existencia

{it.category || '—'} {(it.price ?? 0) > 0 ? fmtMoney(it.price!) : sin precio}
) : catMode === 'articulos' ? ( visiblesItems.length === 0 ? ( } /> ) : (
{visiblesItems.map((i) => ( ))}
) ) : visibles.length === 0 ? ( } /> ) : (
{visibles.map((s) => ( ))}
)}
{/* Ticket desktop: panel sticky full-height. El foco en este panel habilita +/− para la cantidad de la última línea. */}
setTicketFocused(true)} onBlurCapture={() => setTicketFocused(false)} title="Atajos: + y − ajustan la cantidad de la última línea cuando el ticket tiene el foco" >

Ticket

{lines.length > 0 && ( )}
{pacienteBlock}
{linesBlock}
{totalsBlock}
{/* Botón flotante móvil */} {lines.length > 0 && ( )} {/* Ticket móvil */} setTicketMovil(false)} title="Ticket" maxWidth="md">
setTicketFocused(true)} onBlurCapture={() => setTicketFocused(false)} > {pacienteBlock}
{linesBlock}
{totalsBlock}
{/* Ayuda discreta de atajos de teclado */} setShortcutsOpen(false)} title="Atajos de teclado" maxWidth="sm" footer={} >
    {[ { k: 'F2', d: 'Ir al buscador de servicios/artículos' }, { k: 'F9', d: 'Cobrar (abre el modal de cobro si hay líneas)' }, { k: '+ / −', d: 'Cantidad de la última línea (con el ticket enfocado)' }, { k: 'Esc', d: 'Cerrar el modal abierto' }, ].map((s) => (
  • {s.k} {s.d}
  • ))}
{/* Abrir caja sin salir del POS */} setCajaModalOpen(false)} title="Abrir caja" maxWidth="sm" footer={ <> } >

Se abre el corte de caja de hoy sin perder el ticket. El cierre guiado (conteo por método) se hace en Cortes de caja.

setCajaFondo(e.target.value)} />
{/* Modal de cobro */} setCobroOpen(false)} title={`Cobrar ${fmtMoney(total)}`} maxWidth="md" footer={ <> } >
{doctorBlock}

Se asigna la venta completa a este doctor para sus comisiones.

setRecibido(e.target.value)} placeholder={moneda === 'USD' && tcRate ? (total / tcRate).toFixed(2) : String(total)} /> {moneda === 'USD' && recibido && tcRate && (

≈ {fmtMoney(recibidoMxn)} MXN

)} {recibido && (

Cambio: 0 ? 'text-theme-heading' : 'text-theme-muted'}`}> {fmtMoney(cambio)} {moneda === 'USD' && cambio > 0 && ( (≈ {fmtUsd(cambioUsd)}) )}

)} )} {puedePuntos && moneda === 'MXN' && ( )} {conPuntos && (

Se canjearán {Math.ceil(total)} pts (1 pt = $1). El paciente acumula puntos solo por lo pagado en dinero.

)}
{/* Ticket de éxito: se ofrece automáticamente tras el cobro (imprimible ~80mm) */} Nueva venta} ticket={resultado && { folio: resultado.sale.name, fecha: `${resultado.sale.date} ${new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })}`, paciente: resultado.sale.patient, lines: resultado.sale.lines.map((l) => ({ name: l.service || l.item || l.description, qty: l.quantity, price: l.price_unit, subtotal: l.subtotal, })), subtotal: resultado.sale.subtotal, discount: resultado.sale.discount, tax: resultado.sale.tax_total ?? resultado.impuesto ?? 0, tax_name: impuestos.nombre, total: resultado.sale.total, metodo: resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos', puntos_usados: resultado.puntos_usados, puntos_ganados: resultado.puntos_ganados, wallet_points: resultado.wallet_points, cambio: resultado.cambio, recibido: resultado.recibido, cambio_usd: resultado.cambio_usd, recibido_usd: resultado.recibido_usd, rate_used: resultado.rate_used, cajero: resultado.sale.user_login, doctor: resultado.sale.doctor, telefono_paciente: resultado.sale.patient_phone, notas: resultado.sale.notes, }} />
); }; export default Pos;