Files
agenciapsilmno/src/views/pages/billing/ClinicMeuPlanoPage.vue
T
Leonardo 2644e60bb6 CRM WhatsApp Grupo 3 completo + Marco A/B (Asaas) + admin SaaS + refactors polimórficos
Sessão 11+: fechamento do CRM de WhatsApp com dois providers (Evolution/Twilio),
sistema de créditos com Asaas/PIX, polimorfismo de telefones/emails, e integração
admin SaaS no /saas/addons existente.

═══════════════════════════════════════════════════════════════════════════
GRUPO 3 — WORKFLOW / CRM (completo)
═══════════════════════════════════════════════════════════════════════════

3.1 Tags · migration conversation_tags + seed de 5 system tags · composable
useConversationTags.js · popover + pills no drawer e nos cards do Kanban.

3.2 Atribuição de conversa a terapeuta · migration 20260421000012 com PK
(tenant_id, thread_key), UPSERT, RLS que valida assignee como membro ativo
do mesmo tenant · view conversation_threads expandida com assigned_to +
assigned_at · composable useConversationAssignment.js · drawer com Select
filtrável + botão "Assumir" · inbox com filtro aside (Todas/Minhas/Não
atribuídas) e chip do responsável em cada card (destaca "Eu" em azul).

3.3 Notas internas · migration conversation_notes · composable + seção
colapsável no drawer · apenas o criador pode editar/apagar (RLS).

3.5 Converter desconhecido em paciente · botão + dialog quick-cadastro ·
"Vincular existente" com Select filter de até 500 pacientes · cria
telefone WhatsApp (vinculado) via upsertWhatsappForExisting.

3.6 Histórico de conversa no prontuário · nova aba "Conversas" em
PatientProntuario.vue · PatientConversationsTab.vue com stats (total /
recebidas / enviadas / primeira / última), SelectButton de filtro, timeline
com bolhas por direção, mídia inline (imagem/áudio/vídeo/doc via signed
URL), indicadores ✓ ✓✓ de delivery, botão "Abrir no CRM".

═══════════════════════════════════════════════════════════════════════════
MARCO A — UNIFICAÇÃO WHATSAPP (dois providers mutuamente exclusivos)
═══════════════════════════════════════════════════════════════════════════

- Página chooser ConfiguracoesWhatsappChooserPage.vue com 2 cards (Pessoal/
  Oficial), deactivate via edge function deactivate-notification-channel
- send-whatsapp-message refatorada com roteamento por provider; Twilio deduz
  1 crédito antes do envio e refunda em falha
- Paridade Twilio (novo): módulo compartilhado supabase/functions/_shared/
  whatsapp-hooks.ts com lógica provider-agnóstica (opt-in, opt-out, auto-
  reply, schedule helpers em TZ São Paulo, makeTwilioCreditedSendFn que
  envolve envio em dedução atômica + rollback). Consumido por Evolution E
  Twilio inbound. Evolution refatorado (~290 linhas duplicadas removidas).
- Bucket privado whatsapp-media · decrypt via Evolution getBase64From
  MediaMessage · upload com path tenant/yyyy/mm · signed URLs on-demand

═══════════════════════════════════════════════════════════════════════════
MARCO B — SISTEMA DE CRÉDITOS WHATSAPP + ASAAS
═══════════════════════════════════════════════════════════════════════════

Banco:
- Migration 20260421000007_whatsapp_credits (4 tabelas: balance,
  transactions, packages, purchases) + RPCs add_whatsapp_credits e
  deduct_whatsapp_credits (atômicas com SELECT FOR UPDATE)
- Migration 20260421000013_tenant_cpf_cnpj (coluna em tenants com CHECK
  de 11 ou 14 dígitos)

Edge functions:
- create-whatsapp-credit-charge · Asaas v3 (sandbox + prod) · PIX com
  QR code · getOrCreateAsaasCustomer patcha customer existente com CPF
  quando está faltando
- asaas-webhook · recebe PAYMENT_RECEIVED/CONFIRMED e credita balance

