UX Pacientes: drawer de ficha clicable global, cumpleanos en dashboard/agenda, badges VIP/alergias/adeudo en ficha y POS
This commit is contained in:
@@ -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>
|
||||
|
||||
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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 ? (
|
||||
<div className="flex items-center justify-between p-2.5 bg-theme-bg rounded-xl">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading truncate">{paciente.name}</p>
|
||||
<p className="text-xs text-theme-muted flex items-center gap-1.5">
|
||||
{paciente.phone}
|
||||
{puntosPaciente > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5"><Wallet size={11} /> {puntosPaciente} pts</span>
|
||||
)}
|
||||
</p>
|
||||
<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">
|
||||
<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 && (
|
||||
<span className="inline-flex items-center gap-0.5"><Wallet size={11} /> {puntosPaciente} pts</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setPaciente(null); setPacienteAdeudo(0); setPacienteQuery(''); }}>Cambiar</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setPaciente(null); setPacienteQuery(''); }}>Cambiar</Button>
|
||||
{/* 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>
|
||||
|
||||
@@ -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}` },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user