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 type { FC } from 'react';
|
||||||
import { useEffect, useState } 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 { Badge, Button, Input, Modal, Select, TextArea, toast } from '../ui';
|
||||||
import PatientCombobox from '../PatientCombobox';
|
import PatientCombobox from '../PatientCombobox';
|
||||||
import { odooApi, type Doctor, type ListaEspera, type Patient, type Service } from '../../services/odoo';
|
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)}`;
|
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 {
|
interface PanelProps {
|
||||||
entries: ListaEspera[];
|
entries: ListaEspera[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -31,6 +38,8 @@ interface PanelProps {
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
/** Permiso 'Agendar cita': sin él el panel es solo consulta */
|
/** Permiso 'Agendar cita': sin él el panel es solo consulta */
|
||||||
puedeGestionar?: boolean;
|
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.
|
* prioridad y antigüedad, con acciones de agendar / avisar / quitar.
|
||||||
*/
|
*/
|
||||||
export const ListaEsperaPanel: FC<PanelProps> = ({
|
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);
|
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 (
|
return (
|
||||||
<div className="mb-4 sm:mb-5 rounded-2xl border-2 border-violet-300 bg-violet-50/50 p-3">
|
<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">
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||||
@@ -57,6 +70,29 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-theme-muted normal-case font-normal">{abierto ? '· ocultar' : '· ver'}</span>
|
<span className="text-theme-muted normal-case font-normal">{abierto ? '· ocultar' : '· ver'}</span>
|
||||||
</button>
|
</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">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<Button size="sm" variant="ghost" onClick={onRefresh} title="Recargar la lista de espera">
|
<Button size="sm" variant="ghost" onClick={onRefresh} title="Recargar la lista de espera">
|
||||||
Actualizar
|
Actualizar
|
||||||
@@ -68,17 +104,22 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!abierto ? null : entries.length === 0 ? (
|
{!abierto ? null : visibles.length === 0 ? (
|
||||||
<p className="text-xs text-theme-muted">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{entries.map((e) => {
|
{visibles.map((e) => {
|
||||||
const wa = whatsappLink(
|
const wa = whatsappLink(
|
||||||
e.phone,
|
e.phone,
|
||||||
`Hola ${e.patient}, se liberó un espacio en la clínica${e.doctor ? ` con ${e.doctor}` : ''}. ¿Te gustaría tomarlo?`,
|
`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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={e.id}
|
key={e.id}
|
||||||
@@ -94,6 +135,23 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<Badge variant={prioridadVariant(e.priority)}>{e.priority_label || e.priority}</Badge>
|
<Badge variant={prioridadVariant(e.priority)}>{e.priority_label || e.priority}</Badge>
|
||||||
</div>
|
</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">
|
<p className="mt-1 text-[11px] text-theme-muted">
|
||||||
{e.date_from || e.date_to
|
{e.date_from || e.date_to
|
||||||
? `Desea: ${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 [serviceId, setServiceId] = useState(defaultServiceId || '');
|
||||||
const [priority, setPriority] = useState('normal');
|
const [priority, setPriority] = useState('normal');
|
||||||
const [franja, setFranja] = useState('');
|
const [franja, setFranja] = useState('');
|
||||||
|
const [sala, setSala] = useState('');
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
const [dateTo, setDateTo] = useState('');
|
const [dateTo, setDateTo] = useState('');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
@@ -188,6 +247,7 @@ export const ListaEsperaModal: FC<ModalProps> = ({
|
|||||||
setServiceId(defaultServiceId || '');
|
setServiceId(defaultServiceId || '');
|
||||||
setPriority('normal');
|
setPriority('normal');
|
||||||
setFranja('');
|
setFranja('');
|
||||||
|
setSala('');
|
||||||
setDateFrom('');
|
setDateFrom('');
|
||||||
setDateTo('');
|
setDateTo('');
|
||||||
setNotes('');
|
setNotes('');
|
||||||
@@ -207,6 +267,7 @@ export const ListaEsperaModal: FC<ModalProps> = ({
|
|||||||
service_id: serviceId ? parseInt(serviceId, 10) : null,
|
service_id: serviceId ? parseInt(serviceId, 10) : null,
|
||||||
priority,
|
priority,
|
||||||
franja,
|
franja,
|
||||||
|
sala,
|
||||||
date_from: dateFrom || undefined,
|
date_from: dateFrom || undefined,
|
||||||
date_to: dateTo || undefined,
|
date_to: dateTo || undefined,
|
||||||
notes,
|
notes,
|
||||||
@@ -284,6 +345,16 @@ export const ListaEsperaModal: FC<ModalProps> = ({
|
|||||||
onChange={(e) => setFranja(e.target.value)}
|
onChange={(e) => setFranja(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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 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)} />
|
<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) => {
|
// Toggle de sala en la lista de espera: Consultoría <-> Cabina
|
||||||
if (!window.confirm(`¿Quitar a ${entry.patient} de la lista de espera?`)) return;
|
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 {
|
try {
|
||||||
await odooApi.updateListaEspera(entry.id, { state: 'descartado' });
|
await odooApi.updateListaEspera(entry.id, { state: 'descartado' });
|
||||||
toast.success('Paciente descartado de la lista de espera');
|
toast.success('Paciente descartado de la lista de espera');
|
||||||
@@ -2032,6 +2044,7 @@ const Agenda: FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
loadingAgendaId={listaAgendando}
|
loadingAgendaId={listaAgendando}
|
||||||
puedeGestionar={puedeAgendar}
|
puedeGestionar={puedeAgendar}
|
||||||
|
onChangeSala={(e, sala) => void cambiarSala(e, sala)}
|
||||||
onAgendar={agendarDesdeLista}
|
onAgendar={agendarDesdeLista}
|
||||||
onQuitar={(e) => void quitarDeListaEspera(e)}
|
onQuitar={(e) => void quitarDeListaEspera(e)}
|
||||||
onAdd={() => { setListaPrefill({ doctorId: doctorFilter || undefined }); setListaModalOpen(true); }}
|
onAdd={() => { setListaPrefill({ doctorId: doctorFilter || undefined }); setListaModalOpen(true); }}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ const WacrmMessages: FC = () => {
|
|||||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||||
const threadRef = useRef<HTMLDivElement | null>(null);
|
const threadRef = useRef<HTMLDivElement | null>(null);
|
||||||
const convIdRef = useRef<string | 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 [members, setMembers] = useState<Member[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [syncing, setSyncing] = useState(false);
|
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,
|
// 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
|
// paginado: página 1 = últimos 50; con "Cargar mensajes anteriores" se
|
||||||
// anteponen páginas más antiguas conservando la posición del scroll.
|
// anteponen páginas más antiguas conservando la posición del scroll.
|
||||||
@@ -118,6 +129,8 @@ const WacrmMessages: FC = () => {
|
|||||||
const el = threadRef.current;
|
const el = threadRef.current;
|
||||||
const prevHeight = keepScroll && el ? el.scrollHeight : 0;
|
const prevHeight = keepScroll && el ? el.scrollHeight : 0;
|
||||||
const prevTop = keepScroll && el ? el.scrollTop : 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);
|
const res = await odooApi.getWacrmConversationMessages(conversationId, page, 50);
|
||||||
if (res.status === 'success') {
|
if (res.status === 'success') {
|
||||||
convIdRef.current = conversationId;
|
convIdRef.current = conversationId;
|
||||||
@@ -129,7 +142,11 @@ const WacrmMessages: FC = () => {
|
|||||||
const minTs = res.messages.length ? res.messages[0].created_at : '';
|
const minTs = res.messages.length ? res.messages[0].created_at : '';
|
||||||
const older = messages.filter((m) => !known.has(m.external_id) && (m.created_at ?? '') < (minTs ?? ''));
|
const older = messages.filter((m) => !known.has(m.external_id) && (m.created_at ?? '') < (minTs ?? ''));
|
||||||
setMessages([...older, ...res.messages]);
|
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 {
|
} else {
|
||||||
const prev = messages;
|
const prev = messages;
|
||||||
const known = new Set(prev.map((m) => m.external_id));
|
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);
|
const found = conversations.find((c) => c.external_id === convParam);
|
||||||
if (found) {
|
if (found) {
|
||||||
userSelectedRef.current = true;
|
userSelectedRef.current = true;
|
||||||
|
cercaDelFondoRef.current = true;
|
||||||
setSelected(found);
|
setSelected(found);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -285,6 +303,8 @@ const WacrmMessages: FC = () => {
|
|||||||
|
|
||||||
const handleSelect = useCallback((c: WacrmConversation) => {
|
const handleSelect = useCallback((c: WacrmConversation) => {
|
||||||
userSelectedRef.current = true;
|
userSelectedRef.current = true;
|
||||||
|
// Conversación nueva: arranca viendo el final (permite el auto-scroll)
|
||||||
|
cercaDelFondoRef.current = true;
|
||||||
setSelected(c);
|
setSelected(c);
|
||||||
setDetalleTab('mensajes');
|
setDetalleTab('mensajes');
|
||||||
}, []);
|
}, []);
|
||||||
@@ -312,6 +332,8 @@ const WacrmMessages: FC = () => {
|
|||||||
});
|
});
|
||||||
toast.success('Mensaje enviado');
|
toast.success('Mensaje enviado');
|
||||||
setReplyText('');
|
setReplyText('');
|
||||||
|
// Tu propio mensaje siempre se ve: baja al final aunque estuvieras arriba
|
||||||
|
cercaDelFondoRef.current = true;
|
||||||
await loadMessages(selected.external_id);
|
await loadMessages(selected.external_id);
|
||||||
await loadConversations();
|
await loadConversations();
|
||||||
} catch (err) {
|
} 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 && (
|
{messages.length > 0 && msgHasMore && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ export interface ListaEspera {
|
|||||||
priority: 'alta' | 'normal' | 'baja';
|
priority: 'alta' | 'normal' | 'baja';
|
||||||
priority_label: string;
|
priority_label: string;
|
||||||
franja: '' | 'manana' | 'tarde';
|
franja: '' | 'manana' | 'tarde';
|
||||||
|
// Dónde espera: consultoría (médico) o cabina (tratamiento)
|
||||||
|
sala?: '' | 'consultoria' | 'cabina';
|
||||||
|
sala_label?: string;
|
||||||
date_from: string | null;
|
date_from: string | null;
|
||||||
date_to: string | null;
|
date_to: string | null;
|
||||||
notes: string;
|
notes: string;
|
||||||
@@ -1514,6 +1517,7 @@ export const odooApi = {
|
|||||||
async createListaEspera(payload: {
|
async createListaEspera(payload: {
|
||||||
partner_id: number; doctor_id?: number | null; service_id?: number | null;
|
partner_id: number; doctor_id?: number | null; service_id?: number | null;
|
||||||
priority?: string; franja?: string; date_from?: string; date_to?: string; notes?: string;
|
priority?: string; franja?: string; date_from?: string; date_to?: string; notes?: string;
|
||||||
|
sala?: string;
|
||||||
}): Promise<{ status: string; registro: ListaEspera }> {
|
}): Promise<{ status: string; registro: ListaEspera }> {
|
||||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/lista_espera`, payload);
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/lista_espera`, payload);
|
||||||
return data;
|
return data;
|
||||||
@@ -1522,7 +1526,7 @@ export const odooApi = {
|
|||||||
async updateListaEspera(id: number, payload: Partial<{
|
async updateListaEspera(id: number, payload: Partial<{
|
||||||
state: 'esperando' | 'agendado' | 'descartado'; cita_id: number; priority: string;
|
state: 'esperando' | 'agendado' | 'descartado'; cita_id: number; priority: string;
|
||||||
franja: string; date_from: string; date_to: string; notes: string; doctor_id: number;
|
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 }> {
|
}>): Promise<{ status: string; registro: ListaEspera }> {
|
||||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/lista_espera/${id}`, payload);
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/lista_espera/${id}`, payload);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ class SkeenListaEspera(models.Model):
|
|||||||
('tarde', 'Tarde'),
|
('tarde', 'Tarde'),
|
||||||
], string='Preferencia', default='')
|
], 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_from = fields.Date(string='Desde')
|
||||||
date_to = fields.Date(string='Hasta')
|
date_to = fields.Date(string='Hasta')
|
||||||
notes = fields.Text(string='Nota')
|
notes = fields.Text(string='Nota')
|
||||||
|
|||||||
@@ -1362,6 +1362,8 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'priority': r.priority,
|
'priority': r.priority,
|
||||||
'priority_label': dict(r._fields['priority'].selection).get(r.priority, ''),
|
'priority_label': dict(r._fields['priority'].selection).get(r.priority, ''),
|
||||||
'franja': r.franja or '',
|
'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_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,
|
'date_to': r.date_to.strftime('%Y-%m-%d') if r.date_to else None,
|
||||||
'notes': r.notes or '',
|
'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,
|
'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',
|
'priority': data.get('priority') or 'normal',
|
||||||
'franja': data.get('franja') or '',
|
'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_from': data.get('date_from') or False,
|
||||||
'date_to': data.get('date_to') or False,
|
'date_to': data.get('date_to') or False,
|
||||||
'notes': data.get('notes') or '',
|
'notes': data.get('notes') or '',
|
||||||
@@ -1485,6 +1488,12 @@ class SkeenFrontendController(http.Controller):
|
|||||||
):
|
):
|
||||||
if key in data:
|
if key in data:
|
||||||
vals[field] = data[key] or False
|
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'):
|
if data.get('cita_id'):
|
||||||
vals['cita_id'] = int(data['cita_id'])
|
vals['cita_id'] = int(data['cita_id'])
|
||||||
new_state = data.get('state')
|
new_state = data.get('state')
|
||||||
|
|||||||
Reference in New Issue
Block a user