Ajuste rotas, Menus, Layout, Permissãoes UserRoleGuard
This commit is contained in:
@@ -1,3 +1,50 @@
|
||||
/**
|
||||
* ---------------------------------------------------------
|
||||
* useAuth()
|
||||
* ---------------------------------------------------------
|
||||
*
|
||||
* Stack do projeto:
|
||||
* - Vue 3 (Composition API)
|
||||
* - PrimeVue (UI)
|
||||
* - Supabase (Auth + Database)
|
||||
*
|
||||
* Responsabilidade:
|
||||
* Camada global de AUTENTICAÇÃO baseada no Supabase.
|
||||
*
|
||||
* O que este composable faz:
|
||||
* - Obtém a sessão atual do Supabase (auth.getSession)
|
||||
* - Mantém o estado reativo do usuário autenticado
|
||||
* - Escuta mudanças de autenticação (login, logout, refresh de token)
|
||||
* - Expõe apenas a identidade do usuário (user)
|
||||
*
|
||||
* O que ele NÃO faz:
|
||||
* - Não controla permissões
|
||||
* - Não valida roles
|
||||
* - Não decide acesso a telas ou botões
|
||||
* - Não aplica regras de plano (Free/Pro)
|
||||
*
|
||||
* Conceito arquitetural:
|
||||
* Este arquivo trata apenas de IDENTIDADE (Auth).
|
||||
*
|
||||
* Auth → "Quem é o usuário autenticado?"
|
||||
* AuthZ → "O que esse usuário pode acessar ou executar?"
|
||||
*
|
||||
* A AUTORIZAÇÃO deve ser tratada em outra camada,
|
||||
* como por exemplo:
|
||||
* - useAuthz()
|
||||
* - tenantStore (membership.role)
|
||||
* - entitlementsStore (features do plano)
|
||||
*
|
||||
* Observação importante:
|
||||
* O role do usuário NÃO vem do Supabase Auth.
|
||||
* Ele é definido na tabela de membership (multi-tenant).
|
||||
*
|
||||
* Portanto:
|
||||
* Nunca utilizar apenas `user` para controle de acesso.
|
||||
*
|
||||
* Esse composable é apenas a base de identidade do sistema.
|
||||
*/
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
|
||||
115
src/composables/useRoleGuard.js
Normal file
115
src/composables/useRoleGuard.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { computed } from 'vue'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
/**
|
||||
* ---------------------------------------------------------
|
||||
* useRoleGuard() — RBAC puro (somente PAPEL do tenant)
|
||||
* ---------------------------------------------------------
|
||||
*
|
||||
* Objetivo:
|
||||
* Controlar visibilidade/ações por PAPEL dentro do tenant (clínica).
|
||||
* Aqui NÃO entra plano, módulos ou features pagas.
|
||||
*
|
||||
* Fonte da verdade do papel (tenant role):
|
||||
* - public.tenant_members.role → 'tenant_admin' | 'therapist' | 'patient'
|
||||
* - no frontend: tenantStore.membership.role (ou fallback tenantStore.activeRole)
|
||||
*
|
||||
* O que este composable resolve:
|
||||
* - "Esse papel pode ver/usar este elemento?"
|
||||
* Ex:
|
||||
* - paciente não vê botão Configurações
|
||||
* - therapist e tenant_admin veem
|
||||
*
|
||||
* O que ele NÃO resolve (de propósito):
|
||||
* - liberar feature por plano (Free/Pro)
|
||||
* - limitar módulos / recursos contratados
|
||||
*
|
||||
* Para controle por plano, use o entStore:
|
||||
* - entStore.can('feature_key')
|
||||
*
|
||||
* Padrão recomendado (RBAC + Plano):
|
||||
* Quando algo depende do PLANO e do PAPEL, combine no template:
|
||||
*
|
||||
* v-if="entStore.can('online_scheduling.manage') && canSee('settings.view')"
|
||||
*
|
||||
* Interpretação:
|
||||
* - Gate A (Plano): o tenant tem a feature liberada?
|
||||
* - Gate B (Papel): o usuário, pelo papel, pode ver/usar isso?
|
||||
*
|
||||
* Nota de segurança:
|
||||
* Isso controla UI/rotas (experiência). Segurança real deve existir no backend (RLS).
|
||||
* ---------------------------------------------------------
|
||||
*/
|
||||
export function useRoleGuard () {
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// Roles confirmados no seu banco (tenant_members.role)
|
||||
const ROLES = Object.freeze({
|
||||
ADMIN: 'tenant_admin',
|
||||
THERAPIST: 'therapist',
|
||||
PATIENT: 'patient'
|
||||
})
|
||||
|
||||
// Papel atual no tenant ativo
|
||||
const role = computed(() => tenantStore.membership?.role ?? tenantStore.activeRole ?? null)
|
||||
|
||||
// Opcional: útil se você quiser segurar render até carregar
|
||||
const isReady = computed(() => !!role.value)
|
||||
|
||||
// Helpers semânticos
|
||||
const isTenantAdmin = computed(() => role.value === ROLES.ADMIN)
|
||||
const isTherapist = computed(() => role.value === ROLES.THERAPIST)
|
||||
const isPatient = computed(() => role.value === ROLES.PATIENT)
|
||||
const isStaff = computed(() => [ROLES.ADMIN, ROLES.THERAPIST].includes(role.value))
|
||||
|
||||
// Matriz RBAC (somente por papel)
|
||||
// Dica: mantenha chaves no padrão "modulo.acao"
|
||||
const rbac = Object.freeze({
|
||||
// Botões/telas de configuração do tenant
|
||||
'settings.view': [ROLES.ADMIN, ROLES.THERAPIST],
|
||||
|
||||
// Perfil/conta (normalmente todos)
|
||||
'profile.view': [ROLES.ADMIN, ROLES.THERAPIST, ROLES.PATIENT],
|
||||
|
||||
// Segurança (normalmente todos; ajuste se quiser restringir)
|
||||
'security.view': [ROLES.ADMIN, ROLES.THERAPIST, ROLES.PATIENT]
|
||||
|
||||
// Exemplos futuros:
|
||||
// 'agenda.view': [ROLES.ADMIN, ROLES.THERAPIST, ROLES.PATIENT],
|
||||
// 'agenda.manage': [ROLES.ADMIN, ROLES.THERAPIST],
|
||||
})
|
||||
|
||||
/**
|
||||
* canSee(key)
|
||||
* Retorna true se o PAPEL atual estiver autorizado para a chave RBAC.
|
||||
*
|
||||
* Política segura:
|
||||
* - se não carregou role → false
|
||||
* - se não existe mapeamento pra key → false
|
||||
*/
|
||||
function canSee (key) {
|
||||
const r = role.value
|
||||
if (!r) return false
|
||||
|
||||
const allowed = rbac[key]
|
||||
if (!allowed) return false
|
||||
|
||||
return allowed.includes(r)
|
||||
}
|
||||
|
||||
return {
|
||||
// estado
|
||||
role,
|
||||
isReady,
|
||||
|
||||
// constantes & helpers
|
||||
ROLES,
|
||||
isTenantAdmin,
|
||||
isTherapist,
|
||||
isPatient,
|
||||
isStaff,
|
||||
|
||||
// API
|
||||
canSee
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,10 +47,37 @@
|
||||
v-model="search"
|
||||
class="w-full"
|
||||
autocomplete="off"
|
||||
@keyup.enter="openSearchModal"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="agendaSearch">Buscar paciente...</label>
|
||||
</FloatLabel>
|
||||
|
||||
<!-- Mobile: botão para abrir resultados (não duplica sidebar) -->
|
||||
<div class="sm:hidden mt-2">
|
||||
<Button
|
||||
v-if="searchTrim"
|
||||
:label="`Resultados (${searchResults.length})`"
|
||||
icon="pi pi-list"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="w-full rounded-full"
|
||||
@click="openSearchModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Chip/atalho rápido (desktop + mobile) -->
|
||||
<div v-if="searchTrim" class="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<Tag :value="`Busca: ${searchTrim}`" severity="secondary" />
|
||||
<Button
|
||||
label="Limpar"
|
||||
icon="pi pi-times"
|
||||
text
|
||||
severity="secondary"
|
||||
class="p-0"
|
||||
@click="clearSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -131,10 +158,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Layout: 2 colunas -->
|
||||
<div class="grid">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- Coluna maior: FullCalendar -->
|
||||
<div class="col-12 lg:col-8 xl:col-9">
|
||||
<div class="overflow-hidden rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
|
||||
<div class="w-full lg:flex-1 lg:order-2 min-w-0">
|
||||
<div class="overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
|
||||
<div class="p-2">
|
||||
<!-- ✅ key força reaplicar slotMin/Max quando trocar modo -->
|
||||
<FullCalendar ref="fcRef" :key="fcKey" :options="fcOptions" />
|
||||
@@ -143,7 +170,70 @@
|
||||
</div>
|
||||
|
||||
<!-- Coluna menor -->
|
||||
<div class="col-12 lg:col-4 xl:col-3">
|
||||
<div class="w-full lg:basis-[24%] lg:max-w-[24%] lg:order-1">
|
||||
<!-- ✅ Resultados (DESKTOP): não aparece no mobile para evitar duplicação -->
|
||||
<div
|
||||
v-if="searchTrim"
|
||||
class="hidden sm:block mb-3 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold truncate">Resultados</div>
|
||||
<small class="text-color-secondary truncate">
|
||||
para “{{ searchTrim }}”
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag
|
||||
v-if="!searchLoading"
|
||||
:value="`${searchResults.length}`"
|
||||
severity="secondary"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
text
|
||||
class="h-9 w-9 rounded-full"
|
||||
v-tooltip.top="'Limpar busca'"
|
||||
@click="clearSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="searchLoading" class="text-color-secondary text-sm">
|
||||
Buscando…
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchResults.length === 0" class="text-color-secondary text-sm">
|
||||
Nenhum resultado encontrado.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2 max-h-[360px] overflow-auto pr-1">
|
||||
<button
|
||||
v-for="r in searchResults"
|
||||
:key="r.id"
|
||||
class="text-left rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-0)]/40 p-3 transition hover:shadow-sm"
|
||||
@click="gotoResult(r)"
|
||||
>
|
||||
<div class="font-medium truncate">
|
||||
{{ r.titulo || 'Sem título' }}
|
||||
</div>
|
||||
|
||||
<div class="mt-1 flex items-center justify-between gap-2 text-xs opacity-70">
|
||||
<span class="truncate">
|
||||
{{ fmtDateTime(r.inicio_em) }}
|
||||
</span>
|
||||
<Tag :value="labelTipo(r.tipo)" severity="info" />
|
||||
</div>
|
||||
|
||||
<div v-if="r.observacoes" class="mt-1 text-xs text-color-secondary truncate">
|
||||
{{ r.observacoes }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="font-semibold">Calendário</span>
|
||||
@@ -227,6 +317,83 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ Dialog Resultados (MOBILE) -->
|
||||
<Dialog
|
||||
v-model:visible="searchModalOpen"
|
||||
modal
|
||||
header="Resultados da busca"
|
||||
:style="{ width: '96vw', maxWidth: '720px' }"
|
||||
:breakpoints="{ '960px': '92vw', '640px': '96vw' }"
|
||||
:draggable="false"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold truncate">Para “{{ searchTrim }}”</div>
|
||||
<small class="text-color-secondary">
|
||||
{{ searchResults.length }} resultado(s)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon="pi pi-eraser"
|
||||
severity="secondary"
|
||||
text
|
||||
class="h-9 w-9 rounded-full"
|
||||
v-tooltip.top="'Limpar busca'"
|
||||
@click="clearSearchAndClose"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div v-if="searchLoading" class="text-color-secondary text-sm">
|
||||
Buscando…
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchResults.length === 0" class="text-color-secondary text-sm">
|
||||
Nenhum resultado encontrado.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2 max-h-[65vh] overflow-auto pr-1">
|
||||
<button
|
||||
v-for="r in searchResults"
|
||||
:key="r.id"
|
||||
class="text-left rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-0)]/40 p-3 transition hover:shadow-sm"
|
||||
@click="gotoResultFromModal(r)"
|
||||
>
|
||||
<div class="font-medium truncate">
|
||||
{{ r.titulo || 'Sem título' }}
|
||||
</div>
|
||||
|
||||
<div class="mt-1 flex items-center justify-between gap-2 text-xs opacity-70">
|
||||
<span class="truncate">
|
||||
{{ fmtDateTime(r.inicio_em) }}
|
||||
</span>
|
||||
<Tag :value="labelTipo(r.tipo)" severity="info" />
|
||||
</div>
|
||||
|
||||
<div v-if="r.observacoes" class="mt-1 text-xs text-color-secondary truncate">
|
||||
{{ r.observacoes }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" icon="pi pi-times" text @click="searchModalOpen = false" />
|
||||
<Button
|
||||
v-if="searchTrim"
|
||||
label="Limpar"
|
||||
icon="pi pi-eraser"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
@click="clearSearchAndClose"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Month Picker -->
|
||||
<Dialog v-model:visible="monthPickerVisible" modal header="Escolher mês" :style="{ width: '420px' }">
|
||||
<div class="p-2">
|
||||
@@ -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'
|
||||
// }
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active { transition: opacity .14s ease; }
|
||||
.fade-enter-from,
|
||||
.fade-leave-to { opacity: 0; }
|
||||
|
||||
/* ✅ destaque da busca sem remover eventos */
|
||||
.evt-dim { opacity: .25; }
|
||||
.evt-hit { opacity: 1; }
|
||||
</style>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
<Popover ref="pop" appendTo="body">
|
||||
<div class="min-w-[220px] p-1">
|
||||
<Button
|
||||
v-if="canSee('settings.view')"
|
||||
label="Configurações"
|
||||
icon="pi pi-cog"
|
||||
text
|
||||
@@ -164,14 +189,14 @@ async function signOut () {
|
||||
class="w-full justify-start"
|
||||
@click="goSecurity"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Meu Perfil"
|
||||
icon="pi pi-user"
|
||||
text
|
||||
class="w-full justify-start"
|
||||
@click="goMyProfile"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Meu Perfil"
|
||||
icon="pi pi-user"
|
||||
text
|
||||
class="w-full justify-start"
|
||||
@click="goMyProfile"
|
||||
/>
|
||||
|
||||
<div class="my-1 border-t border-[var(--surface-border)]" />
|
||||
|
||||
@@ -186,4 +211,4 @@ async function signOut () {
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
71
src/router/accessRedirects.js
Normal file
71
src/router/accessRedirects.js
Normal file
@@ -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 || '/'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -235,22 +235,22 @@ onMounted(async () => {
|
||||
<!-- CARDS -->
|
||||
<div class="px-8 pb-10">
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
|
||||
<!-- CLÍNICA (antigo ADMIN) -->
|
||||
|
||||
<!-- PACIENTE -->
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div
|
||||
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
|
||||
@click="go('clinic_admin')"
|
||||
@click="go('patient')"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-xl font-semibold text-[var(--text-color)]">
|
||||
Clínica
|
||||
Paciente
|
||||
</div>
|
||||
<i class="pi pi-building text-sm opacity-70" />
|
||||
<i class="pi pi-user text-sm opacity-70" />
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Gestão da clínica, controle de usuários, permissões, planos e configurações globais.
|
||||
Visualização de informações pessoais, documentos e interações com a clínica.
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition">
|
||||
@@ -281,22 +281,22 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PACIENTE -->
|
||||
|
||||
<!-- CLÍNICA (antigo ADMIN) -->
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div
|
||||
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
|
||||
@click="go('patient')"
|
||||
@click="go('clinic_admin')"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-xl font-semibold text-[var(--text-color)]">
|
||||
Paciente
|
||||
Clínica
|
||||
</div>
|
||||
<i class="pi pi-user text-sm opacity-70" />
|
||||
<i class="pi pi-building text-sm opacity-70" />
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Visualização de informações pessoais, documentos e interações com a clínica.
|
||||
Gestão da clínica, controle de usuários, permissões, planos e configurações globais.
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition">
|
||||
@@ -304,7 +304,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SAAS MASTER -->
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div
|
||||
|
||||
@@ -7,7 +7,17 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
ADMIN DASHBOARD
|
||||
<div class="card mb-0">
|
||||
<div class="flex justify-between mb-4">
|
||||
<div>
|
||||
<span class="text-primary font-medium">Área</span>
|
||||
<span class="text-muted-color"> da Clínica</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
|
||||
<i class="pi pi-user text-blue-500 text-xl!"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<StatsWidget />
|
||||
|
||||
|
||||
51
src/views/pages/misc/AccessDeniedPage.vue
Normal file
51
src/views/pages/misc/AccessDeniedPage.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
const router = useRouter()
|
||||
const tenant = useTenantStore()
|
||||
|
||||
function goHome () {
|
||||
const role = tenant.activeRole
|
||||
|
||||
if (role === 'tenant_admin' || role === 'clinic_admin' || role === 'admin') {
|
||||
router.push('/admin')
|
||||
return
|
||||
}
|
||||
|
||||
if (role === 'therapist') {
|
||||
router.push('/therapist')
|
||||
return
|
||||
}
|
||||
|
||||
if (role === 'patient') {
|
||||
router.push('/portal')
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center px-6">
|
||||
<div class="text-6xl font-bold text-[var(--primary-color)] mb-4">
|
||||
403
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-semibold mb-2">
|
||||
Acesso negado
|
||||
</h1>
|
||||
|
||||
<p class="text-[var(--text-color-secondary)] max-w-md mb-6">
|
||||
Você está autenticado, mas não possui permissão para acessar esta área.
|
||||
Caso acredite que isso seja um erro, entre em contato com o administrador da clínica.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
label="Voltar para minha área"
|
||||
icon="pi pi-home"
|
||||
@click="goHome"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,7 +7,17 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
PATIENT DASHBOARD
|
||||
<div class="card mb-0">
|
||||
<div class="flex justify-between mb-4">
|
||||
<div>
|
||||
<span class="text-primary font-medium">Portal</span>
|
||||
<span class="text-muted-color"> = Área do Paciente</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
|
||||
<i class="pi pi-user text-blue-500 text-xl!"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<StatsWidget />
|
||||
|
||||
|
||||
@@ -7,7 +7,17 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
THERAPIST DASHBOARD
|
||||
<div class="card mb-0">
|
||||
<div class="flex justify-between mb-4">
|
||||
<div>
|
||||
<span class="text-primary font-medium">Área</span>
|
||||
<span class="text-muted-color"> do Terapeuta</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
|
||||
<i class="pi pi-user text-blue-500 text-xl!"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<StatsWidget />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user