637 lines
31 KiB
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>
|