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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user