Skip to main content

Estructura del frontend

Repo aura-front: React 18 + TypeScript + Vite, Tailwind + shadcn/ui (Radix), TanStack Query, lucide-react. Auth con @cuatro-quinas/ajolote-client-web.

Separación socket / hook / UI​

El core del dashboard separa la fuente de eventos del consumidor:

  • CopilotSocket (src/api/socket/): interface con dos implementaciones — RealCopilotSocket (WebSocket nativo, reconexión con backoff) y MockCopilotSocket (fixtures para demos/dev). Se elige por VITE_USE_MOCK.
  • useAgentSocket(socket) (src/hooks/): hook puro con useReducer sobre los eventos. Sin datos hardcodeados, testeable con un socket fake.
  • Componentes de presentación (src/components/copilot/): reciben estado y disparan callbacks; no tocan el WebSocket.

Los slices de conversación en vivo​

El reducer de useAgentSocket rutea conversation_snapshot por channel, no por source, a dos slices separados:

SliceAlimentaPor qué está separado
activeVoiceConversations/monitorUna llamada pasa, se sigue en vivo y termina en minutos. Comparte slice con el camino Retell porque el panel muestra lo mismo: una llamada ocurriendo ahora
whatsappThreads/whatsappUn hilo es asincrónico: se ordena por último mensaje y no tiene duración. Una tarjeta con duración de llamada no significa nada acá

Dos detalles del merge de transcript (mergeTranscript), que valen para las dos vistas:

  • el backend manda solo los últimos N turnos (40), con first_turn_index para ubicar la ventana, así que las posiciones del array se corren cuando desliza. La alineación con lo que ya está en pantalla se hace por contenido (alignOffset: el solapamiento más largo entre la cola de lo que tenemos y la cabeza de lo que llegó), que cubre los dos casos con la misma cuenta — un hilo que todavía crece (offset 0) y una ventana que ya desliza. Alinear por posición pierde los scores viejos en el primer caso, y alinear "desde el final" no arregla el segundo;
  • el score de RAG viene en el turno, así que se lee de ahí. Un snapshot en vivo solo lo trae para la respuesta que acaba de terminar: en los turnos anteriores los campos vienen ausentes, que significa "no está en este payload", y lo que ya estaba en pantalla se conserva. El catch-up los trae todos desde messages, y por eso los scores sobreviven un refresh. Ausente nunca se colapsa a null: null es el hecho de que ese turno no consultó el manual.
suggestion_id tiene que ser parseable

El feedback de un turno usa ${conversationId}_${turnIndex}, que es lo que el backend puede volver a parsear (/_t?(\d+)$/ en feedback.service.ts). El monitor viejo arma ${callId}_monitor, que no matchea: esas filas nunca pueden recomputar el reclamo del paciente, así que la limpieza de lecciones huérfanas no funciona para ellas. No copiar ese patrón.

turnIndex es el messages.turn_index del turno, no su posición en el chat: se calcula como firstTurnIndex + posición, con first_turn_index que manda el snapshot. Usar la posición haría que el id nombre un turno distinto cada vez que la ventana desliza.

Cliente API​

Todo el acceso al backend pasa por src/api/client.ts → apiFetch(path, init), que resuelve la base URL y usa fetchAuthed (Bearer + refresh). Los módulos de src/api/* (metrics, audit, feedback, audio-fork, retell, conversations) lo importan; no redefinen el fetch.

Siempre apiFetch / fetchAuthed

Cualquier request autenticado debe ir por apiFetch. Un fetch plano no manda el Bearer, y el backend no puede identificar al usuario (req.user vacío) → los eventos de auditoría salen sin user_id y los endpoints gateados dan 403.

Data fetching (React Query)​

Los paneles usan useQuery con key [endpoint, ...filtros]. Esto:

  • evita races: al cambiar filtros rápido, respuestas viejas no pisan a las nuevas (React Query descarta resultados de keys obsoletas);
  • da caché y dedupe gratis.

No usar useEffect + fetch manual para data de servidor.

Componentes y helpers compartidos​

Para no duplicar entre paneles:

MóduloQué es
components/copilot/shared/segmented-control.tsxToggle group (Día/Semana/Mes, filtros) — accesible (role=group, aria-pressed)
components/copilot/shared/period-selector.tsxDropdown de período
components/copilot/shared/kpi-card.tsxTarjeta de KPI (label + número + delta) sobre ui/Card
components/copilot/transcript-view.tsxTranscript + marcaciones de una llamada; reusado por /history y el modal de /logs. Prop canEditMarkings habilita editar/eliminar (supervisor) y spacious el layout ampliado del modal
components/copilot/shared/rag-badge.tsxScore de RAG de una respuesta del bot, con el gate de superadmin adentro del componente (un solo lugar donde cambiarlo). No renderiza nada si el turno no registró retrieval — "sin dato" no es "no consultó"
components/copilot/call-detail-modal.tsxModal de detalle de llamada (abre desde el ojo en /logs); envuelve transcript-view
lib/period.tsgetPeriodOptions, isoDate, daysInRange, pct, deltaLabel, tipos
lib/chart-colors.tsPaleta compartida de recharts
components/ui/*Primitivos shadcn (Card, Badge, Button, Select, PanelCard…)

Reusar los primitivos ui/ en vez de <div> con clases repetidas.

Roles en la UI​

Ver Autenticación. En resumen: useRole() + RequireRole (rutas) + roles? en NAV_ITEMS (nav). El front solo oculta; la autoridad es el backend.

Agregar una pantalla nueva​

  1. Crear el componente en components/copilot/.
  2. Cliente en src/api/<recurso>.ts usando apiFetch.
  3. Data con useQuery.
  4. Ruta en copilot-routes.tsx (envuelta en <RequireRole> si aplica).
  5. Item en app-nav.tsx (con roles? si es de supervisor).
  6. Reusar SegmentedControl / PeriodSelector / KpiCard / ui/*.