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:
co-authored by
Claude Opus 5
parent
7ddc3f5286
commit
706263db92
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { api } from '../lib/api.js'
|
||||
import { apiUmind } from '../lib/contexto.js'
|
||||
|
||||
// La barra del espacio: lo que es del negocio y no de un agente puntual.
|
||||
const props = defineProps({ tenantId: { type: [String, Number], required: true } })
|
||||
|
||||
const pendientes = ref(0)
|
||||
|
||||
async function cargarPendientes() {
|
||||
try {
|
||||
const r = await api.get(apiUmind(`/umind/acciones?tenant_id=${props.tenantId}`))
|
||||
pendientes.value = r.pendientes || 0
|
||||
} catch {
|
||||
pendientes.value = 0
|
||||
}
|
||||
}
|
||||
watch(() => props.tenantId, cargarPendientes, { immediate: true })
|
||||
|
||||
const SECCIONES = [
|
||||
['tenant-agentes', '', 'Agentes'],
|
||||
['pendientes', '/pendientes', 'Pendientes'],
|
||||
['archivos', '/archivos', 'Archivos'],
|
||||
['plantillas', '/plantillas', 'Plantillas'],
|
||||
['uso', '/uso', 'Consumo'],
|
||||
['ai-propia', '/ia', 'Tu IA'],
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="flex gap-1 flex-wrap mb-6 border-b border-borde pb-2">
|
||||
<router-link
|
||||
v-for="[nombre, sufijo, label] in SECCIONES"
|
||||
:key="nombre"
|
||||
:to="`/tenants/${tenantId}${sufijo}`"
|
||||
class="px-3 py-1.5 rounded-lg text-sm transition-colors inline-flex items-center gap-1.5"
|
||||
:class="$route.name === nombre
|
||||
? 'bg-brand/10 text-brand font-medium'
|
||||
: 'text-tenue hover:text-texto hover:bg-elevado'"
|
||||
>
|
||||
{{ label }}
|
||||
<!-- El badge solo aparece cuando hay algo esperando: un cero permanente
|
||||
enseña a ignorar el lugar donde después va lo importante. -->
|
||||
<span
|
||||
v-if="nombre === 'pendientes' && pendientes > 0"
|
||||
class="px-1.5 py-0.5 rounded-full bg-amber-500 text-white text-[10px] font-semibold leading-none"
|
||||
>{{ pendientes }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -5,9 +5,12 @@ import { contexto } from './contexto.js'
|
||||
// PortalAuth() redirigen al login devolviendo HTML en vez de un 401 JSON —
|
||||
// fetch sigue ese redirect solo, así que lo detectamos por el content-type.
|
||||
async function request(path, options = {}) {
|
||||
const esFormData = options.body instanceof FormData
|
||||
const res = await fetch(path, {
|
||||
...options,
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
headers: esFormData
|
||||
? options.headers
|
||||
: { 'Content-Type': 'application/json', ...options.headers },
|
||||
})
|
||||
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
@@ -29,6 +32,9 @@ async function request(path, options = {}) {
|
||||
|
||||
export const api = {
|
||||
get: (path) => request(path),
|
||||
// Sin Content-Type a mano: en un multipart el boundary lo pone el navegador,
|
||||
// y fijarlo nosotros deja el body ilegible del lado del servidor.
|
||||
postForm: (path, formData) => request(path, { method: 'POST', body: formData, headers: {} }),
|
||||
post: (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||
put: (path, body) => request(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
del: (path) => request(path, { method: 'DELETE' }),
|
||||
|
||||
@@ -4,6 +4,9 @@ import TenantAgentes from './views/TenantAgentes.vue'
|
||||
import AgenteDetail from './views/AgenteDetail.vue'
|
||||
import Uso from './views/Uso.vue'
|
||||
import AiPropia from './views/AiPropia.vue'
|
||||
import Pendientes from './views/Pendientes.vue'
|
||||
import Archivos from './views/Archivos.vue'
|
||||
import Plantillas from './views/Plantillas.vue'
|
||||
import { contexto } from './lib/contexto.js'
|
||||
|
||||
const router = createRouter({
|
||||
@@ -13,6 +16,9 @@ const router = createRouter({
|
||||
{ path: '/tenants/:id', name: 'tenant-agentes', component: TenantAgentes, props: true },
|
||||
{ path: '/tenants/:id/uso', name: 'uso', component: Uso, props: true },
|
||||
{ path: '/tenants/:id/ia', name: 'ai-propia', component: AiPropia, props: true },
|
||||
{ path: '/tenants/:id/pendientes', name: 'pendientes', component: Pendientes, props: true },
|
||||
{ path: '/tenants/:id/archivos', name: 'archivos', component: Archivos, props: true },
|
||||
{ path: '/tenants/:id/plantillas', name: 'plantillas', component: Plantillas, props: true },
|
||||
{
|
||||
path: '/tenants/:tenantId/agentes/:agenteId',
|
||||
name: 'agente-detail',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apiUmind, contexto } from '../lib/contexto.js'
|
||||
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||
import UiIcono from '../components/ui/UiIcono.vue'
|
||||
import TabAuditoria from './agente/TabAuditoria.vue'
|
||||
import TabAcciones from './agente/TabAcciones.vue'
|
||||
import TabConexiones from './agente/TabConexiones.vue'
|
||||
import TabChat from './agente/TabChat.vue'
|
||||
import TabConversaciones from './agente/TabConversaciones.vue'
|
||||
@@ -268,6 +269,7 @@ async function cargarConexiones() {
|
||||
const ZONAS = [
|
||||
['conversaciones', 'Conversaciones'],
|
||||
['conocimiento', 'Lo que sabe'],
|
||||
['acciones', 'Lo que hace'],
|
||||
['canales', 'Dónde atiende'],
|
||||
]
|
||||
|
||||
@@ -663,6 +665,8 @@ watch(
|
||||
/>
|
||||
|
||||
<!-- Auditoría -->
|
||||
<TabAcciones v-else-if="tab === 'acciones'" :agente-id="agenteIdNum" />
|
||||
|
||||
<TabAuditoria v-else :eventos="eventos" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from '../lib/api.js'
|
||||
import { apiUmind } from '../lib/contexto.js'
|
||||
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||
import UiIcono from '../components/ui/UiIcono.vue'
|
||||
import NavEspacio from '../components/NavEspacio.vue'
|
||||
|
||||
const props = defineProps({ id: { type: String, required: true } })
|
||||
|
||||
@@ -121,6 +122,8 @@ async function borrar(c) {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NavEspacio :tenant-id="id" />
|
||||
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<router-link :to="`/tenants/${id}`" class="text-sm text-tenue hover:text-texto inline-flex items-center gap-1.5"><UiIcono nombre="atras" :tam="14" /> Agentes</router-link>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } 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 items = ref([])
|
||||
const usado = ref(0)
|
||||
const cuota = ref(0)
|
||||
const cargando = ref(true)
|
||||
const error = ref('')
|
||||
const subiendo = ref(false)
|
||||
const agentes = ref([])
|
||||
|
||||
const porcentaje = computed(() => (cuota.value ? Math.min(100, Math.round((usado.value / cuota.value) * 100)) : 0))
|
||||
|
||||
function mb(bytes) {
|
||||
if (!bytes) return '0 KB'
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
async function cargar() {
|
||||
cargando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const r = await api.get(apiUmind(`/umind/archivos?tenant_id=${props.id}`))
|
||||
items.value = r.items || []
|
||||
usado.value = r.usado_bytes || 0
|
||||
cuota.value = r.cuota_bytes || 0
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
cargando.value = false
|
||||
}
|
||||
try {
|
||||
const r = await api.get(apiUmind(`/umind/agentes?tenant_id=${props.id}`))
|
||||
agentes.value = r.items || []
|
||||
} catch {
|
||||
agentes.value = []
|
||||
}
|
||||
}
|
||||
watch(() => props.id, cargar, { immediate: true })
|
||||
|
||||
async function subir(ev) {
|
||||
const file = ev.target.files?.[0]
|
||||
if (!file) return
|
||||
subiendo.value = true
|
||||
error.value = ''
|
||||
const fd = new FormData()
|
||||
fd.append('tenant_id', props.id)
|
||||
fd.append('archivo', file)
|
||||
try {
|
||||
await api.postForm(apiUmind('/umind/archivos'), fd)
|
||||
await cargar()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
subiendo.value = false
|
||||
ev.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function usarComoConocimiento(a) {
|
||||
if (agentes.value.length === 0) return
|
||||
// Con un solo agente no se pregunta nada; con varios hay que elegir a cuál.
|
||||
let agenteId = agentes.value[0].ID
|
||||
if (agentes.value.length > 1) {
|
||||
const opciones = agentes.value.map((g, i) => `${i + 1}. ${g.nombre}`).join('\n')
|
||||
const elegido = prompt(`¿A qué asistente se lo cargamos?\n${opciones}`, '1')
|
||||
if (elegido === null) return
|
||||
const idx = Number(elegido) - 1
|
||||
if (!agentes.value[idx]) return
|
||||
agenteId = agentes.value[idx].ID
|
||||
}
|
||||
try {
|
||||
await api.post(apiUmind(`/umind/archivos/${a.ID}/conocimiento`), { agente_id: agenteId })
|
||||
await cargar()
|
||||
alert('Listo. Lo está leyendo — en un momento va a poder responder con eso.')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function eliminar(a) {
|
||||
if (!confirm(`¿Eliminar ${a.nombre}?`)) return
|
||||
try {
|
||||
await api.del(apiUmind(`/umind/archivos/${a.ID}`))
|
||||
await cargar()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
// Mismo origen: la ruta relativa alcanza y la cookie de sesión viaja sola.
|
||||
function urlDescarga(a) {
|
||||
return apiUmind(`/umind/archivos/${a.ID}/descargar`)
|
||||
}
|
||||
|
||||
function cuando(iso) {
|
||||
return new Date(iso).toLocaleDateString('es', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
</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">Archivos</h1>
|
||||
<p class="label mt-1">
|
||||
Los papeles del negocio: contratos, pólizas, cotizaciones. Los documentos que genera tu
|
||||
asistente también quedan acá.
|
||||
</p>
|
||||
</div>
|
||||
<label class="btn-primary cursor-pointer shrink-0">
|
||||
{{ subiendo ? 'Subiendo…' : '+ Subir archivo' }}
|
||||
<input type="file" class="hidden" :disabled="subiendo" @change="subir" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="cuota" class="mb-4">
|
||||
<div class="flex justify-between text-xs text-tenue mb-1">
|
||||
<span>{{ mb(usado) }} de {{ mb(cuota) }}</span>
|
||||
<span>{{ porcentaje }}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 rounded-full bg-elevado overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:class="porcentaje > 90 ? 'bg-amber-500' : 'bg-brand'"
|
||||
:style="{ width: porcentaje + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
|
||||
<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 archivos todavía"
|
||||
detalle="Subí acá los papeles del negocio. Cualquiera de ellos se le puede cargar al asistente como conocimiento con un clic."
|
||||
/>
|
||||
<div v-for="a in items" :key="a.ID" class="p-4 flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-medium text-texto truncate">{{ a.nombre }}</span>
|
||||
<span v-if="a.origen === 'generado'" class="px-1.5 py-0.5 rounded text-xs badge-ok">generado</span>
|
||||
<span v-if="a.documento_id" class="px-1.5 py-0.5 rounded text-xs badge-neutro">es conocimiento</span>
|
||||
</div>
|
||||
<p class="text-xs text-tenue mt-0.5">{{ mb(a.tamanio) }} · {{ cuando(a.CreatedAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0 text-sm">
|
||||
<a :href="urlDescarga(a)" class="text-brand hover:underline">Descargar</a>
|
||||
<button
|
||||
v-if="!a.documento_id && agentes.length"
|
||||
class="text-brand hover:underline"
|
||||
@click="usarComoConocimiento(a)"
|
||||
>Usar como conocimiento</button>
|
||||
<button class="text-red-500 hover:text-red-700" @click="eliminar(a)">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } 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 items = ref([])
|
||||
const cargando = ref(true)
|
||||
const error = ref('')
|
||||
const historial = ref(false)
|
||||
const trabajando = ref(null)
|
||||
|
||||
const pendientes = computed(() => items.value.filter(a => a.estado === 'pendiente'))
|
||||
|
||||
async function cargar() {
|
||||
cargando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const r = await api.get(apiUmind(`/umind/acciones?tenant_id=${props.id}${historial.value ? '&historial=1' : ''}`))
|
||||
items.value = r.items || []
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
cargando.value = false
|
||||
}
|
||||
}
|
||||
watch(() => props.id, cargar, { immediate: true })
|
||||
watch(historial, cargar)
|
||||
|
||||
async function aprobar(a) {
|
||||
trabajando.value = a.ID
|
||||
error.value = ''
|
||||
try {
|
||||
await api.post(apiUmind(`/umind/acciones/${a.ID}/aprobar`), {})
|
||||
await cargar()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
trabajando.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function rechazar(a) {
|
||||
const motivo = prompt('¿Por qué lo rechazás? (queda para vos, el cliente no lo ve)')
|
||||
if (motivo === null) return
|
||||
trabajando.value = a.ID
|
||||
try {
|
||||
await api.post(apiUmind(`/umind/acciones/${a.ID}/rechazar`), { motivo })
|
||||
await cargar()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
trabajando.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const ETIQUETA_ESTADO = {
|
||||
pendiente: 'esperando', aprobada: 'aprobada', ejecutada: 'hecho',
|
||||
rechazada: 'rechazada', vencida: 'venció', fallida: 'falló',
|
||||
}
|
||||
const CLASE_ESTADO = {
|
||||
ejecutada: 'badge-ok', rechazada: 'badge-neutro',
|
||||
vencida: 'badge-neutro', fallida: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||
}
|
||||
const ICONO_TIPO = { correo: '✉️', documento: '📄', herramienta: '⚙️' }
|
||||
|
||||
function cuando(iso) {
|
||||
return new Date(iso).toLocaleString('es', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</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">Pendientes</h1>
|
||||
<p class="label mt-1">
|
||||
Lo que tu asistente preparó y espera tu visto bueno. Prepara solo; entregar lo decidís vos.
|
||||
</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-tenue cursor-pointer shrink-0">
|
||||
<input type="checkbox" v-model="historial" class="rounded border-borde text-brand focus:ring-brand" />
|
||||
Ver resueltos
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
|
||||
<p v-if="cargando" class="label">Cargando…</p>
|
||||
|
||||
<div v-else class="card divide-y divide-borde">
|
||||
<UiEmptyState
|
||||
v-if="items.length === 0"
|
||||
titulo="Nada esperando"
|
||||
detalle="Cuando alguien le pida algo a tu asistente desde un canal público —una cotización, un correo— lo va a preparar y te lo va a dejar acá para que decidas."
|
||||
/>
|
||||
<div v-for="a in items" :key="a.ID" class="p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span>{{ ICONO_TIPO[a.tipo] || '⚙️' }}</span>
|
||||
<span class="font-medium text-texto">{{ a.resumen }}</span>
|
||||
<span
|
||||
v-if="a.estado !== 'pendiente'"
|
||||
class="px-1.5 py-0.5 rounded text-xs"
|
||||
:class="CLASE_ESTADO[a.estado] || 'badge-neutro'"
|
||||
>{{ ETIQUETA_ESTADO[a.estado] || a.estado }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-tenue mt-1">
|
||||
{{ cuando(a.CreatedAt) }}
|
||||
<template v-if="a.estado === 'pendiente'"> · vence el {{ cuando(a.expira_at) }}</template>
|
||||
<template v-else-if="a.resuelta_por"> · {{ a.resuelta_por }}</template>
|
||||
</p>
|
||||
<p v-if="a.motivo" class="text-xs text-tenue mt-1 italic">Motivo: {{ a.motivo }}</p>
|
||||
<router-link
|
||||
v-if="a.archivo_id"
|
||||
:to="`/tenants/${id}/archivos`"
|
||||
class="text-xs text-brand hover:underline"
|
||||
>Ver el documento</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="a.estado === 'pendiente'" class="flex items-center gap-2 shrink-0">
|
||||
<button class="btn-ghost text-sm" :disabled="trabajando === a.ID" @click="rechazar(a)">Rechazar</button>
|
||||
<button class="btn-primary text-sm" :disabled="trabajando === a.ID" @click="aprobar(a)">
|
||||
{{ trabajando === a.ID ? 'Haciéndolo…' : 'Aprobar' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -5,7 +5,7 @@ import { api } from '../lib/api.js'
|
||||
import { apiUmind, contexto } from '../lib/contexto.js'
|
||||
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||
import UiIcono from '../components/ui/UiIcono.vue'
|
||||
|
||||
import NavEspacio from '../components/NavEspacio.vue'
|
||||
const props = defineProps({ id: { type: String, required: true } })
|
||||
const tenantId = computed(() => Number(props.id))
|
||||
const router = useRouter()
|
||||
@@ -162,17 +162,17 @@ watch(() => props.id, cargar, { immediate: true })
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="tenant" class="mb-6">
|
||||
<div v-if="tenant" class="mb-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-texto">{{ tenant.nombre }}</h1>
|
||||
<p class="text-xs text-tenue mt-1">{{ tenant.dominios_permitidos || 'sin dominios configurados' }}</p>
|
||||
</div>
|
||||
<router-link :to="`/tenants/${tenantId ?? id}/ia`" class="btn-ghost">Tu IA</router-link>
|
||||
<router-link :to="`/tenants/${tenantId ?? id}/uso`" class="btn-ghost inline-flex items-center gap-1.5"><UiIcono nombre="grafico" /> Consumo</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavEspacio :tenant-id="tenantId ?? id" />
|
||||
|
||||
<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">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from '../lib/api.js'
|
||||
import { apiUmind } from '../lib/contexto.js'
|
||||
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||
import UiIcono from '../components/ui/UiIcono.vue'
|
||||
import NavEspacio from '../components/NavEspacio.vue'
|
||||
|
||||
const props = defineProps({ id: { type: String, required: true } })
|
||||
|
||||
@@ -84,6 +85,8 @@ watch(() => props.id, cargar, { immediate: true })
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NavEspacio :tenant-id="id" />
|
||||
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-texto">Consumo</h1>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { api } from '../../lib/api.js'
|
||||
import { apiUmind } from '../../lib/contexto.js'
|
||||
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||
|
||||
// "Lo que hace": los recordatorios y las vigilancias que el dueño le programó
|
||||
// conversando. 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.
|
||||
const props = defineProps({ agenteId: { type: Number, required: true } })
|
||||
|
||||
const avisos = ref([])
|
||||
const vigilancias = ref([])
|
||||
const cargando = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
async function cargar() {
|
||||
cargando.value = true
|
||||
error.value = ''
|
||||
// Por separado a propósito: que falle uno no debe vaciar el otro.
|
||||
try {
|
||||
const r = await api.get(apiUmind(`/umind/avisos?agente_id=${props.agenteId}`))
|
||||
avisos.value = (r.items || []).filter(a => a.estado === 'pendiente')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
try {
|
||||
const r = await api.get(apiUmind(`/umind/vigilancias?agente_id=${props.agenteId}`))
|
||||
vigilancias.value = r.items || []
|
||||
} catch {
|
||||
vigilancias.value = []
|
||||
}
|
||||
cargando.value = false
|
||||
}
|
||||
watch(() => props.agenteId, cargar, { immediate: true })
|
||||
|
||||
async function borrarAviso(a) {
|
||||
if (!confirm(`¿Cancelar el recordatorio "${a.titulo}"?`)) return
|
||||
await api.del(apiUmind(`/umind/avisos/${a.ID}`))
|
||||
await cargar()
|
||||
}
|
||||
|
||||
async function borrarVigilancia(v) {
|
||||
if (!confirm(`¿Apagar la vigilancia "${v.nombre}"?`)) return
|
||||
await api.del(apiUmind(`/umind/vigilancias/${v.ID}`))
|
||||
await cargar()
|
||||
}
|
||||
|
||||
const REPETICION = { diario: 'todos los días', semanal: 'cada semana', mensual: 'cada mes', anual: 'cada año' }
|
||||
|
||||
function cuando(iso) {
|
||||
return new Date(iso).toLocaleString('es', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function dondeLlega(destino) {
|
||||
if (!destino || destino.startsWith('panel:')) return 'te llega por correo'
|
||||
if (destino.startsWith('tg:')) return 'te llega por Telegram'
|
||||
if (destino.startsWith('mail:')) return `te llega a ${destino.slice(5)}`
|
||||
return 'te llega por correo'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
<p v-if="cargando" class="label">Cargando…</p>
|
||||
|
||||
<template v-else>
|
||||
<section>
|
||||
<h3 class="text-sm font-medium text-texto mb-1">Recordatorios</h3>
|
||||
<p class="label mb-3">
|
||||
Pediselos hablando: <em>«avisame el 15 de marzo que vence la póliza de Acme, y todos los años»</em>.
|
||||
</p>
|
||||
<div class="card divide-y divide-borde">
|
||||
<UiEmptyState
|
||||
v-if="avisos.length === 0"
|
||||
titulo="Sin recordatorios"
|
||||
detalle="Escribile desde tu canal privado y programá el primero. Te avisa por donde se lo pediste."
|
||||
/>
|
||||
<div v-for="a in avisos" :key="a.ID" class="p-4 flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium text-texto">{{ a.titulo }}</span>
|
||||
<p class="text-xs text-tenue mt-0.5">
|
||||
{{ cuando(a.proximo_at) }}
|
||||
<template v-if="a.repetir"> · se repite {{ REPETICION[a.repetir] || a.repetir }}</template>
|
||||
· {{ dondeLlega(a.destino) }}
|
||||
</p>
|
||||
<p v-if="a.detalle" class="text-xs text-tenue mt-1">{{ a.detalle }}</p>
|
||||
</div>
|
||||
<button class="text-sm text-red-500 hover:text-red-700 shrink-0" @click="borrarAviso(a)">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-sm font-medium text-texto mb-1">Vigilancias</h3>
|
||||
<p class="label mb-3">
|
||||
Consultan una de tus herramientas cada tanto y te avisan cuando pasa algo.
|
||||
Solo avisan al entrar en la condición, no cada vez que la revisan.
|
||||
</p>
|
||||
<div class="card divide-y divide-borde">
|
||||
<UiEmptyState
|
||||
v-if="vigilancias.length === 0"
|
||||
titulo="Sin vigilancias"
|
||||
detalle="Si tenés una herramienta conectada, pedile algo como «avisame cuando esta API devuelva stock en cero»."
|
||||
/>
|
||||
<div v-for="v in vigilancias" :key="v.ID" class="p-4 flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-medium text-texto">{{ v.nombre }}</span>
|
||||
<span v-if="!v.activa" class="px-1.5 py-0.5 rounded text-xs badge-neutro">apagada</span>
|
||||
<span v-else-if="v.en_condicion" class="px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">se está cumpliendo</span>
|
||||
</div>
|
||||
<p class="text-xs text-tenue mt-0.5">
|
||||
{{ v.condicion }} · revisa cada {{ v.intervalo_min }} min · usa {{ v.herramienta }}
|
||||
</p>
|
||||
<p v-if="v.ultimo_error" class="text-xs text-red-600 dark:text-red-400 mt-1">{{ v.ultimo_error }}</p>
|
||||
</div>
|
||||
<button class="text-sm text-red-500 hover:text-red-700 shrink-0" @click="borrarVigilancia(v)">Apagar</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -38,7 +38,7 @@ function canalVacio() {
|
||||
return {
|
||||
tipo: 'telegram', bot_token: '', phone_number_id: '', access_token: '', app_secret: '', verify_token: '',
|
||||
usuario: '', password: '', imap_host: '', smtp_host: '', imap_encriptado: 'ssl', smtp_encriptado: 'starttls',
|
||||
intervalo_minutos: 5, responder_a: 'todos', filtro_remitentes: '',
|
||||
intervalo_minutos: 5, responder_a: 'todos', filtro_remitentes: '', es_interno: false,
|
||||
usar_whisper_audio: false, usar_ocr_imagenes: false, usar_archivos_docs: false,
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ async function guardarCanal() {
|
||||
agente_id: props.agenteId, tipo: canalForm.value.tipo, credenciales, activo: true,
|
||||
intervalo_minutos: Number(canalForm.value.intervalo_minutos) || 5,
|
||||
filtro_remitentes: canalForm.value.responder_a === 'algunos' ? canalForm.value.filtro_remitentes : '',
|
||||
es_interno: canalForm.value.es_interno,
|
||||
usar_whisper_audio: canalForm.value.usar_whisper_audio, usar_ocr_imagenes: canalForm.value.usar_ocr_imagenes,
|
||||
usar_archivos_docs: canalForm.value.usar_archivos_docs,
|
||||
})
|
||||
@@ -153,6 +154,7 @@ async function eliminarCanal(c) {
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium text-texto capitalize">{{ c.tipo }}</span>
|
||||
<span v-if="c.es_interno" class="ml-2 px-1.5 py-0.5 rounded text-xs badge-neutro">privado</span>
|
||||
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'">
|
||||
{{ c.activo ? 'activo' : 'inactivo' }}
|
||||
</span>
|
||||
@@ -295,6 +297,20 @@ async function eliminarCanal(c) {
|
||||
<input v-model="canalForm.verify_token" required class="input" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="rounded-lg border border-borde p-3 mt-1">
|
||||
<label class="flex items-start gap-2 text-sm text-texto cursor-pointer">
|
||||
<input type="checkbox" v-model="canalForm.es_interno" class="mt-0.5 rounded border-borde text-brand focus:ring-brand" />
|
||||
<span>
|
||||
Este canal es mío, no de mis clientes
|
||||
<span class="block text-xs text-tenue mt-0.5">
|
||||
Marcalo si acá te escribís solo vos. El asistente va a poder programarte
|
||||
recordatorios y ejecutar acciones sin pedir permiso. En los canales donde
|
||||
escriben tus clientes, todo lo que salga hacia afuera espera tu visto bueno.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 pt-1">
|
||||
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
|
||||
<input type="checkbox" v-model="canalForm.usar_whisper_audio" class="rounded border-borde text-brand focus:ring-brand" />
|
||||
|
||||
Reference in New Issue
Block a user