From 2573562218b6d4536b7c1938eff7d73be1e0d905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Wed, 30 Sep 2026 10:33:52 +0000 Subject: [PATCH] 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) --- .../src/components/agenda/ListaEspera.tsx | 81 +++++++++++++++++-- frontend/src/pages/Agenda.tsx | 17 +++- frontend/src/pages/WacrmMessages.tsx | 26 +++++- frontend/src/services/odoo.ts | 6 +- .../skeen_citas/models/lista_espera.py | 8 ++ .../skeen_whatsapp/controllers/frontend.py | 9 +++ 6 files changed, 137 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/agenda/ListaEspera.tsx b/frontend/src/components/agenda/ListaEspera.tsx index be79979..0eecfaa 100644 --- a/frontend/src/components/agenda/ListaEspera.tsx +++ b/frontend/src/components/agenda/ListaEspera.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react'; import { useEffect, useState } from 'react'; -import { CalendarPlus, Clock3, MessageCircle, Plus, Trash2, Users } from 'lucide-react'; +import { CalendarPlus, Clock3, MessageCircle, Plus, Repeat, Trash2, Users } from 'lucide-react'; import { Badge, Button, Input, Modal, Select, TextArea, toast } from '../ui'; import PatientCombobox from '../PatientCombobox'; import { odooApi, type Doctor, type ListaEspera, type Patient, type Service } from '../../services/odoo'; @@ -21,6 +21,13 @@ export const whatsappLink = (phone: string, text: string) => { return `https://wa.me/${digits}?text=${encodeURIComponent(text)}`; }; +// Salas donde puede esperar el paciente (toggle del panel + etiqueta de la tarjeta) +export const SALAS: { key: '' | 'consultoria' | 'cabina'; label: string; chip: string }[] = [ + { key: 'consultoria', label: 'Consultoría', chip: 'bg-sky-100 text-sky-800 border-sky-300' }, + { key: 'cabina', label: 'Cabina', chip: 'bg-amber-100 text-amber-800 border-amber-300' }, +]; +const salaSiguiente = (sala: string) => (sala === 'consultoria' ? 'cabina' : 'consultoria'); + interface PanelProps { entries: ListaEspera[]; loading?: boolean; @@ -31,6 +38,8 @@ interface PanelProps { onRefresh: () => void; /** Permiso 'Agendar cita': sin él el panel es solo consulta */ puedeGestionar?: boolean; + /** Mueve al paciente de sala (toggle Consultoría <-> Cabina) */ + onChangeSala?: (entry: ListaEspera, sala: string) => void; } /** @@ -38,9 +47,13 @@ interface PanelProps { * prioridad y antigüedad, con acciones de agendar / avisar / quitar. */ export const ListaEsperaPanel: FC = ({ - entries, loading, loadingAgendaId, onAgendar, onQuitar, onAdd, onRefresh, puedeGestionar = true, + entries, loading, loadingAgendaId, onAgendar, onQuitar, onAdd, onRefresh, puedeGestionar = true, onChangeSala, }) => { const [abierto, setAbierto] = useState(true); + // Filtro por sala: el toggle de arriba decide qué pacientes se muestran. + const [salaFiltro, setSalaFiltro] = useState<'todas' | 'consultoria' | 'cabina'>('todas'); + const visibles = salaFiltro === 'todas' ? entries : entries.filter((e) => (e.sala || '') === salaFiltro); + const cuenta = (sala: string) => entries.filter((e) => (e.sala || '') === sala).length; return (
@@ -57,6 +70,29 @@ export const ListaEsperaPanel: FC = ({ {abierto ? '· ocultar' : '· ver'} + {/* Toggle de sala: Consultoría / Cabina (filtra y muestra la etiqueta) */} +
+ {([{ key: 'todas', label: 'Todas' }, ...SALAS] as const).map((s) => ( + + ))} +
- {!abierto ? null : entries.length === 0 ? ( + {!abierto ? null : visibles.length === 0 ? (

- {loading ? 'Cargando...' : 'Sin pacientes en espera. Cuando no haya hueco, agrégalos aquí para avisarles al liberarse una cita.'} + {loading + ? 'Cargando...' + : salaFiltro === 'todas' + ? 'Sin pacientes en espera. Cuando no haya hueco, agrégalos aquí para avisarles al liberarse una cita.' + : `Sin pacientes esperando en ${SALAS.find((s) => s.key === salaFiltro)?.label}.`}

) : (
- {entries.map((e) => { + {visibles.map((e) => { const wa = whatsappLink( e.phone, `Hola ${e.patient}, se liberó un espacio en la clínica${e.doctor ? ` con ${e.doctor}` : ''}. ¿Te gustaría tomarlo?`, ); + const salaActual = SALAS.find((s) => s.key === (e.sala || '')); return (
= ({
{e.priority_label || e.priority}
+ {/* Etiqueta de sala: click para alternar Consultoría <-> Cabina */} +
+ +

{e.date_from || e.date_to ? `Desea: ${e.date_from || '...'} → ${e.date_to || '...'}` @@ -172,6 +230,7 @@ export const ListaEsperaModal: FC = ({ const [serviceId, setServiceId] = useState(defaultServiceId || ''); const [priority, setPriority] = useState('normal'); const [franja, setFranja] = useState(''); + const [sala, setSala] = useState(''); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); const [notes, setNotes] = useState(''); @@ -188,6 +247,7 @@ export const ListaEsperaModal: FC = ({ setServiceId(defaultServiceId || ''); setPriority('normal'); setFranja(''); + setSala(''); setDateFrom(''); setDateTo(''); setNotes(''); @@ -207,6 +267,7 @@ export const ListaEsperaModal: FC = ({ service_id: serviceId ? parseInt(serviceId, 10) : null, priority, franja, + sala, date_from: dateFrom || undefined, date_to: dateTo || undefined, notes, @@ -284,6 +345,16 @@ export const ListaEsperaModal: FC = ({ onChange={(e) => setFranja(e.target.value)} />

+ setDateFrom(e.target.value)} /> setDateTo(e.target.value)} /> diff --git a/frontend/src/pages/Agenda.tsx b/frontend/src/pages/Agenda.tsx index 4c3d9e1..4160ab3 100644 --- a/frontend/src/pages/Agenda.tsx +++ b/frontend/src/pages/Agenda.tsx @@ -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); }} diff --git a/frontend/src/pages/WacrmMessages.tsx b/frontend/src/pages/WacrmMessages.tsx index 4c0c19d..ad65079 100644 --- a/frontend/src/pages/WacrmMessages.tsx +++ b/frontend/src/pages/WacrmMessages.tsx @@ -26,6 +26,9 @@ const WacrmMessages: FC = () => { const [loadingOlder, setLoadingOlder] = useState(false); const threadRef = useRef(null); const convIdRef = useRef(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([]); 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 = () => { /> ) : ( <> -
+
{messages.length > 0 && msgHasMore && (