feat: migrar dashboard a React 19 + TypeScript + Vite + Tailwind v4

- Módulo HITL (/cases): 6 patrones de formularios dinámicos para 53 tipos de caso con validación Zod
- Módulo Monitor (/monitor): streaming token-a-token en tiempo real, auto-scroll y notas internas vía WebSocket
- Arquitectura híbrida: REST (canal autoritativo) + WebSocket (difusión/streaming)
- MSW para desarrollo sin backend, hooks de notificaciones/sonido/título preservados
- Backend legacy movido a legacy/, archivos residuales eliminados de raíz
This commit is contained in:
2026-07-23 18:27:03 -05:00
parent 69cc215954
commit 8f044567c0
78 changed files with 11818 additions and 1563 deletions
View File
+3
View File
@@ -0,0 +1,3 @@
export { useNotification } from './useNotification';
export { useSound } from './useSound';
export { useTitleFlash } from './useTitleFlash';
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useCallback, useRef } from 'react';
// ─────────────────────────────────────────────────────────────
// useNotification
// ─────────────────────────────────────────────────────────────
/**
* Hook for HTML5 desktop notifications.
*
* - Requests permission on mount if not already granted.
* - Exposes `notify(title, body, onClick?)` to fire a notification.
* - Clicking the notification executes the optional `onClick` callback,
* focuses the window, and auto-closes the notification.
*/
export function useNotification() {
const permissionRef = useRef<NotificationPermission | null>(null);
// ── Request permission on mount ──────────────────────────────
useEffect(() => {
if (!('Notification' in window)) {
console.warn(
'[useNotification] This browser does not support desktop notifications',
);
return;
}
if (Notification.permission === 'granted') {
permissionRef.current = 'granted';
return;
}
if (Notification.permission !== 'denied') {
Notification.requestPermission()
.then((permission) => {
permissionRef.current = permission;
})
.catch((err) => {
console.error('[useNotification] Permission request failed:', err);
});
}
}, []);
// ── Notify function ─────────────────────────────────────────
const notify = useCallback(
(title: string, body: string, onClick?: () => void): void => {
if (!('Notification' in window)) {
console.warn('[useNotification] Notifications are not supported');
return;
}
if (Notification.permission !== 'granted') {
console.warn(
'[useNotification] Notification permission is not granted',
);
return;
}
try {
const notification = new Notification(title, {
body,
icon: '/favicon.ico',
});
// Attach click handler
if (onClick) {
notification.onclick = (event: Event) => {
event.preventDefault();
window.focus();
notification.close();
onClick();
};
}
// Auto-close after 6 seconds to avoid cluttering the notification tray
setTimeout(() => {
notification.close();
}, 6_000);
} catch (err) {
console.error('[useNotification] Failed to create notification:', err);
}
},
[],
);
return { notify };
}
+114
View File
@@ -0,0 +1,114 @@
import { useRef, useEffect, useCallback } from 'react';
// ─────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────
/** C5 frequency in Hz (523.25) */
const C5 = 523.25;
/** E5 frequency in Hz (659.25) */
const E5 = 659.25;
/** Duration of each tone in seconds (120 ms) */
const TONE_DURATION = 0.12;
/** Overall gain / volume (0.08 = 8%) */
const VOLUME = 0.08;
/** Exponential fade-out duration in seconds (450 ms) */
const FADE_DURATION = 0.45;
// ─────────────────────────────────────────────────────────────
// useSound
// ─────────────────────────────────────────────────────────────
/**
* Hook for playing a notification chime using the Web Audio API.
*
* - Initialises an `AudioContext` lazily on the first user gesture
* (click or keydown) to comply with browser autoplay policies.
* - Exposes `playNotificationSound()` which plays a twotone chime:
* C5 (523.25 Hz) for 120 ms → E5 (659.25 Hz) for 120 ms,
* with a shared exponential fadeout envelope (0.08 → 0.001 over 450 ms).
* - If the `AudioContext` is suspended, a warning is logged and the
* call is silently ignored.
*/
export function useSound() {
const audioCtxRef = useRef<AudioContext | null>(null);
const initializedRef = useRef(false);
// ── Lazy initialisation on first user gesture ──────────────
useEffect(() => {
const initAudio = () => {
if (initializedRef.current) return;
try {
audioCtxRef.current = new AudioContext();
initializedRef.current = true;
} catch (err) {
console.warn('[useSound] Web Audio API is not available:', err);
}
// Remove both listeners after the first gesture
window.removeEventListener('click', initAudio);
window.removeEventListener('keydown', initAudio);
};
// Attach listeners for first gesture
window.addEventListener('click', initAudio, { once: true });
window.addEventListener('keydown', initAudio, { once: true });
return () => {
window.removeEventListener('click', initAudio);
window.removeEventListener('keydown', initAudio);
// Close AudioContext on unmount
const ctx = audioCtxRef.current;
if (ctx) {
ctx.close().catch(() => {});
audioCtxRef.current = null;
}
initializedRef.current = false;
};
}, []);
// ── Play the twotone chime ────────────────────────────────
const playNotificationSound = useCallback((): void => {
const ctx = audioCtxRef.current;
if (!ctx) {
console.warn('[useSound] AudioContext has not been initialised yet');
return;
}
if (ctx.state === 'suspended') {
console.warn('[useSound] AudioContext is suspended — cannot play sound');
return;
}
const now = ctx.currentTime;
// Single shared gain node for both oscillators → unified fadeout
const gain = ctx.createGain();
gain.gain.setValueAtTime(VOLUME, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + FADE_DURATION);
gain.connect(ctx.destination);
// Helper: create and schedule a single sinewave tone
const playTone = (frequency: number, startTime: number): void => {
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(frequency, startTime);
osc.connect(gain);
osc.start(startTime);
osc.stop(startTime + TONE_DURATION);
};
// Schedule the two tones
playTone(C5, now); // C5 starts immediately
playTone(E5, now + TONE_DURATION); // E5 starts after C5 ends
}, []);
return { playNotificationSound };
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useRef, useCallback } from 'react';
// ─────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────
const DEFAULT_TITLE = 'Claro Cases Dashboard';
const FLASH_INTERVAL_MS = 1_000;
// ─────────────────────────────────────────────────────────────
// useTitleFlash
// ─────────────────────────────────────────────────────────────
/**
* Hook for flashing the browser tab title when new unread cases arrive
* and the tab is not focused.
*
* - Maintains an internal counter of unread case notifications.
* - When `triggerNotification()` is called and the tab is **hidden**
* (`document.visibilityState === 'hidden'` or window lacks focus),
* the title alternates every 1 second between:
* `"(🔔 N) ¡Nuevo Caso!"` ↔ `"Claro Cases Dashboard"`
* - When the tab regains focus (visibilitychange → visible, or window focus),
* the interval is cleared, the title is restored to `"Claro Cases Dashboard"`,
* and the unread counter is reset to zero.
* - All DOM event listeners are properly cleaned up on unmount.
*/
export function useTitleFlash() {
const unreadCountRef = useRef(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const originalTitleRef = useRef(DEFAULT_TITLE);
// ── Start the titleflashing interval ─────────────────────
const startFlashing = useCallback(() => {
// Don't start a second interval if one is already active
if (intervalRef.current !== null) return;
const count = unreadCountRef.current;
intervalRef.current = setInterval(() => {
// Toggle between two title states
document.title =
document.title === DEFAULT_TITLE
? `(🔔 ${count}) ¡Nuevo Caso!`
: DEFAULT_TITLE;
}, FLASH_INTERVAL_MS);
}, []);
// ── Stop the titleflashing interval and restore the title ──
const stopFlashing = useCallback(() => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
document.title = DEFAULT_TITLE;
}, []);
// ── Trigger a new unread notification ──────────────────────
const triggerNotification = useCallback(() => {
unreadCountRef.current += 1;
// If the tab is hidden or blurred, start flashing immediately
const isHidden =
document.visibilityState === 'hidden' || !document.hasFocus();
if (isHidden) {
startFlashing();
}
}, [startFlashing]);
// ── Listen for visibility / focus changes ──────────────────
useEffect(() => {
// Save the original title on mount (in case it was changed externally)
originalTitleRef.current = DEFAULT_TITLE;
document.title = DEFAULT_TITLE;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
stopFlashing();
unreadCountRef.current = 0;
} else if (unreadCountRef.current > 0) {
// Tab became hidden with pending notifications → start flashing
startFlashing();
}
};
const handleFocus = () => {
stopFlashing();
unreadCountRef.current = 0;
};
const handleBlur = () => {
// If there are unread notifications, start flashing on blur
if (unreadCountRef.current > 0) {
startFlashing();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('focus', handleFocus);
window.addEventListener('blur', handleBlur);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleFocus);
window.removeEventListener('blur', handleBlur);
// Clean up interval and restore title when the component unmounts
stopFlashing();
unreadCountRef.current = 0;
};
}, [startFlashing, stopFlashing]);
return { triggerNotification };
}