Frontend (tenant):
- Página /configuracoes/creditos-whatsapp com saldo + loja + histórico
- Dialog de confirmação com CPF/CNPJ (validação via isValidCPF/CNPJ de
  utils/validators, formatação on-blur, pré-fill de tenants.cpf_cnpj,
  persiste no primeiro uso) · fallback sandbox 24971563792 REMOVIDO
- Composable useWhatsappCredits extrai erros amigáveis via
  error.context.json()

Frontend (SaaS admin):
- Em /saas/addons (reuso do pattern existente, não criou página paralela):
  - Aba 4 "Pacotes WhatsApp" — CRUD whatsapp_credit_packages com DataTable,
    toggle is_active inline, dialog de edição com validação
  - Aba 5 "Topup WhatsApp" — tenant Select com saldo ao vivo · RPC
    add_whatsapp_credits com p_admin_id = auth.uid() (auditoria) · histórico
    das últimas 20 transações topup/adjustment/refund

═══════════════════════════════════════════════════════════════════════════
GRUPO 2 — AUTOMAÇÃO
═══════════════════════════════════════════════════════════════════════════

2.3 Auto-reply · conversation_autoreply_settings + conversation_autoreply_
log · 3 modos de schedule (agenda das regras semanais, business_hours
custom, custom_window) · cooldown por thread · respeita opt-out · agora
funciona em Evolution E Twilio (hooks compartilhados)

2.4 Lembretes de sessão · conversation_session_reminders_settings +
_logs · edge send-session-reminders (cron) · janelas 24h e 2h antes ·
Twilio deduz crédito com rollback em falha

═══════════════════════════════════════════════════════════════════════════
GRUPO 5 — COMPLIANCE (LGPD Art. 18 §2)
═══════════════════════════════════════════════════════════════════════════

5.2 Opt-out · conversation_optouts + conversation_optout_keywords (10 system
seed + custom por tenant) · detecção por regex word-boundary e normalização
(lowercase + strip acentos + pontuação) · ack automático (deduz crédito em
Twilio) · opt-in via "voltar", "retornar", "reativar", "restart" ·
página /configuracoes/conversas-optouts com CRUD de keywords

═══════════════════════════════════════════════════════════════════════════
REFACTOR POLIMÓRFICO — TELEFONES + EMAILS
═══════════════════════════════════════════════════════════════════════════

- contact_types + contact_phones (entity_type + entity_id) — migration
  20260421000008 · contact_email_types + contact_emails — 20260421000011
- Componentes ContactPhonesEditor.vue e ContactEmailsEditor.vue (add/edit/
  remove com confirm, primary selector, WhatsApp linked badge)
- Composables useContactPhones.js + useContactEmails.js com
  unsetOtherPrimaries() e validação
- Trocado em PatientsCadastroPage.vue e MedicosPage.vue (removidos campos
  legados telefone/telefone_alternativo e email_principal/email_alternativo)
- Migration retroativa v2 (20260421000010) detecta conversation_messages
  e cria/atualiza phone como WhatsApp vinculado

═══════════════════════════════════════════════════════════════════════════
POLIMENTO VISUAL + INFRA
═══════════════════════════════════════════════════════════════════════════

- Skeletons simplificados no dashboard do terapeuta
- Animações fade-up com stagger via [--delay:Xms] (fix specificity sobre
  .dash-card box-shadow transition)
- ConfirmDialog com group="conversation-drawer" (evita montagem duplicada)
- Image preview PrimeVue com botão de download injetado via MutationObserver
  (fetch + blob para funcionar cross-origin)
- Áudio/vídeo com preload="metadata" e controles de velocidade do browser
- friendlySendError() mapeia códigos do edge pra mensagens pt-BR via
  error.context.json()
- Teleport #cfg-page-actions para ações globais de Configurações
- Brotli/Gzip + auto-import Vue/PrimeVue + bundle analyzer
- AppLayout consolidado (removidas duplicatas por área) + RouterPassthrough
- Removido console.trace debug que estava em watch de router e queries
  Supabase (degradava perf pra todos)
