ZERADO
This commit is contained in:
636
src/views/pages/billing/ClinicMeuPlanoPage.vue
Normal file
636
src/views/pages/billing/ClinicMeuPlanoPage.vue
Normal file
@@ -0,0 +1,636 @@
|
||||
<!-- src/views/pages/billing/ClinicMeuPlanoPage.vue -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const subscription = ref(null)
|
||||
const plan = ref(null)
|
||||
const price = ref(null)
|
||||
|
||||
const features = ref([]) // [{ key, description }]
|
||||
const events = ref([]) // subscription_events
|
||||
|
||||
// ✅ para histórico auditável
|
||||
const plans = ref([]) // [{id,key,name}]
|
||||
const profiles = ref([]) // profiles de created_by
|
||||
|
||||
const tenantId = computed(() =>
|
||||
tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.currentTenantId || null
|
||||
)
|
||||
|
||||
// -------------------------
|
||||
// helpers (format)
|
||||
// -------------------------
|
||||
function money (currency, amountCents) {
|
||||
if (amountCents == null) return null
|
||||
const value = Number(amountCents) / 100
|
||||
try {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency || 'BRL' }).format(value)
|
||||
} catch (_) {
|
||||
return `${value.toFixed(2)} ${currency || ''}`.trim()
|
||||
}
|
||||
}
|
||||
|
||||
function goUpgradeClinic () {
|
||||
// ✅ mantém caminho de retorno consistente
|
||||
const redirectTo = route?.fullPath || '/admin/meu-plano'
|
||||
router.push(`/upgrade?redirectTo=${encodeURIComponent(redirectTo)}`)
|
||||
}
|
||||
|
||||
function fmtDate (iso) {
|
||||
if (!iso) return '-'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return String(iso)
|
||||
return d.toLocaleString('pt-BR')
|
||||
}
|
||||
|
||||
function prettyMeta (meta) {
|
||||
if (!meta) return null
|
||||
try {
|
||||
if (typeof meta === 'string') return meta
|
||||
return JSON.stringify(meta, null, 2)
|
||||
} catch (_) {
|
||||
return String(meta)
|
||||
}
|
||||
}
|
||||
|
||||
function statusSeverity (st) {
|
||||
const s = String(st || '').toLowerCase()
|
||||
if (s === 'active') return 'success'
|
||||
if (s === 'trialing') return 'info'
|
||||
if (s === 'past_due') return 'warning'
|
||||
if (s === 'canceled' || s === 'cancelled') return 'danger'
|
||||
if (s === 'incomplete' || s === 'incomplete_expired' || s === 'unpaid') return 'warning'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
function statusLabelPretty (st) {
|
||||
const s = String(st || '').toLowerCase()
|
||||
if (s === 'active') return 'Ativa'
|
||||
if (s === 'trialing') return 'Trial'
|
||||
if (s === 'past_due') return 'Pagamento pendente'
|
||||
if (s === 'canceled' || s === 'cancelled') return 'Cancelada'
|
||||
if (s === 'unpaid') return 'Não paga'
|
||||
if (s === 'incomplete') return 'Incompleta'
|
||||
if (s === 'incomplete_expired') return 'Incompleta (expirada)'
|
||||
return st || '-'
|
||||
}
|
||||
|
||||
function eventSeverity (t) {
|
||||
const k = String(t || '').toLowerCase()
|
||||
if (k === 'plan_changed') return 'info'
|
||||
if (k === 'canceled') return 'danger'
|
||||
if (k === 'reactivated') return 'success'
|
||||
if (k === 'created') return 'secondary'
|
||||
if (k === 'status_changed') return 'warning'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
function eventLabel (t) {
|
||||
const k = String(t || '').toLowerCase()
|
||||
if (k === 'plan_changed') return 'Plano alterado'
|
||||
if (k === 'canceled') return 'Cancelada'
|
||||
if (k === 'reactivated') return 'Reativada'
|
||||
if (k === 'created') return 'Criada'
|
||||
if (k === 'status_changed') return 'Status alterado'
|
||||
return t || '-'
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// helpers (plans / profiles)
|
||||
// -------------------------
|
||||
const planById = computed(() => {
|
||||
const m = new Map()
|
||||
for (const p of plans.value || []) m.set(String(p.id), p)
|
||||
return m
|
||||
})
|
||||
|
||||
function planKeyOrName (planId) {
|
||||
if (!planId) return '—'
|
||||
const p = planById.value.get(String(planId))
|
||||
return p?.key || p?.name || String(planId)
|
||||
}
|
||||
|
||||
const profileById = computed(() => {
|
||||
const m = new Map()
|
||||
for (const p of profiles.value || []) m.set(String(p.id), p)
|
||||
return m
|
||||
})
|
||||
|
||||
function displayUser (userId) {
|
||||
if (!userId) return '—'
|
||||
const p = profileById.value.get(String(userId))
|
||||
if (!p) return String(userId)
|
||||
|
||||
const name = p.nome || p.name || p.full_name || p.display_name || p.username || null
|
||||
const email = p.email || p.email_principal || p.user_email || null
|
||||
|
||||
if (name && email) return `${name} <${email}>`
|
||||
if (name) return name
|
||||
if (email) return email
|
||||
return String(userId)
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// computed (header info)
|
||||
// -------------------------
|
||||
const planName = computed(() => plan.value?.name || subscription.value?.plan_key || '-')
|
||||
const statusLabel = computed(() => subscription.value?.status || '-')
|
||||
const statusLabelPrettyComputed = computed(() => statusLabelPretty(subscription.value?.status))
|
||||
|
||||
const intervalLabel = computed(() => {
|
||||
const i = subscription.value?.interval
|
||||
if (i === 'month') return 'mês'
|
||||
if (i === 'year') return 'ano'
|
||||
return i || '-'
|
||||
})
|
||||
|
||||
const priceLabel = computed(() => {
|
||||
if (!price.value) return null
|
||||
return `${money(price.value.currency, price.value.amount_cents)} / ${intervalLabel.value}`
|
||||
})
|
||||
|
||||
const periodLabel = computed(() => {
|
||||
const s = subscription.value
|
||||
if (!s?.current_period_start || !s?.current_period_end) return '-'
|
||||
return `${fmtDate(s.current_period_start)} → ${fmtDate(s.current_period_end)}`
|
||||
})
|
||||
|
||||
const cancelHint = computed(() => {
|
||||
const s = subscription.value
|
||||
if (!s) return null
|
||||
if (s.cancel_at_period_end) {
|
||||
const end = s.current_period_end ? fmtDate(s.current_period_end) : null
|
||||
return end ? `Cancelamento no fim do período (${end}).` : 'Cancelamento no fim do período.'
|
||||
}
|
||||
if (s.canceled_at) return `Cancelada em ${fmtDate(s.canceled_at)}.`
|
||||
return null
|
||||
})
|
||||
|
||||
// -------------------------
|
||||
// ✅ agrupamento de features por módulo (prefixo)
|
||||
// -------------------------
|
||||
function moduleFromKey (key) {
|
||||
const k = String(key || '').trim()
|
||||
if (!k) return 'Outros'
|
||||
|
||||
// tenta por "."
|
||||
if (k.includes('.')) {
|
||||
const head = k.split('.')[0]
|
||||
return head || 'Outros'
|
||||
}
|
||||
|
||||
// tenta por "_"
|
||||
if (k.includes('_')) {
|
||||
const head = k.split('_')[0]
|
||||
return head || 'Outros'
|
||||
}
|
||||
|
||||
return 'Outros'
|
||||
}
|
||||
|
||||
function moduleLabel (m) {
|
||||
const s = String(m || '').trim()
|
||||
if (!s) return 'Outros'
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
|
||||
const groupedFeatures = computed(() => {
|
||||
const list = features.value || []
|
||||
const map = new Map()
|
||||
|
||||
for (const f of list) {
|
||||
const mod = moduleFromKey(f.key)
|
||||
if (!map.has(mod)) map.set(mod, [])
|
||||
map.get(mod).push(f)
|
||||
}
|
||||
|
||||
// ordena módulos e itens
|
||||
const modules = Array.from(map.keys()).sort((a, b) => {
|
||||
if (a === 'Outros') return 1
|
||||
if (b === 'Outros') return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
return modules.map(mod => {
|
||||
const items = map.get(mod) || []
|
||||
items.sort((a, b) => String(a.key || '').localeCompare(String(b.key || '')))
|
||||
return { module: mod, items }
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------
|
||||
// fetch
|
||||
// -------------------------
|
||||
async function fetchMeuPlanoClinic () {
|
||||
loading.value = true
|
||||
try {
|
||||
const tid = tenantId.value
|
||||
if (!tid) throw new Error('Tenant ativo não encontrado.')
|
||||
|
||||
// 1) assinatura do tenant (prioriza status "ativo" e afins; cai pro mais recente)
|
||||
// ✅ depois das mudanças: não assume só "active" (pode estar trialing/past_due etc.)
|
||||
const sRes = await supabase
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.eq('tenant_id', tid)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
|
||||
if (sRes.error) throw sRes.error
|
||||
|
||||
const list = sRes.data || []
|
||||
const priority = (st) => {
|
||||
const s = String(st || '').toLowerCase()
|
||||
if (s === 'active') return 1
|
||||
if (s === 'trialing') return 2
|
||||
if (s === 'past_due') return 3
|
||||
if (s === 'unpaid') return 4
|
||||
if (s === 'incomplete') return 5
|
||||
if (s === 'canceled' || s === 'cancelled') return 9
|
||||
return 8
|
||||
}
|
||||
|
||||
subscription.value = (list.slice().sort((a, b) => {
|
||||
const pa = priority(a?.status)
|
||||
const pb = priority(b?.status)
|
||||
if (pa !== pb) return pa - pb
|
||||
// empate: mais recente
|
||||
return new Date(b?.created_at || 0) - new Date(a?.created_at || 0)
|
||||
})[0]) || null
|
||||
|
||||
if (!subscription.value) {
|
||||
plan.value = null
|
||||
price.value = null
|
||||
features.value = []
|
||||
events.value = []
|
||||
plans.value = []
|
||||
profiles.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 2) plano (atual)
|
||||
if (subscription.value.plan_id) {
|
||||
const pRes = await supabase
|
||||
.from('plans')
|
||||
.select('id, key, name, description')
|
||||
.eq('id', subscription.value.plan_id)
|
||||
.maybeSingle()
|
||||
|
||||
if (pRes.error) throw pRes.error
|
||||
plan.value = pRes.data || null
|
||||
} else {
|
||||
plan.value = null
|
||||
}
|
||||
|
||||
// 3) preço vigente (intervalo atual)
|
||||
// ✅ robustez: tenta preço vigente por janela; se não achar, pega o último ativo do intervalo
|
||||
price.value = null
|
||||
if (subscription.value.plan_id && subscription.value.interval) {
|
||||
const nowIso = new Date().toISOString()
|
||||
|
||||
const ppRes = await supabase
|
||||
.from('plan_prices')
|
||||
.select('currency, interval, amount_cents, is_active, active_from, active_to')
|
||||
.eq('plan_id', subscription.value.plan_id)
|
||||
.eq('interval', subscription.value.interval)
|
||||
.eq('is_active', true)
|
||||
.lte('active_from', nowIso)
|
||||
.or(`active_to.is.null,active_to.gte.${nowIso}`)
|
||||
.order('active_from', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (ppRes.error) throw ppRes.error
|
||||
price.value = ppRes.data || null
|
||||
|
||||
if (!price.value) {
|
||||
const ppFallback = await supabase
|
||||
.from('plan_prices')
|
||||
.select('currency, interval, amount_cents, is_active, active_from, active_to')
|
||||
.eq('plan_id', subscription.value.plan_id)
|
||||
.eq('interval', subscription.value.interval)
|
||||
.eq('is_active', true)
|
||||
.order('active_from', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (ppFallback.error) throw ppFallback.error
|
||||
price.value = ppFallback.data || null
|
||||
}
|
||||
}
|
||||
|
||||
// 4) features do plano
|
||||
features.value = []
|
||||
if (subscription.value.plan_id) {
|
||||
const pfRes = await supabase
|
||||
.from('plan_features')
|
||||
.select('feature_id')
|
||||
.eq('plan_id', subscription.value.plan_id)
|
||||
|
||||
if (pfRes.error) throw pfRes.error
|
||||
|
||||
const featureIds = (pfRes.data || []).map(r => r.feature_id).filter(Boolean)
|
||||
if (featureIds.length) {
|
||||
const fRes = await supabase
|
||||
.from('features')
|
||||
.select('id, key, description, descricao')
|
||||
.in('id', featureIds)
|
||||
.order('key', { ascending: true })
|
||||
|
||||
if (fRes.error) throw fRes.error
|
||||
|
||||
features.value = (fRes.data || []).map(f => ({
|
||||
key: f.key,
|
||||
description: (f.descricao || f.description || '').trim()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// 5) histórico (50) — se existir subscription_id
|
||||
events.value = []
|
||||
if (subscription.value?.id) {
|
||||
const eRes = await supabase
|
||||
.from('subscription_events')
|
||||
.select('*')
|
||||
.eq('subscription_id', subscription.value.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
if (eRes.error) throw eRes.error
|
||||
events.value = eRes.data || []
|
||||
}
|
||||
|
||||
// ✅ 6) pré-carrega planos citados em (old/new) + plano atual
|
||||
const planIds = new Set()
|
||||
if (subscription.value?.plan_id) planIds.add(String(subscription.value.plan_id))
|
||||
|
||||
for (const ev of events.value) {
|
||||
if (ev?.old_plan_id) planIds.add(String(ev.old_plan_id))
|
||||
if (ev?.new_plan_id) planIds.add(String(ev.new_plan_id))
|
||||
}
|
||||
|
||||
if (planIds.size) {
|
||||
const { data: pAll, error: epAll } = await supabase
|
||||
.from('plans')
|
||||
.select('id,key,name')
|
||||
.in('id', Array.from(planIds))
|
||||
|
||||
plans.value = epAll ? [] : (pAll || [])
|
||||
} else {
|
||||
plans.value = []
|
||||
}
|
||||
|
||||
// ✅ 7) perfis (created_by)
|
||||
const userIds = new Set()
|
||||
for (const ev of events.value) {
|
||||
const by = String(ev.created_by || '').trim()
|
||||
if (by) userIds.add(by)
|
||||
}
|
||||
|
||||
if (userIds.size) {
|
||||
const { data: pr, error: epr } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.in('id', Array.from(userIds))
|
||||
|
||||
profiles.value = epr ? [] : (pr || [])
|
||||
} else {
|
||||
profiles.value = []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchMeuPlanoClinic)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 md:p-6">
|
||||
<Toast />
|
||||
|
||||
<!-- Topbar padrão -->
|
||||
<div class="mb-4 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 shadow-sm">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-2xl font-semibold leading-none">Meu plano</div>
|
||||
<small class="text-color-secondary mt-1">
|
||||
Plano da clínica (tenant) e recursos habilitados.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||
<Button
|
||||
label="Alterar plano"
|
||||
icon="pi pi-arrow-up-right"
|
||||
:loading="loading"
|
||||
@click="goUpgradeClinic"
|
||||
/>
|
||||
<Button
|
||||
label="Atualizar"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="loading"
|
||||
@click="fetchMeuPlanoClinic"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card resumo -->
|
||||
<Card class="rounded-[2rem] overflow-hidden">
|
||||
<template #content>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xl md:text-2xl font-semibold leading-tight">
|
||||
{{ planName }}
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-color-secondary mt-1">
|
||||
<span v-if="priceLabel">{{ priceLabel }}</span>
|
||||
<span v-else>Preço não encontrado para este intervalo.</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<Tag :value="statusLabelPrettyComputed" :severity="statusSeverity(subscription?.status)" />
|
||||
<Tag
|
||||
v-if="subscription?.cancel_at_period_end"
|
||||
severity="warning"
|
||||
value="Cancelamento agendado"
|
||||
rounded
|
||||
/>
|
||||
<Tag
|
||||
v-else-if="subscription"
|
||||
severity="success"
|
||||
value="Renovação automática"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-sm text-color-secondary">
|
||||
<b>Período:</b> {{ periodLabel }}
|
||||
</div>
|
||||
|
||||
<div v-if="cancelHint" class="mt-2 text-sm text-color-secondary">
|
||||
{{ cancelHint }}
|
||||
</div>
|
||||
|
||||
<div v-if="plan?.description" class="mt-3 text-sm opacity-80 max-w-3xl">
|
||||
{{ plan.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="subscription" class="flex flex-col items-end gap-2">
|
||||
<small class="text-color-secondary">subscription_id</small>
|
||||
<code class="text-xs opacity-80 break-all">
|
||||
{{ subscription.id }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!subscription" class="mt-4 rounded-2xl border border-[var(--surface-border)] p-4 text-sm text-color-secondary">
|
||||
Nenhuma assinatura encontrada para este tenant.
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Divider class="my-6" />
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- ✅ Features agrupadas -->
|
||||
<Card class="rounded-[2rem] overflow-hidden">
|
||||
<template #title>Seu plano inclui</template>
|
||||
<template #content>
|
||||
<div v-if="!subscription" class="text-color-secondary">
|
||||
Sem assinatura.
|
||||
</div>
|
||||
|
||||
<div v-else-if="!features.length" class="text-color-secondary">
|
||||
Nenhuma feature vinculada a este plano.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-5">
|
||||
<div
|
||||
v-for="g in groupedFeatures"
|
||||
:key="g.module"
|
||||
class="rounded-2xl border border-[var(--surface-border)] overflow-hidden"
|
||||
>
|
||||
<div class="px-4 py-3 bg-[var(--surface-50)] border-b border-[var(--surface-border)] flex items-center justify-between">
|
||||
<div class="font-semibold">
|
||||
{{ moduleLabel(g.module) }}
|
||||
</div>
|
||||
<Tag :value="`${g.items.length}`" severity="secondary" rounded />
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<ul class="m-0 p-0 list-none space-y-3">
|
||||
<li
|
||||
v-for="f in g.items"
|
||||
:key="f.key"
|
||||
class="rounded-2xl border border-[var(--surface-border)] p-3"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="pi pi-check mt-1 text-sm text-color-secondary"></i>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium break-words">{{ f.key }}</div>
|
||||
<div class="text-sm text-color-secondary mt-1" v-if="f.description">
|
||||
{{ f.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-color-secondary">
|
||||
Agrupamento automático por prefixo da key (ex.: <b>agenda.*</b>, <b>patients.*</b>, etc.).
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- ✅ Histórico auditável -->
|
||||
<Card class="rounded-[2rem] overflow-hidden">
|
||||
<template #title>Histórico</template>
|
||||
<template #content>
|
||||
<div v-if="!subscription" class="text-color-secondary">
|
||||
Sem histórico (não há assinatura).
|
||||
</div>
|
||||
|
||||
<div v-else-if="!events.length" class="text-color-secondary">
|
||||
Sem eventos registrados.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="ev in events"
|
||||
:key="ev.id"
|
||||
class="rounded-2xl border border-[var(--surface-border)] p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Tag
|
||||
:value="eventLabel(ev.event_type)"
|
||||
:severity="eventSeverity(ev.event_type)"
|
||||
rounded
|
||||
/>
|
||||
<span class="text-sm text-color-secondary">
|
||||
por <b>{{ displayUser(ev.created_by) }}</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- De → Para (quando existir) -->
|
||||
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mt-2 text-sm">
|
||||
<span class="text-color-secondary">Plano:</span>
|
||||
<span class="font-medium ml-2">{{ planKeyOrName(ev.old_plan_id) }}</span>
|
||||
<i class="pi pi-arrow-right text-color-secondary mx-2" />
|
||||
<span class="font-medium">{{ planKeyOrName(ev.new_plan_id) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ev.reason" class="mt-2 text-sm opacity-80">
|
||||
{{ ev.reason }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-color-secondary">
|
||||
{{ fmtDate(ev.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-color-secondary" v-if="ev.metadata">
|
||||
<pre class="m-0 whitespace-pre-wrap break-words">{{ prettyMeta(ev.metadata) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-color-secondary">
|
||||
Mostrando até 50 eventos (mais recentes).
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* (intencionalmente vazio) */
|
||||
</style>
|
||||
Reference in New Issue
Block a user