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
+196
View File
@@ -0,0 +1,196 @@
import {
useState,
useRef,
useCallback,
useEffect,
forwardRef,
useImperativeHandle,
} from 'react';
// ─────────────────────────────────────────────────────────────
// Imperative handle
// ─────────────────────────────────────────────────────────────
export interface TimerHandle {
start: () => void;
stop: () => void;
getElapsed: () => number; // returns elapsed seconds
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
function formatMMSS(totalSeconds: number): string {
const m = Math.floor(totalSeconds / 60);
const s = Math.floor(totalSeconds % 60);
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function storageKey(caseId: string | number): string {
return `timer_case_${caseId}`;
}
interface StoredTimer {
startTimestamp: number; // Date.now() when started
accumulated: number; // seconds accumulated before last start
}
function readStorage(caseId: string | number): StoredTimer | null {
try {
const raw = localStorage.getItem(storageKey(caseId));
if (!raw) return null;
return JSON.parse(raw) as StoredTimer;
} catch {
return null;
}
}
function writeStorage(caseId: string | number, data: StoredTimer): void {
try {
localStorage.setItem(storageKey(caseId), JSON.stringify(data));
} catch {
// localStorage unavailable
}
}
export function clearTimerStorage(caseId: string | number): void {
try {
localStorage.removeItem(storageKey(caseId));
} catch {
// ignore
}
}
// ─────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────
interface TimerProps {
caseId: string | number;
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
const Timer = forwardRef<TimerHandle, TimerProps>(({ caseId }, ref) => {
const [displaySeconds, setDisplaySeconds] = useState<number>(0);
// Mutable refs to avoid re-renders on tick
const isRunningRef = useRef(false);
const accumulatedRef = useRef(0);
const startTimestampRef = useRef<number | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// ── Restore from localStorage on mount ─────────────────────
useEffect(() => {
const stored = readStorage(caseId);
if (stored) {
accumulatedRef.current = stored.accumulated;
// If the timer was running when the page closed, treat startTimestamp
// as the new start point but keep the accumulated time.
if (stored.startTimestamp > 0) {
const elapsedSinceStore =
Math.floor((Date.now() - stored.startTimestamp) / 1000);
const total = stored.accumulated + elapsedSinceStore;
accumulatedRef.current = total;
setDisplaySeconds(total);
// Don't auto-start — the parent must call start() explicitly
} else {
setDisplaySeconds(stored.accumulated);
}
}
}, [caseId]);
// ── Cleanup on unmount ────────────────────────────────────
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, []);
// ── Imperative API ────────────────────────────────────────
const start = useCallback(() => {
if (isRunningRef.current) return; // already running
isRunningRef.current = true;
startTimestampRef.current = Date.now();
// Persist: store startTimestamp + accumulated so far
writeStorage(caseId, {
startTimestamp: startTimestampRef.current,
accumulated: accumulatedRef.current,
});
intervalRef.current = setInterval(() => {
if (startTimestampRef.current === null) return;
const elapsed = Math.floor(
(Date.now() - startTimestampRef.current) / 1000,
);
const total = accumulatedRef.current + elapsed;
setDisplaySeconds(total);
}, 1000);
}, [caseId]);
const stop = useCallback(() => {
if (!isRunningRef.current) return;
isRunningRef.current = false;
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// Finalize accumulated time
if (startTimestampRef.current !== null) {
const elapsed = Math.floor(
(Date.now() - startTimestampRef.current) / 1000,
);
accumulatedRef.current += elapsed;
startTimestampRef.current = null;
}
// Persist: accumulated with no running timer
writeStorage(caseId, {
startTimestamp: 0,
accumulated: accumulatedRef.current,
});
setDisplaySeconds(accumulatedRef.current);
}, [caseId]);
const getElapsed = useCallback((): number => {
if (isRunningRef.current && startTimestampRef.current !== null) {
const elapsed = Math.floor(
(Date.now() - startTimestampRef.current) / 1000,
);
return accumulatedRef.current + elapsed;
}
return accumulatedRef.current;
}, []);
useImperativeHandle(ref, () => ({ start, stop, getElapsed }), [
start,
stop,
getElapsed,
]);
// ── Render ────────────────────────────────────────────────
return (
<span className="font-mono text-xl font-bold text-text-primary tabular-nums">
{formatMMSS(displaySeconds)}
</span>
);
});
Timer.displayName = 'Timer';
export default Timer;