feat: uMind pasa a multi-agente por tenant

Un tenant (negocio/sitio, dueño de los dominios permitidos) puede tener
varios UmindAgente independientes (ej. "Ventas", "Soporte"), cada uno con
su propia config de IA, tono, base de conocimiento, tools, canales y
conexión de correo. El site_key también pasa a ser por agente, así cada
uno tiene su propio <script> de widget embebible y su propio color.

Backend:
- Nuevo modelo UmindAgente (pkg/models/umind_agente.go), con SiteKey,
  AiConfigID, Tono, MensajeBienvenida y Color — campos que antes vivían en
  UmindTenant y se sacan de ahí (las columnas viejas quedan huérfanas sin
  usar, no se hace DROP COLUMN).
- UmindDocumento, UmindChunk, UmindHerramienta, UmindCanal, UmindConexion y
  UmindMensaje pasan de TenantID a AgenteID. El campo se agrega sin
  "not null" para no romper el ALTER TABLE en Postgres sobre tablas que ya
  tienen filas (ej. emetropolitana).
- migrations.MigrarUmindAgentes(): idempotente, crea un agente "Principal"
  por cada tenant existente heredando lo que ya tenía configurado, y mueve
  sus datos de tenant_id a agente_id. Corre en cada arranque normal, mismo
  criterio que los Seed* — nada se rompe para los tenants ya en producción.
- Motor del agente, widget, canales (Telegram/WhatsApp) y OAuth de correo
  ahora operan sobre UmindAgente; el tenant solo se consulta para el
  chequeo de dominio permitido y el nombre del negocio que ve el visitante.