- Realtime em conversation_messages via publication supabase_realtime
- Notifier global flutuante com beep + toggle mute (4 camadas: badge +
  sino + popup + browser notification)

═══════════════════════════════════════════════════════════════════════════
MIGRATIONS NOVAS (13)
═══════════════════════════════════════════════════════════════════════════

20260420000001_patient_intake_invite_info_rpc
20260420000002_audit_logs_lgpd
20260420000003_audit_logs_unified_view
20260420000004_lgpd_export_patient_rpc
20260420000005_conversation_messages
20260420000005_search_global_rpc
20260420000006_conv_messages_notifications
20260420000007_notif_channels_saas_admin_insert
20260420000008_conv_messages_realtime
20260420000009_conv_messages_delivery_status
20260421000001_whatsapp_media_bucket
20260421000002_conversation_notes
20260421000003_conversation_tags
20260421000004_conversation_autoreply
20260421000005_conversation_optouts
20260421000006_session_reminders
20260421000007_whatsapp_credits
20260421000008_contact_phones
20260421000009_retroactive_whatsapp_link
20260421000010_retroactive_whatsapp_link_v2
20260421000011_contact_emails
20260421000012_conversation_assignments
20260421000013_tenant_cpf_cnpj

═══════════════════════════════════════════════════════════════════════════
EDGE FUNCTIONS NOVAS / MODIFICADAS
═══════════════════════════════════════════════════════════════════════════

Novas:
- _shared/whatsapp-hooks.ts (módulo compartilhado)
- asaas-webhook
- create-whatsapp-credit-charge
- deactivate-notification-channel
- evolution-webhook-provision
- evolution-whatsapp-inbound
- get-intake-invite-info
- notification-webhook
- send-session-reminders
- send-whatsapp-message
- submit-patient-intake
- twilio-whatsapp-inbound

═══════════════════════════════════════════════════════════════════════════
FRONTEND — RESUMO
═══════════════════════════════════════════════════════════════════════════

Composables novos: useAddonExtrato, useAuditoria, useAutoReplySettings,
useClinicKPIs, useContactEmails, useContactPhones, useConversationAssignment,
useConversationNotes, useConversationOptouts, useConversationTags,
useConversations, useLgpdExport, useSessionReminders, useWhatsappCredits

Stores: conversationDrawerStore

Componentes novos: ConversationDrawer, GlobalInboundNotifier, GlobalSearch,
ContactEmailsEditor, ContactPhonesEditor

Páginas novas: CRMConversasPage, PatientConversationsTab, AddonsExtratoPage,
AuditoriaPage, NotificationsHistoryPage, ConfiguracoesWhatsappChooserPage,
ConfiguracoesConversasAutoreplyPage, ConfiguracoesConversasOptoutsPage,
ConfiguracoesConversasTagsPage, ConfiguracoesCreditosWhatsappPage,
ConfiguracoesLembretesSessaoPage

Utils novos: addonExtratoExport, auditoriaExport, excelExport,
lgpdExportFormats

Páginas existentes alteradas: ClinicDashboard, PatientsCadastroPage,
PatientCadastroDialog, PatientsListPage, MedicosPage, PatientProntuario,
ConfiguracoesWhatsappPage, SaasWhatsappPage, ConfiguracoesRecursosExtrasPage,
ConfiguracoesPage, AgendaTerapeutaPage, AgendaClinicaPage, NotificationItem,
NotificationDrawer, AppLayout, AppTopbar, useMenuBadges,
patientsRepository, SaasAddonsPage (aba 4 + 5 WhatsApp)

Routes: routes.clinic, routes.configs, routes.therapist atualizados
Menus: clinic.menu, therapist.menu, saas.menu atualizados

═══════════════════════════════════════════════════════════════════════════
NOTAS

- Após subir, rodar supabase functions serve --no-verify-jwt
  --env-file supabase/functions/.env pra carregar o módulo _shared
- WHATSAPP_SETUP.md reescrito (~400 linhas) com setup completo dos 3
  providers + troubleshooting + LGPD
- HANDOFF.md atualizado com estado atual e próximos passos

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:05:24 -03:00

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 my-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>