Files
soft_usite/orchestrator/src/components/Sidebar.vue
T
Lizandro GuarnizoandClaude Sonnet 5 115f1675ad fix(studio): los campos Cliente y Plan no aparecían al crear un tenant
/app/api/clientes/select devuelve el array pelado y /app/umind-planes/list
lo envuelve en {items}. El código leía solo .registros/.items, así que con
el array quedaba vacío — y el bloque estaba detrás de v-if="clientes.length",
o sea que Cliente y Plan simplemente no se veían y no había forma de
asignarlos.

- lista() acepta las dos formas (verificado contra ambas y contra
  vacío/null/undefined).
- Se cargan con allSettled: que falle uno ya no vacía el otro.
- El bloque se muestra siempre para el staff, y cada select dice a dónde ir
  si su lista está vacía. Antes un fallo de red se veía exactamente igual
  que "no hay nada configurado", que es lo que enmascaró este bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:49:32 -05:00

261 lines
8.6 KiB
Vue

<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.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)
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>
<aside class="w-64 shrink-0 h-screen sticky top-0 flex flex-col border-r border-borde bg-superficie">
<div class="h-14 px-4 flex items-center border-b border-borde">
<router-link to="/" class="text-base font-semibold text-texto">
uMind <span class="text-brand">Studio</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>