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

@@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-d
import { ToastProvider } from './components/ui';
import { AuthProvider, useAuth } from './lib/auth';
import { QuickLinksProvider } from './lib/quicklinks';
import { PatientDrawerProvider } from './components/PatientDrawer';
import Dashboard from './pages/Dashboard';
import Agenda from './pages/Agenda';
import Pacientes from './pages/Pacientes';
@@ -61,6 +62,7 @@ const App: FC = () => {
<QuickLinksProvider>
<Router>
<ToastProvider />
<PatientDrawerProvider>
<Routes>
<Route path="/login" element={<LoginRoute />} />
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
@@ -89,6 +91,7 @@ const App: FC = () => {
<Route path="/wacrm/leads" element={<RequireAuth href="/wacrm/leads"><WacrmLeads /></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</PatientDrawerProvider>
</Router>
</QuickLinksProvider>
</AuthProvider>

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;

View File

@@ -1,7 +1,8 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone } 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 } from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
import {
Card,
Button,
@@ -44,6 +45,7 @@ const QUARTER_TIME_OPTIONS = (() => {
const Agenda: FC = () => {
const [searchParams] = useSearchParams();
const { user } = useAuth();
const { openPatient } = usePatientDrawer();
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor)
const isMedico = user?.role === 'medico';
const medicoEmployeeId = isMedico && user?.employee_id ? user.employee_id : null;
@@ -202,6 +204,12 @@ const Agenda: FC = () => {
);
};
// Icono de cumpleaños: el paciente cumple años el día de la cita (viene del API)
const birthdayIcon = (apt: Appointment, size = 11) =>
apt.patient_birthday ? (
<Cake size={size} className="inline shrink-0 text-pink-500" aria-label="Cumpleaños del paciente" />
) : null;
const confirmTitle = (apt: Appointment): string => {
if (['arrived', 'in_progress', 'done'].includes(apt.state)) return '2da confirmación: llegó a la clínica';
if (apt.phone_confirmed) {
@@ -1260,6 +1268,7 @@ const Agenda: FC = () => {
>
<div className="font-semibold truncate">
<span title={confirmTitle(apt)} className="mr-1 align-middle">{confirmIndicator(apt)}</span>
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-0.5 align-middle">{birthdayIcon(apt)}</span>}
{apt.time} · {apt.patient}
</div>
<div className="truncate opacity-75">{apt.service}</div>
@@ -1366,6 +1375,7 @@ const Agenda: FC = () => {
>
<div className="font-semibold truncate">
<span title={confirmTitle(apt)} className="mr-0.5 align-middle">{confirmIndicator(apt, 10)}</span>
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-0.5 align-middle">{birthdayIcon(apt, 10)}</span>}
{apt.time} · {firstName(apt.patient)}
</div>
</div>
@@ -1461,6 +1471,7 @@ const Agenda: FC = () => {
className={`mb-0.5 rounded border-l-2 px-1 py-0.5 text-[10px] leading-tight shadow-card cursor-pointer transition-shadow hover:shadow-soft ${aptCategoryBar(apt)} ${gridStateClass[apt.state] || 'bg-theme-bg text-theme-heading'}`}
>
<span className="mr-0.5 align-middle">{confirmIndicator(apt, 10)}</span>
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-0.5 align-middle">{birthdayIcon(apt, 10)}</span>}
<span className="font-semibold">{apt.time}</span> {firstName(apt.patient)}
</div>
))
@@ -1496,7 +1507,17 @@ const Agenda: FC = () => {
<Clock size={14} className="inline mr-1 text-theme-muted" />
{apt.time}
</td>
<td className="p-3 text-sm font-medium text-theme-heading">{apt.patient}</td>
<td className="p-3 text-sm font-medium text-theme-heading">
<button
type="button"
onClick={() => openPatient(apt.patient_id)}
className="hover:underline hover:text-theme-accent text-left"
title="Abrir ficha del paciente"
>
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-1 align-middle">{birthdayIcon(apt, 12)}</span>}
{apt.patient}
</button>
</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">
<button type="button" onClick={() => openAptDetail(apt)} className="hover:underline hover:text-theme-heading text-left">
{apt.service}
@@ -1911,7 +1932,19 @@ const Agenda: FC = () => {
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Paciente</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.patient}</p>
<button
type="button"
onClick={() => openPatient(detailApt.patient_id)}
className="text-sm font-medium text-theme-heading underline underline-offset-2 hover:text-theme-accent text-left"
title="Abrir ficha del paciente"
>
{detailApt.patient}
</button>
{detailApt.patient_birthday && (
<span className="ml-1.5 inline-flex items-center gap-1 rounded-full bg-pink-50 border border-pink-300 px-2 py-0.5 text-[10px] font-medium text-pink-600 align-middle">
{birthdayIcon(detailApt, 10)} Cumpleaños hoy
</span>
)}
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Servicio</p>

View File

@@ -12,6 +12,8 @@ import {
Activity,
TrendingUp,
ArrowRight,
Cake,
MessageCircle,
} from 'lucide-react';
import {
AreaChart,
@@ -39,7 +41,8 @@ import {
toast,
badgeForAppointmentState,
} from '../components/ui';
import { odooApi, type DashboardStats, type Appointment, type Service, type ChartPoint } from '../services/odoo';
import { odooApi, type DashboardStats, type Appointment, type Service, type ChartPoint, type Birthday } from '../services/odoo';
import { usePatientDrawer } from '../components/PatientDrawer';
import { useTheme } from '../lib/theme';
import { useAuth } from '../lib/auth';
@@ -92,6 +95,10 @@ const Dashboard: FC = () => {
const [topServices, setTopServices] = useState<{ service: string; qty: number; revenue: number }[]>([]);
const [aptReport, setAptReport] = useState<{ total: number; by_state: Record<string, number> } | null>(null);
const [loadingExtras, setLoadingExtras] = useState(true);
const { openPatient } = usePatientDrawer();
// Cumpleaños del día (endpoint /reports/birthdays?period=today)
const [birthdays, setBirthdays] = useState<Birthday[]>([]);
const [loadingBirthdays, setLoadingBirthdays] = useState(true);
useEffect(() => {
const loadStats = async () => {
@@ -173,11 +180,24 @@ const Dashboard: FC = () => {
}
};
const loadBirthdays = async () => {
try {
setLoadingBirthdays(true);
const res = await odooApi.getBirthdays('today');
if (res.status === 'success') setBirthdays(res.birthdays || []);
} catch (err) {
console.error(err);
} finally {
setLoadingBirthdays(false);
}
};
loadStats();
loadChart();
loadAppointments();
loadServices();
loadExtras();
loadBirthdays();
}, []);
const statsConfig = useMemo(
@@ -320,6 +340,61 @@ const Dashboard: FC = () => {
))}
</div>
{/* Cumpleaños de hoy */}
<Card className="mb-6 sm:mb-8">
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
<Cake size={18} className="text-pink-500" /> Cumpleaños de hoy
</h3>
<p className="text-xs text-theme-muted">Pacientes que cumplen años hoy — contáctalos</p>
</div>
<Button variant="ghost" size="sm" onClick={() => navigate('/cumpleanos')}>
Ver todos
<ArrowRight size={14} className="ml-1" />
</Button>
</Card.Header>
<Card.Body>
{loadingBirthdays ? (
<Skeleton count={2} className="h-10 w-full" />
) : birthdays.length === 0 ? (
<p className="text-sm text-theme-muted">Ningún paciente cumple años hoy.</p>
) : (
<div className="flex flex-wrap gap-2">
{birthdays.map((b) => (
<div
key={b.id}
className="flex items-center gap-2.5 rounded-xl border border-theme-border px-3 py-2"
>
<button
type="button"
onClick={() => openPatient(b.id)}
className="text-sm font-medium text-theme-heading hover:underline hover:text-theme-accent text-left"
title="Abrir ficha del paciente"
>
{b.name}
</button>
<span className="text-xs text-theme-muted">
cumple {b.turning_age}{b.is_vip ? ' · VIP' : ''}
</span>
{(b.whatsapp || b.phone) && (
<a
href={`https://wa.me/${(b.whatsapp || b.phone).replace(/\D/g, '')}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-theme-heading underline underline-offset-2"
title={`WhatsApp ${b.whatsapp || b.phone}`}
>
<MessageCircle size={12} /> {b.whatsapp || b.phone}
</a>
)}
</div>
))}
</div>
)}
</Card.Body>
</Card>
{/* Chart + appointments */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-6 sm:mb-8">
<Card>

View File

@@ -2,6 +2,7 @@ import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Plus, CheckCircle, CreditCard } from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
import {
Card,
Button,
@@ -34,6 +35,7 @@ const estadoLabels: Record<string, string> = {
};
const Pagos: FC = () => {
const { openPatient } = usePatientDrawer();
const [payments, setPayments] = useState<Payment[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
@@ -173,7 +175,16 @@ const Pagos: FC = () => {
{visibles.map((p) => (
<tr key={p.id} className="hover:bg-theme-bg cursor-pointer" onClick={() => setDetalle(p)}>
<td className="p-3 text-sm text-theme-muted">{p.name}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{p.patient}</td>
<td className="p-3 text-sm font-medium text-theme-heading">
<button
type="button"
onClick={(e) => { e.stopPropagation(); openPatient(p.patient_id); }}
className="hover:underline hover:text-theme-accent text-left"
title="Abrir ficha del paciente"
>
{p.patient}
</button>
</td>
<td className="p-3 text-sm font-semibold text-theme-heading">${p.amount}</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{metodoLabels[p.payment_method] || p.payment_method}</td>
<td className="p-3">
@@ -196,7 +207,16 @@ const Pagos: FC = () => {
{visibles.map((p) => (
<MobileCard
key={p.id}
title={p.patient}
title={
<button
type="button"
onClick={(e) => { e.stopPropagation(); openPatient(p.patient_id); }}
className="hover:underline hover:text-theme-accent text-left"
title="Abrir ficha del paciente"
>
{p.patient}
</button>
}
subtitle={p.name}
rows={[
{ label: 'Monto', value: `$${p.amount}` },
@@ -234,7 +254,14 @@ const Pagos: FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Paciente</p>
<p className="text-sm font-medium text-theme-heading">{detalle.patient}</p>
<button
type="button"
onClick={() => openPatient(detalle.patient_id)}
className="text-sm font-medium text-theme-heading underline underline-offset-2 hover:text-theme-accent text-left"
title="Abrir ficha del paciente"
>
{detalle.patient}
</button>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Monto</p>

View File

@@ -5,6 +5,7 @@ import {
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Printer, Wallet, Trash2, Package,
} from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
import {
Card,
Button,
@@ -44,6 +45,7 @@ interface TicketLine {
}
const Pos: FC = () => {
const { openPatient } = usePatientDrawer();
// Catálogo
const [catMode, setCatMode] = useState<'servicios' | 'articulos'>('servicios');
const [services, setServices] = useState<Service[]>([]);
@@ -57,6 +59,7 @@ const Pos: FC = () => {
const [lines, setLines] = useState<TicketLine[]>([]);
const [discount, setDiscount] = useState('');
const [paciente, setPaciente] = useState<Patient | null>(null);
const [pacienteAdeudo, setPacienteAdeudo] = useState(0);
const [pacienteQuery, setPacienteQuery] = useState('');
const [pacienteResults, setPacienteResults] = useState<Patient[]>([]);
const [altaRapida, setAltaRapida] = useState(false);
@@ -310,20 +313,39 @@ const Pos: FC = () => {
/>
);
// Al seleccionar paciente: traer su adeudo para el badge de alerta en caja
const selectPaciente = (p: Patient) => {
setPaciente(p);
setPacienteResults([]);
setPacienteAdeudo(0);
odooApi.getPatientHistory(p.id)
.then((res) => { if (res.status === 'success') setPacienteAdeudo(res.estado_cuenta?.adeudo ?? 0); })
.catch(() => {});
};
const nuevaVenta = () => {
setResultado(null);
setLines([]);
setDiscount('');
setPaciente(null);
setPacienteAdeudo(0);
setPacienteQuery('');
setDoctorId('');
};
// ---- Bloques del ticket (desktop sticky y modal móvil) ----
const pacienteBlock = paciente ? (
<div className="flex items-center justify-between p-2.5 bg-theme-bg rounded-xl">
<div className="p-2.5 bg-theme-bg rounded-xl space-y-1.5">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading truncate">{paciente.name}</p>
<button
type="button"
onClick={() => openPatient(paciente.id)}
className="text-sm font-medium text-theme-heading truncate hover:underline hover:text-theme-accent"
title="Abrir ficha del paciente"
>
{paciente.name}
</button>
<p className="text-xs text-theme-muted flex items-center gap-1.5">
{paciente.phone}
{puntosPaciente > 0 && (
@@ -331,7 +353,31 @@ const Pos: FC = () => {
)}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => { setPaciente(null); setPacienteQuery(''); }}>Cambiar</Button>
<Button variant="ghost" size="sm" onClick={() => { setPaciente(null); setPacienteAdeudo(0); setPacienteQuery(''); }}>Cambiar</Button>
</div>
{/* Alertas para caja antes de cobrar: VIP / Alergias / Adeudo */}
{(paciente.is_vip || paciente.allergies || pacienteAdeudo > 0) && (
<div className="flex flex-wrap gap-1.5">
{paciente.is_vip && (
<span className="inline-flex items-center gap-1 rounded-full border border-theme-accent bg-theme-accent-bg px-2 py-0.5 text-[11px] font-medium text-theme-heading">
<Star size={11} /> VIP
</span>
)}
{paciente.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: ${paciente.allergies}`}
>
<AlertTriangle size={11} /> Alergias
</span>
)}
{pacienteAdeudo > 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(pacienteAdeudo)}
</span>
)}
</div>
)}
</div>
) : altaRapida ? (
<div className="p-2.5 bg-theme-bg rounded-xl space-y-2">
@@ -357,7 +403,7 @@ const Pos: FC = () => {
<button
key={p.id}
type="button"
onClick={() => { setPaciente(p); setPacienteResults([]); }}
onClick={() => selectPaciente(p)}
className="w-full text-left px-3 py-2 text-sm hover:bg-theme-bg transition"
>
<span className="font-medium text-theme-heading">{p.name}</span>

View File

@@ -2,6 +2,7 @@ import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download } from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
import {
Card,
Button,
@@ -37,6 +38,7 @@ const emptyLine = (): LineForm => ({
const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
const Ventas: FC = () => {
const { openPatient } = usePatientDrawer();
const [sales, setSales] = useState<Sale[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
@@ -356,7 +358,16 @@ const Ventas: FC = () => {
{sales.map((s) => (
<tr key={s.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm text-theme-muted">{s.name}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{s.patient}</td>
<td className="p-3 text-sm font-medium text-theme-heading">
<button
type="button"
onClick={() => openPatient(s.patient_id)}
className="hover:underline hover:text-theme-accent text-left"
title="Abrir ficha del paciente"
>
{s.patient}
</button>
</td>
<td className="p-3 text-sm font-semibold text-theme-heading">${s.total}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">${s.amount_paid}</td>
<td className="p-3">
@@ -391,7 +402,16 @@ const Ventas: FC = () => {
{sales.map((s) => (
<MobileCard
key={s.id}
title={s.patient}
title={
<button
type="button"
onClick={() => openPatient(s.patient_id)}
className="hover:underline hover:text-theme-accent text-left"
title="Abrir ficha del paciente"
>
{s.patient}
</button>
}
subtitle={s.name}
rows={[
{ label: 'Total', value: `$${s.total}` },

View File

@@ -74,6 +74,8 @@ export interface Appointment {
phone_confirmed_by?: string | null;
// Timestamp del check-in de llegada (2da confirmación)
arrived_at?: string | null;
// El paciente cumple años el día de la cita (badge pastel en agenda)
patient_birthday?: boolean;
}
export interface Bloqueo {
@@ -432,6 +434,7 @@ export interface Birthday {
id: number;
name: string;
phone: string;
whatsapp?: string;
birth_date: string;
occurs_on: string;
turning_age: number;
@@ -1058,6 +1061,11 @@ export const odooApi = {
return data;
},
async getPatient(id: number): Promise<{ status: string; patient: Patient }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${id}`);
return data;
},
async createPatient(patient: Partial<Patient>): Promise<{ status: string; patient: Patient }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients`, patient);
return data;

View File

@@ -778,6 +778,11 @@ class SkeenFrontendController(http.Controller):
'phone_confirmed_by': cita.phone_confirmed_by or None,
# 2da confirmación: timestamp del check-in (base del tiempo de espera)
'arrived_at': cita.arrived_at.strftime('%Y-%m-%d %H:%M:%S') if cita.arrived_at else None,
# Cumpleaños del paciente el día de la cita (para badge pastel en agenda)
'patient_birthday': bool(
cita.date and cita.partner_id.birth_date
and (cita.date.month, cita.date.day) == (cita.partner_id.birth_date.month, cita.partner_id.birth_date.day)
),
}
# ============================================================
@@ -1705,6 +1710,7 @@ class SkeenFrontendController(http.Controller):
'migraine': p.migraine,
'faints_with_needles': p.faints_with_needles,
'medical_notes': p.medical_notes or '',
'allergies': p.allergies or '',
# Completitud de expediente
'expediente_completion': p.expediente_completion,
'expediente_missing': p.expediente_missing.split('|') if p.expediente_missing else [],
@@ -3805,6 +3811,7 @@ class SkeenFrontendController(http.Controller):
'id': p.id,
'name': p.name,
'phone': p.phone or '',
'whatsapp': p.whatsapp or '',
'birth_date': p.birth_date.strftime('%Y-%m-%d'),
'occurs_on': occ.strftime('%Y-%m-%d'),
'turning_age': occ.year - p.birth_date.year,