-
+
\ No newline at end of file
diff --git a/src/features/agenda/pages/AgendaTerapeutaPage.vue b/src/features/agenda/pages/AgendaTerapeutaPage.vue
index 0a98086..0bd77e4 100644
--- a/src/features/agenda/pages/AgendaTerapeutaPage.vue
+++ b/src/features/agenda/pages/AgendaTerapeutaPage.vue
@@ -1,53 +1,396 @@
-
+
+
+
+
+
+
+
+
+
+
+
+ Exibir:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Buscar paciente...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ subtitlePrefix }}
+ {{ subtitleText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Visão Geral
+
+
+
+
Atendimentos
+
+ Hoje:
+ {{ stats.today }}
+
+
+ Semana:
+ {{ stats.week }}
+
+
+
+
+
Tempo de Leitura
+
+ Médio:
+ {{ stats.readingAvg }}
+
+
Baseado no ritmo recente
+
+
+
+
Pendências
+
+ Itens:
+ {{ stats.pending }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Aqui você pluga o seu fluxo real de bloqueio (agenda inteira ou intervalo), salvando no banco e refletindo no calendário.
+
+
+
+
+
+
+ Início
+
+
+
+
+
+ Fim
+
+
+
+
+
+ Motivo (opcional)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+// -----------------------------
+// Bloqueio (placeholder)
+// -----------------------------
+const blockDialogVisible = ref(false)
+const blockMode = ref('range')
+const blockStart = ref(new Date())
+const blockEnd = ref(new Date())
+const blockReason = ref('')
-
+const blockDialogHeader = computed(() => (blockMode.value === 'day' ? 'Bloquear o dia inteiro' : 'Bloquear horário'))
-
-
-
-
+const blockMenuItems = [
+ { label: 'Bloquear horário', icon: 'pi pi-clock', command: () => openBlockDialog('range') },
+ { label: 'Bloquear o dia inteiro', icon: 'pi pi-calendar-times', command: () => openBlockDialog('day') }
+]
-
-
-
-
-
+function openBlockDialog (mode) {
+ blockMode.value = mode
+ const now = new Date()
+ blockStart.value = now
+ blockEnd.value = new Date(now.getTime() + 60 * 60 * 1000)
+ blockReason.value = ''
+ blockDialogVisible.value = true
+}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+function confirmBlock () {
+ blockDialogVisible.value = false
+ toast.add({
+ severity: 'success',
+ summary: 'Bloqueado',
+ detail: blockMode.value === 'day' ? 'Dia inteiro bloqueado' : 'Intervalo bloqueado',
+ life: 2500
+ })
+}
+
+// -----------------------------
+// Helpers
+// -----------------------------
+function shiftMonth (date, delta) {
+ const d = new Date(date)
+ d.setMonth(d.getMonth() + delta)
+ return d
+}
+
+function capitalize (s) {
+ if (!s) return s
+ return s.charAt(0).toUpperCase() + s.slice(1)
+}
+
+function padTime (hhmmss, deltaMin) {
+ const [hh, mm] = String(hhmmss || '00:00:00').split(':').map(Number)
+ let total = (hh * 60 + mm) + deltaMin
+ if (total < 0) total = 0
+ if (total > 24 * 60) total = 24 * 60
+ return minutesToDuration(total)
+}
+
+onMounted(async () => {
+ await loadSettings()
+
+ if (settingsError.value) {
+ toast.add({ severity: 'warn', summary: 'Agenda', detail: settingsError.value, life: 4500 })
+ }
+
+ // opcional: refletir modo salvo no banco
+ // if (settings.value?.agenda_view_mode) {
+ // timeMode.value = settings.value.agenda_view_mode === 'full_24h' ? '24' : 'my'
+ // }
+})
+
\ No newline at end of file
diff --git a/src/features/agenda/services/agendaRepository.js b/src/features/agenda/services/agendaRepository.js
index a719881..e33ebde 100644
--- a/src/features/agenda/services/agendaRepository.js
+++ b/src/features/agenda/services/agendaRepository.js
@@ -1,12 +1,28 @@
// src/features/agenda/services/agendaRepository.js
import { supabase } from '@/lib/supabase/client'
+import { useTenantStore } from '@/stores/tenantStore'
-export async function getMyAgendaSettings () {
+function assertValidTenantId (tenantId) {
+ if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
+ throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de carregar a agenda.')
+ }
+}
+
+async function getUid () {
const { data: userRes, error: userErr } = await supabase.auth.getUser()
if (userErr) throw userErr
const uid = userRes?.user?.id
if (!uid) throw new Error('Usuário não autenticado.')
+ return uid
+}
+
+/**
+ * Configurações da agenda (por owner)
+ * Se você decidir que configurações são por tenant também, adicionamos tenant_id aqui.
+ */
+export async function getMyAgendaSettings () {
+ const uid = await getUid()
const { data, error } = await supabase
.from('agenda_configuracoes')
@@ -18,16 +34,23 @@ export async function getMyAgendaSettings () {
return data
}
-export async function listMyAgendaEvents ({ startISO, endISO }) {
- const { data: userRes, error: userErr } = await supabase.auth.getUser()
- if (userErr) throw userErr
+/**
+ * Lista agenda do terapeuta (somente do owner logado) dentro do tenant ativo.
+ * Isso impede misturar eventos caso o terapeuta atue em múltiplas clínicas.
+ */
+export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantIdArg } = {}) {
+ const uid = await getUid()
- const uid = userRes?.user?.id
- if (!uid) throw new Error('Usuário não autenticado.')
+ const tenantStore = useTenantStore()
+ const tenantId = tenantIdArg || tenantStore.activeTenantId
+ assertValidTenantId(tenantId)
+
+ if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).')
const { data, error } = await supabase
.from('agenda_eventos')
.select('*')
+ .eq('tenant_id', tenantId)
.eq('owner_id', uid)
.gte('inicio_em', startISO)
.lt('inicio_em', endISO)
@@ -37,13 +60,26 @@ export async function listMyAgendaEvents ({ startISO, endISO }) {
return data || []
}
-export async function listClinicEvents ({ ownerIds, startISO, endISO }) {
+/**
+ * Lista eventos para mosaico da clínica (admin/secretaria) dentro de um tenant específico.
+ * IMPORTANTE: SEM tenant_id aqui vira vazamento multi-tenant.
+ */
+export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO }) {
+ assertValidTenantId(tenantId)
if (!ownerIds?.length) return []
+ if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).')
+
+ // Sanitiza ownerIds
+ const safeOwnerIds = ownerIds
+ .filter(id => typeof id === 'string' && id && id !== 'null' && id !== 'undefined')
+
+ if (!safeOwnerIds.length) return []
const { data, error } = await supabase
.from('agenda_eventos')
.select('*')
- .in('owner_id', ownerIds)
+ .eq('tenant_id', tenantId)
+ .in('owner_id', safeOwnerIds)
.gte('inicio_em', startISO)
.lt('inicio_em', endISO)
.order('inicio_em', { ascending: true })
@@ -53,7 +89,7 @@ export async function listClinicEvents ({ ownerIds, startISO, endISO }) {
}
export async function listTenantStaff (tenantId) {
- if (!tenantId || tenantId === 'null' || tenantId === 'undefined') return []
+ assertValidTenantId(tenantId)
const { data, error } = await supabase
.from('v_tenant_staff')
@@ -64,10 +100,33 @@ export async function listTenantStaff (tenantId) {
return data || []
}
+/**
+ * Criação segura:
+ * - injeta tenant_id do tenantStore
+ * - injeta owner_id do usuário logado (ignora owner_id vindo de fora)
+ *
+ * Observação:
+ * - Para admin/secretária criar para outros owners, o ideal é ter uma função separada
+ * (ex.: createClinicAgendaEvento) que permita owner_id explicitamente.
+ * Por enquanto, deixo esta função como "safe default" para terapeuta.
+ */
export async function createAgendaEvento (payload) {
+ const uid = await getUid()
+ const tenantStore = useTenantStore()
+ const tenantId = tenantStore.activeTenantId
+ assertValidTenantId(tenantId)
+
+ if (!payload) throw new Error('Payload vazio.')
+
+ const insertPayload = {
+ ...payload,
+ tenant_id: tenantId,
+ owner_id: uid
+ }
+
const { data, error } = await supabase
.from('agenda_eventos')
- .insert(payload)
+ .insert(insertPayload)
.select('*')
.single()
@@ -75,11 +134,24 @@ export async function createAgendaEvento (payload) {
return data
}
-export async function updateAgendaEvento (id, patch) {
+/**
+ * Atualização segura:
+ * - filtra por id + tenant_id (evita update cruzado por acidente)
+ * RLS deve reforçar isso no banco.
+ */
+export async function updateAgendaEvento (id, patch, { tenantId: tenantIdArg } = {}) {
+ if (!id) throw new Error('ID inválido.')
+ if (!patch) throw new Error('Patch vazio.')
+
+ const tenantStore = useTenantStore()
+ const tenantId = tenantIdArg || tenantStore.activeTenantId
+ assertValidTenantId(tenantId)
+
const { data, error } = await supabase
.from('agenda_eventos')
.update(patch)
.eq('id', id)
+ .eq('tenant_id', tenantId)
.select('*')
.single()
@@ -87,12 +159,61 @@ export async function updateAgendaEvento (id, patch) {
return data
}
-export async function deleteAgendaEvento (id) {
+/**
+ * Delete seguro:
+ * - filtra por id + tenant_id
+ */
+export async function deleteAgendaEvento (id, { tenantId: tenantIdArg } = {}) {
+ if (!id) throw new Error('ID inválido.')
+
+ const tenantStore = useTenantStore()
+ const tenantId = tenantIdArg || tenantStore.activeTenantId
+ assertValidTenantId(tenantId)
+
const { error } = await supabase
.from('agenda_eventos')
.delete()
.eq('id', id)
+ .eq('tenant_id', tenantId)
if (error) throw error
return true
+}
+
+// Adicione no mesmo arquivo: src/features/agenda/services/agendaRepository.js
+
+/**
+ * Criação para a área da clínica (admin/secretária):
+ * - exige tenantId explícito (ou cai no tenantStore)
+ * - permite definir owner_id (terapeuta dono do compromisso)
+ *
+ * Segurança real deve ser garantida por RLS:
+ * - clinic_admin/tenant_admin pode criar para qualquer owner dentro do tenant
+ * - therapist não deve conseguir passar daqui (guard + RLS)
+ */
+export async function createClinicAgendaEvento (payload, { tenantId: tenantIdArg } = {}) {
+ const tenantStore = useTenantStore()
+ const tenantId = tenantIdArg || tenantStore.activeTenantId
+ assertValidTenantId(tenantId)
+
+ if (!payload) throw new Error('Payload vazio.')
+
+ const ownerId = payload.owner_id
+ if (!ownerId || ownerId === 'null' || ownerId === 'undefined') {
+ throw new Error('owner_id é obrigatório para criação pela clínica.')
+ }
+
+ const insertPayload = {
+ ...payload,
+ tenant_id: tenantId
+ }
+
+ const { data, error } = await supabase
+ .from('agenda_eventos')
+ .insert(insertPayload)
+ .select('*')
+ .single()
+
+ if (error) throw error
+ return data
}
\ No newline at end of file
diff --git a/src/layout/AppMenu.vue b/src/layout/AppMenu.vue
index 8aa981c..0999b5f 100644
--- a/src/layout/AppMenu.vue
+++ b/src/layout/AppMenu.vue
@@ -25,19 +25,38 @@ const { layoutState } = useLayout()
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
+const tenantId = computed(() => tenantStore.activeTenantId || null)
+
/**
* ✅ Role canônico pro MENU:
- * - Prioriza o role do tenant (mesma fonte usada pelo router guard)
- * - Faz fallback pro sessionRole (ex.: telas fora de tenant)
+ * - PRIORIDADE 1: contexto de rota (evita menu errado quando role do tenant atrasa/falha)
+ * Ex.: /therapist/* => força menu therapist; /admin* => força menu admin
+ * - PRIORIDADE 2: se há tenant ativo: usa role do tenant
+ * - PRIORIDADE 3: fallback pro sessionRole (ex.: telas fora de tenant)
+ *
+ * Motivo: o bug que você descreveu (terapeuta vendo admin.menu) geralmente é:
+ * - tenant role ainda não carregou OU tenantId está null
+ * - sessionRole vem como 'admin'
+ * Então, rota > tenant > session elimina o menu “trocar sozinho”.
*/
const navRole = computed(() => {
- return tenantStore.activeRole || sessionRole.value || null
+ const p = String(route.path || '')
+
+ // ✅ blindagem por contexto
+ if (p.startsWith('/therapist')) return 'therapist'
+ if (p.startsWith('/admin') || p.startsWith('/clinic')) return 'clinic_admin'
+ if (p.startsWith('/patient')) return 'patient'
+
+ // ✅ dentro de tenant: confia no role do tenant
+ if (tenantId.value) return tenantStore.activeRole || null
+
+ // ✅ fora de tenant: fallback pro sessionRole
+ return sessionRole.value || null
})
const model = computed(() => {
- // ✅ fonte correta: tenant role (clinic_admin/therapist/patient)
- // fallback: profiles.role (admin/therapist/patient)
- const effectiveRole = tenantStore.activeRole || sessionRole.value
+ // ✅ role efetivo do menu já vem “canônico” do navRole
+ const effectiveRole = navRole.value
const base = getMenuByRole(effectiveRole, { isSaasAdmin: sessionIsSaasAdmin.value }) || []
@@ -52,8 +71,6 @@ const model = computed(() => {
return [...base].sort((a, b) => priorityOrder(a) - priorityOrder(b))
})
-const tenantId = computed(() => tenantStore.activeTenantId || null)
-
// quando troca tenant -> recarrega entitlements
watch(
tenantId,
@@ -64,7 +81,7 @@ watch(
{ immediate: true }
)
-// ✅ quando troca role efetivo do menu (tenant role / session role) -> recarrega entitlements do tenant atual
+// ✅ quando troca role efetivo do menu (via rota/tenant/session) -> recarrega entitlements do tenant atual
watch(
() => navRole.value,
async () => {
diff --git a/src/navigation/menus/admin.menu.js b/src/navigation/menus/admin.menu.js
index 2fb0667..755f6f8 100644
--- a/src/navigation/menus/admin.menu.js
+++ b/src/navigation/menus/admin.menu.js
@@ -15,16 +15,10 @@ export default function adminMenu (ctx = {}) {
icon: 'pi pi-fw pi-home',
to: '/admin'
},
- {
- label: 'Agenda',
- icon: 'pi pi-fw pi-calendar',
- to: '/admin/agenda',
- feature: 'agenda.view'
- },
{
- label: 'Agenda da Clínica',
+ label: 'Agenda do Terapeuta',
icon: 'pi pi-fw pi-sitemap',
- to: '/admin/agenda/clinica',
+ to: '/therapist/agenda',
feature: 'agenda.view'
}
]
diff --git a/src/router/guards.js b/src/router/guards.js
index 66cf1a7..6966ab1 100644
--- a/src/router/guards.js
+++ b/src/router/guards.js
@@ -233,7 +233,15 @@ export function applyGuards (router) {
// - se tem memberships active mas activeTenantId está null -> seta e segue
if (!tenant.activeTenantId) {
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
- const firstActive = mem.find(m => m && m.status === 'active' && m.tenant_id)
+
+ // 1) tenta casar role da rota (ex.: therapist) com membership
+ const wantedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : []
+ const preferred = wantedRoles.length
+ ? mem.find(m => m && m.status === 'active' && m.tenant_id && wantedRoles.includes(m.role))
+ : null
+
+ // 2) fallback: primeiro active
+ const firstActive = preferred || mem.find(m => m && m.status === 'active' && m.tenant_id)
if (!firstActive) {
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
@@ -292,15 +300,19 @@ export function applyGuards (router) {
}
// roles guard (plural)
- const allowedRoles = to.meta?.roles
- if (Array.isArray(allowedRoles) && allowedRoles.length) {
- if (!matchesRoles(allowedRoles, tenant.activeRole)) {
- const fallback = roleToPath(tenant.activeRole)
- if (to.path === fallback) { console.timeEnd(tlabel); return { path: '/pages/access' } }
- console.timeEnd(tlabel)
- return { path: fallback }
- }
- }
+ // Se a rota pede roles específicas e o role ativo não bate,
+ // tenta ajustar o activeRole dentro do mesmo tenant (se houver membership compatível).
+ const allowedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : null
+ if (allowedRoles && allowedRoles.length && !allowedRoles.includes(tenant.activeRole)) {
+ const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
+ const compatible = mem.find(m =>
+ m && m.status === 'active' && m.tenant_id === tenantId && allowedRoles.includes(m.role)
+ )
+ if (compatible) {
+ // muda role ativo para o compatível
+ tenant.activeRole = compatible.role
+ }
+ }
// role guard (singular) - mantém compatibilidade
const requiredRole = to.meta?.role
diff --git a/src/views/pages/auth/Login.vue b/src/views/pages/auth/Login.vue
index a8b26b6..2486fa6 100644
--- a/src/views/pages/auth/Login.vue
+++ b/src/views/pages/auth/Login.vue
@@ -108,6 +108,11 @@ async function onSubmit () {
// ✅ fonte de verdade: tenant_members (rpc my_tenants)
await tenant.loadSessionAndTenant()
+
+ console.log('[LOGIN] tenant.user', tenant.user)
+ console.log('[LOGIN] memberships', tenant.memberships)
+ console.log('[LOGIN] activeTenantId', tenant.activeTenantId)
+ console.log('[LOGIN] activeRole', tenant.activeRole)
if (!tenant.user) {
authError.value = 'Não foi possível obter a sessão após login.'