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:
@@ -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}
|
||||
|
||||
@@ -124,19 +124,29 @@ const Chat: FC = () => {
|
||||
fetchUsers();
|
||||
}, [fetchThread, fetchUsers]);
|
||||
|
||||
// Buscar en el historial (servidor)
|
||||
const buscarHistorial = useCallback(async () => {
|
||||
if (!activeId || histQuery.trim().length < 2) return;
|
||||
// Buscar en el historial (servidor). La búsqueda es en vivo (debounce 250 ms)
|
||||
// sobre el mismo campo; el botón "Buscar" quedó como atajo opcional.
|
||||
const buscarHistorial = useCallback(async (q: string) => {
|
||||
if (!activeId || q.trim().length < 2) {
|
||||
setHistResults([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setHistLoading(true);
|
||||
const res = await odooApi.searchChatMessages(activeId, histQuery.trim());
|
||||
const res = await odooApi.searchChatMessages(activeId, q.trim());
|
||||
if (res.status === 'success') setHistResults(res.results);
|
||||
} catch {
|
||||
toast.error('No se pudo buscar en el historial');
|
||||
} finally {
|
||||
setHistLoading(false);
|
||||
}
|
||||
}, [activeId, histQuery]);
|
||||
}, [activeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!histOpen) return;
|
||||
const t = setTimeout(() => { void buscarHistorial(histQuery); }, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [histQuery, histOpen, buscarHistorial]);
|
||||
|
||||
// Saltar a un resultado: si ya está cargado se centra; si es antiguo se
|
||||
// pide la ventana de historial que termina en ese mensaje.
|
||||
@@ -361,11 +371,25 @@ const Chat: FC = () => {
|
||||
placeholder="Buscar en el historial..."
|
||||
value={histQuery}
|
||||
onChange={(e) => setHistQuery(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') buscarHistorial(); }}
|
||||
className="w-full rounded-xl border border-theme-border-strong bg-theme-surface pl-9 pr-3 py-2 text-sm text-theme-heading focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void buscarHistorial(histQuery);
|
||||
if (e.key === 'Escape') { setHistOpen(false); setHistResults([]); setHistQuery(''); }
|
||||
}}
|
||||
className="w-full rounded-xl border border-theme-border-strong bg-theme-surface pl-9 pr-9 py-2 text-sm text-theme-heading focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent"
|
||||
/>
|
||||
{histQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setHistQuery(''); setHistResults([]); }}
|
||||
title="Limpiar búsqueda"
|
||||
aria-label="Limpiar búsqueda"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 rounded-lg text-theme-muted hover:text-theme-heading hover:bg-theme-bg"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" onClick={buscarHistorial} loading={histLoading}>Buscar</Button>
|
||||
{histLoading && <span className="text-xs text-theme-muted">Buscando...</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setHistOpen(false); setHistResults([]); setHistQuery(''); }}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Search, Plus, Activity, ShoppingBag, User, Edit2, Phone, Mail, Calendar, Droplet, Heart,
|
||||
MapPin, Briefcase, Users, Baby, Stethoscope, FileText, AlertCircle, CheckCircle2, XCircle,
|
||||
Download, ChevronRight, AlertTriangle, ClipboardList, Camera, Wallet,
|
||||
Download, ChevronRight, AlertTriangle, ClipboardList, Camera, Wallet, X,
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import PatientAdjuntos from '../components/PatientAdjuntos';
|
||||
@@ -124,8 +124,9 @@ const Pacientes: FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [search, setSearch] = useState(() => searchParams.get('q') || '');
|
||||
const [searchInput, setSearchInput] = useState(() => searchParams.get('q') || '');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -234,6 +235,12 @@ const Pacientes: FC = () => {
|
||||
setPage(1);
|
||||
}, [search, onlyIncomplete, onlyVip, onlyRecent, onlyStale, doctorFilter]);
|
||||
|
||||
// Búsqueda con debounce: el listado se refresca solo, sin botón "Buscar".
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearch(searchInput.trim()), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getDoctors()
|
||||
.then((res) => { if (res.status === 'success') setDoctors(res.doctors); })
|
||||
@@ -308,6 +315,33 @@ const Pacientes: FC = () => {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
// Abrir el formulario de edición desde otra pantalla (p. ej. el botón
|
||||
// "Editar" del drawer del expediente): /pacientes?edit=<id>
|
||||
const editPatientId = searchParams.get('edit');
|
||||
useEffect(() => {
|
||||
if (!editPatientId) return;
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await odooApi.getPatient(parseInt(editPatientId, 10));
|
||||
if (active && res.status === 'success') openEdit(res.patient);
|
||||
} catch (err) {
|
||||
toast.error('No se pudo abrir el paciente');
|
||||
console.error(err);
|
||||
} finally {
|
||||
if (active) {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('edit');
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editPatientId, setSearchParams]);
|
||||
|
||||
// Duplicado de teléfono: se consulta con debounce al escribir (phone o mobile).
|
||||
// Al editar se excluye el propio paciente para no auto-alertarse.
|
||||
useEffect(() => {
|
||||
@@ -500,13 +534,26 @@ const Pacientes: FC = () => {
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre o teléfono..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar por nombre, apellidos o teléfono..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput.trim()); if (e.key === 'Escape') { setSearchInput(''); setSearch(''); } }}
|
||||
className="pl-10"
|
||||
inputClassName={searchInput ? 'pr-9' : undefined}
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearchInput(''); setSearch(''); }}
|
||||
title="Limpiar búsqueda"
|
||||
aria-label="Limpiar búsqueda"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 rounded-lg text-theme-muted hover:text-theme-heading hover:bg-theme-bg"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
options={[
|
||||
@@ -855,7 +902,18 @@ const Pacientes: FC = () => {
|
||||
<FileText size={16} className="mr-2" />
|
||||
Estado de cuenta
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => selected && openEdit(selected)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// El modal de detalle se cierra antes de abrir el de edición:
|
||||
// ambos comparten z-index y el de detalle, al estar después en
|
||||
// el DOM, tapaba el formulario (parecía que "Editar" no hacía nada).
|
||||
if (!selected) return;
|
||||
const p = selected;
|
||||
setSelected(null);
|
||||
openEdit(p);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={16} className="mr-2" />
|
||||
Editar paciente
|
||||
</Button>
|
||||
@@ -920,7 +978,11 @@ const Pacientes: FC = () => {
|
||||
{selected.expediente_missing && selected.expediente_missing.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="text-xs text-theme-muted">Falta: {selected.expediente_missing.join(', ')}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => openEdit(selected)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { const p = selected; setSelected(null); openEdit(p); }}
|
||||
>
|
||||
Completar expediente
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Fragment, useEffect, useState, useCallback } from 'react';
|
||||
import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download, Printer, ChevronDown, ChevronRight, Ban } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import PatientCombobox from '../components/PatientCombobox';
|
||||
import TicketPrint from '../components/TicketPrint';
|
||||
import {
|
||||
Card,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
toast,
|
||||
badgeForSaleState,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Sale, type SaleLine, type Service, type Patient, type Doctor } from '../services/odoo';
|
||||
import { odooApi, type Sale, type SaleLine, type Service, type Doctor } from '../services/odoo';
|
||||
import { exportToExcel } from '../lib/exporter';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { DATE_PRESETS, rangeForPreset, readDateFilterPrefs, writeDateFilterPrefs, type DatePreset } from '../lib/dateRange';
|
||||
@@ -50,7 +51,6 @@ const Ventas: FC = () => {
|
||||
const puedeCancelar = ROLES_CANCELAN.includes(user?.role || '');
|
||||
const [sales, setSales] = useState<Sale[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -81,7 +81,6 @@ const Ventas: FC = () => {
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [patientSearch, setPatientSearch] = useState('');
|
||||
|
||||
// ---- Rango de fechas recordado (presets) ----
|
||||
const saved = readDateFilterPrefs(FILTERS_KEY);
|
||||
@@ -154,20 +153,6 @@ const Ventas: FC = () => {
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
|
||||
// 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: 50 });
|
||||
if (res.status === 'success') setPatients(res.patients);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [createOpen, patientSearch]);
|
||||
|
||||
const addLine = () =>
|
||||
setLines([...lines, emptyLine()]);
|
||||
|
||||
@@ -362,11 +347,6 @@ const Ventas: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const patientOptions = [
|
||||
{ value: '', label: 'Seleccionar paciente...' },
|
||||
...patients.map((p) => ({ value: String(p.id), label: `${p.name} (${p.phone})` })),
|
||||
];
|
||||
|
||||
const serviceOptions = [
|
||||
{ value: '', label: 'Servicio...' },
|
||||
...services.map((s) => ({ value: String(s.id), label: `${s.name} — $${s.price}` })),
|
||||
@@ -694,17 +674,12 @@ const Ventas: FC = () => {
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Buscar paciente"
|
||||
placeholder="Escribe nombre o teléfono..."
|
||||
value={patientSearch}
|
||||
onChange={(e) => setPatientSearch(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
<PatientCombobox
|
||||
label="Paciente *"
|
||||
options={patientOptions}
|
||||
value={patientId}
|
||||
onChange={(e) => setPatientId(e.target.value)}
|
||||
placeholder="Escribe nombre, apellidos o teléfono..."
|
||||
emptyHint="Sin resultados. Da de alta al paciente en Pacientes y vuelve a intentar."
|
||||
onChange={(id) => setPatientId(id)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Visita, type VisitaAdjunto, type Doctor, type InventoryItem } from '../services/odoo';
|
||||
import type { BadgeVariant } from '../components/ui/Badge';
|
||||
import { todayLocal } from '../lib/utils';
|
||||
|
||||
const visitaStateLabels: Record<string, string> = {
|
||||
en_curso: 'En curso',
|
||||
@@ -44,7 +45,7 @@ const adjuntoKindLabels: Record<string, string> = {
|
||||
|
||||
const MAX_ADJUNTO_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
const hoy = () => todayLocal();
|
||||
|
||||
const Visitas: FC = () => {
|
||||
const [visitas, setVisitas] = useState<Visita[]>([]);
|
||||
|
||||
Reference in New Issue
Block a user