Files
SKEEN-Proyecto/frontend/src/pages/Tickets.tsx
Consultoría Alcaraz Salazar 72f12946c3 Punto 11: editor de diseno de tickets en Configuracion > Tickets
- Nuevo modelo skeen.ticket.config (AbstractModel) con layout, datos de clinica
  y logo en ir.config_parameter; _mezclar() hace que layouts viejos hereden las
  secciones nuevas; _validar() rechaza valores imposibles y textos largos.
- Endpoints GET/PUT /ticket_config, PUT /ticket_config/clinica,
  POST /ticket_config/reset (reset solo admin) con auditoria en skeen_config_audit.
- Los datos de la clinica pasan de localStorage (cada caja imprimia distinto)
  a configuracion compartida en el servidor.
- TicketDoc.tsx renderiza segun el diseno: ancho 58/80mm, fuente, tamano de letra,
  separador, mayusculas, orden de bloques y 29 secciones activables.
- TicketDesigner.tsx: editor completo con vista previa en vivo, reorden de bloques,
  textos personalizables y carga de logo.
- TicketPrint.tsx ahora es un wrapper de TicketDoc; ReciboPrint, EstadoCuentaPrint,
  RecetaPrint, CortePrint y ConteoPrint usan useClinicaPrint().
- _sale_to_dict expone telefono del paciente, medico que atendio y datos de paquete;
  pos_checkout devuelve recibido/cambio en MXN y USD para imprimirlos.
2026-09-24 18:19:51 +00:00

