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:
co-authored by
Claude Sonnet 5
parent
023a8af494
commit
d8f9acd1f8
@@ -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>
|
||||
Reference in New Issue
Block a user