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 { ToastProvider } from './components/ui';
|
||||||
import { AuthProvider, useAuth } from './lib/auth';
|
import { AuthProvider, useAuth } from './lib/auth';
|
||||||
import { QuickLinksProvider } from './lib/quicklinks';
|
import { QuickLinksProvider } from './lib/quicklinks';
|
||||||
|
import { PatientDrawerProvider } from './components/PatientDrawer';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Agenda from './pages/Agenda';
|
import Agenda from './pages/Agenda';
|
||||||
import Pacientes from './pages/Pacientes';
|
import Pacientes from './pages/Pacientes';
|
||||||
@@ -61,6 +62,7 @@ const App: FC = () => {
|
|||||||
<QuickLinksProvider>
|
<QuickLinksProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
|
<PatientDrawerProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginRoute />} />
|
<Route path="/login" element={<LoginRoute />} />
|
||||||
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
|
<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="/wacrm/leads" element={<RequireAuth href="/wacrm/leads"><WacrmLeads /></RequireAuth>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</PatientDrawerProvider>
|
||||||
</Router>
|
</Router>
|
||||||
</QuickLinksProvider>
|
</QuickLinksProvider>
|
||||||
</AuthProvider>
|
</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 type { FC } from 'react';
|
||||||
import { useEffect, useState, useCallback, useRef } 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 Layout from '../components/Layout';
|
||||||
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Button,
|
Button,
|
||||||
@@ -44,6 +45,7 @@ const QUARTER_TIME_OPTIONS = (() => {
|
|||||||
const Agenda: FC = () => {
|
const Agenda: FC = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { openPatient } = usePatientDrawer();
|
||||||
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor)
|
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor)
|
||||||
const isMedico = user?.role === 'medico';
|
const isMedico = user?.role === 'medico';
|
||||||
const medicoEmployeeId = isMedico && user?.employee_id ? user.employee_id : null;
|
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 => {
|
const confirmTitle = (apt: Appointment): string => {
|
||||||
if (['arrived', 'in_progress', 'done'].includes(apt.state)) return '2da confirmación: llegó a la clínica';
|
if (['arrived', 'in_progress', 'done'].includes(apt.state)) return '2da confirmación: llegó a la clínica';
|
||||||
if (apt.phone_confirmed) {
|
if (apt.phone_confirmed) {
|
||||||
@@ -1260,6 +1268,7 @@ const Agenda: FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="font-semibold truncate">
|
<div className="font-semibold truncate">
|
||||||
<span title={confirmTitle(apt)} className="mr-1 align-middle">{confirmIndicator(apt)}</span>
|
<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}
|
{apt.time} · {apt.patient}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate opacity-75">{apt.service}</div>
|
<div className="truncate opacity-75">{apt.service}</div>
|
||||||
@@ -1366,6 +1375,7 @@ const Agenda: FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="font-semibold truncate">
|
<div className="font-semibold truncate">
|
||||||
<span title={confirmTitle(apt)} className="mr-0.5 align-middle">{confirmIndicator(apt, 10)}</span>
|
<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)}
|
{apt.time} · {firstName(apt.patient)}
|
||||||
</div>
|
</div>
|
||||||
</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'}`}
|
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>
|
<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)}
|
<span className="font-semibold">{apt.time}</span> {firstName(apt.patient)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -1496,7 +1507,17 @@ const Agenda: FC = () => {
|
|||||||
<Clock size={14} className="inline mr-1 text-theme-muted" />
|
<Clock size={14} className="inline mr-1 text-theme-muted" />
|
||||||
{apt.time}
|
{apt.time}
|
||||||
</td>
|
</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">
|
<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">
|
<button type="button" onClick={() => openAptDetail(apt)} className="hover:underline hover:text-theme-heading text-left">
|
||||||
{apt.service}
|
{apt.service}
|
||||||
@@ -1911,7 +1932,19 @@ const Agenda: FC = () => {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="p-3 bg-theme-bg rounded-xl">
|
<div className="p-3 bg-theme-bg rounded-xl">
|
||||||
<p className="text-xs text-theme-muted mb-1">Paciente</p>
|
<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>
|
||||||
<div className="p-3 bg-theme-bg rounded-xl">
|
<div className="p-3 bg-theme-bg rounded-xl">
|
||||||
<p className="text-xs text-theme-muted mb-1">Servicio</p>
|
<p className="text-xs text-theme-muted mb-1">Servicio</p>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
Cake,
|
||||||
|
MessageCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
AreaChart,
|
||||||
@@ -39,7 +41,8 @@ import {
|
|||||||
toast,
|
toast,
|
||||||
badgeForAppointmentState,
|
badgeForAppointmentState,
|
||||||
} from '../components/ui';
|
} 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 { useTheme } from '../lib/theme';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
|
|
||||||
@@ -92,6 +95,10 @@ const Dashboard: FC = () => {
|
|||||||
const [topServices, setTopServices] = useState<{ service: string; qty: number; revenue: number }[]>([]);
|
const [topServices, setTopServices] = useState<{ service: string; qty: number; revenue: number }[]>([]);
|
||||||
const [aptReport, setAptReport] = useState<{ total: number; by_state: Record<string, number> } | null>(null);
|
const [aptReport, setAptReport] = useState<{ total: number; by_state: Record<string, number> } | null>(null);
|
||||||
const [loadingExtras, setLoadingExtras] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
const loadStats = async () => {
|
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();
|
loadStats();
|
||||||
loadChart();
|
loadChart();
|
||||||
loadAppointments();
|
loadAppointments();
|
||||||
loadServices();
|
loadServices();
|
||||||
loadExtras();
|
loadExtras();
|
||||||
|
loadBirthdays();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const statsConfig = useMemo(
|
const statsConfig = useMemo(
|
||||||
@@ -320,6 +340,61 @@ const Dashboard: FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
{/* Chart + appointments */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-6 sm:mb-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-6 sm:mb-8">
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { FC } from 'react';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Plus, CheckCircle, CreditCard } from 'lucide-react';
|
import { Plus, CheckCircle, CreditCard } from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Button,
|
Button,
|
||||||
@@ -34,6 +35,7 @@ const estadoLabels: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Pagos: FC = () => {
|
const Pagos: FC = () => {
|
||||||
|
const { openPatient } = usePatientDrawer();
|
||||||
const [payments, setPayments] = useState<Payment[]>([]);
|
const [payments, setPayments] = useState<Payment[]>([]);
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -173,7 +175,16 @@ const Pagos: FC = () => {
|
|||||||
{visibles.map((p) => (
|
{visibles.map((p) => (
|
||||||
<tr key={p.id} className="hover:bg-theme-bg cursor-pointer" onClick={() => setDetalle(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 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 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 text-sm text-theme-muted hidden sm:table-cell">{metodoLabels[p.payment_method] || p.payment_method}</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
@@ -196,7 +207,16 @@ const Pagos: FC = () => {
|
|||||||
{visibles.map((p) => (
|
{visibles.map((p) => (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
key={p.id}
|
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}
|
subtitle={p.name}
|
||||||
rows={[
|
rows={[
|
||||||
{ label: 'Monto', value: `$${p.amount}` },
|
{ 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="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div className="p-3 bg-theme-bg rounded-xl">
|
<div className="p-3 bg-theme-bg rounded-xl">
|
||||||
<p className="text-xs text-theme-muted mb-1">Paciente</p>
|
<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>
|
||||||
<div className="p-3 bg-theme-bg rounded-xl">
|
<div className="p-3 bg-theme-bg rounded-xl">
|
||||||
<p className="text-xs text-theme-muted mb-1">Monto</p>
|
<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,
|
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Printer, Wallet, Trash2, Package,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Button,
|
Button,
|
||||||
@@ -44,6 +45,7 @@ interface TicketLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Pos: FC = () => {
|
const Pos: FC = () => {
|
||||||
|
const { openPatient } = usePatientDrawer();
|
||||||
// Catálogo
|
// Catálogo
|
||||||
const [catMode, setCatMode] = useState<'servicios' | 'articulos'>('servicios');
|
const [catMode, setCatMode] = useState<'servicios' | 'articulos'>('servicios');
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
@@ -57,6 +59,7 @@ const Pos: FC = () => {
|
|||||||
const [lines, setLines] = useState<TicketLine[]>([]);
|
const [lines, setLines] = useState<TicketLine[]>([]);
|
||||||
const [discount, setDiscount] = useState('');
|
const [discount, setDiscount] = useState('');
|
||||||
const [paciente, setPaciente] = useState<Patient | null>(null);
|
const [paciente, setPaciente] = useState<Patient | null>(null);
|
||||||
|
const [pacienteAdeudo, setPacienteAdeudo] = useState(0);
|
||||||
const [pacienteQuery, setPacienteQuery] = useState('');
|
const [pacienteQuery, setPacienteQuery] = useState('');
|
||||||
const [pacienteResults, setPacienteResults] = useState<Patient[]>([]);
|
const [pacienteResults, setPacienteResults] = useState<Patient[]>([]);
|
||||||
const [altaRapida, setAltaRapida] = useState(false);
|
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 = () => {
|
const nuevaVenta = () => {
|
||||||
setResultado(null);
|
setResultado(null);
|
||||||
setLines([]);
|
setLines([]);
|
||||||
setDiscount('');
|
setDiscount('');
|
||||||
setPaciente(null);
|
setPaciente(null);
|
||||||
|
setPacienteAdeudo(0);
|
||||||
setPacienteQuery('');
|
setPacienteQuery('');
|
||||||
setDoctorId('');
|
setDoctorId('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Bloques del ticket (desktop sticky y modal móvil) ----
|
// ---- Bloques del ticket (desktop sticky y modal móvil) ----
|
||||||
const pacienteBlock = paciente ? (
|
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">
|
<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">
|
<p className="text-xs text-theme-muted flex items-center gap-1.5">
|
||||||
{paciente.phone}
|
{paciente.phone}
|
||||||
{puntosPaciente > 0 && (
|
{puntosPaciente > 0 && (
|
||||||
@@ -331,7 +353,31 @@ const Pos: FC = () => {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : altaRapida ? (
|
) : altaRapida ? (
|
||||||
<div className="p-2.5 bg-theme-bg rounded-xl space-y-2">
|
<div className="p-2.5 bg-theme-bg rounded-xl space-y-2">
|
||||||
@@ -357,7 +403,7 @@ const Pos: FC = () => {
|
|||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
type="button"
|
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"
|
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>
|
<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 { useEffect, useState, useCallback } from 'react';
|
||||||
import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download } from 'lucide-react';
|
import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download } from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Button,
|
Button,
|
||||||
@@ -37,6 +38,7 @@ const emptyLine = (): LineForm => ({
|
|||||||
const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
|
const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
|
||||||
|
|
||||||
const Ventas: FC = () => {
|
const Ventas: FC = () => {
|
||||||
|
const { openPatient } = usePatientDrawer();
|
||||||
const [sales, setSales] = useState<Sale[]>([]);
|
const [sales, setSales] = useState<Sale[]>([]);
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
@@ -356,7 +358,16 @@ const Ventas: FC = () => {
|
|||||||
{sales.map((s) => (
|
{sales.map((s) => (
|
||||||
<tr key={s.id} className="hover:bg-theme-bg">
|
<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 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 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 text-sm text-theme-muted hidden md:table-cell">${s.amount_paid}</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
@@ -391,7 +402,16 @@ const Ventas: FC = () => {
|
|||||||
{sales.map((s) => (
|
{sales.map((s) => (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
key={s.id}
|
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}
|
subtitle={s.name}
|
||||||
rows={[
|
rows={[
|
||||||
{ label: 'Total', value: `$${s.total}` },
|
{ label: 'Total', value: `$${s.total}` },
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export interface Appointment {
|
|||||||
phone_confirmed_by?: string | null;
|
phone_confirmed_by?: string | null;
|
||||||
// Timestamp del check-in de llegada (2da confirmación)
|
// Timestamp del check-in de llegada (2da confirmación)
|
||||||
arrived_at?: string | null;
|
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 {
|
export interface Bloqueo {
|
||||||
@@ -432,6 +434,7 @@ export interface Birthday {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
whatsapp?: string;
|
||||||
birth_date: string;
|
birth_date: string;
|
||||||
occurs_on: string;
|
occurs_on: string;
|
||||||
turning_age: number;
|
turning_age: number;
|
||||||
@@ -1058,6 +1061,11 @@ export const odooApi = {
|
|||||||
return data;
|
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 }> {
|
async createPatient(patient: Partial<Patient>): Promise<{ status: string; patient: Patient }> {
|
||||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients`, patient);
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients`, patient);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -778,6 +778,11 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'phone_confirmed_by': cita.phone_confirmed_by or None,
|
'phone_confirmed_by': cita.phone_confirmed_by or None,
|
||||||
# 2da confirmación: timestamp del check-in (base del tiempo de espera)
|
# 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,
|
'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,
|
'migraine': p.migraine,
|
||||||
'faints_with_needles': p.faints_with_needles,
|
'faints_with_needles': p.faints_with_needles,
|
||||||
'medical_notes': p.medical_notes or '',
|
'medical_notes': p.medical_notes or '',
|
||||||
|
'allergies': p.allergies or '',
|
||||||
# Completitud de expediente
|
# Completitud de expediente
|
||||||
'expediente_completion': p.expediente_completion,
|
'expediente_completion': p.expediente_completion,
|
||||||
'expediente_missing': p.expediente_missing.split('|') if p.expediente_missing else [],
|
'expediente_missing': p.expediente_missing.split('|') if p.expediente_missing else [],
|
||||||
@@ -3805,6 +3811,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'id': p.id,
|
'id': p.id,
|
||||||
'name': p.name,
|
'name': p.name,
|
||||||
'phone': p.phone or '',
|
'phone': p.phone or '',
|
||||||
|
'whatsapp': p.whatsapp or '',
|
||||||
'birth_date': p.birth_date.strftime('%Y-%m-%d'),
|
'birth_date': p.birth_date.strftime('%Y-%m-%d'),
|
||||||
'occurs_on': occ.strftime('%Y-%m-%d'),
|
'occurs_on': occ.strftime('%Y-%m-%d'),
|
||||||
'turning_age': occ.year - p.birth_date.year,
|
'turning_age': occ.year - p.birth_date.year,
|
||||||
|
|||||||
Reference in New Issue
Block a user