Frontend: nueva jerarquía de navegación tenant → lista de agentes
(TenantAgentes.vue) → detalle de un agente (AgenteDetail.vue, antes
TenantDetail.vue) con las mismas 6 tabs de siempre, ahora por agente. El
modal de tenant en el sidebar se achica a nombre/dominios/activo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-13 09:30:06 -05:00
co-authored by Claude Sonnet 5
parent 8eba3ab97f
commit f3f2f421d6
27 changed files with 760 additions and 368 deletions
+14 -59
View File
@@ -1,5 +1,5 @@
<script setup>
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api.js'
@@ -7,35 +7,26 @@ const route = useRoute()
const router = useRouter()
const tenants = ref([])
const aiConfigs = 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: '',
ai_config_id: null,
tono: '',
mensaje_bienvenida: '',
color: '#8eb02f',
activo: true,
}
return { nombre: '', dominios_permitidos: '', activo: true }
}
async function cargar() {
loading.value = true
error.value = ''
try {
const [t, ai] = await Promise.all([
api.get('/app/umind/tenants'),
api.get('/app/api/ai-config/select'),
])
const t = await api.get('/app/umind/tenants')
tenants.value = t.items || []
aiConfigs.value = ai.registros || []
} catch (e) {
error.value = e.message
} finally {
@@ -54,10 +45,6 @@ function editarTenant(t) {
form.value = {
nombre: t.nombre,
dominios_permitidos: t.dominios_permitidos,
ai_config_id: t.ai_config_id,
tono: t.tono,
mensaje_bienvenida: t.mensaje_bienvenida,
color: t.color || '#8eb02f',
activo: t.activo,
}
showForm.value = true
@@ -88,9 +75,9 @@ async function guardar() {
}
async function eliminar(t) {
if (!confirm(`¿Eliminar el tenant "${t.nombre}"? Esto no se puede deshacer.`)) return
if (!confirm(`¿Eliminar el tenant "${t.nombre}"? Esto borra también sus agentes. No se puede deshacer.`)) return
await api.del(`/app/umind/tenants/${t.ID}`)
if (route.params.id === String(t.ID)) router.push('/')
if (tenantActivoId.value === String(t.ID)) router.push('/')
await cargar()
}
@@ -124,12 +111,12 @@ onMounted(cargar)
v-for="t in tenants"
:key="t.ID"
class="group flex items-center rounded-lg transition-colors"
:class="route.params.id === 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 dark:bg-brand/20' : 'hover:bg-gray-100 dark:hover:bg-gray-800'"
>
<router-link
:to="`/tenants/${t.ID}`"
class="flex-1 min-w-0 px-2.5 py-2 text-sm"
:class="route.params.id === 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-dark dark:text-brand font-medium' : 'text-gray-700 dark:text-gray-300'"
>
<div class="truncate">{{ t.nombre }}</div>
<div class="flex items-center gap-1 mt-0.5">
@@ -167,6 +154,9 @@ onMounted(cargar)
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">
{{ editing ? 'Editar tenant' : 'Nuevo tenant' }}
</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 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>
@@ -185,41 +175,6 @@ 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>
<label class="text-xs text-gray-500 dark:text-gray-400">Config de IA</label>
<select
v-model="form.ai_config_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 aiConfigs" :key="c.ID" :value="c.ID">
{{ c.nombre }} ({{ c.provider }})
</option>
</select>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Tono / personalidad</label>
<textarea
v-model="form.tono"
rows="2"
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"
></textarea>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Mensaje de bienvenida</label>
<input
v-model="form.mensaje_bienvenida"
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>
<label class="text-xs text-gray-500 dark:text-gray-400">Color del widget</label>
<div class="flex items-center gap-2">
<input v-model="form.color" type="color" class="w-10 h-9 border border-gray-300 dark:border-gray-700 rounded cursor-pointer bg-white dark:bg-gray-800" />
<input v-model="form.color" type="text" pattern="#[0-9a-fA-F]{6}" class="flex-1 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 font-mono" />
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-500 mt-1">Se aplica al widget embebido automáticamente, no hace falta recopiar el código.</p>
</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
+9 -2
View File
@@ -1,12 +1,19 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from './views/Home.vue'
import TenantDetail from './views/TenantDetail.vue'
import TenantAgentes from './views/TenantAgentes.vue'
import AgenteDetail from './views/AgenteDetail.vue'
const router = createRouter({
history: createWebHistory('/orchestrator/'),
routes: [
{ path: '/', name: 'home', component: Home },
{ path: '/tenants/:id', name: 'tenant-detail', component: TenantDetail, props: true },
{ path: '/tenants/:id', name: 'tenant-agentes', component: TenantAgentes, props: true },
{
path: '/tenants/:tenantId/agentes/:agenteId',
name: 'agente-detail',
component: AgenteDetail,
props: true,
},
],
})
@@ -3,11 +3,14 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../lib/api.js'
const props = defineProps({ id: { type: String, required: true } })
const tenantId = computed(() => Number(props.id))
const props = defineProps({
tenantId: { type: String, required: true },
agenteId: { type: String, required: true },
})
const agenteIdNum = computed(() => Number(props.agenteId))
const route = useRoute()
const tenant = ref(null)
const agente = ref(null)
const error = ref('')
const tab = ref(typeof route.query.tab === 'string' ? route.query.tab : 'conocimiento')
@@ -17,13 +20,13 @@ const nuevaUrl = ref('')
const maxPaginas = ref(30)
const ingestando = ref(false)
async function cargarTenant() {
const t = await api.get('/app/umind/tenants')
tenant.value = (t.items || []).find((x) => String(x.ID) === props.id) || null
async function cargarAgente() {
const r = await api.get(`/app/umind/agentes?tenant_id=${props.tenantId}`)
agente.value = (r.items || []).find((x) => String(x.ID) === props.agenteId) || null
}
async function cargarDocumentos() {
const r = await api.get(`/app/umind/documentos?tenant_id=${props.id}`)
const r = await api.get(`/app/umind/documentos?agente_id=${props.agenteId}`)
documentos.value = r.items || []
}
@@ -33,7 +36,7 @@ async function agregarFuente() {
error.value = ''
try {
await api.post('/app/umind/documentos', {
tenant_id: tenantId.value,
agente_id: agenteIdNum.value,
url: nuevaUrl.value.trim(),
max_paginas: Number(maxPaginas.value) || 30,
})
@@ -65,13 +68,13 @@ const historial = ref([])
const sesionActiva = ref(null)
async function cargarSesiones() {
const r = await api.get(`/app/umind/sesiones?tenant_id=${props.id}`)
const r = await api.get(`/app/umind/sesiones?agente_id=${props.agenteId}`)
sesiones.value = r.items || []
}
async function verHistorial(sessionId) {
sesionActiva.value = sessionId
const r = await api.get(`/app/umind/historial?tenant_id=${props.id}&session_id=${sessionId}`)
const r = await api.get(`/app/umind/historial?agente_id=${props.agenteId}&session_id=${sessionId}`)
historial.value = r.items || []
}
@@ -89,7 +92,7 @@ function toolVacio() {
}
async function cargarTools() {
const r = await api.get(`/app/umind/tools?tenant_id=${props.id}`)
const r = await api.get(`/app/umind/tools?agente_id=${props.agenteId}`)
tools.value = r.items || []
}
@@ -125,7 +128,7 @@ function quitarParametro(i) {
async function guardarTool() {
const payload = {
tenant_id: tenantId.value,
agente_id: agenteIdNum.value,
nombre: toolForm.value.nombre.trim(),
descripcion: toolForm.value.descripcion,
url: toolForm.value.url.trim(),
@@ -162,7 +165,7 @@ const canalForm = ref(canalVacio())
const widgetCopiado = ref(false)
const widgetSnippet = computed(() => {
const siteKey = tenant.value?.site_key || 'TU_SITE_KEY'
const siteKey = agente.value?.site_key || 'TU_SITE_KEY'
return `<script src="${window.location.origin}/widget/umind.js" data-site="${siteKey}" defer><\/script>`
})
@@ -183,7 +186,7 @@ function canalVacio() {
}
async function cargarCanales() {
const r = await api.get(`/app/umind/canales?tenant_id=${props.id}`)
const r = await api.get(`/app/umind/canales?agente_id=${props.agenteId}`)
canales.value = r.items || []
}
@@ -203,7 +206,7 @@ async function guardarCanal() {
verify_token: canalForm.value.verify_token,
}
try {
await api.post('/app/umind/canales', { tenant_id: tenantId.value, tipo: canalForm.value.tipo, credenciales, activo: true })
await api.post('/app/umind/canales', { agente_id: agenteIdNum.value, tipo: canalForm.value.tipo, credenciales, activo: true })
showCanalForm.value = false
await cargarCanales()
} catch (e) {
@@ -226,13 +229,13 @@ async function eliminarCanal(c) {
const conexiones = ref([])
async function cargarConexiones() {
const r = await api.get(`/app/umind/conexiones?tenant_id=${props.id}`)
const r = await api.get(`/app/umind/conexiones?agente_id=${props.agenteId}`)
conexiones.value = r.items || []
}
function conectar(proveedor) {
// Navegación normal (no fetch): el backend redirige a Google/Microsoft.
window.location.href = `/app/umind/conexiones/conectar?tenant_id=${tenantId.value}&proveedor=${proveedor}`
window.location.href = `/app/umind/conexiones/conectar?agente_id=${agenteIdNum.value}&proveedor=${proveedor}`
}
async function desconectar(c) {
@@ -254,7 +257,7 @@ async function enviarChatPrueba() {
chatMensajes.value.push({ role: 'user', content: texto })
chatEnviando.value = true
try {
const r = await api.post('/app/umind/chat', { tenant_id: tenantId.value, session_id: chatSessionId, mensaje: texto })
const r = await api.post('/app/umind/chat', { agente_id: agenteIdNum.value, session_id: chatSessionId, mensaje: texto })
chatMensajes.value.push({ role: 'assistant', content: r.respuesta })
} catch (e) {
chatMensajes.value.push({ role: 'assistant', content: `⚠️ ${e.message}` })
@@ -274,7 +277,7 @@ const tabs = [
onMounted(async () => {
try {
await Promise.all([cargarTenant(), cargarDocumentos(), cargarSesiones(), cargarTools(), cargarCanales(), cargarConexiones()])
await Promise.all([cargarAgente(), cargarDocumentos(), cargarSesiones(), cargarTools(), cargarCanales(), cargarConexiones()])
} catch (e) {
error.value = e.message
}
@@ -283,10 +286,12 @@ onMounted(async () => {
<template>
<div>
<div v-if="tenant" class="mb-6">
<h1 class="text-xl font-semibold text-gray-800 dark:text-gray-100">{{ tenant.nombre }}</h1>
<router-link :to="`/tenants/${tenantId}`" class="text-xs text-gray-500 dark:text-gray-400 hover:text-brand">&larr; Agentes</router-link>
<div v-if="agente" class="mb-6 mt-1">
<h1 class="text-xl font-semibold text-gray-800 dark:text-gray-100">{{ agente.nombre }}</h1>
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">
site_key: <code class="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">{{ tenant.site_key }}</code>
site_key: <code class="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">{{ agente.site_key }}</code>
</p>
</div>
@@ -334,7 +339,7 @@ onMounted(async () => {
<!-- Herramientas -->
<div v-else-if="tab === 'herramientas'">
<div class="flex justify-between items-center mb-4">
<p class="text-xs text-gray-500 dark:text-gray-400">Máximo 10 tools activas por tenant.</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Máximo 10 tools activas por agente.</p>
<button class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors" @click="nuevaTool">
+ Nueva tool
</button>
@@ -527,7 +532,7 @@ onMounted(async () => {
<!-- Conexiones (correo, OAuth) -->
<div v-else-if="tab === 'conexiones'">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
Conectá una cuenta de correo para que el agente pueda enviar y leer correo en nombre del negocio.
Conectá una cuenta de correo para que este agente pueda enviar y leer correo en su nombre.
Se soporta una cuenta activa a la vez.
</p>
<div class="flex gap-2 mb-4">
@@ -557,7 +562,7 @@ onMounted(async () => {
<div v-else-if="tab === 'chat'" class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 flex flex-col h-[28rem]">
<div class="flex-1 overflow-y-auto space-y-2 mb-3">
<p v-if="chatMensajes.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
Probá el agente tal cual lo va a ver un visitante usa la misma config de IA y las mismas tools/base de conocimiento del tenant.
Probá este agente tal cual lo va a ver un visitante usa la misma config de IA y las mismas tools/base de conocimiento.
</p>
<div
v-for="(m, i) in chatMensajes"
+168
View File
@@ -0,0 +1,168 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../lib/api.js'
const props = defineProps({ id: { type: String, required: true } })
const tenantId = computed(() => Number(props.id))
const router = useRouter()
const tenant = ref(null)
const agentes = ref([])
const aiConfigs = ref([])
const error = ref('')
const showForm = ref(false)
const editing = ref(null)
const form = ref(vacio())
function vacio() {
return { nombre: '', ai_config_id: null, tono: '', mensaje_bienvenida: '', color: '#8eb02f', activo: true }
}
async function cargar() {
error.value = ''
try {
const [t, a, ai] = await Promise.all([
api.get('/app/umind/tenants'),
api.get(`/app/umind/agentes?tenant_id=${props.id}`),
api.get('/app/api/ai-config/select'),
])
tenant.value = (t.items || []).find((x) => String(x.ID) === props.id) || null
agentes.value = a.items || []
aiConfigs.value = ai.registros || []
} catch (e) {
error.value = e.message
}
}
function nuevoAgente() {
editing.value = null
form.value = vacio()
showForm.value = true
}
function editarAgente(a) {
editing.value = a
form.value = {
nombre: a.nombre,
ai_config_id: a.ai_config_id,
tono: a.tono,
mensaje_bienvenida: a.mensaje_bienvenida,
color: a.color || '#8eb02f',
activo: a.activo,
}
showForm.value = true
}
async function guardar() {
try {
if (editing.value) {
await api.put(`/app/umind/agentes/${editing.value.ID}`, { tenant_id: tenantId.value, ...form.value })
showForm.value = false
await cargar()
} else {
const r = await api.post('/app/umind/agentes', { tenant_id: tenantId.value, ...form.value })
showForm.value = false
router.push(`/tenants/${tenantId.value}/agentes/${r.id}`)
}
} catch (e) {
error.value = e.message
}
}
async function eliminarAgente(a) {
if (!confirm(`¿Eliminar el agente "${a.nombre}"? Esto no se puede deshacer.`)) return
await api.del(`/app/umind/agentes/${a.ID}`)
await cargar()
}
onMounted(cargar)
</script>
<template>
<div>
<div v-if="tenant" class="mb-6">
<h1 class="text-xl font-semibold text-gray-800 dark:text-gray-100">{{ tenant.nombre }}</h1>
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">{{ tenant.dominios_permitidos || 'sin dominios configurados' }}</p>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-medium text-gray-600 dark:text-gray-300">Agentes</h2>
<button class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors" @click="nuevoAgente">
+ Nuevo agente
</button>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
<div v-if="agentes.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">
Todavía no hay agentes. Creá el primero.
</div>
<div
v-for="a in agentes"
:key="a.ID"
class="group flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-800"
>
<router-link :to="`/tenants/${tenantId}/agentes/${a.ID}`" class="flex-1 min-w-0 p-4">
<div class="font-medium text-gray-800 dark:text-gray-200">{{ a.nombre }}</div>
<div class="flex items-center gap-1 mt-0.5">
<span class="w-1.5 h-1.5 rounded-full" :style="{ background: a.activo ? a.color || '#22c55e' : undefined }" :class="!a.activo && 'bg-gray-300 dark:bg-gray-600'"></span>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ a.activo ? 'activo' : 'inactivo' }}</span>
</div>
</router-link>
<div class="flex items-center gap-3 pr-4 text-sm">
<router-link :to="`/tenants/${tenantId}/agentes/${a.ID}`" class="text-brand font-medium opacity-0 group-hover:opacity-100 transition-opacity">
Configurar &rarr;
</router-link>
<button class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 opacity-0 group-hover:opacity-100 transition-opacity" title="Editar" @click="editarAgente(a)"></button>
<button class="text-gray-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity" title="Eliminar" @click="eliminarAgente(a)"></button>
</div>
</div>
</div>
<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="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 w-full max-w-lg">
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">{{ editing ? 'Editar agente' : 'Nuevo agente' }}</h2>
<form class="space-y-3" @submit.prevent="guardar">
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Nombre</label>
<input v-model="form.nombre" required placeholder="ej: Ventas, Soporte" 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>
<label class="text-xs text-gray-500 dark:text-gray-400">Config de IA</label>
<select v-model="form.ai_config_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 aiConfigs" :key="c.ID" :value="c.ID">{{ c.nombre }} ({{ c.provider }})</option>
</select>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Tono / personalidad</label>
<textarea v-model="form.tono" rows="2" 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"></textarea>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Mensaje de bienvenida</label>
<input v-model="form.mensaje_bienvenida" 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>
<label class="text-xs text-gray-500 dark:text-gray-400">Color del widget</label>
<div class="flex items-center gap-2">
<input v-model="form.color" type="color" class="w-10 h-9 border border-gray-300 dark:border-gray-700 rounded cursor-pointer bg-white dark:bg-gray-800" />
<input v-model="form.color" type="text" pattern="#[0-9a-fA-F]{6}" class="flex-1 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 font-mono" />
</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
</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">Cancelar</button>
<button type="submit" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg">
{{ editing ? 'Guardar' : 'Crear' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>