feat(studio): rediseño con design tokens, pantalla de consumo y rename a Studio

El SPA pasa a llamarse uMind Studio y vive en /studio (el nombre
"Orquestador" no le decía nada al cliente final, que ahora es quien lo usa).

Diseño:
- Los colores viven una sola vez como variables CSS + clases semánticas
  (.card, .input, .btn-*, .badge-*). Antes cada elemento repetía el par
  claro/oscuro a mano en cientos de lugares y cambiar un tono era buscar
  y reemplazar.
- darkMode pasa de 'media' a 'class' con toggle propio persistido: el
  usuario elige, no el sistema operativo. Se aplica antes de montar la
  app para que no parpadee.
- Componentes compartidos (UiModal, UiBadge, UiEmptyState) donde antes
  había markup duplicado inline.

Nueva pantalla de consumo (/tenants/:id/uso): filtros por fecha con
atajos, total del período, pendiente de facturar, barra contra el tope
del plan, desglose por tipo y gráfico por día. El gráfico son divs con
altura porcentual — no vale traer una librería de charts para esto.

Rename:
- /studio y /portal/studio; /orchestrator redirige 301 conservando la
  ruta interna, así los enlaces guardados siguen funcionando. Los assets
  del bundle quedan exentos (el `base` de Vite sigue en /orchestrator/):
  redirigirlos rompería el SPA.
- El submódulo sembrado migra el mismo registro buscando por las tres
  URLs históricas (/app/umind → /orchestrator → /studio) en vez de
  dejar entradas duplicadas en el menú.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-13 12:02:54 -05:00
co-authored by Claude Sonnet 5
parent 023a8af494
commit d8f9acd1f8
26 changed files with 685 additions and 224 deletions
+28 -28
View File
@@ -111,16 +111,16 @@ onMounted(() => {
</script>
<template>
<aside class="w-64 shrink-0 h-screen sticky top-0 flex flex-col border-r border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
<div class="px-4 py-4 border-b border-gray-200 dark:border-gray-800">
<router-link to="/" class="text-base font-semibold text-gray-800 dark:text-gray-100">
<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="w-full bg-brand hover:bg-brand-dark text-white text-sm font-medium px-3 py-2 rounded-lg transition-colors"
class="btn-primary w-full"
@click="nuevoTenant"
>
+ Nuevo tenant
@@ -130,37 +130,37 @@ onMounted(() => {
<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-gray-400">Cargando...</p>
<p v-else-if="tenants.length === 0" class="px-2 text-xs text-gray-400">
<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 dark:bg-brand/20' : 'hover:bg-gray-100 dark:hover:bg-gray-800'"
: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-dark dark:text-brand font-medium' : 'text-gray-700 dark:text-gray-300'"
: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-gray-300 dark:bg-gray-600'"></span>
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ t.activo ? 'activo' : 'inactivo' }}</span>
<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-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
class="p-1 text-tenue hover:text-texto"
title="Editar"
@click="editarTenant(t)"
>
</button>
<button
class="p-1 text-gray-400 hover:text-red-600"
class="p-1 text-tenue hover:text-red-600"
title="Eliminar"
@click="eliminar(t)"
>
@@ -177,66 +177,66 @@ onMounted(() => {
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
@click.self="showForm = false"
>
<div class="bg-white dark:bg-gray-900 rounded-xl p-6 w-full max-w-lg border border-gray-200 dark:border-gray-800">
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">
<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-gray-500 dark:text-gray-400 mb-3">
<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="text-xs text-gray-500 dark:text-gray-400">Nombre</label>
<label class="label">Nombre</label>
<input
v-model="form.nombre"
required
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"
class="input"
/>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Dominios permitidos (separados por coma)</label>
<label class="label">Dominios permitidos (separados por coma)</label>
<input
v-model="form.dominios_permitidos"
placeholder="ejemplo.com, www.ejemplo.com"
required
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"
class="input"
/>
</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>
<label class="label">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"
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-gray-400 mt-1">Define quién ve este tenant desde el portal.</p>
<p class="text-[11px] text-tenue 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>
<label class="label">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"
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-gray-400 mt-1">Límite de agentes y precios de consumo.</p>
<p class="text-[11px] text-tenue 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">
<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="px-4 py-2 text-sm text-gray-500 dark:text-gray-400" @click="showForm = false">
<button type="button" class="btn-ghost" @click="showForm = false">
Cancelar
</button>
<button type="submit" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg">
<button type="submit" class="btn-primary">
Guardar
</button>
</div>
@@ -0,0 +1,16 @@
<script setup>
// tipo: ok | alerta | error | neutro
defineProps({ tipo: { type: String, default: 'neutro' }, punto: Boolean })
const clases = { ok: 'badge-ok', alerta: 'badge-alerta', error: 'badge-error', neutro: 'badge-neutro' }
</script>
<template>
<span :class="clases[tipo] || 'badge-neutro'">
<span
v-if="punto"
class="w-1.5 h-1.5 rounded-full"
:class="{ ok: 'bg-green-500', alerta: 'bg-amber-500', error: 'bg-red-500', neutro: 'bg-gray-400' }[tipo]"
></span>
<slot />
</span>
</template>
@@ -0,0 +1,12 @@
<script setup>
defineProps({ icono: { type: String, default: '' }, titulo: String, detalle: String })
</script>
<template>
<div class="flex flex-col items-center justify-center text-center py-12 px-6">
<div v-if="icono" class="text-3xl mb-3 opacity-70">{{ icono }}</div>
<p class="text-sm font-medium text-texto">{{ titulo }}</p>
<p v-if="detalle" class="text-xs text-tenue mt-1 max-w-sm">{{ detalle }}</p>
<div class="mt-4"><slot /></div>
</div>
</template>
@@ -0,0 +1,20 @@
<script setup>
defineProps({ titulo: { type: String, default: '' }, ancho: { type: String, default: 'max-w-lg' } })
const emit = defineEmits(['cerrar'])
</script>
<template>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
@click.self="emit('cerrar')"
@keydown.esc="emit('cerrar')"
>
<div :class="['card w-full p-6 max-h-[90vh] overflow-y-auto animate-escalar shadow-2xl', ancho]">
<div v-if="titulo" class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-texto">{{ titulo }}</h2>
<button class="btn-ghost !px-2 !py-1" type="button" @click="emit('cerrar')"></button>
</div>
<slot />
</div>
</div>
</template>