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:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions
+182 -184
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
}
}
})
});
+22 -24
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
}
})
});