POS vista lista, modulo Tickets para gerencia con cancelacion auditada, y saldo a favor (recargas de anticipo desde expediente y agenda, canje en POS)

This commit is contained in:
2026-09-18 04:25:33 +00:00
parent cebf8d663d
commit d75411f0d9
19 changed files with 1278 additions and 30 deletions

View File

@@ -1,9 +1,11 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall } from 'lucide-react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet } from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
import AgendaPrint from '../components/AgendaPrint';
import RecargarSaldoModal from '../components/RecargarSaldoModal';
import ReciboPrint from '../components/ReciboPrint';
import {
Card,
Button,
@@ -20,7 +22,7 @@ import {
badgeForAppointmentState,
} from '../components/ui';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { odooApi, type Appointment, type Patient, type Service, type Doctor, type Bloqueo } from '../services/odoo';
import { odooApi, type Appointment, type Patient, type Service, type Doctor, type Bloqueo, type PatientWallet, type ReceiptData } from '../services/odoo';
import { useAuth } from '../lib/auth';
const stateOptions = [
@@ -790,8 +792,20 @@ const Agenda: FC = () => {
}
};
// Saldo a favor del paciente de la cita abierta (para verlo al llegar)
const [saldoPaciente, setSaldoPaciente] = useState<PatientWallet | null>(null);
const [recargaAptOpen, setRecargaAptOpen] = useState(false);
const [reciboApt, setReciboApt] = useState<ReceiptData | null>(null);
const openAptDetail = (apt: Appointment) => {
setDetailApt(apt);
setSaldoPaciente(null);
setRecargaAptOpen(false);
if (apt.patient_id) {
odooApi.getPatientWallet(apt.patient_id)
.then((w) => { if (w.status === 'success') setSaldoPaciente(w); })
.catch(() => setSaldoPaciente(null));
}
setEditMode(false);
setEditForm({
date: apt.date,
@@ -2503,6 +2517,24 @@ const Agenda: FC = () => {
<p className="text-xs text-theme-muted mb-1">Precio</p>
<p className="text-sm font-medium text-theme-heading">${detailApt.price}</p>
</div>
<div className="p-3 rounded-xl border border-emerald-200 bg-emerald-50/60 col-span-2 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1">
<Wallet size={11} /> Saldo a favor del paciente
</p>
<p className="text-sm font-semibold text-theme-heading">
${(saldoPaciente?.equivalent_mxn ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{saldoPaciente?.equivalent_usd != null && (
<span className="ml-1.5 text-xs font-normal text-theme-muted">
≈ {saldoPaciente.equivalent_usd.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} USD
</span>
)}
</p>
</div>
<Button size="sm" variant="outline" onClick={() => setRecargaAptOpen(true)}>
<Wallet size={14} className="mr-1.5" /> Recargar saldo
</Button>
</div>
</div>
{detailApt.notes && (
<div className="p-3 bg-theme-bg rounded-xl">
@@ -2630,6 +2662,26 @@ const Agenda: FC = () => {
)}
</Modal>
{/* Recarga de saldo a favor del paciente de la cita: se monta después del
modal de detalle para quedar por encima (mismo z-index). */}
<RecargarSaldoModal
isOpen={recargaAptOpen}
onClose={() => setRecargaAptOpen(false)}
patientId={detailApt?.patient_id ?? null}
patientName={detailApt?.patient}
citaId={detailApt?.id ?? null}
saldoActual={saldoPaciente?.equivalent_mxn ?? 0}
onDone={(receipt) => {
setReciboApt(receipt);
if (detailApt?.patient_id) {
odooApi.getPatientWallet(detailApt.patient_id)
.then((w) => { if (w.status === 'success') setSaldoPaciente(w); })
.catch(() => {});
}
}}
/>
<ReciboPrint receipt={reciboApt} onClose={() => setReciboApt(null)} />
{/* Modal de conflicto de agenda: mensaje humano + ver la cita que choca */}
<Modal
isOpen={!!conflict}

View File

@@ -3,11 +3,13 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import {
Search, Plus, Activity, ShoppingBag, User, Edit2, Phone, Mail, Calendar, Droplet, Heart,
MapPin, Briefcase, Users, Baby, Stethoscope, FileText, AlertCircle, CheckCircle2, XCircle,
Download, ChevronRight, AlertTriangle, ClipboardList, Camera,
Download, ChevronRight, AlertTriangle, ClipboardList, Camera, Wallet,
} from 'lucide-react';
import Layout from '../components/Layout';
import PatientAdjuntos from '../components/PatientAdjuntos';
import EstadoCuentaPrint from '../components/EstadoCuentaPrint';
import RecargarSaldoModal from '../components/RecargarSaldoModal';
import ReciboPrint from '../components/ReciboPrint';
import { usePatientDrawer } from '../components/PatientDrawer';
import {
Card,
@@ -27,9 +29,12 @@ import {
} from '../components/ui';
import type { BadgeVariant } from '../components/ui/Badge';
import { useSearchParams } from 'react-router-dom';
import { odooApi, type Patient, type Appointment, type Sale, type Visita, type Doctor, type EstadoCuenta } from '../services/odoo';
import { odooApi, type Patient, type Appointment, type Sale, type Visita, type Doctor, type EstadoCuenta, type PatientWallet, type ReceiptData } from '../services/odoo';
import { exportToExcel } from '../lib/exporter';
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
const genderOptions = [
{ value: '', label: 'No especificado' },
{ value: 'male', label: 'Masculino' },
@@ -128,6 +133,10 @@ const Pacientes: FC = () => {
const [selected, setSelected] = useState<Patient | null>(null);
const [estadoCuentaOpen, setEstadoCuentaOpen] = useState(false);
const [history, setHistory] = useState<{ appointments: Appointment[]; sales: Sale[]; visitas: Visita[]; estado_cuenta?: EstadoCuenta } | null>(null);
// Saldo a favor (monedero, 1 pt = $1 MXN) del paciente abierto
const [wallet, setWallet] = useState<PatientWallet | null>(null);
const [recargaOpen, setRecargaOpen] = useState(false);
const [reciboDeposito, setReciboDeposito] = useState<ReceiptData | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
const [tab, setTab] = useState<'info' | 'clinical' | 'timeline' | 'docs'>('info');
const [entryDetail, setEntryDetail] = useState<
@@ -239,9 +248,13 @@ const Pacientes: FC = () => {
setSelected(patient);
setTab('info');
setHistoryLoading(true);
setWallet(null);
try {
const res = await odooApi.getPatientHistory(patient.id);
if (res.status === 'success') setHistory({ appointments: res.appointments, sales: res.sales, visitas: res.visitas || [], estado_cuenta: res.estado_cuenta });
odooApi.getPatientWallet(patient.id)
.then((w) => { if (w.status === 'success') setWallet(w); })
.catch(() => setWallet(null));
} catch (err) {
toast.error('Error al cargar historial');
console.error(err);
@@ -964,6 +977,44 @@ const Pacientes: FC = () => {
</div>
</div>
)}
{/* Saldo a favor: dinero a cuenta del paciente */}
<div className="p-3 rounded-xl border border-emerald-200 bg-emerald-50/60">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-1 flex items-center gap-1">
<Wallet size={12} /> Saldo a favor
</p>
<p className="text-2xl font-heading font-semibold text-theme-heading">
{fmtMoney(wallet?.equivalent_mxn ?? 0)}
</p>
<p className="text-xs text-theme-muted">
{wallet?.equivalent_usd != null
? `≈ ${wallet.equivalent_usd.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} USD`
: 'Sin tipo de cambio'}
{(wallet?.depositado ?? 0) > 0 ? ` · recargado ${fmtMoney(wallet?.depositado ?? 0)}` : ''}
</p>
</div>
<Button size="sm" variant="outline" onClick={() => setRecargaOpen(true)}>
<Plus size={14} className="mr-1.5" /> Recargar saldo
</Button>
</div>
{(wallet?.movimientos || []).length > 0 && (
<div className="mt-3 pt-3 border-t border-emerald-200 max-h-52 overflow-y-auto space-y-1.5">
{wallet!.movimientos.slice(0, 25).map((m) => (
<div key={m.id} className="flex items-start justify-between gap-2 text-[11px]">
<span className="text-theme-muted min-w-0">
<span className="block text-theme-heading">{m.narration}</span>
<span className="block">{m.date}{m.operator ? ` · ${m.operator}` : ''}</span>
</span>
<span className={`shrink-0 font-semibold ${m.points > 0 ? 'text-emerald-700' : 'text-rose-600'}`}>
{m.points > 0 ? '+' : ''}{m.points}
</span>
</div>
))}
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<InfoRow label="Folio" value={selected?.patient_id} icon={FileText} />
<InfoRow label="Teléfono" value={selected?.phone} icon={Phone} />
@@ -1263,6 +1314,23 @@ const Pacientes: FC = () => {
onClose={() => setEstadoCuentaOpen(false)}
paciente={selected}
/>
<RecargarSaldoModal
isOpen={recargaOpen}
onClose={() => setRecargaOpen(false)}
patientId={selected?.id ?? null}
patientName={selected?.name}
saldoActual={wallet?.equivalent_mxn ?? 0}
onDone={(receipt) => {
setReciboDeposito(receipt);
if (selected?.id) {
odooApi.getPatientWallet(selected.id)
.then((w) => { if (w.status === 'success') setWallet(w); })
.catch(() => {});
}
}}
/>
<ReciboPrint receipt={reciboDeposito} onClose={() => setReciboDeposito(null)} />
</Layout>
);
};

View File

@@ -3,7 +3,7 @@ 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,
RotateCcw, Keyboard, Lock, TrendingUp, LayoutGrid, List,
} from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
@@ -58,6 +58,28 @@ const Pos: FC = () => {
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<InventoryItem[]>([]);
// Ticket
@@ -247,6 +269,29 @@ const Pos: FC = () => {
// 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]);
// ---- Ticket ----
const addLine = (service: Service) => {
setLines((prev) => {
@@ -871,8 +916,103 @@ const Pos: FC = () => {
</div>
)}
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden mb-3 ml-2">
<button
type="button"
onClick={() => cambiarVista('grid')}
className={`px-3 py-1.5 text-xs font-medium transition flex items-center gap-1 ${
vista === 'grid' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
}`}
title="Vista de cuadrícula"
>
<LayoutGrid size={12} /> Cuadrícula
</button>
<button
type="button"
onClick={() => cambiarVista('lista')}
className={`px-3 py-1.5 text-xs font-medium transition flex items-center gap-1 ${
vista === 'lista' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
}`}
title="Vista de lista (catálogos largos)"
>
<List size={12} /> Lista
</button>
</div>
{catalogLoading ? (
<Skeleton count={8} className="h-16 w-full" />
) : vista === 'lista' ? (
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto border border-theme-border rounded-xl">
<table className="w-full text-sm">
<thead className="bg-theme-bg sticky top-0 z-10 text-theme-muted text-[11px] uppercase">
<tr>
<th
className="text-left px-3 py-2 cursor-pointer select-none"
onClick={() => alternarOrdenLista('nombre')}
title="Ordenar por nombre"
>
{catMode === 'servicios' ? 'Servicio' : 'Artículo'}
{ordenLista.campo === 'nombre' && (ordenLista.dir === 1 ? ' ▲' : ' ▼')}
</th>
<th className="text-left px-3 py-2 hidden sm:table-cell">Categoría</th>
{catMode === 'servicios' && <th className="text-left px-3 py-2 hidden md:table-cell">Duración</th>}
<th
className="text-right px-3 py-2 cursor-pointer select-none"
onClick={() => alternarOrdenLista('precio')}
title="Ordenar por precio"
>
Precio
{ordenLista.campo === 'precio' && (ordenLista.dir === 1 ? ' ▲' : ' ▼')}
</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{catMode === 'servicios' ? (
serviciosLista.length === 0 ? (
<tr><td colSpan={5} className="px-3 py-6 text-center text-theme-muted">Sin servicios con estos filtros.</td></tr>
) : serviciosLista.map((sv) => (
<tr key={sv.id} className="hover:bg-theme-bg">
<td className="px-3 py-2">
<p className="font-medium text-theme-heading">{sv.name}</p>
{sv.code && <p className="text-[11px] text-theme-muted">{sv.code}</p>}
</td>
<td className="px-3 py-2 text-theme-muted capitalize hidden sm:table-cell">{sv.category || '—'}</td>
<td className="px-3 py-2 text-theme-muted hidden md:table-cell">{sv.duration_min} min</td>
<td className="px-3 py-2 text-right font-semibold text-theme-heading">{fmtMoney(sv.price)}</td>
<td className="px-3 py-2 text-right">
<Button size="sm" onClick={() => addLine(sv)} title="Agregar al ticket">
<Plus size={13} className="mr-1" /> Agregar
</Button>
</td>
</tr>
))
) : (
itemsLista.length === 0 ? (
<tr><td colSpan={4} className="px-3 py-6 text-center text-theme-muted">Sin artículos con esta búsqueda.</td></tr>
) : itemsLista.map((it) => (
<tr key={it.id} className="hover:bg-theme-bg">
<td className="px-3 py-2">
<p className="font-medium text-theme-heading">{it.name}</p>
<p className={`text-[11px] ${it.qty <= 0 ? 'text-rose-600' : 'text-theme-muted'}`}>
{it.qty} {it.unit || 'pzas'} en existencia
</p>
</td>
<td className="px-3 py-2 text-theme-muted hidden sm:table-cell">{it.category || '—'}</td>
<td className="px-3 py-2 text-right font-semibold text-theme-heading">
{(it.price ?? 0) > 0 ? fmtMoney(it.price!) : <span className="text-xs font-normal text-theme-muted">sin precio</span>}
</td>
<td className="px-3 py-2 text-right">
<Button size="sm" onClick={() => addItemLine(it)} title="Agregar al ticket">
<Plus size={13} className="mr-1" /> Agregar
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
) : catMode === 'articulos' ? (
visiblesItems.length === 0 ? (
<EmptyState title="Sin artículos" subtitle="No hay artículos de inventario con esta búsqueda." icon={<Package size={28} />} />
@@ -1115,7 +1255,7 @@ const Pos: FC = () => {
/>
<span className="text-sm text-theme-heading flex items-center gap-1.5">
<Wallet size={15} className="text-theme-muted" />
Pagar con puntos ({puntosPaciente} pts disponibles)
Pagar con saldo a favor ({puntosPaciente} pts = ${puntosPaciente})
</span>
</label>
)}

View File

@@ -0,0 +1,467 @@
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 } 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 { odooApi, type Sale } from '../services/odoo';
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 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 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' && (
<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' && (
<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>
<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,
}}
/>
</Layout>
);
};
export default Tickets;