- 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.
528 lines
23 KiB
TypeScript
528 lines
23 KiB
TypeScript
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;
|