feat(studio): "Lo que hace", la bandeja de pendientes y la barra del espacio

La estructura que faltaba para que todo lo nuevo se encuentre.

NIVEL ESPACIO — barra propia: Agentes · Pendientes · Archivos · Plantillas ·
Consumo · Tu IA. Antes Consumo y Tu IA eran dos enlaces sueltos en el header
que desaparecían al entrar. El badge de Pendientes solo aparece cuando hay algo
esperando: un cero permanente enseña a ignorar el lugar donde después va lo
importante.

NIVEL AGENTE — nueva zona "Lo que hace", al lado de "Lo que sabe" y "Dónde
atiende". Tres preguntas distintas: de dónde saca las respuestas, qué es capaz
de hacer, por dónde lo encuentran. Ahí viven los recordatorios y las
vigilancias — se crean hablando, pero se ven y se cancelan acá: un agente que
agenda cosas invisibles es un agente en el que no se confía.

Los recordatorios dicen por dónde van a llegar ("te llega por Telegram"), no el
sessionID crudo. Las vigilancias muestran si la condición se está cumpliendo
ahora mismo.

En los canales, el checkbox que define todo el modelo de permisos: "este canal
es mío, no de mis clientes". Explicado por lo que significa, no por cómo
funciona — marcarlo habilita acciones directas, no marcarlo hace que todo lo
que salga espere el visto bueno.

Archivos muestra la cuota consumida con barra, marca cuáles ya son
conocimiento, y ofrece "usar como conocimiento" en un clic. Plantillas permite
subir el contrato que el cliente ya usa y que la IA lo convierta.

api.postForm: el multipart ya se escribía a mano en dos lugares y este era el
tercero. Sin Content-Type fijado — el boundary lo pone el navegador.

