UX Pacientes: drawer de ficha clicable global, cumpleanos en dashboard/agenda, badges VIP/alergias/adeudo en ficha y POS
This commit is contained in:
253
frontend/src/components/PatientDrawer.tsx
Normal file
253
frontend/src/components/PatientDrawer.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user