En la misma pantalla convivían dos lenguajes: emojis a color (🌙 📊 🧠 ✍️ 📄 🌐 🔁) y símbolos tipográficos monocromos (✎ ✕ ← ↻ ⚠ ⬇ ✓). Eso se lee como descuido, y encima los emojis los dibuja el sistema operativo — el mismo panel se ve distinto en Mac, en Windows y en Android, sin que podamos hacer nada. Ahora hay un set propio de iconos SVG de trazo: mismo grosor de línea, misma caja, y heredan currentColor, así que toman el color del texto que los rodea y funcionan igual en tema claro y oscuro. Al lado uno de otro tienen el mismo peso visual, que es lo que hacía falta. El emoji suelto de la pantalla de inicio pasa a ser Umi, que ya es el personaje del producto. Un detalle de implementación que costó dos intentos: los iconos NO pueden declararse como cadenas de HTML para v-html. Dentro de un <svg>, v-html parsea como HTML y los <path> quedan sin el namespace de SVG: aparecen en el DOM pero no dibujan nada, así que la primera versión se veía perfecta en el código y en blanco en pantalla. Van declarados como pares [etiqueta, atributos] para que Vue los cree con el namespace correcto. Y AgenteDetail usaba cinco iconos sin importar el componente. Verificado ahora sobre todos los archivos: cada componente usado está importado donde se usa. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
8.2 KiB
Vue
207 lines
8.2 KiB
Vue
<script setup>
|
|
import { computed, ref, watch } from 'vue'
|
|
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'
|
|
|
|
const props = defineProps({ id: { type: String, required: true } })
|
|
|
|
const cargando = ref(true)
|
|
const error = ref('')
|
|
const datos = ref(null)
|
|
|
|
// Por defecto, el mes en curso.
|
|
const hoy = new Date()
|
|
const desde = ref(new Date(hoy.getFullYear(), hoy.getMonth(), 1).toISOString().slice(0, 10))
|
|
const hasta = ref(hoy.toISOString().slice(0, 10))
|
|
|
|
const ETIQUETAS = {
|
|
ia: { nombre: 'Inteligencia artificial', icono: 'cerebro', color: '#8eb02f' },
|
|
ocr: { nombre: 'Lectura de imágenes', icono: 'imagen', color: '#2f7fb0' },
|
|
whisper: { nombre: 'Transcripción de audio', icono: 'microfono', color: '#b0752f' },
|
|
}
|
|
const etiqueta = (t) => ETIQUETAS[t] || { nombre: t, icono: 'grafico', color: '#94a3b8' }
|
|
|
|
async function cargar() {
|
|
cargando.value = true
|
|
error.value = ''
|
|
try {
|
|
datos.value = await api.get(
|
|
apiUmind(`/umind/uso?tenant_id=${props.id}&desde=${desde.value}&hasta=${hasta.value}`),
|
|
)
|
|
} catch (e) {
|
|
error.value = e.message
|
|
} finally {
|
|
cargando.value = false
|
|
}
|
|
}
|
|
|
|
const moneda = computed(() => datos.value?.plan?.moneda || 'COP')
|
|
const resumen = computed(() => datos.value?.resumen || [])
|
|
const totalPeriodo = computed(() => datos.value?.total_periodo || 0)
|
|
const tope = computed(() => datos.value?.plan?.tope_consumo_mensual || 0)
|
|
|
|
const porcentajeTope = computed(() => {
|
|
if (!tope.value) return 0
|
|
return Math.min(100, (totalPeriodo.value / tope.value) * 100)
|
|
})
|
|
|
|
function money(v) {
|
|
return `${moneda.value} ${Number(v || 0).toLocaleString('es-CO', { maximumFractionDigits: 2 })}`
|
|
}
|
|
|
|
function numero(v) {
|
|
return Number(v || 0).toLocaleString('es-CO', { maximumFractionDigits: 0 })
|
|
}
|
|
|
|
// Consumo por día, para el gráfico. Se arma desde el detalle en vez de pedirle
|
|
// otro agregado al backend: son a lo sumo 1000 filas y evita un endpoint más.
|
|
const porDia = computed(() => {
|
|
const mapa = new Map()
|
|
for (const u of datos.value?.detalle || []) {
|
|
const dia = String(u.CreatedAt).slice(0, 10)
|
|
mapa.set(dia, (mapa.get(dia) || 0) + (u.costo || 0))
|
|
}
|
|
const dias = [...mapa.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
|
const max = Math.max(...dias.map((d) => d[1]), 0)
|
|
return { dias, max }
|
|
})
|
|
|
|
function atajo(dias) {
|
|
const fin = new Date()
|
|
const ini = new Date()
|
|
ini.setDate(ini.getDate() - dias)
|
|
desde.value = ini.toISOString().slice(0, 10)
|
|
hasta.value = fin.toISOString().slice(0, 10)
|
|
cargar()
|
|
}
|
|
|
|
// Ver el comentario en TenantAgentes.vue: la instancia se reusa al cambiar
|
|
// de tenant, así que hay que reaccionar al parámetro y no al montaje.
|
|
watch(() => props.id, cargar, { immediate: true })
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="flex items-start justify-between gap-4 mb-6">
|
|
<div>
|
|
<h1 class="text-xl font-semibold text-texto">Consumo</h1>
|
|
<p class="text-sm text-tenue mt-0.5">Lo que usaron tus agentes y cuánto cuesta.</p>
|
|
</div>
|
|
<router-link :to="`/tenants/${id}`" class="btn-ghost inline-flex items-center gap-1.5"><UiIcono nombre="atras" :tam="15" /> Agentes</router-link>
|
|
</div>
|
|
|
|
<!-- Filtros -->
|
|
<div class="card p-4 mb-4">
|
|
<div class="flex flex-wrap items-end gap-3">
|
|
<div>
|
|
<label class="label">Desde</label>
|
|
<input v-model="desde" type="date" class="input !w-auto" />
|
|
</div>
|
|
<div>
|
|
<label class="label">Hasta</label>
|
|
<input v-model="hasta" type="date" class="input !w-auto" />
|
|
</div>
|
|
<button class="btn-primary" @click="cargar">Aplicar</button>
|
|
<div class="flex gap-1 ml-auto">
|
|
<button class="btn-ghost !px-2.5 !py-1 !text-xs" @click="atajo(6)">7 días</button>
|
|
<button class="btn-ghost !px-2.5 !py-1 !text-xs" @click="atajo(29)">30 días</button>
|
|
<button class="btn-ghost !px-2.5 !py-1 !text-xs" @click="atajo(89)">90 días</button>
|
|
</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="text-sm text-tenue">Cargando…</p>
|
|
|
|
<template v-else-if="datos">
|
|
<!-- Totales -->
|
|
<div class="grid gap-3 sm:grid-cols-3 mb-4">
|
|
<div class="card p-4">
|
|
<p class="text-xs text-tenue">Consumo del período</p>
|
|
<p class="text-2xl font-semibold text-texto mt-1 tabular-nums">{{ money(totalPeriodo) }}</p>
|
|
</div>
|
|
<div class="card p-4">
|
|
<p class="text-xs text-tenue">Pendiente de facturar</p>
|
|
<p class="text-2xl font-semibold text-texto mt-1 tabular-nums">
|
|
{{ money(datos.pendiente_facturar) }}
|
|
</p>
|
|
<p class="text-[11px] text-tenue mt-0.5">Se suma a tu próxima factura.</p>
|
|
</div>
|
|
<div class="card p-4">
|
|
<p class="text-xs text-tenue">Plan</p>
|
|
<p class="text-lg font-semibold text-texto mt-1">{{ datos.plan?.nombre || 'Sin plan' }}</p>
|
|
<p v-if="datos.plan" class="text-[11px] text-tenue mt-0.5">
|
|
Mensualidad {{ money(datos.plan.precio_mensual) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tope -->
|
|
<div v-if="tope" class="card p-4 mb-4">
|
|
<div class="flex items-center justify-between text-sm mb-2">
|
|
<span class="text-texto">Tope mensual</span>
|
|
<span class="text-tenue tabular-nums">{{ money(totalPeriodo) }} / {{ money(tope) }}</span>
|
|
</div>
|
|
<div class="h-2 rounded-full bg-elevado overflow-hidden">
|
|
<div
|
|
class="h-full rounded-full transition-all duration-500"
|
|
:class="porcentajeTope >= 100 ? 'bg-red-500' : porcentajeTope >= 80 ? 'bg-amber-500' : 'bg-brand'"
|
|
:style="{ width: porcentajeTope + '%' }"
|
|
></div>
|
|
</div>
|
|
<p v-if="porcentajeTope >= 100" class="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
|
Superaste el tope de tu plan. El servicio sigue funcionando y el excedente se cobra en el próximo ciclo.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Desglose por tipo -->
|
|
<div class="card divide-y divide-borde mb-4">
|
|
<div class="px-4 py-3">
|
|
<h2 class="text-sm font-medium text-texto">Desglose</h2>
|
|
</div>
|
|
<UiEmptyState
|
|
v-if="resumen.length === 0"
|
|
estado="durmiendo"
|
|
titulo="Sin consumo en este período"
|
|
detalle="Cuando tus agentes respondan mensajes, lean imágenes o transcriban audios, vas a verlo acá."
|
|
/>
|
|
<div v-for="r in resumen" :key="r.tipo" class="px-4 py-3 flex items-center gap-3">
|
|
<UiIcono :nombre="etiqueta(r.tipo).icono" :tam="18" class="text-tenue" />
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm text-texto">{{ etiqueta(r.tipo).nombre }}</p>
|
|
<p class="text-xs text-tenue tabular-nums">
|
|
{{ numero(r.cantidad) }} {{ r.unidad }} · {{ numero(r.eventos) }} usos
|
|
</p>
|
|
</div>
|
|
<!-- Costo cero con consumo real quiere decir cuenta propia: lo
|
|
paga su proveedor. Mostrar "$0" a secas parecería un error. -->
|
|
<span v-if="r.costo > 0" class="text-sm font-medium text-texto tabular-nums">{{ money(r.costo) }}</span>
|
|
<span v-else class="text-xs text-tenue text-right leading-tight">
|
|
va por tu<br />cuenta de IA
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Consumo por día: SVG a mano, no vale una librería de charts por esto -->
|
|
<div v-if="porDia.dias.length" class="card p-4">
|
|
<h2 class="text-sm font-medium text-texto mb-3">Por día</h2>
|
|
<div class="flex items-end gap-1 h-28">
|
|
<div
|
|
v-for="[dia, costo] in porDia.dias"
|
|
:key="dia"
|
|
class="flex-1 min-w-[3px] bg-brand/70 hover:bg-brand rounded-t transition-colors"
|
|
:style="{ height: porDia.max ? Math.max(2, (costo / porDia.max) * 100) + '%' : '2px' }"
|
|
:title="`${dia}: ${money(costo)}`"
|
|
></div>
|
|
</div>
|
|
<div class="flex justify-between text-[11px] text-tenue mt-2">
|
|
<span>{{ porDia.dias[0][0] }}</span>
|
|
<span>{{ porDia.dias[porDia.dias.length - 1][0] }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|