lista de espera: salas consultoria/cabina (toggle + etiqueta) y scroll de WhatsApp

- skeen_citas: campo sala en skeen.lista.espera (consultoria/cabina, indexado)
- frontend.py: sala en _lista_espera_to_dict, create y update (toggle desde
  la etiqueta; valor invalido vuelve a sin sala; cambio queda en historial)
- ListaEspera.tsx: toggle Todas/Consultoria/Cabina con conteo, etiqueta
  clicable en cada tarjeta que alterna de sala, selector en el modal de alta
- Agenda.tsx: cambiarSala() conectado al panel
- WacrmMessages.tsx: el auto-refresco ya no empuja el scroll abajo si el
  usuario esta leyendo mensajes anteriores (solo baja al cambiar de
  conversacion, por deep-link o al enviar respuesta propia)

Verificacion API: 7/7 tests OK (alta con sala, toggle, valor invalido, GET)
This commit is contained in:
2026-09-30 10:33:52 +00:00
parent 169864958d
commit 2573562218
6 changed files with 137 additions and 10 deletions

View File

@@ -939,8 +939,20 @@ const Agenda: FC = () => {
}
}, []);
const quitarDeListaEspera = async (entry: ListaEspera) => {
if (!window.confirm(`¿Quitar a ${entry.patient} de la lista de espera?`)) return;
// Toggle de sala en la lista de espera: Consultoría <-> Cabina
const cambiarSala = async (entry: ListaEspera, sala: string) => {
try {
await odooApi.updateListaEspera(entry.id, { sala });
const etiqueta = sala === 'consultoria' ? 'Consultoría' : sala === 'cabina' ? 'Cabina' : 'Sin sala';
toast.success(`${entry.patient}: ${etiqueta}`);
await recargarListaEspera();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo cambiar la sala');
}
};
const quitarDeListaEspera = async (entry: ListaEspera) => { if (!window.confirm(`¿Quitar a ${entry.patient} de la lista de espera?`)) return;
try {
await odooApi.updateListaEspera(entry.id, { state: 'descartado' });
toast.success('Paciente descartado de la lista de espera');
@@ -2032,6 +2044,7 @@ const Agenda: FC = () => {
loading={loading}
loadingAgendaId={listaAgendando}
puedeGestionar={puedeAgendar}
onChangeSala={(e, sala) => void cambiarSala(e, sala)}
onAgendar={agendarDesdeLista}
onQuitar={(e) => void quitarDeListaEspera(e)}
onAdd={() => { setListaPrefill({ doctorId: doctorFilter || undefined }); setListaModalOpen(true); }}

View File

@@ -26,6 +26,9 @@ const WacrmMessages: FC = () => {
const [loadingOlder, setLoadingOlder] = useState(false);
const threadRef = useRef<HTMLDivElement | null>(null);
const convIdRef = useRef<string | null>(null);
// ¿El usuario está viendo el final del hilo? Si subió a leer mensajes
// antiguos, el auto-refresco NO debe regresarlo abajo.
const cercaDelFondoRef = useRef(true);
const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
@@ -109,6 +112,14 @@ const WacrmMessages: FC = () => {
});
};
// Registra si el lector está al final del hilo (tolerancia de 150 px).
// Es lo que decide si el auto-refresco puede hacer scroll o no.
const handleThreadScroll = () => {
const el = threadRef.current;
if (!el) return;
cercaDelFondoRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 150;
};
// Historial de la conversación, leído del origen (Supabase) vía el proxy,
// paginado: página 1 = últimos 50; con "Cargar mensajes anteriores" se
// anteponen páginas más antiguas conservando la posición del scroll.
@@ -118,6 +129,8 @@ const WacrmMessages: FC = () => {
const el = threadRef.current;
const prevHeight = keepScroll && el ? el.scrollHeight : 0;
const prevTop = keepScroll && el ? el.scrollTop : 0;
// ¿Es la primera carga de ESTA conversación? (cambio de conversación)
const esCambio = convIdRef.current !== conversationId;
const res = await odooApi.getWacrmConversationMessages(conversationId, page, 50);
if (res.status === 'success') {
convIdRef.current = conversationId;
@@ -129,7 +142,11 @@ const WacrmMessages: FC = () => {
const minTs = res.messages.length ? res.messages[0].created_at : '';
const older = messages.filter((m) => !known.has(m.external_id) && (m.created_at ?? '') < (minTs ?? ''));
setMessages([...older, ...res.messages]);
if (convIdRef.current === conversationId) scrollThreadToBottom();
// Solo baja al final si cambió de conversación o el usuario ya estaba
// abajo; si estaba leyendo mensajes anteriores, se respeta su posición.
if ((esCambio || cercaDelFondoRef.current) && convIdRef.current === conversationId) {
scrollThreadToBottom();
}
} else {
const prev = messages;
const known = new Set(prev.map((m) => m.external_id));
@@ -209,6 +226,7 @@ const WacrmMessages: FC = () => {
const found = conversations.find((c) => c.external_id === convParam);
if (found) {
userSelectedRef.current = true;
cercaDelFondoRef.current = true;
setSelected(found);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -285,6 +303,8 @@ const WacrmMessages: FC = () => {
const handleSelect = useCallback((c: WacrmConversation) => {
userSelectedRef.current = true;
// Conversación nueva: arranca viendo el final (permite el auto-scroll)
cercaDelFondoRef.current = true;
setSelected(c);
setDetalleTab('mensajes');
}, []);
@@ -312,6 +332,8 @@ const WacrmMessages: FC = () => {
});
toast.success('Mensaje enviado');
setReplyText('');
// Tu propio mensaje siempre se ve: baja al final aunque estuvieras arriba
cercaDelFondoRef.current = true;
await loadMessages(selected.external_id);
await loadConversations();
} catch (err) {
@@ -636,7 +658,7 @@ const WacrmMessages: FC = () => {
/>
) : (
<>
<div ref={threadRef} className="flex-1 overflow-y-auto p-4 space-y-3">
<div ref={threadRef} onScroll={handleThreadScroll} className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length > 0 && msgHasMore && (
<div className="text-center">
<Button