Fix IA (max_tokens del modelo de razonamiento + fallback al gateway), sidebar con toggle arriba, busqueda de un solo campo (paciente/global), editar expediente, marketing en visitas, responsive de agenda; doc de diagnostico WhatsApp

This commit is contained in:
2026-09-24 10:01:55 +00:00
parent d75411f0d9
commit e32a79084a
14 changed files with 897 additions and 238 deletions

View File

@@ -4,6 +4,7 @@ import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, Packag
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
import AgendaPrint from '../components/AgendaPrint';
import PatientCombobox from '../components/PatientCombobox';
import RecargarSaldoModal from '../components/RecargarSaldoModal';
import ReciboPrint from '../components/ReciboPrint';
import {
@@ -24,6 +25,7 @@ import {
import { useSearchParams, useNavigate } from 'react-router-dom';
import { odooApi, type Appointment, type Patient, type Service, type Doctor, type Bloqueo, type PatientWallet, type ReceiptData } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { todayLocal } from '../lib/utils';
const stateOptions = [
{ value: '', label: 'Todos los estados' },
@@ -116,14 +118,13 @@ const Agenda: FC = () => {
const medicoEmployeeId = isMedico && user?.employee_id ? user.employee_id : null;
const propiaColumnaId = isCosmetologa && user?.employee_id ? user.employee_id : null;
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [updating, setUpdating] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [date, setDate] = useState<string>(() => searchParams.get('date') || new Date().toISOString().split('T')[0]);
const [date, setDate] = useState<string>(() => searchParams.get('date') || todayLocal());
const [stateFilter, setStateFilter] = useState('');
const [doctorFilter, setDoctorFilter] = useState(() => {
if (medicoEmployeeId) return String(medicoEmployeeId);
@@ -161,6 +162,9 @@ const Agenda: FC = () => {
const [printOpen, setPrintOpen] = useState(false);
// Paciente precargado vía query param (?patient_id=X) desde la ficha
const [preloadedPatient, setPreloadedPatient] = useState<Patient | null>(null);
// Paciente elegido/creado en el combobox del modal (para mostrar su nombre
// sin depender de la búsqueda ni de la lista de citas cargadas).
const [pickedPatient, setPickedPatient] = useState<{ id: string; name: string } | null>(null);
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
const [editMode, setEditMode] = useState(false);
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
@@ -177,19 +181,18 @@ const Agenda: FC = () => {
const [dropTarget, setDropTarget] = useState<string | null>(null);
// Tooltip flotante de tarjeta de cita (hover en vistas de grid)
const [tooltip, setTooltip] = useState<{ apt: Appointment; x: number; y: number; above: boolean } | null>(null);
const horasScrollRef = useRef<HTMLDivElement | null>(null);
const gridScrollRef = useRef<HTMLDivElement | null>(null);
const autoScrollKey = useRef('');
const [form, setForm] = useState({
patient_id: '',
service_id: '',
doctor_id: '',
date: new Date().toISOString().split('T')[0],
date: todayLocal(),
time: '',
notes: '',
sessions: 1,
});
const [patientSearch, setPatientSearch] = useState('');
const quickToggleClass = editingId
? 'hidden'
: 'inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer';
@@ -204,7 +207,7 @@ const Agenda: FC = () => {
name: '',
phone: '',
doctor_id: '',
date: new Date().toISOString().split('T')[0],
date: todayLocal(),
time: '',
notes: '',
});
@@ -218,7 +221,7 @@ const Agenda: FC = () => {
withAppointment: false,
service_id: '',
doctor_id: '',
date: new Date().toISOString().split('T')[0],
date: todayLocal(),
time: '',
});
@@ -565,7 +568,7 @@ const Agenda: FC = () => {
<span className="text-xs text-theme-muted">
· Las citas canceladas no bloquean horario: su hueco aparece como libre.
</span>
<span className="inline-flex items-center gap-x-3 gap-y-1 text-xs text-theme-muted">
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-theme-muted">
· Barra por categoría:
{Object.entries(categoryLabels).map(([key, label]) => (
<span key={key} className="inline-flex items-center gap-1">
@@ -593,14 +596,19 @@ const Agenda: FC = () => {
Math.floor((nowFloat - 1) * SLOTS_PER_HOUR + 1e-6) / SLOTS_PER_HOUR
);
setTimeout(() => {
const el = document.querySelector(`[data-grid-slot="${target.toFixed(2)}"]`);
const el = document.querySelector(`[data-grid-slot="${target.toFixed(2)}"]`) as HTMLElement | null;
if (!el) return;
if (viewMode === 'horas' && horasScrollRef.current) {
const wrap = gridScrollRef.current;
if (viewMode === 'horas' && wrap) {
// En "Por hora" el desplazamiento es horizontal dentro del contenedor
horasScrollRef.current.scrollLeft = Math.max(0, (el as HTMLElement).offsetLeft - 200);
wrap.scrollLeft = Math.max(0, el.offsetLeft - 200);
} else if (wrap) {
// El grid tiene su propio scroll vertical con la cabecera sticky: hay
// que descontar su alto para que la fila no quede tapada.
const headerH = 72;
wrap.scrollTop = Math.max(0, el.getBoundingClientRect().top - wrap.getBoundingClientRect().top + wrap.scrollTop - headerH);
} else {
el.scrollIntoView({ block: 'start' });
window.scrollBy(0, -90); // compensa el encabezado de la página
}
}, 80);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -666,6 +674,7 @@ const Agenda: FC = () => {
setQuickMode(false);
setEditingId(null);
setCrearNuevo(false);
setPickedPatient({ id: String(res.patient.id), name: res.patient.name });
setForm((f) => ({ ...f, patient_id: String(res.patient.id) }));
setCreateOpen(true);
}
@@ -686,20 +695,6 @@ const Agenda: FC = () => {
};
}, [preloadPatientId, setSearchParams]);
// Cargar pacientes para el selector (búsqueda server-side)
useEffect(() => {
if (!createOpen) return;
const t = setTimeout(async () => {
try {
const res = await odooApi.getPatients({ search: patientSearch, page_size: 8 });
if (res.status === 'success') setPatients(res.patients);
} catch (err) {
console.error(err);
}
}, 250);
return () => clearTimeout(t);
}, [createOpen, patientSearch]);
// Aviso de duplicado: teléfono del mini-form "Crear paciente nuevo"
useEffect(() => {
if (!crearNuevo) {
@@ -740,13 +735,9 @@ const Agenda: FC = () => {
});
if (res.status === 'success') {
toast.success('Paciente creado');
setPickedPatient({ id: String(res.patient.id), name: res.patient.name });
setForm((f) => ({ ...f, patient_id: String(res.patient.id) }));
setPatients((prev) => [
res.patient,
...prev.filter((p) => String(p.id) !== String(res.patient.id)),
]);
setCrearNuevo(false);
setPatientSearch('');
}
} catch (err) {
toast.error('Error al crear el paciente');
@@ -943,7 +934,7 @@ const Agenda: FC = () => {
const resetExpressForm = () =>
setExpressForm({
name: '', phone: '', notes: '', withAppointment: false,
service_id: '', doctor_id: '', date: new Date().toISOString().split('T')[0], time: '',
service_id: '', doctor_id: '', date: todayLocal(), time: '',
});
const submitExpress = async () => {
@@ -1008,11 +999,12 @@ const Agenda: FC = () => {
});
toast.success(sessions > 1 ? `Paquete creado: ${sessions} citas` : 'Cita creada');
setCreateOpen(false);
setPickedPatient(null);
setForm({
patient_id: '',
service_id: '',
doctor_id: '',
date: new Date().toISOString().split('T')[0],
date: todayLocal(),
time: '',
notes: '',
sessions: 1,
@@ -1031,6 +1023,7 @@ const Agenda: FC = () => {
setDetailApt(null);
setEditMode(false);
setEditingId(apt.id);
setPickedPatient(null);
setForm({
patient_id: String(apt.patient_id),
service_id: String(apt.service_id),
@@ -1040,7 +1033,6 @@ const Agenda: FC = () => {
notes: apt.notes || '',
sessions: 1,
});
setPatientSearch('');
setCreateOpen(true);
};
@@ -1076,9 +1068,8 @@ const Agenda: FC = () => {
const openCreate = () => {
setQuickMode(false);
setEditingId(null);
if (medicoEmployeeId) {
setForm((f) => ({ ...f, doctor_id: String(medicoEmployeeId) }));
}
setPickedPatient(null);
setForm((f) => ({ ...f, patient_id: '', ...(medicoEmployeeId ? { doctor_id: String(medicoEmployeeId) } : {}) }));
setQuickForm({
name: '',
phone: '',
@@ -1097,12 +1088,13 @@ const Agenda: FC = () => {
const openCreateAt = (opts?: { date?: string; time?: string; doctorId?: number | null }) => {
setQuickMode(false);
setEditingId(null);
setPickedPatient(null);
const d = opts?.date ?? date;
const docId = medicoEmployeeId
? String(medicoEmployeeId)
: (opts?.doctorId != null ? String(opts.doctorId) : doctorFilter);
const t = opts?.time ?? '';
setForm((f) => ({ ...f, date: d, time: t, doctor_id: docId }));
setForm((f) => ({ ...f, patient_id: '', date: d, time: t, doctor_id: docId }));
setQuickForm({
name: '',
phone: '',
@@ -1156,31 +1148,20 @@ const Agenda: FC = () => {
}
};
const patientOptions = useMemo(() => {
const opts: { value: string; label: string }[] = [{ value: '', label: 'Seleccionar paciente...' }];
const list = patients.map((p) => ({
value: String(p.id),
label: `${p.name} — ${p.phone || 'sin teléfono'}${p.primary_doctor ? ` · ${p.primary_doctor}` : ''}`,
}));
// Garantiza que el paciente precargado (desde celda, query param o edición) aparezca
if (form.patient_id && !list.some((o) => o.value === form.patient_id)) {
const pre = preloadedPatient && String(preloadedPatient.id) === form.patient_id ? preloadedPatient : null;
const sel = appointments.find((a) => String(a.patient_id) === form.patient_id) as Appointment | undefined;
opts.push({
value: form.patient_id,
label: pre
? `${pre.name} — ${pre.phone || 'sin teléfono'}`
: sel
? `${sel.patient} — (cita seleccionada)`
: `Paciente #${form.patient_id}`,
});
// Nombre del paciente precargado (celda libre, query param o edición) para
// que el combobox muestre el texto correcto sin pedir otra búsqueda.
const selectedPatientName = useMemo(() => {
if (!form.patient_id) return '';
if (pickedPatient && pickedPatient.id === form.patient_id) return pickedPatient.name;
if (preloadedPatient && String(preloadedPatient.id) === form.patient_id) return preloadedPatient.name;
const sel = appointments.find((a) => String(a.patient_id) === form.patient_id);
if (sel) return sel.patient;
if (editingId) {
const edit = appointments.find((a) => a.id === editingId);
if (edit && String(edit.patient_id) === form.patient_id) return edit.patient;
}
opts.push(...list);
if (patientSearch.trim()) {
opts.push({ value: '__crear__', label: `Crear paciente nuevo: "${patientSearch.trim()}"` });
}
return opts;
}, [patients, patientSearch, form.patient_id, appointments, preloadedPatient]);
return '';
}, [form.patient_id, pickedPatient, preloadedPatient, appointments, editingId]);
const serviceOptions = [
{ value: '', label: 'Seleccionar servicio...' },
@@ -1334,7 +1315,7 @@ const Agenda: FC = () => {
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4 mb-4 sm:mb-6">
{/* Navegación rápida de fecha: día anterior/siguiente (semana en vista Semana) y Hoy */}
<div className="flex items-end gap-1.5">
<div className="flex flex-wrap items-end gap-1.5">
<Button
variant="outline"
size="sm"
@@ -1349,7 +1330,7 @@ const Agenda: FC = () => {
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="sm:max-w-[180px]"
className="w-full sm:max-w-[180px]"
/>
<Button
variant="outline"
@@ -1623,15 +1604,15 @@ const Agenda: FC = () => {
/>
) : (
<div>
<div className="overflow-x-auto rounded-2xl border border-theme-border shadow-card">
<div ref={gridScrollRef} className="overflow-auto max-h-[70vh] rounded-2xl border border-theme-border shadow-card">
<table className="w-full border-collapse bg-theme-surface">
<thead>
<tr>
<th className="sticky left-0 z-10 w-20 bg-theme-surface p-3 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
<th className="sticky left-0 top-0 z-30 w-20 bg-theme-surface p-3 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Hora
</th>
{gridColumns.map((col) => (
<th key={col.key} className="min-w-[180px] p-3 border-b border-l border-theme-border">
<th key={col.key} className="sticky top-0 z-20 min-w-[180px] bg-theme-surface p-3 border-b border-l border-theme-border">
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
{initials(col.label)}
@@ -1739,17 +1720,17 @@ const Agenda: FC = () => {
/>
) : (
<div>
<div ref={horasScrollRef} className="overflow-x-auto rounded-2xl border border-theme-border shadow-card">
<div ref={gridScrollRef} className="overflow-auto max-h-[70vh] rounded-2xl border border-theme-border shadow-card">
<table className="w-full border-collapse bg-theme-surface">
<thead>
<tr>
<th className="sticky left-0 z-10 min-w-[190px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
<th className="sticky left-0 top-0 z-30 min-w-[190px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Médico
</th>
{gridSlots.map((slot) => {
const isNowSlot = nowSlot !== null && slot === nowSlot;
return (
<th key={slot} data-grid-slot={slot.toFixed(2)} className={`min-w-[52px] p-1 text-center text-[10px] font-medium border-b border-l border-theme-border ${isNowSlot ? 'text-theme-accent border-b-2 border-b-theme-accent' : 'text-theme-muted'}`}>
<th key={slot} data-grid-slot={slot.toFixed(2)} className={`sticky top-0 z-20 min-w-[60px] bg-theme-surface p-1 text-center text-[10px] font-medium border-b border-l border-theme-border ${isNowSlot ? 'text-theme-accent border-b-2 border-b-theme-accent' : 'text-theme-muted'}`}>
{horaFloatStr(slot)}
{isNowSlot && <span className="block text-[8px] font-semibold uppercase">ahora</span>}
</th>
@@ -1852,17 +1833,17 @@ const Agenda: FC = () => {
<p className="text-sm text-theme-muted mb-2">
Semana del {weekDates[0]} al {weekDates[6]} · usa ‹ › para moverte por semana
</p>
<div className="overflow-x-auto rounded-2xl border border-theme-border shadow-card">
<div className="overflow-auto max-h-[70vh] rounded-2xl border border-theme-border shadow-card">
<table className="w-full border-collapse bg-theme-surface">
<thead>
<tr>
<th className="sticky left-0 z-10 min-w-[150px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
<th className="sticky left-0 top-0 z-30 min-w-[150px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Médico
</th>
{weekDates.map((d, i) => (
<th
key={d}
className={`min-w-[132px] p-2 text-center text-[11px] font-medium border-b border-l border-theme-border ${
className={`sticky top-0 z-20 min-w-[132px] bg-theme-surface p-2 text-center text-[11px] font-medium border-b border-l border-theme-border ${
d === todayStr ? 'bg-theme-accent-bg text-theme-accent' : 'text-theme-muted'
}`}
>
@@ -2255,11 +2236,21 @@ const Agenda: FC = () => {
</>
) : (
<>
<Input
label="Buscar paciente"
placeholder="Escribe nombre o teléfono..."
value={patientSearch}
onChange={(e) => setPatientSearch(e.target.value)}
<PatientCombobox
label="Paciente *"
value={form.patient_id}
initialName={selectedPatientName}
placeholder="Escribe nombre, apellidos o teléfono..."
emptyHint="Sin resultados. Usa «Crear paciente nuevo» para darlo de alta."
onCreateNew={(q) => {
setNuevoPac((p) => ({ ...p, name: p.name || q }));
setCrearNuevo(true);
}}
onChange={(id, patient) => {
setCrearNuevo(false);
setPickedPatient(patient ? { id: String(patient.id), name: patient.name } : null);
setForm((f) => ({ ...f, patient_id: id }));
}}
/>
{crearNuevo && (
<div className="rounded-xl border border-theme-border p-3 space-y-3 bg-theme-bg">
@@ -2297,25 +2288,12 @@ const Agenda: FC = () => {
<Button size="sm" onClick={crearYSeleccionarPaciente} loading={creandoPac}>
Crear y seleccionar
</Button>
<Button size="sm" variant="ghost" onClick={() => { setCrearNuevo(false); setForm((f) => ({ ...f, patient_id: '' })); }}>
<Button size="sm" variant="ghost" onClick={() => setCrearNuevo(false)}>
Cancelar
</Button>
</div>
</div>
)}
<Select
label="Paciente *"
options={patientOptions}
value={form.patient_id}
onChange={(e) => {
if (e.target.value === '__crear__') {
setCrearNuevo(true);
return;
}
setCrearNuevo(false);
setForm({ ...form, patient_id: e.target.value });
}}
/>
<Select
label="Servicio *"
options={serviceOptions}