Logo. Hasta ahora no había: era el texto "uM" en un cuadrado, con tamaño y esquinas distintas en cada pantalla. Ahora hay un mark — la "u" del nombre con el punto de pulso integrado — probado a 16px, 20px y 32px sobre claro y oscuro, que es donde se cae la mayoría de los logos. Reemplaza el texto en el portal, el header, el sidebar del Studio y la promo, y va también de favicon. Plantillas por rubro. Un agente recién creado no sabía nada, y escribir el conocimiento desde cero frente a un campo en blanco es donde la mayoría abandona. Al crear uno se puede elegir un punto de partida —tienda, restaurante, consultorio, servicios, peluquería o la base genérica— y arranca con sus notas ya escritas, listas para editar. Los valores de ejemplo van entre corchetes a propósito, para que se vea que hay que reemplazarlos: una nota que parezca dato real termina en boca del asistente. Un test lo verifica nota por nota. Y la plantilla de salud abre con el límite — el asistente no da consejo médico ni interpreta síntomas — porque es el único rubro donde una respuesta inventada hace daño de verdad. El catálogo vive en código, no en base: es contenido editorial que mejoramos nosotros, no configuración que cada instalación toca por su lado. Las notas se cargan en segundo plano porque cada una necesita sus embeddings, y hacer esperar el alta sería castigar justo al que eligió plantilla. Con las opciones nuevas el formulario pasaba de largo la pantalla y el botón de guardar quedaba abajo del borde: el modal ahora tiene su propio scroll. Y la página promocional de uMind queda servida en /umind.html. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
285 lines
9.8 KiB
Vue
285 lines
9.8 KiB
Vue
<script setup>
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { api } from '../lib/api.js'
|
|
import { apiUmind, contexto } from '../lib/contexto.js'
|
|
import { menuAbierto } from '../lib/ui.js'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const tenants = ref([])
|
|
const clientes = ref([])
|
|
const planes = ref([])
|
|
const loading = ref(true)
|
|
const error = ref('')
|
|
const showForm = ref(false)
|
|
const editing = ref(null)
|
|
const form = ref(vacio())
|
|
|
|
// El tenant "activo" en la nav es tanto /tenants/:id como cualquier ruta
|
|
// anidada de sus agentes (/tenants/:tenantId/agentes/:agenteId).
|
|
const tenantActivoId = computed(() => route.params.tenantId || route.params.id)
|
|
|
|
// Al elegir un tenant en móvil el menú se cierra solo: dejarlo abierto tapa
|
|
// justo la pantalla a la que se acaba de entrar.
|
|
watch(() => route.fullPath, () => { menuAbierto.value = false })
|
|
|
|
function vacio() {
|
|
return { nombre: '', dominios_permitidos: '', activo: true, cliente_id: null, plan_id: null }
|
|
}
|
|
|
|
async function cargar() {
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
const t = await api.get(apiUmind('/umind/tenants'))
|
|
tenants.value = t.items || []
|
|
} catch (e) {
|
|
error.value = e.message
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// Los endpoints del panel no comparten una forma de respuesta: el de clientes
|
|
// devuelve el array pelado y el de planes lo envuelve en {items}. Aceptar las
|
|
// dos evita que un select quede vacío sin que nadie se entere.
|
|
function lista(r) {
|
|
if (Array.isArray(r)) return r
|
|
return r?.items || r?.registros || []
|
|
}
|
|
|
|
// Clientes y planes solo los necesita el staff para asignarlos; en el portal
|
|
// del cliente esos endpoints no se alcanzan y el formulario no los muestra.
|
|
// Se cargan por separado a propósito: que falle uno no debe vaciar el otro.
|
|
async function cargarAsignables() {
|
|
if (contexto.esPortal) return
|
|
const [c, p] = await Promise.allSettled([
|
|
api.get('/app/api/clientes/select'),
|
|
api.get('/app/umind-planes/list'),
|
|
])
|
|
clientes.value = c.status === 'fulfilled' ? lista(c.value) : []
|
|
planes.value = p.status === 'fulfilled' ? lista(p.value) : []
|
|
// Sin esto un fallo se veía igual que "no hay nada configurado", que es
|
|
// justo lo que hizo que los campos Cliente y Plan no aparecieran.
|
|
const fallos = []
|
|
if (c.status === 'rejected') fallos.push('clientes')
|
|
if (p.status === 'rejected') fallos.push('planes')
|
|
if (fallos.length) error.value = `No se pudo cargar la lista de ${fallos.join(' ni ')}.`
|
|
}
|
|
|
|
function nuevoTenant() {
|
|
editing.value = null
|
|
form.value = vacio()
|
|
showForm.value = true
|
|
}
|
|
|
|
function editarTenant(t) {
|
|
editing.value = t
|
|
form.value = {
|
|
nombre: t.nombre,
|
|
dominios_permitidos: t.dominios_permitidos,
|
|
activo: t.activo,
|
|
cliente_id: t.cliente_id ?? null,
|
|
plan_id: t.plan_id ?? null,
|
|
}
|
|
showForm.value = true
|
|
}
|
|
|
|
async function guardar() {
|
|
const payload = {
|
|
...form.value,
|
|
dominios_permitidos: form.value.dominios_permitidos
|
|
.split(',')
|
|
.map((d) => d.trim())
|
|
.filter(Boolean),
|
|
}
|
|
try {
|
|
if (editing.value) {
|
|
await api.put(apiUmind(`/umind/tenants/${editing.value.ID}`), payload)
|
|
showForm.value = false
|
|
await cargar()
|
|
} else {
|
|
const r = await api.post(apiUmind('/umind/tenants'), payload)
|
|
showForm.value = false
|
|
await cargar()
|
|
router.push(`/tenants/${r.id}`)
|
|
}
|
|
} catch (e) {
|
|
error.value = e.message
|
|
}
|
|
}
|
|
|
|
async function eliminar(t) {
|
|
if (!confirm(`¿Eliminar el tenant "${t.nombre}"? Esto borra también sus agentes. No se puede deshacer.`)) return
|
|
await api.del(apiUmind(`/umind/tenants/${t.ID}`))
|
|
if (tenantActivoId.value === String(t.ID)) router.push('/')
|
|
await cargar()
|
|
}
|
|
|
|
defineExpose({ recargar: cargar })
|
|
onMounted(() => {
|
|
cargar()
|
|
cargarAsignables()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<!-- Fondo oscuro en móvil: sin esto el menú abierto se superpone al
|
|
contenido sin dejar claro que hay que cerrarlo. -->
|
|
<div
|
|
v-if="menuAbierto"
|
|
class="fixed inset-0 bg-black/50 z-30 md:hidden"
|
|
@click="menuAbierto = false"
|
|
></div>
|
|
|
|
<aside
|
|
class="w-64 shrink-0 flex flex-col border-r border-borde bg-superficie
|
|
fixed inset-y-0 left-0 z-40 transition-transform duration-200
|
|
md:static md:h-screen md:sticky md:top-0 md:translate-x-0"
|
|
:class="menuAbierto ? 'translate-x-0' : '-translate-x-full'"
|
|
>
|
|
<div class="h-14 px-4 flex items-center gap-2 border-b border-borde">
|
|
<router-link to="/" class="flex items-center gap-2 text-base font-semibold text-texto">
|
|
<svg viewBox="0 0 96 96" class="w-6 h-6 shrink-0" aria-hidden="true">
|
|
<rect width="96" height="96" rx="22" fill="#8eb02f" />
|
|
<path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" />
|
|
<path d="M60,58 V64" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" />
|
|
<circle cx="60" cy="28" r="7" fill="#fff" />
|
|
</svg>
|
|
<span>uMind <span class="text-brand">Studio</span></span>
|
|
</router-link>
|
|
</div>
|
|
|
|
<div v-if="!contexto.esPortal" class="px-3 pt-3">
|
|
<button
|
|
class="btn-primary w-full"
|
|
@click="nuevoTenant"
|
|
>
|
|
+ Nuevo tenant
|
|
</button>
|
|
</div>
|
|
|
|
<p v-if="error" class="px-3 pt-2 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
|
|
|
|
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5">
|
|
<p v-if="loading" class="px-2 text-xs text-tenue">Cargando...</p>
|
|
<p v-else-if="tenants.length === 0" class="px-2 text-xs text-tenue">
|
|
{{ contexto.esPortal ? 'Todavía no tenés ningún espacio asignado. Escribinos y lo activamos.' : 'Sin tenants todavía.' }}
|
|
</p>
|
|
<div
|
|
v-for="t in tenants"
|
|
:key="t.ID"
|
|
class="group flex items-center rounded-lg transition-colors"
|
|
:class="tenantActivoId === String(t.ID) ? 'bg-brand/10' : 'hover:bg-elevado'"
|
|
>
|
|
<router-link
|
|
:to="`/tenants/${t.ID}`"
|
|
class="flex-1 min-w-0 px-2.5 py-2 text-sm"
|
|
:class="tenantActivoId === String(t.ID) ? 'text-brand font-medium' : 'text-texto'"
|
|
>
|
|
<div class="truncate">{{ t.nombre }}</div>
|
|
<div class="flex items-center gap-1 mt-0.5">
|
|
<span class="w-1.5 h-1.5 rounded-full" :class="t.activo ? 'bg-green-500' : 'bg-tenue/40'"></span>
|
|
<span class="text-[11px] text-tenue">{{ t.activo ? 'activo' : 'inactivo' }}</span>
|
|
</div>
|
|
</router-link>
|
|
<div v-if="!contexto.esPortal" class="flex opacity-0 group-hover:opacity-100 transition-opacity pr-1.5 gap-0.5">
|
|
<button
|
|
class="p-1 text-tenue hover:text-texto"
|
|
title="Editar"
|
|
@click="editarTenant(t)"
|
|
>
|
|
✎
|
|
</button>
|
|
<button
|
|
class="p-1 text-tenue hover:text-red-600"
|
|
title="Eliminar"
|
|
@click="eliminar(t)"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
</aside>
|
|
|
|
<!-- Modal de alta/edición -->
|
|
<div
|
|
v-if="showForm"
|
|
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
|
|
@click.self="showForm = false"
|
|
>
|
|
<div class="card w-full max-w-lg p-6 animate-escalar shadow-2xl">
|
|
<h2 class="font-semibold text-texto mb-4">
|
|
{{ editing ? 'Editar tenant' : 'Nuevo tenant' }}
|
|
</h2>
|
|
<p class="text-xs text-tenue mb-3">
|
|
Un tenant es el negocio/sitio dueño de los dominios permitidos. La config de IA, tono y demás se configuran por agente, dentro del tenant.
|
|
</p>
|
|
<form class="space-y-3" @submit.prevent="guardar">
|
|
<div>
|
|
<label class="label">Nombre</label>
|
|
<input
|
|
v-model="form.nombre"
|
|
required
|
|
class="input"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="label">Dominios permitidos (separados por coma)</label>
|
|
<input
|
|
v-model="form.dominios_permitidos"
|
|
placeholder="ejemplo.com, www.ejemplo.com"
|
|
required
|
|
class="input"
|
|
/>
|
|
</div>
|
|
<div v-if="!contexto.esPortal" class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="label">Cliente</label>
|
|
<select
|
|
v-model="form.cliente_id"
|
|
class="input"
|
|
>
|
|
<option :value="null">— sin asignar —</option>
|
|
<option v-for="c in clientes" :key="c.ID" :value="c.ID">{{ c.nombre }}</option>
|
|
</select>
|
|
<p class="text-[11px] text-tenue mt-1">
|
|
{{ clientes.length ? 'Define quién ve este tenant desde el portal.' : 'No hay clientes activos — creá uno en Clientes.' }}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label class="label">Plan</label>
|
|
<select
|
|
v-model="form.plan_id"
|
|
class="input"
|
|
>
|
|
<option :value="null">— sin plan —</option>
|
|
<option v-for="p in planes" :key="p.ID" :value="p.ID">
|
|
{{ p.nombre }} ({{ p.max_agentes === 0 ? '∞' : p.max_agentes }} agentes)
|
|
</option>
|
|
</select>
|
|
<p class="text-[11px] text-tenue mt-1">
|
|
{{ planes.length ? 'Límite de agentes y precios de consumo.' : 'No hay planes — creá uno en uMind Planes.' }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<label class="flex items-center gap-2 text-sm text-texto">
|
|
<input v-model="form.activo" type="checkbox" />
|
|
Activo
|
|
</label>
|
|
<div class="flex justify-end gap-2 pt-2">
|
|
<button type="button" class="btn-ghost" @click="showForm = false">
|
|
Cancelar
|
|
</button>
|
|
<button type="submit" class="btn-primary">
|
|
Guardar
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</template>
|