diff --git a/src/layout/melissa/MelissaConfiguracoes.vue b/src/layout/melissa/MelissaConfiguracoes.vue index 70b2a7d..57c7cde 100644 --- a/src/layout/melissa/MelissaConfiguracoes.vue +++ b/src/layout/melissa/MelissaConfiguracoes.vue @@ -39,7 +39,6 @@ const emit = defineEmits(['close']); // existir, senão a key como-é. const ROUTE_ALIASES = { aparencia: 'aparencia', - plano: 'cfg-plano', negocio: 'cfg-negocio', seguranca: 'cfg-seguranca', bloqueios: 'cfg-bloqueios' @@ -99,7 +98,7 @@ const COMPONENT_MAP = { 'cfg-auditoria': defineAsyncComponent(() => import('@/layout/configuracoes/AuditoriaPage.vue')), // Conta (páginas pessoais que vivem em /account/*) // 'cfg-perfil' removido — virou pagina nativa MelissaPerfil em /melissa/perfil - 'cfg-plano': defineAsyncComponent(() => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')), + // 'cfg-plano' removido — virou pagina nativa MelissaPlano em /melissa/plano 'cfg-negocio': defineAsyncComponent(() => import('@/views/pages/account/Negociopage.vue')), 'cfg-seguranca': defineAsyncComponent(() => import('@/views/pages/auth/SecurityPage.vue')) }; @@ -150,11 +149,11 @@ const grupos = [ { key: 'conta', label: 'Conta', - desc: 'Plano contratado, dados do negócio e segurança.', + desc: 'Dados do negócio e segurança.', icon: 'pi pi-user', items: [ // "Meu Perfil" virou pagina nativa em /melissa/perfil (MelissaPerfil) - { key: 'cfg-plano', label: 'Meu Plano', desc: 'Plano contratado, limites de uso e fatura.', icon: 'pi pi-credit-card' }, + // "Meu Plano" virou pagina nativa em /melissa/plano (MelissaPlano) { key: 'cfg-negocio', label: 'Meu Negócio', desc: 'Dados do negócio, faturamento e branding.', icon: 'pi pi-briefcase' }, { key: 'cfg-seguranca', label: 'Segurança', desc: 'Senha, dispositivos confiáveis e sessões ativas.', icon: 'pi pi-shield' } ] diff --git a/src/layout/melissa/MelissaLayout.vue b/src/layout/melissa/MelissaLayout.vue index da8388a..aba0e9f 100644 --- a/src/layout/melissa/MelissaLayout.vue +++ b/src/layout/melissa/MelissaLayout.vue @@ -34,6 +34,7 @@ import MelissaTags from './MelissaTags.vue'; import MelissaGrupos from './MelissaGrupos.vue'; import MelissaConfiguracoes from './MelissaConfiguracoes.vue'; import MelissaPerfil from './MelissaPerfil.vue'; +import MelissaPlano from './MelissaPlano.vue'; import MelissaEmbed from './MelissaEmbed.vue'; import MelissaCadastrosRecebidos from './MelissaCadastrosRecebidos.vue'; import MelissaAgendamentosRecebidos from './MelissaAgendamentosRecebidos.vue'; @@ -166,8 +167,9 @@ const SECOES = { aparencia: { label: 'Layout Melissa', icon: 'pi pi-palette', descricao: 'Aparência, plano de fundo, relógio e cronômetro do resumo.' }, // Pagina nativa do perfil (MelissaPerfil) — saiu do MelissaConfiguracoes perfil: { label: 'Meu Perfil', icon: 'pi pi-user', descricao: 'Identidade, contato, bio, redes — gamificacao no aside.' }, - // Atalhos de Conta restantes — todos montam o MelissaConfiguracoes com a seção embed pré-selecionada - plano: { label: 'Meu Plano', icon: 'pi pi-credit-card', descricao: 'Plano contratado, limites e fatura.' }, + // Pagina nativa do plano (MelissaPlano) — saiu do MelissaConfiguracoes + plano: { label: 'Meu Plano', icon: 'pi pi-credit-card', descricao: 'Assinatura, recursos liberados e historico de mudancas.' }, + // Atalhos de Conta restantes — montam o MelissaConfiguracoes com a seção embed pré-selecionada negocio: { label: 'Meu Negócio', icon: 'pi pi-briefcase', descricao: 'Dados do negócio, faturamento e branding.' }, seguranca: { label: 'Segurança', icon: 'pi pi-shield', descricao: 'Senha, dispositivos confiáveis e sessões.' }, // Onda 1 — pages embedadas via MelissaEmbed (1-coluna, hero glass) @@ -195,13 +197,13 @@ const MELISSA_NON_CONFIG_SLUGS = new Set([ 'tags', 'grupos', 'cadastros-recebidos', 'medicos', 'agendamentos-recebidos', 'link-externo', 'notificacoes', 'financeiro', 'financeiro-lancamentos', 'documentos', 'documentos-templates', 'relatorios', - 'perfil', + 'perfil', 'plano', ...MELISSA_EMBED_KEYS ]); // Aliases "bonitos" + INLINE_KEYS reconhecidos pelo MelissaConfiguracoes. // Mantidos sincronizados com o ROUTE_ALIASES/INLINE_KEYS de lá. const MELISSA_CONFIG_ALIASES = new Set([ - 'aparencia', 'plano', 'negocio', 'seguranca', 'bloqueios', + 'aparencia', 'negocio', 'seguranca', 'bloqueios', 'fundo', 'relogio', 'cronometro' ]); function isMelissaConfigRoute(slug) { @@ -2215,6 +2217,11 @@ function onKeydown(e) { @close="fecharSecao" /> + + +/* + * MelissaPlano — Pagina nativa Melissa pra "Meu Plano". + * + * Substitui o embed cfg-plano que vivia dentro do MelissaConfiguracoes. + * Layout 2-col (espelha MelissaPerfil): + * - COL 1 (sidebar) — Card "Plano atual" (nome + status + valor + + * periodo + ID) + Card "Resumo" (mini-stats: recursos, eventos, + * proxima renovacao) + Footer com CTAs (Alterar plano + Atualizar) + * - COL 2 (main) — Card "Seu plano inclui" (features agrupadas por + * modulo) + Card "Historico" (subscription_events com badges). + * + * Logica de fetch espelhada de TherapistMeuPlanoPage.vue (subscriptions + * + plans + plan_prices + plan_features + features + subscription_events + * + profiles). + */ +import { ref, computed, onMounted } from 'vue'; +import { useRouter } from 'vue-router'; +import { useToast } from 'primevue/usetoast'; +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; +// Tag/Skeleton: auto via PrimeVueResolver + +const emit = defineEmits(['close']); + +const router = useRouter(); +const toast = useToast(); +const tenantStore = useTenantStore(); + +// ── Breakpoints + drawer ─────────────────────────────────── +const drawerOpen = ref(false); +const isMobile = ref(false); +let _mqMobile = null; +function _onMqMobileChange(e) { + isMobile.value = e.matches; + if (!e.matches) drawerOpen.value = false; +} +function toggleDrawer() { drawerOpen.value = !drawerOpen.value; } +function fecharDrawer() { drawerOpen.value = false; } + +// ── Estado ───────────────────────────────────────────────── +const loading = ref(false); +const hasLoaded = 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([]); + +// ── 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 fmtDateShort(iso) { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return String(iso); + return d.toLocaleDateString('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 `${fmtDateShort(s.current_period_start)} → ${fmtDateShort(s.current_period_end)}`; +}); +const renewLabel = computed(() => { + const s = subscription.value; + if (!s?.current_period_end) return '—'; + return fmtDateShort(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 }; + }); +}); + +// ── Historico ────────────────────────────────────────────── +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=/melissa/plano'); +} + +// ── Fetch ────────────────────────────────────────────────── +async function fetchMeuPlano() { + 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) { + toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 }); + } finally { + loading.value = false; + hasLoaded.value = true; + } +} + +// ── Lifecycle ────────────────────────────────────────────── +onMounted(async () => { + if (typeof window !== 'undefined' && window.matchMedia) { + _mqMobile = window.matchMedia('(max-width: 1023px)'); + isMobile.value = _mqMobile.matches; + try { _mqMobile.addEventListener('change', _onMqMobileChange); } + catch { _mqMobile.addListener(_onMqMobileChange); } + } + await tenantStore.ensureLoaded(); + await fetchMeuPlano(); +}); + + + + +