feat(umind): vincula tenants a clientes y agrega planes con precios

Base para que el cliente administre uMind desde su portal y para el cobro
por consumo. Todavía no cambia nada del comportamiento actual.

- UmindTenant gana ClienteID (punteros, sin not null: los tenants creados
  antes del portal quedan sin asignar y el ALTER TABLE no falla sobre
  datos existentes) y PlanID.
- GetUmindTenantsByClientes es fail-closed: sin clientes, no ve nada.
- UmindPlan define el máximo de agentes y los precios por 1k tokens, por
  imagen OCR, por transcripción y la mensualidad, más un tope de consumo
  que solo avisa. CRUD para staff en /app/umind-planes.
- El modelo va en AMBAS listas de AutoMigrate (main.go y migrations),
  no repetir el error de agregarlo solo en una.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-13 11:32:38 -05:00
co-authored by Claude Sonnet 5
parent 84506a98e2
commit c69b904b03
14 changed files with 493 additions and 33 deletions
+51 -2
View File
@@ -7,6 +7,8 @@ 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)
@@ -18,7 +20,7 @@ const form = ref(vacio())
const tenantActivoId = computed(() => route.params.tenantId || route.params.id)
function vacio() {
return { nombre: '', dominios_permitidos: '', activo: true }
return { nombre: '', dominios_permitidos: '', activo: true, cliente_id: null, plan_id: null }
}
async function cargar() {
@@ -34,6 +36,22 @@ async function cargar() {
}
}
// Clientes y planes solo los necesita el staff para asignarlos; si el endpoint
// no está disponible (portal del cliente) el formulario sigue funcionando.
async function cargarAsignables() {
try {
const [c, p] = await Promise.all([
api.get('/app/api/clientes/select'),
api.get('/app/umind-planes/list'),
])
clientes.value = c.registros || c.items || []
planes.value = p.items || []
} catch {
clientes.value = []
planes.value = []
}
}
function nuevoTenant() {
editing.value = null
form.value = vacio()
@@ -46,6 +64,8 @@ function editarTenant(t) {
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
}
@@ -82,7 +102,10 @@ async function eliminar(t) {
}
defineExpose({ recargar: cargar })
onMounted(cargar)
onMounted(() => {
cargar()
cargarAsignables()
})
</script>
<template>
@@ -175,6 +198,32 @@ onMounted(cargar)
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div v-if="clientes.length" class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Cliente</label>
<select
v-model="form.cliente_id"
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
>
<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-gray-400 mt-1">Define quién ve este tenant desde el portal.</p>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Plan</label>
<select
v-model="form.plan_id"
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
>
<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-gray-400 mt-1">Límite de agentes y precios de consumo.</p>
</div>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<input v-model="form.activo" type="checkbox" />
Activo