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
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useMemo } from 'react';
import { FolderOpen } from 'lucide-react';
import { useAppStore } from '@/store/useAppStore';
import { CaseStatus } from '@/types';
import type { CaseRequest } from '@/types';
import SearchBar from '@/components/shared/SearchBar';
import TabsBar from '@/components/shared/TabsBar';
import EmptyState from '@/components/shared/EmptyState';
import ApplicativeFilter from '@/components/cases/ApplicativeFilter';
import CaseCard from '@/components/cases/CaseCard';
import CaseDetail from '@/components/cases/CaseDetail';
// ─────────────────────────────────────────────────────────────
// Filter logic
// ─────────────────────────────────────────────────────────────
function filterCases(
cases: CaseRequest[],
tab: 'all' | 'pending' | 'resolved',
search: string,
applicative: string | null,
): CaseRequest[] {
let filtered = cases;
// 1. Filter by tab (status)
if (tab === 'pending') {
filtered = filtered.filter(
(c) =>
c.status === CaseStatus.PENDING ||
c.status === CaseStatus.IN_PROGRESS,
);
} else if (tab === 'resolved') {
filtered = filtered.filter(
(c) =>
c.status === CaseStatus.RESOLVED ||
c.status === CaseStatus.FAILED,
);
}
// 2. Filter by search query
if (search.trim()) {
const q = search.toLowerCase().trim();
filtered = filtered.filter(
(c) =>
c.title.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q) ||
(c.externalId && c.externalId.toLowerCase().includes(q)),
);
}
// 3. Filter by applicative
if (applicative) {
filtered = filtered.filter((c) => c.applicative === applicative);
}
return filtered;
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function CasesPage() {
// ── Store selectors ──────────────────────────────────────
const cases = useAppStore((s) => s.cases);
const selectedCaseId = useAppStore((s) => s.selectedCaseId);
const sidebarTab = useAppStore((s) => s.sidebarTab);
const searchQuery = useAppStore((s) => s.searchQuery);
const applicativeFilter = useAppStore((s) => s.applicativeFilter);
const fetchCases = useAppStore((s) => s.fetchCases);
// ── Fetch cases on mount ─────────────────────────────────
useEffect(() => {
fetchCases();
}, [fetchCases]);
// ── Filtered cases ───────────────────────────────────────
const filteredCases = useMemo(
() => filterCases(cases, sidebarTab, searchQuery, applicativeFilter),
[cases, sidebarTab, searchQuery, applicativeFilter],
);
// ── Selected case object ─────────────────────────────────
const selectedCase = useMemo(
() => cases.find((c) => c.id === selectedCaseId) ?? null,
[cases, selectedCaseId],
);
// ── Case selection handler ───────────────────────────────
const handleCaseClick = (id: string | number) => {
useAppStore.setState({ selectedCaseId: id });
};
return (
<div className="flex h-full overflow-hidden">
{/* ── Sidebar (320px) ──────────────────────────────────── */}
<aside className="w-[320px] shrink-0 flex flex-col border-r border-border bg-surface">
{/* Search */}
<div className="px-3 py-2.5 border-b border-border">
<SearchBar />
</div>
{/* Tabs */}
<TabsBar />
{/* Applicative filter */}
<ApplicativeFilter />
{/* Cases list with scroll */}
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
{filteredCases.length === 0 ? (
<EmptyState
icon={<FolderOpen />}
title="Sin casos"
description="No se encontraron casos con los filtros actuales."
/>
) : (
filteredCases.map((c) => (
<CaseCard
key={c.id}
case={c}
isActive={selectedCaseId === c.id}
onClick={() => handleCaseClick(c.id)}
/>
))
)}
</div>
{/* Footer count */}
<div className="shrink-0 px-3 py-2 border-t border-border">
<p className="text-[10px] text-text-muted">
{filteredCases.length} de {cases.length} casos
</p>
</div>
</aside>
{/* ── Right panel (flex-1) ──────────────────────────────── */}
<main className="flex-1 flex flex-col bg-bg-base overflow-hidden">
{selectedCase ? (
<CaseDetail key={selectedCase.id} case={selectedCase} />
) : (
<EmptyState
icon={<FolderOpen />}
title="Seleccione un caso"
description="Elija un caso de la lista para ver su detalle y gestionarlo."
/>
)}
</main>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useMemo } from 'react';
import { MessageSquare } from 'lucide-react';
import { useAppStore } from '@/store/useAppStore';
import ConversationCard from '@/components/monitor/ConversationCard';
import ChatFeed from '@/components/monitor/ChatFeed';
import InternalNoteBanner from '@/components/monitor/InternalNoteBanner';
import EmptyState from '@/components/shared/EmptyState';
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function MonitorPage() {
// ── Store selectors ──────────────────────────────────────
const conversations = useAppStore((s) => s.conversations);
const selectedConversationId = useAppStore((s) => s.selectedConversationId);
const fetchConversations = useAppStore((s) => s.fetchConversations);
// ── Fetch conversations on mount ─────────────────────────
useEffect(() => {
fetchConversations();
}, [fetchConversations]);
// ── Selected conversation object ─────────────────────────
const selectedConversation = useMemo(
() =>
conversations.find((c) => c.id === selectedConversationId) ?? null,
[conversations, selectedConversationId],
);
// ── Conversation click handler ────────────────────────────
const handleConversationClick = (id: string) => {
useAppStore.setState({ selectedConversationId: id });
};
return (
<div className="flex h-full overflow-hidden">
{/* ── Left sidebar (280px) ─────────────────────────────── */}
<aside className="w-[280px] shrink-0 flex flex-col border-r border-border bg-surface">
{/* Header */}
<div className="px-3 py-2.5 border-b border-border">
<h2 className="text-[13px] font-semibold text-text-primary flex items-center gap-2">
<MessageSquare size={14} className="text-accent-orange" />
Conversaciones
{conversations.length > 0 && (
<span className="text-[10px] font-normal text-text-muted">
({conversations.length})
</span>
)}
</h2>
</div>
{/* Scrollable conversation list */}
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
{conversations.length === 0 ? (
<EmptyState
icon={<MessageSquare />}
title="Sin conversaciones"
description="No hay conversaciones activas en este momento."
/>
) : (
conversations.map((conv) => (
<ConversationCard
key={conv.id}
conversation={conv}
isActive={selectedConversationId === conv.id}
onClick={() => handleConversationClick(conv.id)}
/>
))
)}
</div>
</aside>
{/* ── Right panel (flex-1) ──────────────────────────────── */}
<main className="flex-1 flex flex-col bg-bg-base overflow-hidden">
{selectedConversation ? (
<>
{/* Chat header */}
<div className="shrink-0 px-4 py-2.5 border-b border-border bg-surface flex items-center gap-2">
<span className="text-[13px] font-semibold text-text-primary">
{selectedConversation.clientId || `Conversación ${selectedConversation.id}`}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
selectedConversation.status === 'active'
? 'bg-accent-green/10 text-accent-green'
: 'bg-elevated text-text-muted'
}`}>
{selectedConversation.status === 'active' ? 'En vivo' : selectedConversation.status}
</span>
</div>
{/* Chat feed */}
<ChatFeed conversation={selectedConversation} />
{/* Internal note banner */}
<InternalNoteBanner conversationId={selectedConversation.id} />
</>
) : (
<EmptyState
icon={<MessageSquare />}
title="Selecciona una conversación"
description="Elija una conversación de la lista para monitorear el chat en tiempo real."
/>
)}
</main>
</div>
);
}