@@ -302,6 +469,8 @@ import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import SelectButton from 'primevue/selectbutton'
import SplitButton from 'primevue/splitbutton'
+import Tag from 'primevue/tag'
+import Divider from 'primevue/divider'
import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid'
@@ -348,6 +517,9 @@ const calendarView = ref('day') // day | week | month
const timeMode = ref('my') // 24 | 12 | my
const search = ref('')
+// Modal resultados (mobile)
+const searchModalOpen = ref(false)
+
// Mini calendário
const miniDate = ref(new Date())
@@ -440,23 +612,50 @@ const fcViewName = computed(() => {
return 'dayGridMonth'
})
-const filteredRows = computed(() => {
+/* -------------------------------------------------
+ ✅ Correção:
+ - calendário NÃO filtra por search
+ - search vira lista de resultados (desktop sidebar / mobile dialog)
+-------------------------------------------------- */
+const calendarRows = computed(() => {
const list = rows.value || []
- const q = (search.value || '').trim().toLowerCase()
-
return list.filter(r => {
const tipo = String(r.tipo || '').toLowerCase()
- const titulo = String(r.titulo || '').toLowerCase()
- const obs = String(r.observacoes || '').toLowerCase()
-
if (onlySessions.value && !tipo.includes('sess')) return false
- if (q && !(titulo.includes(q) || obs.includes(q))) return false
return true
})
})
+const searchTrim = computed(() => String(search.value || '').trim())
+const searchLoading = computed(() => false) // placeholder (se quiser debounce/async)
+
+const searchResults = computed(() => {
+ const q = searchTrim.value.toLowerCase()
+ if (!q) return []
+
+ return (calendarRows.value || []).filter(r => {
+ const tipo = String(r.tipo || '').toLowerCase()
+ const titulo = String(r.titulo || '').toLowerCase()
+ const obs = String(r.observacoes || '').toLowerCase()
+
+ // Se seu row tiver campos do paciente, plugue aqui:
+ const pacienteNome = String(r.paciente_nome || r.patient_name || r.nome_paciente || '').toLowerCase()
+ const pacienteEmail = String(r.paciente_email || r.patient_email || '').toLowerCase()
+ const pacienteTel = String(r.paciente_phone || r.patient_phone || '').toLowerCase()
+
+ return (
+ titulo.includes(q) ||
+ obs.includes(q) ||
+ tipo.includes(q) ||
+ pacienteNome.includes(q) ||
+ pacienteEmail.includes(q) ||
+ pacienteTel.includes(q)
+ )
+ })
+})
+
const calendarEvents = computed(() => {
- const base = mapAgendaEventosToCalendarEvents(filteredRows.value || [])
+ const base = mapAgendaEventosToCalendarEvents(calendarRows.value || [])
const breaks =
settings.value && currentRange.value.start && currentRange.value.end
@@ -537,11 +736,21 @@ const fcOptions = computed(() => ({
eventDrop: (info) => persistMoveOrResize(info, 'Movido'),
eventResize: (info) => persistMoveOrResize(info, 'Redimensionado'),
+ // ✅ destaque da busca sem remover eventos (melhoria UX)
eventClassNames: (arg) => {
const tipo = String(arg?.event?.extendedProps?.tipo || arg?.event?.extendedProps?.kind || '').toLowerCase()
- if (tipo.includes('sess')) return ['evt-session']
- if (tipo.includes('bloq')) return ['evt-block']
- return []
+ const title = String(arg?.event?.title || '').toLowerCase()
+ const obs = String(arg?.event?.extendedProps?.observacoes || '').toLowerCase()
+
+ const q = searchTrim.value.toLowerCase()
+ const hit = q && (title.includes(q) || obs.includes(q) || tipo.includes(q))
+
+ const classes = []
+ if (tipo.includes('sess')) classes.push('evt-session')
+ if (tipo.includes('bloq')) classes.push('evt-block')
+ if (q && hit) classes.push('evt-hit')
+ if (q && !hit) classes.push('evt-dim')
+ return classes
}
}))
@@ -554,6 +763,11 @@ watch(calendarView, async () => {
getApi()?.changeView?.(fcViewName.value)
})
+// se limpou a busca, fecha modal mobile automaticamente
+watch(searchTrim, (v) => {
+ if (!v) searchModalOpen.value = false
+})
+
// -----------------------------
// Ações Topbar
// -----------------------------
@@ -561,6 +775,17 @@ function goToday () { getApi()?.today?.() }
function goPrev () { getApi()?.prev?.() }
function goNext () { getApi()?.next?.() }
+function clearSearch () { search.value = '' }
+function clearSearchAndClose () {
+ search.value = ''
+ searchModalOpen.value = false
+}
+
+function openSearchModal () {
+ if (!searchTrim.value) return
+ searchModalOpen.value = true
+}
+
function toggleMonthPicker () {
monthPickerDate.value = new Date(currentDate.value)
monthPickerVisible.value = true
@@ -580,6 +805,40 @@ function onMiniPick (d) {
function miniPrevMonth () { miniDate.value = shiftMonth(miniDate.value, -1) }
function miniNextMonth () { miniDate.value = shiftMonth(miniDate.value, +1) }
+/* -----------------------------
+ Clique no resultado:
+ - vai para o dia do evento
+ - abre o dialog (edit)
+------------------------------ */
+function gotoResult (row) {
+ const api = getApi()
+ if (api && row?.inicio_em) api.gotoDate(new Date(row.inicio_em))
+
+ dialogEventRow.value = row
+ dialogStartISO.value = ''
+ dialogEndISO.value = ''
+ dialogOpen.value = true
+}
+
+function gotoResultFromModal (row) {
+ searchModalOpen.value = false
+ nextTick(() => gotoResult(row))
+}
+
+function fmtDateTime (iso) {
+ if (!iso) return '—'
+ const d = new Date(iso)
+ return d.toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' })
+}
+
+function labelTipo (tipo) {
+ const t = String(tipo || '').toLowerCase()
+ if (t.includes('sess')) return 'Sessão'
+ if (t.includes('bloq')) return 'Bloqueio'
+ if (t.includes('avali')) return 'Avaliação'
+ return (tipo || 'Evento')
+}
+
function onCreateFromButton () {
if (!ownerId.value) {
toast.add({ severity: 'warn', summary: 'Agenda', detail: 'Aguarde carregar as configurações da agenda.', life: 3000 })
@@ -776,10 +1035,16 @@ onMounted(async () => {
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
+
+
+
\ No newline at end of file
diff --git a/src/features/patients/cadastro/PatientsCadastroPage.vue b/src/features/patients/cadastro/PatientsCadastroPage.vue
index fd17815..8f139a8 100644
--- a/src/features/patients/cadastro/PatientsCadastroPage.vue
+++ b/src/features/patients/cadastro/PatientsCadastroPage.vue
@@ -7,11 +7,39 @@ import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client'
+import { useTenantStore } from '@/stores/tenantStore'
+
const route = useRoute()
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
+const tenantStore = useTenantStore()
+
+async function getCurrentTenantId () {
+ // ajuste para o nome real no seu store
+ return tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
+}
+
+async function getCurrentMemberId (tenantId) {
+ const { data: authData, error: authError } = await supabase.auth.getUser()
+ if (authError) throw authError
+ const uid = authData?.user?.id
+ if (!uid) throw new Error('Sessão inválida.')
+
+ const { data, error } = await supabase
+ .from('tenant_members')
+ .select('id')
+ .eq('tenant_id', tenantId)
+ .eq('user_id', uid)
+ .eq('status', 'active')
+ .single()
+
+ if (error) throw error
+ if (!data?.id) throw new Error('Responsible member not found')
+ return data.id
+}
+
// ------------------------------------------------------
// Accordion: abre 1 por vez + scroll
// ------------------------------------------------------
@@ -400,6 +428,8 @@ async function getOwnerId () {
// ------------------------------------------------------
const PACIENTES_COLUNAS_PERMITIDAS = new Set([
'owner_id',
+ 'tenant_id',
+ 'responsible_member_id',
// Sessão 1
'nome_completo',
@@ -688,12 +718,14 @@ async function createPatient (payload) {
}
async function updatePatient (id, payload) {
- const ownerId = await getOwnerId()
const { error } = await supabase
.from('patients')
- .update({ ...payload, updated_at: new Date().toISOString() })
+ .update({
+ ...payload,
+ updated_at: new Date().toISOString()
+ })
.eq('id', id)
- .eq('owner_id', ownerId)
+
if (error) throw error
}
@@ -840,38 +872,55 @@ async function fetchAll () {
watch(() => route.params?.id, fetchAll, { immediate: true })
onMounted(fetchAll)
+
+async function resolveTenantContextOrFail () {
+ const { data: authData, error: authError } = await supabase.auth.getUser()
+ if (authError) throw authError
+ const uid = authData?.user?.id
+ if (!uid) throw new Error('Sessão inválida.')
+
+ const { data, error } = await supabase
+ .from('tenant_members')
+ .select('id, tenant_id')
+ .eq('user_id', uid)
+ .eq('status', 'active')
+ .order('created_at', { ascending: false }) // se existir
+ .limit(1)
+ .single()
+
+ if (error) throw error
+ if (!data?.tenant_id || !data?.id) throw new Error('Responsible member not found')
+
+ return { tenantId: data.tenant_id, memberId: data.id }
+}
+
// ------------------------------------------------------
// Submit
// ------------------------------------------------------
async function onSubmit () {
- if (saving.value) return
-
- // validações...
- saving.value = true
try {
+ saving.value = true
+
const ownerId = await getOwnerId()
+ const { tenantId, memberId } = await resolveTenantContextOrFail()
+
const payload = sanitizePayload(form.value, ownerId)
- let id = patientId.value
+ // multi-tenant obrigatório
+ payload.tenant_id = tenantId
+ payload.responsible_member_id = memberId
+
if (isEdit.value) {
- await updatePatient(id, payload)
+ await updatePatient(patientId.value, payload)
+ toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 })
} else {
const created = await createPatient(payload)
- id = created?.id
- if (!id) throw new Error('Falha ao obter ID do paciente criado.')
- router.replace(`${getAreaBase()}/patients/cadastro/${id}`)
+ toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
+ // opcional: router.push(`${getAreaBase()}/patients/${created.id}`)
}
-
- await replacePatientGroups(id, grupoIdSelecionado.value || null)
- await replacePatientTags(id, tagIdsSelecionadas.value || [])
-
- // Avatar por último, mas dentro do mesmo fluxo (sem toast de sucesso)
- await maybeUploadAvatar(ownerId, id)
-
- // ✅ um sucesso só
- toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente salvo.', life: 2500 })
- } catch (err) {
- toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao salvar paciente', life: 4000 })
+ } catch (e) {
+ console.error(e)
+ toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar paciente.', life: 4000 })
} finally {
saving.value = false
}
diff --git a/src/layout/AppMenuFooterPanel.vue b/src/layout/AppMenuFooterPanel.vue
index 2fc98b2..acc7326 100644
--- a/src/layout/AppMenuFooterPanel.vue
+++ b/src/layout/AppMenuFooterPanel.vue
@@ -5,13 +5,19 @@ import { useRouter } from 'vue-router'
import { sessionUser, sessionRole } from '@/app/session'
import { supabase } from '@/lib/supabase/client'
+import { useRoleGuard } from '@/composables/useRoleGuard'
+
const router = useRouter()
const pop = ref(null)
-function isAdminRole (r) {
- return r === 'admin' || r === 'tenant_admin'
-}
+// ------------------------------------------------------
+// RBAC (Tenant): fonte da verdade para permissões por papel
+// ------------------------------------------------------
+const { role, canSee, isPatient } = useRoleGuard()
+// ------------------------------------------------------
+// UI labels (nome/iniciais)
+// ------------------------------------------------------
const initials = computed(() => {
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || ''
const parts = String(name).trim().split(/\s+/).filter(Boolean)
@@ -25,15 +31,30 @@ const label = computed(() => {
return name || sessionUser.value?.email || 'Conta'
})
+/**
+ * sublabel:
+ * Aqui eu recomendo exibir o papel do TENANT (role do useRoleGuard),
+ * porque é ele que realmente governa a UI dentro da clínica.
+ *
+ * Se você preferir manter sessionRole como rótulo "global", ok,
+ * mas isso pode confundir quando o usuário estiver em contextos diferentes.
+ */
const sublabel = computed(() => {
- const r = sessionRole.value
+ const r = role.value || sessionRole.value
if (!r) return 'Sessão'
- if (isAdminRole(r)) return 'Administrador'
+
+ // tenant roles (confirmados no banco): tenant_admin | therapist | patient
+ if (r === 'tenant_admin') return 'Administrador'
if (r === 'therapist') return 'Terapeuta'
if (r === 'patient') return 'Paciente'
+
+ // fallback (caso venha algo diferente)
return r
})
+// ------------------------------------------------------
+// Popover helpers
+// ------------------------------------------------------
function toggle (e) {
pop.value?.toggle(e)
}
@@ -44,35 +65,9 @@ function close () {
} catch {}
}
-function goMyProfile() {
- close()
-
- // Navegação segura para Account → Profile
- safePush(
- { name: 'account-profile' },
- '/account/profile'
- )
-}
-
-function goSettings () {
- close()
-
- const r = sessionRole.value
-
- if (isAdminRole(r) || r === 'therapist') {
- // rota por name (como você já usa)
- router.push({ name: 'ConfiguracoesAgenda' })
- return
- }
-
- if (r === 'patient') {
- router.push('/patient/portal')
- return
- }
-
- router.push('/')
-}
-
+// ------------------------------------------------------
+// Navegação segura (NAME com fallback)
+// ------------------------------------------------------
async function safePush (target, fallback) {
try {
await router.push(target)
@@ -90,6 +85,35 @@ async function safePush (target, fallback) {
}
}
+function goMyProfile () {
+ close()
+
+ // Navegação segura para Account → Profile
+ safePush(
+ { name: 'account-profile' },
+ '/account/profile'
+ )
+}
+
+function goSettings () {
+ close()
+
+ // ✅ Decide por RBAC (tenant role), não por sessionRole
+ if (canSee('settings.view')) {
+ router.push({ name: 'ConfiguracoesAgenda' })
+ return
+ }
+
+ // Se não pode ver configurações, manda paciente pro portal.
+ // (Se amanhã você criar outro papel, esta regra continua segura.)
+ if (isPatient.value) {
+ router.push('/patient/portal')
+ return
+ }
+
+ router.push('/')
+}
+
function goSecurity () {
close()
@@ -150,6 +174,7 @@ async function signOut () {
-
-
+
+
@@ -186,4 +211,4 @@ async function signOut () {
-
+
\ No newline at end of file
diff --git a/src/router/accessRedirects.js b/src/router/accessRedirects.js
new file mode 100644
index 0000000..4db1673
--- /dev/null
+++ b/src/router/accessRedirects.js
@@ -0,0 +1,71 @@
+// src/router/accessRedirects.js
+
+/**
+ * Redirecionamentos padronizados para:
+ * - RBAC (papel): usuário NÃO deveria acessar essa área nem pagando → manda pra 403 (ou home do papel)
+ * - Entitlements (plano): usuário poderia acessar se tivesse feature → manda pro /upgrade
+ *
+ * Por que isso existe?
+ * - Evitar o bug clássico: “pessoa sem permissão caiu no /upgrade”
+ * - Padronizar o comportamento do app em um único lugar
+ * - Deixar claro: RBAC ≠ Plano
+ *
+ * Convenção recomendada:
+ * - RBAC (role): sempre é bloqueio (403) OU home do papel (UX mais “suave”)
+ * - Plano (feature): sempre é upgrade (porque o usuário *poderia* ter acesso pagando)
+ */
+
+/**
+ * Home por role (tenant_members.role).
+ * Obs: 'admin' é role GLOBAL (profiles.role). Aqui é guard de tenant,
+ * mas mantemos um fallback seguro para legado.
+ */
+export function roleHomePath (role) {
+ // ✅ clínica: aceita nomes canônicos e legado
+ if (role === 'clinic_admin' || role === 'tenant_admin') return '/admin'
+ if (role === 'therapist') return '/therapist'
+ if (role === 'patient') return '/portal'
+
+ // ✅ fallback (não deveria acontecer em tenant)
+ if (role === 'admin') return '/admin'
+
+ return '/'
+}
+
+/**
+ * RBAC (papel) → padrão: acesso negado (403).
+ *
+ * Se você preferir UX “suave”, pode mandar para a home do papel.
+ * Eu deixei as duas opções:
+ * - use403 = true → sempre /pages/access (recomendado para clareza)
+ * - use403 = false → home do papel (útil quando você quer “auto-corrigir” navegação)
+ */
+export function denyByRole ({ to, currentRole, use403 = true } = {}) {
+ // ✅ padrão forte: 403 (não é caso de upgrade)
+ if (use403) return { path: '/pages/access' }
+
+ // modo “suave”: manda pra home do papel
+ const fallback = roleHomePath(currentRole)
+
+ // evita loop: se já está no fallback, manda pra página de acesso negado
+ if (to?.path && to.path === fallback) {
+ return { path: '/pages/access' }
+ }
+
+ return { path: fallback }
+}
+
+/**
+ * Entitlements (plano) → upgrade.
+ * missingFeature: feature key (ex: 'online_scheduling.manage')
+ * redirectTo: para onde voltar após upgrade
+ */
+export function denyByPlan ({ to, missingFeature, redirectTo } = {}) {
+ return {
+ path: '/upgrade',
+ query: {
+ feature: missingFeature || '',
+ redirectTo: redirectTo || to?.fullPath || '/'
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/router/guards.js b/src/router/guards.js
index 6966ab1..08eee16 100644
--- a/src/router/guards.js
+++ b/src/router/guards.js
@@ -11,6 +11,9 @@ import { buildUpgradeUrl } from '@/utils/upgradeContext'
import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session'
+// ✅ separa RBAC (papel) vs Plano (upgrade)
+import { denyByRole, denyByPlan } from '@/router/accessRedirects'
+
// cache simples (evita bater no banco em toda navegação)
let sessionUidCache = null
@@ -234,14 +237,14 @@ export function applyGuards (router) {
if (!tenant.activeTenantId) {
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
- // 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
+ // 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)
+ // 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 }
@@ -299,42 +302,60 @@ export function applyGuards (router) {
}
}
- // roles guard (plural)
- // 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
- }
- }
+ // ------------------------------------------------
+ // ✅ RBAC (roles) — BLOQUEIA se não for compatível
+ //
+ // Importante:
+ // - Isso é "papel": se falhar, NÃO é caso de upgrade.
+ // - Só depois disso checamos feature/plano.
+ // ------------------------------------------------
+ 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 (mesmo tenant)
+ tenant.activeRole = compatible.role
+ } else {
+ // 🔥 aqui era o "furo": antes ajustava se achasse, mas se não achasse, deixava passar.
+ console.timeEnd(tlabel)
+ return denyByRole({ to, currentRole: tenant.activeRole })
+ }
+ }
// role guard (singular) - mantém compatibilidade
const requiredRole = to.meta?.role
if (requiredRole && tenant.activeRole !== requiredRole) {
- const fallback = roleToPath(tenant.activeRole)
- if (to.path === fallback) { console.timeEnd(tlabel); return { path: '/pages/access' } }
+ // RBAC singular também é "papel" → cai fora (não é upgrade)
console.timeEnd(tlabel)
- return { path: fallback }
+ return denyByRole({ to, currentRole: tenant.activeRole })
}
- // feature guard (entitlements/plano → upgrade)
+ // ------------------------------------------------
+ // ✅ feature guard (entitlements/plano → upgrade)
+ //
+ // Aqui sim é caso de upgrade:
+ // - o usuário "poderia" usar, mas o plano do tenant não liberou.
+ // ------------------------------------------------
const requiredFeature = to.meta?.feature
- if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
- if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
+ if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
+ // evita loop
+ if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
- const url = buildUpgradeUrl({
- missingKeys: [requiredFeature],
- redirectTo: to.fullPath
- })
- console.timeEnd(tlabel)
- return url
- }
+ // Mantém compatibilidade com seu fluxo existente (buildUpgradeUrl)
+ const url = buildUpgradeUrl({
+ missingKeys: [requiredFeature],
+ redirectTo: to.fullPath
+ })
+
+ // Se quiser padronizar no futuro, você pode trocar por:
+ // return denyByPlan({ to, missingFeature: requiredFeature, redirectTo: to.fullPath })
+ console.timeEnd(tlabel)
+ return url
+ }
console.timeEnd(tlabel)
return true
diff --git a/src/router/index.js b/src/router/index.js
index 5920534..83df5fb 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -38,7 +38,7 @@ const routes = [
})
},
- { path: '/:pathMatch(.*)*', name: 'notfound', component: () => import('@/views/pages/NotFound.vue') }
+ // inserido no routes.misc { path: '/:pathMatch(.*)*', name: 'notfound', component: () => import('@/views/pages/NotFound.vue') }
];
const router = createRouter({
diff --git a/src/router/routes.misc.js b/src/router/routes.misc.js
index cc61170..b0d317f 100644
--- a/src/router/routes.misc.js
+++ b/src/router/routes.misc.js
@@ -6,10 +6,28 @@ export default {
name: 'landing',
component: () => import('@/views/pages/Landing.vue')
},
+
+ // 404
{
path: 'pages/notfound',
name: 'notfound',
component: () => import('@/views/pages/NotFound.vue')
+ },
+
+ // 403 (Acesso negado - RBAC)
+ {
+ path: 'pages/access', // ❗ SEM barra inicial aqui
+ name: 'AccessDenied',
+ component: () => import('@/views/pages/misc/AccessDeniedPage.vue'),
+ meta: {
+ requiresAuth: true
+ }
+ },
+
+ // Catch-all (SEMPRE o último)
+ {
+ path: ':pathMatch(.*)*',
+ redirect: { name: 'notfound' }
}
]
-}
+}
\ No newline at end of file
diff --git a/src/views/pages/HomeCards.vue b/src/views/pages/HomeCards.vue
index d45f15f..9fb3de7 100644
--- a/src/views/pages/HomeCards.vue
+++ b/src/views/pages/HomeCards.vue
@@ -235,22 +235,22 @@ onMounted(async () => {