563 lines
26 KiB
Vue
563 lines
26 KiB
Vue
<!-- src/views/pages/billing/TherapistMeuPlanoPage.vue -->
|
|
<script setup>
|
|
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useToast } from 'primevue/usetoast'
|
|
import { supabase } from '@/lib/supabase/client'
|
|
|
|
const router = useRouter()
|
|
const toast = useToast()
|
|
|
|
const loading = ref(false)
|
|
const subscription = ref(null)
|
|
const plan = ref(null)
|
|
const price = ref(null)
|
|
const features = ref([])
|
|
const events = ref([])
|
|
const plans = ref([])
|
|
const profiles = ref([])
|
|
|
|
// ── Hero sticky ────────────────────────────────────────────
|
|
const headerEl = ref(null)
|
|
const headerSentinelRef = ref(null)
|
|
const headerStuck = ref(false)
|
|
let _observer = null
|
|
|
|
// ── Formatters ─────────────────────────────────────────────
|
|
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 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'
|
|
return 'secondary'
|
|
}
|
|
|
|
function statusLabel(st) {
|
|
const s = String(st || '').toLowerCase()
|
|
if (s === 'active') return 'Ativo'
|
|
if (s === 'trialing') return 'Trial'
|
|
if (s === 'past_due') return 'Atrasado'
|
|
if (s === 'canceled' || s === 'cancelled') return 'Cancelado'
|
|
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'
|
|
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'
|
|
return t || '—'
|
|
}
|
|
|
|
// ── Computed ────────────────────────────────────────────────
|
|
const planName = computed(() => plan.value?.name || subscription.value?.plan_key || '—')
|
|
|
|
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)}`
|
|
})
|
|
|
|
// ── Features agrupadas ──────────────────────────────────────
|
|
function moduleFromKey(key) {
|
|
const k = String(key || '').trim()
|
|
if (!k) return 'Outros'
|
|
if (k.includes('.')) return k.split('.')[0] || 'Outros'
|
|
if (k.includes('_')) return k.split('_')[0] || '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)
|
|
}
|
|
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 }
|
|
})
|
|
})
|
|
|
|
// ── Histórico ───────────────────────────────────────────────
|
|
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 || null
|
|
const email = p.email || p.email_principal || null
|
|
if (name && email) return `${name} <${email}>`
|
|
return name || email || String(userId)
|
|
}
|
|
|
|
// ── Actions ─────────────────────────────────────────────────
|
|
function goUpgrade() {
|
|
router.push('/therapist/upgrade?redirectTo=/therapist/meu-plano')
|
|
}
|
|
|
|
// ── Fetch ───────────────────────────────────────────────────
|
|
async function fetchMeuPlanoTherapist() {
|
|
loading.value = true
|
|
try {
|
|
const { data: authData, error: authError } = await supabase.auth.getUser()
|
|
if (authError) throw authError
|
|
const uid = authData?.user?.id
|
|
if (!uid) throw new Error('Sessão não encontrada.')
|
|
|
|
const sRes = await supabase
|
|
.from('subscriptions')
|
|
.select('*')
|
|
.eq('user_id', uid)
|
|
.order('created_at', { ascending: false })
|
|
.limit(10)
|
|
|
|
if (sRes.error) throw sRes.error
|
|
|
|
const subList = 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 = subList.length
|
|
? subList.slice().sort((a, b) => {
|
|
const pa = priority(a?.status)
|
|
const pb = priority(b?.status)
|
|
if (pa !== pb) return pa - pb
|
|
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
|
|
}
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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() }))
|
|
} else {
|
|
features.value = []
|
|
}
|
|
|
|
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 || []
|
|
|
|
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 = []
|
|
}
|
|
|
|
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(() => {
|
|
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
|
_observer = new IntersectionObserver(
|
|
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
|
{ threshold: 0, rootMargin }
|
|
)
|
|
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
|
fetchMeuPlanoTherapist()
|
|
})
|
|
|
|
onBeforeUnmount(() => { _observer?.disconnect() })
|
|
</script>
|
|
|
|
<template>
|
|
<Toast />
|
|
|
|
<!-- Sentinel -->
|
|
<div ref="headerSentinelRef" class="h-px" />
|
|
|
|
<!-- ══════════════════════════════════════════════════════
|
|
HERO sticky
|
|
═══════════════════════════════════════════════════════ -->
|
|
<section
|
|
ref="headerEl"
|
|
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 transition-[border-radius] duration-200"
|
|
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
|
|
: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 flex-shrink-0">
|
|
<div class="grid place-items-center w-9 h-9 rounded-md flex-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="flex items-center gap-2 flex-wrap">
|
|
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Meu Plano</div>
|
|
<Tag v-if="subscription" :value="statusLabel(subscription.status)" :severity="statusSeverity(subscription.status)" />
|
|
</div>
|
|
<div class="text-[1rem] text-[var(--text-color-secondary)]">Plano pessoal do terapeuta — gerencie sua assinatura</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ações desktop (≥ xl) -->
|
|
<div class="hidden xl:flex items-center gap-1 flex-shrink-0 ml-auto">
|
|
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchMeuPlanoTherapist" />
|
|
<Button label="Alterar plano" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgrade" />
|
|
</div>
|
|
|
|
<!-- Ações mobile (< xl) -->
|
|
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
|
|
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchMeuPlanoTherapist" />
|
|
<Button label="Alterar plano" icon="pi pi-arrow-up-right" size="small" class="rounded-full" @click="goUpgrade" />
|
|
</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)]'"
|
|
>{{ subscription ? statusLabel(subscription.status) : '—' }}</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 flex flex-col gap-3">
|
|
|
|
<!-- 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 flex-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>
|
|
|
|
<!-- 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">Escolha um plano para começar a usar todos os recursos.</div>
|
|
</div>
|
|
<Button label="Ver planos" icon="pi pi-arrow-up-right" class="rounded-full mt-1" @click="goUpgrade" />
|
|
</div>
|
|
|
|
<template v-else>
|
|
|
|
<!-- ── 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="statusLabel(subscription.status)" :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="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">
|
|
|
|
<!-- ── Seu plano inclui ───────────────────────── -->
|
|
<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">
|
|
<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>
|
|
<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 flex-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>
|
|
</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)] flex-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>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* (intencionalmente vazio) */
|
|
</style>
|