From 2710d9d92d42fdaeb5dafecff9fb48bdc4968bc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Tue, 1 Sep 2026 22:26:09 +0000 Subject: [PATCH] UX Pacientes: drawer de ficha clicable global, cumpleanos en dashboard/agenda, badges VIP/alergias/adeudo en ficha y POS --- frontend/src/App.tsx | 3 + frontend/src/components/PatientDrawer.tsx | 253 ++++++++++++++++++ frontend/src/pages/Agenda.tsx | 39 ++- frontend/src/pages/Dashboard.tsx | 77 +++++- frontend/src/pages/Pagos.tsx | 33 ++- frontend/src/pages/Pos.tsx | 68 ++++- frontend/src/pages/Ventas.tsx | 24 +- frontend/src/services/odoo.ts | 8 + .../skeen_whatsapp/controllers/frontend.py | 7 + 9 files changed, 492 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/PatientDrawer.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b202089..1fafcb8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => { + } /> } /> @@ -89,6 +91,7 @@ const App: FC = () => { } /> } /> + diff --git a/frontend/src/components/PatientDrawer.tsx b/frontend/src/components/PatientDrawer.tsx new file mode 100644 index 0000000..e231b46 --- /dev/null +++ b/frontend/src/components/PatientDrawer.tsx @@ -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({ 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(null); + const [visitas, setVisitas] = useState([]); + const [cuenta, setCuenta] = useState(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 */} +
+ {/* Panel deslizante */} + + + ); +}; + +export const PatientDrawerProvider: FC<{ children: ReactNode }> = ({ children }) => { + const [patientId, setPatientId] = useState(null); + const openPatient = useCallback((id: number) => setPatientId(id), []); + return ( + + {children} + {patientId !== null && ( + setPatientId(null)} /> + )} + + ); +}; + +export default PatientDrawerProvider; diff --git a/frontend/src/pages/Agenda.tsx b/frontend/src/pages/Agenda.tsx index 6058c08..dd73e4d 100644 --- a/frontend/src/pages/Agenda.tsx +++ b/frontend/src/pages/Agenda.tsx @@ -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 ? ( + + ) : 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 = () => { >
{confirmIndicator(apt)} + {apt.patient_birthday && {birthdayIcon(apt)}} {apt.time} · {apt.patient}
{apt.service}
@@ -1366,6 +1375,7 @@ const Agenda: FC = () => { >
{confirmIndicator(apt, 10)} + {apt.patient_birthday && {birthdayIcon(apt, 10)}} {apt.time} · {firstName(apt.patient)}
@@ -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'}`} > {confirmIndicator(apt, 10)} + {apt.patient_birthday && {birthdayIcon(apt, 10)}} {apt.time} {firstName(apt.patient)} )) @@ -1496,7 +1507,17 @@ const Agenda: FC = () => { {apt.time} - {apt.patient} + + + + {detailApt.patient_birthday && ( + + {birthdayIcon(detailApt, 10)} Cumpleaños hoy + + )}

Servicio

diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index da3aa24..3554f21 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 } | null>(null); const [loadingExtras, setLoadingExtras] = useState(true); + const { openPatient } = usePatientDrawer(); + // Cumpleaños del día (endpoint /reports/birthdays?period=today) + const [birthdays, setBirthdays] = useState([]); + 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 = () => { ))}
+ {/* Cumpleaños de hoy */} + + +
+

+ Cumpleaños de hoy +

+

Pacientes que cumplen años hoy — contáctalos

+
+ +
+ + {loadingBirthdays ? ( + + ) : birthdays.length === 0 ? ( +

Ningún paciente cumple años hoy.

+ ) : ( +
+ {birthdays.map((b) => ( +
+ + + cumple {b.turning_age}{b.is_vip ? ' · VIP' : ''} + + {(b.whatsapp || b.phone) && ( + + {b.whatsapp || b.phone} + + )} +
+ ))} +
+ )} +
+
+ {/* Chart + appointments */}
diff --git a/frontend/src/pages/Pagos.tsx b/frontend/src/pages/Pagos.tsx index 4fac53c..b212356 100644 --- a/frontend/src/pages/Pagos.tsx +++ b/frontend/src/pages/Pagos.tsx @@ -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 = { }; const Pagos: FC = () => { + const { openPatient } = usePatientDrawer(); const [payments, setPayments] = useState([]); const [patients, setPatients] = useState([]); const [loading, setLoading] = useState(true); @@ -173,7 +175,16 @@ const Pagos: FC = () => { {visibles.map((p) => ( setDetalle(p)}> {p.name} - {p.patient} + + + ${p.amount} {metodoLabels[p.payment_method] || p.payment_method} @@ -196,7 +207,16 @@ const Pagos: FC = () => { {visibles.map((p) => ( { e.stopPropagation(); openPatient(p.patient_id); }} + className="hover:underline hover:text-theme-accent text-left" + title="Abrir ficha del paciente" + > + {p.patient} + + } subtitle={p.name} rows={[ { label: 'Monto', value: `$${p.amount}` }, @@ -234,7 +254,14 @@ const Pagos: FC = () => {

Paciente

-

{detalle.patient}

+

Monto

diff --git a/frontend/src/pages/Pos.tsx b/frontend/src/pages/Pos.tsx index 9d5be2d..08e1629 100644 --- a/frontend/src/pages/Pos.tsx +++ b/frontend/src/pages/Pos.tsx @@ -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([]); @@ -57,6 +59,7 @@ const Pos: FC = () => { const [lines, setLines] = useState([]); const [discount, setDiscount] = useState(''); const [paciente, setPaciente] = useState(null); + const [pacienteAdeudo, setPacienteAdeudo] = useState(0); const [pacienteQuery, setPacienteQuery] = useState(''); const [pacienteResults, setPacienteResults] = useState([]); const [altaRapida, setAltaRapida] = useState(false); @@ -310,28 +313,71 @@ 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 ? ( -
-
-

{paciente.name}

-

- {paciente.phone} - {puntosPaciente > 0 && ( - {puntosPaciente} pts - )} -

+
+
+
+ +

+ {paciente.phone} + {puntosPaciente > 0 && ( + {puntosPaciente} pts + )} +

+
+
- + {/* Alertas para caja antes de cobrar: VIP / Alergias / Adeudo */} + {(paciente.is_vip || paciente.allergies || pacienteAdeudo > 0) && ( +
+ {paciente.is_vip && ( + + VIP + + )} + {paciente.allergies && ( + + Alergias + + )} + {pacienteAdeudo > 0 && ( + + Adeudo {fmtMoney(pacienteAdeudo)} + + )} +
+ )}
) : altaRapida ? (
@@ -357,7 +403,7 @@ const Pos: FC = () => { + ${s.total} ${s.amount_paid} @@ -391,7 +402,16 @@ const Ventas: FC = () => { {sales.map((s) => ( openPatient(s.patient_id)} + className="hover:underline hover:text-theme-accent text-left" + title="Abrir ficha del paciente" + > + {s.patient} + + } subtitle={s.name} rows={[ { label: 'Total', value: `$${s.total}` }, diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index 6340bbf..5f72ec1 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -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): Promise<{ status: string; patient: Patient }> { const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients`, patient); return data; diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py index fa66165..b3c879d 100644 --- a/odoo-addons/skeen_whatsapp/controllers/frontend.py +++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py @@ -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,