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
+434
View File
@@ -0,0 +1,434 @@
import { create } from 'zustand';
import type { CaseRequest, Conversation, Message } from '@/types';
import { api, type CaseFilters } from '@/services/api';
// ─────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────
export type SidebarTab = 'all' | 'pending' | 'resolved';
export type WsStatus = 'connected' | 'disconnected' | 'reconnecting';
interface AppState {
// ── Cases slice ──────────────────────────────────────────
cases: CaseRequest[];
selectedCaseId: string | number | null;
totalCases: number;
fetchCases: (filters?: CaseFilters) => Promise<void>;
upsertCase: (c: CaseRequest) => void;
resolveCase: (
id: string | number,
data: { action: string; payload: Record<string, unknown>; note?: string },
) => Promise<void>;
// ── Conversations slice ──────────────────────────────────
conversations: Conversation[];
selectedConversationId: string | null;
fetchConversations: () => Promise<void>;
upsertConversation: (c: Conversation) => void;
addMessage: (convId: string, msg: Message) => void;
appendToken: (convId: string, msgId: string, token: string, index: number) => void;
completeStream: (convId: string, msgId: string, fullContent: string) => void;
setSelectedConversationId: (convId: string | null) => void;
removeConversation: (convId: string) => void;
// ── UI slice ─────────────────────────────────────────────
sidebarTab: SidebarTab;
searchQuery: string;
applicativeFilter: string | null;
isDarkMode: boolean;
wsStatus: WsStatus;
setSidebarTab: (tab: SidebarTab) => void;
setSearchQuery: (q: string) => void;
setApplicativeFilter: (app: string | null) => void;
toggleDarkMode: () => void;
setWsStatus: (status: WsStatus) => void;
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
/**
* Read initial dark mode from localStorage, defaulting to false.
*/
function readDarkMode(): boolean {
try {
const stored = localStorage.getItem('claro-cases:darkMode');
return stored === 'true';
} catch {
return false;
}
}
/**
* Persist dark mode preference to localStorage.
*/
function persistDarkMode(value: boolean): void {
try {
localStorage.setItem('claro-cases:darkMode', String(value));
} catch {
// localStorage may be unavailable (private browsing, quota, etc.)
}
}
// ─────────────────────────────────────────────────────────────
// Token Streaming Buffer (Regla 4 — 50ms throttling, 20 fps)
// ─────────────────────────────────────────────────────────────
interface PendingToken {
msgId: string;
token: string;
index: number;
}
interface ConversationBufferEntry {
pending: PendingToken[];
timer: ReturnType<typeof setTimeout> | null;
}
/**
* External buffer map — NOT stored in Zustand state to avoid
* triggering re-renders on every chunk. Each conversation gets
* its own entry with a pending queue and a 50ms flush timer.
*/
const conversationBuffers = new Map<string, ConversationBufferEntry>();
/**
* Flush all pending tokens for a given conversation into the store
* with a SINGLE `set()` call. Only updates the store if this
* conversation is the actively selected one (Regla 4: solo
* re-renderizar conversación seleccionada).
*/
function flushBuffer(
convId: string,
get: () => AppState,
set: (partial: AppState | ((state: AppState) => Partial<AppState>)) => void,
): void {
const entry = conversationBuffers.get(convId);
if (!entry) return;
// Clear the timer reference first
entry.timer = null;
// If the conversation no longer exists in the store, clean up the buffer
const currentState = get();
const convExists = currentState.conversations.some((c) => c.id === convId);
if (!convExists) {
conversationBuffers.delete(convId);
return;
}
// If nothing is pending, delete the entry and bail out
if (entry.pending.length === 0) {
conversationBuffers.delete(convId);
return;
}
// Only update the store for the selected conversation (Regla 4)
if (currentState.selectedConversationId !== convId) {
// Keep tokens in buffer — they'll be flushed when this conversation
// becomes selected, or cleared by completeStream.
return;
}
// Atomically take and clear the pending queue
const pendingToProcess = entry.pending;
entry.pending = [];
// Sort by index to guarantee correct order even with out-of-order delivery
pendingToProcess.sort((a, b) => a.index - b.index);
// Single batched set() call — ALL accumulated chunks in one update
set((state) => {
const convIndex = state.conversations.findIndex((c) => c.id === convId);
if (convIndex < 0) return state;
const conv = state.conversations[convIndex];
const messages = [...conv.messages];
let hasChanges = false;
for (const pending of pendingToProcess) {
const msgIndex = messages.findIndex((m) => m.id === pending.msgId);
if (msgIndex < 0) continue;
const msg = { ...messages[msgIndex] };
const existingChunks: Array<{ token: string; index: number }> =
(msg.metadata?._chunks as Array<{ token: string; index: number }>) ?? [];
const newChunks = [
...existingChunks,
{ token: pending.token, index: pending.index },
];
newChunks.sort((a, b) => a.index - b.index);
messages[msgIndex] = {
...msg,
content: newChunks.map((ch) => ch.token).join(''),
metadata: { ...msg.metadata, _chunks: newChunks },
isStreaming: true,
};
hasChanges = true;
}
if (!hasChanges) return state;
return {
conversations: state.conversations.map((c, i) =>
i === convIndex ? { ...conv, messages } : c,
),
};
});
}
/**
* Schedule a flush for the given conversation in ~50ms.
* Does nothing if a timer is already pending for this conversation.
*/
function scheduleBufferFlush(
convId: string,
get: () => AppState,
set: (partial: AppState | ((state: AppState) => Partial<AppState>)) => void,
): void {
const entry = conversationBuffers.get(convId);
if (!entry || entry.timer !== null) return;
entry.timer = setTimeout(() => {
flushBuffer(convId, get, set);
}, 50);
}
/**
* Immediately flush all pending tokens for the given conversation.
* Used when switching to a conversation mid-stream.
*/
function forceFlushBuffer(
convId: string,
get: () => AppState,
set: (partial: AppState | ((state: AppState) => Partial<AppState>)) => void,
): void {
const entry = conversationBuffers.get(convId);
if (!entry) return;
if (entry.timer !== null) {
clearTimeout(entry.timer);
}
flushBuffer(convId, get, set);
}
// ─────────────────────────────────────────────────────────────
// Store
// ─────────────────────────────────────────────────────────────
export const useAppStore = create<AppState>((set, get) => ({
// ── Cases initial state ──────────────────────────────────
cases: [],
selectedCaseId: null,
totalCases: 0,
fetchCases: async (filters: CaseFilters = {}) => {
try {
const response = await api.getCases(filters);
set({ cases: response.items, totalCases: response.total });
} catch (err) {
console.error('[Store] fetchCases failed:', err);
// On failure, keep current state (or set empty)
set({ cases: [], totalCases: 0 });
}
},
upsertCase: (c: CaseRequest) =>
set((state) => {
const index = state.cases.findIndex(
(existing) => existing.id === c.id,
);
if (index >= 0) {
// Replace existing
const updated = [...state.cases];
updated[index] = c;
return { cases: updated };
}
// Prepend new case
return { cases: [c, ...state.cases] };
}),
resolveCase: async (id, data) => {
try {
const updatedCase = await api.resolveCase(id, data);
set((state) => {
const index = state.cases.findIndex(
(existing) => existing.id === id,
);
if (index >= 0) {
const updated = [...state.cases];
updated[index] = updatedCase;
return { cases: updated };
}
return state;
});
} catch (err) {
console.error('[Store] resolveCase failed:', err);
throw err; // re-throw so calling code can handle
}
},
// ── Conversations initial state ──────────────────────────
conversations: [],
selectedConversationId: null,
fetchConversations: async () => {
try {
const conversations = await api.getActiveConversations();
set({ conversations });
} catch (err) {
console.error('[Store] fetchConversations failed:', err);
set({ conversations: [] });
}
},
upsertConversation: (c: Conversation) =>
set((state) => {
const index = state.conversations.findIndex(
(existing) => existing.id === c.id,
);
if (index >= 0) {
const updated = [...state.conversations];
updated[index] = c;
return { conversations: updated };
}
return { conversations: [...state.conversations, c] };
}),
addMessage: (convId: string, msg: Message) =>
set((state) => {
const convIndex = state.conversations.findIndex(
(c) => c.id === convId,
);
if (convIndex < 0) return state;
const updated = [...state.conversations];
updated[convIndex] = {
...updated[convIndex],
messages: [...updated[convIndex].messages, msg],
};
return { conversations: updated };
}),
appendToken: (convId: string, msgId: string, token: string, index: number) => {
// Step 1: Add chunk to the conversation's external buffer
let entry = conversationBuffers.get(convId);
if (!entry) {
entry = { pending: [], timer: null };
conversationBuffers.set(convId, entry);
}
entry.pending.push({ msgId, token, index });
// Step 2: Schedule a flush only if this is the selected conversation
// (non-selected conversations accumulate in buffer without triggering re-renders)
const state = get();
if (state.selectedConversationId === convId) {
scheduleBufferFlush(convId, get, set);
}
},
completeStream: (convId: string, msgId: string, fullContent: string) => {
// Step 1: Clear the conversation's buffer — no more tokens expected
const entry = conversationBuffers.get(convId);
if (entry) {
if (entry.timer !== null) {
clearTimeout(entry.timer);
}
conversationBuffers.delete(convId);
}
// Step 2: Perform a single store update to set the final content
set((state) => {
const convIndex = state.conversations.findIndex(
(c) => c.id === convId,
);
if (convIndex < 0) return state;
const conv = state.conversations[convIndex];
const msgIndex = conv.messages.findIndex((m) => m.id === msgId);
if (msgIndex < 0) return state;
const messages = [...conv.messages];
const msg = { ...messages[msgIndex] };
// Clear chunk buffer — rebuild metadata without _chunks
const cleanMetadata: Record<string, unknown> = {};
if (msg.metadata) {
for (const [key, value] of Object.entries(msg.metadata)) {
if (key !== '_chunks') {
cleanMetadata[key] = value;
}
}
}
messages[msgIndex] = {
...msg,
content: fullContent,
isStreaming: false,
metadata: cleanMetadata,
};
return {
conversations: state.conversations.map((c, i) =>
i === convIndex ? { ...conv, messages } : c,
),
};
});
},
setSelectedConversationId: (convId: string | null) => {
// Force-flush any pending buffer for the newly selected conversation
const prevSelected = get().selectedConversationId;
set({ selectedConversationId: convId });
if (convId !== null && convId !== prevSelected) {
// If switching to a conversation that has buffered tokens, flush them immediately
forceFlushBuffer(convId, get, set);
}
},
removeConversation: (convId: string) => {
// Clear the buffer for this conversation
const entry = conversationBuffers.get(convId);
if (entry) {
if (entry.timer !== null) {
clearTimeout(entry.timer);
}
conversationBuffers.delete(convId);
}
set((state) => ({
conversations: state.conversations.filter((c) => c.id !== convId),
selectedConversationId:
state.selectedConversationId === convId
? null
: state.selectedConversationId,
}));
},
// ── UI initial state ──────────────────────────────────
sidebarTab: 'all',
searchQuery: '',
applicativeFilter: null,
isDarkMode: readDarkMode(),
wsStatus: 'disconnected',
setSidebarTab: (tab) => set({ sidebarTab: tab }),
setSearchQuery: (q) => set({ searchQuery: q }),
setApplicativeFilter: (app) => set({ applicativeFilter: app }),
toggleDarkMode: () =>
set((state) => {
const next = !state.isDarkMode;
persistDarkMode(next);
return { isDarkMode: next };
}),
setWsStatus: (status) => set({ wsStatus: status }),
}));