Files
agenciapsilmno/src/views/pages/billing/ClinicMeuPlanoPage.vue

637 lines
31 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/views/pages/billing/ClinicMeuPlanoPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<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>
<!-- Sentinel -->
<div class="h-px" />
<!--
HERO sticky
-->
<section class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<div class="relative z-1 flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-credit-card text-base" />
</div>
<div class="min-w-0 hidden sm:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Meu Plano</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Plano da clínica (tenant) e recursos habilitados</div>
</div>
</div>
<!-- Ações desktop -->
<div class="hidden sm:flex items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full shrink-0" :loading="loading" title="Atualizar" @click="fetchMeuPlanoClinic" />
<Button label="Alterar plano" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgradeClinic" />
</div>
<!-- Ações mobile -->
<div class="flex sm:hidden items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full shrink-0" :loading="loading" @click="fetchMeuPlanoClinic" />
<Button label="Upgrade" icon="pi pi-arrow-up-right" size="small" class="rounded-full" @click="goUpgradeClinic" />
</div>
</div>
</section>
<!--
QUICK-STATS
-->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)] truncate">{{ planName }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Plano atual</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
:class="subscription?.status === 'active' ? 'border-green-500/25 bg-green-500/5' : 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]'"
>
<div class="text-[1.1rem] font-bold leading-none truncate" :class="subscription?.status === 'active' ? 'text-green-500' : 'text-[var(--text-color)]'">{{ statusLabelPrettyComputed }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Status</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ features.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Recursos</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ events.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Eventos</div>
</div>
</div>
<!--
CONTEÚDO
-->
<div class="px-3 md:px-4 pb-8">
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col gap-3">
<div v-for="n in 3" :key="n" class="flex items-center gap-4 p-4 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="w-10 h-10 rounded-full shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="flex flex-col gap-2 flex-1">
<div class="h-3.5 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="h-2.5 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
</div>
</div>
</div>
<!-- Empty state: sem assinatura -->
<div v-else-if="!subscription" class="flex flex-col items-center justify-center gap-4 rounded-md border-2 border-dashed border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] py-16 px-6 text-center">
<div class="relative">
<div class="grid place-items-center w-16 h-16 rounded-2xl bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm text-[var(--text-color-secondary)]">
<i class="pi pi-credit-card text-3xl opacity-30" />
</div>
<div class="absolute -top-2 -right-2 w-7 h-7 rounded-full bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm grid place-items-center">
<i class="pi pi-times text-[0.65rem] text-[var(--text-color-secondary)] opacity-50" />
</div>
</div>
<div>
<div class="font-bold text-[1rem] text-[var(--text-color)] mb-1">Nenhuma assinatura encontrada</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs leading-relaxed">Nenhuma assinatura foi encontrada para este tenant.</div>
</div>
<Button label="Ver planos" icon="pi pi-arrow-up-right" class="rounded-full mt-1" @click="goUpgradeClinic" />
</div>
<!-- Conteúdo com assinatura -->
<div v-else class="flex flex-col gap-3">
<!-- Assinatura atual -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-credit-card text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Assinatura atual</span>
</div>
<div class="flex items-center gap-2">
<Tag :value="statusLabelPrettyComputed" :severity="statusSeverity(subscription?.status)" />
<Tag v-if="subscription?.cancel_at_period_end" severity="warning" value="Cancelamento agendado" />
<Tag v-else severity="success" value="Renovação automática" />
</div>
</div>
<div class="px-4 py-3 flex flex-wrap gap-x-8 gap-y-3">
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Plano</span>
<span class="text-[0.9rem] font-semibold text-[var(--text-color)]">{{ planName }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Valor</span>
<span class="text-[1rem] text-[var(--text-color)]">{{ priceLabel || 'Preço não encontrado para este intervalo.' }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Período</span>
<span class="text-[1rem] text-[var(--text-color)]">{{ periodLabel }}</span>
</div>
<div v-if="cancelHint" class="flex flex-col gap-0.5 w-full">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Atenção</span>
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ cancelHint }}</span>
</div>
<div v-if="plan?.description" class="flex flex-col gap-0.5 w-full">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Descrição</span>
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ plan.description }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">ID da assinatura</span>
<code class="text-[0.75rem] text-[var(--text-color-secondary)] break-all font-mono select-all">{{ subscription.id }}</code>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<!-- Features agrupadas -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-check-circle text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Seu plano inclui</span>
</div>
<span v-if="features.length" class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[1rem] font-bold">{{ features.length }}</span>
</div>
<div class="p-4">
<div v-if="!features.length" class="text-[1rem] text-[var(--text-color-secondary)]">Nenhuma feature vinculada a este plano.</div>
<div v-else class="flex flex-col gap-5">
<div v-for="g in groupedFeatures" :key="g.module">
<!-- Cabeçalho do módulo -->
<div class="flex items-center gap-2 mb-2">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.07em] text-[var(--text-color-secondary)] opacity-50">{{ moduleLabel(g.module) }}</span>
<div class="flex-1 h-px bg-[var(--surface-border,#e2e8f0)]" />
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ g.items.length }}</span>
</div>
<!-- Grid de features -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
<div v-for="f in g.items" :key="f.key" class="flex items-start gap-2 py-1 px-2 rounded-md hover:bg-[var(--surface-ground,#f8fafc)] transition-colors" :title="f.description || f.key">
<i class="pi pi-check-circle text-emerald-500 text-[1rem] mt-0.5 shrink-0" />
<div class="min-w-0">
<div class="text-[1rem] font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
<div v-if="f.description" class="text-[1rem] text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
</div>
</div>
</div>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60">Agrupamento automático por prefixo da key (ex.: <b>agenda.*</b>, <b>patients.*</b>).</div>
</div>
</div>
</div>
<!-- Histórico -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-history text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Histórico</span>
</div>
<span v-if="events.length" class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[1rem] font-bold">{{ events.length }}</span>
</div>
<div class="p-4">
<div v-if="!events.length" class="py-8 text-center">
<i class="pi pi-history text-2xl text-[var(--text-color-secondary)] opacity-20 mb-2 block" />
<div class="text-[1rem] text-[var(--text-color-secondary)]">Sem eventos registrados.</div>
</div>
<div v-else class="flex flex-col gap-2">
<div v-for="ev in events" :key="ev.id" class="rounded-md border border-[var(--surface-border,#e2e8f0)] p-3 hover:bg-[var(--surface-ground,#f8fafc)] transition-colors duration-100">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="eventLabel(ev.event_type)" :severity="eventSeverity(ev.event_type)" />
<span class="text-[1rem] text-[var(--text-color-secondary)]">
por <b>{{ displayUser(ev.created_by) }}</b>
</span>
</div>
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)] flex items-center gap-1.5 flex-wrap">
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.old_plan_id) }}</span>
<i class="pi pi-arrow-right text-[1rem] opacity-50" />
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.new_plan_id) }}</span>
</div>
<div v-if="ev.reason" class="mt-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70">{{ ev.reason }}</div>
<div v-if="ev.metadata" class="mt-1.5">
<pre class="m-0 text-[1rem] text-[var(--text-color-secondary)] whitespace-pre-wrap break-words opacity-60">{{ prettyMeta(ev.metadata) }}</pre>
</div>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] shrink-0">{{ fmtDate(ev.created_at) }}</div>
</div>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-1">Mostrando até 50 eventos (mais recentes).</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>