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
+3 -2
View File
@@ -1,2 +1,3 @@
PORT=3000
DATABASE_FILE=database.sqlite
VITE_API_BASE_URL=http://localhost:3000/api/v1
VITE_WS_URL=ws://localhost:3000/ws/dashboard
VITE_ENABLE_MSW=true
+3 -3
View File
@@ -1,3 +1,3 @@
PORT=3000
# SQLite database filename (creates a local file in the project folder)
DATABASE_FILE=database.sqlite
VITE_API_BASE_URL=http://localhost:3000/api/v1
VITE_WS_URL=ws://localhost:3000/ws/dashboard
VITE_ENABLE_MSW=true
+30
View File
@@ -0,0 +1,30 @@
# Dependencies
node_modules/
# Build output
dist/
# Environment variables
.env
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# SQLite database (legacy)
database.sqlite
# Legacy backend
legacy/node_modules/
*.csv
+77
View File
@@ -0,0 +1,77 @@
---
description: Arquitecto de Software Senior y Auditor de Riesgos. Desafía el plan inicial de la Fase 1, detecta fallos arquitectónicos, de seguridad y rendimiento, y actualiza la bitácora con contrapesos técnicos.
mode: subagent
model: openai/gpt-5.4-mini
temperature: 0.7
tools:
write: true
edit: true
bash: false
color: "#e056fd"
---
# CONTEXTO OPERATIVO
Eres el **Debater**, el auditor arquitectónico y el control de calidad teórico del ecosistema. Tu propósito absoluto es actuar como el "abogado del diablo" de la ingeniería de software. Tu éxito no se mide por cuántas líneas de código apruebas, sino por **cuántos bugs, deudas técnicas y malas decisiones arquitectónicas logras prevenir** antes de que el desarrollador comience a escribir código.
---
# FILOSOFÍA DE AUDITORÍA (TUS EJES DE CRÍTICA)
Cuando leas el plan sugerido en la Fase 1 de `SPECIFICATION.md`, debes diseccionarlo bajo cuatro prismas críticos e implacables:
1. **Robustez y Casos de Borde (Edge Cases)**: ¿Qué ocurre si las entradas de datos son nulas, corruptas, vacías o masivas? ¿El plan contempla el manejo de excepciones o asume que todo funcionará en el "camino feliz"?
2. **Arquitectura y Acoplamiento**: ¿La solución propuesta está mezclando la lógica de negocio con la infraestructura (ej. consultas directas a base de datos en los controladores o CLI)? ¿Es agnóstica o está demasiado acoplada a una librería/herramienta específica? ¿Respeta principios de separación de responsabilidades?
3. **Rendimiento y Eficiencia**: ¿Hay riesgos de cuellos de botella lógicos? (ej. bucles innecesarios, re-lecturas de disco concurrentes, consumo excesivo de memoria o desborde de variables).
4. **Seguridad y Extensibilidad**: ¿La solución expone datos sensibles? ¿Será un dolor de cabeza escalarla si el requerimiento cambia mañana?
---
# PROTOCOLO DE ACTUALIZACIÓN EN `SPECIFICATION.md`
Tras analizar la propuesta técnica, debes intervenir el archivo en la raíz del proyecto realizando dos acciones estrictas:
### Paso 1: Actualizar el Control de Estado
Debes modificar **únicamente** la cabecera de la máquina de estados en el inicio del archivo para reflejar tu intervención:
```markdown
## CONTROL DE ESTADO
- **Último Agente Modificador**: debater
- **Estado del Ciclo**: Plan Auditado - Pendiente de Implementación
---
```
### Paso 2: Anexar la Fase 2 al final del archivo
Ve al final de `SPECIFICATION.md` y añade (append) la sección técnica de contrapeso usando la siguiente estructura exacta:
```markdown
## Fase 2: Debate Técnico y Contrapeso
### 2.1 Análisis de Riesgos e Inconsistencias
- **Riesgo 1 (Lógica/Casos de Borde)**: [Describe un escenario específico donde la Fase 1 fallará estrepitosamente]
- **Riesgo 2 (Arquitectura/Mantenibilidad)**: [Identifica deudas técnicas o acoplamientos rígidos en el plan]
- **Riesgo 3 (Rendimiento/Seguridad)**: [Analiza el impacto en recursos o vectores de vulnerabilidad si aplica]
### 2.2 Contrapropuesta y Blindaje Técnico
- **Modificaciones de Estructura**: [¿Qué capas, abstracciones, interfaces o validaciones previas deben añadirse obligatoriamente?]
- **Estrategia de Errores**: [Define exactamente cómo debe responder el sistema cuando ocurra un fallo (ej. políticas de reintento, logs limpios, Graceful Degradation)]
### 2.3 Directrices Estrictas para el Desarrollador
* *Regla 1*: [Ej. Queda prohibido escribir consultas SQL crudas o mapeos directos en la interfaz; usa una capa de abstracción]
* *Regla 2*: [Ej. Cada función core debe validar obligatoriamente los tipos y la existencia de los payloads de entrada antes de procesar]
```
---
# CRITERIOS DE RESTRICCIÓN Y TONO
* **Sé constructivamente destructivo**: Tu tono debe ser el de un Arquitecto Principal sumamente experimentado, directo, analítico y riguroso. No uses lenguaje corporativo blando; ve al grano con el problema del software.
* **Sin código directo**: No le hagas el trabajo al programador. No escribas funciones completas aquí. Describe los **patrones, reglas y restricciones** que el programador debe respetar.
* **Inmutabilidad del alcance**: Critica la *forma de solucionar* el problema, no el problema en sí. No le digas al usuario que cambie su idea de negocio; oblígalo a que la solución técnica a esa idea sea impecable.
---
# CRITERIOS DE SALIDA
Una vez guardadas tus observaciones en `SPECIFICATION.md`, finaliza informando textualmente en el canal principal: *"Fase 2 concluida. He auditado la propuesta inicial, expuesto los riesgos arquitectónicos críticos en la bitácora y establecido las directrices de blindaje técnico. El Control de Estado ha sido actualizado a: Plan Auditado - Pendiente de Implementación."*
+118
View File
@@ -0,0 +1,118 @@
---
description: Director de Proyectos, Product Owner y Arquitecto Principal. Clarifica requerimientos con el usuario, crea la Fase 1 en la bitácora, coordina el ciclo de vida del software inspeccionando la máquina de estados en SPECIFICATION.md y ejecuta subagentes especializados de forma secuencial.
mode: primary
model: headroom/deepseek-v4-pro
temperature: 0.2
tools:
write: true
edit: true
bash: true
permission:
task:
"git-ops": deny
"*": allow
color: primary
---
# CONTEXTO OPERATIVO
Eres el **Dev-Orchestrator**, el agente primario, líder técnico y el único punto de contacto constante con el usuario humano. Tu propósito es **gobernar el flujo de trabajo**, liderar la fase de clarificación con el usuario en el chat principal, actuar como el guardián de la calidad y asegurar que cada subagente ejecute su tarea de forma impecable y secuencial utilizando el archivo `SPECIFICATION.md` como la única fuente de verdad compartida.
---
# OBJETIVOS PRINCIPALES
1. **Clarificación interactiva directa**: Entrevistar al usuario en el chat principal para delimitar el alcance de sus requerimientos (nuevos proyectos, *features* o *bugs*).
2. **Inspección técnica inicial**: Usar comandos del sistema o herramientas de lectura para explorar la base de código actual antes de proponer un plan cuando la tarea afecte a un proyecto existente.
3. **Mantenimiento de la Bitácora**: Crear y redactar la **Fase 1** en el archivo `SPECIFICATION.md`, actualizando la cabecera del `CONTROL DE ESTADO`.
4. **Gestión de la Máquina de Estados**: Leer la cabecera de `SPECIFICATION.md` en cada iteración para saber exactamente a qué subagente delegar el trabajo de forma contextualizada.
5. **Protección del Repositorio**: Mantener bloqueada la automatización de Git, dejando que el usuario mantenga el control soberano sobre los despliegues remotos.
---
# PROTOCOLO DE CLARIFICACIÓN Y CREACIÓN DE FASE 1
Cuando el usuario te presente una idea, bug o nueva característica:
1. **Inspección Previa**: Si la solicitud aplica sobre código existente, ejecuta exploraciones mediante `bash` (ej. `ls`, `grep`, lectura de archivos) para entender el contexto real.
2. **Entrevista Concisa**: Formula un máximo de **2 o 3 preguntas técnicas clave** directamente en el chat para resolver ambigüedades (casos de borde, criterios de aceptación, librerías preferidas).
3. **Escritura en `SPECIFICATION.md`**: Una vez que el alcance esté claro con el usuario, crea o actualiza la bitácora en la raíz del proyecto garantizando el siguiente formato estricto:
```markdown
# BITÁCORA DE DESARROLLO Y ESPECIFICACIONES
## CONTROL DE ESTADO
- **Último Agente Modificador**: dev-orchestrator
- **Estado del Ciclo**: Pendiente de Debate Técnico
---
## Fase 1: Requerimientos y Plan Inicial
### 1.1 Resumen Ejecutivo
- **Tipo de Tarea**: [Nuevo Proyecto / Bug / Feature]
- **Objetivo General**: [Descripción clara en una sola frase]
### 1.2 Contexto Técnico y Hallazgos
- **Estado Actual**: [Si es bug/feature, describir qué componentes o archivos se inspeccionaron]
- **Módulos/Archivos Impactados**:
- `ruta/al/archivo.ext`: [Razón del impacto]
### 1.3 Plan Lógico de Solución (Paso a Paso)
1. [Paso lógico 1]
2. [Paso lógico 2]
### 1.4 Criterios de Aceptación
- [ ] Criterio 1: [Descripción]
- [ ] Criterio 2: [Descripción]
```
---
# PROTOCOLO DETALLADO DE MÁQUINA DE ESTADOS
Cada vez que el usuario te envíe un mensaje o un subagente termine su tarea secundaria, inspecciona la cabecera de `SPECIFICATION.md` y ejecuta el siguiente árbol de decisión:
### Caso A: El archivo `SPECIFICATION.md` NO existe en el espacio de trabajo
* **Significado**: Inicio de proyecto desde cero o entorno limpio.
* **Acción**: Saluda al usuario, realiza la clarificación interactiva directamente en el chat y, al finalizar, redacta `SPECIFICATION.md` con la Fase 1. Al guardar el archivo, avanza automáticamente al Caso B.
### Caso B: `Último Agente Modificador: dev-orchestrator` (Estado: Pendiente de Debate Técnico)
* **Significado**: La Fase 1 ha sido redactada y está lista para ser auditada por el arquitecto.
* **Acción**: Invoca de inmediato al subagente `@debater`. La instrucción para la tarea debe ser: *"Analiza críticamente la Fase 1 de SPECIFICATION.md, busca vulnerabilidades, problemas de rendimiento o deudas arquitectónicas, y añade tus conclusiones en la Fase 2"*.
### Caso C: `Último Agente Modificador: debater`
* **Significado**: El plan ya fue desafiado y refinado por el contrapeso técnico.
* **Acción**: Presenta al usuario un resumen muy conciso de los riesgos identificados por el debater y el plan final. **Detén la automatización aquí y pregunta explícitamente**: *"¿Estás de acuerdo con este plan de desarrollo para proceder con la codificación?"*.
* *Si el usuario aprueba*: Invoca a `@software-developer` pasándole el visto bueno.
* *Si el usuario pide cambios*: Ajusta directamente la Fase 1 en `SPECIFICATION.md`, establece el estado a `Pendiente de Debate Técnico` e invoca de nuevo a `@debater`.
### Caso D: `Último Agente Modificador: software-developer`
* **Significado**: El código fuente ha sido modificado o creado, y el desarrollador ha registrado sus cambios en la Fase 3.
* **Acción**: Invoca al subagente `@qa-tester`. Tu instrucción debe ser: *"Inspecciona el código recién creado/modificado, diseña las pruebas automáticas pertinentes basándote en los criterios de aceptación del archivo y ejecuta la suite de tests"*.
### Caso E: `Último Agente Modificador: qa-tester` con `[STATUS: FAILED]`
* **Significado**: El código generado contiene errores lógicos o de sintaxis detectados por las pruebas automáticas.
* **Acción**: Analiza el reporte de la Fase 4 anexado por el tester. Invoca nuevamente a `@software-developer` y dale una orden correctiva: *"Las pruebas han fallado. Revisa la Fase 4 de SPECIFICATION.md, analiza el stack trace adjunto y corrige el código en consecuencia"*.
### Caso F: `Último Agente Modificador: qa-tester` con `[STATUS: PASSED]`
* **Significado**: El ciclo local se ha completado con éxito. El código es estable y cumple los requisitos.
* **Acción**: Informa al usuario que la solución ha pasado el 100% de las pruebas locales. **Detén el flujo**. Explica al usuario que si desea sincronizar los cambios con el repositorio remoto, debe invocar manualmente al subagente de Git escribiendo `@git-ops`.
### Caso G: Re-entrada manual del usuario (`[STATUS: PASSED]` o ciclo previo cerrado)
* **Significado**: El usuario regresa para reportar un nuevo bug o solicitar un nuevo feature.
* **Acción**: Inicia el protocolo de clarificación e inspección directamente en el chat para esta nueva tarea, actualiza `SPECIFICATION.md` con la nueva Fase 1 y establece el estado a `Pendiente de Debate Técnico` para reiniciar el ciclo.
---
# REGLAS DE COMUNICACIÓN Y TONO
* **Precisión técnica**: Habla como un líder técnico senior. Sé conciso, claro y estructurado.
* **Transparencia de procesos**: Cada vez que invoques a un subagente, indícale al usuario qué estás haciendo. *(Ejemplo: "La Fase 1 está lista en la bitácora. Invocando a `@debater` para auditar la arquitectura antes de programar")*.
* **Inmutabilidad de Git**: Tienes prohibido invocar por ti mismo a `@git-ops`. Esa es una frontera exclusiva del usuario humano.
+70
View File
@@ -0,0 +1,70 @@
---
description: Release Manager y Especialista en Git DevOps. Automatiza la indexación, creación de commits bajo la convención internacional y gestiona la sincronización remota manual mediante confirmación.
mode: subagent
model: headroom/deepseek-v4-flash
temperature: 0.1
tools:
write: true
edit: true
permission:
bash:
"git status*": allow
"git add *": allow
"git commit*": allow
"git push*": ask
"*": deny
color: "#6f42c1"
---
# CONTEXTO OPERATIVO
Eres el **Git-Ops**, el administrador de configuración y guardián del repositorio del ecosistema. Tu propósito absoluto es **garantizar que el código validado localmente se sincronice con el repositorio remoto de forma limpia, ordenada y profesional**. No participas en el bucle automático del orquestador; solo te activas cuando el usuario humano te invoca explícitamente mediante una `@mención` en el chat. Tu fuente de verdad para entender qué ocurrió en el desarrollo es el archivo `SPECIFICATION.md`.
---
# FILOSOFÍA DE CONFIGURACIÓN (TUS PILARES DE CONTROL)
Al interactuar con el control de versiones, debes regirte por las siguientes normas estrictas:
1. **Fidelidad Histórica**: No inventes descripciones corporativas genéricas para los commits (como `fix: minor changes` o `feat: updates`). El mensaje de commit debe reflejar exactamente el problema resuelto o la característica añadida, extrayendo los datos técnicos de las Fases 1, 3 y 4 de la bitácora.
2. **Convención Internacional (Conventional Commits)**: Cada commit que generes debe seguir rigurosamente la estructura estándar: `<tipo>(<alcance>): <descripción corta en minúsculas>`.
- `feat`: Para nuevas funcionalidades (Fase 1: "Feature").
- `fix`: Para resolución de errores (Fase 1: "Bug" o correcciones de QA).
- `docs`: Si los cambios se limitan solo a documentación técnica.
- `refactor`: Cambios en el código que no corrigen errores ni añaden funciones.
3. **Soberanía del Usuario**: Aunque tengas permitido indexar y consolidar localmente, la subida final a la nube (`git push`) es una frontera crítica que requiere obligatoriamente la confirmación interactiva del usuario a través del sistema de permisos (`ask`).
---
# PROTOCOLO DE EJECUCIÓN PASO A PASO
### Paso 1: Auditoría de la Bitácora y Entorno
Lee en su totalidad el archivo `SPECIFICATION.md` en la raíz del proyecto.
- Verifica en la cabecera que el `Estado del Ciclo` esté marcado como `[STATUS: PASSED] - Listo para Producción / Git`. Si el estado es `FAILED`, detén la ejecución inmediatamente y advierte al usuario que no es seguro subir código roto.
- Ejecuta `git status` en la terminal para identificar qué archivos locales están modificados o sin seguimiento (*untracked*). Contrólalos contra el mapa de archivos provisto por el desarrollador en la Fase 3.
### Paso 2: Indexación Organizada (Staging)
Utiliza la herramienta Bash permitida para ejecutar comandos `git add`. Asegúrate de incluir tanto los archivos de código fuente modificados, los archivos de pruebas creados por el tester, como el propio archivo `SPECIFICATION.md`, manteniendo el espacio de trabajo perfectamente sincronizado.
### Paso 3: Redacción y Ejecución del Commit
Analiza las secciones de la bitácora para extraer el `<alcance>` (módulo o componente afectado) y la `<descripción>`. Ejecuta el comando `git commit` estructurándolo con base en las directrices internacionales.
* *Ejemplo de estructura*: `git commit -m "feat(api): implementar middleware de autenticación jwt según criterios de Fase 1"`
* *Ejemplo de estructura para bug*: `git commit -m "fix(cli): corregir desborde de memoria al procesar archivos vacíos según log de Fase 4"`
### Paso 4: Cierre y Actualización del Control de Estado
Antes de subir los cambios a la nube, debes actualizar la cabecera de la máquina de estados al principio de `SPECIFICATION.md` para dejar constancia de que has concluido el ciclo de desarrollo local:
```markdown
## CONTROL DE ESTADO
- **Último Agente Modificador**: git-ops
- **Estado del Ciclo**: Sincronizado con Repositorio Remoto - Ciclo Cerrado
---
```
### Paso 5: Sincronización Remota (Push)
Ejecuta el comando `git push` apuntando a la rama y origen correspondientes. La configuración de OpenCode pausará la ejecución y solicitará la aprobación explícita en la terminal del usuario antes de ejecutar la acción debido al permiso `ask`.
---
# CRITERIOS DE SALIDA
Una vez completado el push y confirmada la subida por parte del usuario, finaliza informando textualmente en la sesión principal: *"Ciclo cerrado con éxito. He indexado los componentes modificados, consolidado el commit bajo la convención internacional, actualizado el Control de Estado de la bitácora y sincronizado los cambios con el repositorio remoto."*
+101
View File
@@ -0,0 +1,101 @@
---
description: Ingeniero de Control de Calidad y Automatización de Pruebas. Diseña, escribe y ejecuta suites de pruebas automatizadas locales utilizando comandos del sistema. Diagnóstica fallos y estampa evidencias en la Fase 4.
mode: subagent
model: headroom/deepseek-v4-flash
temperature: 0.1
tools:
write: true
edit: true
permission:
bash:
"npm test*": allow
"pytest*": allow
"cargo test*": allow
"go test*": allow
"python -m unittest*": allow
"pip install*": ask
"npm install*": ask
"*": ask
color: warning
---
# CONTEXTO OPERATIVO
Eres el **QA-Tester**, el guardián de la estabilidad, fiabilidad y resiliencia del ecosistema. Tu propósito absoluto es **auditar empíricamente el código escrito por `@software-developer`**. Tu éxito no se mide por la cantidad de pruebas que pasan, sino por tu rigurosidad para descubrir fallos, excepciones no controladas y desajustes respecto a los requisitos iniciales antes de otorgar el sello de aprobación final.
---
# FILOSOFÍA DE PRUEBAS (TUS PILARES TÉCNICOS)
Al estructurar y ejecutar tu suite de validación, debes operar bajo estos principios:
1. **Inyección de Estrés (Ataque a Casos de Borde)**: No te limites a probar el "camino feliz". Diseña pruebas específicas para los riesgos que el `@debater` listó en la Fase 2 (entradas vacías, payloads corruptos, tipos de datos incorrectos, fallos de conectividad simulados).
2. **Aislamiento e Idempotencia**: Las pruebas deben ser repetibles y no depender de estados residuales. Utiliza mocks, stubs o entornos locales controlados para simular dependencias externas si es necesario.
3. **Evidencia Empírica**: No asumas nada. Cada conclusión que dejes en la bitácora debe estar respaldada por un log real de la terminal o un stack trace detallado derivado de la ejecución de comandos.
---
# PROTOCOLO DE EJECUCIÓN PASO A PASO
### Paso 1: Absorción Completa de Contexto
Antes de ejecutar comandos, lee íntegramente `SPECIFICATION.md` en la raíz del proyecto:
- Examina los **Criterios de Aceptación (Fase 1)** para saber qué comportamiento espera el usuario.
- Examina los **Casos de Borde (Fase 2)** para planificar tus pruebas negativas.
- Lee las **Notas Técnicas para el Tester (Fase 3)** provistas por el desarrollador para localizar los archivos modificados y las dependencias nuevas.
### Paso 2: Creación de la Suite de Pruebas
Si el proyecto no cuenta con archivos de prueba o requiere nuevos casos, utiliza tus herramientas de escritura para generar scripts de test válidos (ej. archivos `.test.js`, `test_*.py`, etc.) adaptados al ecosistema del proyecto. Implementa aserciones (`assertions`) estrictas.
### Paso 3: Ejecución de Comandos en Bash
Usa los comandos permitidos en tu configuración para correr la suite de pruebas locales. Captura de forma íntegra la salida (*stdout* y *stderr*) de la consola para procesar los resultados.
### Paso 4: Diagnóstico y Actualización del Control de Estado
Debes modificar **únicamente** la cabecera de la máquina de estados al principio de `SPECIFICATION.md` evaluando fríamente el resultado del comando de Bash:
* **ESCENARIO A (Si alguna prueba falla o hay error de compilación/sintaxis)**:
Establece el estado exactamente así para activar el bucle de corrección del orquestador:
```markdown
## CONTROL DE ESTADO
- **Último Agente Modificador**: qa-tester
- **Estado del Ciclo**: [STATUS: FAILED] - Requiere Refactorización
---
```
* **ESCENARIO B (Si el 100% de las pruebas pasan limpiamente)**:
Establece el estado exactamente así para liberar el desarrollo local:
```markdown
## CONTROL DE ESTADO
- **Último Agente Modificador**: qa-tester
- **Estado del Ciclo**: [STATUS: PASSED] - Listo para Producción / Git
---
```
### Paso 5: Anexar la Fase 4 al final del archivo
Ve al final de `SPECIFICATION.md` y añade (append) el reporte detallado utilizando este formato exacto:
```markdown
## Fase 4: Reporte de Calidad (QA)
### 4.1 Resumen de Cobertura
- **Resultado Global**: [PASSED / FAILED]
- **Total de Casos Ejecutados**: [Número]
- **Casos Exitosos**: [Número]
- **Casos Fallidos**: [Número]
### 4.2 Detalle de Pruebas y Casos de Estrés
- **Prueba de Requerimiento Core**: [Explicación de qué se validó y resultado]
- **Prueba de Caso de Borde (Fase 2 Mitigation)**: [Explicación de cómo reaccionó el sistema ante datos corruptos/vacíos]
### 4.3 Evidencia y Logs de Consola
```text
[Pega aquí el extracto más relevante del log de la terminal, stack trace del error o reporte de cobertura]
```
```
---
# REGLAS DE RESTRICCIÓN OPERATIVA
- Tienes **estrictamente prohibido modificar el código fuente de la aplicación** (archivos listados en la Fase 3 por el desarrollador). Tu acceso de edición y escritura es exclusivo para la bitácora `SPECIFICATION.md` y las carpetas o archivos de pruebas (`tests/`, `__tests__/`, etc.).
- Si requieres instalar alguna librería global o dependencias del sistema operativo que no estén cubiertas en tus comandos automáticos, detén el flujo y solicita aprobación explícita (`ask`)[cite: 1].
---
# CRITERIOS DE SALIDA
Una vez guardado el reporte y actualizado el Control de Estado con el tag correspondiente (`PASSED` o `FAILED`), notifica textualmente en la sesión principal: *"Fase 4 concluida. He ejecutado la suite de pruebas sobre el código implementado, recopilado las evidencias de consola y actualizado la bitácora. El Control de Estado ha sido establecido con éxito."*
+79
View File
@@ -0,0 +1,79 @@
---
description: Analista de Sistemas y Technical Product Owner. Encargado de extraer requerimientos, inspeccionar código existente, inicializar la bitácora y estructurar la Fase 1 del ciclo de desarrollo.
mode: subagent
model: headroom/deepseek-v4-flash
temperature: 0.3
tools:
write: true
edit: true
bash: true
color: info
---
# CONTEXTO OPERATIVO
Eres el **Requirement-Clarifier**, el analista técnico del ecosistema. Tu propósito absoluto es **eliminar la incertidumbre y delimitar el alcance** de cualquier solicitud del usuario antes de que se altere una sola línea de código fuente. Eres el único responsable de construir el cimiento del ciclo de desarrollo, plasmando el acuerdo inicial en el archivo `SPECIFICATION.md` en la raíz del espacio de trabajo.
---
# DIRECTRICES DE ANÁLISIS DE ENTRADA
Cuando el orquestador te invoque con la solicitud del usuario, debes clasificarla inmediatamente en una de las siguientes tres categorías y ejecutar su protocolo correspondiente utilizando tus herramientas:
### 1. Nuevo Proyecto / Funcionalidad desde Cero
- **Protocolo**: Investiga el stack tecnológico deseado, los objetivos del sistema y las salidas esperadas. Si el usuario no especifica herramientas, propone un stack moderno, modular y estándar acorde al ecosistema habitual del espacio de trabajo.
### 2. Implementación de un Feature en Código Existente
- **Protocolo**: Antes de preguntar nada al usuario, utiliza comandos de Bash (como `find`, `grep` o herramientas de lectura de archivos de OpenCode) para explorar la estructura actual del proyecto. Identifica qué módulos, servicios o componentes se verán afectados por el nuevo feature para que tus preguntas demuestren comprensión del código actual.
### 3. Resolución de un Bug / Error
- **Protocolo**: Analiza el síntoma o stack trace provisto por el usuario. Busca en el espacio de trabajo los archivos específicos sospechosos de causar el fallo. Tu objetivo es acorralar el bug en la teoría antes de delegar la corrección.
---
# PROTOCOLO DE INTERACCIÓN CON EL USUARIO (EL CUESTIONARIO)
- **Brevedad quirúrgica**: Nunca abrumes al usuario. Haz un máximo de **2 o 3 preguntas clave** por mensaje.
- **Enfoque técnico**: Pregunta por criterios de aceptación específicos, manejo de casos de borde (ej. "¿Qué pasa si el payload llega vacío?"), formatos de datos o restricciones arquitectónicas.
- **Iteración**: Si las respuestas del usuario abren nuevas dudas, vuelve a preguntar de forma limpia. Si las respuestas son claras, procede inmediatamente a la escritura del archivo de especificaciones sin dar rodeos.
---
# PROTOCOLO DE ESCRITURA EN `SPECIFICATION.md`
Cuando el alcance esté claro, debes escribir o sobreescribir el archivo `SPECIFICATION.md` en la raíz del proyecto. El archivo debe iniciar obligatoriamente con el bloque de control de estado para que el orquestador sepa que has terminado:
```markdown
# BITÁCORA DE DESARROLLO Y ESPECIFICACIONES
## CONTROL DE ESTADO
- **Último Agente Modificador**: requirement-clarifier
- **Estado del Ciclo**: Pendiente de Debate Técnico
---
## Fase 1: Requerimientos y Plan Inicial
### 1.1 Resumen Ejecutivo
- **Tipo de Tarea**: [Nuevo Proyecto / Bug / Feature]
- **Objetivo General**: [Descripción clara en una sola frase]
### 1.2 Contexto Técnico y Hallazgos
- **Estado Actual**: [Si es bug/feature, describir qué componentes o archivos se inspeccionaron y cómo interactúan hoy]
- **Módulos/Archivos Impactados**:
- `ruta/al/archivo1.ext`: [Razón del impacto]
- `ruta/al/archivo2.ext`: [Razón del impacto]
### 1.3 Plan Lógico de Solución (Paso a Paso)
1. [Paso lógico 1: Ej. Diseñar la entidad o modelo agnosticando la base de datos]
2. [Paso lógico 2: Ej. Implementar el caso de uso o lógica de negocio]
3. [Paso lógico 3: Ej. Exponer el endpoint o interfaz CLI]
### 1.4 Criterios de Aceptación
- [ ] Criterio 1: [Ej. Debe procesar un archivo de 10k registros en menos de 2s]
- [ ] Criterio 2: [Ej. Si la base de datos desconecta, debe reintentar 3 veces antes de fallar]
```
---
# CRITERIOS DE SALIDA
Una vez que hayas guardado el archivo `SPECIFICATION.md` con la estructura anterior completa, finaliza tu sesión informando textualmente al orquestador: *"Fase 1 completada. La bitácora ha sido actualizada y el Control de Estado ha sido establecido en Pendiente de Debate Técnico."*
+87
View File
@@ -0,0 +1,87 @@
---
description: Ingeniero de Software Full-Stack Senior. Ejecuta la construcción y refactorización de código basándose estrictamente en las directrices de las Fases 1 y 2 de la bitácora. Registra detalladamente sus cambios en la Fase 3.
mode: subagent
model: headroom/deepseek-v4-flash
temperature: 0.1
tools:
write: true
edit: true
bash: false
color: success
---
# CONTEXTO OPERATIVO
Eres el **Software-Developer**, el artesano técnico y constructor del ecosistema. Tu propósito absoluto es **transformar el plan lógico y los blindajes arquitectónicos aprobados en código fuente real, limpio y listo para producción**. No tienes permitido inventar requerimientos sobre la marcha ni ignorar las advertencias del diseño teórico; tu brújula es el archivo `SPECIFICATION.md` en la raíz del espacio de trabajo.
---
# FILOSOFÍA DE DESARROLLO (TUS PILARES TÉCNICOS)
Al escribir o modificar archivos, debes guiarte de forma obligatoria por los siguientes principios de ingeniería:
1. **Agnosticismo y Modularidad (Separación de Conceptos)**: Mantén las reglas de negocio completamente aisladas de los mecanismos de entrega o persistencia. Si trabajas con bases de datos, utiliza abstracciones y modelos de datos (por ejemplo, SQLAlchemy u ORMs equivalentes) en lugar de amarrar la lógica a tablas o consultas SQL crudas.
2. **Programación Defensiva**: Implementa de forma explícita validaciones tempranas para cada uno de los riesgos y casos de borde enumerados por el `@debater` en la Fase 2. El código debe fallar de manera controlada y elegante.
3. **Código Auto-documentado y Tipado**: Utiliza tipado estático o anotaciones de tipo siempre que el lenguaje lo permita. Nombra las variables, funciones y clases por su propósito real, reduciendo la necesidad de comentarios redundantes.
---
# PROTOCOLO DE EJECUCIÓN PASO A PASO
Cuando el orquestador te invoque, debes ejecutar la tarea siguiendo estrictamente esta secuencia táctica:
### Paso 1: Absorbente de Contexto
Lee en su totalidad el archivo `SPECIFICATION.md` en la raíz del proyecto.
- Analiza la **Fase 1** para comprender el alcance y los criterios de aceptación.
- Analiza minuciosamente la **Fase 2** para identificar las restricciones, riesgos y reglas de blindaje impuestas por el arquitecto. No ignores ninguna directriz del debater.
### Paso 2: Planificación de Archivos
Antes de escribir código suelto, proyecta mentalmente la estructura de directorios y los archivos que vas a crear o editar para cumplir con los principios de bajo acoplamiento y alta cohesión.
### Paso 3: Codificación Completa y Rigurosa
Utiliza tus herramientas de edición y escritura para modificar la base de código.
- **Prohibición de Placeholders**: Queda estrictamente prohibido dejar funciones vacías, bloques `try-catch` que silencien errores, o comentarios del estilo `// TODO: Añadir validación aquí`. Cada línea de código debe estar completamente implementada.
- **Tratamiento de Bugs/Refactorizaciones**: Si el orquestador te invoca debido a un error previo detectado por el `@qa-tester` (Caso E del orquestador), dirígete de inmediato al reporte de fallos, localiza la raíz del problema y soluciónalo de raíz sin romper la modularidad ni alterar las partes estables del sistema.
### Paso 4: Actualizar el Control de Estado de la Bitácora
Modifica **únicamente** la sección de la máquina de estados ubicada al principio de `SPECIFICATION.md` para notificar al orquestador que has terminado tu turno:
```markdown
## CONTROL DE ESTADO
- **Último Agente Modificador**: software-developer
- **Estado del Ciclo**: Código Implementado - Pendiente de Validación (QA)
---
```
### Paso 5: Anexar la Fase 3 al final del archivo
Ve al final de `SPECIFICATION.md` y registra detalladamente el alcance de tu construcción utilizando el siguiente formato exacto:
```markdown
## Fase 3: Implementación y Cambios de Código
### 3.1 Mapa de Archivos Afectados
- `ruta/completa/archivo1.ext`: [Creado / Modificado] -> [Explica brevemente su propósito en la solución]
- `ruta/completa/archivo2.ext`: [Creado / Modificado] -> [Explica brevemente su propósito en la solución]
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: [Detalla cómo estructuraste las clases/funciones para respetar el agnocitismo o patrones limpios]
- **Mitigación de Riesgos (Fase 2)**: [Explica de qué manera codificaste el software para neutralizar específicamente los riesgos descritos por el debater]
### 3.3 Notas Técnicas para el Tester
* *Dependencias Añadidas*: [Lista de librerías nuevas si fue necesario agregarlas]
* *Puntos Críticos a Probar*: [Indícale al tester qué funciones o flujos lógicos son los más propensos a fallar o requieren pruebas más estrictas]
```
---
# REGLAS DE RESTRICCIÓN OPERATIVA
* Tienes **deshabilitado el acceso a la herramienta Bash**; tu trabajo se limita puramente a la ingeniería y estructuración de archivos lógicos. No intentes compilar, correr scripts de prueba ni hacer operaciones de control de versiones.
* Si encuentras una contradicción insalvable entre el plan del clarificador y las restricciones del debater, frena la ejecución y notifica en el chat principal al orquestador para que tome una decisión de gestión.
---
# CRITERIOS DE SALIDA
Una vez guardados los cambios en el código y anexada la Fase 3 en `SPECIFICATION.md`, finaliza tu intervención informando textualmente en la sesión principal: *"Fase 3 concluida. He implementado el código fuente bajo estándares limpios, mitigado los riesgos de diseño y actualizado la bitácora. El Control de Estado ha sido establecido en: Código Implementado - Pendiente de Validación (QA)."*
+198 -101
View File
@@ -1,122 +1,219 @@
# Claro Cases Tracking System - Manual Técnico
# Claro Cases — Dashboard HITL
Este documento detalla el funcionamiento técnico, arquitectura y endpoints de la aplicación **Claro Cases**, un sistema en tiempo real para rastrear solicitudes de clientes, gestionar casos y registrar métricas de tiempo de atención.
Dashboard *Human-in-the-Loop* (HITL) para que asesores humanos gestionen peticiones enviadas por un agente virtual durante conversaciones con clientes, y monitoreen la operación en tiempo real.
---
## 🏗️ Arquitectura del Sistema
## 🏗️ Arquitectura
La aplicación sigue una arquitectura desacoplada monolítica simple basada en Node.js, Express y SQLite:
```mermaid
graph TD
Client[Cliente Externo / API Client] -->|POST /api/requests| ExpressServer[Express Server Node.js]
ExpressServer -->|Guarda Datos| SQLite[SQLite database.sqlite]
ExpressServer -->|SSE Broadcast| Frontend[Frontend SPA HTML/JS]
Frontend -->|Interacción de Operador| ExpressServer
```
┌──────────────────────────────────────────────────┐
│ Frontend React (este repo) │
│ │
│ /cases ──▶ Gestión HITL (formularios dinámicos)│
│ /monitor ──▶ Monitoreo en tiempo real │
│ │
│ REST (POST) ──▶ Backend Python (canal escritura) │
│ WebSocket ◀── Backend Python (difusión/stream) │
└──────────────────────────────────────────────────┘
```
1. **Frontend (SPA - Single Page Application)**: Desarrollado con Vanilla HTML5, Javascript (ES6) y CSS3 adaptativo con soporte para temas Claro/Oscuro.
2. **Backend**: Servidor REST Express que provee persistencia y notificaciones en tiempo real a través de Server-Sent Events (SSE).
3. **Persistencia**: Base de datos ligera embebida SQLite gestionada eficientemente de manera síncrona por medio del driver `better-sqlite3`.
- **REST** es el canal autoritativo de escritura (resolución de casos)
- **WebSocket** es para difusión de eventos y streaming en tiempo real
- **MSW** (Mock Service Worker) simula el backend en modo desarrollo
### Stack
| Componente | Tecnología |
|-----------|-----------|
| Framework | React 19 + TypeScript |
| Build | Vite 6 |
| Estado global | Zustand |
| Ruteo | React Router (`/cases`, `/monitor`) |
| Estilos | Tailwind CSS v4 (CSS-first con `@theme`) |
| Validación | Zod |
| Iconos | Lucide React |
| Mocks | MSW (Mock Service Worker) |
---
## 🗄️ Esquema de la Base de Datos
## 📂 Estructura del Proyecto
La tabla principal es `requests`. La inicialización del esquema y las migraciones automáticas de columnas se gestionan dinámicamente en el módulo [db.js](file:///c:/Users/pepit/Desktop/doc/NODE/autonomus/Claro%20Cases/db.js).
```sql
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'Pending',
external_id TEXT,
cedula TEXT,
tipo_solicitud TEXT,
payload TEXT, -- Objeto JSON guardado como String
handling_time INTEGER DEFAULT 0, -- Tiempo de gestión en segundos
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
claro-cases/
├── index.html # Vite entry point
├── package.json # Dependencias y scripts
├── vite.config.ts # Configuración Vite + Tailwind + alias @/
├── tsconfig.json # TypeScript config
├── .env # Variables de entorno (VITE_*)
├── SPECIFICATION.md # Bitácora de desarrollo
├── BACKEND_HANDOFF.md # Contratos para equipo Python
├── Consulta de aplicativos... # CSV con 53 tipos de caso
├── src/
│ ├── main.tsx # Entry point React + MSW condicional
│ ├── App.tsx # Router principal
│ ├── index.css # Tailwind v4 @theme + @custom-variant dark
│ │
│ ├── types/
│ │ ├── index.ts # Interfaces: CaseRequest, Conversation, Message, etc.
│ │ └── wsProtocol.ts # Schemas Zod de eventos WebSocket
│ │
│ ├── data/
│ │ └── caseTypeDefinitions.ts # 53 tipos de caso con uiPattern y formFields
│ │
│ ├── services/
│ │ ├── api.ts # Cliente REST (fetch nativo)
│ │ └── wsClient.ts # Cliente WebSocket con reconexión
│ │
│ ├── store/
│ │ └── useAppStore.ts # Zustand store: cases, conversations, ui
│ │
│ ├── hooks/
│ │ ├── useNotification.ts # Notificaciones escritorio HTML5
│ │ ├── useSound.ts # Alerta sonora Web Audio API
│ │ ├── useTitleFlash.ts # Parpadeo de título de pestaña
│ │ └── index.ts # Barrel export
│ │
│ ├── components/
│ │ ├── layout/
│ │ │ ├── AppShell.tsx # Shell global (WS init, tema oscuro)
│ │ │ ├── Header.tsx # Logo, badge, indicador WS, toggle tema
│ │ │ └── Sidebar.tsx # Nav: Casos / Monitor
│ │ │
│ │ ├── cases/
│ │ │ ├── CaseCard.tsx # Tarjeta de caso (sidebar)
│ │ │ ├── CaseDetail.tsx # Panel de detalle
│ │ │ ├── FormRenderer.tsx # Formulario dinámico (6 patrones UI)
│ │ │ ├── TypeBadge.tsx # Badge de tipo de solicitud
│ │ │ └── ApplicativeFilter.tsx # Filtro por aplicativo
│ │ │
│ │ ├── monitor/
│ │ │ ├── ConversationCard.tsx # Tarjeta de conversación
│ │ │ ├── ChatFeed.tsx # Feed de mensajes con auto-scroll
│ │ │ ├── MessageBubble.tsx # Burbuja por rol (user/agent/system/internal)
│ │ │ ├── InternalNoteBanner.tsx # Inyección de notas internas
│ │ │ └── InternalNotesGroup.tsx # Acordeón de notas agrupadas
│ │ │
│ │ └── shared/
│ │ ├── StatusBadge.tsx # Badge de estado (PENDING/IN_PROGRESS/RESOLVED/FAILED)
│ │ ├── SearchBar.tsx # Input con debounce 300ms
│ │ ├── TabsBar.tsx # Pestañas: Todos/Pendientes/Finalizados
│ │ ├── EmptyState.tsx # Estado vacío centrado
│ │ ├── Modal.tsx # Overlay modal con backdrop blur
│ │ └── Timer.tsx # Cronómetro independiente (localStorage)
│ │
│ ├── pages/
│ │ ├── CasesPage.tsx # Vista /cases: sidebar + detalle
│ │ └── MonitorPage.tsx # Vista /monitor: conversaciones + chat
│ │
│ └── mocks/
│ ├── browser.ts # MSW setup
│ └── handlers.ts # 10 casos mock + 3 conversaciones
├── public/
│ └── mockServiceWorker.js # MSW service worker
└── legacy/ # Backend legacy Node.js + SQLite
├── server.js
├── db.js
├── schema.sql
├── package.json
└── public/ # Frontend vanilla original
```
---
## 🔌 API Endpoints (Backend)
## 🚀 Ejecución
### 1. Registrar Nuevo Caso
* **Endpoint**: `POST /api/requests`
* **Content-Type**: `application/json`
* **Payload**:
```json
{
"title": "Título del Caso",
"description": "Detalle descriptivo",
"external_id": "EXT-12345",
"cedula": "10203040",
"tipo_solicitud": "validacion_usuario",
"payload": {
"nombre": "Juan Pérez",
"telefono": "31000000"
}
}
```
* **Comportamiento**: Guarda la solicitud y transmite en tiempo real la información a todos los operadores conectados usando SSE.
### Prerrequisitos
- Node.js 20+
### 2. Stream de Actualizaciones en Tiempo Real
* **Endpoint**: `GET /api/sse`
* **Content-Type**: `text/event-stream`
* **Comportamiento**: Registra el cliente para recibir eventos unidireccionales desde el servidor en tiempo real.
### 3. Obtener Solicitudes
* **Endpoint**: `GET /api/requests`
* **Comportamiento**: Devuelve todos los casos de la base de datos en orden descendente por fecha de creación.
### 4. Actualizar Estado / Finalizar Caso
* **Endpoint**: `PUT /api/requests/:id`
* **Payload**:
```json
{
"status": "Finalizado",
"handling_time": 125,
"payload": {
"valor_cierre": "Comentario de finalización"
}
}
```
### 5. Eliminar Solicitud
* **Endpoint**: `DELETE /api/requests/:id`
---
## ⚡ Lógica del Operador (Frontend)
El flujo interactivo se gestiona en [public/app.js](file:///c:/Users/pepit/Desktop/doc/NODE/autonomus/Claro%20Cases/public/app.js) y se compone de:
* **Sincronización en Tiempo Real (SSE)**: Permite la llegada instantánea de nuevos casos al panel de control del operador sin recargar la página. Al ingresar un caso nuevo:
- Se reproduce una alerta de sonido usando la API nativa de **Web Audio**.
- Se genera una **Notificación de Escritorio HTML5** en el sistema operativo.
- El título de la pestaña del navegador destella con la cantidad de casos pendientes.
* **Filtros e Historial**:
- Buscador integrado que busca en tiempo real por título, ID de referencia, descripción, cédula y tipo de solicitud.
- Pestañas rápidas para clasificar casos entre *Todos*, *Pendientes* y *Finalizados*.
* **Flujos Especiales al Gestionar Casos**:
- Al dar clic en **Gestionar Caso**, se inicia un cronómetro individual para medir el tiempo que el operador tarda en resolver la solicitud.
- **Solicitud de Validación**: Si el caso contiene la palabra `"validacion"` en su campo `tipo_solicitud`, la UI cambia dinámicamente y despliega los botones **Sí** y **No** integrados bajo la pregunta *"¿El usuario es válido?"*. Al hacer clic en cualquiera de ellos, el caso se cierra.
- **Otras Solicitudes**: Si es de cualquier otro tipo, la interfaz le exige al operador ingresar un valor de resolución en una entrada de texto antes de dar clic en **Finalizar Caso**. Este valor se inyecta en el objeto JSON de `payload` del caso.
---
## 🛠️ Ejecución Local
1. Instalar dependencias:
```cmd
### Instalación
```bash
npm install
```
2. Ejecutar servidor en modo desarrollo (con recarga automática vía nodemon):
```cmd
### Desarrollo (con MSW — sin backend real)
```bash
npm run dev
```
El servidor levantará en http://localhost:3000.
- `http://localhost:5173/cases` — Gestión de casos HITL
- `http://localhost:5173/monitor` — Monitoreo de conversaciones
Con `VITE_ENABLE_MSW=true` en `.env`, el MSW simula el backend automáticamente. Para usar un backend real, cambia a `VITE_ENABLE_MSW=false`.
### Build de producción
```bash
npm run build # Genera dist/
npm run preview # Previsualiza el build
```
---
## 🎨 Sistema de Diseño
El tema está definido en `src/index.css` mediante la directiva `@theme` de Tailwind v4:
| Token | Valor |
|-------|-------|
| `--color-accent-orange` | `#ff4e00` |
| `--color-accent-yellow` | `#ffa600` |
| `--color-accent-red` | `#f80018` |
| `--color-accent-green` | `#10b981` |
| `--color-bg-base` | `#f0f2f5` |
| `--color-bg-surface` | `#ffffff` |
| `--color-bg-elevated` | `#f8fafc` |
| Tipografía | Inter (Google Fonts) |
Modo oscuro: toggle vía `@custom-variant dark` + clase `.dark` en `<html>`.
---
## 📡 Contratos de Integración
El backend Python debe implementar los contratos documentados en [`BACKEND_HANDOFF.md`](./BACKEND_HANDOFF.md). Resumen rápido:
### REST (canal autoritativo)
| Método | Ruta | Descripción |
|--------|------|-------------|
| `GET` | `/api/v1/cases?status=&applicative=&search=&offset=&limit=` | Listar casos (filtrable, paginado) |
| `GET` | `/api/v1/cases/:id` | Obtener caso |
| `POST` | `/api/v1/cases/:id/resolve` | Resolver caso (**único canal de escritura**) |
| `GET` | `/api/v1/conversations/active` | Conversaciones activas |
### WebSocket (`/ws/dashboard`)
- **Server → Client**: `init_state`, `conversation_started`, `agent_stream_started/chunk/completed`, `hitl_request`, `hitl_resolved`
- **Client → Server**: `internal_note` (notas internas del asesor)
- Envelope: `{ type, eventId, occurredAt, payload }`
---
## 🤖 Modo Mock (MSW)
El proyecto incluye 10 casos de ejemplo y 3 conversaciones simuladas para desarrollo sin backend:
```bash
# .env
VITE_ENABLE_MSW=true
```
Los mocks cubren los 6 patrones de UI (`SIMPLE_CONFIRMATION`, `CONFIRMATION_WITH_VALUE`, `MULTI_FIELD_FORM`, `DATE_SIMPLE`, `FREE_TEXT`, `READ_ONLY`) y múltiples aplicativos (AC+, ASCARD, DiMe, Formatos SGCS, Mi asistencia 360, RR).
---
## 🔐 Seguridad
- **`advisorId` NUNCA es enviado por el frontend**. El backend deriva la identidad del asesor desde el token de sesión (REST) o conexión WebSocket autenticada.
- La resolución de casos es exclusivamente REST — no hay ruta de escritura por WebSocket que pueda ser explotada.
---
## 📚 Documentación Adicional
| Archivo | Contenido |
|---------|-----------|
| [`SPECIFICATION.md`](./SPECIFICATION.md) | Bitácora completa: fases de diseño, auditoría, implementación y QA |
| [`BACKEND_HANDOFF.md`](./BACKEND_HANDOFF.md) | Contratos REST/WS detallados para el equipo Python |
| [`Consulta de aplicativos - Claro - Facturación.csv`](./Consulta%20de%20aplicativos%20-%20Claro%20-%20Facturaci%C3%B3n.csv) | 53 tipos de caso con pasos, objetivos y formatos de respuesta |
| [`legacy/README.md`](./legacy/README.md) | Documentación del backend legacy (Node.js + SQLite) |
+810
View File
@@ -0,0 +1,810 @@
# BITÁCORA DE DESARROLLO Y ESPECIFICACIONES
## CONTROL DE ESTADO
- **Último Agente Modificador**: qa-tester
- **Estado del Ciclo**: [STATUS: PASSED] - Listo para Producción / Git
---
## Fase 1: Requerimientos y Plan Inicial
### 1.1 Resumen Ejecutivo
- **Tipo de Tarea**: Migración y expansión (New Feature + Rewrite)
- **Objetivo General**: Reescribir el dashboard Claro Cases de vanilla HTML/CSS/JS a React + TypeScript + Vite, expandiéndolo con dos módulos: (1) Gestión de Casos HITL con formularios dinámicos por tipología y (2) Monitoreo completo de conversaciones en tiempo real con capacidad de intervención mediante notas internas, utilizando comunicación híbrida REST + WebSocket.
### 1.2 Contexto Técnico y Hallazgos
#### Estado Actual (Proyecto Claro Cases existente)
- **Backend**: Node.js + Express + SQLite (`better-sqlite3`). Monolítico, acoplado al frontend.
- **Frontend**: SPA vanilla HTML/CSS/JS. Sidebar de casos + panel de detalle.
- **Comunicación**: REST (CRUD) + SSE unidireccional para notificaciones.
- **Persistencia**: SQLite local (`database.sqlite`). Tabla `requests` con campos: `id`, `title`, `description`, `status`, `external_id`, `cedula`, `tipo_solicitud`, `payload` (JSON), `handling_time`, `created_at`.
- **Lógica actual**: Dos flujos de resolución (validación Sí/No y texto libre). Cronómetros individuales con persistencia en `localStorage`. Notificaciones de escritorio + sonido Web Audio + parpadeo de título.
- **Estilos**: Sistema de diseño con CSS custom properties. Paleta orange/red/yellow/green. Tipografía Inter. Modo oscuro/claro. Sin framework CSS.
#### Proyecto de Referencia (Linguo Nexus)
- **Stack**: React 19 + TypeScript + Vite + Tailwind CSS v4.
- **Estado**: Zustand store centralizado.
- **Ruteo**: React Router con `/monitor` e `/intervention`.
- **Comunicación**: REST (`/api/v1/conversations/active`, `/api/v1/tickets/pending`) + WebSocket (`/ws/monitor`) con eventos tipados (`init_state`, `conversation_started`, `user_message`, `agent_stream`, `hitl_required`, `hitl_resolved`, `CLIENT_TOOL_REQUEST`).
- **Validación**: Zod para payloads WebSocket y edge tool calling.
- **UI**: Kanban drag&drop (`@dnd-kit`), streaming token-a-token con auto-scroll, renderizado Markdown (`marked-react`), leader election (`navigator.locks`).
#### Tipos de Caso (CSV: 45 registros)
- **Aplicativos origen**: AC+, ASCARD, DiMe, Formatos SGCS, Mi asistencia 360, Paradigma, RR, Phone Protect.
- **Taxonomía de interacción aprobada**: Confirmación simple, Confirmación + valor, Formulario multi-campo, Fecha simple, Texto libre, Solo lectura.
- **Jerarquía secundaria**: Filtro por aplicativo (columna A del CSV).
#### Módulos/Archivos Impactados
- `public/index.html`: Reemplazado por `index.html` de Vite + React root.
- `public/style.css`: Migrado a Tailwind config + CSS custom properties preservados.
- `public/app.js`: Reescrito en componentes React + Zustand store.
- `server.js`: Backend actual se reemplazará por backend Python (fuera del scope de esta migración frontend).
- `db.js`, `schema.sql`, `database.sqlite`: Reemplazados por backend Python.
- `Consulta de aplicativos - Claro - Facturación.csv`: Parseado e incrustado como datos estáticos en `src/data/caseTypeDefinitions.ts`.
### 1.3 Plan Lógico de Solución (Paso a Paso)
#### Paso 0 — Bootstrap del proyecto React + TypeScript + Vite y Reestructuración del Repositorio
1. **Reorganización del repositorio** (previa al bootstrap):
- Mover todo el backend legacy (`server.js`, `db.js`, `schema.sql`, `database.sqlite`, `node_modules/`, `public/`, `package.json`, `package-lock.json`, `.env`, `.env.example`) a un subdirectorio `legacy/`.
- Conservar en la raíz: `.git/`, `.opencode/`, `SPECIFICATION.md`, `Consulta de aplicativos - Claro - Facturación.csv`, `README.md`.
2. Inicializar proyecto con `npm create vite@latest . -- --template react-ts` en el directorio raíz.
3. Instalar dependencias core: `react-router-dom`, `zustand`, `zod`, `date-fns`, `lucide-react`.
4. Instalar dependencias de desarrollo: `msw` (Mock Service Worker para desacoplar frontend del backend), `@testing-library/react`, `vitest`.
5. Configurar **Tailwind CSS v4 con enfoque CSS-first** (sin `tailwind.config.ts`):
- Definir design tokens en `src/index.css` mediante la directiva `@theme`:
```css
@import "tailwindcss";
@theme {
--color-accent-orange: #ff4e00;
--color-accent-yellow: #ffa600;
--color-accent-red: #f80018;
--color-accent-green: #10b981;
--color-bg-base: #f0f2f5;
--color-bg-surface: #ffffff;
--color-bg-elevated: #f8fafc;
--color-bg-hover: #e2e8f0;
--color-text-primary: #1e293b;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
--color-border: rgba(0, 0, 0, 0.08);
--color-border-accent: rgba(255, 78, 0, 0.25);
--radius-sm: 4px; --radius-md: 8px; --radius-lg: 12px; --radius-xl: 16px;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 12px 24px rgba(0, 0, 0, 0.12);
--font-family-sans: 'Inter', system-ui, sans-serif;
--transition-default: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
}
```
- Modo oscuro mediante `@custom-variant dark (&:where(.dark, .dark *))` con overrides de variables en bloque `@media (prefers-color-scheme: dark)` y clase `.dark` toggleada manualmente.
- Animaciones definidas como `@keyframes` en el mismo archivo CSS.
6. Estructura de carpetas:
```
src/
components/
layout/ (AppShell, Sidebar, Header, StatusBar)
cases/ (CaseCard, CaseDetail, FormRenderer, Timer, TypeBadge, ApplicativeFilter)
monitor/ (ConversationCard, ChatFeed, MessageBubble, InternalNoteBanner, InterventionPanel)
shared/ (StatusBadge, SearchBar, TabsBar, Modal, EmptyState)
hooks/ (useWebSocket, useTimer, useNotification)
services/ (api.ts, wsClient.ts)
store/ (useAppStore.ts — slices: cases, conversations, ui)
types/ (index.ts, wsProtocol.ts, caseTypes.ts)
pages/ (CasesPage.tsx, MonitorPage.tsx)
data/ (caseTypeDefinitions.ts — parsed from CSV)
App.tsx
main.tsx
```
#### Paso 1 — Sistema de Tipos y Contratos
1. **`src/types/index.ts`**: Interfaces base:
- `CaseRequest`: id, title, description, status, externalId, cedula, tipoSolicitud, payload, handlingTime, createdAt, applicative, uiPattern.
- `Conversation`: id, clientId, agentId, status, messages[], createdAt.
- `Message`: id, conversationId, role (user/agent/system/internal), content, timestamp, metadata?.
- `CaseUIType` enum: `SIMPLE_CONFIRMATION`, `CONFIRMATION_WITH_VALUE`, `MULTI_FIELD_FORM`, `DATE_SIMPLE`, `FREE_TEXT`, `READ_ONLY`.
- `CaseStatus`: `PENDING`, `IN_PROGRESS`, `RESOLVED`, `FAILED`.
- `AgentStatus`: `ONLINE`, `BUSY`, `OFFLINE`.
2. **`src/types/wsProtocol.ts`**: Contratos WebSocket tipados (Zod):
- **Eventos entrantes (backend → frontend)**:
- `init_state`: `{ conversations: Conversation[], activeCases: CaseRequest[] }`
- `conversation_started`: `{ conversation: Conversation }`
- `conversation_update`: `{ conversationId: string, message: Message }`
- `agent_stream`: `{ conversationId: string, token: string }`
- `agent_status_update`: `{ agentId: string, status: AgentStatus }`
- `hitl_request`: `{ case: CaseRequest, conversationId: string }`
- `hitl_resolved`: `{ caseId: string, resolution: object }`
- **Eventos salientes (frontend → backend)**:
- `internal_note`: `{ conversationId: string, content: string }` (sin `advisorId`; backend deriva identidad)
3. **`src/data/caseTypeDefinitions.ts`**: Mapeo completo de los 45 tipos del CSV a `CaseTypeDefinition`:
```ts
interface CaseTypeDefinition {
toolName: string; // Ej: "Validar_Proporcionales_Movil"
applicative: string; // Ej: "AC+"
specialist: string; // Ej: "Cobros adicionales - Móvil"
inputData: string; // Ej: "Número de la línea"
steps: string[]; // Paso a paso
objective: string;
responseFormat: string; // Formato de respuesta esperada (según CSV)
document: string; // Categoría documental
uiPattern: CaseUIType; // Clasificación de UI (6 familias visuales)
formFields: FormField[]; // Campos del formulario dinámico
validationSchema: ZodSchema; // Esquema Zod de validación del payload de respuesta
payloadBuilder: (formData: Record<string, unknown>) => object; // Serializador a payload para el backend
}
interface FormField {
key: string; // Identificador del campo
label: string; // Etiqueta visible
type: 'text' | 'number' | 'currency' | 'date' | 'select' | 'textarea' | 'toggle';
required: boolean;
placeholder?: string;
options?: { value: string; label: string }[]; // Para type: 'select'
min?: number; // Para type: 'number'/'currency'
max?: number;
conditionalOn?: { field: string; value: unknown }; // Campo condicional
}
```
- **Ejemplo concreto** — `Plan_De_Pagos_EF` (ASCARD, Equipos financiados):
```ts
{
toolName: "Plan_De_Pagos_EF",
applicative: "ASCARD",
uiPattern: CaseUIType.MULTI_FIELD_FORM,
formFields: [
{ key: "numero_cuotas", label: "Número de cuotas", type: "number", required: true, min: 1 },
{ key: "valor_cuota", label: "Valor de la cuota", type: "currency", required: true },
{ key: "dia_corte", label: "Día de corte", type: "number", required: true, min: 1, max: 31 },
{ key: "dia_limite_pago", label: "Día límite de pago", type: "number", required: true, min: 1, max: 31 }
],
validationSchema: z.object({
numero_cuotas: z.number().int().min(1),
valor_cuota: z.number().positive(),
dia_corte: z.number().int().min(1).max(31),
dia_limite_pago: z.number().int().min(1).max(31)
}),
payloadBuilder: (data) => ({
numero_cuotas: data.numero_cuotas,
valor_cuota: data.valor_cuota,
dia_corte: data.dia_corte,
dia_limite_pago: data.dia_limite_pago
})
}
```
#### Paso 2 — Capa de Servicios y Store (arquitectura híbrida: REST autoritativo + WS difusión)
1. **`src/services/api.ts`**: Cliente REST (canal autoritativo de escritura):
- `GET /api/v1/cases?status=&applicative=&search=&offset=&limit=` → `{ items: CaseRequest[], total: number }` (filtrable, paginado).
- `GET /api/v1/cases/:id` → `CaseRequest` (detalle de caso).
- `POST /api/v1/cases/:id/resolve` → `CaseRequest` (canal único de resolución; el backend deriva `advisorId` del token de sesión).
- `GET /api/v1/conversations/active` → `Conversation[]`.
- Base URL configurable via variable de entorno (`VITE_API_BASE_URL`).
- Se implementará una capa de **MSW (Mock Service Worker)** con handlers que simulen estas respuestas para desarrollo sin backend.
2. **`src/hooks/useWebSocket.ts`**: Hook de conexión WebSocket (solo difusión/streaming, sin escritura de negocio):
- Conexión a `ws://<host>/ws/dashboard`.
- Reconexión automática con backoff exponencial (inicio 1s, máx 30s, factor 2x).
- Al reconectar, el backend envía `init_state` para resincronizar; el frontend reemplaza el estado local completo.
- Parseo con Zod de cada mensaje entrante usando el envelope estándar (ver Paso 8).
- Dispatch a acciones del store según `payload.type`.
- Envío de eventos salientes solo para `internal_note` (sin `advisorId`; el backend deriva la identidad).
- Indicador de estado de conexión en el store (`connected` | `disconnected` | `reconnecting`).
- **No se emite `hitl_response` por WebSocket**; la resolución de casos es exclusiva de REST.
3. **`src/store/useAppStore.ts`**: Store centralizado Zustand con slices:
- **casesSlice**: `cases[]`, `selectedCaseId`, `totalCases`, `fetchCases(filters)`, `upsertCase()`, `resolveCase()`, `deleteCase()`.
- **conversationsSlice**: `conversations[]`, `selectedConversationId`, `fetchConversations()`, `upsertConversation()`, `addMessage()`, `appendToken()`.
- **uiSlice**: `sidebarTab`, `searchQuery`, `applicativeFilter`, `isDarkMode`, `wsStatus`.
- **timerSlice**: Timers gestionados con `useRef` para intervalos (evitar re-renders); `localStorage` solo como caché de UI, no como fuente de verdad para `handling_time` (el backend calcula con `startedAt`/`resolvedAt`).
#### Paso 3 — Componentes Compartidos
1. **`StatusBadge`**: Badge de estado con colores por estado (`pending`/`in_progress`/`resolved`/`failed`).
2. **`SearchBar`**: Input de búsqueda con debounce.
3. **`TabsBar`**: Pestañas de filtro (Todos/Pendientes/Finalizados).
4. **`ApplicativeFilter`**: Dropdown/chips para filtrar por aplicativo (AC+, ASCARD, RR, etc.).
5. **`Timer`**: Cronómetro independiente por caso con persistencia en `localStorage` (migrado del JS actual).
6. **`Modal`**: Diálogo de confirmación genérico.
7. **`EmptyState`**: Estado vacío para paneles sin selección.
#### Paso 4 — Módulo de Gestión de Casos HITL (`/cases`)
1. **`CasesPage.tsx`**: Layout maestro: sidebar izquierda (lista de casos) + panel derecho (detalle/acciones).
2. **`CaseCard.tsx`**: Tarjeta de caso en la lista con título, status badge, timer (si activo), tipo de solicitud, aplicativo, fecha.
3. **`CaseDetail.tsx`**: Vista detallada del caso seleccionado con:
- Metadata grid (ID, cédula, tipo solicitud, aplicativo).
- Descripción del caso.
- Payload de datos entrantes.
- **`FormRenderer.tsx`**: Componente dinámico que renderiza el formulario adecuado según `uiPattern`:
- `SIMPLE_CONFIRMATION` → Botones "Sí" / "No".
- `CONFIRMATION_WITH_VALUE` → Radio group (Sí/No) + campo numérico con prefijo `$`.
- `MULTI_FIELD_FORM` → Formulario con campos definidos en `formFields[]` (text, number, select, date).
- `DATE_SIMPLE` → Date picker con formato `dd-mm-aaaa`.
- `FREE_TEXT` → Textarea con placeholder contextual.
- `READ_ONLY` → Panel informativo sin campos editables, solo botón "Marcar como revisado".
- Panel de operación con timer y botones de acción.
- Instrucciones paso a paso del aplicativo (del CSV) colapsables en acordeón.
4. **Flujo de resolución**:
- Asesor abre caso → timer inicia automáticamente.
- Completa formulario dinámico → botón "Enviar resolución".
- Se envía `POST /api/v1/cases/:id/resolve` (REST, canal autoritativo) con payload estructurado. El backend difunde `hitl_resolved` por WS a todos los asesores.
- Caso pasa a estado `resolved` y timer se detiene.
#### Paso 5 — Módulo de Monitoreo (`/monitor`)
1. **`MonitorPage.tsx`**: Layout de dos columnas: lista de conversaciones (izquierda estrecha) + feed de chat (derecha amplia).
2. **`ConversationCard.tsx`**: Tarjeta de conversación activa mostrando:
- ID/Nombre del cliente.
- Último mensaje (truncado).
- Indicador de streaming activo (spinner).
- Badge de HITL pendiente.
- Estado del agente asignado.
3. **`ChatFeed.tsx`**: Feed de mensajes con:
- Auto-scroll inteligente (respeta scroll manual del usuario, reanuda al llegar al fondo).
- Renderizado de mensajes con diferenciación visual por rol (cliente, agente, sistema).
- **Streaming token-a-token**: Concatenación progresiva de tokens en el último mensaje del agente.
4. **`MessageBubble.tsx`**: Burbuja de mensaje individual con timestamp y rol.
5. **`InternalNoteBanner.tsx`**: Banner de intervención que permite al asesor:
- Escribir nota interna en un textarea.
- Previsualizar cómo se verá en la conversación (etiquetada como "Nota interna").
- Enviar vía WebSocket (`internal_note`).
6. **`InterventionPanel.tsx`**: Panel lateral o modal para cuando se detecta un caso HITL asociado a la conversación activa.
#### Paso 6 — Ruteo y Shell de Aplicación
1. **`App.tsx`**: Router con dos rutas:
- `/` → redirect a `/cases`.
- `/cases` → `CasesPage`.
- `/monitor` → `MonitorPage`.
2. **`AppShell.tsx`**: Layout global:
- **`Header`**: Logo Claro Cases, badge "En vivo", indicador de conexión WebSocket, toggle tema oscuro.
- **`Sidebar`**: Navegación entre módulos (Casos, Monitor) con iconos de `lucide-react`.
- Inicializa WebSocket y fetch inicial al montar.
#### Paso 7 — Migración de Estilos (Preservar línea gráfica)
1. Extraer todos los design tokens del `style.css` actual a bloques `@theme` en `src/index.css` (ver Paso 0 para la configuración completa).
2. Mapear cada clase CSS a utilidades Tailwind equivalentes:
- `.app-header` → `flex items-center justify-between h-[50px] px-4 border-b bg-surface shadow-sm`
- `.case-card` → `bg-elevated border border-border rounded-md p-3 cursor-pointer transition`
- `.btn-primary` → `bg-accent-orange text-white px-4 py-2 rounded-md font-semibold`
3. Preservar animaciones (`slideIn`, `fadeIn`, `pulse-op`) como keyframes en Tailwind config.
4. Scrollbar styling → utilities de Tailwind o CSS global.
5. Modo oscuro: conservar lógica de toggle con `class` strategy de Tailwind + persistencia en `localStorage`.
#### Paso 8 — Contratos de Comunicación Completos (para el equipo Python)
##### 8.1 Envelope WebSocket Estándar
Todo mensaje WebSocket (en ambas direcciones) usa el siguiente envelope JSON:
```json
{
"type": "string", // Tipo de evento (ej. "agent_stream")
"eventId": "uuid", // ID único del evento para deduplicación
"occurredAt": "ISO-8601",// Timestamp UTC del lado emisor
"payload": { } // Carga específica del evento
}
```
##### 8.2 REST Endpoints (canal autoritativo)
| Método | Ruta | Query Params | Body | Respuesta |
|--------|------|-------------|------|-----------|
| `GET` | `/api/v1/cases` | `status`, `applicative`, `search`, `offset`, `limit` | — | `{ items: CaseRequest[], total: number }` |
| `GET` | `/api/v1/cases/:id` | — | — | `CaseRequest` |
| `POST` | `/api/v1/cases/:id/resolve` | — | `{ action, payload, note? }` | `CaseRequest` (updated) |
| `GET` | `/api/v1/conversations/active` | — | — | `Conversation[]` |
| `GET` | `/api/v1/conversations/:id` | — | — | `Conversation` (con mensajes) |
> **Nota para backend**: `POST /cases/:id/resolve` no recibe `advisorId`. El backend debe derivar la identidad del asesor desde el token de autenticación de la sesión HTTP (Bearer token o cookie).
##### 8.3 WebSocket Events (servidor → cliente)
| Evento `type` | Payload | Trigger |
|---------------|---------|---------|
| `init_state` | `{ conversations: Conversation[], activeCases: CaseRequest[] }` | Al conectar o reconectar |
| `conversation_started` | `{ conversation: Conversation }` | Nueva conversación |
| `conversation_ended` | `{ conversationId: string, endedAt: ISO-8601 }` | Conversación finalizada |
| `user_message` | `{ conversationId: string, message: Message }` | Mensaje completo de usuario |
| `agent_stream_started` | `{ conversationId: string, messageId: string }` | Inicio de streaming del agente |
| `agent_stream_chunk` | `{ conversationId: string, messageId: string, token: string, index: number }` | Token individual con índice de orden |
| `agent_stream_completed` | `{ conversationId: string, messageId: string, fullContent: string }` | Cierre de streaming; `fullContent` es el texto completo para verificación |
| `agent_status_update` | `{ agentId: string, status: AgentStatus }` | Cambio de estado del agente |
| `hitl_request` | `{ case: CaseRequest, conversationId: string }` | Se requiere intervención humana |
| `hitl_resolved` | `{ caseId: string, resolution: object }` | Caso resuelto (broadcast a todos los asesores) |
| `error` | `{ code: string, message: string, details?: object }` | Error del servidor notificable al frontend |
##### 8.4 WebSocket Events (cliente → servidor)
| Evento `type` | Payload | Trigger |
|---------------|---------|---------|
| `internal_note` | `{ conversationId: string, content: string }` | Asesor inyecta nota interna |
> **Nota**: El backend deriva `advisorId` del contexto de la conexión WebSocket autenticada. El cliente **no** envía identificadores de asesor en ningún payload.
##### 8.5 Estrategia de Reconexión
1. Backoff exponencial: 1s → 2s → 4s → 8s → 16s → 30s (máx).
2. Al reconectar exitosamente, el servidor envía `init_state` con el estado completo actual.
3. El frontend reemplaza `conversations` y `activeCases` con los datos de `init_state`.
4. Durante la desconexión, el frontend muestra indicador "Reconectando..." y deshabilita acciones de escritura (resolución de casos e inyección de notas).
##### 8.6 Estrategia de Streaming (lado frontend)
- `agent_stream_started`: crear mensaje placeholder en la conversación con `isStreaming: true`.
- `agent_stream_chunk`: concatenar token al contenido del mensaje usando el `index` para garantizar orden (no asumir orden de llegada de red).
- `agent_stream_completed`: marcar mensaje con `isStreaming: false`, reemplazar contenido con `fullContent` para verificación de integridad.
- Las actualizaciones al store se bufferizan cada 50ms (máximo 20 actualizaciones/segundo) para evitar re-renders excesivos. Solo la conversación activa/seleccionada dispara re-renders de UI; las demás acumulan tokens en el store sin re-render hasta ser seleccionadas.
### 1.4 Criterios de Aceptación
- [ ] **CA-1**: Proyecto arranca con `npm run dev` sobre Vite + React + TypeScript, sirviendo en `localhost:5173`.
- [ ] **CA-2**: Ruteo funcional: `/` redirige a `/cases`; navegación entre `/cases` y `/monitor` vía sidebar con iconos `lucide-react`.
- [ ] **CA-3**: Sidebar de casos muestra lista con búsqueda textual (debounced 300ms), pestañas (Todos/Pendientes/Finalizados) y filtro secundario por aplicativo (chips/dropdown con los 8 aplicativos del CSV).
- [ ] **CA-4**: Al seleccionar un caso, el panel de detalle renderiza el formulario dinámico correcto según el `uiPattern` del tipo de caso, con validación Zod antes de enviar.
- [ ] **CA-5**: El formulario `MULTI_FIELD_FORM` renderiza campos específicos (ej. para `Plan_De_Pagos_EF`: número de cuotas, valor cuota, día corte, día límite) con validación por tipo (número, moneda, rango) y mensajes de error inline.
- [ ] **CA-6**: Timer independiente por caso con persistencia en `localStorage` como cache de UI; el `handling_time` oficial lo calcula el backend con `startedAt`/`resolvedAt`.
- [ ] **CA-7**: Resolución de caso se envía exclusivamente por REST (`POST /cases/:id/resolve`). El backend difunde `hitl_resolved` por WS a todos los asesores conectados.
- [ ] **CA-8**: Módulo de monitoreo muestra lista de conversaciones activas con streaming token-a-token usando eventos `agent_stream_started`/`agent_stream_chunk`/`agent_stream_completed`, con buffer de 50ms para limitar re-renders a 20 fps.
- [ ] **CA-9**: Chat feed con auto-scroll inteligente y diferenciación visual de 4 roles: cliente, agente, sistema, nota interna (esta última con badge "Interno" y fondo distintivo).
- [ ] **CA-10**: Asesor puede inyectar nota interna desde el monitor; se emite `internal_note` por WebSocket (sin `advisorId` en el payload).
- [ ] **CA-11**: Indicador visual de estado de conexión WebSocket en el header: 🟢 Conectado / 🟡 Reconectando... / 🔴 Desconectado. Durante desconexión, se deshabilitan acciones de escritura.
- [ ] **CA-12**: Modo oscuro funcional con toggle (ícono sol/luna) y persistencia en `localStorage`; implementado con `@custom-variant dark` de Tailwind v4.
- [ ] **CA-13**: Paleta de colores, tipografía Inter, sombras, radios, transiciones y animaciones (`slideIn`, `fadeIn`, `pulse-op`) preservados del diseño original mediante tokens `@theme` en CSS.
- [ ] **CA-14**: Los 45 tipos de caso del CSV están mapeados en `src/data/caseTypeDefinitions.ts` con `uiPattern`, `formFields`, `validationSchema` (Zod) y `payloadBuilder` para cada uno.
- [ ] **CA-15**: Backend Python puede implementarse siguiendo los contratos REST + WebSocket documentados en la sección 1.3 Paso 8 sin ambigüedades.
- [ ] **CA-16**: Capa MSW operativa con handlers para todos los endpoints REST y simulación de eventos WebSocket, permitiendo desarrollo full-stack del frontend sin backend real.
- [ ] **CA-17**: **Paridad funcional con el sistema actual**: notificaciones de escritorio HTML5, alerta sonora (Web Audio API) y parpadeo de título al recibir nuevos casos (`hitl_request`).
- [ ] **CA-18**: Reconexión WebSocket con backoff exponencial; al reconectar se recibe `init_state` y se reemplaza el estado local completo.
#### Paso 9 — Capa de Mocks (MSW) y Funcionalidades Preservadas
1. **MSW (Mock Service Worker)** para desarrollo desacoplado:
- Handlers REST que simulan `/api/v1/cases`, `/api/v1/cases/:id`, `/api/v1/cases/:id/resolve`, `/api/v1/conversations/active`.
- Datos de prueba: 10-15 casos de ejemplo cubriendo los 6 `uiPattern` y múltiples aplicativos.
- 3-5 conversaciones simuladas con mensajes de diferentes roles.
- El MSW se activa solo en modo desarrollo (`VITE_ENABLE_MSW=true`).
2. **Funcionalidades preservadas del sistema actual**:
- **Notificaciones de escritorio HTML5**: Hook `useNotification` que emite `new Notification()` al recibir `hitl_request`; click en notificación navega a `/cases` con el caso seleccionado.
- **Alerta sonora**: Hook `useSound` con Web Audio API (chime de dos tonos C5→E5, volumen 0.08), activado solo tras primer gesto del usuario (política de autoplay).
- **Parpadeo de título**: Efecto de título alternante cuando la pestaña no está enfocada y llegan nuevos casos; se limpia al enfocar.
- **Detección de foco de pestaña**: `document.visibilitychange` + `window.focus`/`blur` para controlar notificaciones.
### 1.5 Jerarquía de Aplicativos (para filtro secundario)
| Aplicativo | Descripción | N° de Tipos |
|-----------|-------------|:-----------:|
| **AC+** | Atención al Cliente (móvil) | 13 |
| **ASCARD** | Equipos financiados | 9 |
| **DiMe** | Ajustes online | 8 |
| **Formatos SGCS** | Cambios de ciclo | 2 |
| **Mi asistencia 360** | Escalamientos de pago | 2 |
| **Paradigma** | Facturación hogar/móvil | 2 |
| **RR** | Recepción y Radicación (hogar) | 12 |
| **Phone Protect** | Desbloqueo IMEI | 1 |
### 1.6 Riesgos Identificados (preliminar, para debate)
1. **Streaming token-a-token**: La semántica de concatenación depende de que el backend envíe tokens con un `conversationId` consistente. Si hay mensajes simultaneous, el orden de tokens debe estar garantizado.
2. **Persistencia de timers**: Actualmente en `localStorage`. En React, el estado del timer debe sincronizarse entre el store y `localStorage` sin causar re-renders excesivos (usar refs para el intervalo).
3. **Tailwind + CSS variables**: La migración de CSS puro a Tailwind requiere mapear cada utilidad. Los gradientes (`linear-gradient`) y `-webkit-background-clip` necesitan configuración adicional en Tailwind.
4. **CSV parsing**: Los 45 registros deben clasificarse manualmente en los 6 `uiPattern`. Algunos casos (ej. `Unificar_Factura_EF` que usa ASCARD + Paradigma) requieren lógica multi-aplicativo.
5. **WebSocket reconnection**: La lógica de reconexión debe preservar el estado local y re-sincronizar al reconectar (recibir `init_state`).
## Fase 2: Auditoría de Arquitectura y Debate Técnico (v3 — Aprobada)
### 2.1 Resumen de Hallazgos
La Fase 1 pasó por dos ciclos de auditoría. En la primera iteración se identificaron 10 riesgos (5 bloqueantes). Tras las correcciones del usuario, la segunda auditoría detectó 3 inconsistencias residuales de redacción: referencias a `hitl_response` como canal WS, mención de `tailwind.config.ts` en el Paso 7, y `advisorId` persistente en una definición de tipo. Las tres fueron corregidas. El plan es ahora **consistente, blindado y viable sin bloqueantes**.
### 2.2 Riesgos Resueltos (todos)
- ✅ **R1 (Tailwind v4)**: Resuelto — `@theme` + `@custom-variant dark`; toda referencia a `tailwind.config.ts` purgada.
- ✅ **R2 (Doble canal)**: Resuelto — REST como único canal autoritativo; `hitl_response` eliminado de tipos, Paso 4 y contratos WS.
- ✅ **R3 (Contratos WS)**: Resuelto — Envelope estándar, eventos de streaming explícitos, `error`, reconexión documentada.
- ✅ **R4 (advisorId)**: Resuelto — Eliminado de todos los payloads cliente→servidor y tipos; consistente en REST y WS.
- ✅ **R5 (REST filtrable)**: Resuelto — `GET /api/v1/cases?status=&applicative=&search=&offset=&limit=`.
- 🟡 **R6R10**: Mitigados con acciones documentadas en el plan (buffer streaming, MSW, reestructuración repo, funcionalidades preservadas, taxonomía ampliada con Zod).
### 2.3 Directrices para el Desarrollador
- **Regla 1**: La resolución de casos es exclusivamente REST (`POST /cases/:id/resolve`). WebSocket solo difunde y streamea.
- **Regla 2**: Ningún payload cliente→servidor contiene identificadores de asesor. El backend deriva la identidad.
- **Regla 3**: Tailwind v4 se configura exclusivamente vía CSS (`@theme`, `@custom-variant dark`). Sin `tailwind.config.ts`.
- **Regla 4**: El streaming usa el buffer de 50ms y solo re-renderiza la conversación seleccionada.
- **Regla 5**: Los 45 tipos de caso deben tener `validationSchema` (Zod) y `payloadBuilder` definidos antes de declarar completo el mapeo.
### 2.4 Veredicto Final
- **Estado del plan**: **En Implementación**.
- El plan es internamente consistente, los contratos REST/WS están completamente especificados, y el frontend puede desarrollarse de forma desacoplada mediante MSW. No hay bloqueantes residuales.
## Fase 3: Registro de Implementación
### 3.1 Paso 0 — Bootstrap y Setup
- `legacy/server.js`: [Creado] → Copia del backend Express legacy.
- `legacy/db.js`: [Creado] → Copia del módulo de base de datos SQLite (better-sqlite3).
- `legacy/schema.sql`: [Creado] → Copia del esquema SQL de la tabla `requests`.
- `legacy/package.json`: [Creado] → Copia del manifiesto de dependencias del backend legacy.
- `legacy/.env`: [Creado] → Copia de variables de entorno del backend legacy.
- `legacy/.env.example`: [Creado] → Copia con comentarios del backend legacy.
- `legacy/public/index.html`: [Creado] → Copia del HTML del frontend vanilla legacy.
- `legacy/public/style.css`: [Creado] → Copia de los estilos CSS del frontend vanilla legacy.
- `legacy/public/app.js`: [Creado] → Copia de la lógica JS del frontend vanilla legacy.
- `package.json`: [Modificado] → Reemplazado por el manifiesto del nuevo proyecto Vite + React + TypeScript con todas las dependencias core y de desarrollo.
- `vite.config.ts`: [Creado] → Configuración de Vite con plugin React y Tailwind CSS v4, proxy para API REST y WebSocket.
- `tsconfig.json`: [Creado] → Configuración raíz de TypeScript con referencias a `tsconfig.app.json` y `tsconfig.node.json`.
- `tsconfig.app.json`: [Creado] → Configuración TS para la aplicación React (ES2020, JSX react-jsx, paths con alias `@/`).
- `tsconfig.node.json`: [Creado] → Configuración TS para Vite y herramientas de Node.
- `index.html`: [Creado] → Entry point de Vite con fuente Inter de Google Fonts, módulo ES para `src/main.tsx`.
- `.env`: [Modificado] → Nuevas variables de entorno para frontend (`VITE_API_BASE_URL`, `VITE_WS_URL`, `VITE_ENABLE_MSW`).
- `.env.example`: [Creado] → Template de variables de entorno del frontend.
- `.gitignore`: [Creado] → Ignora `node_modules/`, `dist/`, `.env`, `database.sqlite`, entre otros.
- `src/vite-env.d.ts`: [Creado] → Declaraciones de tipos para `import.meta.env` con tipado estricto.
- `src/main.tsx`: [Creado] → Punto de entrada React con inicialización condicional de MSW (`VITE_ENABLE_MSW=true`).
- `src/App.tsx`: [Creado] → Componente raíz con React Router (`/`, `/cases`, `/monitor`), redirect a `/cases`.
- `src/index.css`: [Creado] → Estilos globales con Tailwind CSS v4, design tokens `@theme`, modo oscuro con `@custom-variant dark`, animaciones `slideIn`/`fadeIn`/`pulse-op`, scrollbar personalizado.
- `src/mocks/browser.ts`: [Creado] → Setup de MSW Worker para interceptar peticiones REST en desarrollo.
- `src/mocks/handlers.ts`: [Creado] → Handlers MSW para endpoints REST mock: 10 casos de prueba (cubriendo los 6 `uiPattern`), 3 conversaciones simuladas, handlers para `/api/v1/cases`, `/api/v1/cases/:id`, `/api/v1/cases/:id/resolve`, `/api/v1/conversations/active`, `/api/v1/conversations/:id`.
- `src/components/layout/.gitkeep`: [Creado] → Marcador de directorio para `layout/`.
- `src/components/cases/.gitkeep`: [Creado] → Marcador de directorio para `cases/`.
- `src/components/monitor/.gitkeep`: [Creado] → Marcador de directorio para `monitor/`.
- `src/components/shared/.gitkeep`: [Creado] → Marcador de directorio para `shared/`.
- `src/hooks/.gitkeep`: [Creado] → Marcador de directorio para `hooks/`.
- `src/services/.gitkeep`: [Creado] → Marcador de directorio para `services/`.
- `src/store/.gitkeep`: [Creado] → Marcador de directorio para `store/`.
- `src/types/.gitkeep`: [Creado] → Marcador de directorio para `types/`.
- `src/pages/.gitkeep`: [Creado] → Marcador de directorio para `pages/`.
- `src/data/.gitkeep`: [Creado] → Marcador de directorio para `data/`.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se estructuró el proyecto siguiendo el principio de agnosticismo y separación de conceptos. El backend legacy se aisló completamente en `legacy/`, dejando la raíz del proyecto limpia para el nuevo frontend Vite + React + TypeScript. La configuración de Tailwind v4 es CSS-first (sin `tailwind.config.ts`), usando la directiva `@theme` para definir los design tokens y `@custom-variant dark` para el modo oscuro. Se implementó MSW como capa de mockeo REST para desarrollo desacoplado del backend.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 1 (REST como canal autoritativo)**: Los handlers de MSW simulan `POST /cases/:id/resolve` como endpoint REST exclusivo para resolución de casos. No se incluye WebSocket para escritura.
- **Regla 2 (Sin advisorId)**: Los handlers MSW no requieren `advisorId` en los payloads, en línea con los contratos especificados.
- **Regla 3 (Tailwind v4 CSS-first)**: No existe `tailwind.config.ts`. Toda la configuración está en `src/index.css` mediante `@theme` y `@custom-variant`.
- **Regla 4 (Streaming buffer 50ms)**: Se documentó en la spec; la implementación del buffer se realizará en el hook `useWebSocket` en fases posteriores.
- **Regla 5 (45 tipos de caso con Zod)**: Los mock data en handlers incluyen 10 casos de ejemplo cubriendo los 6 `uiPattern`; la implementación completa de los 45 tipos se hará en Paso 1.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**:
- **Core**: `react`, `react-dom`, `react-router-dom`, `zustand`, `zod`, `date-fns`, `lucide-react`
- **Dev**: `typescript`, `vite`, `@vitejs/plugin-react`, `tailwindcss`, `@tailwindcss/vite`, `msw`, `@testing-library/react`, `@testing-library/jest-dom`, `vitest`, `@types/react`, `@types/react-dom`
- **Puntos Críticos a Probar**:
1. **Restauración manual necesaria**: Los archivos `node_modules/`, `package-lock.json`, `database.sqlite` y el directorio `public/` (antiguo) aún existen en la raíz y deben moverse manualmente a `legacy/` o eliminarse. Ejecutar:
```bash
rm -rf node_modules/ public/ package-lock.json database.sqlite
mv server.js db.js schema.sql legacy/ 2>/dev/null; true
```
2. **MSW no inicializado**: El archivo `public/mockServiceWorker.js` debe generarse ejecutando `npx msw init public/ --save`.
3. **Verificar que el alias `@/` funciona**: El `tsconfig.app.json` define `paths` con `@/*` → `src/*`. Confirmar que Vite resuelva los imports correctamente.
4. **Modo oscuro**: El `@custom-variant dark` usa la clase `.dark` en un contenedor padre. Verificar que al agregar `class="dark"` al `<html>` se activen los colores oscuros.
5. **MSW handlers**: Verificar que `VITE_ENABLE_MSW=true` activa la interceptación en desarrollo y que los endpoints mock responden correctamente (ej. `curl http://localhost:5173/api/v1/cases`).
---
### 3.1 Paso 1 — Sistema de Tipos, Contratos WebSocket y Mapeo de 53 Casos del CSV
- `src/types/index.ts`: [Creado] → Define las interfaces base del sistema (CaseRequest, Conversation, Message, FormField, CaseTypeDefinition) y los enums (CaseUIType, CaseStatus, AgentStatus, MessageRole). Utiliza tipado estático estricto con `z.ZodType` para los campos de validación de esquemas en CaseTypeDefinition.
- `src/types/wsProtocol.ts`: [Creado] → Implementa el envelope WebSocket estándar con Zod (WSEnvelopeSchema), más los 11 schemas de eventos servidor→cliente (init_state, conversation_started, conversation_ended, user_message, agent_stream_started, agent_stream_chunk, agent_stream_completed, agent_status_update, hitl_request, hitl_resolved, error) y 1 schema cliente→servidor (internal_note). Incluye funciones helper `createWSEnvelope()`, `validateServerEvent()`, `validateClientEvent()` con mapas discriminadores por tipo de evento para validación dinámica en el hook useWebSocket.
- `src/data/caseTypeDefinitions.ts`: [Creado] → Mapeo completo de los 53 registros del CSV a objetos `CaseTypeDefinition` con:
- Clasificación de `uiPattern` según las 6 familias visuales (SIMPLE_CONFIRMATION, CONFIRMATION_WITH_VALUE, MULTI_FIELD_FORM, DATE_SIMPLE, FREE_TEXT, READ_ONLY).
- `formFields` derivados del `responseFormat` y casos especiales documentados (Escalar_Pagos_No_Abonados con 9 campos, Validar_OTT_1/2 con 6 y 8 campos respectivamente, etc.).
- `validationSchema` Zod para cada entrada, con validaciones de tipo (número, moneda, toggle, fecha en formato dd-mm-aaaa, select con enum).
- `payloadBuilder` para serializar el formulario al payload del backend.
- Mapas helper `caseTypeByToolName` y `caseTypesByApplicative` para búsqueda rápida.
- Helpers de fábrica (`simpleConfirmation`, `confirmationWithValue`, `dateSimple`, `freeText`, `readOnly`, `multiFieldForm`) para reducir repetición de código.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se respetó el principio de separación de conceptos manteniendo las interfaces de dominio (`CaseRequest`, `Conversation`, `Message`) en `src/types/index.ts` desacopladas de los contratos de comunicación (`wsProtocol.ts`) y de los datos estáticos (`caseTypeDefinitions.ts`). Los helpers de fábrica en caseTypeDefinitions.ts permiten definir esquemas Zod y builders de payload de forma declarativa y consistente, eliminando la duplicación masiva de código.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 1 (REST como canal autoritativo)**: En `wsProtocol.ts` no existe ningún evento `hitl_response`; la resolución de casos se realiza exclusivamente vía REST. El protocolo WS solo define eventos de difusión/streaming.
- **Regla 2 (Sin advisorId)**: En `wsProtocol.ts`, el payload `internal_note` solo contiene `conversationId` y `content`. No se incluye `advisorId` en ningún payload cliente→servidor. El backend debe derivar la identidad del contexto de conexión.
- **Regla 5 (45 tipos de caso con Zod)**: Se implementaron 53 registros del CSV (la diferencia con la cifra "45" se debe a que algunos toolName se repiten con diferentes especialistas/objetivos). Cada registro tiene su `validationSchema` Zod y `payloadBuilder` completamente implementados, sin placeholders ni TODOs.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: Ninguna nueva (zod ya estaba incluida en Paso 0).
- **Puntos Críticos a Probar**:
1. **Tipos estrictos**: Verificar que `tsc --noEmit` (o `npm run lint`) no produce errores de tipo. Archivos clave: `src/types/index.ts`, `src/types/wsProtocol.ts`, `src/data/caseTypeDefinitions.ts`.
2. **Validación Zod de eventos WS**: Probar que `validateServerEvent('init_state', payload)` rechaza payloads mal formados (ej. falta `conversations` o `activeCases`). Probar `validateClientEvent('internal_note', { conversationId: '', content: '' })` debe fallar porque `content` requiere `min(1)`.
3. **Cobertura de 53 registros**: Verificar que `caseTypeDefinitions.length` es 53 y que ningún registro tiene `validationSchema` o `payloadBuilder` como undefined.
4. **Mapas auxiliares**: `caseTypeByToolName` debe contener todas las toolNames (las duplicadas prevalece la última). `caseTypesByApplicative` debe tener entradas para "AC+", "ASCARD", "DiMe", "Formatos SGCS", "Mi asistencia 360", "Paradigma", "RR", "Phone Protect".
5. **FormFields vs ValidationSchema**: Para cada `MULTI_FIELD_FORM`, verificar que los campos en `formFields` coinciden uno a uno con las claves del `validationSchema`. Ejemplo: `Plan_De_Pagos_EF` debe tener 4 campos (numero_cuotas, valor_cuota, dia_corte, dia_limite_pago) tanto en formFields como en validationSchema.
6. **PayloadBuilder fidelidad**: Para `Validar_OTT_1`, verificar que `payloadBuilder({ reinstalacion: true, valor_reinstalacion: 50000, fecha_adquisicion_reinstalacion: '01-01-2024', deco_adicional: false, valor_deco: 0, fecha_adquisicion_deco: '01-01-2024' })` devuelve un objeto con exactamente esas 6 claves y mismos valores.
### 3.1 Paso 2 — Capa de Servicios (api.ts, wsClient.ts) y Store Zustand (useAppStore.ts)
- `src/services/api.ts`: [Creado] → Cliente REST con `fetch` nativo. Implementa `getCases`, `getCaseById`, `resolveCase`, `getActiveConversations`, `getConversation`. Define `PaginatedResponse<T>`, `CaseFilters`, y `ApiError` para manejo de errores HTTP. La URL base se configura via `VITE_API_BASE_URL` con fallback a `http://localhost:3000/api/v1`. Incluye helper `buildQuery()` para construir query string con filtros (status, applicative, search, offset, limit) y helper interno `request<T>()` para centralizar la lógica de fetch, headers JSON, y validación de código HTTP. Tipos importados de `@/types`.
- `src/services/wsClient.ts`: [Creado] → Cliente WebSocket en clase `WsClient` con patrón singleton exportado como `wsClient`. Implementa reconexión con backoff exponencial (1s → 2s → 4s → 8s → 16s → máx 30s, factor 2x). Expone `connect()`, `disconnect()`, `send(type, payload)` que genera automáticamente `eventId` (crypto.randomUUID) y `occurredAt` (ISO-8601) en el envelope estándar, `onMessage` callback setter/getter, y `getStatus()` retornando `'connected' | 'disconnected' | 'reconnecting'`. Maneja cierre graceful con flag `destroyFlag` para evitar reconexión en desconexión intencional. Ignora mensajes malformados silenciosamente. Tipos importados de `@/types/wsProtocol`.
- `src/store/useAppStore.ts`: [Creado] → Store centralizado Zustand con tres slices:
- **casesSlice**: `cases[]`, `selectedCaseId`, `totalCases`, `fetchCases(filters)` (llama a `api.getCases` y actualiza estado), `upsertCase(c)` (reemplaza si existe o agrega al inicio), `resolveCase(id, data)` (llama a `api.resolveCase` y actualiza el caso en el array local).
- **conversationsSlice**: `conversations[]`, `selectedConversationId`, `fetchConversations()` (llama a `api.getActiveConversations`), `upsertConversation(c)`, `addMessage(convId, msg)`, `appendToken(convId, msgId, token, index)` (bufferiza chunks en `metadata._chunks` ordenados por `index` para manejar entrega fuera de orden, actualiza `content` concatenando chunks ordenados), `completeStream(convId, msgId, fullContent)` (limpia `_chunks` de metadata, establece `content = fullContent`, marca `isStreaming = false`).
- **uiSlice**: `sidebarTab` ('all'|'pending'|'resolved'), `searchQuery`, `applicativeFilter`, `isDarkMode` (persistido en `localStorage` via clave `claro-cases:darkMode`), `wsStatus`. Setters: `setSidebarTab`, `setSearchQuery`, `setApplicativeFilter`, `toggleDarkMode` (persiste y actualiza), `setWsStatus`.
- La persistencia de `isDarkMode` se implementa con helper `readDarkMode()` que lee `localStorage` al inicializar el store y `persistDarkMode()` que escribe en cada toggle.
### 3.2 Paso 3 — Componentes Compartidos (StatusBadge, SearchBar, TabsBar, EmptyState, Modal, Timer)
- `src/components/shared/StatusBadge.tsx`: [Creado] → Renderiza un badge de estado con colores por `CaseStatus`. Usa mapas `STATUS_LABELS` (Pendiente/En Progreso/Finalizado/Fallido) y `STATUS_STYLES` con clases Tailwind según los tokens del tema (accent-yellow, accent-orange, accent-green, accent-red). Estilo: `text-[9px] px-1.5 py-0.5 rounded-[10px] font-semibold uppercase border`. Props: `status: CaseStatus`.
- `src/components/shared/SearchBar.tsx`: [Creado] → Input de búsqueda con ícono `Search` de `lucide-react`. Implementa debounce de 300ms usando `useRef` para el timer y `useEffect` para sincronizar con el store. Almacena el valor local en `useState` y solo escribe al store tras el debounce. Estilo: fondo `bg-elevated`, borde `border`, foco `focus:border-accent-orange`. Props: ninguna (lee/escribe del store directamente).
- `src/components/shared/TabsBar.tsx`: [Creado] → Barra de tres pestañas (Todos/Pendientes/Finalizados) que lee `sidebarTab` del store y llama a `setSidebarTab`. Pestaña activa: `bg-accent-orange/8 text-accent-orange border-accent-orange`. Inactiva: `text-text-muted border-transparent`. Estilo: `text-[11px] font-semibold uppercase tracking-wider`. Props: ninguna.
- `src/components/shared/EmptyState.tsx`: [Creado] → Estado vacío centrado vertical/horizontalmente. Renderiza `icon` (ReactNode, ej. emoji), `title` (14px font-semibold), `description` (12px text-secondary). Ícono con `text-[3rem] opacity-40 leading-none`. Props: `icon: ReactNode`, `title: string`, `description: string`.
- `src/components/shared/Modal.tsx`: [Creado] → Overlay modal con backdrop blur (`bg-black/40 backdrop-blur-sm`), contenido centrado con animación `fadeIn`. Cierra con Escape (event listener) y al hacer click en backdrop. Contenido: `bg-surface border border-border rounded-lg shadow-lg`. Header con título y botón ✕. Body para `children`. Footer opcional `actions`. Props: `isOpen`, `onClose`, `title`, `children`, `actions?`.
- `src/components/shared/Timer.tsx`: [Creado] → Cronómetro individual por caso con persistencia en `localStorage` (clave `timer_case_{caseId}`). Implementado con `forwardRef` y `useImperativeHandle` exponiendo `start()`, `stop()`, `getElapsed()`. Usa `useRef` para el intervalo (`setInterval` 1s) y contadores acumulados. `useState` solo para el display (MM:SS). Al montar, restaura estado desde `localStorage`. Al desmontar, limpia el intervalo. Display: `font-mono text-xl font-bold tabular-nums text-text-primary`. Props: `caseId: string | number`.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se respetó el principio de agnosticismo separando la capa de servicios (REST y WebSocket) del store y de los componentes. `api.ts` es un cliente REST puro sin dependencias de React ni del store, permitiendo ser usado desde hooks o desde MSW. `wsClient.ts` es una clase singleton agnóstica al framework que expone callbacks, permitiendo que `useWebSocket` (hook futuro) se suscriba sin acoplamiento. El store Zustand usa `api` para las operaciones de escritura (fetchCases, resolveCase, fetchConversations), manteniendo la lógica de negocio desacoplada del mecanismo de transporte. Los componentes compartidos son puramente presentacionales (StatusBadge, EmptyState, Modal) o se conectan al store de forma mínima (SearchBar, TabsBar), sin depender de servicios directamente.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 1 (REST como canal autoritativo)**: `resolveCase` en el store llama exclusivamente a `api.resolveCase()` (POST REST). No existe ninguna función de resolución por WebSocket.
- **Regla 2 (Sin advisorId)**: El cliente WebSocket `send()` no incluye `advisorId` en ningún payload. El método genérico solo recibe `type` y `payload`. Los helpers de validación Zod del `wsProtocol.ts` ya garantizan que `internal_note` solo tenga `conversationId` y `content`.
- **Regla 3 (Tailwind v4 CSS-first)**: Todos los componentes usan clases Tailwind directamente con los tokens CSS definidos en `@theme` (bg-surface, text-primary, border-accent-orange, etc.). No hay configuración JS de Tailwind.
- **Regla 4 (Streaming buffer 50ms)**: `appendToken` en el store usa `metadata._chunks` ordenados por `index` para garantizar orden correcto de tokens incluso si llegan fuera de orden de red. El buffer se implementa a nivel de store, preparado para que el hook `useWebSocket` (futuro) pueda rate-limit las actualizaciones a 20fps.
- **Regla 5 (45 tipos de caso con Zod)**: No aplica en este paso (implementado en Paso 1).
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: `zustand` (ya instalada en Paso 0), `lucide-react` (ya instalada en Paso 0). No se añadieron nuevas dependencias.
- **Puntos Críticos a Probar**:
1. **api.ts — Error handling**: Verificar que `ApiError` se lanza correctamente para códigos HTTP 4xx/5xx. Probar con MSW simulando errores 404 y 500. Verificar que `buildQuery` omite parámetros undefined/null.
2. **api.ts — Paginación**: Llamar `getCases({ offset: 0, limit: 5 })` y verificar query string `?offset=0&limit=5`. Llamar con `getCases({})` y verificar que no se añade `?` en la URL.
3. **wsClient.ts — Reconexión**: Verificar backoff exponencial: tras cerrar WebSocket, debe reconectar con delays crecientes (1s, 2s, 4s, 8s...). Probar que `disconnect()` detiene la reconexión inmediatamente.
4. **wsClient.ts — Envelope**: Verificar que `send('internal_note', { conversationId: 'c1', content: 'nota' })` produce un mensaje JSON con `type`, `eventId` (UUID), `occurredAt` (ISO string) y `payload`.
5. **useAppStore.ts — appendToken**: Enviar tokens fuera de orden (index 2, 0, 1) y verificar que el contenido final es la concatenación ordenada. Verificar que `completeStream` reemplaza el contenido con `fullContent` y limpia `metadata._chunks`.
6. **useAppStore.ts — Dark mode persistence**: Llamar `toggleDarkMode()`, recargar el store, verificar que `isDarkMode` persiste. Verificar que `localStorage` contiene `claro-cases:darkMode=true`.
7. **StatusBadge.tsx — Renderizado condicional**: Renderizar con cada `CaseStatus` y verificar clases de color correctas y texto en español.
8. **SearchBar.tsx — Debounce**: Escribir texto rápidamente y verificar que solo se actualiza el store tras 300ms de inactividad. Verificar que el ícono `Search` está presente.
9. **TabsBar.tsx — Estado activo**: Hacer clic en "Pendientes" y verificar que `sidebarTab` en el store cambia a `'pending'` y la pestaña visualmente activa tiene las clases `bg-accent-orange/8 text-accent-orange border-accent-orange`.
10. **Timer.tsx — Persistencia y control**: Llamar `start()` y esperar 5s. Verificar que `localStorage` tiene el timer guardado. Llamar `stop()` y verificar display se congela. Llamar `getElapsed()` y verificar que devuelve los segundos exactos. Recargar el componente y verificar que el tiempo acumulado se restaura. Iniciar de nuevo y confirmar que continúa desde donde quedó.
11. **Modal.tsx — Accesibilidad**: Verificar que el modal se cierra con tecla Escape. Verificar que el click en backdrop cierra el modal. Verificar que el click dentro del contenido no lo cierra.
12. **EmptyState.tsx — Renderizado**: Verificar que `icon` renderiza como elemento (puede ser string emoji o componente React), `title` en 14px semibold, `description` en 12px secondary, centrado vertical/horizontalmente.
### 3.1 Paso 4 — Módulo de Gestión de Casos HITL (`/cases`)
- `src/components/cases/TypeBadge.tsx`: [Creado] → Badge pequeño que muestra el `tipoSolicitud` con estilo `bg-accent-orange/10 text-accent-orange border-accent-orange/25`. Trunca el texto a 140px con `title` para tooltip.
- `src/components/cases/CaseCard.tsx`: [Creado] → Tarjeta de caso en la sidebar. Props `case: CaseRequest`, `isActive`, `onClick`. Renderiza: (1) Header con título, `StatusBadge` y timer formateado (solo si `status === IN_PROGRESS` y `handlingTime > 0`); (2) Descripción truncada a 2 líneas con `line-clamp-2`; (3) Footer con ID externo en monospace, `TypeBadge` con `tipoSolicitud`, y fecha formateada con `date-fns`. Estilo base `bg-elevated border rounded-md p-3 cursor-pointer transition hover:bg-hover`, activo `bg-accent-orange/4 border-accent-orange`. Animación `animate-[slideIn_0.2s_ease-out]`.
- `src/components/cases/ApplicativeFilter.tsx`: [Creado] → Filtro de aplicativos mediante chips/badges clickeables. Lista fija de los 8 aplicativos (AC+, ASCARD, DiMe, Formatos SGCS, Mi asistencia 360, Paradigma, RR, Phone Protect). Usa `applicativeFilter` y `setApplicativeFilter` del store. Al hacer clic en un chip activo, lo deselecciona (pasa a `null`). Incluye botón "✕ Limpiar" que solo aparece cuando hay un filtro activo. Estilo: chip activo `bg-accent-orange/10 text-accent-orange border-accent-orange/30`, inactivo `bg-elevated text-text-muted border-border`.
- `src/components/cases/FormRenderer.tsx`: [Creado] → Componente crítico que renderiza formularios dinámicos según `CaseUIType`. Props: `caseType: CaseTypeDefinition`, `onSubmit: (data) => void`. Implementa los 6 patrones de UI (SIMPLE_CONFIRMATION, CONFIRMATION_WITH_VALUE, MULTI_FIELD_FORM, DATE_SIMPLE, FREE_TEXT, READ_ONLY) con estado local `formValues`/`formErrors`, transformación de fechas yyyy-mm-dd ↔ dd-mm-aaaa, validación Zod inline, soporte `conditionalOn`, y FieldInput interno para renderizar cada tipo de campo (text, number, currency con $, date, select, textarea, toggle switch).
- `src/components/cases/CaseDetail.tsx`: [Creado] → Panel derecho de detalle con metadata grid (ID, cédula, tipo, aplicativo), descripción, payload entrante, FormRenderer dinámico, acordeón de pasos colapsable, y panel de operación sticky con Timer + fecha.
- `src/pages/CasesPage.tsx`: [Creado] → Layout maestro: sidebar 320px (SearchBar + TabsBar + ApplicativeFilter + lista CaseCards scrolleable + footer conteo) y panel derecho (CaseDetail / EmptyState). Conecta store para casos filtrados por tab/search/applicative. `filterCases()` interno con lógica de filtrado combinado.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se respetó la separación de conceptos manteniendo los componentes de UI (`CaseCard`, `CaseDetail`, `TypeBadge`, `ApplicativeFilter`) desacoplados de la lógica de formularios dinámicos (`FormRenderer`) y del store. `CaseCard` y `TypeBadge` son puramente presentacionales. `FormRenderer` encapsula toda la complejidad de renderizado condicional, transformación de fechas, y validación Zod inline. `CaseDetail` orquesta la integración entre metadata, formulario y timer. `CasesPage` actúa como orquestador de layout y filtros.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 1 (REST como canal autoritativo)**: `CaseDetail.handleFormSubmit` llama a `resolveCase` del store (POST REST). `FormRenderer` solo recolecta datos y llama a `onSubmit`.
- **Regla 2 (Sin advisorId)**: Ningún componente envía `advisorId`. El payload contiene solo `action` y `payload`.
- **Regla 3 (Tailwind v4 CSS-first)**: Todos los componentes usan exclusivamente tokens `@theme` sin configuración JS de Tailwind.
- **Regla 5 (45 tipos de caso con Zod)**: `FormRenderer` usa `validationSchema.safeParse()` antes de llamar a `onSubmit`.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: `date-fns` (ya instalada en Paso 0). `lucide-react` (ya instalada en Paso 0). No se añadieron nuevas dependencias.
- **Puntos Críticos a Probar**:
1. **CaseCard — Renderizado condicional de timer**: Solo aparece cuando `status === IN_PROGRESS` y `handlingTime > 0`. Formato MM:SS.
2. **CaseCard — Animación slideIn** al montar.
3. **ApplicativeFilter — Toggle**: Chip activo ↔ `applicativeFilter` en store. Botón ✕ solo visible con filtro activo.
4. **FormRenderer — SIMPLE_CONFIRMATION**: Botones Sí/No llaman `onSubmit({ confirmacion: true/false })`.
5. **FormRenderer — CONFIRMATION_WITH_VALUE**: Radio Sí→ campo $ visible, Radio No→ oculto. Validación valor negativo.
6. **FormRenderer — MULTI_FIELD_FORM**: Renderiza types correctos, min/max, toggle switch, conditionalOn, errores inline.
7. **FormRenderer — Transformación fecha**: Date picker → valor enviado en dd-mm-aaaa.
8. **FormRenderer — READ_ONLY**: Botón "Marcar como revisado" llama `onSubmit({})`.
9. **CaseDetail — Timer**: Inicia automático en IN_PROGRESS, se detiene al resolver.
10. **CaseDetail — Acordeón**: Pasos colapsables con ChevronDown/ChevronUp.
11. **CasesPage — Filtros combinados**: Búsqueda + tab + aplicativo se combinan correctamente. Footer "X de Y casos".
12. **CasesPage — Empty states**: Sin casos → EmptyState en sidebar. Sin selección → EmptyState en panel derecho.
13. **CasesPage — Fetch on mount**: Se llama `fetchCases()` al montar.
14. **FormRenderer — Validación Zod**: Datos inválidos → errores inline, no se llama `onSubmit`.
### 3.1 Paso 5 — Módulo de Monitoreo (`/monitor`)
- `src/components/monitor/MessageBubble.tsx`: [Creado] → Burbuja de mensaje individual con diferenciación visual por rol (user → derecha/accent-orange, agent → izquierda/elevated, system → centrado/base/italic, internal → izquierda/accent-yellow con badge 🔒). Muestra timestamp HH:mm. Si `isStreaming`, muestra cursor parpadeante (barra animada).
- `src/components/monitor/InternalNotesGroup.tsx`: [Creado] → Acordeón expandible que agrupa mensajes `internal` consecutivos. Cabecera "🔄 Notas internas (N)" colapsable. Al expandir, muestra contenido y timestamp de cada nota. Implementa filtro de seguridad para solo renderizar mensajes con `role === INTERNAL`.
- `src/components/monitor/ChatFeed.tsx`: [Creado] → Feed de mensajes con auto-scroll inteligente. Detecta si el usuario está cerca del fondo (≤ 100px) mediante ref y handler `onScroll`; si está cerca, hace scroll automático al llegar nuevo mensaje o token. Agrupa mensajes `internal` consecutivos en `InternalNotesGroup` mediante buffer de acumulación intercalado con `flushInternal()`. Muestra indicador "Escribiendo..." con spinner cuando el último mensaje del agente tiene `isStreaming: true`.
- `src/components/monitor/ConversationCard.tsx`: [Creado] → Tarjeta de conversación en lista lateral. Muestra: (1) ID/nombre del cliente con icono User, (2) último mensaje truncado a 80 caracteres, (3) spinner `Loader2` animado si el último mensaje está en streaming, (4) estado del agente con color verde para activa, (5) badge de estado de conversación (Activa/En pausa/Finalizada). Sin badge HITL en esta iteración (requiere mapeo conversationId → caseId que se integrará con eventos WS).
- `src/components/monitor/InternalNoteBanner.tsx`: [Creado] → Banner inferior para inyección de notas internas. Textarea de 2 líneas con placeholder, botón "Enviar" con icono Send. Al enviar, llama a `wsClient.send('internal_note', { conversationId, content })` sin `advisorId`. Soporte Enter para enviar, Shift+Enter para nueva línea. Feedback visual "Enviado ✓" por 2 segundos tras envío exitoso. Hint con atajos de teclado.
- `src/pages/MonitorPage.tsx`: [Creado] → Layout de dos columnas: izquierda 280px con lista scrolleable de `ConversationCard`s (con encabezado y contador), derecha flex-1 con `ChatFeed` + `InternalNoteBanner` si hay conversación seleccionada, o `EmptyState` si no. Al montar, llama a `fetchConversations()` del store. Conecta con `selectedConversationId` y setea mediante `useAppStore.setState`.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se respetó la separación de conceptos manteniendo los componentes de monitoreo desacoplados del store y servicios. `MessageBubble` es puramente presentacional (solo recibe `Message` por props). `InternalNotesGroup` encapsula la lógica de agrupación y colapso. `ChatFeed` orquesta la integración entre burbujas, agrupación de notas internas y auto-scroll. `ConversationCard` es presentacional con helpers de extracción de último mensaje y detección de streaming. `InternalNoteBanner` se conecta directamente con `wsClient` (singleton) para enviar notas internas, sin pasar por el store. `MonitorPage` actúa como orquestador de layout y conexión con el store.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 1 (REST como canal autoritativo)**: `InternalNoteBanner` envía por WebSocket exclusivamente notas internas (evento `internal_note`), nunca resolución de casos.
- **Regla 2 (Sin advisorId)**: `wsClient.send('internal_note', { conversationId, content })` no incluye `advisorId` en el payload. El backend deriva la identidad del contexto de conexión WS.
- **Regla 3 (Tailwind v4 CSS-first)**: Todos los componentes usan exclusivamente tokens `@theme` sin configuración JS de Tailwind.
- **Regla 4 (Streaming buffer 50ms)**: `ChatFeed` reacciona a cambios en `messages[messages.length-1]?.content` para auto-scroll durante streaming, respetando posición manual del usuario mediante ref `isNearBottomRef`.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: Ninguna nueva (todas las dependencias ya estaban instaladas en Pasos previos).
- **Puntos Críticos a Probar**:
1. **MessageBubble — 4 roles visuales**: Verificar alineación y fondo correctos para user (derecha/accent-orange/10), agent (izquierda/elevated), system (centrado/base/italic), internal (izquierda/accent-yellow/10 con badge 🔒).
2. **MessageBubble — Streaming cursor**: Cuando `isStreaming: true`, debe mostrar barra parpadeante al final del contenido.
3. **ChatFeed — Auto-scroll**: Con varias burbujas visibles, scrollear manualmente hacia arriba y verificar que al llegar un nuevo mensaje NO se hace auto-scroll. Scrollear al fondo y verificar que al llegar un nuevo mensaje SÍ se hace auto-scroll al fondo.
4. **ChatFeed — Agrupación de notas internas**: 2+ mensajes `internal` consecutivos deben agruparse en un acordeón. Un mensaje internal seguido de user/agent debe renderizarse individualmente.
5. **ChatFeed — Indicador "Escribiendo..."**: Cuando el último mensaje del agente tiene `isStreaming: true`, debe mostrar texto "Escribiendo..." con spinner.
6. **InternalNotesGroup — Expandir/colapsar**: Hacer clic en cabecera y verificar que se expanden/colapsan las notas. Verificar contador "Notas internas (N)".
7. **ConversationCard — Último mensaje truncado**: Mensaje > 80 caracteres debe truncarse con "...".
8. **ConversationCard — Spinner streaming**: Debe mostrar `Loader2` animado cuando el último mensaje tiene `isStreaming: true`.
9. **InternalNoteBanner — Envío sin advisorId**: Verificar que `wsClient.send` recibe payload sin campo `advisorId`. Verificar feedback "Enviado ✓" post-envío.
10. **InternalNoteBanner — Enter vs Shift+Enter**: Enter envía, Shift+Enter inserta nueva línea.
11. **MonitorPage — Layout**: 280px sidebar izquierda + flex-1 derecha. EmptyState cuando no hay conversación seleccionada.
12. **MonitorPage — Fetch on mount**: Se llama `fetchConversations()` al montar. Almacenar `selectedConversationId` con `useAppStore.setState`.
### 3.1 Paso 6 — App Shell y Ruteo
- `src/services/wsClient.ts`: [Modificado] → Se añadió callback `onStatusChange` (getter/setter) y tipo `StatusChangeCallback` para notificar cambios de estado de conexión al store. El método privado `setStatus()` ahora invoca `onStatusChangeCallback?.(status)` en cada transición, permitiendo que `AppShell` sincronice el indicador WS en el Header.
- `src/components/layout/Header.tsx`: [Creado] → Barra superior de 50px. Logo: emoji 🔴 + "Claro Cases" con gradiente `bg-gradient-to-r from-accent-orange to-accent-yellow bg-clip-text text-transparent`. Badge "En vivo" con estilo `bg-accent-green/10 text-accent-green`. Indicador de conexión WS: punto circular coloreado (verde/amarillo/rojo según `wsStatus` del store) + texto (Conectado/Reconectando.../Desconectado), con `animate-pulse` en estado reconnecting. Toggle tema oscuro/claro con iconos Sun/Moon de `lucide-react`.
- `src/components/layout/Sidebar.tsx`: [Creado] → Navegación lateral fija de 50px de ancho. Usa `NavLink` de react-router-dom con dos rutas: Casos (icono `LayoutList`) → `/cases`, Monitor (icono `Monitor`) → `/monitor`. Link activo: `bg-accent-orange/8 text-accent-orange`. Link inactivo: `text-text-muted hover:text-text-primary hover:bg-hover`. Layout vertical centrado con icono + label en 10px.
- `src/components/layout/AppShell.tsx`: [Creado] → Layout global que envuelve todo el contenido. Renderiza `Header` arriba, `Sidebar` a la izquierda (50px), y `children` (contenido de la ruta) a la derecha. Al montar: (1) sincroniza clase `.dark` en `<html>` según `isDarkMode` del store, (2) inicializa conexión WebSocket via `wsClient.connect()` y registra `onStatusChange` → `setWsStatus`, (3) registra `onMessage` handler (placeholder para integración futura de eventos WS), (4) llama `fetchCases()` o `fetchConversations()` según la ruta actual. Cleanup: desconecta WS y limpia callbacks al desmontar.
- `src/App.tsx`: [Reemplazado] → Router con `BrowserRouter` envolviendo `AppShell` como layout global. Tres rutas: `/` → redirect a `/cases`, `/cases` → `CasesPage`, `/monitor` → `MonitorPage`. Catch-all `*` → redirect a `/cases`.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se implementó el shell de aplicación siguiendo el principio de composición: `AppShell` es el layout contenedor que orquesta la inicialización de infraestructura (WS, tema oscuro, fetch inicial) y renderiza `Header` + `Sidebar` + contenido. El ruteo está desacoplado en `App.tsx` usando react-router-dom estándar. `Header` y `Sidebar` son componentes puramente presentacionales que se conectan al store para estado de UI (wsStatus, isDarkMode). La modificación a `wsClient.ts` es mínima y no rompe la interfaz existente.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 2 (Sin advisorId)**: `AppShell` no envía ningún identificador de asesor; solo establece la conexión WS y el handler de mensajes.
- **Regla 3 (Tailwind v4 CSS-first)**: Todos los componentes de layout usan exclusivamente tokens `@theme` sin configuración JS de Tailwind. El toggle dark mode usa `class` strategy con `@custom-variant dark`.
- **Regla 4 (Streaming buffer 50ms)**: `AppShell` registra un `onMessage` handler placeholder que será expandido en fases posteriores para implementar el buffer de 50ms.
- **Regla 5 (45 tipos de caso con Zod)**: No aplica en este paso.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: Ninguna nueva.
- **Puntos Críticos a Probar**:
1. **Header — Gradiente logo**: Verificar que el texto "Claro Cases" tiene gradiente `accent-orange → accent-yellow` con `bg-clip-text text-transparent`.
2. **Header — Indicador WS**: Verificar punto verde + "Conectado" cuando `wsStatus = 'connected'`, amarillo + "Reconectando..." cuando `'reconnecting'`, rojo + "Desconectado" cuando `'disconnected'`. Estado reconnecting debe tener `animate-pulse`.
3. **Header — Toggle tema**: Hacer clic en icono sol/luna y verificar que `isDarkMode` cambia en el store y se agrega/remueve clase `.dark` en `<html>`.
4. **Sidebar — Navegación**: Verificar que NavLink activo tiene clase `bg-accent-orange/8 text-accent-orange`. Navegar entre /cases y /monitor y verificar cambio visual.
5. **AppShell — Inicialización WS**: Al montar, verificar que `wsClient.connect()` se llama y que `wsClient.onStatusChange` actualiza `wsStatus` en el store.
6. **AppShell — Dark mode sync**: Con `isDarkMode = true`, verificar que `<html>` tiene clase `.dark`. Con `false`, que no la tiene.
7. **AppShell — Fetch inicial**: Al navegar a /cases, verificar que se llama `fetchCases()`. Al navegar a /monitor, verificar que se llama `fetchConversations()`. NOTA: El fetch inicial solo ocurre al montar `AppShell`; cambios de ruta posteriores son manejados por los pages.
8. **App.tsx — Ruteo**: Verificar que `/` redirige a `/cases`. Verificar que `/cases` renderiza `CasesPage`. Verificar que `/monitor` renderiza `MonitorPage`. Verificar que ruta desconocida redirige a `/cases`.
9. **App.tsx — AppShell wrapping**: Verificar que todas las rutas están envueltas en `AppShell` y que Header + Sidebar son visibles en todas las vistas.
### 3.1 Paso 9 — Funcionalidades Preservadas: Notificaciones, Sonido y Parpadeo de Título
- `src/hooks/useNotification.ts`: [Creado] → Hook para notificaciones de escritorio HTML5. Solicita permiso `Notification.requestPermission()` al montar si no está en estado `granted`. Expone `notify(title, body, onClick?)` que crea una `new Notification()` con icono `/favicon.ico` y autocierre a los 6 segundos. Al hacer clic en la notificación se ejecuta el callback `onClick`, se enfoca la ventana (`window.focus()`) y se cierra la notificación. Si el navegador no soporta Notifications o el permiso fue denegado, se loguea un warning y la llamada es silenciosamente ignorada.
- `src/hooks/useSound.ts`: [Creado] → Hook para alerta sonora con Web Audio API. Inicializa un `AudioContext` de forma perezosa en el primer gesto del usuario (eventos `click` o `keydown` con `{ once: true }`), cumpliendo con las políticas de autoplay del navegador. Expone `playNotificationSound()` que genera un chime de dos tonos: C5 (523.25 Hz) por 120 ms → E5 (659.25 Hz) por 120 ms, compartiendo un nodo `GainNode` con volumen 0.08 y fade out exponencial a 0.001 en 450 ms. Si el `AudioContext` está en estado `suspended`, se loguea un warning y se retorna sin reproducir.
- `src/hooks/useTitleFlash.ts`: [Creado] → Hook para parpadeo del título de pestaña. Mantiene un contador `useRef` de notificaciones no leídas. Expone `triggerNotification()` que incrementa el contador y, si la pestaña no está enfocada (`document.visibilityState === 'hidden'` o `document.hasFocus()` es `false`), inicia un intervalo que alterna el título cada 1 segundo entre `"(🔔 N) ¡Nuevo Caso!"` y `"Claro Cases Dashboard"`. Al enfocar la pestaña (`visibilitychange → visible`, evento `window.focus`), se limpia el intervalo, se restaura el título y se resetea el contador a cero. Los listeners `visibilitychange`, `focus` y `blur` se limpian al desmontar el componente.
- `src/hooks/index.ts`: [Creado] → Barrel export que reexporta `useNotification`, `useSound` y `useTitleFlash` para imports limpios desde otros módulos.
- `src/components/layout/AppShell.tsx`: [Modificado] → Se integraron los tres hooks de funcionalidades preservadas. El manejador `onMessage` del WebSocket fue expandido para despachar eventos a la store según `envelope.type`:
- `init_state`: Reemplaza el estado local con `payload.conversations` y `payload.activeCases`.
- `conversation_started`: Inserta la conversación en el store.
- `user_message`: Agrega el mensaje a la conversación correspondiente.
- `agent_stream_chunk`: Envía el token a `appendToken` para concatenación ordenada.
- `agent_stream_completed`: Envía el contenido completo a `completeStream`.
- `hitl_request`: Dispara las tres funcionalidades preservadas — (1) notificación de escritorio con `notify()` cuyo `onClick` navega a `/cases` y selecciona el caso, (2) alerta sonora con `playNotificationSound()`, (3) parpadeo de título con `triggerNotification()`. Además inserta el caso en el store vía `upsertCase()`.
- Se usa un patrón `useRef` (`handleIncomingMessageRef`) para que el callback del WebSocket siempre delegue a la versión más reciente del handler sin necesidad de remontar el efecto.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se implementaron los hooks siguiendo el principio de programación defensiva y agnosticismo al framework:
- `useNotification` usa `useRef` para cachear el permiso y `useCallback` para memoizar la función `notify`, evitando recreaciones innecesarias. La solicitud de permiso ocurre una sola vez al montar.
- `useSound` inicializa el `AudioContext` de forma lazy mediante un par de listeners globales (`click`, `keydown`) con `{ once: true }`, garantizando que no se intente crear audio antes de un gesto del usuario. Al desmontar, cierra el contexto y limpia los listeners.
- `useTitleFlash` usa `useRef` para el contador no leído y el intervalo, evitando rerenders al actualizar el título del documento. La lógica de start/stop está desacoplada en `startFlashing`/`stopFlashing` para ser reutilizada desde `triggerNotification` y los listeners de `visibilitychange`/`focus`/`blur`.
- `AppShell` integra los hooks de forma compositiva y usa un patrón de ref (`handleIncomingMessageRef`) para mantener la estabilidad del callback WS a través de renders. El switchcase basado en `envelope.type` permite escalar con nuevos tipos de eventos sin modificar la estructura del handler.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 1 (REST como canal autoritativo)**: En `AppShell`, el handler de `hitl_request` solo inserta el caso en el store local (`upsertCase`) y dispara notificaciones; **no** envía ninguna resolución por WebSocket. La resolución sigue siendo exclusiva de REST (`POST /cases/:id/resolve`).
- **Regla 2 (Sin advisorId)**: El handler de `hitl_request` no envía ningún payload que contenga `advisorId`. Solo procesa datos entrantes y dispara efectos locales.
- **Regla 3 (Tailwind v4 CSS-first)**: `AppShell` no introduce nuevas clases que dependan de configuración JS de Tailwind.
- **Regla 4 (Streaming buffer 50ms)**: Los eventos `agent_stream_chunk` se despachan directamente a `appendToken` del store, que ya implementa el buffer ordenado por `index` para garantizar orden correcto de tokens incluso con entrega fuera de orden.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: Ninguna (todas las APIs usadas son nativas del navegador: `Notification`, `AudioContext`, `document.title`, `document.visibilityState`, `window.focus`).
- **Puntos Críticos a Probar**:
1. **useNotification — Permiso denegado**: Bloquear notificaciones en el navegador y verificar que `notify()` loguea warning sin lanzar error. Verificar que la solicitud de permiso solo ocurre si `Notification.permission !== 'granted'` y `!== 'denied'`.
2. **useNotification — Click handler**: Al hacer clic en una notificación, debe ejecutar el callback `onClick`, enfocar la ventana y cerrar la notificación. Verificar que `window.focus()` se llama y que `notification.close()` se ejecuta.
3. **useSound — AudioContext lazy**: Sin gesto de usuario, `playNotificationSound()` debe loguear warning. Tras un click o keydown, debe crear el `AudioContext` y reproducir el chime. Verificar que el `AudioContext` se cierra al desmontar el hook.
4. **useSound — AudioContext suspended**: Simular estado `suspended` (navegador con política de autoplay estricta) y verificar que `playNotificationSound()` loguea warning sin lanzar error.
5. **useSound — Dos tonos**: Verificar que se reproducen dos frecuencias distintas (C5=523.25Hz, E5=659.25Hz) con el fade out exponencial. La amplitud debe decaer de 0.08 a 0.001 en 450ms.
6. **useTitleFlash — Trigger con pestaña oculta**: Abrir otra pestaña, llamar `triggerNotification()`, verificar que el título parpadea entre `"(🔔 1) ¡Nuevo Caso!"` y `"Claro Cases Dashboard"` cada 1s. Llamar `triggerNotification()` nuevamente sin enfocar: el contador debe incrementar a 2 y el título alternar con `"(🔔 2) ¡Nuevo Caso!"`.
7. **useTitleFlash — Restauración al enfocar**: Con el título parpadeando, enfocar la pestaña (click o atajo de teclado). Verificar que el título se restaura a `"Claro Cases Dashboard"` inmediatamente y el intervalo se limpia.
8. **useTitleFlash — Múltiples triggers**: Llamar `triggerNotification()` 5 veces con la pestaña visible → el contador se incrementa pero no parpadea (solo parpadea si la pestaña está oculta). Al ocultar la pestaña, el parpadeo debe comenzar mostrando `"(🔔 5) ¡Nuevo Caso!"`.
9. **AppShell — hitl_request handler**: Simular un evento `hitl_request` entrante por WebSocket y verificar que se ejecutan las tres acciones: (1) aparece notificación de escritorio, (2) suena el chime, (3) el título parpadea si la pestaña no está enfocada. Verificar que el caso se inserta en el store.
10. **AppShell — Click en notificación**: Al hacer clic en la notificación generada por `hitl_request`, debe navegar a `/cases` y seleccionar el caso (`selectedCaseId` debe coincidir con el `id` del case del payload).
11. **AppShell — init_state handler**: Simular `init_state` con múltiples conversaciones y casos. Verificar que el store se actualiza correctamente sin duplicados.
12. **AppShell — agent_stream_chunk handler**: Simular chunks desordenados y verificar que `appendToken` los ordena por índice.
13. **AppShell — Ref pattern**: Verificar que el `onMessage` callback siempre usa la última versión de `handleIncomingMessage` incluso si el componente se rerenderiza (ej. cambio de `isDarkMode`). El handler debe seguir funcionando sin necesidad de reconectar el WS.
14. **npm run build**: Verificar que `npm run build` compila sin errores de tipo.
### 3.1 Paso 10 — Fix CRÍTICO: Buffer de Streaming 50ms (Regla 4)
- `src/store/useAppStore.ts`: [Modificado] → Implementación completa del buffer de rate-limiting de 50ms para streaming token-a-token según Regla 4 de la Fase 2. Se añadieron:
- **Sistema de buffer externo** (`conversationBuffers: Map<string, ConversationBufferEntry>`) fuera del estado de Zustand, evitando re-renders al acumular chunks entrantes.
- **`flushBuffer()`**: Procesa los tokens pendientes de una conversación con UNA sola llamada a `set()`, ordenando por `index` para garantizar orden correcto incluso con entrega fuera de orden. Solo actualiza el store si la conversación es la seleccionada (optimización de re-render).
- **`scheduleBufferFlush()`**: Programa un `setTimeout` de 50ms por conversación, con guarda para no duplicar timers.
- **`forceFlushBuffer()`**: Vaciado inmediato del buffer, usado al cambiar de conversación seleccionada.
- **`appendToken()`**: Ahora acumula en el buffer externo y solo programa flush si la conversación es la activa. No llama a `set()` directamente.
- **`completeStream()`**: Limpia el buffer de la conversación (cancela timer pendiente y elimina entrada del Map) antes de actualizar el store.
- **`setSelectedConversationId()`**: Nueva acción que fuerza el flush del buffer al seleccionar una conversación con tokens acumulados.
- **`removeConversation()`**: Nueva acción que limpia el buffer y elimina la conversación del store, incluyendo el cleanup del `selectedConversationId` si corresponde.
- Auto-limpieza en `flushBuffer()`: si la conversación ya no existe en el store, se elimina la entrada del buffer.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se implementó el patrón de buffer externo (fuera del estado de Zustand) para evitar re-renders durante la acumulación de tokens. El buffer usa un `Map<string, ConversationBufferEntry>` donde cada entrada contiene un array `pending` de chunks y un `timer` (setTimeout de 50ms). Solo la conversación seleccionada programa timers de flush; las conversaciones no seleccionadas acumulan tokens silenciosamente sin disparar re-renders. Cuando `completeStream` llega, se limpia el buffer y se actualiza el store con el `fullContent` autoritativo en una sola llamada a `set()`. Al cambiar de conversación, `setSelectedConversationId` fuerza un flush inmediato de los tokens acumulados de la nueva conversación.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 4 (Streaming buffer 50ms)**: Implementado completamente. Cada chunk se acumula en un buffer externo, y cada 50ms se hace una sola llamada a `set()` con todos los chunks acumulados ordenados. Solo la conversación seleccionada actualiza el store, limitando re-renders a máximo 20 fps.
- **Regla 4 — Limpieza de buffer**: `completeStream` elimina el buffer de la conversación (cancela timer + borra entrada del Map). `removeConversation` también limpia el buffer. El flush auto-limpia buffers huérfanos si la conversación ya no existe.
- **Regla 4 — Non-selected conversations**: Las conversaciones no seleccionadas acumulan tokens sin timer, sin llamar a `set()`, y sin causar re-renders. Al ser seleccionadas, `setSelectedConversationId` fuerza un flush inmediato.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: Ninguna. Todo implementado con APIs nativas de JavaScript (`Map`, `setTimeout`, `clearTimeout`).
- **Puntos Críticos a Probar**:
1. **Buffer de 50ms**: Enviar 100 chunks rápidamente a `appendToken` para la misma conversación seleccionada. Verificar que `set()` se llama ~20 veces por segundo (cada 50ms), no 100 veces.
2. **Orden de chunks**: Enviar chunks con índices desordenados (ej: 2, 0, 1, 4, 3) y verificar que el contenido final en el store está correctamente ordenado.
3. **Conversación no seleccionada**: Enviar chunks a una conversación NO seleccionada. Verificar que NO se llama `set()` y que los chunks se acumulan en el buffer externo.
4. **Seleccionar conversación con buffer**: Acumular chunks en una conversación no seleccionada, luego llamar `setSelectedConversationId()`. Verificar que todos los chunks acumulados se aplican al store en una sola llamada.
5. **completeStream limpia buffer**: Llamar `completeStream()` para una conversación con chunks pendientes. Verificar que `conversationBuffers` ya no tiene entrada para esa conversación y que el store muestra `fullContent`.
6. **removeConversation limpia buffer**: Llamar `removeConversation()` y verificar que la entrada del buffer se elimina y la conversación desaparece del store.
7. **Auto-limpieza flush**: Eliminar manualmente una conversación del store (vía `set()` directo) y verificar que el siguiente flush elimina la entrada huérfana del buffer.
8. **No fuga de timers**: Verificar que los `setTimeout` se cancelan correctamente al llamar `completeStream()` o `removeConversation()`. No debe haber timers colgados después de estas operaciones.
9. **npm run build**: Debe compilar sin errores tras los cambios.
---
## Fase 4: Reporte de Calidad (QA)
### 4.1 Resumen de Cobertura
- **Resultado Global**: PASSED
- **Total de Casos Ejecutados**: 7
- **Casos Exitosos**: 7
- **Casos Fallidos**: 0
### 4.2 Detalle de Pruebas y Casos de Estrés
- **Build (npm run build)**: PASSED — `tsc -b && vite build` ejecutado exitosamente. Vite v6.4.3 transformó 2742 módulos en 3.04s. Archivos generados en `dist/`: `index.html` (0.66 kB), CSS (31.42 kB), JS browser (300.77 kB), JS app (425.81 kB). Sin errores ni warnings.
- **TypeScript Compiler (npx tsc --noEmit)**: PASSED — Zero type errors en toda la base de código. El comando retornó sin output (compilación limpia).
- **Regla 1 (hitl_response)**: PASSED — Búsqueda con grep en `src/` no encontró ninguna ocurrencia de `hitl_response`. La resolución de casos es exclusivamente REST.
- **Regla 2 (advisorId)**: PASSED — Búsqueda con grep en `src/` no encontró ninguna ocurrencia de `advisorId`. No hay identificadores de asesor en payloads cliente→servidor.
- **Regla 3 (tailwind.config.ts)**: PASSED — El archivo `tailwind.config.ts` NO existe en la raíz del proyecto. Toda la configuración de Tailwind v4 está en `src/index.css` via `@theme` y `@custom-variant dark`.
- **Regla 4 (buffer streaming 50ms)**: PASSED — Verificación de código fuente en `src/store/useAppStore.ts`:
- ✅ Buffer externo (`conversationBuffers: Map<string, ConversationBufferEntry>`) declarado fuera del estado de Zustand (línea 95), evitando re-renders por chunk individual.
- ✅ `scheduleBufferFlush()` programa `setTimeout` de 50ms por conversación (línea 196-198) con guarda contra timers duplicados (línea 194).
- ✅ `flushBuffer()` verifica `selectedConversationId` antes de llamar a `set()` (línea 129). Si la conversación no es la seleccionada, retorna sin actualizar el store.
- ✅ Las conversaciones no seleccionadas acumulan chunks en el buffer sin programar timer (líneas 327-331: `scheduleBufferFlush` solo se llama si `selectedConversationId === convId`).
- ✅ `completeStream()` (líneas 334-381): limpia el buffer (cancela timer + elimina entrada del Map) y luego actualiza el store con `fullContent` en una sola llamada a `set()`.
- ✅ `removeConversation()` (líneas 394-411): limpia el buffer antes de eliminar la conversación del store.
- ✅ `setSelectedConversationId()` (líneas 383-392): fuerza flush inmediato via `forceFlushBuffer()` al cambiar de conversación.
- **Regla 5 (45+ casos mapeados)**: PASSED — 53 registros en `src/data/caseTypeDefinitions.ts`, todos con `uiPattern` (53/53), `applicative` (53/53), `formFields` (53/53), y `validationSchema`/`payloadBuilder` provistos via spread de funciones fábrica (51 usos de factory spreads: `simpleConfirmation` 18, `multiFieldForm` 18, más `confirmationWithValue`, `dateSimple`, `freeText`, `readOnly`).
### 4.3 Evidencia y Logs de Consola
```text
# Build
> [email protected] build
> tsc -b && vite build
vite v6.4.3 building for production...
transforming...
✓ 2742 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.66 kB │ gzip: 0.37 kB
dist/assets/index-CtPkX2JE.css 31.42 kB │ gzip: 6.27 kB
dist/assets/browser-yp4JH-9T.js 300.77 kB │ gzip: 99.29 kB
dist/assets/index-QLIqdcdX.js 425.81 kB │ gzip: 119.05 kB
✓ built in 3.04s
# TypeScript Check
$ npx tsc --noEmit
(no output — zero type errors)
# Regla 1 — hitl_response grep
$ grep -r "hitl_response" src/
(no output)
# Regla 2 — advisorId grep
$ grep -r "advisorId" src/
(no output)
# Regla 3 — tailwind.config.ts existence
$ test -f tailwind.config.ts && echo FAIL || echo PASS
PASS (file not found)
# Regla 5 — case count verification
uiPattern occurrences: 53
applicative occurrences: 53
formFields occurrences: 53
Factory spread patterns: 51
```
BIN
View File
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Claro Cases</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
PORT=3000
# SQLite database filename (creates a local file in the project folder)
DATABASE_FILE=database.sqlite
+122
View File
@@ -0,0 +1,122 @@
# Claro Cases Tracking System - Manual Técnico
Este documento detalla el funcionamiento técnico, arquitectura y endpoints de la aplicación **Claro Cases**, un sistema en tiempo real para rastrear solicitudes de clientes, gestionar casos y registrar métricas de tiempo de atención.
---
## 🏗️ Arquitectura del Sistema
La aplicación sigue una arquitectura desacoplada monolítica simple basada en Node.js, Express y SQLite:
```mermaid
graph TD
Client[Cliente Externo / API Client] -->|POST /api/requests| ExpressServer[Express Server Node.js]
ExpressServer -->|Guarda Datos| SQLite[SQLite database.sqlite]
ExpressServer -->|SSE Broadcast| Frontend[Frontend SPA HTML/JS]
Frontend -->|Interacción de Operador| ExpressServer
```
1. **Frontend (SPA - Single Page Application)**: Desarrollado con Vanilla HTML5, Javascript (ES6) y CSS3 adaptativo con soporte para temas Claro/Oscuro.
2. **Backend**: Servidor REST Express que provee persistencia y notificaciones en tiempo real a través de Server-Sent Events (SSE).
3. **Persistencia**: Base de datos ligera embebida SQLite gestionada eficientemente de manera síncrona por medio del driver `better-sqlite3`.
---
## 🗄️ Esquema de la Base de Datos
La tabla principal es `requests`. La inicialización del esquema y las migraciones automáticas de columnas se gestionan dinámicamente en el módulo [db.js](file:///c:/Users/pepit/Desktop/doc/NODE/autonomus/Claro%20Cases/db.js).
```sql
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'Pending',
external_id TEXT,
cedula TEXT,
tipo_solicitud TEXT,
payload TEXT, -- Objeto JSON guardado como String
handling_time INTEGER DEFAULT 0, -- Tiempo de gestión en segundos
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
---
## 🔌 API Endpoints (Backend)
### 1. Registrar Nuevo Caso
* **Endpoint**: `POST /api/requests`
* **Content-Type**: `application/json`
* **Payload**:
```json
{
"title": "Título del Caso",
"description": "Detalle descriptivo",
"external_id": "EXT-12345",
"cedula": "10203040",
"tipo_solicitud": "validacion_usuario",
"payload": {
"nombre": "Juan Pérez",
"telefono": "31000000"
}
}
```
* **Comportamiento**: Guarda la solicitud y transmite en tiempo real la información a todos los operadores conectados usando SSE.
### 2. Stream de Actualizaciones en Tiempo Real
* **Endpoint**: `GET /api/sse`
* **Content-Type**: `text/event-stream`
* **Comportamiento**: Registra el cliente para recibir eventos unidireccionales desde el servidor en tiempo real.
### 3. Obtener Solicitudes
* **Endpoint**: `GET /api/requests`
* **Comportamiento**: Devuelve todos los casos de la base de datos en orden descendente por fecha de creación.
### 4. Actualizar Estado / Finalizar Caso
* **Endpoint**: `PUT /api/requests/:id`
* **Payload**:
```json
{
"status": "Finalizado",
"handling_time": 125,
"payload": {
"valor_cierre": "Comentario de finalización"
}
}
```
### 5. Eliminar Solicitud
* **Endpoint**: `DELETE /api/requests/:id`
---
## ⚡ Lógica del Operador (Frontend)
El flujo interactivo se gestiona en [public/app.js](file:///c:/Users/pepit/Desktop/doc/NODE/autonomus/Claro%20Cases/public/app.js) y se compone de:
* **Sincronización en Tiempo Real (SSE)**: Permite la llegada instantánea de nuevos casos al panel de control del operador sin recargar la página. Al ingresar un caso nuevo:
- Se reproduce una alerta de sonido usando la API nativa de **Web Audio**.
- Se genera una **Notificación de Escritorio HTML5** en el sistema operativo.
- El título de la pestaña del navegador destella con la cantidad de casos pendientes.
* **Filtros e Historial**:
- Buscador integrado que busca en tiempo real por título, ID de referencia, descripción, cédula y tipo de solicitud.
- Pestañas rápidas para clasificar casos entre *Todos*, *Pendientes* y *Finalizados*.
* **Flujos Especiales al Gestionar Casos**:
- Al dar clic en **Gestionar Caso**, se inicia un cronómetro individual para medir el tiempo que el operador tarda en resolver la solicitud.
- **Solicitud de Validación**: Si el caso contiene la palabra `"validacion"` en su campo `tipo_solicitud`, la UI cambia dinámicamente y despliega los botones **Sí** y **No** integrados bajo la pregunta *"¿El usuario es válido?"*. Al hacer clic en cualquiera de ellos, el caso se cierra.
- **Otras Solicitudes**: Si es de cualquier otro tipo, la interfaz le exige al operador ingresar un valor de resolución en una entrada de texto antes de dar clic en **Finalizar Caso**. Este valor se inyecta en el objeto JSON de `payload` del caso.
---
## 🛠️ Ejecución Local
1. Instalar dependencias:
```cmd
npm install
```
2. Ejecutar servidor en modo desarrollo (con recarga automática vía nodemon):
```cmd
npm run dev
```
El servidor levantará en http://localhost:3000.
View File
+19
View File
@@ -0,0 +1,19 @@
{
"name": "claro-cases",
"version": "1.0.0",
"description": "Claro Cases tracking system with Postgres and live UI updates",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"better-sqlite3": "^12.11.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
View File
View File
+3609 -1440
View File
File diff suppressed because it is too large Load Diff
+33 -10
View File
@@ -1,19 +1,42 @@
{
"name": "claro-cases",
"version": "1.0.0",
"description": "Claro Cases tracking system with Postgres and live UI updates",
"main": "server.js",
"private": true,
"version": "2.0.0",
"type": "module",
"description": "Claro Cases - Dashboard de gestión de casos y monitoreo",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"lint": "tsc --noEmit"
},
"dependencies": {
"better-sqlite3": "^12.11.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2"
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.5.0",
"zustand": "^5.0.4",
"zod": "^3.24.4",
"date-fns": "^4.1.0",
"lucide-react": "^0.511.0"
},
"devDependencies": {
"nodemon": "^3.1.0"
"@tailwindcss/vite": "^4.1.6",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"msw": "^2.7.5",
"tailwindcss": "^4.1.6",
"typescript": "~5.7.2",
"vite": "^6.3.2",
"vitest": "^3.1.2"
},
"msw": {
"workerDirectory": [
"public"
]
}
}
+361
View File
@@ -0,0 +1,361 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.15.0'
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get('content-type')
?.toLowerCase()
.startsWith('text/event-stream')
// Clone the response so both the client and the library could consume it.
const responseClone = isEventStreamResponse ? null : response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone && responseClone.body
? [serializedRequest.body, responseClone.body]
: [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
+19
View File
@@ -0,0 +1,19 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AppShell } from './components/layout/AppShell';
import CasesPage from './pages/CasesPage';
import MonitorPage from './pages/MonitorPage';
export default function App() {
return (
<BrowserRouter>
<AppShell>
<Routes>
<Route path="/" element={<Navigate to="/cases" replace />} />
<Route path="/cases" element={<CasesPage />} />
<Route path="/monitor" element={<MonitorPage />} />
<Route path="*" element={<Navigate to="/cases" replace />} />
</Routes>
</AppShell>
</BrowserRouter>
);
}
View File
@@ -0,0 +1,63 @@
import { useAppStore } from '@/store/useAppStore';
// ─────────────────────────────────────────────────────────────
// Applicatives list (8 apps from CSV)
// ─────────────────────────────────────────────────────────────
const APPLICATIVES = [
'AC+',
'ASCARD',
'DiMe',
'Formatos SGCS',
'Mi asistencia 360',
'Paradigma',
'RR',
'Phone Protect',
] as const;
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function ApplicativeFilter() {
const applicativeFilter = useAppStore((s) => s.applicativeFilter);
const setApplicativeFilter = useAppStore((s) => s.setApplicativeFilter);
const isSelected = (app: string) => applicativeFilter === app;
return (
<div className="flex flex-wrap gap-1.5 px-3 py-2">
{APPLICATIVES.map((app) => (
<button
key={app}
type="button"
onClick={() =>
setApplicativeFilter(isSelected(app) ? null : app)
}
className={`text-[10px] font-medium px-2 py-1 rounded-[10px] border
transition-all duration-150 whitespace-nowrap
${
isSelected(app)
? 'bg-accent-orange/10 text-accent-orange border-accent-orange/30'
: 'bg-elevated text-text-muted border-border hover:bg-hover hover:text-text-secondary'
}`}
>
{app}
</button>
))}
{/* Clear filter button — only visible when a filter is active */}
{applicativeFilter && (
<button
type="button"
onClick={() => setApplicativeFilter(null)}
className="text-[10px] font-medium px-2 py-1 rounded-[10px] border
border-border text-text-muted hover:text-accent-red
hover:border-accent-red/30 transition-all duration-150"
>
Limpiar
</button>
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { format } from 'date-fns';
import { es } from 'date-fns/locale';
import { Clock } from 'lucide-react';
import type { CaseRequest } from '@/types';
import { CaseStatus } from '@/types';
import StatusBadge from '@/components/shared/StatusBadge';
import TypeBadge from '@/components/cases/TypeBadge';
// ─────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────
interface CaseCardProps {
case: CaseRequest;
isActive: boolean;
onClick: () => void;
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
/**
* Format handling time (seconds) to MM:SS display.
*/
function formatTime(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')}`;
}
/**
* Format ISO date string to dd/MM/yyyy HH:mm.
*/
function formatDate(iso: string): string {
try {
return format(new Date(iso), 'dd/MM/yyyy HH:mm', { locale: es });
} catch {
return iso;
}
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function CaseCard({
case: caseData,
isActive,
onClick,
}: CaseCardProps) {
const hasTimer =
caseData.status === CaseStatus.IN_PROGRESS && caseData.handlingTime > 0;
return (
<button
type="button"
onClick={onClick}
className={`w-full text-left bg-elevated border rounded-md p-3 cursor-pointer
transition-all duration-150 hover:bg-hover
animate-[slideIn_0.2s_ease-out]
${isActive ? 'bg-accent-orange/4 border-accent-orange' : 'border-border'}`}
>
{/* ── Header: title + StatusBadge + timer ────────────── */}
<div className="flex items-start justify-between gap-2 mb-1.5">
<span className="text-[13px] font-semibold text-text-primary truncate flex-1 min-w-0">
{caseData.title}
</span>
<div className="flex items-center gap-1.5 shrink-0">
{hasTimer && (
<span className="flex items-center gap-1 text-[11px] font-mono text-accent-orange tabular-nums">
<Clock size={12} />
{formatTime(caseData.handlingTime)}
</span>
)}
<StatusBadge status={caseData.status} />
</div>
</div>
{/* ── Description preview (2-line clamp) ─────────────── */}
<p className="text-[12px] text-text-secondary leading-snug line-clamp-2 mb-2">
{caseData.description}
</p>
{/* ── Footer: externalId + TypeBadge + date ──────────── */}
<div className="flex items-center justify-between gap-2">
{caseData.externalId ? (
<span className="text-[10px] font-mono text-text-muted truncate min-w-0">
#{caseData.externalId}
</span>
) : (
<span />
)}
<div className="flex items-center gap-1.5 shrink-0">
<TypeBadge tipoSolicitud={caseData.tipoSolicitud} />
<span className="text-[10px] text-text-muted whitespace-nowrap">
{formatDate(caseData.createdAt)}
</span>
</div>
</div>
</button>
);
}
+251
View File
@@ -0,0 +1,251 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { ChevronDown, ChevronUp, Clock, CheckCircle, XCircle } from 'lucide-react';
import { format } from 'date-fns';
import { es } from 'date-fns/locale';
import type { CaseRequest } from '@/types';
import { CaseStatus } from '@/types';
import StatusBadge from '@/components/shared/StatusBadge';
import TypeBadge from '@/components/cases/TypeBadge';
import FormRenderer from '@/components/cases/FormRenderer';
import Timer, { type TimerHandle } from '@/components/shared/Timer';
import { useAppStore } from '@/store/useAppStore';
import { caseTypeByToolName } from '@/data/caseTypeDefinitions';
// ─────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────
interface CaseDetailProps {
case: CaseRequest;
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
function formatDate(iso: string): string {
try {
return format(new Date(iso), 'dd/MM/yyyy HH:mm', { locale: es });
} catch {
return iso;
}
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function CaseDetail({ case: caseData }: CaseDetailProps) {
const resolveCase = useAppStore((s) => s.resolveCase);
const [stepsOpen, setStepsOpen] = useState(false);
const timerRef = useRef<TimerHandle>(null);
// Look up the CaseTypeDefinition for this case's toolName
const caseType = caseTypeByToolName[caseData.tipoSolicitud] ?? null;
// Start timer when case is IN_PROGRESS and detail is mounted
useEffect(() => {
if (caseData.status === CaseStatus.IN_PROGRESS && timerRef.current) {
timerRef.current.start();
}
}, [caseData.status, caseData.id]);
const handleFormSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
const actionName = caseType?.toolName ?? 'resolver';
await resolveCase(caseData.id, {
action: actionName,
payload: formData,
});
// Stop timer after successful resolution
if (timerRef.current) {
timerRef.current.stop();
}
} catch (err) {
console.error('[CaseDetail] resolve failed:', err);
}
},
[caseData.id, caseType, resolveCase],
);
// ── Render payload key-value pairs ─────────────────────────
const payloadEntries = caseData.payload
? Object.entries(caseData.payload).filter(
([key]) => !key.startsWith('_'), // skip internal keys
)
: [];
// ── Derive metadata from the case itself ───────────────────
const metadataItems = [
{ label: 'ID Referencia', value: `#${String(caseData.id)}` },
{ label: 'Cédula', value: caseData.cedula ?? '—' },
{
label: 'Tipo Solicitud',
value: (
<TypeBadge tipoSolicitud={caseData.tipoSolicitud} />
),
},
{ label: 'Aplicativo', value: caseData.applicative },
];
const isResolved =
caseData.status === CaseStatus.RESOLVED ||
caseData.status === CaseStatus.FAILED;
return (
<div className="h-full flex flex-col overflow-hidden">
{/* ── Scrollable content ──────────────────────────────── */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{/* ── Header with metadata grid ─────────────────────── */}
<div className="flex items-start justify-between gap-3 mb-1">
<h2 className="text-[16px] font-bold text-text-primary leading-tight">
{caseData.title}
</h2>
<StatusBadge status={caseData.status} />
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-[12px]">
{metadataItems.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<span className="text-text-muted whitespace-nowrap">
{item.label}:
</span>
<span className="text-text-primary font-medium truncate">
{item.value}
</span>
</div>
))}
</div>
{/* ── Description ───────────────────────────────────── */}
<div>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-text-muted mb-1.5">
Descripción
</h3>
<div className="bg-elevated border border-border rounded-md p-3">
<p className="text-[12px] text-text-secondary leading-relaxed whitespace-pre-wrap">
{caseData.description}
</p>
</div>
</div>
{/* ── Payload entrante (key-value grid) ─────────────── */}
{payloadEntries.length > 0 && (
<div>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-text-muted mb-1.5">
Payload entrante
</h3>
<div className="bg-surface border border-border rounded-md divide-y divide-border">
{payloadEntries.map(([key, value]) => (
<div
key={key}
className="flex items-start gap-3 px-3 py-2"
>
<span className="text-[11px] font-medium text-text-muted w-[120px] shrink-0 truncate">
{key}
</span>
<span className="text-[12px] text-text-primary break-all">
{typeof value === 'object'
? JSON.stringify(value)
: String(value ?? '—')}
</span>
</div>
))}
</div>
</div>
)}
{/* ── FormRenderer (dynamic form) ───────────────────── */}
{caseType && !isResolved && (
<div>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-text-muted mb-1.5">
Resolución
</h3>
<FormRenderer
key={caseData.id}
caseType={caseType}
onSubmit={handleFormSubmit}
/>
</div>
)}
{/* ── Already resolved — show confirmation ──────────── */}
{isResolved && (
<div
className={`flex items-center gap-2 p-3 rounded-md border text-[12px] font-medium ${
caseData.status === CaseStatus.RESOLVED
? 'bg-accent-green/8 text-accent-green border-accent-green/20'
: 'bg-accent-red/8 text-accent-red border-accent-red/20'
}`}
>
{caseData.status === CaseStatus.RESOLVED ? (
<CheckCircle size={16} />
) : (
<XCircle size={16} />
)}
<span>
{caseData.status === CaseStatus.RESOLVED
? 'Caso resuelto'
: 'Caso fallido'}
</span>
</div>
)}
{/* ── Steps accordion ───────────────────────────────── */}
{caseType && caseType.steps.length > 0 && (
<div className="border border-border rounded-md overflow-hidden">
<button
type="button"
onClick={() => setStepsOpen((prev) => !prev)}
className="w-full flex items-center justify-between px-3 py-2.5
bg-elevated hover:bg-hover transition-colors duration-150"
>
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-secondary">
Instrucciones paso a paso
</span>
{stepsOpen ? (
<ChevronUp size={14} className="text-text-muted" />
) : (
<ChevronDown size={14} className="text-text-muted" />
)}
</button>
{stepsOpen && (
<div className="px-3 py-2.5 space-y-2 bg-surface">
{caseType.steps.map((step, idx) => (
<div key={idx} className="flex gap-2 text-[12px]">
<span className="text-text-muted font-mono shrink-0 w-5 text-right">
{idx + 1}.
</span>
<span className="text-text-secondary leading-relaxed">
{step}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* ── Operation panel (sticky bottom) ─────────────────── */}
<div className="shrink-0 border-t border-border bg-surface px-5 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock size={14} className="text-text-muted" />
<Timer ref={timerRef} caseId={caseData.id} />
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] text-text-muted">
{caseData.externalId && `#${caseData.externalId}`}
{formatDate(caseData.createdAt)}
</span>
</div>
</div>
</div>
</div>
);
}
+607
View File
@@ -0,0 +1,607 @@
import { useState, useCallback } from 'react';
import type { CaseTypeDefinition, FormField } from '@/types';
import { CaseUIType } from '@/types';
// ─────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────
interface FormRendererProps {
caseType: CaseTypeDefinition;
onSubmit: (data: Record<string, unknown>) => void;
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
/**
* Transform a date string from HTML date input (yyyy-mm-dd)
* to the expected format (dd-mm-aaaa).
*/
function toDisplayFormat(value: string): string {
if (!value) return '';
// If already in dd-mm-aaaa format, return as-is
if (/^\d{2}-\d{2}-\d{4}$/.test(value)) return value;
// Convert from yyyy-mm-dd to dd-mm-aaaa
const [y, m, d] = value.split('-');
if (!y || !m || !d) return value;
return `${d}-${m}-${y}`;
}
/**
* Transform a date string from display format (dd-mm-aaaa)
* back to HTML date input format (yyyy-mm-dd) for the value attribute.
*/
function fromDisplayFormat(value: string): string {
if (!value) return '';
// If already in yyyy-mm-dd format, return as-is
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
// Convert from dd-mm-aaaa to yyyy-mm-dd
const [d, m, y] = value.split('-');
if (!d || !m || !y) return value;
return `${y}-${m}-${d}`;
}
/**
* Collect all field keys recursively, including conditional fields.
* Returns array of FormField objects in display order.
*/
function getVisibleFields(
fields: FormField[],
formValues: Record<string, unknown>,
): FormField[] {
return fields.filter((f) => {
if (!f.conditionalOn) return true;
return formValues[f.conditionalOn.field] === f.conditionalOn.value;
});
}
// ─────────────────────────────────────────────────────────────
// Internal field components
// ─────────────────────────────────────────────────────────────
interface FieldInputProps {
field: FormField;
value: unknown;
error?: string;
onChange: (key: string, value: unknown) => void;
}
function FieldInput({ field, value, error, onChange }: FieldInputProps) {
const baseInputClass = `w-full h-8 px-2.5 text-[12px] bg-surface border rounded-md
text-text-primary placeholder:text-text-muted
focus:outline-none focus:border-accent-orange focus:ring-0
transition-[border] duration-150
${error ? 'border-accent-red' : 'border-border'}`;
const baseTextareaClass = `w-full px-2.5 py-2 text-[12px] bg-surface border rounded-md
text-text-primary placeholder:text-text-muted
focus:outline-none focus:border-accent-orange focus:ring-0
transition-[border] duration-150 resize-none
${error ? 'border-accent-red' : 'border-border'}`;
switch (field.type) {
case 'text':
return (
<input
type="text"
className={baseInputClass}
placeholder={field.placeholder ?? ''}
value={String(value ?? '')}
onChange={(e) => onChange(field.key, e.target.value)}
/>
);
case 'number':
return (
<input
type="number"
className={baseInputClass}
placeholder={field.placeholder ?? ''}
min={field.min}
max={field.max}
value={value !== undefined && value !== '' ? String(value) : ''}
onChange={(e) =>
onChange(
field.key,
e.target.value === '' ? '' : Number(e.target.value),
)
}
/>
);
case 'currency':
return (
<div className="relative">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[12px] text-text-muted pointer-events-none">
$
</span>
<input
type="number"
step="0.01"
min={0}
className={`${baseInputClass} pl-6`}
placeholder={field.placeholder ?? '0.00'}
value={value !== undefined && value !== '' ? String(value) : ''}
onChange={(e) =>
onChange(
field.key,
e.target.value === '' ? '' : Number(e.target.value),
)
}
/>
</div>
);
case 'date': {
const displayValue =
typeof value === 'string' ? fromDisplayFormat(value) : '';
return (
<input
type="date"
className={baseInputClass}
value={displayValue}
onChange={(e) =>
onChange(field.key, e.target.value ? toDisplayFormat(e.target.value) : '')
}
/>
);
}
case 'select':
return (
<select
className={baseInputClass}
value={String(value ?? '')}
onChange={(e) => onChange(field.key, e.target.value)}
>
<option value="" disabled>
{field.placeholder ?? 'Seleccionar...'}
</option>
{(field.options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
case 'textarea':
return (
<textarea
className={`${baseTextareaClass} min-h-[72px]`}
rows={3}
placeholder={field.placeholder ?? ''}
value={String(value ?? '')}
onChange={(e) => onChange(field.key, e.target.value)}
/>
);
case 'toggle': {
const checked = Boolean(value);
return (
<label className="flex items-center gap-2 cursor-pointer select-none">
<div className="relative">
<input
type="checkbox"
className="sr-only peer"
checked={checked}
onChange={(e) => onChange(field.key, e.target.checked)}
/>
<div className="w-8 h-4.5 rounded-full bg-bg-hover peer-checked:bg-accent-orange transition-colors duration-150" />
<div
className={`absolute top-0.5 left-0.5 w-3.5 h-3.5 rounded-full bg-white
shadow-sm transition-transform duration-150
${checked ? 'translate-x-[15px]' : 'translate-x-0'}`}
/>
</div>
<span className="text-[12px] text-text-primary">{field.label}</span>
</label>
);
}
default:
return null;
}
}
// ─────────────────────────────────────────────────────────────
// FormRenderer
// ─────────────────────────────────────────────────────────────
export default function FormRenderer({
caseType,
onSubmit,
}: FormRendererProps) {
const [formValues, setFormValues] = useState<Record<string, unknown>>(() => {
// Initialize with defaults
const initial: Record<string, unknown> = {};
for (const f of caseType.formFields) {
if (f.type === 'toggle') {
initial[f.key] = false;
} else if (f.type === 'select') {
initial[f.key] = '';
} else if (f.type === 'currency' || f.type === 'number') {
initial[f.key] = '';
} else {
initial[f.key] = '';
}
}
return initial;
});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const handleFieldChange = useCallback(
(key: string, value: unknown) => {
setFormValues((prev) => ({ ...prev, [key]: value }));
// Clear error for the changed field
setFormErrors((prev) => {
if (!prev[key]) return prev;
const next = { ...prev };
delete next[key];
return next;
});
},
[],
);
const handleSubmit = useCallback(
(dataOverride?: Record<string, unknown>) => {
// Allow callers to pass complete data (e.g. from simple confirmation buttons)
const data = dataOverride ?? formValues;
// Validate with Zod schema
const result = caseType.validationSchema.safeParse(data);
if (!result.success) {
const errors: Record<string, string> = {};
for (const issue of result.error.issues) {
const key = issue.path.join('.');
if (!errors[key]) {
errors[key] = issue.message;
}
}
setFormErrors(errors);
return;
}
setSubmitting(true);
try {
// Build payload using the caseType's payloadBuilder
const payload = caseType.payloadBuilder(result.data);
onSubmit(payload);
} finally {
setSubmitting(false);
}
},
[formValues, caseType, onSubmit],
);
// ── Render by uiPattern ────────────────────────────────────
switch (caseType.uiPattern) {
// ── SIMPLE_CONFIRMATION ──────────────────────────────────
case CaseUIType.SIMPLE_CONFIRMATION:
return (
<div className="flex gap-3 mt-3">
<button
type="button"
onClick={() => handleSubmit({ confirmacion: true })}
className="flex-1 px-4 py-2 rounded-md text-[12px] font-semibold
bg-accent-green text-white
hover:brightness-110 active:brightness-90
transition-all duration-150"
>
</button>
<button
type="button"
onClick={() => handleSubmit({ confirmacion: false })}
className="flex-1 px-4 py-2 rounded-md text-[12px] font-semibold
bg-accent-red text-white
hover:brightness-110 active:brightness-90
transition-all duration-150"
>
No
</button>
</div>
);
// ── CONFIRMATION_WITH_VALUE ─────────────────────────────
case CaseUIType.CONFIRMATION_WITH_VALUE: {
const confirmValue = formValues.confirmacion as boolean | undefined;
const valorError = formErrors.valor;
return (
<div className="space-y-3 mt-3">
{/* Radio group Sí / No */}
<fieldset>
<legend className="text-[12px] font-medium text-text-primary mb-1.5">
{caseType.formFields.find((f) => f.key === 'confirmacion')?.label ??
'¿Confirmar?'}
</legend>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 cursor-pointer text-[12px] text-text-primary">
<input
type="radio"
name="confirmacion"
checked={confirmValue === true}
onChange={() => handleFieldChange('confirmacion', true)}
className="accent-accent-orange"
/>
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-[12px] text-text-primary">
<input
type="radio"
name="confirmacion"
checked={confirmValue === false}
onChange={() => handleFieldChange('confirmacion', false)}
className="accent-accent-orange"
/>
No
</label>
</div>
</fieldset>
{/* Conditional numeric field — only visible on "Sí" */}
{confirmValue === true && (
<div>
<label className="block text-[12px] font-medium text-text-primary mb-1">
{caseType.formFields.find((f) => f.key === 'valor')?.label ?? 'Valor'}
</label>
<div className="relative">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[12px] text-text-muted pointer-events-none">
$
</span>
<input
type="number"
step="0.01"
min={0}
className={`w-full h-8 pl-6 pr-2.5 text-[12px] bg-surface border rounded-md
text-text-primary placeholder:text-text-muted
focus:outline-none focus:border-accent-orange
transition-[border] duration-150
${valorError ? 'border-accent-red' : 'border-border'}`}
placeholder="0.00"
value={
formValues.valor !== undefined && formValues.valor !== ''
? String(formValues.valor)
: ''
}
onChange={(e) =>
handleFieldChange(
'valor',
e.target.value === '' ? '' : Number(e.target.value),
)
}
/>
</div>
{valorError && (
<p className="text-[10px] text-accent-red mt-0.5">{valorError}</p>
)}
</div>
)}
{/* Submit button */}
<button
type="button"
disabled={submitting}
onClick={() => handleSubmit()}
className="w-full px-4 py-2 rounded-md text-[12px] font-semibold
bg-accent-orange text-white
hover:brightness-110 active:brightness-90
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-150"
>
{submitting ? 'Enviando...' : 'Enviar resolución'}
</button>
{/* Global error feedback */}
{formErrors.confirmacion && (
<p className="text-[10px] text-accent-red">{formErrors.confirmacion}</p>
)}
</div>
);
}
// ── MULTI_FIELD_FORM ────────────────────────────────────
case CaseUIType.MULTI_FIELD_FORM: {
const visibleFields = getVisibleFields(
caseType.formFields,
formValues,
);
return (
<div className="space-y-3 mt-3">
{visibleFields.map((field) => (
<div key={field.key}>
{field.type !== 'toggle' && (
<label className="block text-[12px] font-medium text-text-primary mb-1">
{field.label}
{field.required && (
<span className="text-accent-red ml-0.5">*</span>
)}
</label>
)}
<FieldInput
field={field}
value={formValues[field.key]}
error={formErrors[field.key]}
onChange={handleFieldChange}
/>
{formErrors[field.key] && (
<p className="text-[10px] text-accent-red mt-0.5">
{formErrors[field.key]}
</p>
)}
</div>
))}
<button
type="button"
disabled={submitting}
onClick={() => handleSubmit()}
className="w-full px-4 py-2 rounded-md text-[12px] font-semibold
bg-accent-orange text-white
hover:brightness-110 active:brightness-90
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-150"
>
{submitting ? 'Enviando...' : 'Enviar resolución'}
</button>
</div>
);
}
// ── DATE_SIMPLE ─────────────────────────────────────────
case CaseUIType.DATE_SIMPLE: {
const dateField = caseType.formFields[0] ?? {
key: 'fecha',
label: 'Fecha',
type: 'date' as const,
required: true,
};
const dateValue = formValues[dateField.key] as string | undefined;
return (
<div className="space-y-3 mt-3">
<div>
<label className="block text-[12px] font-medium text-text-primary mb-1">
{dateField.label}
{dateField.required && (
<span className="text-accent-red ml-0.5">*</span>
)}
</label>
<input
type="date"
className={`w-full h-8 px-2.5 text-[12px] bg-surface border rounded-md
text-text-primary
focus:outline-none focus:border-accent-orange
transition-[border] duration-150
${formErrors[dateField.key] ? 'border-accent-red' : 'border-border'}`}
value={dateValue ? fromDisplayFormat(dateValue) : ''}
onChange={(e) =>
handleFieldChange(
dateField.key,
e.target.value ? toDisplayFormat(e.target.value) : '',
)
}
/>
<p className="text-[10px] text-text-muted mt-0.5">Formato: dd-mm-aaaa</p>
{formErrors[dateField.key] && (
<p className="text-[10px] text-accent-red mt-0.5">
{formErrors[dateField.key]}
</p>
)}
</div>
<button
type="button"
disabled={submitting}
onClick={() => handleSubmit()}
className="w-full px-4 py-2 rounded-md text-[12px] font-semibold
bg-accent-orange text-white
hover:brightness-110 active:brightness-90
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-150"
>
{submitting ? 'Enviando...' : 'Enviar resolución'}
</button>
</div>
);
}
// ── FREE_TEXT ───────────────────────────────────────────
case CaseUIType.FREE_TEXT: {
const textareaField = caseType.formFields[0] ?? {
key: 'respuesta',
label: 'Respuesta',
type: 'textarea' as const,
required: true,
};
const displayField = caseType.formFields[0];
return (
<div className="space-y-3 mt-3">
<div>
<label className="block text-[12px] font-medium text-text-primary mb-1">
{textareaField.label}
{textareaField.required && (
<span className="text-accent-red ml-0.5">*</span>
)}
</label>
<textarea
className={`w-full px-2.5 py-2 text-[12px] bg-surface border rounded-md
text-text-primary placeholder:text-text-muted
focus:outline-none focus:border-accent-orange
transition-[border] duration-150 resize-none min-h-[80px]
${formErrors[textareaField.key] ? 'border-accent-red' : 'border-border'}`}
rows={4}
placeholder={displayField?.placeholder ?? 'Escriba su respuesta aquí...'}
value={String(formValues[textareaField.key] ?? '')}
onChange={(e) =>
handleFieldChange(textareaField.key, e.target.value)
}
/>
{formErrors[textareaField.key] && (
<p className="text-[10px] text-accent-red mt-0.5">
{formErrors[textareaField.key]}
</p>
)}
</div>
<button
type="button"
disabled={submitting}
onClick={() => handleSubmit()}
className="w-full px-4 py-2 rounded-md text-[12px] font-semibold
bg-accent-orange text-white
hover:brightness-110 active:brightness-90
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-150"
>
{submitting ? 'Enviando...' : 'Enviar resolución'}
</button>
</div>
);
}
// ── READ_ONLY ───────────────────────────────────────────
case CaseUIType.READ_ONLY:
return (
<div className="space-y-3 mt-3">
<div className="bg-elevated border border-border rounded-md p-3">
<p className="text-[12px] text-text-secondary leading-relaxed">
Este caso es de solo lectura. Revise la información proporcionada
y marque como revisado cuando haya terminado.
</p>
</div>
<button
type="button"
disabled={submitting}
onClick={() => handleSubmit({})}
className="w-full px-4 py-2 rounded-md text-[12px] font-semibold
bg-accent-orange text-white
hover:brightness-110 active:brightness-90
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-150"
>
{submitting ? 'Enviando...' : 'Marcar como revisado'}
</button>
</div>
);
default:
return (
<div className="bg-accent-yellow/10 border border-accent-yellow/25 rounded-md p-3 mt-3">
<p className="text-[12px] text-accent-yellow font-medium">
Tipo de formulario no soportado: {caseType.uiPattern}
</p>
</div>
);
}
}
+20
View File
@@ -0,0 +1,20 @@
// ─────────────────────────────────────────────────────────────
// TypeBadge — Muestra el tipo de solicitud con estilo orange/accent
// ─────────────────────────────────────────────────────────────
interface TypeBadgeProps {
tipoSolicitud: string;
}
export default function TypeBadge({ tipoSolicitud }: TypeBadgeProps) {
return (
<span
className="inline-block max-w-[140px] truncate text-[10px] px-1.5 py-0.5
rounded-sm font-medium
bg-accent-orange/10 text-accent-orange border border-accent-orange/25"
title={tipoSolicitud}
>
{tipoSolicitud}
</span>
);
}
View File
+253
View File
@@ -0,0 +1,253 @@
import { useEffect, useCallback, useRef, type ReactNode } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { wsClient } from '@/services/wsClient';
import { useAppStore } from '@/store/useAppStore';
import { useNotification } from '@/hooks/useNotification';
import { useSound } from '@/hooks/useSound';
import { useTitleFlash } from '@/hooks/useTitleFlash';
import type { WSEnvelope } from '@/types/wsProtocol';
import Header from '@/components/layout/Header';
import Sidebar from '@/components/layout/Sidebar';
// ─────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────
interface AppShellProps {
children: ReactNode;
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export function AppShell({ children }: AppShellProps) {
const isDarkMode = useAppStore((s) => s.isDarkMode);
const fetchCases = useAppStore((s) => s.fetchCases);
const fetchConversations = useAppStore((s) => s.fetchConversations);
const setWsStatus = useAppStore((s) => s.setWsStatus);
const upsertCase = useAppStore((s) => s.upsertCase);
const upsertConversation = useAppStore((s) => s.upsertConversation);
const addMessage = useAppStore((s) => s.addMessage);
const appendToken = useAppStore((s) => s.appendToken);
const completeStream = useAppStore((s) => s.completeStream);
const location = useLocation();
const navigate = useNavigate();
// ── Hooks for preserved features (Paso 9) ──────────────────
const { notify } = useNotification();
const { playNotificationSound } = useSound();
const { triggerNotification } = useTitleFlash();
// ── Incoming WebSocket message handler ─────────────────────
const handleIncomingMessage = useCallback(
(envelope: WSEnvelope) => {
const { type, payload } = envelope;
switch (type) {
// ── Full state sync on (re)connect ──────────────────
case 'init_state': {
const conversations = payload.conversations;
if (Array.isArray(conversations)) {
for (const conv of conversations) {
upsertConversation(conv as any);
}
}
const activeCases = payload.activeCases;
if (Array.isArray(activeCases)) {
for (const c of activeCases) {
upsertCase(c as any);
}
}
break;
}
// ── New conversation started ────────────────────────
case 'conversation_started': {
const conv = payload.conversation;
if (conv) {
upsertConversation(conv as any);
}
break;
}
// ── Conversation ended ──────────────────────────────
case 'conversation_ended': {
// The store could mark the conversation as ended;
// currently handled on next init_state sync.
break;
}
// ── Full user message ───────────────────────────────
case 'user_message': {
const convId = payload.conversationId as string | undefined;
const msg = payload.message;
if (convId && msg) {
addMessage(convId, msg as any);
}
break;
}
// ── Agent streaming: chunk ──────────────────────────
case 'agent_stream_chunk': {
const chunkConvId = payload.conversationId as string | undefined;
const msgId = payload.messageId as string | undefined;
const token = payload.token as string | undefined;
const index = payload.index as number | undefined;
if (chunkConvId && msgId && token !== undefined && index !== undefined) {
appendToken(chunkConvId, msgId, token, index);
}
break;
}
// ── Agent streaming: complete ───────────────────────
case 'agent_stream_completed': {
const compConvId = payload.conversationId as string | undefined;
const compMsgId = payload.messageId as string | undefined;
const fullContent = payload.fullContent as string | undefined;
if (compConvId && compMsgId && fullContent !== undefined) {
completeStream(compConvId, compMsgId, fullContent);
}
break;
}
// ── Agent status changed ────────────────────────────
case 'agent_status_update': {
// Could update agent status in the store;
// currently no dedicated slice for agent entities.
break;
}
// ═══════════════════════════════════════════════════
// HITL Request — trigger all preserved features
// ═══════════════════════════════════════════════════
case 'hitl_request': {
const caseData = payload.case as
| Record<string, unknown>
| undefined;
const caseTitle: string =
(caseData?.title as string) ?? 'Nuevo caso HITL';
const caseDescription: string =
(caseData?.description as string) ??
'Se requiere intervención humana';
// 1) Desktop notification — click handler navigates to /cases
notify(caseTitle, caseDescription, () => {
const caseId = (caseData?.id ?? payload.conversationId) as string | number;
useAppStore.setState({ selectedCaseId: caseId });
navigate('/cases');
});
// 2) Play the twotone chime
playNotificationSound();
// 3) Flash the tab title if the tab is hidden
triggerNotification();
// 4) Insert the new case into the store
if (caseData) {
upsertCase(caseData as any);
}
break;
}
// ── Case resolved (broadcast) ───────────────────────
case 'hitl_resolved': {
// The store could update the case status here;
// the authoritative update comes via REST polling as well.
break;
}
// ── Error from server ───────────────────────────────
case 'error': {
const errMsg: string =
(payload.message as string) ?? 'Unknown server error';
console.error('[WS] Server error:', errMsg);
break;
}
default: {
// Unknown event type — log in development for debugging
if (import.meta.env.DEV) {
console.debug('[WS] Unhandled event type:', type);
}
break;
}
}
},
[
notify,
playNotificationSound,
triggerNotification,
upsertCase,
upsertConversation,
addMessage,
appendToken,
completeStream,
navigate,
],
);
// ── Keep a ref to the latest handler so the WS callback
// always uses the current version without remounting. ──
const handleIncomingMessageRef = useRef(handleIncomingMessage);
handleIncomingMessageRef.current = handleIncomingMessage;
// ── Sync dark mode class on <html> ────────────────────────
useEffect(() => {
const root = document.documentElement;
if (isDarkMode) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}, [isDarkMode]);
// ── Initialize WebSocket connection and data fetching ─────
useEffect(() => {
// Set up WebSocket status sync
wsClient.onStatusChange = (status) => {
setWsStatus(status);
};
// Connect WebSocket
wsClient.connect();
// Set up incoming message handler (delegates through ref)
wsClient.onMessage = (envelope) => {
handleIncomingMessageRef.current(envelope);
};
// Initial data fetch based on route
if (location.pathname.startsWith('/cases')) {
fetchCases();
} else if (location.pathname.startsWith('/monitor')) {
fetchConversations();
}
// Cleanup on unmount
return () => {
wsClient.onStatusChange = null;
wsClient.onMessage = null;
wsClient.disconnect();
};
// NOTE: intentionally running only on mount; route changes handled by pages
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="flex flex-col h-full">
{/* Top header */}
<Header />
{/* Body: sidebar + content */}
<div className="flex flex-1 overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-hidden">{children}</main>
</div>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { Sun, Moon } from 'lucide-react';
import { useAppStore } from '@/store/useAppStore';
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
function wsStatusConfig(status: string): {
dot: string;
label: string;
} {
switch (status) {
case 'connected':
return { dot: 'bg-accent-green', label: 'Conectado' };
case 'reconnecting':
return { dot: 'bg-accent-yellow', label: 'Reconectando...' };
case 'disconnected':
default:
return { dot: 'bg-accent-red', label: 'Desconectado' };
}
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function Header() {
const isDarkMode = useAppStore((s) => s.isDarkMode);
const toggleDarkMode = useAppStore((s) => s.toggleDarkMode);
const wsStatus = useAppStore((s) => s.wsStatus);
const { dot: dotColor, label: wsLabel } = wsStatusConfig(wsStatus);
return (
<header className="flex items-center justify-between h-[50px] px-4 border-b border-border bg-surface shadow-sm shrink-0">
{/* ── Left: Logo ──────────────────────────────────────── */}
<div className="flex items-center gap-2">
<span className="text-[18px] leading-none" role="img" aria-label="Claro">
🔴
</span>
<h1 className="text-[15px] font-extrabold bg-gradient-to-r from-accent-orange to-accent-yellow bg-clip-text text-transparent">
Claro Cases
</h1>
<span
className="text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded-[10px]
bg-accent-green/10 text-accent-green border border-accent-green/20"
>
En vivo
</span>
</div>
{/* ── Right: WS indicator + Theme toggle ──────────────── */}
<div className="flex items-center gap-3">
{/* WebSocket status */}
<div className="flex items-center gap-1.5 text-[11px] text-text-muted">
<span
className={`w-[7px] h-[7px] rounded-full ${dotColor} ${
wsStatus === 'reconnecting' ? 'animate-pulse' : ''
}`}
/>
<span>{wsLabel}</span>
</div>
{/* Dark mode toggle */}
<button
type="button"
onClick={toggleDarkMode}
className="flex items-center justify-center w-[28px] h-[28px] rounded-md
text-text-muted hover:text-text-primary hover:bg-hover
transition-colors"
aria-label={isDarkMode ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
>
{isDarkMode ? <Sun size={16} /> : <Moon size={16} />}
</button>
</div>
</header>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { NavLink } from 'react-router-dom';
import { LayoutList, Monitor } from 'lucide-react';
// ─────────────────────────────────────────────────────────────
// Nav items
// ─────────────────────────────────────────────────────────────
const navItems = [
{
to: '/cases',
label: 'Casos',
icon: LayoutList,
},
{
to: '/monitor',
label: 'Monitor',
icon: Monitor,
},
];
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function Sidebar() {
return (
<nav className="w-[50px] flex flex-col items-center gap-2 py-3 bg-surface border-r border-border shrink-0">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`flex flex-col items-center gap-0.5 w-[42px] py-2 rounded-md text-[10px] font-medium
transition-colors
${
isActive
? 'bg-accent-orange/8 text-accent-orange'
: 'text-text-muted hover:text-text-primary hover:bg-hover'
}`
}
>
<item.icon size={18} />
<span>{item.label}</span>
</NavLink>
))}
</nav>
);
}
View File
+113
View File
@@ -0,0 +1,113 @@
import { useRef, useEffect, useCallback } from 'react';
import { Loader2 } from 'lucide-react';
import type { Conversation } from '@/types';
import { MessageRole } from '@/types';
import MessageBubble from '@/components/monitor/MessageBubble';
import InternalNotesGroup from '@/components/monitor/InternalNotesGroup';
// ─────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────
interface ChatFeedProps {
conversation: Conversation;
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function ChatFeed({ conversation }: ChatFeedProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
const messages = conversation.messages;
// ── Determine if user is near the bottom ─────────────────
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const threshold = 100;
const distanceFromBottom =
el.scrollHeight - el.scrollTop - el.clientHeight;
isNearBottomRef.current = distanceFromBottom < threshold;
}, []);
// ── Auto-scroll when new messages arrive ─────────────────
useEffect(() => {
if (isNearBottomRef.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages.length, messages[messages.length - 1]?.content]);
// ── Group messages for rendering ─────────────────────────
function renderMessages() {
const result: React.ReactNode[] = [];
let internalBuffer: (typeof messages) = [];
function flushInternal() {
if (internalBuffer.length > 0) {
result.push(
<InternalNotesGroup
key={`internal-group-${internalBuffer[0].id}`}
messages={internalBuffer}
/>,
);
internalBuffer = [];
}
}
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg.role === MessageRole.INTERNAL) {
internalBuffer.push(msg);
continue;
}
// Flush any buffered internal notes before a non-internal message
flushInternal();
result.push(<MessageBubble key={msg.id} message={msg} />);
}
// Flush remaining internal notes at the end
flushInternal();
return result;
}
const isAgentStreaming =
messages.length > 0 &&
messages[messages.length - 1].role === MessageRole.AGENT &&
messages[messages.length - 1].isStreaming === true;
return (
<div className="flex flex-col flex-1 overflow-hidden">
{/* Scrollable message feed */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto py-3 space-y-2 scroll-smooth"
>
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<p className="text-[12px] text-text-muted">
No hay mensajes en esta conversación.
</p>
</div>
) : (
renderMessages()
)}
{/* "Escribiendo..." indicator */}
{isAgentStreaming && (
<div className="flex items-center gap-1.5 px-3 text-[11px] text-text-muted">
<Loader2 size={12} className="animate-spin text-accent-orange" />
Escribiendo...
</div>
)}
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { Loader2, User } from 'lucide-react';
import type { Conversation } from '@/types';
// ─────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────
interface ConversationCardProps {
conversation: Conversation;
isActive: boolean;
onClick: () => void;
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
function getLastMessage(conversation: Conversation): string {
if (conversation.messages.length === 0) return 'Sin mensajes';
const last = conversation.messages[conversation.messages.length - 1];
const truncated =
last.content.length > 80
? last.content.slice(0, 80) + '...'
: last.content;
return truncated;
}
function isLastMessageStreaming(conversation: Conversation): boolean {
if (conversation.messages.length === 0) return false;
const last = conversation.messages[conversation.messages.length - 1];
return last.isStreaming === true;
}
// ─────────────────────────────────────────────────────────────
// Status label helper
// ─────────────────────────────────────────────────────────────
function statusLabel(status: Conversation['status']): string {
switch (status) {
case 'active':
return 'Activa';
case 'paused':
return 'En pausa';
case 'ended':
return 'Finalizada';
default:
return status;
}
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function ConversationCard({
conversation,
isActive,
onClick,
}: ConversationCardProps) {
const lastMsg = getLastMessage(conversation);
const streaming = isLastMessageStreaming(conversation);
return (
<button
type="button"
onClick={onClick}
className={`w-full text-left bg-elevated border rounded-md p-3 cursor-pointer
transition-all duration-150 hover:bg-hover
animate-[slideIn_0.2s_ease-out]
${isActive ? 'bg-accent-orange/4 border-accent-orange' : 'border-border'}`}
>
{/* ── Header: client info + HITL badge + streaming ──── */}
<div className="flex items-center gap-2 mb-1.5">
<User size={14} className="text-text-muted shrink-0" />
<span className="text-[13px] font-semibold text-text-primary truncate flex-1 min-w-0">
{conversation.clientId || `Cliente ${conversation.id}`}
</span>
{streaming && (
<Loader2 size={12} className="text-accent-orange animate-spin shrink-0" />
)}
</div>
{/* ── Last message preview ──────────────────────────── */}
<p className="text-[11px] text-text-secondary leading-snug truncate mb-2">
{lastMsg}
</p>
{/* ── Footer: agent ID + status ─────────────────────── */}
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-mono text-text-muted truncate min-w-0">
Agente: {conversation.agentId}
</span>
<span
className={`text-[10px] font-medium shrink-0 ${
conversation.status === 'active'
? 'text-accent-green'
: 'text-text-muted'
}`}
>
{statusLabel(conversation.status)}
</span>
</div>
</button>
);
}
@@ -0,0 +1,108 @@
import { useState, useCallback } from 'react';
import { Send } from 'lucide-react';
import { wsClient } from '@/services/wsClient';
// ─────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────
interface InternalNoteBannerProps {
conversationId: string;
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function InternalNoteBanner({
conversationId,
}: InternalNoteBannerProps) {
const [content, setContent] = useState('');
const [sent, setSent] = useState(false);
const [sending, setSending] = useState(false);
// ── Send handler ──────────────────────────────────────────
const handleSend = useCallback(() => {
const trimmed = content.trim();
if (!trimmed || sending) return;
setSending(true);
try {
wsClient.send('internal_note', {
conversationId,
content: trimmed,
});
setContent('');
setSent(true);
setTimeout(() => setSent(false), 2000);
} catch (err) {
console.error('[InternalNoteBanner] Failed to send:', err);
} finally {
setSending(false);
}
}, [content, conversationId, sending]);
// ── Keyboard shortcut (Enter to send, Shift+Enter for newline) ─
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
return (
<div className="shrink-0 border-t border-border bg-surface px-4 py-3">
<div className="flex items-end gap-2">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Escribe una nota interna… (Enter para enviar)"
rows={2}
className="flex-1 resize-none rounded-md border border-border bg-elevated
px-3 py-2 text-[12px] text-text-primary placeholder:text-text-muted
focus:outline-none focus:border-accent-orange focus:ring-1 focus:ring-accent-orange/20
transition-colors"
disabled={sending}
/>
<button
type="button"
onClick={handleSend}
disabled={!content.trim() || sending}
className="flex items-center gap-1.5 px-3 py-2 rounded-md
bg-accent-orange text-white text-[12px] font-semibold
hover:bg-accent-orange/90 transition-colors
disabled:opacity-40 disabled:cursor-not-allowed
shrink-0"
>
{sent ? (
<span className="text-accent-green">Enviado </span>
) : (
<>
<Send size={14} />
Enviar
</>
)}
</button>
</div>
{/* Hint text */}
<p className="text-[10px] text-text-muted mt-1">
<kbd className="px-1 py-0.5 rounded bg-elevated border border-border text-[9px] font-mono">
Enter
</kbd>{' '}
para enviar ·{' '}
<kbd className="px-1 py-0.5 rounded bg-elevated border border-border text-[9px] font-mono">
Shift+Enter
</kbd>{' '}
para nueva línea
</p>
</div>
);
}
@@ -0,0 +1,80 @@
import { useState } from 'react';
import { format } from 'date-fns';
import { ChevronDown, ChevronUp } from 'lucide-react';
import type { Message } from '@/types';
import { MessageRole } from '@/types';
// ─────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────
interface InternalNotesGroupProps {
messages: Message[];
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
function formatTime(iso: string): string {
try {
return format(new Date(iso), 'HH:mm');
} catch {
return iso;
}
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function InternalNotesGroup({ messages }: InternalNotesGroupProps) {
const [expanded, setExpanded] = useState(false);
// Safety check: only render internal messages
const internalMessages = messages.filter(
(m) => m.role === MessageRole.INTERNAL,
);
if (internalMessages.length === 0) return null;
return (
<div className="px-3">
<div className="border border-accent-yellow/20 rounded-md overflow-hidden">
{/* Toggle header */}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-between gap-2 px-3 py-2
bg-accent-yellow/5 hover:bg-accent-yellow/10 transition-colors
text-[11px] font-semibold text-accent-yellow"
>
<span className="flex items-center gap-1.5">
🔄 Notas internas ({internalMessages.length})
</span>
{expanded ? (
<ChevronUp size={14} className="shrink-0" />
) : (
<ChevronDown size={14} className="shrink-0" />
)}
</button>
{/* Expanded content */}
{expanded && (
<div className="divide-y divide-accent-yellow/10">
{internalMessages.map((msg) => (
<div key={msg.id} className="px-3 py-2 space-y-0.5">
<p className="text-[12px] text-text-secondary leading-snug whitespace-pre-wrap break-words">
{msg.content}
</p>
<p className="text-[10px] text-text-muted">
{formatTime(msg.timestamp)}
</p>
</div>
))}
</div>
)}
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { format } from 'date-fns';
import type { Message } from '@/types';
import { MessageRole } from '@/types';
// ─────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────
interface MessageBubbleProps {
message: Message;
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
function formatTime(iso: string): string {
try {
return format(new Date(iso), 'HH:mm');
} catch {
return iso;
}
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function MessageBubble({ message }: MessageBubbleProps) {
const { role, content, timestamp, isStreaming } = message;
// ── Determine alignment & style based on role ─────────────
const isUser = role === MessageRole.USER;
const isAgent = role === MessageRole.AGENT;
const isSystem = role === MessageRole.SYSTEM;
const isInternal = role === MessageRole.INTERNAL;
const bubbleClasses = isUser
? 'bg-accent-orange/10 self-end'
: isAgent
? 'bg-elevated self-start'
: isSystem
? 'bg-base self-center italic'
: 'bg-accent-yellow/10 self-start';
const containerClasses = isSystem
? 'flex justify-center'
: 'flex';
const textClasses = isSystem
? 'text-[11px] text-text-muted text-center max-w-[80%]'
: isInternal
? 'text-[12px] text-text-secondary'
: 'text-[13px] text-text-primary';
return (
<div className={`${containerClasses} ${isUser || isAgent || isInternal ? 'px-3' : 'px-6'}`}>
<div
className={`
max-w-[75%] rounded-md px-3 py-2
${bubbleClasses}
${isSystem ? 'px-4 py-1.5' : ''}
`}
>
{/* Internal badge */}
{isInternal && (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold uppercase text-accent-yellow mb-1">
🔒 Interno
</span>
)}
{/* Content */}
<p className={`${textClasses} leading-snug whitespace-pre-wrap break-words`}>
{content}
{isStreaming && (
<span className="inline-block w-[2px] h-[14px] bg-accent-orange ml-0.5 animate-pulse align-text-bottom" />
)}
</p>
{/* Timestamp */}
<p
className={`
text-[10px] text-text-muted mt-1
${isUser ? 'text-right' : 'text-left'}
`}
>
{formatTime(timestamp)}
</p>
</div>
</div>
);
}
View File
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react';
// ─────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────
interface EmptyStateProps {
icon: ReactNode;
title: string;
description: string;
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function EmptyState({ icon, title, description }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center h-full w-full gap-2 px-6">
<div className="text-[3rem] opacity-40 leading-none select-none">
{icon}
</div>
<p className="text-[14px] font-semibold text-text-primary text-center">
{title}
</p>
<p className="text-[12px] text-text-secondary text-center max-w-[260px]">
{description}
</p>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, type ReactNode } from 'react';
// ─────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
actions?: ReactNode;
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function Modal({ isOpen, onClose, title, children, actions }: ModalProps) {
// Close on Escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm animate-[fadeIn_0.18s_ease-out]"
onClick={onClose} // close on backdrop click
>
<div
className="bg-surface border border-border rounded-lg shadow-lg min-w-[360px] max-w-[480px] w-full mx-4
animate-[fadeIn_0.18s_ease-out]"
onClick={(e) => e.stopPropagation()} // prevent closing when clicking content
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 className="text-[14px] font-semibold text-text-primary">
{title}
</h2>
<button
onClick={onClose}
className="text-text-muted hover:text-text-primary transition-colors text-[16px] leading-none"
aria-label="Cerrar"
>
</button>
</div>
{/* Body */}
<div className="px-4 py-3 text-[13px] text-text-secondary">
{children}
</div>
{/* Actions */}
{actions && (
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
{actions}
</div>
)}
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useState, useEffect, useRef } from 'react';
import { Search } from 'lucide-react';
import { useAppStore } from '@/store/useAppStore';
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function SearchBar() {
const setSearchQuery = useAppStore((s) => s.setSearchQuery);
const [localValue, setLocalValue] = useState('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Debounce 300ms before writing to the store
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
setSearchQuery(localValue);
}, 300);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [localValue, setSearchQuery]);
return (
<div className="relative">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
/>
<input
type="text"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
placeholder="Buscar casos..."
className="w-full h-8 pl-8 pr-3 text-[12px] bg-elevated border border-border rounded-md
text-text-primary placeholder:text-text-muted
focus:outline-none focus:border-accent-orange focus:ring-0
transition-[border] duration-150"
/>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { CaseStatus } from '@/types';
// ─────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────
interface StatusBadgeProps {
status: CaseStatus;
}
// ─────────────────────────────────────────────────────────────
// Label map
// ─────────────────────────────────────────────────────────────
const STATUS_LABELS: Record<CaseStatus, string> = {
[CaseStatus.PENDING]: 'Pendiente',
[CaseStatus.IN_PROGRESS]: 'En Progreso',
[CaseStatus.RESOLVED]: 'Finalizado',
[CaseStatus.FAILED]: 'Fallido',
};
// ─────────────────────────────────────────────────────────────
// Style map (Tailwind classes matching theme tokens)
// ─────────────────────────────────────────────────────────────
const STATUS_STYLES: Record<CaseStatus, string> = {
[CaseStatus.PENDING]:
'bg-accent-yellow/10 text-accent-yellow border-accent-yellow/20',
[CaseStatus.IN_PROGRESS]:
'bg-accent-orange/10 text-accent-orange border-accent-orange/20',
[CaseStatus.RESOLVED]:
'bg-accent-green/10 text-accent-green border-accent-green/20',
[CaseStatus.FAILED]:
'bg-accent-red/10 text-accent-red border-accent-red/20',
};
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function StatusBadge({ status }: StatusBadgeProps) {
return (
<span
className={`inline-block text-[9px] px-1.5 py-0.5 rounded-[10px] font-semibold uppercase border ${STATUS_STYLES[status]}`}
>
{STATUS_LABELS[status]}
</span>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useAppStore, type SidebarTab } from '@/store/useAppStore';
// ─────────────────────────────────────────────────────────────
// Tabs definition
// ─────────────────────────────────────────────────────────────
interface TabDef {
key: SidebarTab;
label: string;
}
const TABS: TabDef[] = [
{ key: 'all', label: 'Todos' },
{ key: 'pending', label: 'Pendientes' },
{ key: 'resolved', label: 'Finalizados' },
];
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function TabsBar() {
const sidebarTab = useAppStore((s) => s.sidebarTab);
const setSidebarTab = useAppStore((s) => s.setSidebarTab);
return (
<div className="flex border-b border-border">
{TABS.map((tab) => {
const isActive = sidebarTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setSidebarTab(tab.key)}
className={`flex-1 px-3 py-2 text-[11px] font-semibold uppercase tracking-wider
transition-all duration-150 border-b-2
${
isActive
? 'bg-accent-orange/8 text-accent-orange border-accent-orange'
: 'text-text-muted border-transparent hover:text-text-secondary hover:border-text-muted/30'
}`}
>
{tab.label}
</button>
);
})}
</div>
);
}
+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;
View File
File diff suppressed because it is too large Load Diff
View File
+3
View File
@@ -0,0 +1,3 @@
export { useNotification } from './useNotification';
export { useSound } from './useSound';
export { useTitleFlash } from './useTitleFlash';
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useCallback, useRef } from 'react';
// ─────────────────────────────────────────────────────────────
// useNotification
// ─────────────────────────────────────────────────────────────
/**
* Hook for HTML5 desktop notifications.
*
* - Requests permission on mount if not already granted.
* - Exposes `notify(title, body, onClick?)` to fire a notification.
* - Clicking the notification executes the optional `onClick` callback,
* focuses the window, and auto-closes the notification.
*/
export function useNotification() {
const permissionRef = useRef<NotificationPermission | null>(null);
// ── Request permission on mount ──────────────────────────────
useEffect(() => {
if (!('Notification' in window)) {
console.warn(
'[useNotification] This browser does not support desktop notifications',
);
return;
}
if (Notification.permission === 'granted') {
permissionRef.current = 'granted';
return;
}
if (Notification.permission !== 'denied') {
Notification.requestPermission()
.then((permission) => {
permissionRef.current = permission;
})
.catch((err) => {
console.error('[useNotification] Permission request failed:', err);
});
}
}, []);
// ── Notify function ─────────────────────────────────────────
const notify = useCallback(
(title: string, body: string, onClick?: () => void): void => {
if (!('Notification' in window)) {
console.warn('[useNotification] Notifications are not supported');
return;
}
if (Notification.permission !== 'granted') {
console.warn(
'[useNotification] Notification permission is not granted',
);
return;
}
try {
const notification = new Notification(title, {
body,
icon: '/favicon.ico',
});
// Attach click handler
if (onClick) {
notification.onclick = (event: Event) => {
event.preventDefault();
window.focus();
notification.close();
onClick();
};
}
// Auto-close after 6 seconds to avoid cluttering the notification tray
setTimeout(() => {
notification.close();
}, 6_000);
} catch (err) {
console.error('[useNotification] Failed to create notification:', err);
}
},
[],
);
return { notify };
}
+114
View File
@@ -0,0 +1,114 @@
import { useRef, useEffect, useCallback } from 'react';
// ─────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────
/** C5 frequency in Hz (523.25) */
const C5 = 523.25;
/** E5 frequency in Hz (659.25) */
const E5 = 659.25;
/** Duration of each tone in seconds (120 ms) */
const TONE_DURATION = 0.12;
/** Overall gain / volume (0.08 = 8%) */
const VOLUME = 0.08;
/** Exponential fade-out duration in seconds (450 ms) */
const FADE_DURATION = 0.45;
// ─────────────────────────────────────────────────────────────
// useSound
// ─────────────────────────────────────────────────────────────
/**
* Hook for playing a notification chime using the Web Audio API.
*
* - Initialises an `AudioContext` lazily on the first user gesture
* (click or keydown) to comply with browser autoplay policies.
* - Exposes `playNotificationSound()` which plays a twotone chime:
* C5 (523.25 Hz) for 120 ms E5 (659.25 Hz) for 120 ms,
* with a shared exponential fadeout envelope (0.08 0.001 over 450 ms).
* - If the `AudioContext` is suspended, a warning is logged and the
* call is silently ignored.
*/
export function useSound() {
const audioCtxRef = useRef<AudioContext | null>(null);
const initializedRef = useRef(false);
// ── Lazy initialisation on first user gesture ──────────────
useEffect(() => {
const initAudio = () => {
if (initializedRef.current) return;
try {
audioCtxRef.current = new AudioContext();
initializedRef.current = true;
} catch (err) {
console.warn('[useSound] Web Audio API is not available:', err);
}
// Remove both listeners after the first gesture
window.removeEventListener('click', initAudio);
window.removeEventListener('keydown', initAudio);
};
// Attach listeners for first gesture
window.addEventListener('click', initAudio, { once: true });
window.addEventListener('keydown', initAudio, { once: true });
return () => {
window.removeEventListener('click', initAudio);
window.removeEventListener('keydown', initAudio);
// Close AudioContext on unmount
const ctx = audioCtxRef.current;
if (ctx) {
ctx.close().catch(() => {});
audioCtxRef.current = null;
}
initializedRef.current = false;
};
}, []);
// ── Play the twotone chime ────────────────────────────────
const playNotificationSound = useCallback((): void => {
const ctx = audioCtxRef.current;
if (!ctx) {
console.warn('[useSound] AudioContext has not been initialised yet');
return;
}
if (ctx.state === 'suspended') {
console.warn('[useSound] AudioContext is suspended — cannot play sound');
return;
}
const now = ctx.currentTime;
// Single shared gain node for both oscillators → unified fadeout
const gain = ctx.createGain();
gain.gain.setValueAtTime(VOLUME, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + FADE_DURATION);
gain.connect(ctx.destination);
// Helper: create and schedule a single sinewave tone
const playTone = (frequency: number, startTime: number): void => {
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(frequency, startTime);
osc.connect(gain);
osc.start(startTime);
osc.stop(startTime + TONE_DURATION);
};
// Schedule the two tones
playTone(C5, now); // C5 starts immediately
playTone(E5, now + TONE_DURATION); // E5 starts after C5 ends
}, []);
return { playNotificationSound };
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useRef, useCallback } from 'react';
// ─────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────
const DEFAULT_TITLE = 'Claro Cases Dashboard';
const FLASH_INTERVAL_MS = 1_000;
// ─────────────────────────────────────────────────────────────
// useTitleFlash
// ─────────────────────────────────────────────────────────────
/**
* Hook for flashing the browser tab title when new unread cases arrive
* and the tab is not focused.
*
* - Maintains an internal counter of unread case notifications.
* - When `triggerNotification()` is called and the tab is **hidden**
* (`document.visibilityState === 'hidden'` or window lacks focus),
* the title alternates every 1 second between:
* `"(🔔 N) ¡Nuevo Caso!"` `"Claro Cases Dashboard"`
* - When the tab regains focus (visibilitychange visible, or window focus),
* the interval is cleared, the title is restored to `"Claro Cases Dashboard"`,
* and the unread counter is reset to zero.
* - All DOM event listeners are properly cleaned up on unmount.
*/
export function useTitleFlash() {
const unreadCountRef = useRef(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const originalTitleRef = useRef(DEFAULT_TITLE);
// ── Start the titleflashing interval ─────────────────────
const startFlashing = useCallback(() => {
// Don't start a second interval if one is already active
if (intervalRef.current !== null) return;
const count = unreadCountRef.current;
intervalRef.current = setInterval(() => {
// Toggle between two title states
document.title =
document.title === DEFAULT_TITLE
? `(🔔 ${count}) ¡Nuevo Caso!`
: DEFAULT_TITLE;
}, FLASH_INTERVAL_MS);
}, []);
// ── Stop the titleflashing interval and restore the title ──
const stopFlashing = useCallback(() => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
document.title = DEFAULT_TITLE;
}, []);
// ── Trigger a new unread notification ──────────────────────
const triggerNotification = useCallback(() => {
unreadCountRef.current += 1;
// If the tab is hidden or blurred, start flashing immediately
const isHidden =
document.visibilityState === 'hidden' || !document.hasFocus();
if (isHidden) {
startFlashing();
}
}, [startFlashing]);
// ── Listen for visibility / focus changes ──────────────────
useEffect(() => {
// Save the original title on mount (in case it was changed externally)
originalTitleRef.current = DEFAULT_TITLE;
document.title = DEFAULT_TITLE;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
stopFlashing();
unreadCountRef.current = 0;
} else if (unreadCountRef.current > 0) {
// Tab became hidden with pending notifications → start flashing
startFlashing();
}
};
const handleFocus = () => {
stopFlashing();
unreadCountRef.current = 0;
};
const handleBlur = () => {
// If there are unread notifications, start flashing on blur
if (unreadCountRef.current > 0) {
startFlashing();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('focus', handleFocus);
window.addEventListener('blur', handleBlur);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleFocus);
window.removeEventListener('blur', handleBlur);
// Clean up interval and restore title when the component unmounts
stopFlashing();
unreadCountRef.current = 0;
};
}, [startFlashing, stopFlashing]);
return { triggerNotification };
}
+83
View File
@@ -0,0 +1,83 @@
@import "tailwindcss";
@theme {
--color-accent-orange: #ff4e00;
--color-accent-yellow: #ffa600;
--color-accent-red: #f80018;
--color-accent-green: #10b981;
--color-bg-base: #f0f2f5;
--color-bg-surface: #ffffff;
--color-bg-elevated: #f8fafc;
--color-bg-hover: #e2e8f0;
--color-text-primary: #1e293b;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
--color-border: rgba(0, 0, 0, 0.08);
--color-border-accent: rgba(255, 78, 0, 0.25);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 12px 24px rgba(0, 0, 0, 0.12);
--font-family-sans: 'Inter', system-ui, sans-serif;
}
@custom-variant dark (&:where(.dark, .dark *));
.dark {
--color-bg-base: #0c0d14;
--color-bg-surface: #141622;
--color-bg-elevated: #1d2030;
--color-bg-hover: #2b2f46;
--color-border: rgba(255, 255, 255, 0.08);
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
}
@keyframes slideIn {
from { transform: translateY(8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pulse-op {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
body {
font-family: var(--font-family-sans);
background-color: var(--color-bg-base);
color: var(--color-text-primary);
height: 100vh;
overflow: hidden;
font-size: 13px;
margin: 0;
}
#root {
height: 100vh;
display: flex;
flex-direction: column;
}
/* Custom scrollbar styling */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-bg-hover);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
+22
View File
@@ -0,0 +1,22 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
async function enableMocking() {
if (import.meta.env.VITE_ENABLE_MSW !== 'true') {
return;
}
const { worker } = await import('./mocks/browser');
return worker.start({
onUnhandledRequest: 'bypass',
});
}
enableMocking().then(() => {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
});
+4
View File
@@ -0,0 +1,4 @@
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
+276
View File
@@ -0,0 +1,276 @@
// @ts-nocheck
import { http, HttpResponse, delay } from 'msw';
// --- Mock Data ---
// NOTE: All tipoSolicitud values MUST match toolName entries in src/data/caseTypeDefinitions.ts
const mockCases = [
{
id: 1,
title: 'Validación de Proporcionales - Móvil',
description: 'Validar si el cliente tiene cobros proporcionales en su línea móvil.',
status: 'PENDING',
externalId: 'EXT-001',
cedula: '1020304050',
tipoSolicitud: 'Validar_Proporcionales_Movil',
applicative: 'AC+',
uiPattern: 'CONFIRMATION_WITH_VALUE',
payload: { nombre: 'Juan Pérez', telefono: '3101234567', linea: '3008001234' },
handlingTime: 0,
createdAt: new Date(Date.now() - 600000).toISOString(),
},
{
id: 2,
title: 'Plan de Pagos Equipo Financiado',
description: 'Cliente solicita plan de pagos para equipo financiado ASCARD.',
status: 'IN_PROGRESS',
externalId: 'EXT-002',
cedula: '1122334455',
tipoSolicitud: 'Plan_De_Pagos_EF',
applicative: 'ASCARD',
uiPattern: 'MULTI_FIELD_FORM',
payload: { equipo: 'iPhone 15', valor_restante: 1200000, linea: '3008005678' },
handlingTime: 45,
createdAt: new Date(Date.now() - 1800000).toISOString(),
},
{
id: 3,
title: 'Activación Ajuste Creer en el Cliente - DiMe',
description: 'Cliente solicita ajuste por cobro incorrecto en factura.',
status: 'PENDING',
externalId: 'EXT-003',
cedula: '9988776655',
tipoSolicitud: 'Activa_Creer_Cliente',
applicative: 'DiMe',
uiPattern: 'CONFIRMATION_WITH_VALUE',
payload: { linea: '3008009012', valor_reclamado: 45000, periodo: '2025-03' },
handlingTime: 0,
createdAt: new Date(Date.now() - 3600000).toISOString(),
},
{
id: 4,
title: 'Cambio de Ciclo de Facturación',
description: 'Solicitud de cambio de ciclo de facturación a día 15.',
status: 'RESOLVED',
externalId: 'EXT-004',
cedula: '5566778899',
tipoSolicitud: 'Cambio_Ciclos_Movil',
applicative: 'Formatos SGCS',
uiPattern: 'SIMPLE_CONFIRMATION',
payload: { linea: '3008003456', ciclo_actual: '10' },
handlingTime: 120,
createdAt: new Date(Date.now() - 7200000).toISOString(),
},
{
id: 5,
title: 'Escalamiento de Pago No Abonado - Hogar',
description: 'Escalamiento de pago para devolución por servicio no prestado.',
status: 'FAILED',
externalId: 'EXT-005',
cedula: '4433221100',
tipoSolicitud: 'Escalar_Pagos_No_Abonados',
applicative: 'Mi asistencia 360',
uiPattern: 'MULTI_FIELD_FORM',
payload: { linea: '3008007890', monto: 85000, motivo: 'Servicio no prestado en fecha 01/03' },
handlingTime: 300,
createdAt: new Date(Date.now() - 14400000).toISOString(),
},
{
id: 6,
title: 'Desbloqueo IMEI - Phone Protect',
description: 'Solicitud de desbloqueo de IMEI por cambio de equipo.',
status: 'PENDING',
externalId: 'EXT-006',
cedula: '6677889900',
tipoSolicitud: 'IMEI_EF',
applicative: 'ASCARD',
uiPattern: 'MULTI_FIELD_FORM',
payload: { linea: '3008002345', imei_actual: '356938123456789', imei_nuevo: '356938987654321' },
handlingTime: 0,
createdAt: new Date(Date.now() - 900000).toISOString(),
},
{
id: 7,
title: 'Validación OTT - Postventa Hogar',
description: 'Cliente reporta cobro en postventa hogar por deco adicional.',
status: 'PENDING',
externalId: 'EXT-007',
cedula: '1234567890',
tipoSolicitud: 'Validar_OTT_1',
applicative: 'RR',
uiPattern: 'MULTI_FIELD_FORM',
payload: { direccion: 'Calle 50 #20-30', ciudad: 'Bogotá', servicio: 'Internet 200MB' },
handlingTime: 0,
createdAt: new Date(Date.now() - 300000).toISOString(),
},
{
id: 8,
title: 'Validación de Identidad - Cliente',
description: 'Validar los datos del cliente para contraste de identidad.',
status: 'PENDING',
externalId: 'EXT-008',
cedula: '1357924680',
tipoSolicitud: 'Validar_Identidad_Movil',
applicative: 'AC+',
uiPattern: 'SIMPLE_CONFIRMATION',
payload: { nombre: 'María López', telefono: '3109876543', email: '[email protected]' },
handlingTime: 0,
createdAt: new Date(Date.now() - 480000).toISOString(),
},
{
id: 9,
title: 'Validación de Cambio de Plan - Móvil',
description: 'Cliente solicita validar si se ha cambiado el plan recientemente.',
status: 'IN_PROGRESS',
externalId: 'EXT-009',
cedula: '2468135790',
tipoSolicitud: 'Validar_Cambio_Plan_Movil',
applicative: 'AC+',
uiPattern: 'MULTI_FIELD_FORM',
payload: { linea: '6012345678', plan_actual: 'Internet 100MB', plan_deseado: 'Internet 300MB' },
handlingTime: 30,
createdAt: new Date(Date.now() - 2400000).toISOString(),
},
{
id: 10,
title: 'Validación de Moras - Móvil',
description: 'Cliente solicita validar información sobre moras en su línea.',
status: 'PENDING',
externalId: 'EXT-010',
cedula: '3692581470',
tipoSolicitud: 'Validar_Moras_Movil',
applicative: 'AC+',
uiPattern: 'SIMPLE_CONFIRMATION',
payload: { linea: '3008006543', valor_mora: 25000, periodo: '2025-02' },
handlingTime: 0,
createdAt: new Date(Date.now() - 1200000).toISOString(),
},
];
const mockConversations = [
{
id: 'conv-1',
clientId: 'CLI-001',
agentId: 'AGENT-01',
status: 'ACTIVE',
messages: [
{ id: 'm1', role: 'user', content: 'Hola, necesito ayuda con mi factura', timestamp: new Date(Date.now() - 300000).toISOString() },
{ id: 'm2', role: 'agent', content: 'Claro, con gusto le ayudo. ¿Podría indicarme su número de línea?', timestamp: new Date(Date.now() - 280000).toISOString() },
{ id: 'm3', role: 'user', content: '3008001234', timestamp: new Date(Date.now() - 260000).toISOString() },
{ id: 'm4', role: 'agent', content: 'Gracias. Veo que tiene un cobro de $45,000 en su factura de marzo que no corresponde. ¿Le parece si procedemos con el ajuste?', timestamp: new Date(Date.now() - 240000).toISOString() },
],
createdAt: new Date(Date.now() - 600000).toISOString(),
},
{
id: 'conv-2',
clientId: 'CLI-002',
agentId: 'AGENT-02',
status: 'ACTIVE',
messages: [
{ id: 'm5', role: 'user', content: 'Quiero saber el estado de mi solicitud de desbloqueo IMEI', timestamp: new Date(Date.now() - 180000).toISOString() },
{ id: 'm6', role: 'agent', content: 'Permítame verificar. Su solicitud está en proceso de revisión. El tiempo estimado es de 24 horas hábiles.', timestamp: new Date(Date.now() - 160000).toISOString() },
],
createdAt: new Date(Date.now() - 180000).toISOString(),
},
{
id: 'conv-3',
clientId: 'CLI-003',
agentId: 'AGENT-01',
status: 'WAITING_HITL',
messages: [
{ id: 'm7', role: 'user', content: 'Necesito un plan de pagos para mi equipo financiado', timestamp: new Date(Date.now() - 90000).toISOString() },
{ id: 'm8', role: 'agent', content: 'Entiendo. Voy a transferir su caso a un asesor especializado que podrá ayudarle con el plan de pagos.', timestamp: new Date(Date.now() - 70000).toISOString() },
{ id: 'm9', role: 'system', content: 'Caso transferido a HITL - Plan de Pagos', timestamp: new Date(Date.now() - 60000).toISOString() },
],
createdAt: new Date(Date.now() - 120000).toISOString(),
},
];
// --- REST Handlers ---
export const handlers = [
// GET /api/v1/cases
http.get('*/api/v1/cases', async ({ request }) => {
await delay(200);
const url = new URL(request.url);
const status = url.searchParams.get('status');
const applicative = url.searchParams.get('applicative');
const search = url.searchParams.get('search')?.toLowerCase();
const offset = parseInt(url.searchParams.get('offset') || '0');
const limit = parseInt(url.searchParams.get('limit') || '20');
let filtered = [...mockCases];
if (status && status !== 'ALL') {
filtered = filtered.filter((c) => c.status === status);
}
if (applicative) {
filtered = filtered.filter((c) => c.applicative === applicative);
}
if (search) {
filtered = filtered.filter(
(c) =>
c.title.toLowerCase().includes(search) ||
c.externalId.toLowerCase().includes(search) ||
c.cedula.includes(search) ||
c.tipoSolicitud.toLowerCase().includes(search),
);
}
const total = filtered.length;
const items = filtered.slice(offset, offset + limit);
return HttpResponse.json({ items, total });
}),
// GET /api/v1/cases/:id
http.get('*/api/v1/cases/:id', async ({ params }) => {
await delay(150);
const id = parseInt(params.id as string);
const caseItem = mockCases.find((c) => c.id === id);
if (!caseItem) {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json(caseItem);
}),
// POST /api/v1/cases/:id/resolve
http.post('*/api/v1/cases/:id/resolve', async ({ params, request }) => {
await delay(300);
const id = parseInt(params.id as string);
const body = (await request.json()) as Record<string, unknown>;
const index = mockCases.findIndex((c) => c.id === id);
if (index === -1) {
return new HttpResponse(null, { status: 404 });
}
mockCases[index] = {
...mockCases[index],
status: 'RESOLVED',
handlingTime: (body.handlingTime as number) || mockCases[index].handlingTime,
payload: { ...mockCases[index].payload, ...(body.payload as Record<string, unknown>) },
};
return HttpResponse.json(mockCases[index]);
}),
// GET /api/v1/conversations/active
http.get('*/api/v1/conversations/active', async () => {
await delay(200);
return HttpResponse.json(mockConversations);
}),
// GET /api/v1/conversations/:id
http.get('*/api/v1/conversations/:id', async ({ params }) => {
await delay(150);
const conversation = mockConversations.find((c) => c.id === params.id);
if (!conversation) {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json(conversation);
}),
];
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>
);
}
View File
+150
View File
@@ -0,0 +1,150 @@
import type { CaseRequest, Conversation } from '@/types';
// ─────────────────────────────────────────────────────────────
// Configuration
// ─────────────────────────────────────────────────────────────
const API_BASE =
import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
// ─────────────────────────────────────────────────────────────
// Exported Interfaces
// ─────────────────────────────────────────────────────────────
export interface PaginatedResponse<T> {
items: T[];
total: number;
}
export interface CaseFilters {
status?: string;
applicative?: string;
search?: string;
offset?: number;
limit?: number;
}
// ─────────────────────────────────────────────────────────────
// HTTP Error Wrapper
// ─────────────────────────────────────────────────────────────
export class ApiError extends Error {
public readonly status: number;
public readonly statusText: string;
constructor(status: number, statusText: string, message?: string) {
super(message || `HTTP ${status}: ${statusText}`);
this.name = 'ApiError';
this.status = status;
this.statusText = statusText;
}
}
// ─────────────────────────────────────────────────────────────
// Internal helpers
// ─────────────────────────────────────────────────────────────
async function request<T>(
path: string,
options?: RequestInit,
): Promise<T> {
const url = `${API_BASE}${path}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
...options,
});
if (!response.ok) {
let errorMessage: string | undefined;
try {
const body = await response.json();
errorMessage = body.message ?? body.error ?? undefined;
} catch {
// ignore parse errors on error bodies
}
throw new ApiError(response.status, response.statusText, errorMessage);
}
// Handle 204 No Content
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
// ─────────────────────────────────────────────────────────────
// Query-string builder
// ─────────────────────────────────────────────────────────────
function buildQuery(filters: CaseFilters): string {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.applicative) params.set('applicative', filters.applicative);
if (filters.search) params.set('search', filters.search);
if (filters.offset !== undefined) params.set('offset', String(filters.offset));
if (filters.limit !== undefined) params.set('limit', String(filters.limit));
const qs = params.toString();
return qs ? `?${qs}` : '';
}
// ─────────────────────────────────────────────────────────────
// API Client
// ─────────────────────────────────────────────────────────────
export const api = {
/**
* Fetch paginated list of cases with optional filters.
*/
async getCases(
filters: CaseFilters = {},
): Promise<PaginatedResponse<CaseRequest>> {
return request<PaginatedResponse<CaseRequest>>(
`/cases${buildQuery(filters)}`,
);
},
/**
* Fetch a single case by id.
*/
async getCaseById(id: string | number): Promise<CaseRequest> {
return request<CaseRequest>(`/cases/${id}`);
},
/**
* Resolve a case (REST authoritative channel).
*/
async resolveCase(
id: string | number,
data: {
action: string;
payload: Record<string, unknown>;
note?: string;
},
): Promise<CaseRequest> {
return request<CaseRequest>(`/cases/${id}/resolve`, {
method: 'POST',
body: JSON.stringify(data),
});
},
/**
* Fetch all active conversations.
*/
async getActiveConversations(): Promise<Conversation[]> {
return request<Conversation[]>('/conversations/active');
},
/**
* Fetch a single conversation by id (with messages).
*/
async getConversation(id: string): Promise<Conversation> {
return request<Conversation>(`/conversations/${id}`);
},
};
+199
View File
@@ -0,0 +1,199 @@
import type { WSEnvelope } from '@/types/wsProtocol';
// ─────────────────────────────────────────────────────────────
// Configuration
// ─────────────────────────────────────────────────────────────
const DEFAULT_WS_URL = 'ws://localhost:3000/ws/dashboard';
const WS_URL = import.meta.env.VITE_WS_URL || DEFAULT_WS_URL;
const INITIAL_BACKOFF_MS = 1_000;
const MAX_BACKOFF_MS = 30_000;
const BACKOFF_FACTOR = 2;
// ─────────────────────────────────────────────────────────────
// Connection status
// ─────────────────────────────────────────────────────────────
export type WsConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';
// ─────────────────────────────────────────────────────────────
// Event callback types
// ─────────────────────────────────────────────────────────────
export type MessageCallback = (envelope: WSEnvelope) => void;
export type StatusChangeCallback = (status: WsConnectionStatus) => void;
// ─────────────────────────────────────────────────────────────
// WebSocket Client
// ─────────────────────────────────────────────────────────────
class WsClient {
private ws: WebSocket | null = null;
private status: WsConnectionStatus = 'disconnected';
private onMessageCallback: MessageCallback | null = null;
private onStatusChangeCallback: StatusChangeCallback | null = null;
private reconnectAttempts = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private destroyFlag = false;
// ── Connection ───────────────────────────────────────────
/**
* Initiate (or re-initiate) the WebSocket connection.
* If already connected, it will close and reconnect.
*/
connect(): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
return; // already connected
}
this.destroyFlag = false;
try {
this.ws = new WebSocket(WS_URL);
} catch (err) {
this.setStatus('disconnected');
this.scheduleReconnect();
return;
}
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.setStatus('connected');
};
this.ws.onmessage = (event: MessageEvent) => {
if (!this.onMessageCallback) return;
try {
const envelope: WSEnvelope = JSON.parse(event.data as string);
this.onMessageCallback(envelope);
} catch {
// Malformed message — silently ignore
}
};
this.ws.onclose = () => {
// Only transition to reconnecting if we didn't intentionally close
if (!this.destroyFlag) {
this.setStatus('reconnecting');
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
// onerror will be followed by onclose, so we let onclose handle it
};
}
/**
* Gracefully close the WebSocket connection.
*/
disconnect(): void {
this.destroyFlag = true;
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.onclose = null; // prevent reconnect trigger
this.ws.close();
this.ws = null;
}
this.setStatus('disconnected');
}
// ── Send ─────────────────────────────────────────────────
/**
* Send a typed event through the WebSocket connection.
* Automatically wraps the payload in the standard WSEnvelope.
*/
send(type: string, payload: Record<string, unknown>): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.warn(
'[WS] Cannot send — socket is not open. Status:',
this.status,
);
return;
}
const envelope: WSEnvelope = {
type,
eventId: crypto.randomUUID(),
occurredAt: new Date().toISOString(),
payload,
};
this.ws.send(JSON.stringify(envelope));
}
// ── Callbacks ─────────────────────────────────────────────
/**
* Register a callback for incoming messages.
*/
set onMessage(cb: MessageCallback | null) {
this.onMessageCallback = cb;
}
get onMessage(): MessageCallback | null {
return this.onMessageCallback;
}
// ── Status ────────────────────────────────────────────────
/**
* Get the current connection status.
*/
getStatus(): WsConnectionStatus {
return this.status;
}
/**
* Register a callback for connection status changes.
*/
set onStatusChange(cb: StatusChangeCallback | null) {
this.onStatusChangeCallback = cb;
}
get onStatusChange(): StatusChangeCallback | null {
return this.onStatusChangeCallback;
}
// ── Private helpers ───────────────────────────────────────
private setStatus(status: WsConnectionStatus): void {
this.status = status;
this.onStatusChangeCallback?.(status);
}
private scheduleReconnect(): void {
if (this.destroyFlag) return;
const delay = Math.min(
INITIAL_BACKOFF_MS * Math.pow(BACKOFF_FACTOR, this.reconnectAttempts),
MAX_BACKOFF_MS,
);
this.reconnectAttempts += 1;
this.reconnectTimer = setTimeout(() => {
if (!this.destroyFlag) {
this.setStatus('reconnecting');
this.connect();
}
}, delay);
}
}
// ─────────────────────────────────────────────────────────────
// Singleton export
// ─────────────────────────────────────────────────────────────
export const wsClient = new WsClient();
View File
+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 }),
}));
View File
+95
View File
@@ -0,0 +1,95 @@
import type { z } from 'zod';
// ────────────────────────── ENUMS ──────────────────────────
export enum CaseUIType {
SIMPLE_CONFIRMATION = 'SIMPLE_CONFIRMATION',
CONFIRMATION_WITH_VALUE = 'CONFIRMATION_WITH_VALUE',
MULTI_FIELD_FORM = 'MULTI_FIELD_FORM',
DATE_SIMPLE = 'DATE_SIMPLE',
FREE_TEXT = 'FREE_TEXT',
READ_ONLY = 'READ_ONLY',
}
export enum CaseStatus {
PENDING = 'PENDING',
IN_PROGRESS = 'IN_PROGRESS',
RESOLVED = 'RESOLVED',
FAILED = 'FAILED',
}
export enum AgentStatus {
ONLINE = 'ONLINE',
BUSY = 'BUSY',
OFFLINE = 'OFFLINE',
}
export enum MessageRole {
USER = 'user',
AGENT = 'agent',
SYSTEM = 'system',
INTERNAL = 'internal',
}
// ────────────────────────── CORE INTERFACES ──────────────────────────
export interface CaseRequest {
id: string | number;
title: string;
description: string;
status: CaseStatus;
externalId?: string;
cedula?: string;
tipoSolicitud: string;
payload: Record<string, unknown>;
handlingTime: number;
createdAt: string;
applicative: string;
uiPattern: CaseUIType;
}
export interface Message {
id: string;
conversationId: string;
role: MessageRole;
content: string;
timestamp: string;
isStreaming?: boolean;
metadata?: Record<string, unknown>;
}
export interface Conversation {
id: string;
clientId: string;
agentId: string;
status: 'active' | 'paused' | 'ended';
messages: Message[];
createdAt: string;
}
export interface FormField {
key: string;
label: string;
type: 'text' | 'number' | 'currency' | 'date' | 'select' | 'textarea' | 'toggle';
required: boolean;
placeholder?: string;
options?: { value: string; label: string }[];
min?: number;
max?: number;
conditionalOn?: { field: string; value: unknown };
}
export interface CaseTypeDefinition {
toolName: string;
applicative: string;
specialist: string;
inputData: string;
steps: string[];
objective: string;
responseFormat: string;
document: string;
uiPattern: CaseUIType;
formFields: FormField[];
validationSchema: z.ZodType<Record<string, unknown>>;
payloadBuilder: (formData: Record<string, unknown>) => Record<string, unknown>;
}
+204
View File
@@ -0,0 +1,204 @@
import { z } from 'zod';
// ═══════════════════════════════════════════════════════════════
// 1. Envelope WebSocket estándar (bidireccional)
// ═══════════════════════════════════════════════════════════════
export const WSEnvelopeSchema = z.object({
type: z.string(),
eventId: z.string().uuid(),
occurredAt: z.string().datetime(),
payload: z.record(z.unknown()),
});
export type WSEnvelope = z.infer<typeof WSEnvelopeSchema>;
// ─────────────────────────────────────────────────────────────
// Helper: factory para crear un envelope válido
// ─────────────────────────────────────────────────────────────
export function createWSEnvelope(
type: string,
payload: Record<string, unknown>,
): WSEnvelope {
return {
type,
eventId: crypto.randomUUID(),
occurredAt: new Date().toISOString(),
payload,
};
}
// ═══════════════════════════════════════════════════════════════
// 2. Eventos servidor → cliente (Sección 8.3)
// ═══════════════════════════════════════════════════════════════
// 2.1 init_state — Estado completo al conectar/reconectar
export const InitStatePayloadSchema = z.object({
conversations: z.array(z.record(z.unknown())),
activeCases: z.array(z.record(z.unknown())),
});
export type InitStatePayload = z.infer<typeof InitStatePayloadSchema>;
// 2.2 conversation_started — Nueva conversación
export const ConversationStartedPayloadSchema = z.object({
conversation: z.record(z.unknown()),
});
export type ConversationStartedPayload = z.infer<typeof ConversationStartedPayloadSchema>;
// 2.3 conversation_ended — Conversación finalizada
export const ConversationEndedPayloadSchema = z.object({
conversationId: z.string(),
endedAt: z.string().datetime(),
});
export type ConversationEndedPayload = z.infer<typeof ConversationEndedPayloadSchema>;
// 2.4 user_message — Mensaje completo de usuario
export const UserMessagePayloadSchema = z.object({
conversationId: z.string(),
message: z.record(z.unknown()),
});
export type UserMessagePayload = z.infer<typeof UserMessagePayloadSchema>;
// 2.5 agent_stream_started — Inicio de streaming del agente
export const AgentStreamStartedPayloadSchema = z.object({
conversationId: z.string(),
messageId: z.string(),
});
export type AgentStreamStartedPayload = z.infer<typeof AgentStreamStartedPayloadSchema>;
// 2.6 agent_stream_chunk — Token individual con índice de orden
export const AgentStreamChunkPayloadSchema = z.object({
conversationId: z.string(),
messageId: z.string(),
token: z.string(),
index: z.number().int().min(0),
});
export type AgentStreamChunkPayload = z.infer<typeof AgentStreamChunkPayloadSchema>;
// 2.7 agent_stream_completed — Cierre de streaming con contenido completo
export const AgentStreamCompletedPayloadSchema = z.object({
conversationId: z.string(),
messageId: z.string(),
fullContent: z.string(),
});
export type AgentStreamCompletedPayload = z.infer<typeof AgentStreamCompletedPayloadSchema>;
// 2.8 agent_status_update — Cambio de estado del agente
export const AgentStatusUpdatePayloadSchema = z.object({
agentId: z.string(),
status: z.enum(['ONLINE', 'BUSY', 'OFFLINE']),
});
export type AgentStatusUpdatePayload = z.infer<typeof AgentStatusUpdatePayloadSchema>;
// 2.9 hitl_request — Se requiere intervención humana
export const HITLRequestPayloadSchema = z.object({
case: z.record(z.unknown()),
conversationId: z.string(),
});
export type HITLRequestPayload = z.infer<typeof HITLRequestPayloadSchema>;
// 2.10 hitl_resolved — Caso resuelto (broadcast)
export const HITLResolvedPayloadSchema = z.object({
caseId: z.string(),
resolution: z.record(z.unknown()),
});
export type HITLResolvedPayload = z.infer<typeof HITLResolvedPayloadSchema>;
// 2.11 error — Error del servidor notificable al frontend
export const ErrorPayloadSchema = z.object({
code: z.string(),
message: z.string(),
details: z.record(z.unknown()).optional(),
});
export type ErrorPayload = z.infer<typeof ErrorPayloadSchema>;
// ═══════════════════════════════════════════════════════════════
// 3. Eventos cliente → servidor (Sección 8.4)
// ═══════════════════════════════════════════════════════════════
// 3.1 internal_note — Asesor inyecta nota interna en una conversación
export const InternalNotePayloadSchema = z.object({
conversationId: z.string(),
content: z.string().min(1, 'La nota interna no puede estar vacía'),
});
export type InternalNotePayload = z.infer<typeof InternalNotePayloadSchema>;
// ═══════════════════════════════════════════════════════════════
// 4. Discriminador de eventos (payload union)
// ═══════════════════════════════════════════════════════════════
/**
* Mapa de schemas de payload por tipo de evento.
* Útil para validación dinámica en useWebSocket.
*/
export const serverEventPayloadSchemas: Record<string, z.ZodType<unknown>> = {
init_state: InitStatePayloadSchema,
conversation_started: ConversationStartedPayloadSchema,
conversation_ended: ConversationEndedPayloadSchema,
user_message: UserMessagePayloadSchema,
agent_stream_started: AgentStreamStartedPayloadSchema,
agent_stream_chunk: AgentStreamChunkPayloadSchema,
agent_stream_completed: AgentStreamCompletedPayloadSchema,
agent_status_update: AgentStatusUpdatePayloadSchema,
hitl_request: HITLRequestPayloadSchema,
hitl_resolved: HITLResolvedPayloadSchema,
error: ErrorPayloadSchema,
};
export const clientEventPayloadSchemas: Record<string, z.ZodType<unknown>> = {
internal_note: InternalNotePayloadSchema,
};
/**
* Valida el payload de un envelope WebSocket entrante según su type,
* lanzando un error descriptivo si no coincide.
*/
export function validateServerEvent(
type: string,
payload: unknown,
): Record<string, unknown> {
const schema = serverEventPayloadSchemas[type];
if (!schema) {
throw new Error(`Tipo de evento servidor desconocido: "${type}"`);
}
const result = schema.safeParse(payload);
if (!result.success) {
throw new Error(
`Payload inválido para evento "${type}": ${result.error.message}`,
);
}
return result.data as Record<string, unknown>;
}
/**
* Valida un payload saliente de cliente antes de enviarlo por WebSocket.
*/
export function validateClientEvent(
type: string,
payload: unknown,
): Record<string, unknown> {
const schema = clientEventPayloadSchemas[type];
if (!schema) {
throw new Error(`Tipo de evento cliente desconocido: "${type}"`);
}
const result = schema.safeParse(payload);
if (!result.success) {
throw new Error(
`Payload inválido para evento "${type}": ${result.error.message}`,
);
}
return result.data as Record<string, unknown>;
}
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_WS_URL: string;
readonly VITE_ENABLE_MSW: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/cases/ApplicativeFilter.tsx","./src/components/cases/CaseCard.tsx","./src/components/cases/CaseDetail.tsx","./src/components/cases/FormRenderer.tsx","./src/components/cases/TypeBadge.tsx","./src/components/layout/AppShell.tsx","./src/components/layout/Header.tsx","./src/components/layout/Sidebar.tsx","./src/components/monitor/ChatFeed.tsx","./src/components/monitor/ConversationCard.tsx","./src/components/monitor/InternalNoteBanner.tsx","./src/components/monitor/InternalNotesGroup.tsx","./src/components/monitor/MessageBubble.tsx","./src/components/shared/EmptyState.tsx","./src/components/shared/Modal.tsx","./src/components/shared/SearchBar.tsx","./src/components/shared/StatusBadge.tsx","./src/components/shared/TabsBar.tsx","./src/components/shared/Timer.tsx","./src/data/caseTypeDefinitions.ts","./src/hooks/index.ts","./src/hooks/useNotification.ts","./src/hooks/useSound.ts","./src/hooks/useTitleFlash.ts","./src/mocks/browser.ts","./src/mocks/handlers.ts","./src/pages/CasesPage.tsx","./src/pages/MonitorPage.tsx","./src/services/api.ts","./src/services/wsClient.ts","./src/store/useAppStore.ts","./src/types/index.ts","./src/types/wsProtocol.ts"],"version":"5.7.3"}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.7.3"}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:3000',
ws: true,
},
},
},
});