Melissa: hub Configuracoes + Embed + 9 Pages novas + dialog blueprint dark
Sprints 04-29 + 04-30 acumuladas. - MelissaConfiguracoes: hub 2-col com 6 grupos (Layout/Conta/Agenda/ Financeiro/WhatsApp/Sistema), tudo embedado via MelissaEmbed. - MelissaEmbed: wrapper generico que injeta layout-variant=melissa e remove cromos pra reaproveitar Pages tradicionais. - 9 Melissa Pages novas: CadastrosRecebidos, Compromissos, Configuracoes, Conversas, Embed, Grupos, Medicos, Recorrencias, Tags. - Dialog blueprint atualizado: bg-gray-100 (hardcoded light) -> bg-[var(--surface-ground)] (tema-aware). 22 dialogs migrados em 9 arquivos. Anti-pattern documentado. - PatientsCadastroPage: bug fix dropdown Grupo (optionLabel nome->name), toggle vertical/abas com persist localStorage, sticky margin-top. - Surface picker no popover do MelissaLayout (8 swatches). - useTopbarPlanMenu, useMelissaWhatsapp, useMelissaPacientesAside novos. - Migration: status agenda remarcado/confirmado. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,14 @@ function normalizeEmail(raw) {
|
||||
return String(raw || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Mesma estratégia do useContactPhones: emails sem entidade ficam em
|
||||
// memória com id 'pending_*' até flushPending gravar tudo em lote.
|
||||
const PENDING_PREFIX = 'pending_';
|
||||
function isPending(id) { return typeof id === 'string' && id.startsWith(PENDING_PREFIX); }
|
||||
function genPendingId() {
|
||||
return `${PENDING_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function useContactEmails() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
@@ -77,12 +85,34 @@ export function useContactEmails() {
|
||||
}
|
||||
|
||||
async function addEmail(entityType, entityId, { contact_email_type_id, email, is_primary = false, notes = null }) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const clean = normalizeEmail(email);
|
||||
if (!tenantId || !entityType || !entityId) return { ok: false, error: 'invalid_context' };
|
||||
if (!contact_email_type_id) return { ok: false, error: 'Tipo obrigatório' };
|
||||
if (!clean || !EMAIL_RE.test(clean)) return { ok: false, error: 'Email inválido' };
|
||||
|
||||
// Modo pendente: entidade ainda não existe — mantém em memória até flushPending.
|
||||
if (!entityType || !entityId) {
|
||||
const wasFirst = emails.value.length === 0;
|
||||
if (is_primary || wasFirst) {
|
||||
emails.value.forEach((e) => { e.is_primary = false; });
|
||||
is_primary = true;
|
||||
}
|
||||
const maxPos = emails.value.reduce((m, e) => Math.max(m, e.position || 0), 0);
|
||||
const tempEmail = {
|
||||
id: genPendingId(),
|
||||
contact_email_type_id,
|
||||
email: clean,
|
||||
is_primary,
|
||||
notes,
|
||||
position: maxPos + 10,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
emails.value = [...emails.value, tempEmail];
|
||||
return { ok: true, email: tempEmail };
|
||||
}
|
||||
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (is_primary) {
|
||||
@@ -117,15 +147,28 @@ export function useContactEmails() {
|
||||
}
|
||||
|
||||
async function updateEmail(entityType, entityId, id, patch) {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.email !== undefined) {
|
||||
sanitized.email = normalizeEmail(sanitized.email);
|
||||
if (!sanitized.email || !EMAIL_RE.test(sanitized.email)) {
|
||||
return { ok: false, error: 'Email inválido' };
|
||||
}
|
||||
}
|
||||
|
||||
// Pending: muta no array local, sem DB
|
||||
if (isPending(id) || !entityType || !entityId) {
|
||||
const idx = emails.value.findIndex((e) => e.id === id);
|
||||
if (idx === -1) return { ok: false, error: 'not_found' };
|
||||
if (sanitized.is_primary === true) {
|
||||
emails.value.forEach((e, i) => { if (i !== idx) e.is_primary = false; });
|
||||
}
|
||||
emails.value[idx] = { ...emails.value[idx], ...sanitized };
|
||||
emails.value = [...emails.value];
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.email !== undefined) {
|
||||
sanitized.email = normalizeEmail(sanitized.email);
|
||||
if (!sanitized.email || !EMAIL_RE.test(sanitized.email)) {
|
||||
return { ok: false, error: 'Email inválido' };
|
||||
}
|
||||
}
|
||||
if (sanitized.is_primary === true) {
|
||||
await unsetOtherPrimaries(entityType, entityId, id);
|
||||
}
|
||||
@@ -141,6 +184,19 @@ export function useContactEmails() {
|
||||
}
|
||||
|
||||
async function removeEmail(entityType, entityId, id) {
|
||||
// Pending: tira do array + promove próximo a primary se necessário
|
||||
if (isPending(id) || !entityType || !entityId) {
|
||||
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
|
||||
emails.value = emails.value.filter((e) => e.id !== id);
|
||||
if (wasPrimary && emails.value.length > 0) {
|
||||
const remaining = [...emails.value].sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
emails.value = emails.value.map((e) =>
|
||||
e.id === remaining[0].id ? { ...e, is_primary: true } : e
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
|
||||
@@ -161,6 +217,36 @@ export function useContactEmails() {
|
||||
}
|
||||
}
|
||||
|
||||
// Grava em lote os emails que estavam em modo pendente.
|
||||
async function flushPending(entityType, entityId) {
|
||||
if (!entityType || !entityId) return { ok: false, error: 'missing_entity' };
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
||||
const pendingItems = emails.value.filter((e) => isPending(e.id));
|
||||
if (pendingItems.length === 0) return { ok: true, count: 0 };
|
||||
saving.value = true;
|
||||
try {
|
||||
const rows = pendingItems.map((e) => ({
|
||||
tenant_id: tenantId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
contact_email_type_id: e.contact_email_type_id,
|
||||
email: normalizeEmail(e.email),
|
||||
is_primary: !!e.is_primary,
|
||||
notes: e.notes || null,
|
||||
position: e.position
|
||||
}));
|
||||
const { error } = await supabase.from('contact_emails').insert(rows);
|
||||
if (error) throw error;
|
||||
await loadEmails(entityType, entityId);
|
||||
return { ok: true, count: rows.length };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'flush_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimary(entityType, entityId, id) {
|
||||
return updateEmail(entityType, entityId, id, { is_primary: true });
|
||||
}
|
||||
@@ -179,6 +265,7 @@ export function useContactEmails() {
|
||||
updateEmail,
|
||||
removeEmail,
|
||||
setPrimary,
|
||||
flushPending,
|
||||
typeBySlug,
|
||||
typeById
|
||||
};
|
||||
|
||||
@@ -17,6 +17,15 @@ function normalizeDigits(raw) {
|
||||
return String(raw || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
// Telefones em "modo pendente" (entidade ainda não existe no DB) usam ID
|
||||
// com este prefixo. Permite reusar o mesmo array `phones` na UI sem
|
||||
// sub-state e detectar quais precisam de INSERT no flushPending.
|
||||
const PENDING_PREFIX = 'pending_';
|
||||
function isPending(id) { return typeof id === 'string' && id.startsWith(PENDING_PREFIX); }
|
||||
function genPendingId() {
|
||||
return `${PENDING_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function useContactPhones() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
@@ -76,12 +85,37 @@ export function useContactPhones() {
|
||||
}
|
||||
|
||||
async function addPhone(entityType, entityId, { contact_type_id, number, is_primary = false, whatsapp_linked_at = null, notes = null }) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const digits = normalizeDigits(number);
|
||||
if (!tenantId || !entityType || !entityId) return { ok: false, error: 'invalid_context' };
|
||||
if (!contact_type_id) return { ok: false, error: 'Tipo de contato obrigatório' };
|
||||
if (!digits || digits.length < 8 || digits.length > 15) return { ok: false, error: 'Telefone inválido' };
|
||||
|
||||
// Modo pendente: entidade ainda não existe (ex: novo paciente sendo
|
||||
// cadastrado). Mantém em memória — flushPending grava tudo em lote
|
||||
// depois que a entidade for criada.
|
||||
if (!entityType || !entityId) {
|
||||
const wasFirst = phones.value.length === 0;
|
||||
if (is_primary || wasFirst) {
|
||||
phones.value.forEach((p) => { p.is_primary = false; });
|
||||
is_primary = true;
|
||||
}
|
||||
const maxPos = phones.value.reduce((m, p) => Math.max(m, p.position || 0), 0);
|
||||
const tempPhone = {
|
||||
id: genPendingId(),
|
||||
contact_type_id,
|
||||
number: digits,
|
||||
is_primary,
|
||||
whatsapp_linked_at,
|
||||
notes,
|
||||
position: maxPos + 10,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
phones.value = [...phones.value, tempPhone];
|
||||
return { ok: true, phone: tempPhone };
|
||||
}
|
||||
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
// Se marcou como primary, desmarca outros
|
||||
@@ -119,14 +153,26 @@ export function useContactPhones() {
|
||||
}
|
||||
|
||||
async function updatePhone(entityType, entityId, id, patch) {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.number !== undefined) sanitized.number = normalizeDigits(sanitized.number);
|
||||
if (sanitized.number && (sanitized.number.length < 8 || sanitized.number.length > 15)) {
|
||||
return { ok: false, error: 'Telefone inválido' };
|
||||
}
|
||||
|
||||
// Pending: muta no array local sem ir pro DB
|
||||
if (isPending(id) || !entityType || !entityId) {
|
||||
const idx = phones.value.findIndex((p) => p.id === id);
|
||||
if (idx === -1) return { ok: false, error: 'not_found' };
|
||||
if (sanitized.is_primary === true) {
|
||||
phones.value.forEach((p, i) => { if (i !== idx) p.is_primary = false; });
|
||||
}
|
||||
phones.value[idx] = { ...phones.value[idx], ...sanitized };
|
||||
phones.value = [...phones.value];
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.number !== undefined) sanitized.number = normalizeDigits(sanitized.number);
|
||||
if (sanitized.number && (sanitized.number.length < 8 || sanitized.number.length > 15)) {
|
||||
return { ok: false, error: 'Telefone inválido' };
|
||||
}
|
||||
|
||||
if (sanitized.is_primary === true) {
|
||||
await unsetOtherPrimaries(entityType, entityId, id);
|
||||
}
|
||||
@@ -146,6 +192,19 @@ export function useContactPhones() {
|
||||
}
|
||||
|
||||
async function removePhone(entityType, entityId, id) {
|
||||
// Pending: tira do array local + promove o próximo a primary se sumiu
|
||||
if (isPending(id) || !entityType || !entityId) {
|
||||
const wasPrimary = phones.value.find((p) => p.id === id)?.is_primary;
|
||||
phones.value = phones.value.filter((p) => p.id !== id);
|
||||
if (wasPrimary && phones.value.length > 0) {
|
||||
const remaining = [...phones.value].sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
phones.value = phones.value.map((p) =>
|
||||
p.id === remaining[0].id ? { ...p, is_primary: true } : p
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const wasPrimary = phones.value.find((p) => p.id === id)?.is_primary;
|
||||
@@ -171,6 +230,40 @@ export function useContactPhones() {
|
||||
}
|
||||
}
|
||||
|
||||
// Grava em lote os telefones que estavam em modo pendente. Chamado pelo
|
||||
// parent (ex: PatientsCadastroPage) logo depois de criar a entidade no DB.
|
||||
// Mantém ordem (position) e o flag is_primary do estado local.
|
||||
async function flushPending(entityType, entityId) {
|
||||
if (!entityType || !entityId) return { ok: false, error: 'missing_entity' };
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
||||
const pendingItems = phones.value.filter((p) => isPending(p.id));
|
||||
if (pendingItems.length === 0) return { ok: true, count: 0 };
|
||||
saving.value = true;
|
||||
try {
|
||||
const rows = pendingItems.map((p) => ({
|
||||
tenant_id: tenantId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
contact_type_id: p.contact_type_id,
|
||||
number: normalizeDigits(p.number),
|
||||
is_primary: !!p.is_primary,
|
||||
whatsapp_linked_at: p.whatsapp_linked_at || null,
|
||||
notes: p.notes || null,
|
||||
position: p.position
|
||||
}));
|
||||
const { error } = await supabase.from('contact_phones').insert(rows);
|
||||
if (error) throw error;
|
||||
// Recarrega do DB pra ter IDs reais — substitui os pending_* por uuids.
|
||||
await loadPhones(entityType, entityId);
|
||||
return { ok: true, count: rows.length };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'flush_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimary(entityType, entityId, id) {
|
||||
return updatePhone(entityType, entityId, id, { is_primary: true });
|
||||
}
|
||||
@@ -193,6 +286,7 @@ export function useContactPhones() {
|
||||
updatePhone,
|
||||
removePhone,
|
||||
setPrimary,
|
||||
flushPending,
|
||||
typeBySlug,
|
||||
typeById
|
||||
};
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* useTopbarPlanMenu — DEV-only switcher de subscription_plan no topbar.
|
||||
*
|
||||
* Extraído do AppTopbar.vue pra ser reusado pelo Melissa (e qualquer
|
||||
* topbar futuro). Encapsula toda a máquina de estados:
|
||||
* - resolve subscription ativa do contexto (clinic vs therapist)
|
||||
* - lista plans ativos do target
|
||||
* - ordena (free primeiro)
|
||||
* - troca via RPC `change_subscription_plan` + invalida entitlements
|
||||
*
|
||||
* Visibilidade controlada por `showPlanDevMenu` (DEV mode + feature flag
|
||||
* `VITE_ENABLE_PLAN_TOGGLE` + permissão settings.view).
|
||||
*
|
||||
* Uso:
|
||||
* const { planBtn, planMenu, planMenuModel, planMenuLoading,
|
||||
* trocandoPlano, showPlanDevMenu, openPlanMenu } = useTopbarPlanMenu();
|
||||
*
|
||||
* <Button v-if="showPlanDevMenu" ref="planBtn" :loading="planMenuLoading || trocandoPlano" @click="openPlanMenu">…</Button>
|
||||
* <Menu ref="planMenu" :model="planMenuModel" popup appendTo="body" />
|
||||
*/
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore';
|
||||
import { useRoleGuard } from '@/composables/useRoleGuard';
|
||||
|
||||
export function useTopbarPlanMenu() {
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
const entitlementsStore = useEntitlementsStore();
|
||||
const { canSee } = useRoleGuard();
|
||||
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || null);
|
||||
|
||||
const planBtn = ref(null);
|
||||
const planMenu = ref(null);
|
||||
const planMenuLoading = ref(false);
|
||||
const planMenuTarget = ref(null); // 'therapist' | 'clinic' | null
|
||||
const planMenuSub = ref(null); // subscription ativa
|
||||
const planMenuPlans = ref([]); // plans ativos do target
|
||||
const trocandoPlano = ref(false);
|
||||
|
||||
const enablePlanToggle = computed(() => {
|
||||
const flag = String(import.meta.env?.VITE_ENABLE_PLAN_TOGGLE || '').toLowerCase();
|
||||
return Boolean(import.meta.env?.DEV) || flag === 'true';
|
||||
});
|
||||
|
||||
const showPlanDevMenu = computed(() => canSee('settings.view') && enablePlanToggle.value);
|
||||
|
||||
async function getMyUserId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Sessão inválida (sem user).');
|
||||
return uid;
|
||||
}
|
||||
|
||||
async function getActiveTherapistSubscription() {
|
||||
const uid = await getMyUserId();
|
||||
const { data, error } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, tenant_id, user_id, plan_id, status, updated_at')
|
||||
.eq('user_id', uid)
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(10);
|
||||
if (error) throw error;
|
||||
const list = data || [];
|
||||
if (!list.length) return null;
|
||||
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;
|
||||
};
|
||||
return list.slice().sort((a, b) => {
|
||||
const pa = priority(a?.status);
|
||||
const pb = priority(b?.status);
|
||||
if (pa !== pb) return pa - pb;
|
||||
return new Date(b?.updated_at || 0) - new Date(a?.updated_at || 0);
|
||||
})[0];
|
||||
}
|
||||
|
||||
async function getActiveClinicSubscription() {
|
||||
const tid = tenantId.value;
|
||||
if (!tid) return null;
|
||||
const { data, error } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, tenant_id, user_id, plan_id, status, updated_at')
|
||||
.eq('tenant_id', tid)
|
||||
.eq('status', 'active')
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
}
|
||||
|
||||
async function listActivePlansByTarget(target) {
|
||||
const { data, error } = await supabase
|
||||
.from('plans')
|
||||
.select('id, key, target, is_active')
|
||||
.eq('target', target)
|
||||
.eq('is_active', true)
|
||||
.order('key', { ascending: true });
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
async function refreshEntitlementsAfterToggle(target) {
|
||||
if (target === 'clinic') {
|
||||
const tid = tenantId.value;
|
||||
if (!tid) return;
|
||||
await entitlementsStore.loadForTenant(tid, { force: true });
|
||||
return;
|
||||
}
|
||||
const uid = await getMyUserId();
|
||||
await entitlementsStore.loadForUser(uid, { force: true });
|
||||
}
|
||||
|
||||
// Áreas de clínica (/admin, /supervisor) → contexto tenant_id (clinic).
|
||||
// Demais áreas (/therapist, /editor, /portal, etc.) → contexto user_id.
|
||||
// Em /melissa não há um caminho semântico de área, então cai no fallback
|
||||
// therapist (que é o role mais comum do user que escolhe Melissa).
|
||||
async function resolveActiveSubscriptionContext() {
|
||||
const path = route.path || '';
|
||||
const isClinicContext = path.startsWith('/admin') || path.startsWith('/supervisor');
|
||||
|
||||
if (isClinicContext && tenantId.value) {
|
||||
const clinicSub = await getActiveClinicSubscription();
|
||||
if (clinicSub) return { sub: clinicSub, target: 'clinic' };
|
||||
}
|
||||
|
||||
const therapistSub = await getActiveTherapistSubscription();
|
||||
if (therapistSub) return { sub: therapistSub, target: 'therapist' };
|
||||
|
||||
if (tenantId.value) {
|
||||
const clinicSub = await getActiveClinicSubscription();
|
||||
return { sub: clinicSub || null, target: clinicSub ? 'clinic' : null };
|
||||
}
|
||||
return { sub: null, target: null };
|
||||
}
|
||||
|
||||
function normalizeKey(k) {
|
||||
return String(k || '').trim();
|
||||
}
|
||||
|
||||
// free primeiro, depois o resto por key
|
||||
function sortPlansSmart(plans) {
|
||||
const arr = [...(plans || [])];
|
||||
arr.sort((a, b) => {
|
||||
const ak = normalizeKey(a?.key).toLowerCase();
|
||||
const bk = normalizeKey(b?.key).toLowerCase();
|
||||
const aIsFree = ak.endsWith('_free') || ak === 'free';
|
||||
const bIsFree = bk.endsWith('_free') || bk === 'free';
|
||||
if (aIsFree && !bIsFree) return -1;
|
||||
if (!aIsFree && bIsFree) return 1;
|
||||
return ak.localeCompare(bk);
|
||||
});
|
||||
return arr;
|
||||
}
|
||||
|
||||
async function loadPlanMenuData() {
|
||||
planMenuLoading.value = true;
|
||||
try {
|
||||
const { sub, target } = await resolveActiveSubscriptionContext();
|
||||
planMenuSub.value = sub;
|
||||
planMenuTarget.value = target;
|
||||
if (!sub?.id || !target) {
|
||||
planMenuPlans.value = [];
|
||||
return;
|
||||
}
|
||||
const plans = await listActivePlansByTarget(target);
|
||||
planMenuPlans.value = sortPlansSmart(plans);
|
||||
} finally {
|
||||
planMenuLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const planMenuModel = computed(() => {
|
||||
const sub = planMenuSub.value;
|
||||
const target = planMenuTarget.value;
|
||||
const plans = planMenuPlans.value || [];
|
||||
|
||||
if (!sub?.id || !target) {
|
||||
return [
|
||||
{ label: 'Sem assinatura ativa', icon: 'pi pi-exclamation-triangle', disabled: true },
|
||||
{ label: 'Não encontrei subscription ativa nem para therapist (user_id) nem para clinic (tenant_id).', disabled: true }
|
||||
];
|
||||
}
|
||||
|
||||
const currentPlanId = String(sub.plan_id || '');
|
||||
|
||||
const header = {
|
||||
label: `Planos (${target})`,
|
||||
icon: target === 'therapist' ? 'pi pi-user' : 'pi pi-building',
|
||||
disabled: true
|
||||
};
|
||||
|
||||
const subInfo = {
|
||||
label: `Sub: ${String(sub.id).slice(0, 8)}… • Atual: ${String(currentPlanId).slice(0, 8)}…`,
|
||||
icon: 'pi pi-info-circle',
|
||||
disabled: true
|
||||
};
|
||||
|
||||
const items = [];
|
||||
let insertedSeparator = false;
|
||||
|
||||
plans.forEach((p) => {
|
||||
const isCurrent = String(p.id) === currentPlanId;
|
||||
const keyLower = String(p.key || '').toLowerCase();
|
||||
const isFree = keyLower.endsWith('_free') || keyLower === 'free';
|
||||
|
||||
items.push({
|
||||
label: isCurrent ? `${p.key} (atual)` : p.key,
|
||||
icon: isCurrent ? 'pi pi-check' : isFree ? 'pi pi-star' : 'pi pi-circle',
|
||||
disabled: isCurrent || planMenuLoading.value || trocandoPlano.value,
|
||||
command: async () => {
|
||||
await changePlanTo(p.id, p.key, target);
|
||||
}
|
||||
});
|
||||
|
||||
if (!insertedSeparator && isFree) {
|
||||
items.push({ separator: true });
|
||||
insertedSeparator = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (items.length && items[items.length - 1]?.separator) items.pop();
|
||||
|
||||
if (!plans.length) {
|
||||
return [header, subInfo, { separator: true }, { label: 'Nenhum plano ativo encontrado', icon: 'pi pi-info-circle', disabled: true }];
|
||||
}
|
||||
|
||||
return [header, subInfo, { separator: true }, ...items];
|
||||
});
|
||||
|
||||
async function openPlanMenu(event) {
|
||||
if (!showPlanDevMenu.value) return;
|
||||
|
||||
// Captura a âncora ANTES do await — `event.currentTarget` é null
|
||||
// depois que a microtask resume (DOM behavior). Suporta tanto
|
||||
// PrimeVue <Button> (expõe `$el`) quanto <button> HTML cru
|
||||
// (planBtn.value já é o DOM element).
|
||||
const anchorEl =
|
||||
planBtn.value?.$el ||
|
||||
planBtn.value ||
|
||||
event?.currentTarget ||
|
||||
event?.target;
|
||||
|
||||
try {
|
||||
await loadPlanMenuData();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[PLANO][DEV menu] erro:', err?.message || err);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro ao carregar planos',
|
||||
detail: err?.message || 'Falha desconhecida.',
|
||||
life: 5200
|
||||
});
|
||||
}
|
||||
|
||||
if (!anchorEl) {
|
||||
planMenu.value?.toggle?.(event);
|
||||
return;
|
||||
}
|
||||
planMenu.value?.show?.({ currentTarget: anchorEl });
|
||||
}
|
||||
|
||||
async function changePlanTo(newPlanId, newPlanKey, target) {
|
||||
if (trocandoPlano.value) return;
|
||||
trocandoPlano.value = true;
|
||||
try {
|
||||
const sub = planMenuSub.value;
|
||||
if (!sub?.id) throw new Error('Subscription inválida.');
|
||||
const { error: rpcError } = await supabase.rpc('change_subscription_plan', {
|
||||
p_subscription_id: sub.id,
|
||||
p_new_plan_id: newPlanId
|
||||
});
|
||||
if (rpcError) throw rpcError;
|
||||
planMenuSub.value = { ...sub, plan_id: newPlanId };
|
||||
await refreshEntitlementsAfterToggle(target);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Plano alterado (DEV)',
|
||||
detail: `${String(newPlanKey).toUpperCase()} aplicado (${target})`,
|
||||
life: 3200
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[PLANO] Erro ao trocar:', err?.message || err);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro ao trocar plano',
|
||||
detail: err?.message || 'Falha desconhecida.',
|
||||
life: 6000
|
||||
});
|
||||
} finally {
|
||||
trocandoPlano.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
planBtn,
|
||||
planMenu,
|
||||
planMenuModel,
|
||||
planMenuLoading,
|
||||
trocandoPlano,
|
||||
showPlanDevMenu,
|
||||
openPlanMenu
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user