UX Pacientes: drawer de ficha clicable global, cumpleanos en dashboard/agenda, badges VIP/alergias/adeudo en ficha y POS

This commit is contained in:
2026-09-01 22:26:09 +00:00
parent 7261da3ab1
commit 2710d9d92d
9 changed files with 492 additions and 20 deletions

View File

@@ -0,0 +1,253 @@
import type { FC, ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { X, Star, AlertTriangle, Phone, MessageCircle, Mail, Wallet, Stethoscope, ArrowRight } from 'lucide-react';
import { odooApi, type Patient, type Visita, type EstadoCuenta } from '../services/odoo';
import { Badge, Button, Skeleton } from './ui';
// Drawer lateral global con el resumen del expediente del paciente.
// Se abre por patient_id desde cualquier pantalla (Agenda, POS, Ventas, Pagos)
// sin perder la vista actual. Reutiliza los endpoints del expediente
// (getPatient + getPatientHistory); no duplica lógica de negocio.
interface PatientDrawerContextValue {
openPatient: (patientId: number) => void;
}
const PatientDrawerContext = createContext<PatientDrawerContextValue>({ openPatient: () => {} });
export const usePatientDrawer = () => useContext(PatientDrawerContext);
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
const initials = (name: string) =>
name.split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]?.toUpperCase()).join('');
const waDigits = (phone: string) => (phone || '').replace(/\D/g, '');
const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ patientId, onClose }) => {
const navigate = useNavigate();
const [patient, setPatient] = useState<Patient | null>(null);
const [visitas, setVisitas] = useState<Visita[]>([]);
const [cuenta, setCuenta] = useState<EstadoCuenta | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
setLoading(true);
setPatient(null);
setCuenta(null);
setVisitas([]);
Promise.all([odooApi.getPatient(patientId), odooApi.getPatientHistory(patientId)])
.then(([pRes, hRes]) => {
if (!active) return;
if (pRes.status === 'success') setPatient(pRes.patient);
if (hRes.status === 'success') {
setVisitas((hRes.visitas || []).slice(0, 5));
setCuenta(hRes.estado_cuenta);
}
})
.catch(() => {})
.finally(() => active && setLoading(false));
return () => {
active = false;
};
}, [patientId]);
// Cerrar con Escape
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose]);
const adeudo = cuenta?.adeudo ?? 0;
const whatsapp = patient?.whatsapp || patient?.phone || '';
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[60] bg-black/30" onClick={onClose} />
{/* Panel deslizante */}
<aside className="fixed inset-y-0 right-0 z-[61] w-full max-w-md bg-theme-surface shadow-soft border-l border-theme-border flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-theme-border">
<h2 className="font-heading text-lg text-theme-heading">Ficha del paciente</h2>
<Button variant="ghost" size="sm" onClick={onClose} title="Cerrar">
<X size={18} />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{loading ? (
<div className="space-y-3 pt-2">
<Skeleton count={1} className="h-20 w-full" />
<Skeleton count={5} className="h-10 w-full" />
</div>
) : !patient ? (
<p className="text-sm text-theme-muted py-8 text-center">No se pudo cargar el paciente.</p>
) : (
<>
{/* Encabezado: foto, nombre, badges de alerta */}
<div className="flex items-start gap-3">
{patient.has_photo && patient.photo_url ? (
<img
src={odooApi.patientPhotoUrl(patient.photo_url)}
alt={patient.name}
className="h-14 w-14 rounded-full object-cover border border-theme-border shrink-0"
/>
) : (
<span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-sm font-bold text-theme-heading">
{initials(patient.name)}
</span>
)}
<div className="min-w-0">
<p className="font-semibold text-theme-heading leading-tight">{patient.name}</p>
<p className="text-xs text-theme-muted mt-0.5">
{patient.patient_id}
{patient.age ? ` · ${patient.age} años` : ''}
{patient.primary_doctor ? ` · ${patient.primary_doctor}` : ''}
</p>
{/* Alertas: VIP / Alergias / Saldo pendiente */}
<div className="flex flex-wrap gap-1.5 mt-2">
{patient.is_vip && (
<Badge variant="primary">
<Star size={11} className="mr-1" /> VIP
</Badge>
)}
{patient.allergies && (
<span
className="inline-flex items-center gap-1 rounded-full border border-rose-300 bg-rose-50 px-2 py-0.5 text-[11px] font-medium text-rose-700"
title={`Alergias: ${patient.allergies}`}
>
<AlertTriangle size={11} /> Alergias
</span>
)}
{adeudo > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-700">
Adeudo {fmtMoney(adeudo)}
</span>
)}
</div>
</div>
</div>
{patient.allergies && (
<div className="rounded-xl border border-rose-300 bg-rose-50 p-3">
<p className="text-xs font-semibold text-rose-700 uppercase mb-1">Alergias</p>
<p className="text-sm text-rose-700 whitespace-pre-wrap">{patient.allergies}</p>
</div>
)}
{/* Contacto */}
<div className="grid grid-cols-2 gap-2">
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1"><Phone size={11} /> Teléfono</p>
<p className="text-sm font-medium text-theme-heading break-all">{patient.phone || '—'}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1"><MessageCircle size={11} /> WhatsApp</p>
{whatsapp ? (
<a
href={`https://wa.me/${waDigits(whatsapp)}`}
target="_blank"
rel="noreferrer"
className="text-sm font-medium text-theme-heading underline underline-offset-2 break-all"
>
{whatsapp}
</a>
) : (
<p className="text-sm text-theme-muted">—</p>
)}
</div>
<div className="p-3 bg-theme-bg rounded-xl col-span-2">
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1"><Mail size={11} /> Correo</p>
<p className="text-sm font-medium text-theme-heading break-all">{patient.email || '—'}</p>
</div>
</div>
{/* Estado de cuenta */}
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Estado de cuenta</p>
<div className="grid grid-cols-2 gap-2">
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1"><Wallet size={11} /> Monedero</p>
<p className="text-sm font-semibold text-theme-heading">{cuenta?.puntos_monedero ?? 0} pts</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Saldo pendiente</p>
<p className={`text-sm font-semibold ${adeudo > 0 ? 'text-amber-700' : 'text-theme-heading'}`}>
{fmtMoney(adeudo)}
</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Total gastado</p>
<p className="text-sm font-semibold text-theme-heading">{fmtMoney(cuenta?.total_gastado ?? 0)}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Visitas</p>
<p className="text-sm font-semibold text-theme-heading">{cuenta?.visitas_total ?? 0}</p>
</div>
</div>
</div>
{/* Visitas recientes */}
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2 flex items-center gap-1">
<Stethoscope size={12} /> Visitas recientes
</p>
{visitas.length === 0 ? (
<p className="text-sm text-theme-muted">Sin visitas registradas.</p>
) : (
<ul className="space-y-1.5">
{visitas.map((v) => (
<li key={v.id} className="p-2.5 bg-theme-bg rounded-lg text-sm">
<p className="font-medium text-theme-heading">
{(v.date_start || '').slice(0, 10)} · {v.servicio || v.motivo || v.name}
</p>
<p className="text-xs text-theme-muted">
{v.doctor || 'Sin médico'}
{v.diagnostico ? ` · ${v.diagnostico.slice(0, 80)}` : ''}
</p>
</li>
))}
</ul>
)}
</div>
</>
)}
</div>
<div className="p-4 border-t border-theme-border">
<Button
variant="outline"
className="w-full"
onClick={() => {
onClose();
navigate(`/pacientes?q=${encodeURIComponent(patient?.name || '')}`);
}}
>
Ver expediente completo <ArrowRight size={14} className="ml-1.5" />
</Button>
</div>
</aside>
</>
);
};
export const PatientDrawerProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [patientId, setPatientId] = useState<number | null>(null);
const openPatient = useCallback((id: number) => setPatientId(id), []);
return (
<PatientDrawerContext.Provider value={{ openPatient }}>
{children}
{patientId !== null && (
<PatientDrawerPanel patientId={patientId} onClose={() => setPatientId(null)} />
)}
</PatientDrawerContext.Provider>
);
};
export default PatientDrawerProvider;