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

@@ -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>
);
};