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

@@ -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<PanelProps> = ({
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 (
<div className="mb-4 sm:mb-5 rounded-2xl border-2 border-violet-300 bg-violet-50/50 p-3">
<div className="flex flex-wrap items-center gap-2 mb-2">
@@ -57,6 +70,29 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
</span>
<span className="text-theme-muted normal-case font-normal">{abierto ? '· ocultar' : '· ver'}</span>
</button>
{/* Toggle de sala: Consultoría / Cabina (filtra y muestra la etiqueta) */}
<div className="flex items-center gap-1 rounded-full border border-violet-300 bg-theme-surface p-0.5">
{([{ key: 'todas', label: 'Todas' }, ...SALAS] as const).map((s) => (
<button
key={s.key}
type="button"
onClick={() => setSalaFiltro(s.key as 'todas' | 'consultoria' | 'cabina')}
className={`px-2.5 py-1 text-[11px] font-medium rounded-full transition ${
salaFiltro === s.key
? 'bg-violet-600 text-white'
: 'text-theme-heading hover:bg-violet-100'
}`}
title={s.key === 'todas' ? 'Todas las salas' : `Pacientes esperando en ${s.label}`}
>
{s.label}
{s.key !== 'todas' && (
<span className={`ml-1 text-[10px] ${salaFiltro === s.key ? 'text-white/80' : 'text-theme-muted'}`}>
{cuenta(s.key)}
</span>
)}
</button>
))}
</div>
<div className="ml-auto flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={onRefresh} title="Recargar la lista de espera">
Actualizar
@@ -68,17 +104,22 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
</div>
</div>
{!abierto ? null : entries.length === 0 ? (
{!abierto ? null : visibles.length === 0 ? (
<p className="text-xs text-theme-muted">
{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}.`}
</p>
) : (
<div className="flex flex-wrap gap-2">
{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 (
<div
key={e.id}
@@ -94,6 +135,23 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
</div>
<Badge variant={prioridadVariant(e.priority)}>{e.priority_label || e.priority}</Badge>
</div>
{/* Etiqueta de sala: click para alternar Consultoría <-> Cabina */}
<div className="mt-1.5">
<button
type="button"
disabled={!puedeGestionar || !onChangeSala}
onClick={() => onChangeSala?.(e, salaSiguiente(e.sala || ''))}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold transition ${
salaActual ? salaActual.chip : 'bg-theme-bg text-theme-muted border-theme-border'
} ${puedeGestionar ? 'hover:brightness-95 cursor-pointer' : 'cursor-default'}`}
title={puedeGestionar
? `Sala: ${salaActual?.label || 'Sin asignar'} — click para pasar a ${SALAS.find((s) => s.key === salaSiguiente(e.sala || ''))?.label}`
: `Sala: ${salaActual?.label || 'Sin asignar'}`}
>
{salaActual?.label || 'Sin sala'}
{puedeGestionar && <Repeat size={10} />}
</button>
</div>
<p className="mt-1 text-[11px] text-theme-muted">
{e.date_from || e.date_to
? `Desea: ${e.date_from || '...'} → ${e.date_to || '...'}`
@@ -172,6 +230,7 @@ export const ListaEsperaModal: FC<ModalProps> = ({
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<ModalProps> = ({
setServiceId(defaultServiceId || '');
setPriority('normal');
setFranja('');
setSala('');
setDateFrom('');
setDateTo('');
setNotes('');
@@ -207,6 +267,7 @@ export const ListaEsperaModal: FC<ModalProps> = ({
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<ModalProps> = ({
onChange={(e) => setFranja(e.target.value)}
/>
</div>
<Select
label="Sala de espera"
options={[
{ value: '', label: 'Sin asignar' },
{ value: 'consultoria', label: 'Consultoría' },
{ value: 'cabina', label: 'Cabina' },
]}
value={sala}
onChange={(e) => setSala(e.target.value)}
/>
<div className="grid grid-cols-2 gap-4">
<Input label="Fecha desde" type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} />
<Input label="Fecha hasta" type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} />

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

View File

@@ -122,6 +122,9 @@ export interface ListaEspera {
priority: 'alta' | 'normal' | 'baja';
priority_label: string;
franja: '' | 'manana' | 'tarde';
// Dónde espera: consultoría (médico) o cabina (tratamiento)
sala?: '' | 'consultoria' | 'cabina';
sala_label?: string;
date_from: string | null;
date_to: string | null;
notes: string;
@@ -1514,6 +1517,7 @@ export const odooApi = {
async createListaEspera(payload: {
partner_id: number; doctor_id?: number | null; service_id?: number | null;
priority?: string; franja?: string; date_from?: string; date_to?: string; notes?: string;
sala?: string;
}): Promise<{ status: string; registro: ListaEspera }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/lista_espera`, payload);
return data;
@@ -1522,7 +1526,7 @@ export const odooApi = {
async updateListaEspera(id: number, payload: Partial<{
state: 'esperando' | 'agendado' | 'descartado'; cita_id: number; priority: string;
franja: string; date_from: string; date_to: string; notes: string; doctor_id: number;
service_id: number;
service_id: number; sala: string;
}>): Promise<{ status: string; registro: ListaEspera }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/lista_espera/${id}`, payload);
return data;

View File

@@ -45,6 +45,14 @@ class SkeenListaEspera(models.Model):
('tarde', 'Tarde'),
], string='Preferencia', default='')
# Dónde espera el paciente: consultoría (consulta con el médico) o cabina
# (tratamiento/valoración). Recepción lo mueve con un click en la etiqueta.
sala = fields.Selection([
('', 'Sin asignar'),
('consultoria', 'Consultoría'),
('cabina', 'Cabina'),
], string='Sala de espera', default='', index=True)
date_from = fields.Date(string='Desde')
date_to = fields.Date(string='Hasta')
notes = fields.Text(string='Nota')

View File

@@ -1362,6 +1362,8 @@ class SkeenFrontendController(http.Controller):
'priority': r.priority,
'priority_label': dict(r._fields['priority'].selection).get(r.priority, ''),
'franja': r.franja or '',
'sala': r.sala or '',
'sala_label': dict(r._fields['sala'].selection).get(r.sala, '') if r.sala else '',
'date_from': r.date_from.strftime('%Y-%m-%d') if r.date_from else None,
'date_to': r.date_to.strftime('%Y-%m-%d') if r.date_to else None,
'notes': r.notes or '',
@@ -1446,6 +1448,7 @@ class SkeenFrontendController(http.Controller):
'servicio_id': int(data['service_id'] or data['servicio_id']) if (data.get('service_id') or data.get('servicio_id')) else False,
'priority': data.get('priority') or 'normal',
'franja': data.get('franja') or '',
'sala': data.get('sala') if data.get('sala') in ('consultoria', 'cabina') else False,
'date_from': data.get('date_from') or False,
'date_to': data.get('date_to') or False,
'notes': data.get('notes') or '',
@@ -1485,6 +1488,12 @@ class SkeenFrontendController(http.Controller):
):
if key in data:
vals[field] = data[key] or False
if 'sala' in data:
# Toggle de sala desde la etiqueta: '' vuelve a "Sin asignar"
vals['sala'] = data['sala'] if data['sala'] in ('consultoria', 'cabina') else False
if vals['sala']:
record._log('Sala: %s' % dict(record._fields['sala'].selection).get(vals['sala'], vals['sala']),
user=request.frontend_user.login)
if data.get('cita_id'):
vals['cita_id'] = int(data['cita_id'])
new_state = data.get('state')