Agenda: cierre rápido de agendas por médico, lista de espera, duraciones 30/45/60, checklist de estados con bitácora y auto-refresh 30s
- BloqueosModal: alta multi-médico, rango repetible por día de la semana, motivos predefinidos, edición/borrado y cierre rápido desde la cabecera de cada columna (todo el día / resto del día / rango). - skeen.lista.espera + panel y modal en la agenda: prioridad, franja, rango de fechas, agendar precargado, avisar por WhatsApp y aviso cuando se libera un hueco compatible. - duration en skeen.cita (0 = la del servicio): la cita ocupa sus bloques de 15 min reales en las 4 vistas, con celdas de continuación. - Checklist de estados (Agendada → Confirmada → En cabina → En consulta → Completada) con avance/retroceso, cancelación y no-show con confirmación, y bitácora de quién/cuándo en skeen.cita.state.log. - Etiquetas del sistema legacy alineadas (Canceló / Lista de espera / En cabina) en badges de agenda y dashboard. - Auto refresco silencioso cada 30 s (pausable, se pospone con modales, escritura o drag) en citas + bloqueos + lista de espera en un solo request.
This commit is contained in:
180
frontend/src/lib/autoRefresh.tsx
Normal file
180
frontend/src/lib/autoRefresh.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Pause, Play, RefreshCw } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Auto refresco por polling inteligente (cada 30 s).
|
||||
*
|
||||
* Decisión de diseño: se usa polling, no SSE.
|
||||
* - El API es Odoo + WSGI con hilos; una conexión SSE por usuario mantendría
|
||||
* un worker ocupado y el proxy (nginx) corta las respuestas sin tráfico. El
|
||||
* bus nativo de Odoo (longpolling) tampoco está montado para el frontend.
|
||||
* - El polling aquí es de un solo request por refresco (la agenda trae citas y
|
||||
* bloqueos en la misma llamada; la lista de espera en otra) y solo se dispara
|
||||
* con la pestaña visible.
|
||||
* - Se pospone si hay un modal abierto, si el usuario está escribiendo o
|
||||
* arrastrando, y no toca el estado de carga: el refresco es "silencioso",
|
||||
* por lo que no parpadea ni pierde scroll, filtros o vista activa.
|
||||
*/
|
||||
export interface AutoRefreshOptions {
|
||||
/** Acción de recarga (debe ser silenciosa: sin loaders ni resets) */
|
||||
onRefresh: () => void | Promise<void>;
|
||||
/** Si devuelve true, este ciclo se pospone (modal, drag, escritura...) */
|
||||
blocked?: () => boolean;
|
||||
/** Intervalo en ms (por defecto 30 s) */
|
||||
intervalMs?: number;
|
||||
/** Permite desactivar el ciclo (p. ej. una vista sin datos en vivo) */
|
||||
enabled?: boolean;
|
||||
/** Clave de localStorage para recordar la pausa entre navegaciones */
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export interface AutoRefreshState {
|
||||
/** Momento del último refresco completado */
|
||||
lastUpdated: Date;
|
||||
/** Segundos transcurridos desde el último refresco */
|
||||
secondsAgo: number;
|
||||
paused: boolean;
|
||||
setPaused: (value: boolean) => void;
|
||||
/** Fuerza un refresco inmediato (ignora la pausa, respeta el bloqueo) */
|
||||
refreshNow: () => Promise<void>;
|
||||
/** Marca "actualizado" cuando la vista recarga por su cuenta */
|
||||
markUpdated: () => void;
|
||||
refreshing: boolean;
|
||||
}
|
||||
|
||||
export function useAutoRefresh({
|
||||
onRefresh,
|
||||
blocked,
|
||||
intervalMs = 30000,
|
||||
enabled = true,
|
||||
storageKey,
|
||||
}: AutoRefreshOptions): AutoRefreshState {
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(() => new Date());
|
||||
const [secondsAgo, setSecondsAgo] = useState(0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [paused, setPausedState] = useState<boolean>(() => {
|
||||
if (!storageKey) return false;
|
||||
try {
|
||||
return localStorage.getItem(storageKey) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Callbacks en refs: el intervalo no se re-crea en cada render ni reinicia
|
||||
// el contador cada vez que la vista cambia de filtro.
|
||||
const onRefreshRef = useRef(onRefresh);
|
||||
onRefreshRef.current = onRefresh;
|
||||
const blockedRef = useRef(blocked);
|
||||
blockedRef.current = blocked;
|
||||
const pausedRef = useRef(paused);
|
||||
pausedRef.current = paused;
|
||||
const inFlight = useRef(false);
|
||||
|
||||
const setPaused = useCallback((value: boolean) => {
|
||||
setPausedState(value);
|
||||
if (!storageKey) return;
|
||||
try {
|
||||
localStorage.setItem(storageKey, value ? '1' : '0');
|
||||
} catch {
|
||||
// localStorage no disponible: la pausa solo dura la sesión
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
const runRefresh = useCallback(async (force = false) => {
|
||||
if (inFlight.current) return;
|
||||
if (!force && pausedRef.current) return;
|
||||
if (document.hidden) return;
|
||||
if (blockedRef.current?.()) return;
|
||||
inFlight.current = true;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await onRefreshRef.current();
|
||||
setLastUpdated(new Date());
|
||||
} catch {
|
||||
// Un refresco fallido no debe romper la vista: se reintenta al siguiente ciclo
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markUpdated = useCallback(() => {
|
||||
setLastUpdated(new Date());
|
||||
}, []);
|
||||
|
||||
const refreshNow = useCallback(async () => {
|
||||
await runRefresh(true);
|
||||
}, [runRefresh]);
|
||||
|
||||
// Ciclo de refresco
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const id = window.setInterval(() => { void runRefresh(); }, intervalMs);
|
||||
return () => window.clearInterval(id);
|
||||
}, [enabled, intervalMs, runRefresh]);
|
||||
|
||||
// Al volver a la pestaña, si el último refresco quedó viejo, se pone al día
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onVisible = () => {
|
||||
if (document.hidden) return;
|
||||
if (Date.now() - lastUpdated.getTime() >= intervalMs) void runRefresh();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => document.removeEventListener('visibilitychange', onVisible);
|
||||
}, [enabled, intervalMs, lastUpdated, runRefresh]);
|
||||
|
||||
// Etiqueta "hace Xs" (se recalcula cada 5 s para no re-renderizar de más)
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setSecondsAgo(Math.max(0, Math.round((Date.now() - lastUpdated.getTime()) / 1000)));
|
||||
}, 5000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [lastUpdated]);
|
||||
|
||||
return { lastUpdated, secondsAgo, paused, setPaused, refreshNow, markUpdated, refreshing };
|
||||
}
|
||||
|
||||
interface BadgeProps {
|
||||
state: AutoRefreshState;
|
||||
/** Texto del tooltip que explica qué se refresca */
|
||||
hint?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Indicador "Actualizado hace Xs" con pausa/reanudar y refresco manual. */
|
||||
export function AutoRefreshBadge({ state, hint, className = '' }: BadgeProps) {
|
||||
const { secondsAgo, paused, setPaused, refreshNow, refreshing } = state;
|
||||
const texto = secondsAgo < 5
|
||||
? 'Actualizado ahora'
|
||||
: `Actualizado hace ${secondsAgo < 60 ? `${secondsAgo}s` : `${Math.floor(secondsAgo / 60)} min`}`;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs text-theme-muted ${className}`}>
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${paused ? 'bg-theme-border-strong' : 'bg-emerald-500'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span title={hint || 'La agenda se actualiza sola cada 30 segundos'}>{texto}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshNow()}
|
||||
disabled={refreshing}
|
||||
className="inline-flex items-center rounded-full border border-theme-border p-1 hover:border-theme-border-strong disabled:opacity-50"
|
||||
title="Actualizar ahora"
|
||||
aria-label="Actualizar ahora"
|
||||
>
|
||||
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaused(!paused)}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-theme-border px-2 py-0.5 hover:border-theme-border-strong"
|
||||
title={paused ? 'Reanudar la actualización automática' : 'Pausar la actualización automática'}
|
||||
>
|
||||
{paused ? <Play size={10} /> : <Pause size={10} />}
|
||||
{paused ? 'Reanudar' : 'Pausar'}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user