528 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
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<DatePreset>(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<Sale[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(true);
const [detalle, setDetalle] = useState<Sale | null>(null);
const [cancelando, setCancelando] = useState<Sale | null>(null);
const [motivo, setMotivo] = useState('');
const [enviando, setEnviando] = useState(false);
const [reimprimir, setReimprimir] = useState<Sale | null>(null);
const [editando, setEditando] = useState<Sale | null>(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 (
<Layout title="Tickets" subtitle="Cobros del día y cancelaciones">
<PageHeader title="Tickets" subtitle="Busca un cobro, revísalo y cancélalo con nota de crédito">
<Button variant="outline" onClick={load} loading={loading}>
<RefreshCw size={15} className="mr-2" /> Actualizar
</Button>
</PageHeader>
<Card className="mb-4">
<Card.Body className="space-y-3">
<div className="flex flex-wrap gap-1.5">
{DATE_PRESETS.map((p) => (
<button
key={p.value}
type="button"
onClick={() => aplicarPreset(p.value)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition ${
preset === p.value ? 'bg-theme-accent text-theme-inverse border-theme-accent' : 'bg-theme-bg text-theme-muted border-theme-border'
}`}
>
{p.label}
</button>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
<Input label="Desde" type="date" value={desde} onChange={(e) => { setDesde(e.target.value); setPreset('custom'); }} />
<Input label="Hasta" type="date" value={hasta} onChange={(e) => { setHasta(e.target.value); setPreset('custom'); }} />
<Select label="Estado" options={ESTADOS} value={estado} onChange={(e) => setEstado(e.target.value)} />
<Select label="Método de pago" options={METODOS_FILTRO} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
<Input label="Cobró (usuario)" placeholder="ej. recepcion" value={usuario} onChange={(e) => setUsuario(e.target.value)} />
</div>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar por folio (VENTA-…) o paciente..."
value={busqueda}
onChange={(e) => setBusqueda(e.target.value)}
className="pl-10"
/>
</div>
</Card.Body>
</Card>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Tickets en el filtro</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{total}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Importe (página)</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{money(totalPeriodo)}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Cancelados (página)</p>
<p className={`text-xl font-heading font-semibold ${canceladosPeriodo > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>
{canceladosPeriodo}
</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Página</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{page} / {totalPages || 1}</p>
</Card>
</div>
<Card>
<Card.Body>
{loading ? (
<Skeleton count={8} className="h-12 w-full" />
) : sales.length === 0 ? (
<EmptyState
title="Sin tickets"
subtitle="No hay cobros con estos filtros. Prueba otro rango de fechas."
icon={<Ticket size={28} />}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-3 py-2">Folio</th>
<th className="text-left px-3 py-2">Fecha</th>
<th className="text-left px-3 py-2">Paciente</th>
<th className="text-right px-3 py-2">Total</th>
<th className="text-left px-3 py-2 hidden md:table-cell">Método</th>
<th className="text-left px-3 py-2 hidden lg:table-cell">Cobró</th>
<th className="text-left px-3 py-2">Estado</th>
<th className="text-right px-3 py-2">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{sales.map((s) => (
<tr key={s.id} className="hover:bg-theme-bg">
<td className="px-3 py-2 font-mono text-[12px] text-theme-heading">{s.name}</td>
<td className="px-3 py-2 text-theme-muted whitespace-nowrap">{(s.date || '').slice(0, 10)}</td>
<td className="px-3 py-2">
<Link to={`/pacientes?q=${encodeURIComponent(s.patient || '')}`} className="text-theme-heading hover:underline">
{s.patient}
</Link>
</td>
<td className="px-3 py-2 text-right font-semibold text-theme-heading">{money(s.total)}</td>
<td className="px-3 py-2 text-theme-muted hidden md:table-cell">
{METODO_LABELS[s.payment_method || ''] || s.payment_method || '—'}
</td>
<td className="px-3 py-2 text-theme-muted hidden lg:table-cell">{s.user_login || '—'}</td>
<td className="px-3 py-2">
<Badge variant={s.state === 'cancelled' ? 'danger' : s.state === 'paid' ? 'success' : 'pending'}>
{s.state === 'cancelled' ? 'Cancelado' : s.state === 'paid' ? 'Pagado' : s.state === 'partial' ? 'Parcial' : 'Confirmado'}
</Badge>
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1">
<Button size="sm" variant="ghost" onClick={() => setDetalle(s)} title="Ver detalle del ticket">
<Receipt size={14} />
</Button>
{s.state !== 'cancelled' && puedeCorregir && (
<Button
size="sm"
variant="ghost"
title="Corregir ticket"
onClick={async () => { await cargarCatalogos(); setEditando(s); }}
>
<Pencil size={14} />
</Button>
)}
{s.state !== 'cancelled' && (
<Button size="sm" variant="ghost" onClick={() => abrirCancelar(s)} title="Cancelar ticket (nota de crédito)">
<XCircle size={14} className="text-rose-600" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-end gap-2 mt-4 pt-3 border-t border-theme-border">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
Anterior
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Siguiente
</Button>
</div>
)}
</Card.Body>
</Card>
{/* Detalle del ticket */}
<Modal
isOpen={!!detalle}
onClose={() => setDetalle(null)}
title={detalle ? `Ticket ${detalle.name}` : 'Ticket'}
maxWidth="lg"
footer={
<>
{detalle && detalle.state === 'cancelled' && (
<Button variant="outline" onClick={() => setReimprimir(detalle)}>
<Printer size={14} className="mr-1.5" /> Imprimir
</Button>
)}
<Button variant="outline" onClick={() => setDetalle(null)}>Cerrar</Button>
{detalle && detalle.state !== 'cancelled' && puedeCorregir && (
<Button variant="secondary" onClick={async () => { const d = detalle; setDetalle(null); await cargarCatalogos(); setEditando(d); }}>
<Pencil size={14} className="mr-1.5" /> Corregir
</Button>
)}
{detalle && detalle.state !== 'cancelled' && (
<Button variant="danger" onClick={() => { const d = detalle; setDetalle(null); abrirCancelar(d); }}>
<XCircle size={14} className="mr-1.5" /> Cancelar ticket
</Button>
)}
</>
}
>
{detalle && (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted">Paciente</p>
<p className="text-sm font-medium text-theme-heading">{detalle.patient}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted">Total</p>
<p className="text-sm font-semibold text-theme-heading">{money(detalle.total)}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted">Método</p>
<p className="text-sm text-theme-heading">{METODO_LABELS[detalle.payment_method || ''] || '—'}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted">Cobró</p>
<p className="text-sm text-theme-heading inline-flex items-center gap-1">
<User size={12} /> {detalle.user_login || '—'}
</p>
</div>
</div>
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Conceptos</p>
<table className="w-full text-sm">
<tbody className="divide-y divide-theme-border">
{detalle.lines.map((l) => (
<tr key={l.id}>
<td className="py-1.5 text-theme-heading">{l.description || l.service || l.item || '—'}</td>
<td className="py-1.5 text-right text-theme-muted">{l.quantity} × {money(l.price_unit)}</td>
<td className="py-1.5 text-right font-medium text-theme-heading">{money(l.subtotal)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Pagos registrados</p>
{(detalle.pagos || []).length === 0 ? (
<p className="text-sm text-theme-muted">
Sin pagos ligados a este ticket (los cobros anteriores al vínculo de pagos no se pueden asociar).
</p>
) : (
<table className="w-full text-sm">
<tbody className="divide-y divide-theme-border">
{(detalle.pagos || []).map((p) => (
<tr key={p.id}>
<td className="py-1.5 font-mono text-[12px] text-theme-heading">{p.name}</td>
<td className="py-1.5 text-theme-muted">{METODO_LABELS[p.payment_method] || p.payment_method}</td>
<td className="py-1.5 text-theme-muted">{p.create_date || '—'}</td>
<td className="py-1.5 text-right font-medium text-theme-heading">{money(p.amount)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{detalle.state === 'cancelled' && (
<div className="rounded-lg bg-rose-50 border border-rose-200 px-3 py-2 text-xs text-rose-800 space-y-1">
<p className="font-medium">Ticket cancelado</p>
<p>Motivo: {detalle.cancel_reason || '—'}</p>
<p>Canceló: {detalle.cancelled_by || '—'} · {detalle.cancelled_at || '—'}</p>
<p>
Puntos revertidos: {detalle.cancel_points_reversed || 0} ·
Saldo devuelto al paciente: {detalle.cancel_points_restored || 0}
</p>
</div>
)}
</div>
)}
</Modal>
{/* Diálogo de cancelación (nota de crédito) */}
<Modal
isOpen={!!cancelando}
onClose={() => setCancelando(null)}
title={`Cancelar ticket ${cancelando?.name || ''}`}
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setCancelando(null)}>Volver</Button>
<Button variant="danger" onClick={submitCancelar} loading={enviando}>Cancelar ticket</Button>
</>
}
>
{cancelando && (
<div className="space-y-4">
<div className="flex items-start gap-2">
<AlertTriangle size={16} className="text-amber-600 shrink-0 mt-0.5" />
<p className="text-sm text-theme-muted">
Paciente: <span className="font-medium text-theme-heading">{cancelando.patient}</span>
{' · '}Total: <span className="font-medium text-theme-heading">{money(cancelando.total)}</span>
{cancelando.user_login ? ` · Cobró: ${cancelando.user_login}` : ''}
</p>
</div>
<div className="rounded-lg bg-rose-50 border border-rose-200 px-3 py-2 text-xs text-rose-800 space-y-1.5">
<p className="font-medium">Se emitirá la nota de crédito:</p>
<p>La venta queda cancelada y deja de contar en ingresos, comisiones y reportes.</p>
<p>Se revierten los puntos otorgados y se devuelve al saldo a favor lo que el paciente canjeó.</p>
<p>Los artículos vendidos vuelven al inventario.</p>
</div>
<div className="rounded-lg bg-amber-50 border border-amber-200 px-3 py-2 text-xs text-amber-900 space-y-1.5">
<p className="font-medium">Importante: el pago NO se revierte automáticamente</p>
<p>
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'}).
</p>
<p>Si corresponde devolver el dinero, hay que ajustar la caja por separado.</p>
</div>
<TextArea
label="Motivo de cancelación *"
placeholder="Ej. recepción cobró el servicio equivocado, cobro duplicado..."
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
/>
</div>
)}
</Modal>
{editando && (
<TicketEditor
ticket={editando}
catalogo={{ servicios: catalogo.servicios, items: catalogo.items }}
doctores={doctores}
onClose={() => setEditando(null)}
onGuardado={(sale) => { setDetalle(sale); load(); }}
/>
)}
<TicketPrint
isOpen={!!reimprimir}
onClose={() => setReimprimir(null)}
ticket={reimprimir && {
folio: reimprimir.name,
fecha: reimprimir.date,
paciente: reimprimir.patient,
lines: reimprimir.lines.map((l) => ({
name: l.service || l.item || l.description,
qty: l.quantity,
price: l.price_unit,
subtotal: l.subtotal,
})),
subtotal: reimprimir.subtotal,
discount: reimprimir.discount,
total: reimprimir.total,
amount_paid: reimprimir.amount_paid,
amount_due: reimprimir.amount_due,
metodo: METODO_LABELS[reimprimir.payment_method || ''] || null,
cajero: reimprimir.user_login,
doctor: reimprimir.doctor,
telefono_paciente: reimprimir.patient_phone,
notas: reimprimir.notes,
}}
/>
</Layout>
);
};
export default Tickets;