Verificado con capturas de las cuatro pantallas nuevas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-24 21:48:41 -05:00
co-authored by Claude Opus 5
parent 7ddc3f5286
commit 706263db92
18 changed files with 738 additions and 36 deletions
+174
View File
@@ -0,0 +1,174 @@
<script setup>
import { ref, watch } from 'vue'
import { api } from '../lib/api.js'
import { apiUmind } from '../lib/contexto.js'
import NavEspacio from '../components/NavEspacio.vue'
import UiEmptyState from '../components/ui/UiEmptyState.vue'
const props = defineProps({ id: { type: String, required: true } })
const TIPOS = [
['cotizacion', 'Cotización'],
['contrato', 'Contrato'],
['acta', 'Acta'],
['cuenta_cobro', 'Cuenta de cobro'],
]
const items = ref([])
const cargando = ref(true)
const error = ref('')
const editando = ref(null)
const importando = ref(false)
const guardando = ref(false)
const form = ref({ tipo: 'cotizacion', nombre: '', contenido_html: '' })
async function cargar() {
cargando.value = true
error.value = ''
try {
const r = await api.get(apiUmind(`/umind/plantillas?tenant_id=${props.id}`))
items.value = r.items || []
} catch (e) {
error.value = e.message
} finally {
cargando.value = false
}
}
watch(() => props.id, cargar, { immediate: true })
function nueva() {
form.value = { tipo: 'cotizacion', nombre: '', contenido_html: '' }
editando.value = true
}
// El camino corto: subís el contrato que ya usás y la IA lo convierte en
// plantilla con las variables puestas. No se guarda: lo revisás antes.
async function importar(ev) {
const file = ev.target.files?.[0]
if (!file) return
importando.value = true
error.value = ''
const fd = new FormData()
fd.append('tenant_id', props.id)
fd.append('tipo', form.value.tipo)
fd.append('archivo', file)
try {
const r = await api.postForm(apiUmind('/umind/plantillas/importar'), fd)
form.value.contenido_html = r.contenido_html
} catch (e) {
error.value = e.message
} finally {
importando.value = false
ev.target.value = ''
}
}
async function guardar() {
guardando.value = true
error.value = ''
try {
await api.post(apiUmind('/umind/plantillas'), { tenant_id: Number(props.id), ...form.value })
editando.value = null
await cargar()
} catch (e) {
error.value = e.message
} finally {
guardando.value = false
}
}
async function eliminar(p) {
if (!confirm(`¿Eliminar la plantilla "${p.nombre}"?`)) return
try {
await api.del(apiUmind(`/umind/plantillas/${p.ID}`))
await cargar()
} catch (e) {
error.value = e.message
}
}
function etiqueta(tipo) {
return TIPOS.find(([t]) => t === tipo)?.[1] || tipo
}
</script>
<template>
<div>
<NavEspacio :tenant-id="id" />
<div class="flex items-start justify-between gap-4 mb-4">
<div>
<h1 class="text-xl font-semibold text-texto">Plantillas</h1>
<p class="label mt-1">
Tus cotizaciones y contratos con tu membrete. Tu asistente los usa cuando alguien
te pide un presupuesto. Si no cargás ninguna, usa una genérica.
</p>
</div>
<button class="btn-primary shrink-0" @click="nueva">+ Nueva plantilla</button>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
<form v-if="editando" class="card p-4 mb-4 space-y-3" @submit.prevent="guardar">
<div class="grid sm:grid-cols-2 gap-3">
<div>
<label class="label">Tipo</label>
<select v-model="form.tipo" class="input">
<option v-for="[t, l] in TIPOS" :key="t" :value="t">{{ l }}</option>
</select>
</div>
<div>
<label class="label">Nombre</label>
<input v-model="form.nombre" placeholder="Cotización 2026" class="input" />
</div>
</div>
<div class="rounded-lg border border-dashed border-borde p-3 text-center">
<label class="btn-ghost cursor-pointer inline-block">
{{ importando ? 'Leyéndolo…' : 'Subir el documento que ya usás' }}
<input type="file" class="hidden" accept=".docx,.pdf,.txt" :disabled="importando" @change="importar" />
</label>
<p class="text-xs text-tenue mt-2">
Subí tu contrato o cotización en Word o PDF y lo convertimos en plantilla, con los datos
variables ya marcados. Después lo revisás acá abajo.
</p>
</div>
<div>
<label class="label">Contenido (HTML)</label>
<textarea v-model="form.contenido_html" rows="12" required class="input font-mono text-xs"></textarea>
<!-- v-pre: sin esto Vue intenta interpretar las llaves del ejemplo
como interpolación suya y no compila. -->
<p v-pre class="text-xs text-tenue mt-1">
Los datos variables van entre llaves: <span class="font-mono">{{.Cliente}}</span>,
<span class="font-mono">{{.Fecha}}</span>, <span class="font-mono">{{.Total}}</span>,
y la tabla de ítems con <span class="font-mono">{{range .Items}}</span>.
</p>
</div>
<div class="flex justify-end gap-2">
<button type="button" class="btn-ghost" @click="editando = null">Cancelar</button>
<button type="submit" class="btn-primary" :disabled="guardando">
{{ guardando ? 'Guardando…' : 'Guardar' }}
</button>
</div>
</form>
<p v-if="cargando" class="label">Cargando</p>
<div v-else class="card divide-y divide-borde">
<UiEmptyState
v-if="items.length === 0"
titulo="Sin plantillas propias"
detalle="Tu asistente puede emitir documentos igual, con un formato genérico. Cargá el tuyo cuando quieras que salgan con tu membrete."
/>
<div v-for="p in items" :key="p.ID" class="p-4 flex items-center justify-between gap-4">
<div class="min-w-0">
<span class="font-medium text-texto">{{ p.nombre }}</span>
<span class="ml-2 text-xs text-tenue">{{ etiqueta(p.tipo) }} · versión {{ p.version }}</span>
<span v-if="p.activa" class="ml-2 px-1.5 py-0.5 rounded text-xs badge-ok">en uso</span>
</div>
<button class="text-sm text-red-500 hover:text-red-700 shrink-0" @click="eliminar(p)">Eliminar</button>
</div>
</div>
</div>
</template>