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:
@@ -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)} />
|
||||
|
||||
@@ -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); }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user