feat(studio): tarjetas de agente con señales de estado

La lista de agentes era un directorio: nombre y activo/inactivo. No decía
si el agente estaba realmente listo para atender.

- Tarjetas en grid con el color de marca del agente (el mismo que ve el
  visitante en el widget) y sus iniciales.
- Tres datos por agente: conversaciones de los últimos 7 días, fuentes de
  conocimiento y canales activos. Salen de tres consultas agrupadas, no de
  tres por agente: el N+1 en un listado es el que después no se saca.
- Badge de estado accionable. "sin conocimiento" cuando el agente está
  activo pero no tiene ninguna fuente — ese agente responde de memoria e
  inventa datos, que es exactamente el problema de la URL falsa, y hasta
  ahora no se veía hasta que un cliente recibía la respuesta inventada.
- Esqueleto de carga: la pantalla hace 3 llamadas y quedaba en blanco el
  tiempo suficiente como para parecer rota.

Arregla además el fondo en modo oscuro: index.html traía
<body class="bg-gray-50">, y esa utilidad le ganaba a la regla de body de
la capa base, así que el área principal quedaba clara con el tema oscuro
puesto (medido: --fondo resolvía a 9 13 22 pero el body pintaba 249 250 251).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-14 22:09:32 -05:00
co-authored by Claude Sonnet 5
parent 45f773fb62
commit 5182c45d11
9 changed files with 193 additions and 54 deletions
+60
View File
@@ -1,6 +1,8 @@
package models
import (
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
@@ -82,3 +84,61 @@ func GetUmindAgentePorSiteKey(siteKey string) (*UmindAgente, error) {
}
return &a, nil
}
// ResumenAgente son las señales que hacen falta para saber de un vistazo si
// un agente está listo o le falta algo, sin entrar a configurarlo.
type ResumenAgente struct {
AgenteID uint `json:"agente_id"`
Documentos int64 `json:"documentos"`
Canales int64 `json:"canales"`
Conversaciones7d int64 `json:"conversaciones_7d"`
}
// GetResumenAgentes agrega las tres señales en tres consultas agrupadas, no
// en tres por agente: con 5 agentes la diferencia no se nota, pero el patrón
// N+1 en una pantalla de listado es el que después no se puede sacar.
func GetResumenAgentes(agenteIDs []uint) map[uint]*ResumenAgente {
out := make(map[uint]*ResumenAgente, len(agenteIDs))
for _, id := range agenteIDs {
out[id] = &ResumenAgente{AgenteID: id}
}
if len(agenteIDs) == 0 {
return out
}
db := app.Http.Database.DB
type fila struct {
AgenteID uint
Total int64
}
var docs []fila
db.Model(&UmindDocumento{}).Select("agente_id, COUNT(*) AS total").
Where("agente_id IN ? AND deleted_at IS NULL", agenteIDs).Group("agente_id").Scan(&docs)
for _, f := range docs {
if r, ok := out[f.AgenteID]; ok {
r.Documentos = f.Total
}
}
var canales []fila
db.Model(&UmindCanal{}).Select("agente_id, COUNT(*) AS total").
Where("agente_id IN ? AND activo = ? AND deleted_at IS NULL", agenteIDs, true).Group("agente_id").Scan(&canales)
for _, f := range canales {
if r, ok := out[f.AgenteID]; ok {
r.Canales = f.Total
}
}
// Conversaciones, no mensajes: lo que importa es a cuánta gente atendió.
var convs []fila
db.Model(&UmindMensaje{}).Select("agente_id, COUNT(DISTINCT session_id) AS total").
Where("agente_id IN ? AND created_at >= ? AND deleted_at IS NULL", agenteIDs, time.Now().AddDate(0, 0, -7)).
Group("agente_id").Scan(&convs)
for _, f := range convs {
if r, ok := out[f.AgenteID]; ok {
r.Conversaciones7d = f.Total
}
}
return out
}