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:
Kimi
2026-09-24 15:46:38 +00:00
parent e32a79084a
commit d0eac00a40
19 changed files with 2964 additions and 328 deletions

View 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>
);
}