Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras
This commit is contained in:
+182
-184
@@ -14,202 +14,200 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { defineStore } from 'pinia';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
function normalizeKey (k) {
|
||||
return String(k || '').trim()
|
||||
function normalizeKey(k) {
|
||||
return String(k || '').trim();
|
||||
}
|
||||
|
||||
function uniqKeys (rows, field) {
|
||||
const list = []
|
||||
const seen = new Set()
|
||||
for (const r of (rows || [])) {
|
||||
const key = normalizeKey(r?.[field])
|
||||
if (!key) continue
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
list.push(key)
|
||||
}
|
||||
return list
|
||||
function uniqKeys(rows, field) {
|
||||
const list = [];
|
||||
const seen = new Set();
|
||||
for (const r of rows || []) {
|
||||
const key = normalizeKey(r?.[field]);
|
||||
if (!key) continue;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
list.push(key);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export const useEntitlementsStore = defineStore('entitlements', {
|
||||
state: () => ({
|
||||
// =========================
|
||||
// Tenant entitlements (B)
|
||||
// =========================
|
||||
tenantLoading: false,
|
||||
loadedForTenant: null,
|
||||
tenantFeatures: [],
|
||||
tenantRaw: [],
|
||||
tenantError: null,
|
||||
tenantLoadedAt: null,
|
||||
state: () => ({
|
||||
// =========================
|
||||
// Tenant entitlements (B)
|
||||
// =========================
|
||||
tenantLoading: false,
|
||||
loadedForTenant: null,
|
||||
tenantFeatures: [],
|
||||
tenantRaw: [],
|
||||
tenantError: null,
|
||||
tenantLoadedAt: null,
|
||||
|
||||
// =========================
|
||||
// User entitlements (A)
|
||||
// =========================
|
||||
userLoading: false,
|
||||
loadedForUser: null,
|
||||
userFeatures: [],
|
||||
userRaw: [],
|
||||
userError: null,
|
||||
userLoadedAt: null
|
||||
}),
|
||||
// =========================
|
||||
// User entitlements (A)
|
||||
// =========================
|
||||
userLoading: false,
|
||||
loadedForUser: null,
|
||||
userFeatures: [],
|
||||
userRaw: [],
|
||||
userError: null,
|
||||
userLoadedAt: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
/**
|
||||
* ✅ Sem scope: união de tenant + user entitlements.
|
||||
* Um terapeuta com plano pessoal (therapist_pro) tem features em userFeatures,
|
||||
* não em tenantFeatures — ambos devem ser verificados.
|
||||
*/
|
||||
has: (state) => (featureKey, scope = null) => {
|
||||
const key = normalizeKey(featureKey)
|
||||
if (!key) return false
|
||||
getters: {
|
||||
/**
|
||||
* ✅ Sem scope: união de tenant + user entitlements.
|
||||
* Um terapeuta com plano pessoal (therapist_pro) tem features em userFeatures,
|
||||
* não em tenantFeatures — ambos devem ser verificados.
|
||||
*/
|
||||
has:
|
||||
(state) =>
|
||||
(featureKey, scope = null) => {
|
||||
const key = normalizeKey(featureKey);
|
||||
if (!key) return false;
|
||||
|
||||
if (scope === 'tenant') return state.tenantFeatures.includes(key)
|
||||
if (scope === 'user') return state.userFeatures.includes(key)
|
||||
if (scope === 'tenant') return state.tenantFeatures.includes(key);
|
||||
if (scope === 'user') return state.userFeatures.includes(key);
|
||||
|
||||
// sem scope: true se qualquer uma das origens tiver a feature
|
||||
return state.tenantFeatures.includes(key) || state.userFeatures.includes(key)
|
||||
// sem scope: true se qualquer uma das origens tiver a feature
|
||||
return state.tenantFeatures.includes(key) || state.userFeatures.includes(key);
|
||||
},
|
||||
|
||||
can:
|
||||
(state) =>
|
||||
(featureKey, scope = null) => {
|
||||
const key = normalizeKey(featureKey);
|
||||
if (!key) return false;
|
||||
|
||||
if (scope === 'tenant') return state.tenantFeatures.includes(key);
|
||||
if (scope === 'user') return state.userFeatures.includes(key);
|
||||
|
||||
return state.tenantFeatures.includes(key) || state.userFeatures.includes(key);
|
||||
}
|
||||
},
|
||||
|
||||
can: (state) => (featureKey, scope = null) => {
|
||||
const key = normalizeKey(featureKey)
|
||||
if (!key) return false
|
||||
actions: {
|
||||
// =========================
|
||||
// Compat: fetch() continua existindo
|
||||
// =========================
|
||||
async fetch(tenantId, opts = {}) {
|
||||
return this.loadForTenant(tenantId, opts);
|
||||
},
|
||||
|
||||
if (scope === 'tenant') return state.tenantFeatures.includes(key)
|
||||
if (scope === 'user') return state.userFeatures.includes(key)
|
||||
clear() {
|
||||
return this.invalidate();
|
||||
},
|
||||
|
||||
return state.tenantFeatures.includes(key) || state.userFeatures.includes(key)
|
||||
// =========================
|
||||
// Tenant (clinic) — view v_tenant_entitlements
|
||||
// =========================
|
||||
async loadForTenant(tenantId, { force = false, maxAgeMs = 0 } = {}) {
|
||||
if (!tenantId) {
|
||||
this.invalidateTenant();
|
||||
return;
|
||||
}
|
||||
|
||||
const same = this.loadedForTenant === tenantId;
|
||||
const hasLoadedAt = typeof this.tenantLoadedAt === 'number';
|
||||
const isFresh = same && hasLoadedAt && maxAgeMs > 0 && Date.now() - this.tenantLoadedAt < maxAgeMs;
|
||||
|
||||
if (!force && same && (maxAgeMs === 0 || isFresh)) return;
|
||||
|
||||
this.tenantLoading = true;
|
||||
this.tenantError = null;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.from('v_tenant_entitlements').select('feature_key').eq('tenant_id', tenantId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const rows = data ?? [];
|
||||
this.tenantRaw = rows;
|
||||
this.tenantFeatures = uniqKeys(rows, 'feature_key');
|
||||
|
||||
this.loadedForTenant = tenantId;
|
||||
this.tenantLoadedAt = Date.now();
|
||||
} catch (e) {
|
||||
this.tenantError = e;
|
||||
this.tenantRaw = [];
|
||||
this.tenantFeatures = [];
|
||||
this.loadedForTenant = tenantId;
|
||||
this.tenantLoadedAt = Date.now();
|
||||
} finally {
|
||||
this.tenantLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// =========================
|
||||
// User (therapist personal) — view v_user_entitlements
|
||||
// =========================
|
||||
async loadForUser(userId, { force = false, maxAgeMs = 0 } = {}) {
|
||||
if (!userId) {
|
||||
this.invalidateUser();
|
||||
return;
|
||||
}
|
||||
|
||||
const same = this.loadedForUser === userId;
|
||||
const hasLoadedAt = typeof this.userLoadedAt === 'number';
|
||||
const isFresh = same && hasLoadedAt && maxAgeMs > 0 && Date.now() - this.userLoadedAt < maxAgeMs;
|
||||
|
||||
if (!force && same && (maxAgeMs === 0 || isFresh)) return;
|
||||
|
||||
this.userLoading = true;
|
||||
this.userError = null;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.from('v_user_entitlements').select('feature_key').eq('user_id', userId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const rows = data ?? [];
|
||||
this.userRaw = rows;
|
||||
this.userFeatures = uniqKeys(rows, 'feature_key');
|
||||
|
||||
this.loadedForUser = userId;
|
||||
this.userLoadedAt = Date.now();
|
||||
} catch (e) {
|
||||
this.userError = e;
|
||||
this.userRaw = [];
|
||||
this.userFeatures = [];
|
||||
this.loadedForUser = userId;
|
||||
this.userLoadedAt = Date.now();
|
||||
} finally {
|
||||
this.userLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// =========================
|
||||
// Invalidate granular
|
||||
// =========================
|
||||
invalidateTenant() {
|
||||
this.tenantLoading = false;
|
||||
this.loadedForTenant = null;
|
||||
this.tenantFeatures = [];
|
||||
this.tenantRaw = [];
|
||||
this.tenantError = null;
|
||||
this.tenantLoadedAt = null;
|
||||
},
|
||||
|
||||
invalidateUser() {
|
||||
this.userLoading = false;
|
||||
this.loadedForUser = null;
|
||||
this.userFeatures = [];
|
||||
this.userRaw = [];
|
||||
this.userError = null;
|
||||
this.userLoadedAt = null;
|
||||
},
|
||||
|
||||
// =========================
|
||||
// Invalidate geral (compat)
|
||||
// =========================
|
||||
invalidate() {
|
||||
this.invalidateTenant();
|
||||
this.invalidateUser();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
// =========================
|
||||
// Compat: fetch() continua existindo
|
||||
// =========================
|
||||
async fetch (tenantId, opts = {}) {
|
||||
return this.loadForTenant(tenantId, opts)
|
||||
},
|
||||
|
||||
clear () {
|
||||
return this.invalidate()
|
||||
},
|
||||
|
||||
// =========================
|
||||
// Tenant (clinic) — view v_tenant_entitlements
|
||||
// =========================
|
||||
async loadForTenant (tenantId, { force = false, maxAgeMs = 0 } = {}) {
|
||||
if (!tenantId) {
|
||||
this.invalidateTenant()
|
||||
return
|
||||
}
|
||||
|
||||
const same = this.loadedForTenant === tenantId
|
||||
const hasLoadedAt = typeof this.tenantLoadedAt === 'number'
|
||||
const isFresh = same && hasLoadedAt && maxAgeMs > 0 && (Date.now() - this.tenantLoadedAt < maxAgeMs)
|
||||
|
||||
if (!force && same && (maxAgeMs === 0 || isFresh)) return
|
||||
|
||||
this.tenantLoading = true
|
||||
this.tenantError = null
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('v_tenant_entitlements')
|
||||
.select('feature_key')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const rows = data ?? []
|
||||
this.tenantRaw = rows
|
||||
this.tenantFeatures = uniqKeys(rows, 'feature_key')
|
||||
|
||||
this.loadedForTenant = tenantId
|
||||
this.tenantLoadedAt = Date.now()
|
||||
} catch (e) {
|
||||
this.tenantError = e
|
||||
this.tenantRaw = []
|
||||
this.tenantFeatures = []
|
||||
this.loadedForTenant = tenantId
|
||||
this.tenantLoadedAt = Date.now()
|
||||
} finally {
|
||||
this.tenantLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
// =========================
|
||||
// User (therapist personal) — view v_user_entitlements
|
||||
// =========================
|
||||
async loadForUser (userId, { force = false, maxAgeMs = 0 } = {}) {
|
||||
if (!userId) {
|
||||
this.invalidateUser()
|
||||
return
|
||||
}
|
||||
|
||||
const same = this.loadedForUser === userId
|
||||
const hasLoadedAt = typeof this.userLoadedAt === 'number'
|
||||
const isFresh = same && hasLoadedAt && maxAgeMs > 0 && (Date.now() - this.userLoadedAt < maxAgeMs)
|
||||
|
||||
if (!force && same && (maxAgeMs === 0 || isFresh)) return
|
||||
|
||||
this.userLoading = true
|
||||
this.userError = null
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('v_user_entitlements')
|
||||
.select('feature_key')
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const rows = data ?? []
|
||||
this.userRaw = rows
|
||||
this.userFeatures = uniqKeys(rows, 'feature_key')
|
||||
|
||||
this.loadedForUser = userId
|
||||
this.userLoadedAt = Date.now()
|
||||
} catch (e) {
|
||||
this.userError = e
|
||||
this.userRaw = []
|
||||
this.userFeatures = []
|
||||
this.loadedForUser = userId
|
||||
this.userLoadedAt = Date.now()
|
||||
} finally {
|
||||
this.userLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
// =========================
|
||||
// Invalidate granular
|
||||
// =========================
|
||||
invalidateTenant () {
|
||||
this.tenantLoading = false
|
||||
this.loadedForTenant = null
|
||||
this.tenantFeatures = []
|
||||
this.tenantRaw = []
|
||||
this.tenantError = null
|
||||
this.tenantLoadedAt = null
|
||||
},
|
||||
|
||||
invalidateUser () {
|
||||
this.userLoading = false
|
||||
this.loadedForUser = null
|
||||
this.userFeatures = []
|
||||
this.userRaw = []
|
||||
this.userError = null
|
||||
this.userLoadedAt = null
|
||||
},
|
||||
|
||||
// =========================
|
||||
// Invalidate geral (compat)
|
||||
// =========================
|
||||
invalidate () {
|
||||
this.invalidateTenant()
|
||||
this.invalidateUser()
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
+18
-18
@@ -14,25 +14,25 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useMenuStore = defineStore('menuStore', {
|
||||
state: () => ({
|
||||
model: [],
|
||||
key: null, // assinatura do contexto (uid+tenant+role)
|
||||
ready: false
|
||||
}),
|
||||
state: () => ({
|
||||
model: [],
|
||||
key: null, // assinatura do contexto (uid+tenant+role)
|
||||
ready: false
|
||||
}),
|
||||
|
||||
actions: {
|
||||
setMenu (key, model) {
|
||||
this.key = key || null
|
||||
this.model = Array.isArray(model) ? model : []
|
||||
this.ready = true
|
||||
},
|
||||
reset () {
|
||||
this.model = []
|
||||
this.key = null
|
||||
this.ready = false
|
||||
actions: {
|
||||
setMenu(key, model) {
|
||||
this.key = key || null;
|
||||
this.model = Array.isArray(model) ? model : [];
|
||||
this.ready = true;
|
||||
},
|
||||
reset() {
|
||||
this.model = [];
|
||||
this.key = null;
|
||||
this.ready = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
+165
-178
@@ -16,225 +16,212 @@
|
||||
*/
|
||||
// Store Pinia — lógica de prioridade, filtragem, dismiss e regras de exibição
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
fetchActiveNotices,
|
||||
loadUserDismissals,
|
||||
saveDismissal,
|
||||
trackView,
|
||||
trackClick as svcTrackClick
|
||||
} from '@/features/notices/noticeService'
|
||||
import { defineStore } from 'pinia';
|
||||
import { fetchActiveNotices, loadUserDismissals, saveDismissal, trackView, trackClick as svcTrackClick } from '@/features/notices/noticeService';
|
||||
|
||||
// ── Storage helpers ────────────────────────────────────────────
|
||||
|
||||
function dismissKey (id, version) {
|
||||
return `notice_dismissed_${id}_v${version}`
|
||||
function dismissKey(id, version) {
|
||||
return `notice_dismissed_${id}_v${version}`;
|
||||
}
|
||||
|
||||
function viewKey (id) {
|
||||
return `notice_views_${id}`
|
||||
function viewKey(id) {
|
||||
return `notice_views_${id}`;
|
||||
}
|
||||
|
||||
function lastSeenKey (id) {
|
||||
return `notice_last_seen_${id}`
|
||||
function lastSeenKey(id) {
|
||||
return `notice_last_seen_${id}`;
|
||||
}
|
||||
|
||||
function isDismissedLocally (id, version, scope) {
|
||||
const store = scope === 'session' ? sessionStorage : localStorage
|
||||
return store.getItem(dismissKey(id, version)) === '1'
|
||||
function isDismissedLocally(id, version, scope) {
|
||||
const store = scope === 'session' ? sessionStorage : localStorage;
|
||||
return store.getItem(dismissKey(id, version)) === '1';
|
||||
}
|
||||
|
||||
function setDismissedLocally (id, version, scope) {
|
||||
const store = scope === 'session' ? sessionStorage : localStorage
|
||||
store.setItem(dismissKey(id, version), '1')
|
||||
function setDismissedLocally(id, version, scope) {
|
||||
const store = scope === 'session' ? sessionStorage : localStorage;
|
||||
store.setItem(dismissKey(id, version), '1');
|
||||
}
|
||||
|
||||
function getViewCount (id) {
|
||||
return parseInt(localStorage.getItem(viewKey(id)) || '0', 10)
|
||||
function getViewCount(id) {
|
||||
return parseInt(localStorage.getItem(viewKey(id)) || '0', 10);
|
||||
}
|
||||
|
||||
function incrementViewCount (id) {
|
||||
const count = getViewCount(id) + 1
|
||||
localStorage.setItem(viewKey(id), String(count))
|
||||
return count
|
||||
function incrementViewCount(id) {
|
||||
const count = getViewCount(id) + 1;
|
||||
localStorage.setItem(viewKey(id), String(count));
|
||||
return count;
|
||||
}
|
||||
|
||||
function getLastSeen (id) {
|
||||
const v = localStorage.getItem(lastSeenKey(id))
|
||||
return v ? new Date(v) : null
|
||||
function getLastSeen(id) {
|
||||
const v = localStorage.getItem(lastSeenKey(id));
|
||||
return v ? new Date(v) : null;
|
||||
}
|
||||
|
||||
function setLastSeen (id) {
|
||||
localStorage.setItem(lastSeenKey(id), new Date().toISOString())
|
||||
function setLastSeen(id) {
|
||||
localStorage.setItem(lastSeenKey(id), new Date().toISOString());
|
||||
}
|
||||
|
||||
// ── Contexto da rota → string de contexto ─────────────────────
|
||||
|
||||
export function routeToContext (path = '') {
|
||||
if (path.startsWith('/saas')) return 'saas'
|
||||
if (path.startsWith('/admin')) return 'clinic'
|
||||
if (path.startsWith('/therapist')) return 'therapist'
|
||||
if (path.startsWith('/supervisor')) return 'supervisor'
|
||||
if (path.startsWith('/editor')) return 'editor'
|
||||
if (path.startsWith('/portal')) return 'portal'
|
||||
return 'public'
|
||||
export function routeToContext(path = '') {
|
||||
if (path.startsWith('/saas')) return 'saas';
|
||||
if (path.startsWith('/admin')) return 'clinic';
|
||||
if (path.startsWith('/therapist')) return 'therapist';
|
||||
if (path.startsWith('/supervisor')) return 'supervisor';
|
||||
if (path.startsWith('/editor')) return 'editor';
|
||||
if (path.startsWith('/portal')) return 'portal';
|
||||
return 'public';
|
||||
}
|
||||
|
||||
// ── Store ──────────────────────────────────────────────────────
|
||||
|
||||
export const useNoticeStore = defineStore('noticeStore', {
|
||||
state: () => ({
|
||||
notices: [], // todos os notices ativos buscados
|
||||
activeNotice: null, // o notice sendo exibido agora
|
||||
userDismissals: [], // dismissals do banco (scope = 'user')
|
||||
loading: false,
|
||||
lastFetch: null, // timestamp da última busca
|
||||
currentRole: null, // role do usuário atual
|
||||
currentContext: null, // contexto da rota atual
|
||||
}),
|
||||
state: () => ({
|
||||
notices: [], // todos os notices ativos buscados
|
||||
activeNotice: null, // o notice sendo exibido agora
|
||||
userDismissals: [], // dismissals do banco (scope = 'user')
|
||||
loading: false,
|
||||
lastFetch: null, // timestamp da última busca
|
||||
currentRole: null, // role do usuário atual
|
||||
currentContext: null // contexto da rota atual
|
||||
}),
|
||||
|
||||
actions: {
|
||||
actions: {
|
||||
// ── Inicialização ──────────────────────────────────────────
|
||||
|
||||
// ── Inicialização ──────────────────────────────────────────
|
||||
async init(role, routePath) {
|
||||
this.currentRole = role || null;
|
||||
this.currentContext = routeToContext(routePath);
|
||||
|
||||
async init (role, routePath) {
|
||||
this.currentRole = role || null
|
||||
this.currentContext = routeToContext(routePath)
|
||||
// Não rebusca se buscou há menos de 5 minutos
|
||||
const CACHE_MS = 5 * 60 * 1000;
|
||||
if (this.lastFetch && Date.now() - this.lastFetch < CACHE_MS) {
|
||||
this._recalcActive();
|
||||
return;
|
||||
}
|
||||
|
||||
// Não rebusca se buscou há menos de 5 minutos
|
||||
const CACHE_MS = 5 * 60 * 1000
|
||||
if (this.lastFetch && Date.now() - this.lastFetch < CACHE_MS) {
|
||||
this._recalcActive()
|
||||
return
|
||||
}
|
||||
await this._fetchAndApply();
|
||||
},
|
||||
|
||||
await this._fetchAndApply()
|
||||
},
|
||||
async _fetchAndApply() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const [notices, dismissals] = await Promise.all([fetchActiveNotices(), loadUserDismissals()]);
|
||||
this.notices = notices;
|
||||
this.userDismissals = dismissals;
|
||||
this.lastFetch = Date.now();
|
||||
this._recalcActive();
|
||||
} catch (e) {
|
||||
console.warn('[NoticeStore] falha ao buscar avisos:', e?.message || e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async _fetchAndApply () {
|
||||
this.loading = true
|
||||
try {
|
||||
const [notices, dismissals] = await Promise.all([
|
||||
fetchActiveNotices(),
|
||||
loadUserDismissals()
|
||||
])
|
||||
this.notices = notices
|
||||
this.userDismissals = dismissals
|
||||
this.lastFetch = Date.now()
|
||||
this._recalcActive()
|
||||
} catch (e) {
|
||||
console.warn('[NoticeStore] falha ao buscar avisos:', e?.message || e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
// ── Filtragem + prioridade ─────────────────────────────────
|
||||
|
||||
// ── Filtragem + prioridade ─────────────────────────────────
|
||||
_recalcActive() {
|
||||
const role = this.currentRole;
|
||||
const context = this.currentContext;
|
||||
|
||||
_recalcActive () {
|
||||
const role = this.currentRole
|
||||
const context = this.currentContext
|
||||
const candidates = this.notices.filter((n) => {
|
||||
// 1. Segmentação por role (array vazio = todos)
|
||||
if (n.roles?.length && role && !n.roles.includes(role)) return false;
|
||||
|
||||
const candidates = this.notices.filter(n => {
|
||||
// 1. Segmentação por role (array vazio = todos)
|
||||
if (n.roles?.length && role && !n.roles.includes(role)) return false
|
||||
// 2. Segmentação por context (array vazio = todos)
|
||||
if (n.contexts?.length && context && !n.contexts.includes(context)) return false;
|
||||
|
||||
// 2. Segmentação por context (array vazio = todos)
|
||||
if (n.contexts?.length && context && !n.contexts.includes(context)) return false
|
||||
// 3. Dismiss check
|
||||
if (this._isDismissed(n)) return false;
|
||||
|
||||
// 3. Dismiss check
|
||||
if (this._isDismissed(n)) return false
|
||||
// 4. show_once
|
||||
if (n.show_once && getViewCount(n.id) > 0) return false;
|
||||
|
||||
// 4. show_once
|
||||
if (n.show_once && getViewCount(n.id) > 0) return false
|
||||
// 5. max_views
|
||||
if (n.max_views != null && getViewCount(n.id) >= n.max_views) return false;
|
||||
|
||||
// 5. max_views
|
||||
if (n.max_views != null && getViewCount(n.id) >= n.max_views) return false
|
||||
// 6. cooldown
|
||||
if (n.cooldown_minutes) {
|
||||
const last = getLastSeen(n.id);
|
||||
if (last) {
|
||||
const diffMin = (Date.now() - last.getTime()) / 60_000;
|
||||
if (diffMin < n.cooldown_minutes) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. cooldown
|
||||
if (n.cooldown_minutes) {
|
||||
const last = getLastSeen(n.id)
|
||||
if (last) {
|
||||
const diffMin = (Date.now() - last.getTime()) / 60_000
|
||||
if (diffMin < n.cooldown_minutes) return false
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Ordena por priority desc (já vem ordenado do server, mas garante)
|
||||
candidates.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||
|
||||
this.activeNotice = candidates[0] || null;
|
||||
},
|
||||
|
||||
_isDismissed(notice) {
|
||||
const { id, version, dismiss_scope: scope } = notice;
|
||||
|
||||
if (scope === 'user') {
|
||||
const entry = this.userDismissals.find((d) => d.notice_id === id);
|
||||
return !!entry && entry.version >= version;
|
||||
}
|
||||
|
||||
return isDismissedLocally(id, version, scope || 'device');
|
||||
},
|
||||
|
||||
// ── Dismiss ────────────────────────────────────────────────
|
||||
|
||||
async dismiss(notice) {
|
||||
if (!notice?.dismissible) return;
|
||||
|
||||
const { id, version, dismiss_scope: scope, persist_dismiss: persist } = notice;
|
||||
|
||||
if (scope === 'user' && persist) {
|
||||
await saveDismissal(id, version);
|
||||
this.userDismissals = [...this.userDismissals.filter((d) => d.notice_id !== id), { notice_id: id, version }];
|
||||
} else if (persist) {
|
||||
setDismissedLocally(id, version, scope || 'device');
|
||||
} else {
|
||||
// sem persistência: usa session como temporário
|
||||
setDismissedLocally(id, version, 'session');
|
||||
}
|
||||
|
||||
this._recalcActive();
|
||||
},
|
||||
|
||||
// ── Tracking ───────────────────────────────────────────────
|
||||
|
||||
onView(notice) {
|
||||
if (!notice?.id) return;
|
||||
incrementViewCount(notice.id);
|
||||
setLastSeen(notice.id);
|
||||
trackView(notice.id);
|
||||
},
|
||||
|
||||
async onCtaClick(notice) {
|
||||
if (!notice?.id) return;
|
||||
await svcTrackClick(notice.id);
|
||||
},
|
||||
|
||||
// ── Atualiza contexto quando rota muda ─────────────────────
|
||||
|
||||
updateContext(routePath, role) {
|
||||
const newCtx = routeToContext(routePath);
|
||||
const newRole = role || this.currentRole;
|
||||
|
||||
if (newCtx !== this.currentContext || newRole !== this.currentRole) {
|
||||
this.currentContext = newCtx;
|
||||
this.currentRole = newRole;
|
||||
this._recalcActive();
|
||||
}
|
||||
},
|
||||
|
||||
// ── Força re-busca ─────────────────────────────────────────
|
||||
|
||||
async refresh() {
|
||||
this.lastFetch = null;
|
||||
await this._fetchAndApply();
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Ordena por priority desc (já vem ordenado do server, mas garante)
|
||||
candidates.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||
|
||||
this.activeNotice = candidates[0] || null
|
||||
},
|
||||
|
||||
_isDismissed (notice) {
|
||||
const { id, version, dismiss_scope: scope } = notice
|
||||
|
||||
if (scope === 'user') {
|
||||
const entry = this.userDismissals.find(d => d.notice_id === id)
|
||||
return !!entry && entry.version >= version
|
||||
}
|
||||
|
||||
return isDismissedLocally(id, version, scope || 'device')
|
||||
},
|
||||
|
||||
// ── Dismiss ────────────────────────────────────────────────
|
||||
|
||||
async dismiss (notice) {
|
||||
if (!notice?.dismissible) return
|
||||
|
||||
const { id, version, dismiss_scope: scope, persist_dismiss: persist } = notice
|
||||
|
||||
if (scope === 'user' && persist) {
|
||||
await saveDismissal(id, version)
|
||||
this.userDismissals = [
|
||||
...this.userDismissals.filter(d => d.notice_id !== id),
|
||||
{ notice_id: id, version }
|
||||
]
|
||||
} else if (persist) {
|
||||
setDismissedLocally(id, version, scope || 'device')
|
||||
} else {
|
||||
// sem persistência: usa session como temporário
|
||||
setDismissedLocally(id, version, 'session')
|
||||
}
|
||||
|
||||
this._recalcActive()
|
||||
},
|
||||
|
||||
// ── Tracking ───────────────────────────────────────────────
|
||||
|
||||
onView (notice) {
|
||||
if (!notice?.id) return
|
||||
incrementViewCount(notice.id)
|
||||
setLastSeen(notice.id)
|
||||
trackView(notice.id)
|
||||
},
|
||||
|
||||
async onCtaClick (notice) {
|
||||
if (!notice?.id) return
|
||||
await svcTrackClick(notice.id)
|
||||
},
|
||||
|
||||
// ── Atualiza contexto quando rota muda ─────────────────────
|
||||
|
||||
updateContext (routePath, role) {
|
||||
const newCtx = routeToContext(routePath)
|
||||
const newRole = role || this.currentRole
|
||||
|
||||
if (newCtx !== this.currentContext || newRole !== this.currentRole) {
|
||||
this.currentContext = newCtx
|
||||
this.currentRole = newRole
|
||||
this._recalcActive()
|
||||
}
|
||||
},
|
||||
|
||||
// ── Força re-busca ─────────────────────────────────────────
|
||||
|
||||
async refresh () {
|
||||
this.lastFetch = null
|
||||
await this._fetchAndApply()
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
+80
-100
@@ -14,125 +14,105 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { defineStore } from 'pinia';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', {
|
||||
state: () => ({
|
||||
items: [],
|
||||
drawerOpen: false,
|
||||
_channel: null
|
||||
}),
|
||||
state: () => ({
|
||||
items: [],
|
||||
drawerOpen: false,
|
||||
_channel: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
unreadCount: (state) =>
|
||||
state.items.filter((n) => !n.read_at && !n.archived).length,
|
||||
getters: {
|
||||
unreadCount: (state) => state.items.filter((n) => !n.read_at && !n.archived).length,
|
||||
|
||||
unreadItems: (state) =>
|
||||
state.items.filter((n) => !n.read_at && !n.archived),
|
||||
unreadItems: (state) => state.items.filter((n) => !n.read_at && !n.archived),
|
||||
|
||||
allItems: (state) =>
|
||||
state.items.filter((n) => !n.archived)
|
||||
},
|
||||
|
||||
actions: {
|
||||
async load (ownerId) {
|
||||
const { data, error } = await supabase
|
||||
.from('notifications')
|
||||
.select('*')
|
||||
.eq('owner_id', ownerId)
|
||||
.eq('archived', false)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
if (error) {
|
||||
console.error('[notificationStore] load error:', error.message)
|
||||
return
|
||||
}
|
||||
|
||||
this.items = data || []
|
||||
allItems: (state) => state.items.filter((n) => !n.archived)
|
||||
},
|
||||
|
||||
subscribeRealtime (ownerId) {
|
||||
if (this._channel) return
|
||||
actions: {
|
||||
async load(ownerId) {
|
||||
const { data, error } = await supabase.from('notifications').select('*').eq('owner_id', ownerId).eq('archived', false).order('created_at', { ascending: false }).limit(50);
|
||||
|
||||
const channel = supabase
|
||||
.channel(`notifications:${ownerId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'notifications',
|
||||
filter: `owner_id=eq.${ownerId}`
|
||||
},
|
||||
(payload) => {
|
||||
this.items.unshift(payload.new)
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
if (error) {
|
||||
console.error('[notificationStore] load error:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
this._channel = channel
|
||||
},
|
||||
this.items = data || [];
|
||||
},
|
||||
|
||||
async markRead (id) {
|
||||
const now = new Date().toISOString()
|
||||
const { error } = await supabase
|
||||
.from('notifications')
|
||||
.update({ read_at: now })
|
||||
.eq('id', id)
|
||||
subscribeRealtime(ownerId) {
|
||||
if (this._channel) return;
|
||||
|
||||
if (error) {
|
||||
console.error('[notificationStore] markRead error:', error.message)
|
||||
return
|
||||
}
|
||||
const channel = supabase
|
||||
.channel(`notifications:${ownerId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'notifications',
|
||||
filter: `owner_id=eq.${ownerId}`
|
||||
},
|
||||
(payload) => {
|
||||
this.items.unshift(payload.new);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
const item = this.items.find((n) => n.id === id)
|
||||
if (item) item.read_at = now
|
||||
},
|
||||
this._channel = channel;
|
||||
},
|
||||
|
||||
async markAllRead () {
|
||||
const unreadIds = this.items
|
||||
.filter((n) => !n.read_at && !n.archived)
|
||||
.map((n) => n.id)
|
||||
async markRead(id) {
|
||||
const now = new Date().toISOString();
|
||||
const { error } = await supabase.from('notifications').update({ read_at: now }).eq('id', id);
|
||||
|
||||
if (!unreadIds.length) return
|
||||
if (error) {
|
||||
console.error('[notificationStore] markRead error:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const { error } = await supabase
|
||||
.from('notifications')
|
||||
.update({ read_at: now })
|
||||
.in('id', unreadIds)
|
||||
const item = this.items.find((n) => n.id === id);
|
||||
if (item) item.read_at = now;
|
||||
},
|
||||
|
||||
if (error) {
|
||||
console.error('[notificationStore] markAllRead error:', error.message)
|
||||
return
|
||||
}
|
||||
async markAllRead() {
|
||||
const unreadIds = this.items.filter((n) => !n.read_at && !n.archived).map((n) => n.id);
|
||||
|
||||
this.items.forEach((n) => {
|
||||
if (unreadIds.includes(n.id)) n.read_at = now
|
||||
})
|
||||
},
|
||||
if (!unreadIds.length) return;
|
||||
|
||||
async archive (id) {
|
||||
const { error } = await supabase
|
||||
.from('notifications')
|
||||
.update({ archived: true })
|
||||
.eq('id', id)
|
||||
const now = new Date().toISOString();
|
||||
const { error } = await supabase.from('notifications').update({ read_at: now }).in('id', unreadIds);
|
||||
|
||||
if (error) {
|
||||
console.error('[notificationStore] archive error:', error.message)
|
||||
return
|
||||
}
|
||||
if (error) {
|
||||
console.error('[notificationStore] markAllRead error:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.items = this.items.filter((n) => n.id !== id)
|
||||
},
|
||||
this.items.forEach((n) => {
|
||||
if (unreadIds.includes(n.id)) n.read_at = now;
|
||||
});
|
||||
},
|
||||
|
||||
unsubscribe () {
|
||||
if (this._channel) {
|
||||
supabase.removeChannel(this._channel)
|
||||
this._channel = null
|
||||
}
|
||||
async archive(id) {
|
||||
const { error } = await supabase.from('notifications').update({ archived: true }).eq('id', id);
|
||||
|
||||
if (error) {
|
||||
console.error('[notificationStore] archive error:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.items = this.items.filter((n) => n.id !== id);
|
||||
},
|
||||
|
||||
unsubscribe() {
|
||||
if (this._channel) {
|
||||
supabase.removeChannel(this._channel);
|
||||
this._channel = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -14,33 +14,31 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { defineStore } from 'pinia';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
export const useSaasHealthStore = defineStore('saasHealth', {
|
||||
state: () => ({
|
||||
mismatchCount: 0,
|
||||
loading: false,
|
||||
lastLoadedAt: null
|
||||
}),
|
||||
state: () => ({
|
||||
mismatchCount: 0,
|
||||
loading: false,
|
||||
lastLoadedAt: null
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async loadMismatchCount ({ force = false } = {}) {
|
||||
if (this.loading) return
|
||||
if (!force && this.lastLoadedAt && (Date.now() - this.lastLoadedAt) < 30_000) return // cache 30s
|
||||
actions: {
|
||||
async loadMismatchCount({ force = false } = {}) {
|
||||
if (this.loading) return;
|
||||
if (!force && this.lastLoadedAt && Date.now() - this.lastLoadedAt < 30_000) return; // cache 30s
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
const { count, error } = await supabase
|
||||
.from('v_subscription_feature_mismatch')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
this.loading = true;
|
||||
try {
|
||||
const { count, error } = await supabase.from('v_subscription_feature_mismatch').select('*', { count: 'exact', head: true });
|
||||
|
||||
if (error) throw error
|
||||
this.mismatchCount = Number(count || 0)
|
||||
this.lastLoadedAt = Date.now()
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
if (error) throw error;
|
||||
this.mismatchCount = Number(count || 0);
|
||||
this.lastLoadedAt = Date.now();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
+109
-114
@@ -14,131 +14,126 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
|
||||
const loading = ref(false)
|
||||
const lastError = ref(null)
|
||||
const lastFetchedAt = ref(null)
|
||||
const loading = ref(false);
|
||||
const lastError = ref(null);
|
||||
const lastFetchedAt = ref(null);
|
||||
|
||||
// Cache por tenant: { [tenantId]: { [feature_key]: boolean } }
|
||||
const featuresByTenant = ref({})
|
||||
// Cache por tenant: { [tenantId]: { [feature_key]: boolean } }
|
||||
const featuresByTenant = ref({});
|
||||
|
||||
// Marca o último tenant buscado (útil pra debug)
|
||||
const loadedForTenantId = ref(null)
|
||||
// Marca o último tenant buscado (útil pra debug)
|
||||
const loadedForTenantId = ref(null);
|
||||
|
||||
function getTenantMap (tenantId) {
|
||||
if (!tenantId) return {}
|
||||
return featuresByTenant.value?.[tenantId] || {}
|
||||
}
|
||||
|
||||
// 🔎 Se você passar tenantId, lê desse tenant; se não, tenta o último carregado
|
||||
// Modelo opt-out: se a feature não está configurada (key ausente do mapa), retorna true por padrão.
|
||||
// Só retorna false quando explicitamente desabilitada no banco.
|
||||
function isEnabled (key, tenantId = null) {
|
||||
const tid = tenantId || loadedForTenantId.value
|
||||
if (!tid) return false
|
||||
const map = getTenantMap(tid)
|
||||
if (!(key in map)) return true // não configurada = habilitada por padrão
|
||||
return !!map[key]
|
||||
}
|
||||
|
||||
function invalidate (tenantId = null) {
|
||||
lastError.value = null
|
||||
if (!tenantId) {
|
||||
loadedForTenantId.value = null
|
||||
featuresByTenant.value = {}
|
||||
return
|
||||
}
|
||||
// invalida apenas um tenant
|
||||
const copy = { ...featuresByTenant.value }
|
||||
delete copy[tenantId]
|
||||
featuresByTenant.value = copy
|
||||
if (loadedForTenantId.value === tenantId) loadedForTenantId.value = null
|
||||
}
|
||||
|
||||
async function fetchForTenant (tenantId, { force = false } = {}) {
|
||||
if (!tenantId) return
|
||||
|
||||
// se já tem cache e não é force, não busca de novo
|
||||
if (!force && featuresByTenant.value?.[tenantId]) {
|
||||
loadedForTenantId.value = tenantId
|
||||
return
|
||||
function getTenantMap(tenantId) {
|
||||
if (!tenantId) return {};
|
||||
return featuresByTenant.value?.[tenantId] || {};
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
lastError.value = null
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_features')
|
||||
.select('feature_key, enabled')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const map = {}
|
||||
for (const row of data || []) map[row.feature_key] = !!row.enabled
|
||||
|
||||
featuresByTenant.value = {
|
||||
...featuresByTenant.value,
|
||||
[tenantId]: map
|
||||
}
|
||||
|
||||
loadedForTenantId.value = tenantId
|
||||
lastFetchedAt.value = new Date().toISOString()
|
||||
} catch (e) {
|
||||
lastError.value = e
|
||||
// importante: se falhar, mantém cache anterior (se existir)
|
||||
// e relança para a página poder mostrar toast se quiser
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setForTenant (tenantId, key, enabled) {
|
||||
if (!tenantId) throw new Error('tenantId missing')
|
||||
|
||||
lastError.value = null
|
||||
|
||||
const payload = { tenant_id: tenantId, feature_key: key, enabled: !!enabled }
|
||||
|
||||
const { error } = await supabase
|
||||
.from('tenant_features')
|
||||
.upsert(payload, { onConflict: 'tenant_id,feature_key' })
|
||||
|
||||
if (error) {
|
||||
lastError.value = error
|
||||
throw error
|
||||
// 🔎 Se você passar tenantId, lê desse tenant; se não, tenta o último carregado
|
||||
// Modelo opt-out: se a feature não está configurada (key ausente do mapa), retorna true por padrão.
|
||||
// Só retorna false quando explicitamente desabilitada no banco.
|
||||
function isEnabled(key, tenantId = null) {
|
||||
const tid = tenantId || loadedForTenantId.value;
|
||||
if (!tid) return false;
|
||||
const map = getTenantMap(tid);
|
||||
if (!(key in map)) return true; // não configurada = habilitada por padrão
|
||||
return !!map[key];
|
||||
}
|
||||
|
||||
// Atualiza cache local do tenant (mesmo que ainda não tenha sido carregado)
|
||||
const current = getTenantMap(tenantId)
|
||||
featuresByTenant.value = {
|
||||
...featuresByTenant.value,
|
||||
[tenantId]: { ...current, [key]: !!enabled }
|
||||
function invalidate(tenantId = null) {
|
||||
lastError.value = null;
|
||||
if (!tenantId) {
|
||||
loadedForTenantId.value = null;
|
||||
featuresByTenant.value = {};
|
||||
return;
|
||||
}
|
||||
// invalida apenas um tenant
|
||||
const copy = { ...featuresByTenant.value };
|
||||
delete copy[tenantId];
|
||||
featuresByTenant.value = copy;
|
||||
if (loadedForTenantId.value === tenantId) loadedForTenantId.value = null;
|
||||
}
|
||||
loadedForTenantId.value = tenantId
|
||||
}
|
||||
|
||||
// (opcional) útil pra debug rápido na tela
|
||||
const currentFeatures = computed(() => getTenantMap(loadedForTenantId.value))
|
||||
async function fetchForTenant(tenantId, { force = false } = {}) {
|
||||
if (!tenantId) return;
|
||||
|
||||
return {
|
||||
loading,
|
||||
lastError,
|
||||
lastFetchedAt,
|
||||
// se já tem cache e não é force, não busca de novo
|
||||
if (!force && featuresByTenant.value?.[tenantId]) {
|
||||
loadedForTenantId.value = tenantId;
|
||||
return;
|
||||
}
|
||||
|
||||
loadedForTenantId,
|
||||
featuresByTenant,
|
||||
currentFeatures,
|
||||
loading.value = true;
|
||||
lastError.value = null;
|
||||
|
||||
isEnabled,
|
||||
invalidate,
|
||||
fetchForTenant,
|
||||
setForTenant
|
||||
}
|
||||
})
|
||||
try {
|
||||
const { data, error } = await supabase.from('tenant_features').select('feature_key, enabled').eq('tenant_id', tenantId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const map = {};
|
||||
for (const row of data || []) map[row.feature_key] = !!row.enabled;
|
||||
|
||||
featuresByTenant.value = {
|
||||
...featuresByTenant.value,
|
||||
[tenantId]: map
|
||||
};
|
||||
|
||||
loadedForTenantId.value = tenantId;
|
||||
lastFetchedAt.value = new Date().toISOString();
|
||||
} catch (e) {
|
||||
lastError.value = e;
|
||||
// importante: se falhar, mantém cache anterior (se existir)
|
||||
// e relança para a página poder mostrar toast se quiser
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setForTenant(tenantId, key, enabled) {
|
||||
if (!tenantId) throw new Error('tenantId missing');
|
||||
|
||||
lastError.value = null;
|
||||
|
||||
const payload = { tenant_id: tenantId, feature_key: key, enabled: !!enabled };
|
||||
|
||||
const { error } = await supabase.from('tenant_features').upsert(payload, { onConflict: 'tenant_id,feature_key' });
|
||||
|
||||
if (error) {
|
||||
lastError.value = error;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Atualiza cache local do tenant (mesmo que ainda não tenha sido carregado)
|
||||
const current = getTenantMap(tenantId);
|
||||
featuresByTenant.value = {
|
||||
...featuresByTenant.value,
|
||||
[tenantId]: { ...current, [key]: !!enabled }
|
||||
};
|
||||
loadedForTenantId.value = tenantId;
|
||||
}
|
||||
|
||||
// (opcional) útil pra debug rápido na tela
|
||||
const currentFeatures = computed(() => getTenantMap(loadedForTenantId.value));
|
||||
|
||||
return {
|
||||
loading,
|
||||
lastError,
|
||||
lastFetchedAt,
|
||||
|
||||
loadedForTenantId,
|
||||
featuresByTenant,
|
||||
currentFeatures,
|
||||
|
||||
isEnabled,
|
||||
invalidate,
|
||||
fetchForTenant,
|
||||
setForTenant
|
||||
};
|
||||
});
|
||||
|
||||
+160
-161
@@ -14,8 +14,8 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { defineStore } from 'pinia';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
/**
|
||||
* Normaliza o role de tenant levando em conta o kind do tenant.
|
||||
@@ -26,182 +26,181 @@ import { supabase } from '@/lib/supabase/client'
|
||||
* tenant_admin / admin + kind desconhecido → 'clinic_admin' (padrão legado)
|
||||
* qualquer outro role → pass-through
|
||||
*/
|
||||
function normalizeTenantRole (role, kind) {
|
||||
const r = String(role || '').trim()
|
||||
if (!r) return null
|
||||
function normalizeTenantRole(role, kind) {
|
||||
const r = String(role || '').trim();
|
||||
if (!r) return null;
|
||||
|
||||
const isAdmin = (r === 'tenant_admin' || r === 'admin')
|
||||
const isAdmin = r === 'tenant_admin' || r === 'admin';
|
||||
|
||||
if (isAdmin) {
|
||||
const k = String(kind || '').trim()
|
||||
if (k === 'therapist' || k === 'saas') return 'therapist'
|
||||
if (k === 'supervisor') return 'supervisor'
|
||||
return 'clinic_admin'
|
||||
}
|
||||
if (isAdmin) {
|
||||
const k = String(kind || '').trim();
|
||||
if (k === 'therapist' || k === 'saas') return 'therapist';
|
||||
if (k === 'supervisor') return 'supervisor';
|
||||
return 'clinic_admin';
|
||||
}
|
||||
|
||||
return r
|
||||
return r;
|
||||
}
|
||||
|
||||
function readSavedTenant () {
|
||||
const id = localStorage.getItem('tenant_id')
|
||||
if (!id) return null
|
||||
try {
|
||||
const raw = localStorage.getItem('tenant')
|
||||
const obj = raw ? JSON.parse(raw) : null
|
||||
return { id, role: obj?.role ?? null }
|
||||
} catch {
|
||||
return { id, role: null }
|
||||
}
|
||||
function readSavedTenant() {
|
||||
const id = localStorage.getItem('tenant_id');
|
||||
if (!id) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem('tenant');
|
||||
const obj = raw ? JSON.parse(raw) : null;
|
||||
return { id, role: obj?.role ?? null };
|
||||
} catch {
|
||||
return { id, role: null };
|
||||
}
|
||||
}
|
||||
|
||||
function persistTenant (tenantId, role) {
|
||||
if (!tenantId) return clearPersistedTenant()
|
||||
localStorage.setItem('tenant_id', tenantId)
|
||||
localStorage.setItem('tenant', JSON.stringify({ id: tenantId, role }))
|
||||
function persistTenant(tenantId, role) {
|
||||
if (!tenantId) return clearPersistedTenant();
|
||||
localStorage.setItem('tenant_id', tenantId);
|
||||
localStorage.setItem('tenant', JSON.stringify({ id: tenantId, role }));
|
||||
}
|
||||
|
||||
function clearPersistedTenant () {
|
||||
localStorage.removeItem('tenant_id')
|
||||
localStorage.removeItem('tenant')
|
||||
function clearPersistedTenant() {
|
||||
localStorage.removeItem('tenant_id');
|
||||
localStorage.removeItem('tenant');
|
||||
}
|
||||
|
||||
export const useTenantStore = defineStore('tenant', {
|
||||
state: () => ({
|
||||
loading: false,
|
||||
loaded: false,
|
||||
user: null,
|
||||
memberships: [],
|
||||
activeTenantId: null,
|
||||
activeRole: null,
|
||||
needsTenantLink: false,
|
||||
error: null
|
||||
}),
|
||||
state: () => ({
|
||||
loading: false,
|
||||
loaded: false,
|
||||
user: null,
|
||||
memberships: [],
|
||||
activeTenantId: null,
|
||||
activeRole: null,
|
||||
needsTenantLink: false,
|
||||
error: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
tenantId: (s) => s.activeTenantId,
|
||||
currentTenantId: (s) => s.activeTenantId,
|
||||
role: (s) => s.activeRole,
|
||||
tenant: (s) => (s.activeTenantId ? { id: s.activeTenantId, role: s.activeRole } : null),
|
||||
hasActiveTenant: (s) => !!s.activeTenantId
|
||||
},
|
||||
|
||||
actions: {
|
||||
async ensureLoaded () {
|
||||
if (this.loaded) return
|
||||
if (this.loading) {
|
||||
await new Promise((resolve) => {
|
||||
const t = setInterval(() => {
|
||||
if (!this.loading) { clearInterval(t); resolve() }
|
||||
}, 50)
|
||||
})
|
||||
return
|
||||
}
|
||||
await this.loadSessionAndTenant()
|
||||
getters: {
|
||||
tenantId: (s) => s.activeTenantId,
|
||||
currentTenantId: (s) => s.activeTenantId,
|
||||
role: (s) => s.activeRole,
|
||||
tenant: (s) => (s.activeTenantId ? { id: s.activeTenantId, role: s.activeRole } : null),
|
||||
hasActiveTenant: (s) => !!s.activeTenantId
|
||||
},
|
||||
|
||||
async loadSessionAndTenant () {
|
||||
if (this.loading) return
|
||||
this.loading = true
|
||||
this.error = null
|
||||
actions: {
|
||||
async ensureLoaded() {
|
||||
if (this.loaded) return;
|
||||
if (this.loading) {
|
||||
await new Promise((resolve) => {
|
||||
const t = setInterval(() => {
|
||||
if (!this.loading) {
|
||||
clearInterval(t);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.loadSessionAndTenant();
|
||||
},
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.auth.getSession()
|
||||
if (error) throw error
|
||||
async loadSessionAndTenant() {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
this.user = data?.session?.user ?? null
|
||||
try {
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
if (error) throw error;
|
||||
|
||||
if (!this.user) {
|
||||
this.memberships = []
|
||||
this.activeTenantId = null
|
||||
this.activeRole = null
|
||||
this.needsTenantLink = false
|
||||
this.loaded = true
|
||||
clearPersistedTenant()
|
||||
return
|
||||
this.user = data?.session?.user ?? null;
|
||||
|
||||
if (!this.user) {
|
||||
this.memberships = [];
|
||||
this.activeTenantId = null;
|
||||
this.activeRole = null;
|
||||
this.needsTenantLink = false;
|
||||
this.loaded = true;
|
||||
clearPersistedTenant();
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: mem, error: mErr } = await supabase.rpc('my_tenants');
|
||||
if (mErr) throw mErr;
|
||||
|
||||
this.memberships = Array.isArray(mem) ? mem : [];
|
||||
|
||||
// ✅ FIX: só restaura o tenant salvo se pertence ao usuário atual.
|
||||
// Sem isso, usuário B herdava o tenant_id do usuário A (mesma máquina),
|
||||
// carregava com role errado e o menu ficava incorreto.
|
||||
const saved = readSavedTenant();
|
||||
const savedTenantId = saved?.id || null;
|
||||
|
||||
let activeMembership = null;
|
||||
|
||||
if (savedTenantId) {
|
||||
activeMembership = this.memberships.find((x) => x.tenant_id === savedTenantId && x.status === 'active');
|
||||
if (!activeMembership) {
|
||||
console.warn('[tenantStore] tenant salvo não pertence a este usuário, limpando.');
|
||||
clearPersistedTenant();
|
||||
}
|
||||
}
|
||||
|
||||
// fallback: primeiro active
|
||||
if (!activeMembership) {
|
||||
activeMembership = this.memberships.find((x) => x.status === 'active');
|
||||
}
|
||||
|
||||
this.activeTenantId = activeMembership?.tenant_id ?? null;
|
||||
this.activeRole = normalizeTenantRole(activeMembership?.role, activeMembership?.kind);
|
||||
|
||||
if (this.activeTenantId) {
|
||||
persistTenant(this.activeTenantId, this.activeRole);
|
||||
} else {
|
||||
clearPersistedTenant();
|
||||
}
|
||||
|
||||
this.needsTenantLink = !this.activeTenantId;
|
||||
this.loaded = true;
|
||||
} catch (e) {
|
||||
console.warn('[tenantStore] loadSessionAndTenant falhou:', e);
|
||||
this.error = e;
|
||||
|
||||
if (!this.user) {
|
||||
this.memberships = [];
|
||||
this.activeTenantId = null;
|
||||
this.activeRole = null;
|
||||
this.needsTenantLink = false;
|
||||
clearPersistedTenant();
|
||||
}
|
||||
|
||||
this.loaded = true;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
setActiveTenant(tenantId) {
|
||||
const found = this.memberships.find((x) => x.tenant_id === tenantId && x.status === 'active');
|
||||
|
||||
this.activeTenantId = found?.tenant_id ?? null;
|
||||
this.activeRole = normalizeTenantRole(found?.role, found?.kind);
|
||||
this.needsTenantLink = !this.activeTenantId;
|
||||
|
||||
if (this.activeTenantId) {
|
||||
persistTenant(this.activeTenantId, this.activeRole);
|
||||
} else {
|
||||
clearPersistedTenant();
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.user = null;
|
||||
this.memberships = [];
|
||||
this.activeTenantId = null;
|
||||
this.activeRole = null;
|
||||
this.needsTenantLink = false;
|
||||
this.error = null;
|
||||
this.loaded = false;
|
||||
clearPersistedTenant();
|
||||
}
|
||||
|
||||
const { data: mem, error: mErr } = await supabase.rpc('my_tenants')
|
||||
if (mErr) throw mErr
|
||||
|
||||
this.memberships = Array.isArray(mem) ? mem : []
|
||||
|
||||
// ✅ FIX: só restaura o tenant salvo se pertence ao usuário atual.
|
||||
// Sem isso, usuário B herdava o tenant_id do usuário A (mesma máquina),
|
||||
// carregava com role errado e o menu ficava incorreto.
|
||||
const saved = readSavedTenant()
|
||||
const savedTenantId = saved?.id || null
|
||||
|
||||
let activeMembership = null
|
||||
|
||||
if (savedTenantId) {
|
||||
activeMembership = this.memberships.find(
|
||||
x => x.tenant_id === savedTenantId && x.status === 'active'
|
||||
)
|
||||
if (!activeMembership) {
|
||||
console.warn('[tenantStore] tenant salvo não pertence a este usuário, limpando.')
|
||||
clearPersistedTenant()
|
||||
}
|
||||
}
|
||||
|
||||
// fallback: primeiro active
|
||||
if (!activeMembership) {
|
||||
activeMembership = this.memberships.find(x => x.status === 'active')
|
||||
}
|
||||
|
||||
this.activeTenantId = activeMembership?.tenant_id ?? null
|
||||
this.activeRole = normalizeTenantRole(activeMembership?.role, activeMembership?.kind)
|
||||
|
||||
if (this.activeTenantId) {
|
||||
persistTenant(this.activeTenantId, this.activeRole)
|
||||
} else {
|
||||
clearPersistedTenant()
|
||||
}
|
||||
|
||||
this.needsTenantLink = !this.activeTenantId
|
||||
this.loaded = true
|
||||
} catch (e) {
|
||||
console.warn('[tenantStore] loadSessionAndTenant falhou:', e)
|
||||
this.error = e
|
||||
|
||||
if (!this.user) {
|
||||
this.memberships = []
|
||||
this.activeTenantId = null
|
||||
this.activeRole = null
|
||||
this.needsTenantLink = false
|
||||
clearPersistedTenant()
|
||||
}
|
||||
|
||||
this.loaded = true
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
setActiveTenant (tenantId) {
|
||||
const found = this.memberships.find(
|
||||
x => x.tenant_id === tenantId && x.status === 'active'
|
||||
)
|
||||
|
||||
this.activeTenantId = found?.tenant_id ?? null
|
||||
this.activeRole = normalizeTenantRole(found?.role, found?.kind)
|
||||
this.needsTenantLink = !this.activeTenantId
|
||||
|
||||
if (this.activeTenantId) {
|
||||
persistTenant(this.activeTenantId, this.activeRole)
|
||||
} else {
|
||||
clearPersistedTenant()
|
||||
}
|
||||
},
|
||||
|
||||
reset () {
|
||||
this.user = null
|
||||
this.memberships = []
|
||||
this.activeTenantId = null
|
||||
this.activeRole = null
|
||||
this.needsTenantLink = false
|
||||
this.error = null
|
||||
this.loaded = false
|
||||
clearPersistedTenant()
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user