This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions

View File

@@ -1,38 +1,128 @@
<script setup>
import { onMounted } from 'vue'
import { onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
const route = useRoute()
const tenantStore = useTenantStore()
const entStore = useEntitlementsStore()
onMounted(async () => {
// 1) carrega sessão + tenant ativo (do seu fluxo atual)
await tenantStore.loadSessionAndTenant()
function isTenantArea (path = '') {
return path.startsWith('/admin') || path.startsWith('/therapist')
}
// 2) carrega permissões do tenant ativo (se existir)
if (tenantStore.activeTenantId) {
await entStore.loadForTenant(tenantStore.activeTenantId)
function isPortalArea (path = '') {
return path.startsWith('/portal')
}
function isSaasArea (path = '') {
return path.startsWith('/saas')
}
async function debugSnapshot (label = 'snapshot') {
console.group(`🧭 [APP DEBUG] ${label}`)
try {
// 0) rota + meta
console.log('route.fullPath:', route.fullPath)
console.log('route.path:', route.path)
console.log('route.name:', route.name)
console.log('route.meta:', route.meta)
// 1) storage
console.groupCollapsed('📦 Storage')
console.log('localStorage.tenant_id:', localStorage.getItem('tenant_id'))
console.log('localStorage.currentTenantId:', localStorage.getItem('currentTenantId'))
console.log('localStorage.tenant:', localStorage.getItem('tenant'))
console.log('sessionStorage.redirect_after_login:', sessionStorage.getItem('redirect_after_login'))
console.log('sessionStorage.intended_area:', sessionStorage.getItem('intended_area'))
console.groupEnd()
// 2) sessão auth (fonte real)
const { data: authData, error: authErr } = await supabase.auth.getUser()
if (authErr) console.warn('[auth.getUser] error:', authErr)
const user = authData?.user || null
console.log('auth.user:', user ? { id: user.id, email: user.email } : null)
// 3) profiles.role (identidade global)
let profileRole = null
if (user?.id) {
const { data: profile, error: pErr } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
if (pErr) console.warn('[profiles] error:', pErr)
profileRole = profile?.role || null
}
console.log('profiles.role (global):', profileRole)
// 4) memberships via RPC (fonte de verdade do tenantStore)
let rpcTenants = null
if (user?.id) {
const { data: rpcData, error: rpcErr } = await supabase.rpc('my_tenants')
if (rpcErr) console.warn('[rpc my_tenants] error:', rpcErr)
rpcTenants = rpcData ?? null
}
console.log('rpc.my_tenants():', rpcTenants)
// 5) stores (sempre logar)
console.groupCollapsed('🏪 Stores (before optional loads)')
console.log('tenantStore.activeTenantId:', tenantStore.activeTenantId)
console.log('tenantStore.activeRole:', tenantStore.activeRole)
console.log('tenantStore.memberships:', tenantStore.memberships)
console.log('entStore.loaded:', entStore.loaded)
console.log('entStore.tenantId:', entStore.activeTenantId || entStore.tenantId)
console.groupEnd()
// 6) IMPORTANTÍSSIMO: não carregar tenant fora da área tenant
const path = route.path || ''
if (isTenantArea(path)) {
console.log('✅ Tenant area detected → will loadSessionAndTenant + entitlements')
await tenantStore.loadSessionAndTenant()
if (tenantStore.activeTenantId) {
await entStore.loadForTenant(tenantStore.activeTenantId)
}
console.groupCollapsed('🏪 Stores (after tenant loads)')
console.log('tenantStore.activeTenantId:', tenantStore.activeTenantId)
console.log('tenantStore.activeRole:', tenantStore.activeRole)
console.log('tenantStore.memberships:', tenantStore.memberships)
console.log("entStore.can('online_scheduling.manage'):", entStore.can?.('online_scheduling.manage'))
console.groupEnd()
} else if (isPortalArea(path)) {
console.log('🟣 Portal area detected → SKIP tenantStore.loadSessionAndTenant()')
} else if (isSaasArea(path)) {
console.log('🟠 SaaS area detected → SKIP tenantStore.loadSessionAndTenant()')
} else {
console.log('⚪ Other/public area detected → SKIP tenantStore.loadSessionAndTenant()')
}
} catch (e) {
console.error('[APP DEBUG] snapshot error:', e)
} finally {
console.groupEnd()
}
}
// 3) debug: localStorage com rótulos
console.groupCollapsed('[Debug] Tenant localStorage')
console.log('tenant_id:', localStorage.getItem('tenant_id'))
console.log('currentTenantId:', localStorage.getItem('currentTenantId'))
console.log('tenant:', localStorage.getItem('tenant'))
console.groupEnd()
onMounted(async () => {
// 🔥 PRIMEIRO LOG — TENANT ID BRUTO (mantive sua ideia)
console.log('[SEU_TENANT_ID]', localStorage.getItem('tenant_id'))
// 4) debug: stores
console.groupCollapsed('[Debug] Tenant stores')
console.log('route:', route.fullPath)
console.log('activeTenantId:', tenantStore.activeTenantId)
console.log('activeRole:', tenantStore.activeRole)
console.log("can('online_scheduling.manage'):", entStore.can('online_scheduling.manage'))
console.groupEnd()
// snapshot inicial
await debugSnapshot('mounted')
})
// snapshot a cada navegação (isso é o que vai te salvar)
watch(
() => route.fullPath,
async (to, from) => {
await debugSnapshot(`route change: ${from} -> ${to}`)
}
)
</script>
<template>

View File

@@ -63,7 +63,7 @@ export async function bootstrapUserSettings({
primaryColors = [], // passe a lista do seu Perfil (ou uma versão reduzida)
surfaces = [] // idem
} = {}) {
const { layoutConfig, isDarkTheme, toggleDarkMode, changeMenuMode } = useLayout()
const { layoutConfig, isDarkTheme, toggleDarkMode, changeMenuMode, setVariant } = useLayout()
const { data: uRes, error: uErr } = await supabase.auth.getUser()
if (uErr) return
@@ -72,12 +72,17 @@ export async function bootstrapUserSettings({
const { data: settings, error } = await supabase
.from('user_settings')
.select('theme_mode, preset, primary_color, surface_color, menu_mode')
.select('theme_mode, preset, primary_color, surface_color, menu_mode, layout_variant')
.eq('user_id', user.id)
.maybeSingle()
if (error || !settings) return
// layout variant (rail / classic)
if (settings.layout_variant === 'rail' || settings.layout_variant === 'classic') {
setVariant(settings.layout_variant)
}
// menu mode
if (settings.menu_mode && settings.menu_mode !== layoutConfig.menuMode) {
layoutConfig.menuMode = settings.menu_mode

View File

@@ -8,10 +8,25 @@
@hide="onHide"
>
<template #header>
<div class="flex flex-col gap-1">
<div class="text-xl font-semibold">{{ title }}</div>
<div class="text-sm text-surface-500">
Crie um paciente rapidamente (nome, e-mail e telefone obrigatórios).
<div class="flex flex-col gap-2">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-xl font-semibold">{{ title }}</div>
<div class="text-sm text-surface-500">
Crie um paciente rapidamente (nome, e-mail e telefone obrigatórios).
</div>
</div>
<!-- TOPBAR ACTION -->
<Button
v-if="canSee('testMODE')"
label="Gerar usuário"
icon="pi pi-user-plus"
severity="secondary"
outlined
:disabled="saving"
@click="generateUser"
/>
</div>
</div>
</template>
@@ -21,55 +36,67 @@
{{ errorMsg }}
</Message>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium" for="cr-nome">Nome *</label>
<InputText
id="cr-nome"
v-model.trim="form.nome_completo"
:disabled="saving"
autocomplete="off"
autofocus
@keydown.enter.prevent="submit"
/>
<small v-if="touched && !form.nome_completo" class="text-red-500">
Informe o nome.
</small>
<div class="flex flex-col gap-2 mt-2">
<!-- Nome -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user" />
<InputText
id="cr-nome"
v-model.trim="form.nome_completo"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
autofocus
@keydown.enter.prevent="submit"
/>
</IconField>
<label for="cr-nome">Nome completo *</label>
</FloatLabel>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium" for="cr-email">E-mail *</label>
<InputText
id="cr-email"
v-model.trim="form.email_principal"
:disabled="saving"
inputmode="email"
autocomplete="off"
@keydown.enter.prevent="submit"
/>
<small v-if="touched && !form.email_principal" class="text-red-500">
Informe o e-mail.
</small>
<small v-if="touched && form.email_principal && !isValidEmail(form.email_principal)" class="text-red-500">
E-mail inválido.
</small>
<!-- E-mail -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-envelope" />
<InputText
id="cr-email"
v-model.trim="form.email_principal"
class="w-full"
variant="filled"
:disabled="saving"
inputmode="email"
autocomplete="off"
@keydown.enter.prevent="submit"
/>
</IconField>
<label for="cr-email">E-mail *</label>
</FloatLabel>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium" for="cr-telefone">Telefone *</label>
<InputMask
id="cr-telefone"
v-model="form.telefone"
:disabled="saving"
mask="(99) 99999-9999"
placeholder="(16) 99999-9999"
@keydown.enter.prevent="submit"
/>
<small v-if="touched && !form.telefone" class="text-red-500">
Informe o telefone.
</small>
<small v-else-if="touched && form.telefone && !isValidPhone(form.telefone)" class="text-red-500">
Telefone inválido.
</small>
<!-- Telefone -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputMask
id="cr-telefone"
v-model="form.telefone"
mask="(99) 99999-9999"
class="w-full"
variant="filled"
:disabled="saving"
@keydown.enter.prevent="submit"
/>
</IconField>
<label for="cr-telefone">Telefone *</label>
</FloatLabel>
</div>
<div class="text-xs text-surface-500">
Dica: Gerar usuário preenche automaticamente com dados fictícios.
</div>
</div>
@@ -95,12 +122,49 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { useRoleGuard } from '@/composables/useRoleGuard'
import { useToast } from 'primevue/usetoast'
import InputMask from 'primevue/inputmask'
import Message from 'primevue/message'
import { supabase } from '@/lib/supabase/client'
const { canSee } = useRoleGuard()
/**
* Lista “curada” de pensadores influentes na psicanálise e seu entorno.
* Usada para geração rápida de dados fictícios.
*/
const PSICANALISE_PENSADORES = Object.freeze([
{ nome: 'Sigmund Freud' },
{ nome: 'Jacques Lacan' },
{ nome: 'Melanie Klein' },
{ nome: 'Donald Winnicott' },
{ nome: 'Wilfred Bion' },
{ nome: 'Sándor Ferenczi' },
{ nome: 'Anna Freud' },
{ nome: 'Karl Abraham' },
{ nome: 'Otto Rank' },
{ nome: 'Karen Horney' },
{ nome: 'Erich Fromm' },
{ nome: 'Michael Balint' },
{ nome: 'Ronald Fairbairn' },
{ nome: 'John Bowlby' },
{ nome: 'André Green' },
{ nome: 'Jean Laplanche' },
{ nome: 'Christopher Bollas' },
{ nome: 'Thomas Ogden' },
{ nome: 'Jessica Benjamin' },
{ nome: 'Joyce McDougall' },
{ nome: 'Peter Fonagy' },
{ nome: 'Carl Gustav Jung' },
{ nome: 'Alfred Adler' }
])
// domínio seguro para dados fictícios
const AUTO_EMAIL_DOMAIN = 'example.com'
const props = defineProps({
modelValue: { type: Boolean, default: false },
@@ -114,7 +178,10 @@ const props = defineProps({
emailField: { type: String, default: 'email_principal' },
phoneField: { type: String, default: 'telefone' },
// ✅ NÃO coloque status aqui por padrão (evita violar patients_status_check)
// multi-tenant (defaults do seu schema)
tenantField: { type: String, default: 'tenant_id' },
responsibleMemberField: { type: String, default: 'responsible_member_id' },
extraPayload: { type: Object, default: () => ({}) },
closeOnCreated: { type: Boolean, default: true },
@@ -184,6 +251,78 @@ async function getOwnerId () {
return user.id
}
/**
* Pega tenant_id + member_id do usuário logado.
*/
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 })
.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 }
}
/* ----------------------------
* Gerador (nome/email/telefone)
* ---------------------------- */
function randInt (min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min
}
function pick (arr) {
return arr[randInt(0, arr.length - 1)]
}
function slugify (s) {
return String(s || '')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '.')
.replace(/(^\.)|(\.$)/g, '')
}
function randomPhoneBRMasked () {
const ddd = randInt(11, 99)
const a = randInt(10000, 99999)
const b = randInt(1000, 9999)
return `(${ddd}) ${a}-${b}`
}
function generateUser () {
if (saving.value) return
const p = pick(PSICANALISE_PENSADORES)
const nome = p?.nome || 'Paciente'
const base = slugify(nome) || 'paciente'
const suffix = randInt(10, 999)
const email = `${base}.${suffix}@${AUTO_EMAIL_DOMAIN}`
form.nome_completo = nome
form.email_principal = email
form.telefone = randomPhoneBRMasked()
touched.value = true
errorMsg.value = ''
toast.add({
severity: 'info',
summary: 'Gerar usuário',
detail: 'Dados fictícios preenchidos.',
life: 2200
})
}
async function submit () {
touched.value = true
errorMsg.value = ''
@@ -201,16 +340,21 @@ async function submit () {
saving.value = true
try {
const ownerId = await getOwnerId()
const { tenantId, memberId } = await resolveTenantContextOrFail()
// extraPayload antes; tenant/responsible forçados depois (não podem ser sobrescritos sem querer)
const payload = {
...props.extraPayload,
[props.ownerField]: ownerId,
[props.tenantField]: tenantId,
[props.responsibleMemberField]: memberId,
[props.nameField]: nome,
[props.emailField]: email.toLowerCase(),
[props.phoneField]: normalizePhoneDigits(tel),
...props.extraPayload
[props.phoneField]: normalizePhoneDigits(tel)
}
// remove undefined
Object.keys(payload).forEach((k) => {
if (payload[k] === undefined) delete payload[k]
})
@@ -248,4 +392,4 @@ async function submit () {
saving.value = false
}
}
</script>
</script>

View File

@@ -3,7 +3,6 @@
import { computed, onMounted, ref } from 'vue'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import Tag from 'primevue/tag'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'

View File

@@ -1,14 +1,11 @@
<!-- src/components/agenda/AgendaSlotsPorDiaCard.vue -->
<script setup>
import { computed, ref, watch, onMounted } from 'vue'
import Card from 'primevue/card'
import Button from 'primevue/button'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import Dropdown from 'primevue/dropdown'
import InputNumber from 'primevue/inputnumber'
import InputSwitch from 'primevue/inputswitch'
import FloatLabel from 'primevue/floatlabel'
import { useToast } from 'primevue/usetoast'
import { fetchSlotsRegras, upsertSlotRegra } from '@/services/agendaConfigService'

View File

@@ -1,13 +1,7 @@
<!-- src/components/agenda/PausasChipsEditor.vue -->
<script setup>
import { computed, ref, watch } from 'vue'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import FloatLabel from 'primevue/floatlabel'
import InputText from 'primevue/inputtext'
import Tag from 'primevue/tag'
import Divider from 'primevue/divider'
import DatePicker from 'primevue/datepicker'
import { useToast } from 'primevue/usetoast'
const toast = useToast()
@@ -32,6 +26,15 @@ function minToHHMM(min) {
function newId() {
return Math.random().toString(16).slice(2) + Date.now().toString(16)
}
function hhmmToDate(hhmm) {
if (!isValidHHMM(hhmm)) return null
const [h, m] = String(hhmm).split(':').map(Number)
const d = new Date(); d.setHours(h, m, 0, 0); return d
}
function dateToHHMM(date) {
if (!date || !(date instanceof Date)) return null
return String(date.getHours()).padStart(2, '0') + ':' + String(date.getMinutes()).padStart(2, '0')
}
const internal = ref([])
@@ -151,14 +154,22 @@ const presets = [
]
const dlg = ref(false)
const form = ref({ label: 'Pausa', inicio: '12:00', fim: '13:00' })
const form = ref({ label: 'Pausa', inicio: null, fim: null })
const formInicioHHMM = computed(() => dateToHHMM(form.value.inicio))
const formFimHHMM = computed(() => dateToHHMM(form.value.fim))
const formValid = computed(() =>
isValidHHMM(formInicioHHMM.value) &&
isValidHHMM(formFimHHMM.value) &&
formFimHHMM.value > formInicioHHMM.value
)
function openCustom() {
form.value = { label: 'Pausa', inicio: '12:00', fim: '13:00' }
form.value = { label: 'Pausa', inicio: hhmmToDate('12:00'), fim: hhmmToDate('13:00') }
dlg.value = true
}
function saveCustom() {
addPauseSmart(form.value)
addPauseSmart({ label: form.value.label, inicio: formInicioHHMM.value, fim: formFimHHMM.value })
dlg.value = false
}
</script>
@@ -200,46 +211,44 @@ function saveCustom() {
</div>
<!-- custom dialog -->
<Dialog v-model:visible="dlg" modal header="Adicionar pausa" :style="{ width: '520px' }">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12">
<FloatLabel variant="on">
<InputText v-model="form.label" class="w-full" inputId="plabel" placeholder="Ex.: Almoço" />
<label for="plabel">Nome</label>
</FloatLabel>
<Dialog v-model:visible="dlg" modal :draggable="false" header="Adicionar pausa" :style="{ width: '420px' }">
<div class="flex flex-col gap-4">
<div>
<label class="text-xs text-[var(--text-color-secondary)] mb-1 block">Nome</label>
<InputText v-model="form.label" class="w-full" placeholder="Ex.: Almoço" />
</div>
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<InputText v-model="form.inicio" class="w-full" inputId="pinicio" placeholder="12:00" />
<label for="pinicio">Início (HH:MM)</label>
</FloatLabel>
<div class="flex gap-3">
<div class="flex-1 flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">Início</label>
<DatePicker v-model="form.inicio" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
</DatePicker>
</div>
<div class="flex-1 flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">Fim</label>
<DatePicker v-model="form.fim" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
</DatePicker>
</div>
</div>
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<InputText v-model="form.fim" class="w-full" inputId="pfim" placeholder="13:00" />
<label for="pfim">Fim (HH:MM)</label>
</FloatLabel>
</div>
<div v-if="isValidHHMM(form.inicio) && isValidHHMM(form.fim) && form.fim <= form.inicio" class="col-span-12 text-sm text-red-500">
<div v-if="formInicioHHMM && formFimHHMM && formFimHHMM <= formInicioHHMM" class="text-sm text-red-500">
O fim precisa ser maior que o início.
</div>
<div class="col-span-12 text-600 text-xs">
Se houver conflito com outra pausa, o sistema adiciona automaticamente apenas o trecho que não sobrepõe.
<div class="text-[var(--text-color-secondary)] text-xs">
Se houver conflito com outra pausa, o sistema adiciona apenas o trecho que não sobrepõe.
</div>
</div>
<template #footer>
<Button label="Cancelar" icon="pi pi-times" severity="secondary" outlined @click="dlg = false" />
<Button
label="Adicionar"
icon="pi pi-check"
:disabled="!isValidHHMM(form.inicio) || !isValidHHMM(form.fim) || form.fim <= form.inicio"
@click="saveCustom"
/>
<Button label="Adicionar" icon="pi pi-check" :disabled="!formValid" @click="saveCustom" />
</template>
</Dialog>
</div>

View File

@@ -0,0 +1,81 @@
// src/composables/usePlatformPermissions.js
//
// Permissões de PLATAFORMA (globais, não vinculadas a tenant).
// Distinto do RBAC de tenant (useRoleGuard).
//
// O campo `platform_roles text[]` na tabela `profiles` do Supabase
// armazena papéis globais da plataforma. Ex.: ['editor'].
//
// Quem pode atribuir: somente o saas_admin.
// Quem pode ter: qualquer usuário autenticado (exceto paciente).
//
// PAPÉIS DE PLATAFORMA DISPONÍVEIS:
// 'editor' — pode criar e gerenciar cursos/módulos da plataforma de microlearning.
//
import { ref, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { sessionUser } from '@/app/session'
// cache em módulo (evita queries repetidas por navegação)
let _cachedUid = null
let _cachedRoles = null
export function usePlatformPermissions () {
const platformRoles = ref(_cachedRoles ?? [])
const loading = ref(false)
const error = ref(null)
async function load (force = false) {
const uid = sessionUser.value?.id
if (!uid) {
platformRoles.value = []
return
}
// cache por uid (invalida se usuário mudou)
if (!force && _cachedUid === uid && _cachedRoles !== null) {
platformRoles.value = _cachedRoles
return
}
loading.value = true
error.value = null
try {
const { data, err } = await supabase
.from('profiles')
.select('platform_roles')
.eq('id', uid)
.single()
const roles = !err && Array.isArray(data?.platform_roles) ? data.platform_roles : []
_cachedUid = uid
_cachedRoles = roles
platformRoles.value = roles
} catch (e) {
console.warn('[usePlatformPermissions] load falhou:', e)
error.value = e
platformRoles.value = []
} finally {
loading.value = false
}
}
function invalidate () {
_cachedUid = null
_cachedRoles = null
platformRoles.value = []
}
const isEditor = computed(() => platformRoles.value.includes('editor'))
return {
platformRoles,
loading,
error,
isEditor,
load,
invalidate
}
}

View File

@@ -4,6 +4,7 @@ import { useTenantStore } from '@/stores/tenantStore'
/**
* ---------------------------------------------------------
* useRoleGuard() — RBAC puro (somente PAPEL do tenant)
* + testMODE (modo operacional de teste)
* ---------------------------------------------------------
*
* Objetivo:
@@ -11,82 +12,133 @@ import { useTenantStore } from '@/stores/tenantStore'
* 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)
* - public.tenant_members.role
* → 'saas' | 'tenant_admin' | 'therapist' | 'patient'
*
* 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
* ---------------------------------------------------------
* testMODE — INTERRUPTOR OPERACIONAL
* ---------------------------------------------------------
*
* O que ele NÃO resolve (de propósito):
* - liberar feature por plano (Free/Pro)
* - limitar módulos / recursos contratados
* testMODE NÃO é regra de negócio.
* Ele serve para:
* - testar módulos antes de liberar no plano
* - visualizar telas ainda não contratadas
* - validar UI sem alterar regras de plano
*
* Para controle por plano, use o entStore:
* - entStore.can('feature_key')
* COMPORTAMENTO:
*
* Padrão recomendado (RBAC + Plano):
* Quando algo depende do PLANO e do PAPEL, combine no template:
* - TEST_MODE_ROLES = []
* → testMODE OFF (ninguém vê)
*
* v-if="entStore.can('online_scheduling.manage') && canSee('settings.view')"
* - TEST_MODE_ROLES = [ROLES.ADMIN]
* → Apenas tenant_admin vê elementos marcados como testMODE
*
* - TEST_MODE_ROLES = [ROLES.ADMIN, ROLES.THERAPIST]
* → Admin e Therapist veem
*
* - TEST_MODE_ROLES = [ROLES.SAAS, ROLES.ADMIN, ROLES.THERAPIST, ROLES.PATIENT]
* → testMODE ON geral
*
* ---------------------------------------------------------
* COMO IMPORTAR NO COMPONENTE
* ---------------------------------------------------------
*
* 1) Importação padrão:
*
* import { useRoleGuard } from '@/composables/useRoleGuard'
*
* const { canSee, canSeeOrTest } = useRoleGuard()
*
*
* 2) Se for usar apenas testMODE:
*
* import { useRoleGuard } from '@/composables/useRoleGuard'
*
* const { canSee } = useRoleGuard()
*
*
* ---------------------------------------------------------
* EXEMPLOS DE USO NO TEMPLATE
* ---------------------------------------------------------
*
* 1) Elemento puramente experimental:
*
* <Button
* v-if="canSee('testMODE')"
* label="Botão em teste"
* />
*
*
* 2) Liberar visualização mesmo sem plano:
*
* <Button
* v-if="entStore.can('online_scheduling.manage') || canSee('testMODE')"
* label="Agendamento Online"
* @click="..."
* />
*
* Interpretação:
* - Gate A (Plano): o tenant tem a feature liberada?
* - Gate B (Papel): o usuário, pelo papel, pode ver/usar isso?
* - Gate A → plano liberado
* - Gate B → testMODE ativo
*
* Nota de segurança:
* Isso controla UI/rotas (experiência). Segurança real deve existir no backend (RLS).
*
* 3) Usando helper:
*
* <Button
* v-if="canSeeOrTest('settings.view')"
* ...
* />
*
* Isso significa:
* - Usuário pode ver normalmente pela regra RBAC
* OU
* - testMODE está ativo
*
*
* ---------------------------------------------------------
* IMPORTANTE:
* testMODE controla apenas UI.
* 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',
SAAS: 'saas',
ADMIN: 'clinic_admin',
SUPERVISOR: 'supervisor',
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 isSaas = computed(() => role.value === ROLES.SAAS)
const isTenantAdmin = computed(() => role.value === ROLES.ADMIN)
const isSupervisor = computed(() => role.value === ROLES.SUPERVISOR)
const isTherapist = computed(() => role.value === ROLES.THERAPIST)
const isPatient = computed(() => role.value === ROLES.PATIENT)
const isStaff = computed(() => [ROLES.ADMIN, ROLES.THERAPIST].includes(role.value))
const isStaff = computed(() => [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST].includes(role.value))
const TEST_MODE_ROLES = Object.freeze([
ROLES.ADMIN,
ROLES.SUPERVISOR,
ROLES.THERAPIST,
ROLES.SAAS,
ROLES.PATIENT
])
// 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],
'testMODE': TEST_MODE_ROLES,
// 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],
'settings.view': [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST],
'profile.view': [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST, ROLES.PATIENT],
'security.view': [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST, ROLES.PATIENT]
})
/**
* 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
@@ -97,19 +149,23 @@ export function useRoleGuard () {
return allowed.includes(r)
}
function canSeeOrTest (key) {
return canSee(key) || canSee('testMODE')
}
return {
// estado
role,
isReady,
// constantes & helpers
ROLES,
isSaas,
isTenantAdmin,
isSupervisor,
isTherapist,
isPatient,
isStaff,
// API
canSee
canSee,
canSeeOrTest
}
}

View File

@@ -44,6 +44,7 @@ export function useUserSettingsPersistence() {
primary_color: patch.primary_color ?? layoutConfig.primary ?? 'noir',
surface_color: patch.surface_color ?? layoutConfig.surface ?? 'slate',
menu_mode: patch.menu_mode ?? layoutConfig.menuMode ?? 'static',
layout_variant: patch.layout_variant ?? layoutConfig.variant ?? 'classic',
updated_at: new Date().toISOString()
}
@@ -83,6 +84,7 @@ export function useUserSettingsPersistence() {
primary_color: pendingPatch.value.primary_color ?? layoutConfig.primary,
surface_color: pendingPatch.value.surface_color ?? layoutConfig.surface,
menu_mode: pendingPatch.value.menu_mode ?? layoutConfig.menuMode,
layout_variant: pendingPatch.value.layout_variant ?? layoutConfig.variant ?? 'classic',
updated_at: new Date().toISOString()
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,15 @@
<!-- src/features/agenda/components/AgendaClinicMosaic.vue -->
<script setup>
import { computed, ref, watch, nextTick } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import ptBrLocale from '@fullcalendar/core/locales/pt-br'
const props = defineProps({
view: { type: String, default: 'day' }, // 'day' | 'week'
view: { type: String, default: 'day' }, // 'day' | 'week' | 'month'
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
timezone: { type: String, default: 'America/Sao_Paulo' },
@@ -22,33 +25,63 @@ const props = defineProps({
loading: { type: Boolean, default: false },
// controla quantas colunas "visíveis" por vez (resto vai por scroll horizontal)
minColWidth: { type: Number, default: 360 }
// largura mínima de cada coluna (terapeutas)
minColWidth: { type: Number, default: 360 },
// ✅ coluna da clínica
showClinicColumn: { type: Boolean, default: true },
clinicId: { type: String, default: '' },
clinicTitle: { type: String, default: 'Clínica' },
clinicSubtitle: { type: String, default: 'Agenda da clínica' },
// subtitle terapeutas
staffSubtitle: { type: String, default: 'Visão diária operacional' }
})
// ✅ rangeChange = mudança de range (carregar eventos)
// ✅ slotSelect = seleção de intervalo em uma coluna específica (criar evento)
// ✅ eventClick/Drop/Resize = ações em evento
const emit = defineEmits(['rangeChange', 'slotSelect', 'eventClick', 'eventDrop', 'eventResize'])
const emit = defineEmits([
'rangeChange',
'slotSelect',
'eventClick',
'eventDrop',
'eventResize',
// ✅ debug
'debugColumn'
])
const calendarRefs = ref([])
function setCalendarRef (el, idx) {
if (!el) return
calendarRefs.value[idx] = el
}
const initialView = computed(() => (props.view === 'week' ? 'timeGridWeek' : 'timeGridDay'))
const initialView = computed(() => {
if (props.view === 'week') return 'timeGridWeek'
if (props.view === 'month') return 'dayGridMonth'
return 'timeGridDay'
})
const computedSlotMinTime = computed(() => (props.mode === 'full_24h' ? '00:00:00' : props.slotMinTime))
// ✅ 23:59:59 para evitar edge-case de 24:00:00
const computedSlotMaxTime = computed(() => (props.mode === 'full_24h' ? '23:59:59' : props.slotMaxTime))
// ✅ coluna fixa (clínica)
const clinicColumn = computed(() => {
if (!props.showClinicColumn) return null
const id = String(props.clinicId || '').trim()
if (!id) return null
return { id, title: props.clinicTitle || 'Clínica', __kind: 'clinic' }
})
const staffColumns = computed(() => {
const base = Array.isArray(props.staff) ? props.staff : []
return base
.filter(s => s?.id)
.map(s => ({ ...s, __kind: 'staff' }))
})
function apiAt (idx) {
const fc = calendarRefs.value[idx]
return fc?.getApi?.()
}
function forEachApi (fn) {
for (let i = 0; i < calendarRefs.value.length; i++) {
const api = apiAt(i)
@@ -59,18 +92,24 @@ function forEachApi (fn) {
function goToday () { forEachApi(api => api.today()) }
function prev () { forEachApi(api => api.prev()) }
function next () { forEachApi(api => api.next()) }
function setView (v) {
const target = v === 'week' ? 'timeGridWeek' : 'timeGridDay'
forEachApi(api => api.changeView(target))
function gotoDate (date) {
if (!date) return
const dt = (date instanceof Date) ? new Date(date) : new Date(date)
dt.setHours(12, 0, 0, 0) // anti “voltar dia”
forEachApi(api => api.gotoDate(dt))
}
defineExpose({ goToday, prev, next, setView })
function setView (v) {
const target = v === 'week' ? 'timeGridWeek' : (v === 'month' ? 'dayGridMonth' : 'timeGridDay')
forEachApi(api => api.changeView(target))
}
function setMode () {}
defineExpose({ goToday, prev, next, gotoDate, setView, setMode })
// Eventos por profissional (owner)
function eventsFor (ownerId) {
const list = props.events || []
return list.filter(e => e?.extendedProps?.owner_id === ownerId)
return list.filter(e => String(e?.extendedProps?.owner_id || '') === String(ownerId || ''))
}
// ---- range sync ----
@@ -82,35 +121,93 @@ function onDatesSet (arg) {
if (key === lastRangeKey) return
lastRangeKey = key
// dispara carregamento no pai
emit('rangeChange', {
start: arg.start,
end: arg.end,
startStr: arg.startStr,
endStr: arg.endStr,
viewType: arg.view.type
viewType: arg.view.type,
currentDate: arg.view?.currentStart || arg.start
})
// mantém todos os calendários na mesma data
if (suppressSync) return
suppressSync = true
const masterDate = arg.start
const masterDate = arg.view?.currentStart || arg.start
forEachApi((api) => {
const cur = api.view?.currentStart
if (!cur) return
if (!cur || !masterDate) return
if (cur.getTime() !== masterDate.getTime()) api.gotoDate(masterDate)
})
// libera no próximo tick (evita loops)
Promise.resolve().then(() => { suppressSync = false })
}
// Se trocar view, garante que todos estão no mesmo
watch(() => props.view, async () => {
await nextTick()
setView(props.view)
})
// ---------- helpers UI ----------
function colSubtitle (p) {
return p?.__kind === 'clinic' ? props.clinicSubtitle : props.staffSubtitle
}
// ✅ debug emitter (cabeçalho clicável)
function emitDebug (col) {
emit('debugColumn', {
staffCol: col,
staffUserId: col?.id || null,
staffTitle: col?.title || null,
kind: col?.__kind || null,
at: new Date().toISOString()
})
}
function buildFcOptions (ownerId) {
const base = {
plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin],
locale: ptBrLocale,
timeZone: props.timezone,
headerToolbar: false,
initialView: initialView.value,
nowIndicator: true,
editable: true,
selectable: true,
selectMirror: true,
slotDuration: props.slotDuration,
slotMinTime: computedSlotMinTime.value,
slotMaxTime: computedSlotMaxTime.value,
height: 'auto',
expandRows: true,
allDaySlot: false,
events: eventsFor(ownerId),
datesSet: onDatesSet,
eventClick: (info) => emit('eventClick', info),
eventDrop: (info) => emit('eventDrop', info),
eventResize: (info) => emit('eventResize', info)
}
base.select = (selection) => {
emit('slotSelect', {
ownerId,
start: selection.start,
end: selection.end,
startStr: selection.startStr,
endStr: selection.endStr,
jsEvent: selection.jsEvent || null,
viewType: selection.view?.type || initialView.value
})
}
return base
}
</script>
<template>
@@ -119,74 +216,105 @@ watch(() => props.view, async () => {
Carregando agenda da clínica
</div>
<!-- Mosaic -->
<div
class="p-2 md:p-3 overflow-x-auto"
:style="{ display: 'grid', gridAutoFlow: 'column', gridAutoColumns: `minmax(${minColWidth}px, 1fr)`, gap: '12px' }"
>
<div
v-for="(p, idx) in staff"
:key="p.id"
class="rounded-[1.25rem] border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)] overflow-hidden"
>
<!-- Header da coluna -->
<div class="p-3 border-b border-[var(--surface-border)] flex items-center justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold truncate">{{ p.title }}</div>
<div class="text-xs opacity-70 truncate">Visão diária operacional</div>
<div class="mosaic-shell">
<!-- Coluna fixa: Clínica -->
<div v-if="clinicColumn" class="mosaic-fixed">
<div class="mosaic-col">
<div class="mosaic-col-head cursor-pointer" @click="emitDebug(clinicColumn)" title="Debug desta coluna">
<div class="min-w-0">
<div class="font-semibold truncate">{{ clinicColumn.title }}</div>
<div class="text-xs opacity-70 truncate">{{ colSubtitle(clinicColumn) }}</div>
</div>
<div class="text-xs opacity-70 whitespace-nowrap">
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
</div>
</div>
<div class="text-xs opacity-70 whitespace-nowrap">
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
<div class="p-2">
<FullCalendar
:ref="(el) => setCalendarRef(el, 0)"
:options="buildFcOptions(clinicColumn.id)"
/>
</div>
</div>
</div>
<div class="p-2">
<FullCalendar
:ref="(el) => setCalendarRef(el, idx)"
:options="{
plugins: [timeGridPlugin, interactionPlugin],
initialView: initialView,
timeZone: timezone,
<!-- Área rolável: Terapeutas -->
<div class="mosaic-scroll">
<div
class="mosaic-grid"
:style="{ gridAutoColumns: `minmax(${minColWidth}px, 1fr)` }"
>
<div
v-for="(p, sIdx) in staffColumns"
:key="p.id"
class="mosaic-col"
>
<div class="mosaic-col-head cursor-pointer" @click="emitDebug(p)" title="Debug desta coluna">
<div class="min-w-0">
<div class="font-semibold truncate">{{ p.title }}</div>
<div class="text-xs opacity-70 truncate">{{ colSubtitle(p) }}</div>
</div>
<div class="text-xs opacity-70 whitespace-nowrap">
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
</div>
</div>
headerToolbar: false,
nowIndicator: true,
editable: true,
// ✅ seleção para criar evento (por coluna)
selectable: true,
selectMirror: true,
select: (selection) => {
emit('slotSelect', {
ownerId: p.id,
start: selection.start,
end: selection.end,
startStr: selection.startStr,
endStr: selection.endStr,
jsEvent: selection.jsEvent || null,
viewType: selection.view?.type || initialView
})
},
slotDuration: slotDuration,
slotMinTime: computedSlotMinTime,
slotMaxTime: computedSlotMaxTime,
height: 'auto',
expandRows: true,
allDaySlot: false,
events: eventsFor(p.id),
datesSet: onDatesSet,
eventClick: (info) => emit('eventClick', info),
eventDrop: (info) => emit('eventDrop', info),
eventResize: (info) => emit('eventResize', info)
}"
/>
<div class="p-2">
<FullCalendar
:ref="(el) => setCalendarRef(el, (clinicColumn ? (sIdx + 1) : sIdx))"
:options="buildFcOptions(p.id)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</template>
<style scoped>
.mosaic-shell{
display:flex;
gap:12px;
padding: 8px;
}
@media (min-width: 768px){
.mosaic-shell{ padding: 12px; }
}
.mosaic-fixed{
flex: 0 0 auto;
width: 420px;
min-width: 320px;
max-width: 460px;
}
.mosaic-scroll{
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
}
.mosaic-grid{
display:grid;
grid-auto-flow: column;
gap:12px;
}
.mosaic-col{
border-radius: 1.25rem;
border: 1px solid var(--surface-border);
background: color-mix(in_srgb, var(--surface-card), transparent 12%);
overflow:hidden;
}
.mosaic-col-head{
padding: 12px;
border-bottom: 1px solid var(--surface-border);
display:flex;
align-items:center;
justify-content: space-between;
gap: 8px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,5 @@
<!-- src/features/agenda/components/AgendaRightPanel.vue -->
<script setup>
import Card from 'primevue/card'
import Divider from 'primevue/divider'
import Button from 'primevue/button'
const props = defineProps({
title: { type: String, default: 'Painel' },

View File

@@ -1,13 +1,7 @@
<script setup>
import { computed, ref, watch } from 'vue'
import Button from 'primevue/button'
import SelectButton from 'primevue/selectbutton'
import ToggleButton from 'primevue/togglebutton'
import FloatLabel from 'primevue/floatlabel'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
const props = defineProps({
title: { type: String, default: 'Agenda' },

View File

@@ -0,0 +1,523 @@
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
class="dc-dialog w-[96vw] max-w-2xl"
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' } }"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<!-- Dot de cor -->
<span
class="dc-header-dot shrink-0"
:style="{ backgroundColor: previewBgColor }"
/>
<div class="min-w-0">
<div class="text-base font-semibold truncate">
{{ form.name || (mode === 'create' ? 'Novo compromisso' : 'Editar compromisso') }}
</div>
<div class="text-xs opacity-50">
{{ mode === 'create' ? 'Novo tipo de compromisso' : 'Editando tipo de compromisso' }}
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
v-if="mode === 'edit' && canDelete"
icon="pi pi-trash"
severity="danger"
text
rounded
:disabled="saving"
v-tooltip.top="'Excluir'"
@click="emitDelete"
/>
<Button
label="Cancelar"
severity="secondary"
outlined
class="rounded-full"
:disabled="saving"
@click="close"
/>
<Button
label="Salvar"
icon="pi pi-check"
class="rounded-full"
:loading="saving"
:disabled="!canSubmit"
@click="submit"
/>
</div>
</div>
</template>
<!-- Banner de preview -->
<div
class="dc-banner"
:style="{ backgroundColor: previewBgColor }"
>
<span
class="dc-banner__pill"
:style="{ color: form.text_color || '#ffffff' }"
>
{{ form.name || 'Nome do compromisso' }}
</span>
</div>
<!-- Corpo -->
<div class="flex flex-col gap-4 p-4">
<!-- Nome + Ativo -->
<div class="flex items-center gap-3">
<div class="flex-1">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText
id="cr-nome"
v-model="form.name"
class="w-full"
variant="filled"
:disabled="saving || isEditLocked"
@keydown.enter.prevent="submit"
/>
</IconField>
<label for="cr-nome">Nome *</label>
</FloatLabel>
</div>
<div class="flex items-center gap-2 shrink-0 pt-1">
<span class="text-sm font-medium">Ativo</span>
<InputSwitch v-model="form.active" :disabled="saving || isActiveLocked" />
</div>
</div>
<!-- Seção Cor -->
<div class="dc-section">
<div class="dc-section__label">Cor</div>
<!-- Paleta predefinida -->
<div class="dc-palette">
<button
v-for="p in presetColors"
:key="p.bg"
class="dc-swatch"
:class="{ 'dc-swatch--active': form.bg_color === p.bg }"
:style="{ backgroundColor: `#${p.bg}` }"
:title="p.name"
:disabled="saving || isEditLocked"
@click="applyPreset(p)"
>
<i v-if="form.bg_color === p.bg" class="pi pi-check dc-swatch__check" />
</button>
<!-- Custom ColorPicker -->
<div class="dc-swatch dc-swatch--custom" title="Cor personalizada">
<ColorPicker
v-model="form.bg_color"
format="hex"
:disabled="saving || isEditLocked"
/>
</div>
</div>
<!-- Texto -->
<div class="flex items-center gap-3 mt-2">
<span class="text-xs font-medium opacity-60 uppercase tracking-wide">Texto</span>
<div class="flex gap-1">
<button
class="dc-text-opt"
:class="{ 'dc-text-opt--active': form.text_color === '#ffffff' }"
:disabled="saving || isEditLocked"
@click="form.text_color = '#ffffff'"
>
<span class="dc-text-opt__dot" style="background:#ffffff; border: 1px solid #ccc;" />
Branco
</button>
<button
class="dc-text-opt"
:class="{ 'dc-text-opt--active': form.text_color === '#000000' }"
:disabled="saving || isEditLocked"
@click="form.text_color = '#000000'"
>
<span class="dc-text-opt__dot" style="background:#000000;" />
Preto
</button>
</div>
</div>
</div>
<!-- Descrição -->
<FloatLabel variant="on">
<Textarea
id="cr-descricao"
v-model="form.description"
autoResize
rows="2"
class="w-full"
variant="filled"
:disabled="saving || isEditLocked"
/>
<label for="cr-descricao">Descrição</label>
</FloatLabel>
<!-- Campos adicionais -->
<div class="dc-section">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="dc-section__label mb-0">Campos adicionais</div>
<Button
label="Adicionar campo"
icon="pi pi-plus"
severity="secondary"
outlined
size="small"
class="rounded-full"
:disabled="saving || isFieldsLocked"
@click="addField"
/>
</div>
<div v-if="!form.fields.length" class="py-3 text-center text-sm opacity-50">
Nenhum campo adicional configurado.
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="(f, idx) in form.fields"
:key="f.key"
class="grid grid-cols-1 gap-2 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-0)] p-3 md:grid-cols-12"
>
<div class="md:col-span-6">
<FloatLabel variant="on">
<InputText
:id="`cr-field-label-${idx}`"
v-model="f.label"
class="w-full"
variant="filled"
:disabled="saving || isFieldsLocked"
@keydown.enter.prevent="submit"
@blur="syncKey(f)"
/>
<label :for="`cr-field-label-${idx}`">Nome do campo</label>
</FloatLabel>
</div>
<div class="md:col-span-4">
<FloatLabel variant="on">
<Dropdown
:id="`cr-field-type-${idx}`"
v-model="f.type"
:options="fieldTypeOptions"
optionLabel="label"
optionValue="value"
class="w-full"
variant="filled"
:disabled="saving || isFieldsLocked"
/>
<label :for="`cr-field-type-${idx}`">Tipo</label>
</FloatLabel>
</div>
<div class="md:col-span-2 flex items-center justify-end">
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
:disabled="saving || isFieldsLocked"
@click="removeField(idx)"
/>
</div>
<div class="md:col-span-12 text-xs opacity-40 font-mono">
key: {{ f.key }}
</div>
</div>
</div>
</div>
</div>
</Dialog>
</template>
<script setup>
import { computed, reactive, watch } from 'vue'
import Textarea from 'primevue/textarea'
import Dropdown from 'primevue/dropdown'
import InputSwitch from 'primevue/inputswitch'
import ColorPicker from 'primevue/colorpicker'
const props = defineProps({
modelValue: { type: Boolean, default: false },
mode: { type: String, default: 'create' }, // 'create' | 'edit'
saving: { type: Boolean, default: false },
commitment: { type: Object, default: null } // quando edit
})
const emit = defineEmits(['update:modelValue', 'save', 'delete'])
const fieldTypeOptions = [
{ label: 'Texto', value: 'text' },
{ label: 'Texto longo', value: 'textarea' }
]
const presetColors = [
{ bg: '6366f1', text: '#ffffff', name: 'Índigo' },
{ bg: '8b5cf6', text: '#ffffff', name: 'Violeta' },
{ bg: 'ec4899', text: '#ffffff', name: 'Rosa' },
{ bg: 'ef4444', text: '#ffffff', name: 'Vermelho' },
{ bg: 'f97316', text: '#ffffff', name: 'Laranja' },
{ bg: 'eab308', text: '#000000', name: 'Amarelo' },
{ bg: '22c55e', text: '#ffffff', name: 'Verde' },
{ bg: '14b8a6', text: '#ffffff', name: 'Teal' },
{ bg: '3b82f6', text: '#ffffff', name: 'Azul' },
{ bg: '06b6d4', text: '#ffffff', name: 'Ciano' },
{ bg: '64748b', text: '#ffffff', name: 'Ardósia' },
{ bg: '292524', text: '#ffffff', name: 'Escuro' },
]
function applyPreset (p) {
if (props.saving) return
form.bg_color = p.bg
form.text_color = p.text
}
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const form = reactive({
id: null,
name: '',
description: '',
native: false,
locked: false,
active: true,
bg_color: '6366f1',
text_color: '#ffffff',
fields: []
})
const previewBgColor = computed(() => {
if (!form.bg_color) return '#6366f1'
return form.bg_color.startsWith('#') ? form.bg_color : `#${form.bg_color}`
})
watch(
() => props.modelValue,
(open) => {
if (!open) return
hydrate()
}
)
watch(
() => props.commitment,
() => {
if (!props.modelValue) return
hydrate()
}
)
function hydrate () {
const c = props.commitment
if (props.mode === 'edit' && c) {
form.id = c.id
form.name = c.name || ''
form.description = c.description || ''
form.native = !!c.native
form.locked = !!c.locked
form.active = !!c.active
form.bg_color = c.bg_color || '6366f1'
form.text_color = c.text_color || '#ffffff'
form.fields = Array.isArray(c.fields) ? JSON.parse(JSON.stringify(c.fields)) : []
} else {
form.id = null
form.name = ''
form.description = ''
form.native = false
form.locked = false
form.active = true
form.bg_color = '6366f1'
form.text_color = '#ffffff'
form.fields = []
}
}
const isActiveLocked = computed(() => !!form.locked) // nativo+locked → sempre ativo, nunca pode desativar
const isEditLocked = computed(() => false) // edição sempre permitida
const isFieldsLocked = computed(() => false) // campos sempre editáveis
const canDelete = computed(() => !form.native)
const canSubmit = computed(() => {
if (props.saving) return false
if (!String(form.name || '').trim()) return false
return true
})
function close () {
if (props.saving) return
visible.value = false
}
function submit () {
if (!canSubmit.value) return
const payload = {
id: form.id,
name: String(form.name || '').trim(),
description: String(form.description || '').trim(),
active: form.locked ? true : !!form.active,
bg_color: form.bg_color || null,
text_color: form.text_color || null,
fields: (form.fields || []).map(f => ({
key: f.key,
label: String(f.label || '').trim() || 'Campo',
type: f.type === 'textarea' ? 'textarea' : 'text',
required: !!f.required
}))
}
emit('save', payload)
}
function emitDelete () {
if (props.saving) return
emit('delete', { id: form.id })
visible.value = false
}
function addField () {
const base = `campo-${form.fields.length + 1}`
form.fields.push({
key: makeKey(base),
label: 'Observação',
type: 'textarea',
required: false
})
}
function removeField (idx) {
form.fields.splice(idx, 1)
}
function syncKey (field) {
// se o user renomear, a key acompanha (sem quebrar: simples por enquanto)
const next = makeKey(field.label)
field.key = next
}
function makeKey (label) {
const k = String(label || '')
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/(^_|_$)/g, '') || `field_${Math.random().toString(16).slice(2, 8)}`
return k
}
</script>
<style scoped>
/* ── Header ─────────────────────────────── */
.dc-header-dot {
width: 14px; height: 14px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.3);
box-shadow: 0 0 0 3px rgba(0,0,0,0.08);
transition: background-color 0.2s ease;
}
/* ── Banner de preview ───────────────────── */
.dc-banner {
height: 72px;
display: flex; align-items: center; justify-content: center;
transition: background-color 0.25s ease;
}
.dc-banner__pill {
font-size: 1rem; font-weight: 700; letter-spacing: -0.02em;
padding: 0.35rem 1.1rem;
background: rgba(0,0,0,0.15);
border-radius: 999px;
backdrop-filter: blur(4px);
transition: color 0.2s ease;
}
/* ── Section ─────────────────────────────── */
.dc-section {
border: 1px solid var(--surface-border);
border-radius: 1.25rem;
background: var(--surface-card);
padding: 1rem;
}
.dc-section__label {
font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
opacity: 0.45; margin-bottom: 0.75rem;
}
/* ── Paleta ──────────────────────────────── */
.dc-palette {
display: flex; flex-wrap: wrap; gap: 0.45rem;
}
.dc-swatch {
width: 28px; height: 28px;
border-radius: 50%;
border: 2px solid transparent;
display: grid; place-items: center;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
position: relative;
}
.dc-swatch:hover:not(:disabled) {
transform: scale(1.18);
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
}
.dc-swatch--active {
border-color: var(--surface-0, #fff);
box-shadow: 0 0 0 2px var(--text-color);
}
.dc-swatch__check {
font-size: 0.6rem; color: #fff; font-weight: 900;
}
.dc-swatch--custom {
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
overflow: hidden;
}
.dc-swatch--custom :deep(.p-colorpicker-preview) {
width: 100%; height: 100%;
border: none; border-radius: 50%;
opacity: 0;
}
/* ── Texto toggle ────────────────────────── */
.dc-text-opt {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.25rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--surface-border);
font-size: 0.8rem; font-weight: 500;
cursor: pointer;
color: var(--text-color);
background: transparent;
transition: background 0.12s, border-color 0.12s;
}
.dc-text-opt:hover:not(:disabled) { background: var(--surface-hover); }
.dc-text-opt--active {
background: var(--surface-section, var(--surface-100));
border-color: var(--primary-color);
color: var(--primary-color);
font-weight: 700;
}
.dc-text-opt__dot {
width: 10px; height: 10px; border-radius: 50%; display: inline-block;
}
</style>

View File

@@ -2,13 +2,6 @@
<script setup>
import { computed, ref, watch } from 'vue'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import SelectButton from 'primevue/selectbutton'
import InputText from 'primevue/inputtext'
import FloatLabel from 'primevue/floatlabel'
import Tag from 'primevue/tag'
const props = defineProps({
title: { type: String, default: 'Agenda' },

View File

@@ -2,10 +2,6 @@
<script setup>
import { computed } from 'vue'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Tag from 'primevue/tag'
const props = defineProps({
stats: { type: Object, default: () => ({}) }

View File

@@ -0,0 +1,75 @@
// src/features/agenda/composables/useAgendaClinicEvents.js
import { ref } from 'vue'
import {
listClinicEvents,
createClinicAgendaEvento,
updateClinicAgendaEvento,
deleteClinicAgendaEvento
} from '@/features/agenda/services/agendaClinicRepository'
export function useAgendaClinicEvents () {
const loading = ref(false)
const error = ref('')
const rows = ref([])
async function loadClinicRange ({ tenantId, ownerIds, startISO, endISO }) {
loading.value = true
error.value = ''
try {
rows.value = await listClinicEvents({ tenantId, ownerIds, startISO, endISO })
} catch (e) {
error.value = e?.message || 'Falha ao carregar eventos.'
} finally {
loading.value = false
}
}
async function createClinic (payload, { tenantId } = {}) {
loading.value = true
error.value = ''
try {
return await createClinicAgendaEvento(payload, { tenantId })
} catch (e) {
error.value = e?.message || 'Falha ao criar evento.'
throw e
} finally {
loading.value = false
}
}
async function updateClinic (id, patch, { tenantId } = {}) {
loading.value = true
error.value = ''
try {
return await updateClinicAgendaEvento(id, patch, { tenantId })
} catch (e) {
error.value = e?.message || 'Falha ao atualizar evento.'
throw e
} finally {
loading.value = false
}
}
async function removeClinic (id, { tenantId } = {}) {
loading.value = true
error.value = ''
try {
return await deleteClinicAgendaEvento(id, { tenantId })
} catch (e) {
error.value = e?.message || 'Falha ao remover evento.'
throw e
} finally {
loading.value = false
}
}
return {
loading,
error,
rows,
loadClinicRange,
createClinic,
updateClinic,
removeClinic
}
}

View File

@@ -0,0 +1,45 @@
import { computed, ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
export function useDeterminedCommitments (tenantIdRef) {
const loading = ref(false)
const error = ref('')
const rows = ref([])
const tenantId = computed(() => {
const v = tenantIdRef?.value ?? tenantIdRef
return v ? String(v) : ''
})
async function load () {
try {
if (!tenantId.value) {
rows.value = []
error.value = ''
return
}
if (loading.value) return
loading.value = true
error.value = ''
const { data, error: err } = await supabase
.from('determined_commitments')
.select('id,tenant_id,created_by,is_native,native_key,is_locked,active,name,description,bg_color,text_color,created_at,determined_commitment_fields(id,key,label,field_type,required,sort_order)')
.eq('tenant_id', tenantId.value) // ✅ SOMENTE tenant corrente
.eq('active', true)
.order('is_native', { ascending: false })
.order('name', { ascending: true })
if (err) throw err
rows.value = data || []
} catch (e) {
error.value = e?.message || 'Falha ao carregar compromissos determinísticos.'
rows.value = []
} finally {
loading.value = false
}
}
return { loading, error, rows, load }
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,753 @@
<!-- src/features/agenda/pages/CompromissosDeterminados.vue -->
<template>
<Toast />
<!-- Sentinel para detecção de sticky -->
<div ref="headerSentinelRef" class="cmpr-sentinel" />
<!-- Hero Header sticky -->
<div ref="headerEl" class="cmpr-hero mx-3 md:mx-5 mb-4" :class="{ 'cmpr-hero--stuck': headerStuck }">
<!-- Blobs decorativos -->
<div class="cmpr-hero__blobs" aria-hidden="true">
<div class="cmpr-hero__blob cmpr-hero__blob--1" />
<div class="cmpr-hero__blob cmpr-hero__blob--2" />
</div>
<!-- Linha 1: brand + controles -->
<div class="cmpr-hero__row1">
<div class="cmpr-hero__brand">
<div class="cmpr-hero__icon">
<i class="pi pi-list text-lg" />
</div>
<div class="min-w-0">
<div class="cmpr-hero__title">Compromissos</div>
<div class="cmpr-hero__sub">Configure tipos de compromissos e campos adicionais</div>
</div>
</div>
<!-- Controles desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button label="Novo" icon="pi pi-plus" class="rounded-full" :disabled="loading" @click="openCreate()" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="fetchAll()" />
</div>
<!-- Menu mobile (<1200px) -->
<div class="flex xl:hidden items-center shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
<!-- Divisor -->
<Divider class="cmpr-hero__divider my-2" />
<!-- Linha 2: filtros + busca (oculta no mobile) -->
<div class="cmpr-hero__row2">
<SelectButton v-model="typeFilter" :options="typeOptions" optionLabel="label" optionValue="value" :disabled="loading" />
<InputGroup class="w-72">
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="filters.global.value" placeholder="Buscar compromisso" :disabled="loading" />
<Button
v-if="filters.global.value"
icon="pi pi-trash"
severity="danger"
title="Limpar busca"
@click="clearSearch"
/>
</InputGroup>
</div>
</div>
<!-- Dialog de busca (mobile) -->
<Dialog v-model:visible="searchDlgOpen" modal :draggable="false" header="Buscar compromisso" class="w-[94vw] max-w-sm">
<div class="pt-1">
<InputGroup>
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="filters.global.value" placeholder="Nome ou descrição..." autofocus />
<Button
v-if="filters.global.value"
icon="pi pi-trash"
severity="danger"
title="Limpar"
@click="filters.global.value = null"
/>
</InputGroup>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchDlgOpen = false" />
</template>
</Dialog>
<!-- Cards -->
<div class="mb-4 px-3 md:px-5 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
<Card
v-for="c in cardsCommitments"
:key="c.id"
class="rounded-3xl border border-[var(--surface-border)] shadow-sm"
>
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span
v-if="c.bg_color"
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold"
:style="{ backgroundColor: `#${c.bg_color}`, color: c.text_color || '#ffffff' }"
>{{ c.name }}</span>
<span v-else class="truncate text-base font-semibold">{{ c.name }}</span>
<Tag v-if="c.is_native" value="Nativo" severity="info" />
</div>
<div class="mt-1 line-clamp-2 text-sm opacity-70">
{{ c.description || '—' }}
</div>
<div class="mt-3 text-sm">
<span class="opacity-70">Tempo total:</span>
<span class="ml-2 font-semibold">{{ formatMinutes(getTotalMinutes(c.id)) }}</span>
</div>
</div>
<div class="flex flex-col items-end gap-2">
<div class="flex items-center gap-2">
<span class="text-xs opacity-70">Ativo</span>
<InputSwitch
v-model="c.active"
:disabled="isActiveLocked(c) || saving"
@change="onToggleActive(c)"
/>
</div>
<div class="flex items-center gap-1">
<Button
icon="pi pi-pencil"
severity="secondary"
text
rounded
:disabled="isEditLocked(c) || saving"
@click="openEdit(c)"
/>
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
:disabled="isDeleteLocked(c) || saving"
@click="confirmDelete(c)"
/>
</div>
</div>
</div>
</template>
</Card>
</div>
<!-- Tabela -->
<div class="mx-3 md:mx-5 mb-5 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-3">
<div class="text-base font-semibold">Lista de compromissos</div>
<div class="text-sm opacity-60">
{{ visibleCommitments.length }} itens
</div>
</div>
<DataTable
:value="visibleCommitments"
dataKey="id"
:loading="loading"
:paginator="true"
:rows="10"
responsiveLayout="scroll"
class="p-datatable-sm"
:filters="filters"
filterDisplay="menu"
:globalFilterFields="['name','description']"
>
<Column field="name" header="Nome" sortable filter filterPlaceholder="Filtrar nome" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex items-center gap-2">
<span class="font-semibold">{{ data.name }}</span>
<Tag v-if="data.is_native" value="Nativo" severity="info" />
</div>
</template>
</Column>
<Column field="description" header="Descrição" sortable filter filterPlaceholder="Filtrar descrição" style="min-width: 18rem">
<template #body="{ data }">
<span class="opacity-80">{{ data.description || '—' }}</span>
</template>
</Column>
<Column header="Tempo total" sortable style="min-width: 10rem">
<template #body="{ data }">
{{ formatMinutes(getTotalMinutes(data.id)) }}
</template>
</Column>
<Column field="active" header="Ativo" style="width: 8rem">
<template #body="{ data }">
<InputSwitch
v-model="data.active"
:disabled="isActiveLocked(data) || saving"
@change="onToggleActive(data)"
/>
</template>
</Column>
<Column header="Ação" style="width: 10rem">
<template #body="{ data }">
<div class="flex items-center gap-1">
<Button
icon="pi pi-pencil"
severity="secondary"
text
rounded
:disabled="isEditLocked(data) || saving"
@click="openEdit(data)"
/>
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
:disabled="isDeleteLocked(data) || saving"
@click="confirmDelete(data)"
/>
</div>
</template>
</Column>
<template #empty>
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum compromisso determinístico encontrado</div>
<div class="mt-1 text-sm text-color-secondary">
Tente limpar filtros ou mudar o termo de busca.
</div>
<div class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearSearch" />
<Button icon="pi pi-plus" label="Cadastrar compromisso" @click="openCreate()" />
</div>
</div>
</template>
</DataTable>
</div>
<!-- Dialog -->
<DeterminedCommitmentDialog
v-model="dlgOpen"
:mode="dlgMode"
:saving="saving"
:commitment="editing"
@save="onSave"
@delete="onDelete"
/>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useToast } from 'primevue/usetoast'
import InputSwitch from 'primevue/inputswitch'
import Menu from 'primevue/menu'
import DeterminedCommitmentDialog from '@/features/agenda/components/DeterminedCommitmentDialog.vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
const toast = useToast()
const tenantStore = useTenantStore()
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
// ── Mobile menu ───────────────────────────────────────────
const mobileMenuRef = ref(null)
const searchDlgOpen = ref(false)
const mobileMenuItems = computed(() => [
{
label: 'Novo compromisso',
icon: 'pi pi-plus',
command: () => openCreate()
},
{
label: 'Buscar',
icon: 'pi pi-search',
command: () => { searchDlgOpen.value = true }
},
{ separator: true },
{
label: 'Recarregar',
icon: 'pi pi-refresh',
command: () => fetchAll()
}
])
onMounted(async () => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
await tenantStore.loadSessionAndTenant()
await fetchAll()
})
onBeforeUnmount(() => { _observer?.disconnect() })
const loading = ref(false)
const saving = ref(false)
const filters = reactive({
global: { value: null, matchMode: 'contains' },
name: { value: null, matchMode: 'contains' },
description: { value: null, matchMode: 'contains' }
})
/**
* Filtro por tipo (Todos / Nativos / Meus)
* - aplica na tabela (via computed) e nos cards
*/
const typeFilter = ref('all')
const typeOptions = [
{ label: 'Todos', value: 'all' },
{ label: 'Nativos', value: 'native' },
{ label: 'Meus', value: 'custom' }
]
/**
* Modelo de compromisso (tipo determinístico):
* - is_native: template do sistema
* - is_locked: trava comportamento (ex: Sessão)
* - fields: campos adicionais (dinâmicos)
*/
const commitments = ref([])
// Totais reais (minutos) agregados de commitment_time_logs
const totalsByCommitmentId = ref({})
/**
* Lista base para tabela:
* - aplica filtro por tipo (Todos / Nativos / Meus)
* - (global search do DataTable continua via :filters)
*/
const visibleCommitments = computed(() => {
let list = commitments.value
if (typeFilter.value === 'native') list = list.filter(c => !!c.is_native)
if (typeFilter.value === 'custom') list = list.filter(c => !c.is_native)
return list
})
/**
* Lista para cards:
* - aplica o mesmo filtro de tipo
* - + aplica busca global (para cards acompanharem a barra de busca)
*/
const cardsCommitments = computed(() => {
let list = visibleCommitments.value
const q = String(filters.global?.value ?? '').trim().toLowerCase()
if (q) {
list = list.filter(c =>
String(c.name || '').toLowerCase().includes(q) ||
String(c.description || '').toLowerCase().includes(q)
)
}
return list
})
function clearSearch () {
filters.global.value = null
}
const dlgOpen = ref(false)
const dlgMode = ref('create') // 'create' | 'edit'
const editing = ref(null)
function getTenantId () {
// ✅ sem fallback (evita vazamento clinic↔therapist)
return tenantStore.activeTenantId || null
}
async function fetchAll () {
const tenantId = getTenantId()
if (!tenantId) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Tenant inválido.', life: 3000 })
return
}
loading.value = true
try {
// 1) commitments
const { data: cData, error: cErr } = await supabase
.from('determined_commitments')
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
.eq('tenant_id', tenantId)
.order('is_native', { ascending: false })
.order('name', { ascending: true })
if (cErr) throw cErr
const ids = (cData || []).map(x => x.id)
// 2) fields
let fieldsByCommitmentId = {}
if (ids.length > 0) {
const { data: fData, error: fErr } = await supabase
.from('determined_commitment_fields')
.select('id, tenant_id, commitment_id, key, label, field_type, required, sort_order')
.eq('tenant_id', tenantId)
.in('commitment_id', ids)
.order('sort_order', { ascending: true })
if (fErr) throw fErr
fieldsByCommitmentId = (fData || []).reduce((acc, row) => {
const k = row.commitment_id
if (!acc[k]) acc[k] = []
acc[k].push({
id: row.id,
key: row.key,
label: row.label,
type: row.field_type,
required: !!row.required,
sort_order: row.sort_order
})
return acc
}, {})
}
// 3) totals (logs)
const { data: lData, error: lErr } = await supabase
.from('commitment_time_logs')
.select('commitment_id, minutes')
.eq('tenant_id', tenantId)
if (lErr) throw lErr
const totals = {}
for (const row of (lData || [])) {
const cid = row.commitment_id
const m = Number(row.minutes ?? 0) || 0
totals[cid] = (totals[cid] || 0) + m
}
totalsByCommitmentId.value = totals
// 4) merge
commitments.value = (cData || []).map(c => ({
...c,
fields: fieldsByCommitmentId[c.id] || []
}))
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar compromissos.', life: 4500 })
} finally {
loading.value = false
}
}
function getTotalMinutes (commitmentId) {
return Number(totalsByCommitmentId.value?.[commitmentId] ?? 0)
}
function formatMinutes (minutes) {
const m = Math.max(0, Number(minutes) || 0)
const h = Math.floor(m / 60)
const mm = m % 60
if (h <= 0) return `${mm}m`
return `${h}h ${String(mm).padStart(2, '0')}m`
}
function isActiveLocked (c) {
return !!c.is_locked
}
function isDeleteLocked (c) {
return !!c.is_native
}
function isEditLocked (_c) {
return false // edição sempre permitida; só o "ativo" fica travado
}
function openCreate () {
dlgMode.value = 'create'
editing.value = null
dlgOpen.value = true
}
function openEdit (c) {
dlgMode.value = 'edit'
editing.value = JSON.parse(JSON.stringify(c))
dlgOpen.value = true
}
async function onToggleActive (c) {
if (isActiveLocked(c)) return
const tenantId = getTenantId()
if (!tenantId) return
saving.value = true
try {
const { error } = await supabase
.from('determined_commitments')
.update({ active: !!c.active })
.eq('tenant_id', tenantId)
.eq('id', c.id)
if (error) throw error
toast.add({
severity: 'success',
summary: 'Atualizado',
detail: `${c.name}” agora está ${c.active ? 'ativo' : 'inativo'}.`,
life: 2500
})
} catch (e) {
console.error(e)
c.active = !c.active
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao atualizar.', life: 4500 })
} finally {
saving.value = false
}
}
async function onSave (payload) {
const tenantId = getTenantId()
if (!tenantId) return
saving.value = true
try {
// pega usuário atual (se quiser auditoria futura)
await supabase.auth.getUser()
if (dlgMode.value === 'create') {
const insertRow = {
tenant_id: tenantId,
is_native: false,
native_key: null,
is_locked: false,
active: !!payload.active,
name: payload.name,
description: payload.description,
bg_color: payload.bg_color || null,
text_color: payload.text_color || null
}
const { data: newC, error: cErr } = await supabase
.from('determined_commitments')
.insert(insertRow)
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
.single()
if (cErr) throw cErr
const fields = Array.isArray(payload.fields) ? payload.fields : []
if (fields.length > 0) {
const rows = fields.map((f, idx) => ({
tenant_id: tenantId,
commitment_id: newC.id,
key: f.key,
label: f.label,
field_type: f.type,
required: !!f.required,
sort_order: Number(f.sort_order ?? ((idx + 1) * 10))
}))
const { error: fErr } = await supabase
.from('determined_commitment_fields')
.insert(rows)
if (fErr) throw fErr
}
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado com sucesso.', life: 2500 })
dlgOpen.value = false
await fetchAll()
} else {
const updateRow = {
name: payload.name,
description: payload.description,
active: !!payload.active,
bg_color: payload.bg_color || null,
text_color: payload.text_color || null
}
const { error: upErr } = await supabase
.from('determined_commitments')
.update(updateRow)
.eq('tenant_id', tenantId)
.eq('id', payload.id)
if (upErr) throw upErr
const fields = Array.isArray(payload.fields) ? payload.fields : []
const { error: delErr } = await supabase
.from('determined_commitment_fields')
.delete()
.eq('tenant_id', tenantId)
.eq('commitment_id', payload.id)
if (delErr) throw delErr
if (fields.length > 0) {
const rows = fields.map((f, idx) => ({
tenant_id: tenantId,
commitment_id: payload.id,
key: f.key,
label: f.label,
field_type: f.type,
required: !!f.required,
sort_order: Number(f.sort_order ?? ((idx + 1) * 10))
}))
const { error: insErr } = await supabase
.from('determined_commitment_fields')
.insert(rows)
if (insErr) throw insErr
}
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Alterações salvas.', life: 2500 })
dlgOpen.value = false
await fetchAll()
}
} catch (e) {
console.error(e)
const msg = e?.message || ''
const detail = (e?.code === '23505' || /duplicate key value/i.test(msg))
? 'Já existe um compromisso com esse nome neste tenant. Escolha outro nome.'
: (msg || 'Falha ao salvar compromisso.')
toast.add({ severity: 'error', summary: 'Erro', detail, life: 4500 })
} finally {
saving.value = false
}
}
function confirmDelete (c) {
if (isDeleteLocked(c)) return
const ok = window.confirm(`Excluir “${c.name}”? Essa ação não pode ser desfeita.`)
if (!ok) return
onDelete(c)
}
async function onDelete (c) {
if (isDeleteLocked(c)) return
const tenantId = getTenantId()
if (!tenantId) return
saving.value = true
try {
const { error: fErr } = await supabase
.from('determined_commitment_fields')
.delete()
.eq('tenant_id', tenantId)
.eq('commitment_id', c.id)
if (fErr) throw fErr
const { error: lErr } = await supabase
.from('commitment_time_logs')
.delete()
.eq('tenant_id', tenantId)
.eq('commitment_id', c.id)
if (lErr) throw lErr
const { data: delRows, error: dErr } = await supabase
.from('determined_commitments')
.delete()
.eq('tenant_id', tenantId)
.eq('id', c.id)
.eq('is_native', false)
.select('id')
if (dErr) throw dErr
if (!delRows || delRows.length === 0) {
throw new Error('DELETE bloqueado por RLS (0 linhas). Confirme policy dc_delete_custom_for_active_member.')
}
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 })
dlgOpen.value = false
await fetchAll()
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir compromisso.', life: 4500 })
} finally {
saving.value = false
}
}
</script>
<style scoped>
/* ── Hero Header ─────────────────────────────────── */
.cmpr-sentinel { height: 1px; }
.cmpr-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.cmpr-hero--stuck {
margin-left: 0;
margin-right: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
/* Blobs */
.cmpr-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.cmpr-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.cmpr-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
.cmpr-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
/* Linha 1 */
.cmpr-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.cmpr-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.cmpr-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.cmpr-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.cmpr-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
/* Linha 2 (oculta no mobile) */
.cmpr-hero__row2 {
position: relative; z-index: 1;
display: flex; align-items: center;
gap: 0.75rem;
}
@media (max-width: 767px) {
.cmpr-hero__divider,
.cmpr-hero__row2 { display: none; }
}
</style>

View File

@@ -0,0 +1,131 @@
// src/features/agenda/services/agendaClinicRepository.js
import { supabase } from '@/lib/supabase/client'
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.')
}
}
function assertValidIsoRange (startISO, endISO) {
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).')
}
function sanitizeOwnerIds (ownerIds) {
return (ownerIds || [])
.filter(id => typeof id === 'string' && id && id !== 'null' && id !== 'undefined')
}
/**
* 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 []
assertValidIsoRange(startISO, endISO)
const safeOwnerIds = sanitizeOwnerIds(ownerIds)
if (!safeOwnerIds.length) return []
const { data, error } = await supabase
.from('agenda_eventos')
.select('*')
.eq('tenant_id', tenantId)
.in('owner_id', safeOwnerIds)
.gte('inicio_em', startISO)
.lt('inicio_em', endISO)
.order('inicio_em', { ascending: true })
if (error) throw error
return data || []
}
/**
* Lista profissionais/membros para montar colunas no mosaico.
* Usando view "v_tenant_staff" (como você já tem).
*/
export async function listTenantStaff (tenantId) {
assertValidTenantId(tenantId)
const { data, error } = await supabase
.from('v_tenant_staff')
.select('*')
.eq('tenant_id', tenantId)
if (error) throw error
return data || []
}
/**
* Criação para a área da clínica (admin/secretária):
* - exige tenantId explícito
* - 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 } = {}) {
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
}
/**
* Atualização segura para clínica:
* - filtra por id + tenant_id (evita update cruzado)
* - permite editar owner_id (caso você mova evento para outro profissional)
*/
export async function updateClinicAgendaEvento (id, patch, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.')
if (!patch) throw new Error('Patch vazio.')
assertValidTenantId(tenantId)
const { data, error } = await supabase
.from('agenda_eventos')
.update(patch)
.eq('id', id)
.eq('tenant_id', tenantId)
.select('*')
.single()
if (error) throw error
return data
}
/**
* Delete seguro para clínica:
* - filtra por id + tenant_id
*/
export async function deleteClinicAgendaEvento (id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.')
assertValidTenantId(tenantId)
const { error } = await supabase
.from('agenda_eventos')
.delete()
.eq('id', id)
.eq('tenant_id', tenantId)
if (error) throw error
return true
}

View File

@@ -1,20 +1,52 @@
// src/features/agenda/services/agendaMappers.js
export function mapAgendaEventosToCalendarEvents (rows) {
return (rows || []).map((r) => ({
id: r.id,
title: r.titulo || tituloFallback(r.tipo),
start: r.inicio_em,
end: r.fim_em,
extendedProps: {
tipo: r.tipo,
status: r.status,
paciente_id: r.paciente_id,
terapeuta_id: r.terapeuta_id,
observacoes: r.observacoes,
owner_id: r.owner_id
return (rows || []).map((r) => {
// 🔥 regra importante:
// prioridade: owner_id
// fallback: terapeuta_id
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
const commitment = r.determined_commitments
const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
const txtColor = commitment?.text_color || undefined
return {
id: r.id,
title: r.titulo || tituloFallback(r.tipo),
start: r.inicio_em,
end: r.fim_em,
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
...(txtColor && { textColor: txtColor }),
extendedProps: {
// 🔥 ESSENCIAL PARA O MOSAICO
owner_id: ownerId,
tipo: r.tipo ?? null,
status: r.status ?? null,
paciente_id: r.paciente_id ?? null,
paciente_nome: r.patients?.nome_completo ?? null,
paciente_avatar: r.patients?.avatar_url ?? null,
terapeuta_id: r.terapeuta_id ?? null,
observacoes: r.observacoes ?? null,
// ✅ usados na clínica p/ mascarar/privacidade
visibility_scope: r.visibility_scope ?? null,
masked: !!r.masked,
// ✅ compromisso determinístico
determined_commitment_id: r.determined_commitment_id ?? null,
commitment_bg_color: bgColor ?? null,
commitment_text_color: txtColor ?? null,
// ✅ campos customizados
titulo_custom: r.titulo_custom ?? null,
extra_fields: r.extra_fields ?? null
}
}
}))
})
}
export function buildNextSessions (rows, now = new Date()) {
@@ -98,21 +130,52 @@ export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd)
}
export function mapAgendaEventosToClinicResourceEvents (rows) {
return (rows || []).map((r) => ({
id: r.id,
title: r.titulo || tituloFallback(r.tipo),
start: r.inicio_em,
end: r.fim_em,
resourceId: r.owner_id, // 🔥 coluna = dono da agenda (profissional)
extendedProps: {
tipo: r.tipo,
status: r.status,
paciente_id: r.paciente_id,
terapeuta_id: r.terapeuta_id,
observacoes: r.observacoes,
owner_id: r.owner_id
return (rows || []).map((r) => {
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
const commitment = r.determined_commitments
const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
const txtColor = commitment?.text_color || undefined
return {
id: r.id,
title: r.titulo || tituloFallback(r.tipo),
start: r.inicio_em,
end: r.fim_em,
// 🔥 resourceId também precisa ser confiável
resourceId: ownerId,
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
...(txtColor && { textColor: txtColor }),
extendedProps: {
owner_id: ownerId,
tipo: r.tipo ?? null,
status: r.status ?? null,
paciente_id: r.paciente_id ?? null,
terapeuta_id: r.terapeuta_id ?? null,
observacoes: r.observacoes ?? null,
visibility_scope: r.visibility_scope ?? null,
masked: !!r.masked,
determined_commitment_id: r.determined_commitment_id ?? null,
commitment_bg_color: bgColor ?? null,
commitment_text_color: txtColor ?? null
}
}
}))
})
}
// -------------------- helpers --------------------
function normalizeId (v) {
if (v === null || v === undefined) return null
const s = String(v).trim()
return s ? s : null
}
function normalizeWeekday (value) {

View File

@@ -28,7 +28,9 @@ export async function getMyAgendaSettings () {
.from('agenda_configuracoes')
.select('*')
.eq('owner_id', uid)
.single()
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (error) throw error
return data
@@ -49,7 +51,7 @@ export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantId
const { data, error } = await supabase
.from('agenda_eventos')
.select('*')
.select('*, patients(id, nome_completo, avatar_url), determined_commitments!determined_commitment_id(id, bg_color, text_color)')
.eq('tenant_id', tenantId)
.eq('owner_id', uid)
.gte('inicio_em', startISO)
@@ -57,7 +59,27 @@ export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantId
.order('inicio_em', { ascending: true })
if (error) throw error
return data || []
const rows = data || []
// Eventos antigos têm paciente_id mas patient_id=null (sem FK) → join retorna null.
// Fazemos um segundo fetch para esses casos e mesclamos.
const orphanIds = [...new Set(
rows.filter(r => r.paciente_id && !r.patients).map(r => r.paciente_id)
)]
if (orphanIds.length) {
const { data: pts } = await supabase
.from('patients')
.select('id, nome_completo, avatar_url')
.in('id', orphanIds)
if (pts?.length) {
const map = Object.fromEntries(pts.map(p => [p.id, p]))
for (const r of rows) {
if (r.paciente_id && !r.patients) r.patients = map[r.paciente_id] || null
}
}
}
return rows
}
/**
@@ -77,7 +99,7 @@ export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO }
const { data, error } = await supabase
.from('agenda_eventos')
.select('*')
.select('*, determined_commitments!determined_commitment_id(id, bg_color, text_color)')
.eq('tenant_id', tenantId)
.in('owner_id', safeOwnerIds)
.gte('inicio_em', startISO)

View File

@@ -0,0 +1,35 @@
<template>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<i :class="icon" class="opacity-80" />
<div class="text-base font-semibold">{{ title }}</div>
</div>
<div class="mt-1 text-sm opacity-80">{{ desc }}</div>
</div>
<div class="shrink-0">
<ToggleButton
:modelValue="enabled"
onLabel="Ativo"
offLabel="Inativo"
:loading="loading"
@update:modelValue="$emit('toggle')"
/>
</div>
</div>
</template>
<script setup>
import ToggleButton from 'primevue/togglebutton'
defineProps({
title: { type: String, default: '' },
desc: { type: String, default: '' },
icon: { type: String, default: '' },
enabled: { type: Boolean, default: false },
loading: { type: Boolean, default: false }
})
defineEmits(['toggle'])
</script>

View File

@@ -1,139 +1,112 @@
<template>
<div class="p-4">
<!-- HEADER CONCEITUAL -->
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute bottom-0 right-24 h-44 w-44 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<Toast />
<div class="relative flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<!-- título -->
<div class="min-w-0">
<div class="flex items-center gap-3">
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-users text-lg" />
</div>
<!-- Sentinel para detecção de sticky -->
<div ref="headerSentinelRef" class="pat-sentinel" />
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="text-xl font-semibold leading-none">Pacientes</div>
<Tag :value="`${kpis.total}`" severity="secondary" />
</div>
<div class="mt-1 text-sm text-color-secondary">
Lista de pacientes cadastrados. Filtre por status, tags e grupos.
</div>
</div>
</div>
<!-- Hero Header sticky -->
<div ref="headerEl" class="pat-hero mx-3 md:mx-5 mb-4" :class="{ 'pat-hero--stuck': headerStuck }">
<!-- Blobs decorativos -->
<div class="pat-hero__blobs" aria-hidden="true">
<div class="pat-hero__blob pat-hero__blob--1" />
<div class="pat-hero__blob pat-hero__blob--2" />
<div class="pat-hero__blob pat-hero__blob--3" />
</div>
<!-- KPIs como filtros -->
<div class="mt-4 flex flex-wrap gap-2">
<Button
type="button"
class="!rounded-full"
:outlined="filters.status !== 'Todos'"
severity="secondary"
@click="setStatus('Todos')"
>
<span class="flex items-center gap-2">
<i class="pi pi-users" />
Total: <b>{{ kpis.total }}</b>
</span>
</Button>
<!-- Linha 1: brand + controles -->
<div class="pat-hero__row1">
<div class="pat-hero__brand">
<div class="pat-hero__icon">
<i class="pi pi-users text-lg" />
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="pat-hero__title">Pacientes</div>
<Tag :value="`${kpis.total}`" severity="secondary" />
</div>
<div class="pat-hero__sub">Lista de pacientes cadastrados. Filtre por status, tags e grupos.</div>
</div>
</div>
<Button
type="button"
class="!rounded-full"
:outlined="filters.status !== 'Ativo'"
:severity="filters.status === 'Ativo' ? 'success' : 'secondary'"
@click="setStatus('Ativo')"
>
<span class="flex items-center gap-2">
<i class="pi pi-user-plus" />
Ativos: <b>{{ kpis.active }}</b>
</span>
</Button>
<!-- Controles desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchAll" />
<SplitButton label="Cadastrar" icon="pi pi-user-plus" :model="createMenu" class="rounded-full" @click="goCreateFull" />
</div>
<Button
type="button"
class="!rounded-full"
:outlined="filters.status !== 'Inativo'"
:severity="filters.status === 'Inativo' ? 'danger' : 'secondary'"
@click="setStatus('Inativo')"
>
<span class="flex items-center gap-2">
<i class="pi pi-user-minus" />
Inativos: <b>{{ kpis.inactive }}</b>
</span>
</Button>
<!-- Menu mobile (<1200px) -->
<div class="flex xl:hidden items-center shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => patMobileMenuRef.toggle(e)" />
<Menu ref="patMobileMenuRef" :model="patMobileMenuItems" :popup="true" />
</div>
</div>
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-2 text-xs text-color-secondary">
<i class="pi pi-calendar" />
Último atendimento: <b class="text-[var(--text-color)]">{{ prettyDate(kpis.latestLastAttended) }}</b>
</span>
</div>
</div>
<!-- Divisor -->
<Divider class="pat-hero__divider my-2" />
<!-- ações -->
<div class="flex flex-col sm:flex-row gap-2 sm:items-center">
<span class="p-input-icon-left w-full sm:w-[360px]">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-search" />
<InputText
v-model="filters.search"
class="w-full"
placeholder="Buscar por nome, e-mail ou telefone…"
@input="onFilterChangedDebounced"
/>
</IconField>
</FloatLabel>
</span>
<!-- Linha 2: KPI filtros (oculta no mobile) -->
<div class="pat-hero__row2">
<div class="flex flex-wrap items-center gap-2">
<Button
type="button"
size="small"
class="!rounded-full"
:outlined="filters.status !== 'Todos'"
severity="secondary"
@click="setStatus('Todos')"
>
<span class="flex items-center gap-1.5">
<i class="pi pi-users text-xs" />
Total: <b>{{ kpis.total }}</b>
</span>
</Button>
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="fetchAll"
/>
<Button
type="button"
size="small"
class="!rounded-full"
:outlined="filters.status !== 'Ativo'"
:severity="filters.status === 'Ativo' ? 'success' : 'secondary'"
@click="setStatus('Ativo')"
>
<span class="flex items-center gap-1.5">
<i class="pi pi-user-plus text-xs" />
Ativos: <b>{{ kpis.active }}</b>
</span>
</Button>
<SplitButton
label="Cadastrar"
icon="pi pi-user-plus"
:model="createMenu"
@click="goCreateFull"
/>
</div>
</div>
<Button
type="button"
size="small"
class="!rounded-full"
:outlined="filters.status !== 'Inativo'"
:severity="filters.status === 'Inativo' ? 'danger' : 'secondary'"
@click="setStatus('Inativo')"
>
<span class="flex items-center gap-1.5">
<i class="pi pi-user-minus text-xs" />
Inativos: <b>{{ kpis.inactive }}</b>
</span>
</Button>
<!-- chips de filtros ativos (micro-UX) -->
<div v-if="hasActiveFilters" class="relative mt-4 flex flex-wrap items-center gap-2">
<span class="text-xs text-color-secondary">Filtros:</span>
<span class="inline-flex items-center gap-1.5 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1.5 text-xs text-color-secondary">
<i class="pi pi-calendar" />
Último atend.: <b class="text-[var(--text-color)]">{{ prettyDate(kpis.latestLastAttended) }}</b>
</span>
</div>
</div>
</div>
<Tag v-if="filters.status && filters.status !== 'Todos'" :value="`Status: ${filters.status}`" severity="secondary" />
<Tag v-if="filters.groupId" value="Grupo selecionado" severity="secondary" />
<Tag v-if="filters.tagId" value="Tag selecionada" severity="secondary" />
<Tag v-if="filters.createdFrom" value="Data inicial" severity="secondary" />
<Tag v-if="filters.createdTo" value="Data final" severity="secondary" />
<Button
label="Limpar"
icon="pi pi-filter-slash"
severity="danger"
outlined
size="small"
class="!rounded-full"
@click="clearAllFilters"
/>
</div>
</div>
</div>
<!-- Chips de filtros ativos (fora do hero) -->
<div v-if="hasActiveFilters" class="mx-3 md:mx-5 mb-3 flex flex-wrap items-center gap-2">
<span class="text-xs text-color-secondary">Filtros:</span>
<Tag v-if="filters.status && filters.status !== 'Todos'" :value="`Status: ${filters.status}`" severity="secondary" />
<Tag v-if="filters.groupId" value="Grupo selecionado" severity="secondary" />
<Tag v-if="filters.tagId" value="Tag selecionada" severity="secondary" />
<Tag v-if="filters.createdFrom" value="Data inicial" severity="secondary" />
<Tag v-if="filters.createdTo" value="Data final" severity="secondary" />
<Button label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined size="small" class="!rounded-full" @click="clearAllFilters" />
</div>
<!-- KPI Cards
@@ -209,7 +182,7 @@
</div> -->
<!-- TABS (placeholder para evoluir depois) -->
<Tabs value="pacientes" class="mt-3">
<Tabs value="pacientes" class="px-3 md:px-5 mb-5">
<TabList>
<Tab value="pacientes"><i class="pi pi-users mr-2" />Pacientes</Tab>
<Tab value="espera"><i class="pi pi-hourglass mr-2" />Lista de espera</Tab>
@@ -403,163 +376,170 @@
</Transition>
</div>
<!-- Table -->
<DataTable
:value="filteredRows"
dataKey="id"
:loading="loading"
paginator
:rows="15"
:rowsPerPageOptions="[10, 15, 25, 50]"
stripedRows
responsiveLayout="scroll"
scrollable
scrollHeight="flex"
sortMode="single"
:sortField="sort.field"
:sortOrder="sort.order"
@sort="onSort"
>
<template #empty>
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum paciente encontrado</div>
<div class="mt-1 text-sm text-color-secondary">
Tente limpar filtros ou mudar o termo de busca.
</div>
<div class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
<Button icon="pi pi-user-plus" label="Cadastrar paciente" @click="goCreateFull" />
</div>
</div>
</template>
<Column
:key="'col-paciente'"
field="nome_completo"
header="Paciente"
v-if="isColVisible('paciente')"
sortable
>
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar
v-if="data.avatar_url"
:image="data.avatar_url"
shape="square"
size="large"
/>
<Avatar
v-else
:label="initials(data.nome_completo)"
shape="square"
size="large"
/>
<div class="min-w-0">
<div class="font-medium truncate">{{ data.nome_completo }}</div>
<small class="text-color-secondary">ID: {{ shortId(data.id) }}</small>
<!-- Table desktop (md+) -->
<div class="hidden md:block">
<DataTable
:value="filteredRows"
dataKey="id"
:loading="loading"
paginator
:rows="15"
:rowsPerPageOptions="[10, 15, 25, 50]"
stripedRows
scrollable
scrollHeight="flex"
sortMode="single"
:sortField="sort.field"
:sortOrder="sort.order"
@sort="onSort"
>
<template #empty>
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum paciente encontrado</div>
<div class="mt-1 text-sm text-color-secondary">Tente limpar filtros ou mudar o termo de busca.</div>
<div class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
<Button icon="pi pi-user-plus" label="Cadastrar paciente" @click="goCreateFull" />
</div>
</div>
</template>
</Column>
<Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;">
<template #body="{ data }">
<Tag
:value="data.status"
:severity="data.status === 'Ativo' ? 'success' : 'danger'"
/>
</template>
</Column>
<Column :key="'col-paciente'" field="nome_completo" header="Paciente" v-if="isColVisible('paciente')" sortable>
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="square" size="large" />
<Avatar v-else :label="initials(data.nome_completo)" shape="square" size="large" />
<div class="min-w-0">
<div class="font-medium truncate">{{ data.nome_completo }}</div>
<small class="text-color-secondary">ID: {{ shortId(data.id) }}</small>
</div>
</div>
</template>
</Column>
<Column header="Telefone" style="width: 16rem;" v-if="isColVisible('telefone')" :key="'col-telefone'">
<template #body="{ data }">
<div class="text-sm leading-tight">
<div class="font-medium">
{{ fmtPhoneBR(data.telefone) }}
</div>
<div class="text-xs text-color-secondary">
{{ data.email_principal || '—' }}
</div>
</div>
</template>
</Column>
<Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;">
<template #body="{ data }">
<Tag :value="data.status" :severity="data.status === 'Ativo' ? 'success' : 'danger'" />
</template>
</Column>
<Column field="email_principal" header="Email" style="width: 18rem;" v-if="isColVisible('email')" :key="'col-email'">
<template #body="{ data }">
<span class="text-color-secondary">{{ data.email_principal || '—' }}</span>
</template>
</Column>
<Column header="Telefone" style="width: 16rem;" v-if="isColVisible('telefone')" :key="'col-telefone'">
<template #body="{ data }">
<div class="text-sm leading-tight">
<div class="font-medium">{{ fmtPhoneBR(data.telefone) }}</div>
<div class="text-xs text-color-secondary">{{ data.email_principal || '—' }}</div>
</div>
</template>
</Column>
<Column field="last_attended_at" header="Último Atendimento" v-if="isColVisible('last_attended_at')" :key="'col-last_attended_at'" sortable style="width: 12rem;">
<template #body="{ data }">{{ data.last_attended_at || '—' }}</template>
</Column>
<Column field="email_principal" header="Email" style="width: 18rem;" v-if="isColVisible('email')" :key="'col-email'">
<template #body="{ data }">
<span class="text-color-secondary">{{ data.email_principal || '—' }}</span>
</template>
</Column>
<Column field="created_at" header="Cadastrado em" v-if="isColVisible('created_at')" :key="'col-created_at'" sortable style="width: 12rem;">
<template #body="{ data }">{{ data.created_at || '—' }}</template>
</Column>
<Column field="last_attended_at" header="Último Atendimento" v-if="isColVisible('last_attended_at')" :key="'col-last_attended_at'" sortable style="width: 12rem;">
<template #body="{ data }">{{ data.last_attended_at || '—' }}</template>
</Column>
<Column header="Grupos" style="min-width: 14rem;" v-if="isColVisible('grupos')" :key="'col-grupos'">
<template #body="{ data }">
<div v-if="!(data.groups || []).length" class="text-color-secondary"></div>
<div v-else class="flex flex-wrap gap-2">
<Tag
v-for="g in data.groups"
:key="g.id"
:value="g.name"
:style="chipStyle(g.color)"
/>
<Column field="created_at" header="Cadastrado em" v-if="isColVisible('created_at')" :key="'col-created_at'" sortable style="width: 12rem;">
<template #body="{ data }">{{ data.created_at || '—' }}</template>
</Column>
<Column header="Grupos" style="min-width: 14rem;" v-if="isColVisible('grupos')" :key="'col-grupos'">
<template #body="{ data }">
<div v-if="!(data.groups || []).length" class="text-color-secondary"></div>
<div v-else class="flex flex-wrap gap-2">
<Tag v-for="g in data.groups" :key="g.id" :value="g.name" :style="chipStyle(g.color)" />
</div>
</template>
</Column>
<Column header="Tags" style="min-width: 14rem;" v-if="isColVisible('tags')" :key="'col-tags'">
<template #body="{ data }">
<div v-if="!(data.tags || []).length" class="text-color-secondary"></div>
<div v-else class="flex flex-wrap gap-2">
<Tag v-for="t in data.tags" :key="t.id" :value="t.name" :style="chipStyle(t.color)" />
</div>
</template>
</Column>
<Column :key="'col-acoes'" header="Ações" style="width: 16rem;" frozen alignFrozen="right">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
</div>
</template>
</Column>
</DataTable>
</div>
<!-- Cards mobile (<md) -->
<div class="md:hidden">
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner />
</div>
<div v-else-if="filteredRows.length === 0" class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum paciente encontrado</div>
<div class="mt-1 text-sm text-color-secondary">Tente limpar filtros ou mudar o termo de busca.</div>
<div class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
<Button icon="pi pi-user-plus" label="Cadastrar" @click="goCreateFull" />
</div>
</div>
<div v-else class="flex flex-col gap-3 pb-4">
<div
v-for="pat in filteredRows"
:key="pat.id"
class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
>
<!-- Topo: avatar + nome + status -->
<div class="flex items-center gap-3">
<Avatar v-if="pat.avatar_url" :image="pat.avatar_url" shape="square" size="large" />
<Avatar v-else :label="initials(pat.nome_completo)" shape="square" size="large" />
<div class="flex-1 min-w-0">
<div class="font-semibold truncate">{{ pat.nome_completo }}</div>
<div class="text-xs text-color-secondary">{{ fmtPhoneBR(pat.telefone) }} · {{ pat.email_principal || '—' }}</div>
</div>
<Tag :value="pat.status" :severity="pat.status === 'Ativo' ? 'success' : 'danger'" />
</div>
</template>
</Column>
<Column header="Tags" style="min-width: 14rem;" v-if="isColVisible('tags')" :key="'col-tags'">
<template #body="{ data }">
<div v-if="!(data.tags || []).length" class="text-color-secondary"></div>
<div v-else class="flex flex-wrap gap-2">
<Tag
v-for="t in data.tags"
:key="t.id"
:value="t.name"
:style="chipStyle(t.color)"
/>
<!-- Grupos + Tags -->
<div v-if="(pat.groups || []).length || (pat.tags || []).length" class="mt-3 flex flex-wrap gap-1.5">
<Tag v-for="g in pat.groups" :key="g.id" :value="g.name" :style="chipStyle(g.color)" />
<Tag v-for="t in pat.tags" :key="t.id" :value="t.name" :style="chipStyle(t.color)" />
</div>
</template>
</Column>
<Column
:key="'col-acoes'"
header="Ações"
style="width: 16rem;"
frozen
alignFrozen="right"
>
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
</div>
</template>
</Column>
</DataTable>
<!-- Ações -->
<div class="mt-3 flex gap-2 justify-end">
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(pat)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="goEdit(pat)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" @click="confirmDeleteOne(pat)" />
</div>
</div>
</div>
</div>
<div class="mt-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between text-xs text-color-secondary">
<div>
Exibindo <b class="text-[var(--text-color)]">{{ filteredRows.length }}</b> de
<b class="text-[var(--text-color)]">{{ patients.length }}</b> pacientes.
<span v-if="hasActiveFilters"> (filtrado)</span>
</div>
<div class="hidden md:block">
Dica: clique em Ativos/Inativos no topo para filtrar rápido.
</div>
</div>
<div>
Exibindo <b class="text-[var(--text-color)]">{{ filteredRows.length }}</b> de
<b class="text-[var(--text-color)]">{{ patients.length }}</b> pacientes.
<span v-if="hasActiveFilters"> (filtrado)</span>
</div>
<div class="hidden md:block">
Dica: clique em Ativos/Inativos" no topo para filtrar rápido.
</div>
</div>
</TabPanel>
@@ -604,18 +584,19 @@
@close="closeProntuario"
/>
<ConfirmDialog />
</div>
<ConfirmDialog />
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client'
import MultiSelect from 'primevue/multiselect'
import Popover from 'primevue/popover'
import Menu from 'primevue/menu'
import ProgressSpinner from 'primevue/progressspinner'
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
@@ -642,6 +623,22 @@ function getAreaBase() {
const toast = useToast()
const confirm = useConfirm()
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
// ── Mobile menu ───────────────────────────────────────────
const patMobileMenuRef = ref(null)
const patMobileMenuItems = [
{ label: 'Cadastro Rápido', icon: 'pi pi-bolt', command: () => openQuickCreate() },
{ label: 'Cadastro Completo', icon: 'pi pi-file-edit', command: () => goCreateFull() },
{ separator: true },
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchAll() }
]
const uid = ref(null)
const loading = ref(false)
@@ -681,7 +678,7 @@ const lockedKeys = computed(() =>
columnCatalogAll.filter(c => c.locked).map(c => c.key)
)
// SEM mutar selectedColumns: apenas “projeta” as visíveis
// SEM mutar selectedColumns: apenas “projeta" as visíveis
const visibleKeys = computed(() => {
const set = new Set(selectedColumns.value || [])
lockedKeys.value.forEach(k => set.add(k))
@@ -751,10 +748,18 @@ const createMenu = [
]
onMounted(async () => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
await loadUser()
await fetchAll()
})
onBeforeUnmount(() => { _observer?.disconnect() })
function fmtPhoneBR(v) {
const d = String(v ?? '').replace(/\D/g, '')
if (!d) return '—'
@@ -815,37 +820,62 @@ function onQuickCreated(row) {
// -----------------------------
// Navigation (shared feature)
// -----------------------------
function goGroups() {
router.push(`${getAreaBase()}/patients/grupos`)
function getAreaKey () {
const seg = String(route.path || '').split('/')[1]
return seg === 'therapist' ? 'therapist' : 'admin'
}
function goCreateFull() {
router.push(`${getAreaBase()}/patients/cadastro`)
function getPatientsRoutes () {
const area = getAreaKey()
if (area === 'therapist') {
return {
groupsPath: '/therapist/patients/grupos',
createPath: '/therapist/patients/cadastro',
editPath: (id) => `/therapist/patients/cadastro/${id}`,
// se existir no seu router
createName: 'therapist-patients-cadastro',
editName: 'therapist-patients-cadastro-edit',
groupsName: 'therapist-patients-grupos'
}
}
// ✅ admin usa "pacientes" (PT-BR)
return {
groupsPath: '/admin/pacientes/grupos',
createPath: '/admin/pacientes/cadastro',
editPath: (id) => `/admin/pacientes/cadastro/${id}`,
// se existir no seu router (pelo que você mostrou antes, existe)
createName: 'admin-pacientes-cadastro',
editName: 'admin-pacientes-cadastro-edit',
groupsName: 'admin-pacientes-grupos'
}
}
function goEdit(row) {
function safePush (toObj, fallbackPath) {
try {
const r = router.resolve(toObj)
if (r?.matched?.length) return router.push(toObj)
} catch (_) {}
return router.push(fallbackPath)
}
function goGroups () {
const r = getPatientsRoutes()
return safePush({ name: r.groupsName }, r.groupsPath)
}
function goCreateFull () {
const r = getPatientsRoutes()
return safePush({ name: r.createName }, r.createPath)
}
function goEdit (row) {
if (!row?.id) return
router.push(`${getAreaBase()}/patients/cadastro/${row.id}`)
}
function setStatus(v) {
filters.status = v
onFilterChanged()
}
function clearAllFilters() {
filters.status = 'Todos'
filters.search = ''
filters.groupId = null
filters.tagId = null
filters.createdFrom = null
filters.createdTo = null
onFilterChanged()
}
function onSort(e) {
sort.field = e.sortField
sort.order = e.sortOrder
const r = getPatientsRoutes()
return safePush({ name: r.editName, params: { id: row.id } }, r.editPath(row.id))
}
// -----------------------------
@@ -1188,7 +1218,7 @@ function confirmDeleteOne(row) {
const nome = row?.nome_completo || 'este paciente'
confirm.require({
header: 'Excluir paciente',
message: `Tem certeza que deseja excluir “${nome}?`,
message: `Tem certeza que deseja excluir “${nome}"?`,
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
@@ -1237,17 +1267,65 @@ function updateKpis() {
</script>
<style scoped>
.kpi-card :deep(.p-card-body) {
padding: 1rem;
/* ── Hero Header ─────────────────────────────────── */
.pat-sentinel { height: 1px; }
.pat-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.pat-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
/* Blobs */
.pat-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.pat-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.pat-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
.pat-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
.pat-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 30%; background: rgba(236,72,153,0.07); }
/* Linha 1 */
.pat-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.pat-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.pat-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.pat-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.pat-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
/* Linha 2 (oculta no mobile) */
.pat-hero__row2 {
position: relative; z-index: 1;
display: flex; flex-wrap: wrap; align-items: center;
justify-content: space-between; gap: 0.75rem;
}
@media (max-width: 767px) {
.pat-hero__divider,
.pat-hero__row2 { display: none; }
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* KPI card */
.kpi-card :deep(.p-card-body) { padding: 1rem; }
/* Fade */
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@@ -1,14 +1,16 @@
<script setup>
import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoleGuard } from '@/composables/useRoleGuard'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
const { canSee } = useRoleGuard()
const route = useRoute()
const router = useRouter()
const toast = useToast()
@@ -16,8 +18,17 @@ const confirm = useConfirm()
const tenantStore = useTenantStore()
/**
* ✅ NOTAS IMPORTANTES DO AJUSTE
* - Corrige o 404 (admin usa /pacientes..., therapist usa /patients...).
* - Depois de criar (insert), faz upload do avatar usando o ID recém-criado.
* - Se bucket não for público, troca para signed URL automaticamente (fallback).
*/
// ------------------------------------------------------
// Tenant helpers
// ------------------------------------------------------
async function getCurrentTenantId () {
// ajuste para o nome real no seu store
return tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
}
@@ -105,6 +116,48 @@ onBeforeUnmount(() => {
const patientId = computed(() => String(route.params?.id || '').trim() || null)
const isEdit = computed(() => !!patientId.value)
// ------------------------------------------------------
// ✅ FIX 404: base por área + rotas reais (admin: /pacientes | therapist: /patients)
// ------------------------------------------------------
function getAreaKey () {
const seg = String(route.path || '').split('/').filter(Boolean)[0] || 'admin'
return seg === 'therapist' ? 'therapist' : 'admin'
}
function getPatientsRoutes () {
const area = getAreaKey()
if (area === 'therapist') {
return {
listName: 'therapist-patients',
editName: 'therapist-patients-edit',
listPath: '/therapist/patients',
editPath: (id) => `/therapist/patients/cadastro/${id}`
}
}
return {
listName: 'admin-pacientes',
editName: 'admin-pacientes-cadastro-edit',
listPath: '/admin/pacientes',
editPath: (id) => `/admin/pacientes/cadastro/${id}`
}
}
async function safePush (toNameObj, fallbackPath) {
try {
const r = router.resolve(toNameObj)
if (r?.matched?.length) return router.push(toNameObj)
} catch (_) {}
return router.push(fallbackPath)
}
function goBack () {
const { listName, listPath } = getPatientsRoutes()
if (window.history.length > 1) router.back()
else safePush({ name: listName }, listPath)
}
// ------------------------------------------------------
// Avatar state (TEM que existir no setup)
// ------------------------------------------------------
@@ -112,7 +165,7 @@ const avatarFile = ref(null)
const avatarPreviewUrl = ref('')
const avatarUploading = ref(false)
const AVATAR_BUCKET = 'avatars' // confirme o nome do bucket no Supabase
const AVATAR_BUCKET = 'avatars'
function isImageFile (file) {
return !!file && typeof file.type === 'string' && file.type.startsWith('image/')
@@ -149,6 +202,24 @@ function onAvatarPicked (ev) {
toast.add({ severity: 'info', summary: 'Avatar', detail: 'Preview carregado. Clique em “Salvar” para enviar.', life: 2500 })
}
// ✅ Gera URL pública OU signed URL (se o bucket for privado)
async function getReadableAvatarUrl (path) {
// tenta público primeiro
try {
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
const publicUrl = pub?.publicUrl || null
if (publicUrl) return publicUrl
} catch (_) {}
// fallback: signed (bucket privado)
const { data, error } = await supabase.storage
.from(AVATAR_BUCKET)
.createSignedUrl(path, 60 * 60 * 24 * 7) // 7 dias
if (error) throw error
if (!data?.signedUrl) throw new Error('Não consegui gerar signed URL do avatar.')
return data.signedUrl
}
async function uploadAvatarToStorage ({ ownerId, patientId, file }) {
if (!ownerId) throw new Error('ownerId ausente.')
if (!patientId) throw new Error('patientId ausente.')
@@ -171,15 +242,12 @@ async function uploadAvatarToStorage ({ ownerId, patientId, file }) {
if (upErr) throw upErr
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
const publicUrl = pub?.publicUrl || null
if (!publicUrl) throw new Error('Não consegui gerar URL pública do avatar.')
return { publicUrl, path }
const readableUrl = await getReadableAvatarUrl(path)
return { publicUrl: readableUrl, path }
}
async function maybeUploadAvatar (ownerId, id) {
if (!avatarFile.value) return
if (!avatarFile.value) return null
avatarUploading.value = true
try {
@@ -189,45 +257,40 @@ async function maybeUploadAvatar (ownerId, id) {
file: avatarFile.value
})
// 1) atualiza UI IMEDIATAMENTE (não deixa “sumir”)
// UI
form.value.avatar_url = publicUrl
// 2) grava no banco
await updatePatient(id, { avatar_url: publicUrl })
// 3) limpa o arquivo selecionado
avatarFile.value = null
// 4) se o preview era blob, pode revogar
// MAS NÃO zere o avatarPreviewUrl se o template depende dele
// => aqui vamos só revogar e então setar para a própria URL pública.
revokePreview()
avatarPreviewUrl.value = publicUrl
// DB
await updatePatient(id, { avatar_url: publicUrl })
return publicUrl
} catch (e) {
toast.add({
severity: 'warn',
summary: 'Avatar',
detail: e?.message || 'Falha ao enviar avatar.',
life: 4000
life: 4500
})
return null
} finally {
avatarUploading.value = false
}
}
// ------------------------------------------------------
// Form state (PT-BR)
// Form state
// ------------------------------------------------------
function resetForm () {
return {
// Sessão 1 — pessoais
nome_completo: '',
telefone: '',
email_principal: '',
email_alternativo: '',
telefone_alternativo: '',
data_nascimento: '', // ✅ SEMPRE DD-MM-AAAA (hífen)
data_nascimento: '',
genero: '',
estado_civil: '',
cpf: '',
@@ -237,7 +300,6 @@ function resetForm () {
onde_nos_conheceu: '',
encaminhado_por: '',
// Sessão 2 — endereço
cep: '',
pais: 'Brasil',
cidade: '',
@@ -247,24 +309,19 @@ function resetForm () {
bairro: '',
complemento: '',
// Sessão 3 — adicionais
escolaridade: '',
profissao: '',
nome_parente: '',
grau_parentesco: '',
telefone_parente: '',
// Sessão 4 — responsável
nome_responsavel: '',
cpf_responsavel: '',
telefone_responsavel: '',
observacao_responsavel: '',
cobranca_no_responsavel: false,
// Sessão 5 — internos
notas_internas: '',
// Avatar
avatar_url: ''
}
}
@@ -331,7 +388,6 @@ function toISODateFromDDMMYYYY (s) {
return `${yyyy}-${mm}-${dd}`
}
// banco (YYYY-MM-DD ou ISO) -> form (DD-MM-YYYY)
function isoToDDMMYYYY (value) {
if (!value) return ''
const s = String(value).trim()
@@ -407,7 +463,6 @@ function mapDbToForm (p) {
cobranca_no_responsavel: !!p.cobranca_no_responsavel,
notas_internas: p.notas_internas ?? '',
avatar_url: p.avatar_url ?? ''
}
}
@@ -430,8 +485,6 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
'owner_id',
'tenant_id',
'responsible_member_id',
// Sessão 1
'nome_completo',
'telefone',
'email_principal',
@@ -446,8 +499,6 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
'observacoes',
'onde_nos_conheceu',
'encaminhado_por',
// Sessão 2
'pais',
'cep',
'cidade',
@@ -456,25 +507,17 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
'numero',
'bairro',
'complemento',
// Sessão 3
'escolaridade',
'profissao',
'nome_parente',
'grau_parentesco',
'telefone_parente',
// Sessão 4
'nome_responsavel',
'cpf_responsavel',
'telefone_responsavel',
'observacao_responsavel',
'cobranca_no_responsavel',
// Sessão 5
'notas_internas',
// Avatar
'avatar_url'
])
@@ -523,11 +566,10 @@ function sanitizePayload (raw, ownerId) {
cobranca_no_responsavel: !!raw.cobranca_no_responsavel,
notas_internas: raw.notas_internas || null,
avatar_url: raw.avatar_url || null
}
// strings vazias -> null
// strings vazias -> null e trim
Object.keys(payload).forEach(k => {
if (payload[k] === '') payload[k] = null
if (typeof payload[k] === 'string') {
@@ -536,23 +578,19 @@ function sanitizePayload (raw, ownerId) {
}
})
// docs: só dígitos
payload.cpf = payload.cpf ? digitsOnly(payload.cpf) : null
payload.rg = payload.rg ? digitsOnly(payload.rg) : null
payload.cpf_responsavel = payload.cpf_responsavel ? digitsOnly(payload.cpf_responsavel) : null
// fones: só dígitos
payload.telefone = payload.telefone ? digitsOnly(payload.telefone) : null
payload.telefone_alternativo = payload.telefone_alternativo ? digitsOnly(payload.telefone_alternativo) : null
payload.telefone_parente = payload.telefone_parente ? digitsOnly(payload.telefone_parente) : null
payload.telefone_responsavel = payload.telefone_responsavel ? digitsOnly(payload.telefone_responsavel) : null
// ✅ FIX CRÍTICO: DD-MM-YYYY -> YYYY-MM-DD
payload.data_nascimento = payload.data_nascimento
? (toISODateFromDDMMYYYY(payload.data_nascimento) || null)
: null
// filtra
const filtrado = {}
Object.keys(payload).forEach(k => {
if (PACIENTES_COLUNAS_PERMITIDAS.has(k)) filtrado[k] = payload[k]
@@ -565,11 +603,7 @@ function sanitizePayload (raw, ownerId) {
// Supabase: lists / get / relations
// ------------------------------------------------------
async function listGroups () {
const probe = await supabase
.from('patient_groups')
.select('*')
.limit(1)
const probe = await supabase.from('patient_groups').select('*').limit(1)
if (probe.error) throw probe.error
const row = probe.data?.[0] || {}
@@ -582,13 +616,8 @@ async function listGroups () {
.select('id,nome,descricao,cor,is_system,is_active')
.eq('is_active', true)
.order('nome', { ascending: true })
if (error) throw error
return (data || []).map(g => ({
...g,
name: g.nome,
color: g.cor
}))
return (data || []).map(g => ({ ...g, name: g.nome, color: g.cor }))
}
if (hasEN) {
@@ -597,93 +626,42 @@ async function listGroups () {
.select('id,name,description,color,is_system,is_active')
.eq('is_active', true)
.order('name', { ascending: true })
if (error) throw error
return (data || []).map(g => ({
...g,
nome: g.name,
cor: g.color
}))
return (data || []).map(g => ({ ...g, nome: g.name, cor: g.color }))
}
const { data, error } = await supabase
.from('patient_groups')
.select('*')
.order('id', { ascending: true })
const { data, error } = await supabase.from('patient_groups').select('*').order('id', { ascending: true })
if (error) throw error
return data || []
}
async function listTags () {
// 1) Pega 1 registro sem order, só pra descobrir o schema real (sem 400)
const probe = await supabase
.from('patient_tags')
.select('*')
.limit(1)
const probe = await supabase.from('patient_tags').select('*').limit(1)
if (probe.error) throw probe.error
const row = probe.data?.[0] || {}
const hasEN = ('name' in row) || ('color' in row)
const hasPT = ('nome' in row) || ('cor' in row)
// 2) Se não tem nada, a tabela pode estar vazia.
// Ainda assim, precisamos decidir por qual coluna ordenar.
// Vamos descobrir colunas existentes via select de 0 rows (head) NÃO é suportado bem no client,
// então usamos uma estratégia safe:
// - tenta EN com order se faz sentido
// - senão PT
// - e por último sem order.
if (hasEN) {
const { data, error } = await supabase
.from('patient_tags')
.select('id,name,color')
.order('name', { ascending: true })
const { data, error } = await supabase.from('patient_tags').select('id,name,color').order('name', { ascending: true })
if (error) throw error
return data || []
}
if (hasPT) {
const { data, error } = await supabase
.from('patient_tags')
.select('id,nome,cor')
.order('nome', { ascending: true })
const { data, error } = await supabase.from('patient_tags').select('id,nome,cor').order('nome', { ascending: true })
if (error) throw error
return (data || []).map(t => ({
...t,
name: t.nome,
color: t.cor
}))
return (data || []).map(t => ({ ...t, name: t.nome, color: t.cor }))
}
// 3) fallback final: tabela vazia ou schema incomum
const { data, error } = await supabase
.from('patient_tags')
.select('*')
.order('id', { ascending: true })
const { data, error } = await supabase.from('patient_tags').select('*').order('id', { ascending: true })
if (error) throw error
return (data || []).map(t => ({
...t,
name: t.name ?? t.nome ?? '',
color: t.color ?? t.cor ?? null
}))
return (data || []).map(t => ({ ...t, name: t.name ?? t.nome ?? '', color: t.color ?? t.cor ?? null }))
}
async function getPatientById (id) {
const { data, error } = await supabase
.from('patients')
.select('*')
.eq('id', id)
.single()
const { data, error } = await supabase.from('patients').select('*').eq('id', id).single()
if (error) throw error
return data
}
@@ -708,11 +686,7 @@ async function getPatientRelations (id) {
}
async function createPatient (payload) {
const { data, error } = await supabase
.from('patients')
.insert(payload)
.select('id')
.single()
const { data, error } = await supabase.from('patients').insert(payload).select('id').single()
if (error) throw error
return data
}
@@ -720,17 +694,14 @@ async function createPatient (payload) {
async function updatePatient (id, payload) {
const { error } = await supabase
.from('patients')
.update({
...payload,
updated_at: new Date().toISOString()
})
.update({ ...payload, updated_at: new Date().toISOString() })
.eq('id', id)
if (error) throw error
}
// ------------------------------------------------------
// Relations
// Relations state
// ------------------------------------------------------
const groups = ref([])
const tags = ref([])
@@ -738,17 +709,11 @@ const grupoIdSelecionado = ref(null)
const tagIdsSelecionadas = ref([])
async function replacePatientGroups (patient_id, groupId) {
const { error: delErr } = await supabase
.from('patient_group_patient')
.delete()
.eq('patient_id', patient_id)
const { error: delErr } = await supabase.from('patient_group_patient').delete().eq('patient_id', patient_id)
if (delErr) throw delErr
if (!groupId) return
const { error: insErr } = await supabase
.from('patient_group_patient')
.insert({ patient_id, patient_group_id: groupId })
const { tenantId } = await resolveTenantContextOrFail()
const { error: insErr } = await supabase.from('patient_group_patient').insert({ patient_id, patient_group_id: groupId, tenant_id: tenantId })
if (insErr) throw insErr
}
@@ -765,15 +730,9 @@ async function replacePatientTags (patient_id, tagIds) {
const clean = Array.from(new Set([...(tagIds || [])].filter(Boolean)))
if (!clean.length) return
const rows = clean.map(tag_id => ({
owner_id: ownerId,
patient_id,
tag_id
}))
const { error: insErr } = await supabase
.from('patient_patient_tag')
.insert(rows)
const { tenantId } = await resolveTenantContextOrFail()
const rows = clean.map(tag_id => ({ owner_id: ownerId, patient_id, tag_id, tenant_id: tenantId }))
const { error: insErr } = await supabase.from('patient_patient_tag').insert(rows)
if (insErr) throw insErr
}
@@ -808,19 +767,6 @@ const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
// ------------------------------------------------------
// Route base (admin x therapist)
// ------------------------------------------------------
function getAreaBase () {
const seg = String(route.path || '').split('/')[1]
return seg === 'therapist' ? '/therapist' : '/admin'
}
function goBack () {
if (window.history.length > 1) router.back()
else router.push(`${getAreaBase()}/patients`)
}
// ------------------------------------------------------
// Fetch (load everything)
// ------------------------------------------------------
@@ -829,34 +775,32 @@ async function fetchAll () {
try {
const [gRes, tRes] = await Promise.allSettled([listGroups(), listTags()])
if (gRes.status === 'fulfilled') {
groups.value = gRes.value || []
} else {
if (gRes.status === 'fulfilled') groups.value = gRes.value || []
else {
groups.value = []
console.warn('[listGroups error]', gRes.reason)
toast.add({ severity: 'warn', summary: 'Grupos', detail: gRes.reason?.message || 'Falha ao carregar grupos', life: 3500 })
}
if (tRes.status === 'fulfilled') {
tags.value = tRes.value || []
} else {
if (tRes.status === 'fulfilled') tags.value = tRes.value || []
else {
tags.value = []
console.warn('[listTags error]', tRes.reason)
toast.add({ severity: 'warn', summary: 'Tags', detail: tRes.reason?.message || 'Falha ao carregar tags', life: 3500 })
}
console.log('[groups]', groups.value.length, groups.value[0])
console.log('[tags]', tags.value.length, tags.value[0])
if (isEdit.value) {
const p = await getPatientById(patientId.value)
form.value = mapDbToForm(p)
// se já tinha avatar no banco, garante preview
avatarPreviewUrl.value = form.value.avatar_url || ''
const rel = await getPatientRelations(patientId.value)
grupoIdSelecionado.value = rel.groupIds?.[0] || null
tagIdsSelecionadas.value = rel.tagIds || []
} else {
form.value = resetForm()
//form.value = resetForm()
grupoIdSelecionado.value = null
tagIdsSelecionadas.value = []
avatarFile.value = null
@@ -872,19 +816,33 @@ async function fetchAll () {
watch(() => route.params?.id, fetchAll, { immediate: true })
onMounted(fetchAll)
// ------------------------------------------------------
// Tenant resolve (robusto)
// ------------------------------------------------------
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.')
// 1) tenta pelo store
const storeTid = await getCurrentTenantId()
if (storeTid) {
try {
const mid = await getCurrentMemberId(storeTid)
return { tenantId: storeTid, memberId: mid }
} catch (_) {
// cai pro fallback (último membership active)
}
}
// 2) fallback
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
.order('created_at', { ascending: false })
.limit(1)
.single()
@@ -904,20 +862,69 @@ async function onSubmit () {
const ownerId = await getOwnerId()
const { tenantId, memberId } = await resolveTenantContextOrFail()
// depois do sanitize
const payload = sanitizePayload(form.value, ownerId)
// multi-tenant obrigatório
payload.tenant_id = tenantId
payload.responsible_member_id = memberId
// ✅ validações mínimas (NÃO DEIXA CHEGAR NO BANCO)
const nome = String(form.value?.nome_completo || '').trim()
if (!nome) {
toast.add({
severity: 'warn',
summary: 'Nome obrigatório',
detail: 'Preencha “Nome completo” para salvar o paciente.',
life: 3500
})
// abre o painel certo (você já tem navItems: "Informações pessoais" é o 0)
await openPanel(0)
return
}
// ---------------------------
// EDIT
// ---------------------------
if (isEdit.value) {
await updatePatient(patientId.value, payload)
// ✅ Se houver avatar selecionado, sobe e grava avatar_url
await maybeUploadAvatar(ownerId, patientId.value)
await replacePatientGroups(patientId.value, grupoIdSelecionado.value)
await replacePatientTags(patientId.value, tagIdsSelecionadas.value)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 })
} else {
const created = await createPatient(payload)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
// opcional: router.push(`${getAreaBase()}/patients/${created.id}`)
return
}
// ---------------------------
// CREATE
// ---------------------------
const created = await createPatient(payload)
// ✅ upload do avatar usando ID recém-criado
await maybeUploadAvatar(ownerId, created.id)
await replacePatientGroups(created.id, grupoIdSelecionado.value)
await replacePatientTags(created.id, tagIdsSelecionadas.value)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
// ✅ NÃO navega para /cadastro/:id (fica em /admin/pacientes/cadastro)
// Em vez disso, reseta o formulário para novo cadastro:
form.value = resetForm()
grupoIdSelecionado.value = null
tagIdsSelecionadas.value = []
avatarFile.value = null
revokePreview()
avatarPreviewUrl.value = ''
// volta pro primeiro painel (UX boa)
await openPanel(0)
return
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar paciente.', life: 4000 })
@@ -925,8 +932,6 @@ async function onSubmit () {
saving.value = false
}
}
// ------------------------------------------------------
// Delete
// ------------------------------------------------------
@@ -968,7 +973,7 @@ async function doDelete () {
}
// ------------------------------------------------------
// Fake fill (opcional)
// Fake fill (opcional) — mantive como você tinha
// ------------------------------------------------------
function randInt (min, max) { return Math.floor(Math.random() * (max - min + 1)) + min }
function pick (arr) { return arr[randInt(0, arr.length - 1)] }
@@ -1036,7 +1041,6 @@ function fillRandomPatient () {
form.value = {
...resetForm(),
nome_completo: nomeCompleto,
telefone: randomPhoneBR(),
email_principal: randomEmailFromName(nomeCompleto),
@@ -1076,16 +1080,10 @@ function fillRandomPatient () {
cobranca_no_responsavel: true,
notas_internas: 'Paciente apresenta discurso organizado. Acompanhar evolução clínica.',
avatar_url: ''
}
// Grupo
if (Array.isArray(groups.value) && groups.value.length) {
grupoIdSelecionado.value = pick(groups.value).id
}
// Tags
if (Array.isArray(groups.value) && groups.value.length) grupoIdSelecionado.value = pick(groups.value).id
if (Array.isArray(tags.value) && tags.value.length) {
const shuffled = [...tags.value].sort(() => Math.random() - 0.5)
tagIdsSelecionadas.value = shuffled.slice(0, randInt(1, Math.min(3, tags.value.length))).map(t => t.id)
@@ -1118,137 +1116,88 @@ const maritalStatusOptions = [
const createGroupDialog = ref(false)
const createGroupSaving = ref(false)
const createGroupError = ref('')
const newGroup = ref({ name: '', color: '#6366F1' }) // indigo default
const newGroup = ref({ name: '', color: '#6366F1' })
const createTagDialog = ref(false)
const createTagSaving = ref(false)
const createTagError = ref('')
const newTag = ref({ name: '', color: '#22C55E' }) // green default
const newTag = ref({ name: '', color: '#22C55E' })
function openGroupDlg(mode = 'create') {
// por enquanto só create
function openGroupDlg () {
createGroupError.value = ''
newGroup.value = { name: '', color: '#6366F1' }
createGroupDialog.value = true
}
function openTagDlg(mode = 'create') {
// por enquanto só create
function openTagDlg () {
createTagError.value = ''
newTag.value = { name: '', color: '#22C55E' }
createTagDialog.value = true
}
// ------------------------------------------------------
// Persist: Grupo
// ------------------------------------------------------
async function createGroupPersist() {
async function createGroupPersist () {
if (createGroupSaving.value) return
createGroupError.value = ''
const name = String(newGroup.value?.name || '').trim()
const color = String(newGroup.value?.color || '').trim() || '#6366F1'
if (!name) {
createGroupError.value = 'Informe um nome para o grupo.'
return
}
if (!name) { createGroupError.value = 'Informe um nome para o grupo.'; return }
createGroupSaving.value = true
try {
const ownerId = await getOwnerId()
// Tenta schema PT-BR primeiro (pelo teu listGroups)
const { tenantId } = await resolveTenantContextOrFail()
let createdId = null
{
const { data, error } = await supabase
.from('patient_groups')
.insert({
owner_id: ownerId,
nome: name,
descricao: null,
cor: color,
is_system: false,
is_active: true
})
.select('id')
.single()
if (!error) createdId = data?.id || null
else {
// fallback (caso seu schema seja EN)
const { data: d2, error: e2 } = await supabase
.from('patient_groups')
.insert({
owner_id: ownerId,
name,
description: null,
color,
is_system: false,
is_active: true
})
.select('id')
.single()
if (e2) throw e2
createdId = d2?.id || null
}
}
const { data, error } = await supabase
.from('patient_groups')
.insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, descricao: null, cor: color, is_system: false, is_active: true })
.select('id')
.single()
if (error) throw error
createdId = data?.id || null
// Recarrega lista e seleciona o novo
groups.value = await listGroups()
if (createdId) grupoIdSelecionado.value = createdId
toast.add({ severity: 'success', summary: 'Grupo', detail: 'Grupo criado.', life: 2500 })
createGroupDialog.value = false
} catch (e) {
createGroupError.value = e?.message || 'Falha ao criar grupo.'
const msg = e?.message || ''
if (e?.code === '23505' || /duplicate key value/i.test(msg)) {
createGroupError.value = 'Já existe um grupo com esse nome.'
} else {
createGroupError.value = msg || 'Falha ao criar grupo.'
}
} finally {
createGroupSaving.value = false
}
}
// ------------------------------------------------------
// Persist: Tag
// ------------------------------------------------------
async function createTagPersist() {
async function createTagPersist () {
if (createTagSaving.value) return
createTagError.value = ''
const name = String(newTag.value?.name || '').trim()
const color = String(newTag.value?.color || '').trim() || '#22C55E'
if (!name) {
createTagError.value = 'Informe um nome para a tag.'
return
}
if (!name) { createTagError.value = 'Informe um nome para a tag.'; return }
createTagSaving.value = true
try {
const ownerId = await getOwnerId()
// Tenta schema EN primeiro (pelo teu listTags)
const { tenantId } = await resolveTenantContextOrFail()
let createdId = null
{
const { data, error } = await supabase
.from('patient_tags')
.insert({ owner_id: ownerId, name, color })
.select('id')
.single()
if (!error) createdId = data?.id || null
else {
// fallback PT-BR
const { data: d2, error: e2 } = await supabase
.from('patient_tags')
.insert({ owner_id: ownerId, nome: name, cor: color })
.select('id')
.single()
if (e2) throw e2
createdId = d2?.id || null
}
}
const { data, error } = await supabase
.from('patient_tags')
.insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, cor: color })
.select('id')
.single()
if (error) throw error
createdId = data?.id || null
// Recarrega lista e já marca a nova na seleção
tags.value = await listTags()
if (createdId) {
const set = new Set([...(tagIdsSelecionadas.value || []), createdId])
@@ -1258,7 +1207,12 @@ async function createTagPersist() {
toast.add({ severity: 'success', summary: 'Tag', detail: 'Tag criada.', life: 2500 })
createTagDialog.value = false
} catch (e) {
createTagError.value = e?.message || 'Falha ao criar tag.'
const msg = e?.message || ''
if (e?.code === '23505' || /duplicate key value/i.test(msg)) {
createTagError.value = 'Já existe uma tag com esse nome.'
} else {
createTagError.value = msg || 'Falha ao criar tag.'
}
} finally {
createTagSaving.value = false
}
@@ -1285,11 +1239,12 @@ async function createTagPersist() {
<div class="flex flex-wrap gap-2">
<Button
v-if="canSee('testMODE')"
label="Preencher tudo"
icon="pi pi-bolt"
severity="secondary"
outlined
@click="fillRandomPatient"
@click="fillRandomPatient"
/>
<Button
label="Voltar"
@@ -1931,6 +1886,7 @@ async function createTagPersist() {
<Dialog
v-model:visible="createGroupDialog"
modal
:draggable="false"
header="Criar grupo"
:style="{ width: '26rem' }"
:closable="!createGroupSaving"
@@ -1965,6 +1921,7 @@ async function createTagPersist() {
<Dialog
v-model:visible="createTagDialog"
modal
:draggable="false"
header="Criar tag"
:style="{ width: '26rem' }"
:closable="!createTagSaving"

View File

@@ -1,249 +1,240 @@
<template>
<div class="p-4">
<!-- Top header -->
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-2xl bg-slate-900 text-slate-50 grid place-items-center shadow-sm">
<i class="pi pi-link text-lg"></i>
</div>
<Toast />
<div class="min-w-0">
<div class="text-2xl font-semibold text-slate-900 leading-tight">
Cadastro Externo
</div>
<div class="text-slate-600 mt-1">
Gere um link para o paciente preencher o pré-cadastro com calma e segurança.
</div>
</div>
<!-- Sentinel -->
<div ref="headerSentinelRef" class="extlink-sentinel" />
<!-- Hero sticky -->
<div ref="headerEl" class="extlink-hero mx-3 md:mx-5 mb-4" :class="{ 'extlink-hero--stuck': headerStuck }">
<div class="extlink-hero__blobs" aria-hidden="true">
<div class="extlink-hero__blob extlink-hero__blob--1" />
<div class="extlink-hero__blob extlink-hero__blob--2" />
</div>
<!-- Row 1 -->
<div class="extlink-hero__row1">
<div class="extlink-hero__brand">
<div class="extlink-hero__icon"><i class="pi pi-link text-lg" /></div>
<div class="min-w-0">
<div class="extlink-hero__title">Link de Cadastro</div>
<div class="extlink-hero__sub">Compartilhe com o paciente para preencher o pré-cadastro com calma e segurança</div>
</div>
</div>
<div class="flex flex-wrap gap-2 justify-start md:justify-end">
<!-- Desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<span
class="inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border transition-colors"
:class="inviteToken
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
>
<span
class="h-2 w-2 rounded-full"
:class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'"
/>
{{ inviteToken ? 'Link ativo' : 'Gerando…' }}
</span>
<Button
label="Gerar novo link"
icon="pi pi-refresh"
severity="secondary"
outlined
class="rounded-full"
:loading="rotating"
@click="rotateLink"
/>
</div>
<!-- Mobile (<1200px) -->
<div class="flex xl:hidden items-center shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
<!-- Main grid -->
<div class="mt-5 grid grid-cols-1 lg:grid-cols-12 gap-4">
<!-- Left: Link card -->
<div class="lg:col-span-7">
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<!-- Card head -->
<div class="p-5 border-b border-slate-200">
<div class="flex items-start justify-between gap-3">
<!-- Divider -->
<Divider class="extlink-hero__divider my-2" />
<!-- Row 2: link rápido (oculto no mobile) -->
<div class="extlink-hero__row2">
<div v-if="!inviteToken" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner text-xs" /> Gerando link
</div>
<InputGroup v-else class="max-w-2xl">
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
<InputText readonly :value="publicUrl" class="font-mono text-xs" />
<Button icon="pi pi-copy" severity="secondary" title="Copiar link" @click="copyLink" />
<Button icon="pi pi-external-link" severity="secondary" title="Abrir no navegador" @click="openLink" />
</InputGroup>
</div>
</div>
<!-- Conteúdo -->
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 mb-5">
<!-- Esquerda: ações do link -->
<div class="flex-1 min-w-0 flex flex-col gap-4">
<!-- Card principal: link -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="p-5 border-b border-[var(--surface-border)] flex items-center justify-between gap-3 flex-wrap">
<div>
<div class="font-semibold text-[var(--text-color)]">Seu link público</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Envie ao paciente por WhatsApp, e-mail ou mensagem direta</div>
</div>
<span
class="inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border"
:class="inviteToken
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
>
<span class="h-2 w-2 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
{{ inviteToken ? 'Ativo' : 'Gerando…' }}
</span>
</div>
<div class="p-5 space-y-4">
<!-- Skeleton -->
<div v-if="!inviteToken" class="space-y-3">
<div class="h-10 rounded-xl bg-[var(--surface-ground)] animate-pulse" />
<div class="h-10 rounded-xl bg-[var(--surface-ground)] animate-pulse" />
</div>
<div v-else class="space-y-4">
<!-- Link com ações -->
<InputGroup>
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
<InputText readonly :value="publicUrl" class="font-mono text-xs" />
<Button icon="pi pi-copy" severity="secondary" title="Copiar" @click="copyLink" />
<Button icon="pi pi-external-link" severity="secondary" title="Abrir" @click="openLink" />
</InputGroup>
<div class="text-xs text-[var(--text-color-secondary)]">
Token: <span class="font-mono select-all">{{ inviteToken }}</span>
</div>
<!-- CTAs rápidas -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button class="extlink-cta-btn" @click="copyLink">
<div class="extlink-cta-btn__icon bg-[color-mix(in_srgb,var(--p-primary-500,#6366f1)_12%,transparent)] text-[var(--p-primary-500,#6366f1)]">
<i class="pi pi-copy" />
</div>
<div class="text-left min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">Copiar link</div>
<div class="text-xs text-[var(--text-color-secondary)]">Cole no WhatsApp ou e-mail</div>
</div>
</button>
<button class="extlink-cta-btn" @click="copyInviteMessage">
<div class="extlink-cta-btn__icon bg-emerald-500/10 text-emerald-600">
<i class="pi pi-comment" />
</div>
<div class="text-left min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">Copiar mensagem pronta</div>
<div class="text-xs text-[var(--text-color-secondary)]">Texto formatado com o link incluso</div>
</div>
</button>
</div>
<!-- Aviso -->
<Message severity="warn" :closable="false">
<b>Dica:</b> ao gerar um novo link, o anterior é revogado. Use isso quando quiser invalidar um link compartilhado.
</Message>
</div>
</div>
</div>
<!-- Mensagem pronta -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-1">
<i class="pi pi-comment text-sm text-[var(--text-color-secondary)]" />
Mensagem pronta para envio
</div>
<div class="text-sm text-[var(--text-color-secondary)] mb-3">Copie e cole ao enviar o link ao paciente:</div>
<div class="rounded-xl bg-[var(--surface-ground)] border border-[var(--surface-border)] p-4 text-sm text-[var(--text-color)] leading-relaxed">
Olá! Segue o link para seu pré-cadastro. Preencha com calma campos opcionais podem ficar em branco:
<span class="block mt-2 font-mono text-xs break-all text-[var(--text-color-secondary)]">{{ publicUrl || '…aguardando link…' }}</span>
</div>
<div class="mt-3">
<Button
icon="pi pi-copy"
label="Copiar mensagem"
severity="secondary"
outlined
class="rounded-full"
:disabled="!publicUrl"
@click="copyInviteMessage"
/>
</div>
</div>
</div>
<!-- Direita: instruções -->
<div class="lg:w-80 shrink-0 flex flex-col gap-4">
<!-- Como funciona -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="p-5 border-b border-[var(--surface-border)]">
<div class="font-semibold text-[var(--text-color)]">Como funciona</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Simples e sem fricção para o paciente</div>
</div>
<div class="p-5">
<ol class="space-y-4">
<li class="flex gap-3">
<div class="extlink-step shrink-0">1</div>
<div class="min-w-0">
<div class="text-lg font-semibold text-slate-900">Seu link</div>
<div class="text-slate-600 text-sm mt-1">
Envie este link ao paciente. Ele abre a página de cadastro externo.
</div>
<div class="font-semibold text-sm text-[var(--text-color)]">Você envia o link</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Por WhatsApp, e-mail ou mensagem direta.</div>
</div>
<div class="hidden md:flex items-center gap-2">
<span
class="inline-flex items-center gap-2 text-xs px-2.5 py-1 rounded-full border"
:class="inviteToken ? 'border-emerald-200 text-emerald-700 bg-emerald-50' : 'border-slate-200 text-slate-600 bg-slate-50'"
>
<span
class="h-2 w-2 rounded-full"
:class="inviteToken ? 'bg-emerald-500' : 'bg-slate-400'"
></span>
{{ inviteToken ? 'Ativo' : 'Gerando...' }}
</span>
</li>
<li class="flex gap-3">
<div class="extlink-step shrink-0">2</div>
<div class="min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">O paciente preenche</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Campos opcionais podem ficar em branco. Menos fricção, mais adesão.</div>
</div>
</div>
</div>
<!-- Card content -->
<div class="p-5">
<!-- Skeleton while loading -->
<div v-if="!inviteToken" class="space-y-3">
<div class="h-10 rounded-xl bg-slate-100 animate-pulse"></div>
<div class="h-10 rounded-xl bg-slate-100 animate-pulse"></div>
<Message severity="info" :closable="false">
Gerando seu link...
</Message>
</div>
<div v-else class="space-y-4">
<!-- Link display + quick actions -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-slate-700">Link público</label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<div class="flex-1 min-w-0">
<InputText
readonly
:value="publicUrl"
class="w-full"
/>
<div class="mt-1 text-xs text-slate-500 break-words">
Token: <span class="font-mono">{{ inviteToken }}</span>
</div>
</div>
<div class="flex gap-2 sm:flex-col sm:w-[140px]">
<Button
class="w-full"
icon="pi pi-copy"
label="Copiar"
severity="secondary"
outlined
@click="copyLink"
/>
<Button
class="w-full"
icon="pi pi-external-link"
label="Abrir"
severity="secondary"
outlined
@click="openLink"
/>
</div>
</div>
</li>
<li class="flex gap-3">
<div class="extlink-step shrink-0">3</div>
<div class="min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">Você recebe e converte</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">O cadastro aparece em "Cadastros recebidos". Revise e converta em paciente quando quiser.</div>
</div>
<!-- Big CTA -->
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="min-w-0">
<div class="font-semibold text-slate-900">Envio rápido</div>
<div class="text-sm text-slate-600 mt-1">
Copie e mande por WhatsApp / e-mail. O paciente preenche e você recebe o cadastro no sistema.
</div>
</div>
<Button
icon="pi pi-copy"
label="Copiar link agora"
class="md:shrink-0"
@click="copyLink"
/>
</div>
</div>
<!-- Safety note -->
<Message severity="warn" :closable="false">
<b>Dica:</b> ao gerar um novo link, o anterior deve deixar de funcionar. Use isso quando você quiser revogar um link que foi compartilhado.
</Message>
</div>
</div>
</li>
</ol>
</div>
</div>
<!-- Right: Concept / Instructions -->
<div class="lg:col-span-5">
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div class="p-5 border-b border-slate-200">
<div class="text-lg font-semibold text-slate-900">Como funciona</div>
<div class="text-slate-600 text-sm mt-1">
Um fluxo simples, mas com cuidado clínico: menos fricção, mais adesão.
</div>
</div>
<div class="p-5">
<ol class="space-y-4">
<li class="flex gap-3">
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">1</div>
<div class="min-w-0">
<div class="font-semibold text-slate-900">Você envia o link</div>
<div class="text-sm text-slate-600 mt-1">
Pode ser WhatsApp, e-mail ou mensagem direta. O link abre a página de cadastro externo.
</div>
</div>
</li>
<li class="flex gap-3">
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">2</div>
<div class="min-w-0">
<div class="font-semibold text-slate-900">O paciente preenche</div>
<div class="text-sm text-slate-600 mt-1">
Campos opcionais podem ser deixados em branco. A ideia é reduzir ansiedade e acelerar o início.
</div>
</div>
</li>
<li class="flex gap-3">
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">3</div>
<div class="min-w-0">
<div class="font-semibold text-slate-900">Você recebe no admin</div>
<div class="text-sm text-slate-600 mt-1">
Os dados entram como cadastro recebido. Você revisa, completa e transforma em paciente quando quiser.
</div>
</div>
</li>
</ol>
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div class="font-semibold text-slate-900 flex items-center gap-2">
<i class="pi pi-shield text-slate-700"></i>
Boas práticas
</div>
<ul class="mt-2 space-y-2 text-sm text-slate-700">
<li class="flex gap-2">
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
<span>Gere um novo link se você suspeitar que ele foi repassado indevidamente.</span>
</li>
<li class="flex gap-2">
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
<span>Envie junto uma mensagem curta: preencha com calma; campos opcionais podem ficar em branco.</span>
</li>
<li class="flex gap-2">
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
<span>Evite divulgar em público; é um link pensado para compartilhamento individual.</span>
</li>
</ul>
</div>
<div class="mt-4 text-xs text-slate-500">
Se você quiser, eu deixo este card ainda mais noir (contraste, microtextos, ícones, sombras) sem perder legibilidade.
</div>
</div>
</div>
<!-- Small helper card -->
<div class="mt-4 rounded-2xl border border-slate-200 bg-white shadow-sm p-5">
<div class="font-semibold text-slate-900">Mensagem pronta (copiar/colar)</div>
<div class="text-sm text-slate-600 mt-1">
Se quiser, use este texto ao enviar o link:
</div>
<div class="mt-3 rounded-xl bg-slate-50 border border-slate-200 p-3 text-sm text-slate-800">
Olá! Segue o link para seu pré-cadastro. Preencha com calma campos opcionais podem ficar em branco:
<span class="block mt-2 font-mono break-words">{{ publicUrl || '…' }}</span>
</div>
<div class="mt-3 flex gap-2">
<Button
icon="pi pi-copy"
label="Copiar mensagem"
severity="secondary"
outlined
:disabled="!publicUrl"
@click="copyInviteMessage"
/>
</div>
<!-- Boas práticas -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-3">
<i class="pi pi-shield text-sm text-[var(--text-color-secondary)]" />
Boas práticas
</div>
<ul class="space-y-2.5">
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
<span>Gere um novo link se suspeitar que ele foi repassado indevidamente.</span>
</li>
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
<span>Informe o paciente que campos opcionais podem ficar em branco.</span>
</li>
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
<span>Evite divulgar em público; é um link para compartilhamento individual.</span>
</li>
</ul>
</div>
</div>
<!-- Toast is global in layout usually; if not, add <Toast /> -->
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import Button from 'primevue/button'
import Card from 'primevue/card'
import InputText from 'primevue/inputtext'
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import Message from 'primevue/message'
import Menu from 'primevue/menu'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
@@ -252,12 +243,25 @@ const toast = useToast()
const inviteToken = ref('')
const rotating = ref(false)
/**
* Se o cadastro externo estiver em outro domínio, fixe aqui:
* ex.: const PUBLIC_BASE_URL = 'https://seusite.com'
* se vazio, usa window.location.origin
*/
const PUBLIC_BASE_URL = '' // opcional
// ── Hero sticky ────────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
// ── Mobile menu ────────────────────────────────────────────
const mobileMenuRef = ref(null)
const mobileMenuItems = computed(() => [
{ label: 'Copiar link', icon: 'pi pi-copy', command: () => copyLink(), disabled: !inviteToken.value },
{ label: 'Copiar mensagem', icon: 'pi pi-comment', command: () => copyInviteMessage(), disabled: !inviteToken.value },
{ label: 'Abrir no navegador', icon: 'pi pi-external-link', command: () => openLink(), disabled: !inviteToken.value },
{ separator: true },
{ label: 'Gerar novo link', icon: 'pi pi-refresh', command: () => rotateLink() }
])
// ── URL base ────────────────────────────────────────────────
const PUBLIC_BASE_URL = ''
const origin = computed(() => {
if (PUBLIC_BASE_URL) return PUBLIC_BASE_URL
@@ -269,12 +273,13 @@ const publicUrl = computed(() => {
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
})
function newToken () {
// ── Token helpers ───────────────────────────────────────────
function newToken() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36)
}
async function requireUserId () {
async function requireUserId() {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
@@ -282,7 +287,7 @@ async function requireUserId () {
return uid
}
async function loadOrCreateInvite () {
async function loadOrCreateInvite() {
const uid = await requireUserId()
const { data, error } = await supabase
@@ -310,16 +315,14 @@ async function loadOrCreateInvite () {
inviteToken.value = t
}
async function rotateLink () {
async function rotateLink() {
rotating.value = true
try {
const uid = await requireUserId()
const t = newToken()
// tenta RPC primeiro
const rpc = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
if (rpc.error) {
// fallback: desativa todos os ativos e cria um novo
const { error: e1 } = await supabase
.from('patient_invites')
.update({ active: false, updated_at: new Date().toISOString() })
@@ -334,7 +337,7 @@ async function rotateLink () {
}
inviteToken.value = t
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 })
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado. O anterior foi revogado.', life: 2500 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 })
} finally {
@@ -342,40 +345,138 @@ async function rotateLink () {
}
}
async function copyLink () {
async function copyLink() {
try {
if (!publicUrl.value) return
await navigator.clipboard.writeText(publicUrl.value)
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado.', life: 1500 })
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado para a área de transferência.', life: 1500 })
} catch {
// fallback clássico
window.prompt('Copie o link:', publicUrl.value)
}
}
function openLink () {
function openLink() {
if (!publicUrl.value) return
window.open(publicUrl.value, '_blank', 'noopener')
}
async function copyInviteMessage () {
async function copyInviteMessage() {
try {
if (!publicUrl.value) return
const msg =
`Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
${publicUrl.value}`
const msg = `Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:\n${publicUrl.value}`
await navigator.clipboard.writeText(msg)
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada.', life: 1500 })
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada para a área de transferência.', life: 1500 })
} catch {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
}
}
onMounted(async () => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
try {
await loadOrCreateInvite()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 })
}
})
onBeforeUnmount(() => { _observer?.disconnect() })
</script>
<style scoped>
/* ── Sentinel ─────────────────────────────────────── */
.extlink-sentinel { height: 1px; }
/* ── Hero ─────────────────────────────────────────── */
.extlink-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.extlink-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
/* Blobs decorativos */
.extlink-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.extlink-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.extlink-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
.extlink-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
/* Linha 1 */
.extlink-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.extlink-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.extlink-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.extlink-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.extlink-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
/* Linha 2 */
.extlink-hero__row2 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 0.75rem;
}
@media (max-width: 767px) {
.extlink-hero__divider,
.extlink-hero__row2 { display: none; }
}
/* ── CTA button ───────────────────────────────────── */
.extlink-cta-btn {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.875rem 1rem;
border-radius: 1rem;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
cursor: pointer;
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
text-align: left;
}
.extlink-cta-btn:hover {
background: var(--surface-hover);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
transform: translateY(-1px);
}
.extlink-cta-btn:active { transform: translateY(0); }
.extlink-cta-btn__icon {
display: grid; place-items: center;
width: 2.25rem; height: 2.25rem;
border-radius: 0.75rem; flex-shrink: 0;
font-size: 1rem;
}
/* ── Step numbers ─────────────────────────────────── */
.extlink-step {
display: grid; place-items: center;
width: 2rem; height: 2rem;
border-radius: 0.625rem;
font-size: 0.8rem; font-weight: 700;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
</style>

View File

@@ -1,24 +1,21 @@
<!-- src/views/pages/patients/PatientIntakeRequestsPage.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { useTenantStore } from '@/stores/tenantStore'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import Tag from 'primevue/tag'
import InputText from 'primevue/inputtext'
import ConfirmDialog from 'primevue/confirmdialog'
import ProgressSpinner from 'primevue/progressspinner'
import Textarea from 'primevue/textarea'
import Avatar from 'primevue/avatar'
import Menu from 'primevue/menu'
import { brToISO, isoToBR } from '@/utils/dateBR'
const toast = useToast()
const confirm = useConfirm()
const tenantStore = useTenantStore()
const converting = ref(false)
const loading = ref(false)
@@ -227,7 +224,7 @@ function fmtDate (iso) {
return d.toLocaleString('pt-BR')
}
// converte nascimento para ISO date (YYYY-MM-DD) usando teu utils
// converte nascimento para ISO date (YYYY-MM-DD)
function normalizeBirthToISO (v) {
if (!v) return null
const s = String(v).trim()
@@ -248,6 +245,34 @@ function normalizeBirthToISO (v) {
return `${yyyy}-${mm}-${dd}`
}
// -----------------------------
// Tenant + Responsible Member (para satisfazer trigger)
// -----------------------------
async function getTenantIdForConversion (item) {
// intake NÃO tem tenant_id hoje, então usamos o contexto
const fromStore =
tenantStore?.activeTenantId ||
tenantStore?.currentTenantId ||
tenantStore?.tenantId ||
tenantStore?.tenant?.id
return fromStore || null
}
async function getResponsibleMemberId (tenantId, userId) {
const { data, error } = await supabase
.from('tenant_members')
.select('id')
.eq('tenant_id', tenantId)
.eq('user_id', userId)
.eq('status', 'active')
.maybeSingle()
if (error) throw error
if (!data?.id) throw new Error('Responsible member not found')
return data.id
}
// -----------------------------
// Seções do modal
// -----------------------------
@@ -420,19 +445,19 @@ async function markRejected () {
}
// -----------------------------
// Converter
// Converter (com tenant_id + responsible_member_id)
// -----------------------------
async function convertToPatient () {
const item = dlg.value?.item
if (!item?.id) return
if (converting.value) return
// regra de negócio: só converte "new"
if (item.status !== 'new') {
// só bloqueia cadastros já convertidos
if (item.status === 'converted') {
toast.add({
severity: 'warn',
summary: 'Atenção',
detail: 'Só é possível converter cadastros com status "Novo".',
detail: 'Este cadastro já foi convertido em paciente.',
life: 3000
})
return
@@ -447,19 +472,27 @@ async function convertToPatient () {
const ownerId = userData?.user?.id
if (!ownerId) throw new Error('Sessão inválida.')
const tenantId = await getTenantIdForConversion(item)
if (!tenantId) throw new Error('tenant_id is required')
const responsibleMemberId = await getResponsibleMemberId(tenantId, ownerId)
const cleanStr = (v) => {
const s = String(v ?? '').trim()
return s ? s : null
}
const digitsOnly = (v) => {
const d = String(v ?? '').replace(/\D/g, '')
return d ? d : null
}
// tenta reaproveitar avatar do intake (se vier url/path)
// tenta reaproveitar avatar do intake
const intakeAvatar = cleanStr(item.avatar_url) || cleanStr(item.foto_url) || cleanStr(item.photo_url) || null
const patientPayload = {
tenant_id: tenantId,
responsible_member_id: responsibleMemberId,
owner_id: ownerId,
// identificação/contato
@@ -471,7 +504,7 @@ async function convertToPatient () {
telefone_alternativo: digitsOnly(fTelAlt(item)),
// pessoais
data_nascimento: normalizeBirthToISO(fNasc(item)), // ✅ agora é sempre ISO date
data_nascimento: normalizeBirthToISO(fNasc(item)),
naturalidade: cleanStr(fNaturalidade(item)),
genero: cleanStr(fGenero(item)),
estado_civil: cleanStr(fEstadoCivil(item)),
@@ -520,6 +553,7 @@ async function convertToPatient () {
const patientId = created?.id
if (!patientId) throw new Error('Falha ao obter ID do paciente criado.')
// ✅ intake é externo: não prenda por owner_id aqui
const { error: upErr } = await supabase
.from('patient_intake_requests')
.update({
@@ -528,7 +562,6 @@ async function convertToPatient () {
updated_at: new Date().toISOString()
})
.eq('id', item.id)
.eq('owner_id', ownerId)
if (upErr) throw upErr
@@ -537,6 +570,7 @@ async function convertToPatient () {
dlg.value.open = false
await fetchIntakes()
} catch (err) {
console.error(err)
toast.add({
severity: 'error',
summary: 'Falha ao converter',
@@ -557,135 +591,125 @@ const totals = computed(() => {
return { total, nNew, nConv, nRej }
})
onMounted(fetchIntakes)
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
const recMobileMenuRef = ref(null)
const recSearchDlgOpen = ref(false)
const recMobileMenuItems = computed(() => [
{ label: 'Buscar', icon: 'pi pi-search', command: () => { recSearchDlgOpen.value = true } },
{ separator: true },
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchIntakes() }
])
onMounted(() => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
fetchIntakes()
})
onBeforeUnmount(() => { _observer?.disconnect() })
</script>
<template>
<div class="p-4">
<ConfirmDialog />
<Toast />
<ConfirmDialog />
<!-- HEADER -->
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative px-5 py-5">
<!-- faixa de cor -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-10 -right-12 h-40 w-40 rounded-full bg-emerald-400/20 blur-3xl" />
<div class="absolute top-10 -left-16 h-44 w-44 rounded-full bg-indigo-400/20 blur-3xl" />
</div>
<!-- Sentinel -->
<div ref="headerSentinelRef" class="rec-sentinel" />
<div class="relative flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-3">
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-inbox text-lg"></i>
</div>
<!-- Hero sticky -->
<div ref="headerEl" class="rec-hero mx-3 md:mx-5 mb-4" :class="{ 'rec-hero--stuck': headerStuck }">
<div class="rec-hero__blobs" aria-hidden="true">
<div class="rec-hero__blob rec-hero__blob--1" />
<div class="rec-hero__blob rec-hero__blob--2" />
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="text-xl font-semibold leading-none">Cadastros recebidos</div>
<Tag :value="`${totals.total}`" severity="secondary" />
</div>
<div class="text-color-secondary mt-1">
Solicitações de pré-cadastro (cadastro externo) para avaliar e converter.
</div>
</div>
</div>
<!-- filtros -->
<div class="mt-4 flex flex-wrap gap-2">
<Button
type="button"
class="!rounded-full"
:outlined="statusFilter !== 'new'"
:severity="statusFilter === 'new' ? 'info' : 'secondary'"
@click="toggleStatusFilter('new')"
>
<span class="flex items-center gap-2">
<i class="pi pi-sparkles" />
Novos: <b>{{ totals.nNew }}</b>
</span>
</Button>
<Button
type="button"
class="!rounded-full"
:outlined="statusFilter !== 'converted'"
:severity="statusFilter === 'converted' ? 'success' : 'secondary'"
@click="toggleStatusFilter('converted')"
>
<span class="flex items-center gap-2">
<i class="pi pi-check" />
Convertidos: <b>{{ totals.nConv }}</b>
</span>
</Button>
<Button
type="button"
class="!rounded-full"
:outlined="statusFilter !== 'rejected'"
:severity="statusFilter === 'rejected' ? 'danger' : 'secondary'"
@click="toggleStatusFilter('rejected')"
>
<span class="flex items-center gap-2">
<i class="pi pi-times" />
Rejeitados: <b>{{ totals.nRej }}</b>
</span>
</Button>
<Button
v-if="statusFilter"
type="button"
class="!rounded-full"
severity="secondary"
outlined
icon="pi pi-filter-slash"
label="Limpar filtro"
@click="statusFilter = ''"
/>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-2 sm:items-center">
<span class="p-input-icon-left w-full sm:w-[360px]">
<InputText
v-model="q"
class="w-full"
placeholder="Buscar por nome, e-mail ou telefone…"
/>
</span>
<div class="flex gap-2">
<Button
icon="pi pi-refresh"
label="Atualizar"
severity="secondary"
outlined
:loading="loading"
@click="fetchIntakes"
/>
</div>
<!-- Linha 1 -->
<div class="rec-hero__row1">
<div class="rec-hero__brand">
<div class="rec-hero__icon"><i class="pi pi-inbox text-lg" /></div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="rec-hero__title">Cadastros recebidos</div>
<Tag :value="`${totals.total}`" severity="secondary" />
</div>
<div class="rec-hero__sub">Pré-cadastros externos para avaliar e converter em pacientes</div>
</div>
</div>
<!-- Desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchIntakes" />
</div>
<!-- Mobile (<1200px) -->
<div class="flex xl:hidden items-center shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => recMobileMenuRef.toggle(e)" />
<Menu ref="recMobileMenuRef" :model="recMobileMenuItems" :popup="true" />
</div>
</div>
<!-- TABLE -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div v-if="loading" class="flex items-center justify-center py-10">
<ProgressSpinner style="width: 38px; height: 38px" />
<!-- Divisor -->
<Divider class="rec-hero__divider my-2" />
<!-- Linha 2: filtros de status + busca -->
<div class="rec-hero__row2">
<div class="flex flex-wrap items-center gap-2">
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'new'" :severity="statusFilter === 'new' ? 'info' : 'secondary'" @click="toggleStatusFilter('new')">
<span class="flex items-center gap-1.5"><i class="pi pi-sparkles text-xs" /> Novos: <b>{{ totals.nNew }}</b></span>
</Button>
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'converted'" :severity="statusFilter === 'converted' ? 'success' : 'secondary'" @click="toggleStatusFilter('converted')">
<span class="flex items-center gap-1.5"><i class="pi pi-check text-xs" /> Convertidos: <b>{{ totals.nConv }}</b></span>
</Button>
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'rejected'" :severity="statusFilter === 'rejected' ? 'danger' : 'secondary'" @click="toggleStatusFilter('rejected')">
<span class="flex items-center gap-1.5"><i class="pi pi-times text-xs" /> Rejeitados: <b>{{ totals.nRej }}</b></span>
</Button>
<Button v-if="statusFilter" type="button" size="small" class="!rounded-full" severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar" @click="statusFilter = ''" />
</div>
<DataTable
v-else
:value="filteredRows"
dataKey="id"
paginator
:rows="10"
:rowsPerPageOptions="[10, 20, 50]"
responsiveLayout="scroll"
stripedRows
class="!border-0"
>
<InputGroup class="w-72 shrink-0">
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="q" placeholder="Nome, e-mail ou telefone…" :disabled="loading" />
<Button v-if="q" icon="pi pi-trash" severity="danger" title="Limpar" @click="q = ''" />
</InputGroup>
</div>
</div>
<!-- Dialog busca mobile -->
<Dialog v-model:visible="recSearchDlgOpen" modal :draggable="false" header="Buscar cadastro" class="w-[94vw] max-w-sm">
<div class="pt-1">
<InputGroup>
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="q" placeholder="Nome, e-mail ou telefone…" autofocus />
<Button v-if="q" icon="pi pi-trash" severity="danger" @click="q = ''" />
</InputGroup>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="recSearchDlgOpen = false" />
</template>
</Dialog>
<!-- TABLE desktop (md+) -->
<div class="hidden md:block mx-3 md:mx-5 mb-5 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<DataTable
:value="filteredRows"
:loading="loading"
dataKey="id"
paginator
:rows="10"
:rowsPerPageOptions="[10, 20, 50]"
stripedRows
class="!border-0"
>
<Column header="Status" style="width: 10rem">
<template #body="{ data }">
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
@@ -734,13 +758,67 @@ onMounted(fetchIntakes)
</Column>
<template #empty>
<div class="text-color-secondary py-6 text-center">
Nenhum cadastro encontrado.
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-inbox text-xl" />
</div>
<div class="font-semibold">Nenhum cadastro encontrado</div>
<div class="mt-1 text-sm text-color-secondary">
{{ q || statusFilter ? 'Tente limpar os filtros ou mudar o termo de busca.' : 'Ainda não há cadastros recebidos.' }}
</div>
<div v-if="q || statusFilter" class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="q = ''; statusFilter = ''" />
</div>
</div>
</template>
</DataTable>
</div>
<!-- TABLE mobile cards (<md) -->
<div class="md:hidden mx-3 mb-5">
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner />
</div>
<div v-else-if="filteredRows.length === 0" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-inbox text-xl" />
</div>
<div class="font-semibold">Nenhum cadastro encontrado</div>
<div class="mt-1 text-sm text-color-secondary">
{{ q || statusFilter ? 'Tente limpar os filtros ou mudar o termo de busca.' : 'Ainda não há cadastros recebidos.' }}
</div>
<div v-if="q || statusFilter" class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="q = ''; statusFilter = ''" />
</div>
</div>
<div v-else class="flex flex-col gap-3">
<div
v-for="row in filteredRows"
:key="row.id"
class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
>
<div class="flex items-center gap-3">
<Avatar v-if="avatarUrl(row)" :image="avatarUrl(row)" shape="circle" />
<Avatar v-else icon="pi pi-user" shape="circle" />
<div class="flex-1 min-w-0">
<div class="font-semibold truncate">{{ fNome(row) || '—' }}</div>
<div class="text-sm text-color-secondary truncate">{{ fEmail(row) || '—' }}</div>
</div>
<Tag :value="statusLabel(row.status)" :severity="statusSeverity(row.status)" />
</div>
<div class="mt-3 flex items-center justify-between gap-2">
<div class="text-sm text-color-secondary flex flex-col gap-0.5">
<span>{{ fmtPhoneBR(fTel(row)) }}</span>
<span>{{ fmtDate(row.created_at) }}</span>
</div>
<Button icon="pi pi-eye" label="Ver" severity="secondary" outlined size="small" @click="openDetails(row)" />
</div>
</div>
</div>
</div>
<!-- MODAL -->
<Dialog
v-model:visible="dlg.open"
@@ -748,6 +826,7 @@ onMounted(fetchIntakes)
:header="null"
:style="{ width: 'min(940px, 96vw)' }"
:contentStyle="{ padding: 0 }"
:draggable="false"
@hide="closeDlg"
>
<div v-if="dlg.item" class="relative">
@@ -878,5 +957,49 @@ onMounted(fetchIntakes)
</div>
</div>
</Dialog>
</div>
</template>
</template>
<style scoped>
.rec-sentinel { height: 1px; }
.rec-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.rec-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
.rec-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.rec-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.rec-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
.rec-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
.rec-hero__row1 { position: relative; z-index: 1; display: flex; align-items: center; gap: 1rem; }
.rec-hero__brand { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; }
.rec-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.rec-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.rec-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
.rec-hero__row2 {
position: relative; z-index: 1;
display: flex; flex-wrap: wrap; align-items: center;
justify-content: space-between; gap: 0.75rem;
}
@media (max-width: 767px) {
.rec-hero__divider,
.rec-hero__row2 { display: none; }
}
</style>

View File

@@ -1,32 +1,79 @@
<template>
<div class="p-4">
<!-- TOOLBAR (padrão Sakai CRUD) -->
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Grupos de Pacientes</div>
<small class="text-color-secondary mt-1">
Organize seus pacientes por grupos. Alguns grupos são padrões do sistema e não podem ser alterados.
</small>
</div>
</template>
<Toast />
<template #end>
<div class="flex items-center gap-2">
<Button
label="Excluir selecionados"
icon="pi pi-trash"
severity="danger"
outlined
:disabled="!selectedGroups || !selectedGroups.length"
@click="confirmDeleteSelected"
/>
<Button label="Adicionar" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
</Toolbar>
<!-- Sentinel para detecção de sticky -->
<div ref="headerSentinelRef" class="grp-sentinel" />
<div class="flex flex-col lg:flex-row gap-4">
<!-- Hero Header sticky -->
<div ref="headerEl" class="grp-hero mx-3 md:mx-5 mb-4" :class="{ 'grp-hero--stuck': headerStuck }">
<div class="grp-hero__blobs" aria-hidden="true">
<div class="grp-hero__blob grp-hero__blob--1" />
<div class="grp-hero__blob grp-hero__blob--2" />
</div>
<!-- Linha 1 -->
<div class="grp-hero__row1">
<div class="grp-hero__brand">
<div class="grp-hero__icon"><i class="pi pi-sitemap text-lg" /></div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="grp-hero__title">Grupos</div>
<Tag :value="`${groups.length}`" severity="secondary" />
</div>
<div class="grp-hero__sub">Organize seus pacientes por grupos temáticos ou clínicos</div>
</div>
</div>
<!-- Desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button
v-if="selectedGroups?.length"
label="Excluir selecionados"
icon="pi pi-trash"
severity="danger"
outlined
class="rounded-full"
@click="confirmDeleteSelected"
/>
<Button label="Novo" icon="pi pi-plus" class="rounded-full" @click="openCreate" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="fetchAll" />
</div>
<!-- Mobile (<1200px) -->
<div class="flex xl:hidden items-center shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => grpMobileMenuRef.toggle(e)" />
<Menu ref="grpMobileMenuRef" :model="grpMobileMenuItems" :popup="true" />
</div>
</div>
<!-- Divisor -->
<Divider class="grp-hero__divider my-2" />
<!-- Linha 2: busca (oculta no mobile) -->
<div class="grp-hero__row2">
<InputGroup class="w-72">
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="filters.global.value" placeholder="Buscar grupo..." :disabled="loading" />
<Button v-if="filters.global.value" icon="pi pi-trash" severity="danger" title="Limpar" @click="filters.global.value = null" />
</InputGroup>
</div>
</div>
<!-- Dialog de busca (mobile) -->
<Dialog v-model:visible="grpSearchDlgOpen" modal :draggable="false" header="Buscar grupo" class="w-[94vw] max-w-sm">
<div class="pt-1">
<InputGroup>
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="filters.global.value" placeholder="Nome do grupo..." autofocus />
<Button v-if="filters.global.value" icon="pi pi-trash" severity="danger" @click="filters.global.value = null" />
</InputGroup>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="grpSearchDlgOpen = false" />
</template>
</Dialog>
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 mb-5">
<!-- LEFT: TABLE -->
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
<Card class="h-full">
@@ -48,16 +95,9 @@
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} grupos"
>
<template #header>
<div class="flex flex-wrap gap-2 items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium">Lista de Grupos</span>
<Tag :value="`${groups.length} grupos`" severity="secondary" />
</div>
<IconField>
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="filters.global.value" placeholder="Buscar grupos..." class="w-64" />
</IconField>
<div class="flex items-center gap-2">
<span class="font-medium">Lista de Grupos</span>
<Tag :value="`${groups.length} grupos`" severity="secondary" />
</div>
</template>
@@ -73,7 +113,18 @@
</template>
</Column>
<Column field="nome" header="Nome" sortable style="min-width: 16rem" />
<Column field="nome" header="Nome" sortable style="min-width: 16rem">
<template #body="{ data }">
<div class="flex items-center gap-2">
<span
v-if="data.cor"
class="inline-block w-3 h-3 rounded-full flex-shrink-0"
:style="colorStyle(data.cor)"
/>
<span>{{ data.nome }}</span>
</div>
</template>
</Column>
<Column header="Origem" sortable sortField="is_system" style="min-width: 12rem">
<template #body="{ data }">
@@ -116,14 +167,24 @@
outlined
rounded
disabled
v-tooltip.top="'Grupo padrão do sistema (inalterável)'"
title="Grupo padrão do sistema (inalterável)"
/>
</div>
</template>
</Column>
<template #empty>
Nenhum grupo encontrado.
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum grupo encontrado</div>
<div class="mt-1 text-sm text-color-secondary">Tente limpar o filtro ou crie um novo grupo.</div>
<div class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtro" @click="filters.global.value = null" />
<Button icon="pi pi-plus" label="Criar grupo" @click="openCreate" />
</div>
</div>
</template>
</DataTable>
</template>
@@ -192,31 +253,118 @@
<!-- DIALOG CREATE / EDIT -->
<Dialog
v-model:visible="dlg.open"
:header="dlg.mode === 'create' ? 'Criar Grupo' : 'Editar Grupo'"
modal
:style="{ width: '520px', maxWidth: '92vw' }"
>
<div class="flex flex-col gap-3">
<div>
<label class="block mb-2">Nome do Grupo</label>
<InputText v-model="dlg.nome" class="w-full" :disabled="dlg.saving" />
<small class="text-color-secondary">
Grupos Padrão são do sistema e não podem ser editados.
</small>
</div>
</div>
v-model:visible="dlg.open"
modal
:draggable="false"
:closable="!dlg.saving"
:dismissableMask="!dlg.saving"
class="grp-dialog w-[96vw] max-w-lg"
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' } }"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<span class="grp-dlg-dot shrink-0" :style="{ backgroundColor: dlgPreviewColor }" />
<div class="min-w-0">
<div class="text-base font-semibold truncate">
{{ dlg.nome || (dlg.mode === 'create' ? 'Novo grupo' : 'Editar grupo') }}
</div>
<div class="text-xs opacity-50">
{{ dlg.mode === 'create' ? 'Criar tipo de grupo' : 'Editar tipo de grupo' }}
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" text :disabled="dlg.saving" @click="dlg.open = false" />
<Button
:label="dlg.mode === 'create' ? 'Criar' : 'Salvar'"
:loading="dlg.saving"
@click="saveDialog"
:disabled="!String(dlg.nome || '').trim()"
/>
</template>
</Dialog>
<div class="flex items-center gap-2 shrink-0">
<Button
label="Cancelar"
severity="secondary"
outlined
class="rounded-full"
:disabled="dlg.saving"
@click="dlg.open = false"
/>
<Button
label="Salvar"
icon="pi pi-check"
class="rounded-full"
:loading="dlg.saving"
:disabled="!String(dlg.nome || '').trim()"
@click="saveDialog"
/>
</div>
</div>
</template>
<!-- Banner de preview -->
<div class="grp-dlg-banner" :style="{ backgroundColor: dlgPreviewColor }">
<span class="grp-dlg-banner__pill">{{ dlg.nome || 'Nome do grupo' }}</span>
</div>
<!-- Corpo -->
<div class="flex flex-col gap-4 p-4">
<!-- Nome -->
<FloatLabel variant="on">
<IconField>
<InputIcon>
<i class="pi pi-sitemap" />
</InputIcon>
<InputText
id="grp-nome"
v-model="dlg.nome"
class="w-full"
variant="filled"
:disabled="dlg.saving"
@keydown.enter.prevent="saveDialog"
/>
</IconField>
<label for="grp-nome">Nome do grupo *</label>
</FloatLabel>
<!-- Cor -->
<div class="grp-dlg-section">
<div class="grp-dlg-section__label">Cor</div>
<div class="grp-dlg-palette">
<button
v-for="p in dlgPresetColors"
:key="p.bg"
class="grp-dlg-swatch"
:class="{ 'grp-dlg-swatch--active': dlg.cor === p.bg }"
:style="{ backgroundColor: `#${p.bg}` }"
:title="p.name"
:disabled="dlg.saving"
@click="dlg.cor = p.bg"
>
<i v-if="dlg.cor === p.bg" class="pi pi-check grp-dlg-swatch__check" />
</button>
<!-- Custom ColorPicker -->
<div class="grp-dlg-swatch grp-dlg-swatch--custom" title="Cor personalizada">
<ColorPicker v-model="dlg.cor" format="hex" :disabled="dlg.saving" />
</div>
<!-- Limpar cor -->
<button
v-if="dlg.cor"
class="grp-dlg-swatch grp-dlg-swatch--clear"
title="Sem cor"
:disabled="dlg.saving"
@click="dlg.cor = ''"
>
<i class="pi pi-times text-xs" />
</button>
</div>
</div>
</div>
</Dialog>
<!-- DIALOG PACIENTES (com botão Abrir) -->
<Dialog
@@ -253,8 +401,12 @@
</Message>
<div v-else>
<div v-if="patientsDialog.items.length === 0" class="text-color-secondary">
Nenhum paciente associado a este grupo.
<div v-if="patientsDialog.items.length === 0" class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-users text-xl" />
</div>
<div class="font-semibold">Nenhum paciente neste grupo</div>
<div class="mt-1 text-sm text-color-secondary">Associe pacientes a este grupo na página de pacientes.</div>
</div>
<div v-else>
@@ -299,7 +451,16 @@
</Column>
<template #empty>
<div class="text-color-secondary py-5">Nenhum resultado para "{{ patientsDialog.search }}".</div>
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum resultado</div>
<div class="mt-1 text-sm text-color-secondary">Nenhum paciente corresponde a "{{ patientsDialog.search }}".</div>
<div class="mt-4">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar busca" @click="patientsDialog.search = ''" />
</div>
</div>
</template>
</DataTable>
</div>
@@ -311,17 +472,17 @@
</template>
</Dialog>
<ConfirmDialog />
</div>
<ConfirmDialog />
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Checkbox from 'primevue/checkbox'
import Menu from 'primevue/menu'
import { supabase } from '@/lib/supabase/client'
import {
@@ -335,6 +496,24 @@ const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
// ── Mobile menu ───────────────────────────────────────────
const grpMobileMenuRef = ref(null)
const grpSearchDlgOpen = ref(false)
const grpMobileMenuItems = computed(() => [
{ label: 'Adicionar grupo', icon: 'pi pi-plus', command: () => openCreate() },
{ label: 'Buscar', icon: 'pi pi-search', command: () => { grpSearchDlgOpen.value = true } },
{ separator: true },
...(selectedGroups.value?.length ? [{ label: 'Excluir selecionados', icon: 'pi pi-trash', command: () => confirmDeleteSelected() }, { separator: true }] : []),
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => fetchAll() }
])
const dt = ref(null)
const loading = ref(false)
const groups = ref([])
@@ -350,9 +529,30 @@ const dlg = reactive({
mode: 'create', // 'create' | 'edit'
id: '',
nome: '',
cor: '',
saving: false
})
const dlgPresetColors = [
{ bg: '6366f1', name: 'Índigo' },
{ bg: '8b5cf6', name: 'Violeta' },
{ bg: 'ec4899', name: 'Rosa' },
{ bg: 'ef4444', name: 'Vermelho' },
{ bg: 'f97316', name: 'Laranja' },
{ bg: 'eab308', name: 'Amarelo' },
{ bg: '22c55e', name: 'Verde' },
{ bg: '14b8a6', name: 'Teal' },
{ bg: '3b82f6', name: 'Azul' },
{ bg: '06b6d4', name: 'Ciano' },
{ bg: '64748b', name: 'Ardósia' },
{ bg: '292524', name: 'Escuro' },
]
const dlgPreviewColor = computed(() => {
if (!dlg.cor) return '#64748b'
return dlg.cor.startsWith('#') ? dlg.cor : `#${dlg.cor}`
})
const patientsDialog = reactive({
open: false,
loading: false,
@@ -428,6 +628,12 @@ function patientsLabel (n) {
return n === 1 ? '1 paciente' : `${n} pacientes`
}
function colorStyle (cor) {
if (!cor) return {}
const hex = String(cor).startsWith('#') ? cor : '#' + cor
return { background: hex }
}
function humanizeError (err) {
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.'
const code = err?.code
@@ -482,6 +688,7 @@ function openCreate () {
dlg.mode = 'create'
dlg.id = ''
dlg.nome = ''
dlg.cor = ''
}
function openEdit (row) {
@@ -489,6 +696,7 @@ function openEdit (row) {
dlg.mode = 'edit'
dlg.id = row.id
dlg.nome = row.nome
dlg.cor = row.cor || ''
}
async function saveDialog () {
@@ -502,13 +710,16 @@ async function saveDialog () {
return
}
const corRaw = String(dlg.cor || '').trim()
const cor = corRaw ? (corRaw.startsWith('#') ? corRaw : `#${corRaw}`) : null
dlg.saving = true
try {
if (dlg.mode === 'create') {
await createGroup(nome)
await createGroup(nome, cor)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 })
} else {
await updateGroup(dlg.id, nome)
await updateGroup(dlg.id, nome, cor)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo atualizado.', life: 2500 })
}
dlg.open = false
@@ -653,12 +864,125 @@ function abrirPaciente (patient) {
router.push(`/features/patients/cadastro/${patient.id}`)
}
onMounted(fetchAll)
onMounted(() => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
fetchAll()
})
onBeforeUnmount(() => { _observer?.disconnect() })
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active { transition: opacity .14s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
/* ── Hero ────────────────────────────────────────── */
.grp-sentinel { height: 1px; }
.grp-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.grp-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
.grp-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.grp-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.grp-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(16,185,129,0.10); }
.grp-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
.grp-hero__row1 { position: relative; z-index: 1; display: flex; align-items: center; gap: 1rem; }
.grp-hero__brand { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; }
.grp-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.grp-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.grp-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
.grp-hero__row2 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 0.75rem;
}
@media (max-width: 767px) {
.grp-hero__divider,
.grp-hero__row2 { display: none; }
}
/* ── Dialog ──────────────────────────────────────── */
.grp-dlg-dot {
width: 14px; height: 14px; border-radius: 50%;
border: 2px solid rgba(255,255,255,0.3);
box-shadow: 0 0 0 3px rgba(0,0,0,0.08);
transition: background-color 0.2s ease;
}
.grp-dlg-banner {
height: 72px;
display: flex; align-items: center; justify-content: center;
transition: background-color 0.25s ease;
}
.grp-dlg-banner__pill {
font-size: 1rem; font-weight: 700; letter-spacing: -0.02em;
padding: 0.35rem 1.1rem;
background: rgba(0,0,0,0.15);
border-radius: 999px;
backdrop-filter: blur(4px);
color: #fff;
}
.grp-dlg-section {
border: 1px solid var(--surface-border);
border-radius: 1.25rem;
background: var(--surface-card);
padding: 1rem;
}
.grp-dlg-section__label {
font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
opacity: 0.45; margin-bottom: 0.75rem;
}
.grp-dlg-palette { display: flex; flex-wrap: wrap; gap: 0.45rem; }
.grp-dlg-swatch {
width: 28px; height: 28px; border-radius: 50%;
border: 2px solid transparent;
display: grid; place-items: center;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
}
.grp-dlg-swatch:hover:not(:disabled) { transform: scale(1.18); box-shadow: 0 3px 10px rgba(0,0,0,0.2); }
.grp-dlg-swatch--active {
border-color: var(--surface-0, #fff);
box-shadow: 0 0 0 2px var(--text-color);
}
.grp-dlg-swatch__check { font-size: 0.6rem; color: #fff; font-weight: 900; }
.grp-dlg-swatch--custom {
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
overflow: hidden;
}
.grp-dlg-swatch--custom :deep(.p-colorpicker-preview) {
width: 100%; height: 100%; border: none; border-radius: 50%; opacity: 0;
}
.grp-dlg-swatch--clear {
background: var(--surface-border);
color: var(--text-color-secondary);
}
/* Fade */
.fade-enter-active, .fade-leave-active { transition: opacity .14s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@@ -1,19 +1,9 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import Dialog from 'primevue/dialog'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Chip from 'primevue/chip'
import FloatLabel from 'primevue/floatlabel'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import Accordion from 'primevue/accordion'
import AccordionPanel from 'primevue/accordionpanel'
import AccordionHeader from 'primevue/accordionheader'

View File

@@ -1,32 +1,88 @@
<template>
<div class="p-4">
<!-- TOOLBAR -->
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Tags de Pacientes</div>
<small class="text-color-secondary mt-1">
Classifique pacientes por temas (ex.: Burnout, Ansiedade, Triagem). Clique em Pacientes para ver a lista.
</small>
</div>
</template>
<Toast />
<template #end>
<div class="flex items-center gap-2">
<Button
label="Excluir selecionados"
icon="pi pi-trash"
severity="danger"
outlined
:disabled="!etiquetasSelecionadas?.length"
@click="confirmarExclusaoSelecionadas"
/>
<Button label="Adicionar" icon="pi pi-plus" @click="abrirCriar" />
</div>
</template>
</Toolbar>
<!-- Sentinel para detecção de sticky -->
<div ref="headerSentinelRef" class="tags-sentinel" />
<div class="flex flex-col lg:flex-row gap-4">
<!-- Hero Header sticky -->
<div ref="headerEl" class="tags-hero mx-3 md:mx-5 mb-4" :class="{ 'tags-hero--stuck': headerStuck }">
<!-- Blobs decorativos -->
<div class="tags-hero__blobs" aria-hidden="true">
<div class="tags-hero__blob tags-hero__blob--1" />
<div class="tags-hero__blob tags-hero__blob--2" />
</div>
<!-- Linha 1: brand + controles -->
<div class="tags-hero__row1">
<div class="tags-hero__brand">
<div class="tags-hero__icon">
<i class="pi pi-tags text-lg" />
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="tags-hero__title">Tags</div>
<Tag :value="`${etiquetas.length}`" severity="secondary" />
</div>
<div class="tags-hero__sub">Classifique pacientes por temas ex.: Burnout, Ansiedade, Triagem</div>
</div>
</div>
<!-- Controles desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button
v-if="etiquetasSelecionadas?.length"
label="Excluir selecionados"
icon="pi pi-trash"
severity="danger"
outlined
class="rounded-full"
@click="confirmarExclusaoSelecionadas"
/>
<Button label="Nova" icon="pi pi-plus" class="rounded-full" @click="abrirCriar" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="carregando" title="Recarregar" @click="buscarEtiquetas" />
</div>
<!-- Menu mobile (<1200px) -->
<div class="flex xl:hidden items-center shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
<!-- Divisor -->
<Divider class="tags-hero__divider my-2" />
<!-- Linha 2: busca (oculta no mobile) -->
<div class="tags-hero__row2">
<InputGroup class="w-72">
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="filtros.global.value" placeholder="Buscar tag..." :disabled="carregando" />
<Button
v-if="filtros.global.value"
icon="pi pi-trash"
severity="danger"
title="Limpar busca"
@click="filtros.global.value = null"
/>
</InputGroup>
</div>
</div>
<!-- Dialog de busca (mobile) -->
<Dialog v-model:visible="searchDlgOpen" modal :draggable="false" header="Buscar tag" class="w-[94vw] max-w-sm">
<div class="pt-1">
<InputGroup>
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="filtros.global.value" placeholder="Nome da tag..." autofocus />
<Button v-if="filtros.global.value" icon="pi pi-trash" severity="danger" title="Limpar" @click="filtros.global.value = null" />
</InputGroup>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchDlgOpen = false" />
</template>
</Dialog>
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 mb-5">
<!-- LEFT: tabela -->
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
<Card class="h-full">
@@ -46,34 +102,6 @@
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} tags"
>
<template #header>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div class="flex items-center gap-2">
<span class="font-medium">Lista de Tags</span>
<Tag :value="`${etiquetas.length} tags`" severity="secondary" />
</div>
<div class="flex items-center gap-2 w-full md:w-auto">
<IconField class="w-full md:w-80">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText
v-model="filtros.global.value"
placeholder="Buscar tag..."
class="w-full"
/>
</IconField>
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
v-tooltip.top="'Atualizar'"
@click="buscarEtiquetas"
/>
</div>
</div>
</template>
<!-- Seleção (bloqueia tags padrão) -->
<Column :exportable="false" headerStyle="width: 3rem">
<template #body="{ data }">
@@ -124,7 +152,7 @@
outlined
size="small"
:disabled="data.is_padrao"
v-tooltip.top="data.is_padrao ? 'Tags padrão não podem ser editadas' : 'Editar'"
:title="data.is_padrao ? 'Tags padrão não podem ser editadas' : 'Editar'"
@click="abrirEditar(data)"
/>
<Button
@@ -133,7 +161,7 @@
outlined
size="small"
:disabled="data.is_padrao"
v-tooltip.top="data.is_padrao ? 'Tags padrão não podem ser excluídas' : 'Excluir'"
:title="data.is_padrao ? 'Tags padrão não podem ser excluídas' : 'Excluir'"
@click="confirmarExclusaoUma(data)"
/>
</div>
@@ -141,8 +169,20 @@
</Column>
<template #empty>
<div class="text-color-secondary py-5">Nenhuma tag encontrada.</div>
</template>
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhuma tag encontrada</div>
<div class="mt-1 text-sm text-color-secondary">
Tente limpar filtros ou mudar o termo de busca.
</div>
<div class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="filtros.global.value = null" />
<Button icon="pi pi-user-plus" label="Cadastrar tag" @click="abrirCriar" />
</div>
</div>
</template>
</DataTable>
</template>
</Card>
@@ -155,12 +195,12 @@
<template #subtitle>As tags aparecem aqui quando houver pacientes associados.</template>
<template #content>
<div v-if="cards.length === 0" class="min-h-[150px] flex flex-col items-center justify-center text-center gap-2">
<i class="pi pi-tags text-3xl"></i>
<div class="font-medium">Sem dados ainda</div>
<small class="text-color-secondary">
Quando você associar pacientes às tags, elas aparecem aqui.
</small>
<div v-if="cards.length === 0" class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-tags text-xl" />
</div>
<div class="font-semibold">Nenhuma tag em uso</div>
<div class="mt-1 text-sm text-color-secondary">As tags mais usadas aparecem aqui quando houver pacientes associados.</div>
</div>
<div v-else class="flex flex-col gap-3">
@@ -212,64 +252,102 @@
<!-- DIALOG CREATE / EDIT -->
<Dialog
v-model:visible="dlg.open"
:header="dlg.mode === 'create' ? 'Criar Tag' : 'Editar Tag'"
modal
:style="{ width: '520px', maxWidth: '92vw' }"
:draggable="false"
:closable="!dlg.saving"
:dismissableMask="!dlg.saving"
class="tag-dialog w-[96vw] max-w-lg"
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' } }"
>
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Nome da Tag</label>
<InputText v-model="dlg.nome" class="w-full" :disabled="dlg.saving" />
<small class="text-color-secondary">Ex.: Burnout, Ansiedade, Triagem.</small>
</div>
<div>
<label class="block mb-2">Cor (opcional)</label>
<div class="flex flex-wrap items-center gap-3">
<ColorPicker v-model="dlg.cor" format="hex" :disabled="dlg.saving" />
<InputText
v-model="dlg.cor"
class="w-44"
placeholder="#22c55e"
:disabled="dlg.saving"
/>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<span
class="inline-block rounded-lg"
:style="{
width: '34px',
height: '34px',
border: '1px solid var(--surface-border)',
background: corPreview(dlg.cor)
}"
class="tag-dlg-dot shrink-0"
:style="{ backgroundColor: tagDlgPreviewColor }"
/>
<div class="min-w-0">
<div class="text-base font-semibold truncate">
{{ dlg.nome || (dlg.mode === 'create' ? 'Nova tag' : 'Editar tag') }}
</div>
<div class="text-xs opacity-50">
{{ dlg.mode === 'create' ? 'Criar nova tag' : 'Editando tag' }}
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="dlg.saving" @click="fecharDlg" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="dlg.saving" :disabled="!String(dlg.nome || '').trim()" @click="salvarDlg" />
</div>
<small class="text-color-secondary">
Pode usar HEX (#rrggbb). Se vazio, usamos uma cor neutra.
</small>
</div>
</template>
<!-- Banner -->
<div class="tag-dlg-banner" :style="{ backgroundColor: tagDlgPreviewColor }">
<span class="tag-dlg-banner__pill">{{ dlg.nome || 'Nome da tag' }}</span>
</div>
<template #footer>
<Button label="Cancelar" icon="pi pi-times" text @click="fecharDlg" :disabled="dlg.saving" />
<Button
:label="dlg.mode === 'create' ? 'Criar' : 'Salvar'"
icon="pi pi-check"
@click="salvarDlg"
:loading="dlg.saving"
:disabled="!String(dlg.nome || '').trim()"
/>
</template>
<!-- Corpo -->
<div class="flex flex-col gap-4 p-4">
<!-- Nome -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText
id="tag-nome"
v-model="dlg.nome"
class="w-full"
variant="filled"
:disabled="dlg.saving"
@keydown.enter.prevent="salvarDlg"
/>
</IconField>
<label for="tag-nome">Nome da tag *</label>
</FloatLabel>
<!-- Cor -->
<div class="tag-dlg-section">
<div class="tag-dlg-section__label">Cor</div>
<div class="tag-dlg-palette">
<button
v-for="p in tagPresetColors"
:key="p.bg"
class="tag-dlg-swatch"
:class="{ 'tag-dlg-swatch--active': dlg.cor === p.bg }"
:style="{ backgroundColor: `#${p.bg}` }"
:title="p.name"
:disabled="dlg.saving"
@click="dlg.cor = p.bg"
>
<i v-if="dlg.cor === p.bg" class="pi pi-check tag-dlg-swatch__check" />
</button>
<!-- Custom ColorPicker -->
<div class="tag-dlg-swatch tag-dlg-swatch--custom" title="Cor personalizada">
<ColorPicker v-model="dlg.cor" format="hex" :disabled="dlg.saving" />
</div>
<!-- Limpar -->
<button
v-if="dlg.cor"
class="tag-dlg-swatch tag-dlg-swatch--clear"
title="Sem cor"
:disabled="dlg.saving"
@click="dlg.cor = ''"
>
<i class="pi pi-times text-xs" />
</button>
</div>
</div>
</div>
</Dialog>
<!-- MODAL: pacientes da tag -->
<Dialog
v-model:visible="modalPacientes.open"
:header="modalPacientes.tag ? `Pacientes — ${modalPacientes.tag.nome}` : 'Pacientes'"
:header="modalPacientesHeader"
modal
:draggable="false"
:style="{ width: '900px', maxWidth: '96vw' }"
>
<div class="flex flex-col gap-3">
@@ -330,7 +408,16 @@
</Column>
<template #empty>
<div class="text-color-secondary py-5">Nenhum paciente encontrado.</div>
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum paciente encontrado</div>
<div class="mt-1 text-sm text-color-secondary">Nenhum resultado para "{{ modalPacientes.search }}".</div>
<div class="mt-4">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar busca" @click="modalPacientes.search = ''" />
</div>
</div>
</template>
</DataTable>
</div>
@@ -340,17 +427,17 @@
</template>
</Dialog>
<ConfirmDialog />
</div>
<ConfirmDialog />
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import ColorPicker from 'primevue/colorpicker'
import Checkbox from 'primevue/checkbox'
import Menu from 'primevue/menu'
import { supabase } from '@/lib/supabase/client'
@@ -358,6 +445,27 @@ const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
// ── Mobile menu ───────────────────────────────────────────
const mobileMenuRef = ref(null)
const searchDlgOpen = ref(false)
const mobileMenuItems = computed(() => [
{ label: 'Adicionar', icon: 'pi pi-plus', command: () => abrirCriar() },
{ label: 'Buscar', icon: 'pi pi-search', command: () => { searchDlgOpen.value = true } },
...(etiquetasSelecionadas.value?.length ? [
{ separator: true },
{ label: 'Excluir selecionados', icon: 'pi pi-trash', command: () => confirmarExclusaoSelecionadas() }
] : []),
{ separator: true },
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => buscarEtiquetas() }
])
const dt = ref(null)
const carregando = ref(false)
@@ -393,6 +501,30 @@ const cards = computed(() =>
.sort((a, b) => Number(b.pacientes_count ?? 0) - Number(a.pacientes_count ?? 0))
)
const tagPresetColors = [
{ bg: '6366f1', name: 'Índigo' },
{ bg: '8b5cf6', name: 'Violeta' },
{ bg: 'ec4899', name: 'Rosa' },
{ bg: 'ef4444', name: 'Vermelho' },
{ bg: 'f97316', name: 'Laranja' },
{ bg: 'eab308', name: 'Amarelo' },
{ bg: '22c55e', name: 'Verde' },
{ bg: '14b8a6', name: 'Teal' },
{ bg: '3b82f6', name: 'Azul' },
{ bg: '06b6d4', name: 'Ciano' },
{ bg: '64748b', name: 'Ardósia' },
{ bg: '292524', name: 'Escuro' },
]
const tagDlgPreviewColor = computed(() => {
if (!dlg.cor) return '#64748b'
const s = String(dlg.cor).trim()
return s.startsWith('#') ? s : `#${s}`
})
const modalPacientesHeader = computed(() =>
modalPacientes.tag ? `Pacientes — ${modalPacientes.tag.nome}` : 'Pacientes'
)
const modalPacientesFiltrado = computed(() => {
const s = String(modalPacientes.search || '').trim().toLowerCase()
if (!s) return modalPacientes.items || []
@@ -405,9 +537,17 @@ const modalPacientesFiltrado = computed(() => {
})
onMounted(() => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
buscarEtiquetas()
})
onBeforeUnmount(() => { _observer?.disconnect() })
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
@@ -416,6 +556,20 @@ async function getOwnerId() {
return user.id
}
async function getActiveTenantId(uid) {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('user_id', uid)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.single()
if (error) throw error
if (!data?.tenant_id) throw new Error('Tenant não encontrado.')
return data.tenant_id
}
function normalizarEtiquetaRow(r) {
// Compatível com banco antigo (name/color/is_native/patient_count)
// e com banco pt-BR (nome/cor/is_padrao/patients_count)
@@ -441,7 +595,7 @@ function isUniqueViolation(e) {
}
function friendlyDupMessage(nome) {
return `Já existe uma tag chamada “${nome}. Tente outro nome.`
return `Já existe uma tag chamada “${nome}". Tente outro nome.`
}
function corPreview(raw) {
@@ -560,22 +714,14 @@ async function salvarDlg() {
const cor = hex ? `#${hex}` : null
if (dlg.mode === 'create') {
// tenta pt-BR
let res = await supabase.from('patient_tags').insert({
const tenantId = await getActiveTenantId(ownerId)
const res = await supabase.from('patient_tags').insert({
owner_id: ownerId,
tenant_id: tenantId,
nome,
cor
})
// se colunas pt-BR não existem ainda, cai pra legado
if (res.error && /column .*nome/i.test(String(res.error.message || ''))) {
res = await supabase.from('patient_tags').insert({
owner_id: ownerId,
name: nome,
color: cor
})
}
if (res.error) throw res.error
toast.add({ severity: 'success', summary: 'Tag criada', detail: nome, life: 2500 })
} else {
@@ -640,7 +786,7 @@ async function salvarDlg() {
-------------------------------- */
function confirmarExclusaoUma(row) {
confirm.require({
message: `Excluir a tag “${row.nome}? (Isso remove também os vínculos com pacientes)`,
message: `Excluir a tag “${row.nome}"? (Isso remove também os vínculos com pacientes)`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir',
@@ -807,7 +953,114 @@ function abrirPaciente (patient) {
</script>
<style scoped>
/* Mantido apenas porque Transition name="fade" precisa das classes */
/* ── Hero Header ─────────────────────────────────── */
.tags-sentinel { height: 1px; }
.tags-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.tags-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
/* Blobs */
.tags-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.tags-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.tags-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(236,72,153,0.09); }
.tags-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.08); }
/* Linha 1 */
.tags-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.tags-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.tags-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.tags-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.tags-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
/* Linha 2 (oculta no mobile) */
.tags-hero__row2 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 0.75rem;
}
@media (max-width: 767px) {
.tags-hero__divider,
.tags-hero__row2 { display: none; }
}
/* ── Dialog de tag ───────────────────────────────── */
.tag-dlg-dot {
width: 14px; height: 14px; border-radius: 50%;
border: 2px solid rgba(255,255,255,0.3);
box-shadow: 0 0 0 3px rgba(0,0,0,0.08);
transition: background-color 0.2s ease;
}
.tag-dlg-banner {
height: 72px;
display: flex; align-items: center; justify-content: center;
transition: background-color 0.25s ease;
}
.tag-dlg-banner__pill {
font-size: 1rem; font-weight: 700; letter-spacing: -0.02em;
padding: 0.35rem 1.1rem;
background: rgba(0,0,0,0.15);
border-radius: 999px;
backdrop-filter: blur(4px);
color: #fff;
}
.tag-dlg-section {
border: 1px solid var(--surface-border);
border-radius: 1.25rem;
background: var(--surface-card);
padding: 1rem;
}
.tag-dlg-section__label {
font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
opacity: 0.45; margin-bottom: 0.75rem;
}
.tag-dlg-palette { display: flex; flex-wrap: wrap; gap: 0.45rem; }
.tag-dlg-swatch {
width: 28px; height: 28px; border-radius: 50%;
border: 2px solid transparent;
display: grid; place-items: center;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
}
.tag-dlg-swatch:hover:not(:disabled) { transform: scale(1.18); box-shadow: 0 3px 10px rgba(0,0,0,0.2); }
.tag-dlg-swatch--active {
border-color: var(--surface-0, #fff);
box-shadow: 0 0 0 2px var(--text-color);
}
.tag-dlg-swatch__check { font-size: 0.6rem; color: #fff; font-weight: 900; }
.tag-dlg-swatch--custom {
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
overflow: hidden;
}
.tag-dlg-swatch--custom :deep(.p-colorpicker-preview) {
width: 100%; height: 100%; border: none; border-radius: 50%; opacity: 0;
}
.tag-dlg-swatch--clear { background: var(--surface-border); color: var(--text-color-secondary); }
/* Fade (Transition nos cards) */
.fade-enter-active,
.fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from,

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

View File

@@ -1,7 +0,0 @@
<template>
<AppShellLayout />
</template>
<script setup>
import AppShellLayout from './AppShellLayout.vue'
</script>

View File

@@ -1,15 +1,12 @@
<script setup>
import { computed, inject } from 'vue'
import { useLayout } from '@/layout/composables/layout'
import SelectButton from 'primevue/selectbutton'
import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options'
const { layoutConfig, isDarkTheme, changeMenuMode } = useLayout()
// ✅ vem do AppTopbar (mesma instância)
const queuePatch = inject('queueUserSettingsPatch', null)
console.log('[AppConfigurator] queuePatch injected?', !!queuePatch)
// menu mode options
const menuModeOptions = [
@@ -35,14 +32,14 @@ const menuModeModel = computed({
if (!val || val === layoutConfig.menuMode) return
layoutConfig.menuMode = val
// composable pode aceitar nada (no teu caso, costuma ser isso)
try { changeMenuMode() } catch {}
// ✅ changeMenuMode espera event.value (seu composable usa event.value)
try { changeMenuMode({ value: val }) } catch {}
queuePatch?.({ menu_mode: val })
}
})
function updateColors(type, item) {
function updateColors (type, item) {
if (type === 'primary') {
layoutConfig.primary = item.name
applyThemeEngine(layoutConfig)
@@ -116,4 +113,4 @@ function updateColors(type, item) {
</div>
</div>
</div>
</template>
</template>

View File

@@ -1,34 +1,161 @@
<script setup>
import { useLayout } from '@/layout/composables/layout';
import { computed } from 'vue';
import AppFooter from './AppFooter.vue';
import AppSidebar from './AppSidebar.vue';
import AppTopbar from './AppTopbar.vue';
import { useLayout } from '@/layout/composables/layout'
import { computed, onMounted, onBeforeUnmount, provide } from 'vue'
import { useRoute } from 'vue-router'
const { layoutConfig, layoutState, hideMobileMenu } = useLayout();
import AppFooter from './AppFooter.vue'
import AppSidebar from './AppSidebar.vue'
import AppTopbar from './AppTopbar.vue'
import AppRail from './AppRail.vue'
import AppRailPanel from './AppRailPanel.vue'
import AppRailTopbar from './AppRailTopbar.vue'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
const route = useRoute()
const { layoutConfig, layoutState, hideMobileMenu, isDesktop } = useLayout()
// ✅ área do layout definida por rota (shell único)
const layoutArea = computed(() => route.meta?.area || null)
provide('layoutArea', layoutArea)
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
const tf = useTenantFeaturesStore()
const containerClass = computed(() => {
return {
'layout-overlay': layoutConfig.menuMode === 'overlay',
'layout-static': layoutConfig.menuMode === 'static',
'layout-overlay-active': layoutState.overlayMenuActive,
'layout-mobile-active': layoutState.mobileMenuActive,
'layout-static-inactive': layoutState.staticMenuInactive
};
});
return {
'layout-overlay': layoutConfig.menuMode === 'overlay',
'layout-static': layoutConfig.menuMode === 'static',
'layout-overlay-active': layoutState.overlayMenuActive,
'layout-mobile-active': layoutState.mobileMenuActive,
'layout-static-inactive': layoutState.staticMenuInactive
}
})
function getTenantId () {
return (
tenantStore.activeTenantId ||
tenantStore.tenantId ||
tenantStore.currentTenantId ||
tenantStore.tenant?.id ||
null
)
}
async function revalidateAfterSessionRefresh () {
try {
if (!tenantStore.loaded && !tenantStore.loading) {
await tenantStore.loadSessionAndTenant()
}
const tid = getTenantId()
if (!tid) return
await Promise.allSettled([
entitlementsStore.loadForTenant?.(tid, { force: true }),
tf.fetchForTenant?.(tid, { force: true })
])
} catch (e) {
console.warn('[AppLayout] revalidateAfterSessionRefresh failed:', e?.message || e)
}
}
function onSessionRefreshed () {
// ✅ Só revalidar tenantStore/entitlements em áreas TENANT.
// Em /portal e /account isso causa vazamento de contexto e troca de menu.
const p = String(route.path || '')
const isTenantArea =
p.startsWith('/admin') ||
p.startsWith('/therapist') ||
p.startsWith('/supervisor') ||
p.startsWith('/saas')
if (!isTenantArea) return
revalidateAfterSessionRefresh()
}
onMounted(() => {
window.addEventListener('app:session-refreshed', onSessionRefreshed)
})
onBeforeUnmount(() => {
window.removeEventListener('app:session-refreshed', onSessionRefreshed)
})
</script>
<template>
<div class="layout-wrapper" :class="containerClass">
<AppTopbar />
<AppSidebar />
<div class="layout-main-container">
<div class="layout-main">
<router-view />
</div>
<AppFooter />
<!-- Layout 2: Rail + Painel + Main (full-width) -->
<template v-if="layoutConfig.variant === 'rail' && isDesktop()">
<div class="l2-root">
<AppRail />
<div class="l2-body">
<AppRailTopbar />
<div class="l2-content">
<AppRailPanel />
<div class="l2-main">
<router-view />
</div>
</div>
<div class="layout-mask animate-fadein" @click="hideMobileMenu" />
</div>
</div>
<Toast />
</template>
<!-- Layout 1: Clássico -->
<template v-else>
<div class="layout-wrapper" :class="containerClass">
<AppTopbar />
<AppSidebar />
<div class="layout-main-container">
<div class="layout-main">
<router-view />
</div>
<AppFooter />
</div>
<div class="layout-mask animate-fadein" @click="hideMobileMenu" />
</div>
<Toast />
</template>
</template>
<style scoped>
/* ─── Layout 2 ───────────────────────────────────────────── */
.l2-root {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--surface-ground);
}
/* Coluna direita do rail: topbar + conteúdo */
.l2-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* Linha: painel lateral + main */
.l2-content {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
/* Área de conteúdo principal */
.l2-main {
flex: 1;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
/* Headers sticky no Rail colam no topo do scroll container (já abaixo da topbar) */
--layout-sticky-top: 0px;
}
</style>

View File

@@ -7,114 +7,84 @@ import AppMenuItem from './AppMenuItem.vue'
import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
import FloatLabel from 'primevue/floatlabel'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { sessionRole, sessionIsSaasAdmin } from '@/app/session'
import { getMenuByRole } from '@/navigation'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useMenuStore } from '@/stores/menuStore'
const route = useRoute()
const router = useRouter()
const { layoutState } = useLayout()
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
const menuStore = useMenuStore()
const tenantId = computed(() => tenantStore.activeTenantId || null)
// ======================================================
// ✅ Blindagem anti-“menu some”
// - se o menuStore.model piscar como [], mantém o último menu válido
// - evita sumiço ao entrar em /admin/clinic/features (reset momentâneo)
// ======================================================
/**
* ✅ Role canônico pro MENU:
* - 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(() => {
const p = String(route.path || '')
// raw (pode piscar vazio)
const rawModel = computed(() => menuStore.model || [])
// ✅ 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'
// último menu válido
const lastGoodModel = ref([])
// ✅ dentro de tenant: confia no role do tenant
if (tenantId.value) return tenantStore.activeRole || null
// debounce curto para aceitar "vazio real" (ex.: logout) sem travar UI
let acceptEmptyT = null
// ✅ fora de tenant: fallback pro sessionRole
return sessionRole.value || null
})
const model = computed(() => {
// ✅ role efetivo do menu já vem “canônico” do navRole
const effectiveRole = navRole.value
const base = getMenuByRole(effectiveRole, { isSaasAdmin: sessionIsSaasAdmin.value }) || []
const normalize = (s) => String(s || '').toLowerCase()
const priorityOrder = (group) => {
const label = normalize(group?.label)
if (label.includes('saas')) return 0
if (label.includes('pacientes')) return 1
return 99
function setLastGoodIfValid (m) {
if (Array.isArray(m) && m.length) {
lastGoodModel.value = m
}
}
return [...base].sort((a, b) => priorityOrder(a) - priorityOrder(b))
})
// quando troca tenant -> recarrega entitlements
watch(
tenantId,
async (id) => {
entitlementsStore.invalidate()
if (id) await entitlementsStore.loadForTenant(id, { force: true })
rawModel,
(m) => {
// se veio com itens, atualiza na hora
if (Array.isArray(m) && m.length) {
if (acceptEmptyT) clearTimeout(acceptEmptyT)
setLastGoodIfValid(m)
return
}
// se veio vazio, NÃO derruba o menu imediatamente.
// Só aceita vazio se continuar vazio por um tempinho.
if (acceptEmptyT) clearTimeout(acceptEmptyT)
acceptEmptyT = setTimeout(() => {
// se ainda estiver vazio, e você quiser realmente limpar, faça aqui.
// Por padrão, manteremos o último menu válido para evitar UX quebrada.
// Se quiser limpar em logout, o logout deve limpar lastGoodModel explicitamente.
}, 250)
},
{ immediate: true }
{ immediate: true, deep: false }
)
// ✅ quando troca role efetivo do menu (via rota/tenant/session) -> recarrega entitlements do tenant atual
watch(
() => navRole.value,
async () => {
if (!tenantId.value) return
entitlementsStore.invalidate()
await entitlementsStore.loadForTenant(tenantId.value, { force: true })
}
)
// model final exibido (com fallback)
const model = computed(() => {
const m = rawModel.value
if (Array.isArray(m) && m.length) return m
if (Array.isArray(lastGoodModel.value) && lastGoodModel.value.length) return lastGoodModel.value
return []
})
// ✅ rota -> activePath (NÃO fecha menu em nenhum cenário)
// ✅ rota -> activePath (NÃO fecha menu)
watch(
() => route.path,
(p) => { layoutState.activePath = p },
{ immediate: true }
)
// ==============================
// 🔎 Busca no menu (flatten + resultados)
// ==============================
// ======================================================
// 🔎 Busca no menu (mantive igual)
// ======================================================
const query = ref('')
const showResults = ref(false)
const activeIndex = ref(-1)
// ✅ garante Ctrl/Cmd+K mesmo sem recentes
const forcedOpen = ref(false)
// ref do InputText (pra Ctrl/Cmd + K)
const searchEl = ref(null)
// wrapper pra click-outside
const searchWrapEl = ref(null)
// Recentes
const RECENT_KEY = 'menu_search_recent'
const recent = ref([])
@@ -136,15 +106,11 @@ loadRecent()
watch(query, (v) => {
const hasText = !!v?.trim()
// digitou: abre e sai do modo "forced"
if (hasText) {
forcedOpen.value = false
showResults.value = true
return
}
// vazio: mantém aberto apenas se foi "forçado" (Ctrl/Cmd+K ou foco)
showResults.value = forcedOpen.value
})
@@ -163,10 +129,17 @@ function norm (s) {
.trim()
}
function isVisibleItem (it) {
const v = it?.visible
if (typeof v === 'function') return !!v()
if (v === undefined || v === null) return true
return v !== false
}
function flattenMenu (items, trail = []) {
const out = []
for (const it of (items || [])) {
if (it?.visible === false) continue
if (!isVisibleItem(it)) continue
const nextTrail = [...trail, it?.label].filter(Boolean)
@@ -176,7 +149,10 @@ function flattenMenu (items, trail = []) {
to: it.to,
icon: it.icon,
trail: nextTrail,
proBadge: !!it.proBadge,
// ✅ usa cálculo dinâmico vindo do navigation (quando existir)
proBadge: !!(it.__showProBadge ?? it.proBadge),
feature: it.feature || null
})
}
@@ -210,7 +186,6 @@ watch(results, (list) => {
activeIndex.value = list.length ? 0 : -1
})
// ===== highlight =====
function escapeHtml (s) {
return String(s || '')
.replace(/&/g, '&amp;')
@@ -235,7 +210,6 @@ function highlight (text, q) {
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`
}
// ===== teclado =====
function onSearchKeydown (e) {
if (e.key === 'Escape') {
showResults.value = false
@@ -273,7 +247,6 @@ function isTypingTarget (el) {
return tag === 'input' || tag === 'textarea' || el.isContentEditable
}
// ===== Ctrl/Cmd + K =====
function focusSearch () {
forcedOpen.value = true
showResults.value = true
@@ -304,25 +277,18 @@ function onGlobalKeydown (e) {
}
}
// ✅ Recentes: aplicar query + abrir + focar (sem depender de watch timing)
function applyRecent (q) {
query.value = q
forcedOpen.value = true
showResults.value = true
activeIndex.value = 0
nextTick(() => {
// garante foco e teclado funcionando
focusSearch()
})
nextTick(() => focusSearch())
}
// click outside para fechar painel
function onDocMouseDown (e) {
if (!showResults.value) return
const root = searchWrapEl.value
if (!root) return
if (!root.contains(e.target)) {
showResults.value = false
forcedOpen.value = false
@@ -334,6 +300,7 @@ onMounted(() => {
document.addEventListener('mousedown', onDocMouseDown)
})
onBeforeUnmount(() => {
if (acceptEmptyT) clearTimeout(acceptEmptyT)
window.removeEventListener('keydown', onGlobalKeydown, true)
document.removeEventListener('mousedown', onDocMouseDown)
})
@@ -356,7 +323,6 @@ const quickDialog = ref(false)
function onQuickCreate () { quickDialog.value = true }
function onQuickCreated () { quickDialog.value = false }
// controle de “recentes”: mostrar ao focar (mesmo sem recentes, para exibir dicas)
function onSearchFocus () {
if (!query.value?.trim()) {
forcedOpen.value = true
@@ -370,12 +336,29 @@ function onSearchFocus () {
<!-- 🔎 TOPO FIXO -->
<div ref="searchWrapEl" class="pb-2 border-b border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative">
<div
aria-hidden="true"
style="position:absolute; left:-9999px; top:-9999px; width:1px; height:1px; overflow:hidden;"
>
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
</div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
ref="searchEl"
id="menu_search"
name="menu_search"
type="search"
inputmode="search"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
v-model="query"
class="w-full pr-10"
variant="filled"
@@ -383,10 +366,9 @@ function onSearchFocus () {
@keydown="onSearchKeydown"
/>
</IconField>
<label for="menu_search">Buscar no menu</label>
<label for="menu_search">Encontrar menu...</label>
</FloatLabel>
<!-- botão limpar busca -->
<button
v-if="query.trim()"
type="button"
@@ -398,7 +380,6 @@ function onSearchFocus () {
</button>
</div>
<!-- Recentes (quando query vazio) -->
<div
v-if="showResults && !query.trim() && recent.length"
class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
@@ -427,14 +408,13 @@ function onSearchFocus () {
</button>
</div>
<!-- Resultados -->
<div
v-else-if="showResults && results.length"
class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
>
<button
v-for="(r, i) in results"
:key="r.to"
:key="String(r.to)"
type="button"
@mousedown.prevent="goTo(r)"
:class="[
@@ -449,7 +429,7 @@ function onSearchFocus () {
</div>
<span
v-if="r.proBadge || r.feature"
v-if="r.proBadge"
class="text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"
>
PRO
@@ -457,48 +437,19 @@ function onSearchFocus () {
</button>
</div>
<div
v-else-if="showResults && query && !results.length"
class="mt-2 px-3 py-2 text-sm opacity-70"
>
<div v-else-if="showResults && query && !results.length" class="mt-2 px-3 py-2 text-sm opacity-70">
Nenhum item encontrado.
</div>
<!-- instruções embaixo quando houver recentes/resultados/uso -->
<div
v-if="showResults && (recent.length || results.length || query.trim())"
class="mt-2 px-3 text-xs opacity-70 flex flex-wrap gap-x-3 gap-y-1"
>
<span><b>Ctrl+K</b>/<b>Cmd+K</b> focar</span>
<span><b></b> navegar</span>
<span><b>Enter</b> abrir</span>
<span><b>Esc</b> fechar</span>
</div>
<!-- fallback quando não tem nada -->
<div
v-else-if="showResults && !query.trim() && !recent.length"
class="mt-2 px-3 py-2 text-xs opacity-60"
>
Dica: pressione <b>Ctrl + K</b> (ou <b>Cmd + K</b>) para buscar.
</div>
</div>
<!-- SOMENTE O MENU ROLA -->
<div class="flex-1 overflow-y-auto">
<ul class="layout-menu pb-20">
<template v-for="(item, i) in model" :key="i">
<AppMenuItem
:item="item"
:index="i"
:root="true"
@quick-create="onQuickCreate"
/>
<AppMenuItem :item="item" :index="i" :root="true" @quick-create="onQuickCreate" />
</template>
</ul>
</div>
<!-- rodapé fixo -->
<AppMenuFooterPanel />
<ComponentCadastroRapido

View File

@@ -2,6 +2,8 @@
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Popover from 'primevue/popover'
import { sessionUser, sessionRole } from '@/app/session'
import { supabase } from '@/lib/supabase/client'
@@ -13,7 +15,7 @@ const pop = ref(null)
// ------------------------------------------------------
// RBAC (Tenant): fonte da verdade para permissões por papel
// ------------------------------------------------------
const { role, canSee, isPatient } = useRoleGuard()
const { role, canSee } = useRoleGuard()
// ------------------------------------------------------
// UI labels (nome/iniciais)
@@ -33,22 +35,21 @@ const label = computed(() => {
/**
* 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.
* Prefere exibir o papel do TENANT (role do useRoleGuard),
* porque governa a UI dentro da clínica.
*/
const sublabel = computed(() => {
const r = role.value || sessionRole.value
if (!r) return 'Sessão'
// tenant roles (confirmados no banco): tenant_admin | therapist | patient
if (r === 'tenant_admin') return 'Administrador'
// tenant roles
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return 'Administrador'
if (r === 'therapist') return 'Terapeuta'
if (r === 'patient') return 'Paciente'
// fallback (caso venha algo diferente)
// portal/global roles
if (r === 'portal_user') return 'Portal'
if (r === 'patient') return 'Portal' // legado (caso ainda exista em algum lugar)
return r
})
@@ -60,69 +61,52 @@ function toggle (e) {
}
function close () {
try {
pop.value?.hide()
} catch {}
try { pop.value?.hide() } catch {}
}
// ------------------------------------------------------
// Navegação segura (NAME com fallback)
// Navegação segura (resolve antes; fallback se não existir)
// ------------------------------------------------------
async function safePush (target, fallback) {
try {
await router.push(target)
} catch (e) {
// fallback quando o "name" não existe no router
if (fallback) {
try {
await router.push(fallback)
} catch {
await router.push('/')
}
} else {
await router.push('/')
}
const r = router.resolve(target)
if (r?.matched?.length) return await router.push(target)
} catch {}
if (fallback) {
try { return await router.push(fallback) } catch {}
}
return router.push('/')
}
// ------------------------------------------------------
// Actions
// ------------------------------------------------------
function goMyProfile () {
close()
// Navegação segura para Account → Profile
safePush(
{ name: 'account-profile' },
'/account/profile'
)
safePush({ name: 'account-profile' }, '/account/profile')
}
function goSettings () {
close()
// ✅ Decide por RBAC (tenant role), não por sessionRole
// ✅ Configurações é RBAC (quem pode ver, vê)
if (canSee('settings.view')) {
router.push({ name: 'ConfiguracoesAgenda' })
return
return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings') // fallback genérico
}
// 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('/')
// ✅ quem não pode (ex.: paciente), manda pro portal correto
return safePush({ name: 'portal-sessoes' }, '/portal')
}
function goSecurity () {
close()
// ✅ 1) tenta por NAME (recomendado)
// ✅ 2) fallback: caminhos mais prováveis do teu projeto
// Ajuste/defina a rota no router como name: 'AdminSecurity' para ficar perfeito
safePush(
{ name: 'AdminSecurity' },
'/admin/settings/security'
// ✅ Segurança é "Account": todos podem acessar
return safePush(
{ name: 'account-security' },
'/account/security'
)
}
@@ -147,9 +131,10 @@ async function signOut () {
>
<!-- avatar -->
<img
v-if="sessionUser.value?.user_metadata?.avatar_url"
:src="sessionUser.value.user_metadata.avatar_url"
v-if="sessionUser?.user_metadata?.avatar_url"
:src="sessionUser.user_metadata.avatar_url"
class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]"
alt="avatar"
/>
<div
v-else

View File

@@ -1,10 +1,10 @@
<!-- src/layout/AppMenuItem.vue -->
<script setup>
import { useLayout } from '@/layout/composables/layout'
import { computed, ref, nextTick } from 'vue'
import { computed, ref, nextTick, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import Popover from 'primevue/popover'
import Button from 'primevue/button'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
@@ -31,54 +31,85 @@ const fullPath = computed(() =>
)
// ==============================
// Active logic: mantém submenu aberto se algum descendente estiver ativo
// Visible: boolean OU function() -> boolean
// ==============================
const isVisible = computed(() => {
const v = props.item?.visible
if (typeof v === 'function') return !!v()
if (v === undefined || v === null) return true
return v !== false
})
// ==============================
// Helpers de rota: aceita string OU objeto
// ==============================
function toPath (to) {
if (!to) return ''
if (typeof to === 'string') return to
try { return router.resolve(to).path || '' } catch { return '' }
}
// ==============================
// Active logic
// ==============================
function isSameRoute (current, target) {
if (!current || !target) return false
return current === target || current.startsWith(target + '/')
const cur = typeof current === 'string' ? current : toPath(current)
const tar = typeof target === 'string' ? target : toPath(target)
if (!cur || !tar) return false
return cur === tar || cur.startsWith(tar + '/')
}
function hasActiveDescendant (node, currentPath) {
const children = node?.items || []
for (const child of children) {
if (child?.to && isSameRoute(currentPath, child.to)) return true
const childTo = toPath(child?.to)
if (childTo && isSameRoute(currentPath, childTo)) return true
if (child?.items?.length && hasActiveDescendant(child, currentPath)) return true
}
return false
}
const isActive = computed(() => {
const current = layoutState.activePath || ''
const current = typeof layoutState.activePath === 'string'
? layoutState.activePath
: toPath(layoutState.activePath)
const item = props.item
// grupo com submenu: active se qualquer descendente estiver ativo
if (item?.items?.length) {
if (hasActiveDescendant(item, current)) return true
// fallback pelo "path" (útil para rotas internas tipo /saas/plans/123)
return item.path ? current.startsWith(fullPath.value || '') : false
}
// folha: active se rota igual ao to
return item?.to ? isSameRoute(current, item.to) : false
const leafTo = toPath(item?.to)
return leafTo ? isSameRoute(current, leafTo) : false
})
// ==============================
// Feature lock + label
// ✅ PRO badge (agora 100% por entitlementsStore)
// ==============================
const ownerId = computed(() => tenantStore.activeTenantId || null)
const isLocked = computed(() => {
const showProBadge = computed(() => {
const feature = props.item?.feature
return !!(props.item?.proBadge && feature && ownerId.value && !entitlementsStore.has(feature))
if (!props.item?.proBadge || !feature) return false
// se tiver a feature, NÃO é PRO (não mostra badge)
// se NÃO tiver, mostra PRO
try {
return !entitlementsStore.has(feature)
} catch {
// se der erro, não mostra (evita “PRO fantasma”)
return false
}
})
// 🔒 locked quando precisa mostrar PRO (ou seja, não tem feature)
const isLocked = computed(() => !!(props.item?.proBadge && showProBadge.value))
const itemDisabled = computed(() => !!props.item?.disabled)
const isBlocked = computed(() => itemDisabled.value || isLocked.value)
const labelText = computed(() => {
const base = props.item?.label || ''
return props.item?.proBadge && isLocked.value ? `${base} (PRO)` : base
return props.item?.label || ''
})
const itemClick = async (event, item) => {
@@ -96,17 +127,14 @@ const itemClick = async (event, item) => {
return
}
// 🚫 disabled -> bloqueia
if (itemDisabled.value) {
event.preventDefault()
event.stopPropagation()
return
}
// commands
if (item?.command) item.command({ originalEvent: event, item })
// ✅ submenu: expande/colapsa e não navega
if (item?.items?.length) {
event.preventDefault()
event.stopPropagation()
@@ -114,24 +142,22 @@ const itemClick = async (event, item) => {
if (isActive.value) {
layoutState.activePath = props.parentPath || ''
} else {
layoutState.activePath = fullPath.value
layoutState.activePath = fullPath.value || ''
layoutState.menuHoverActive = true
}
return
}
// ✅ leaf: marca ativo e NÃO fecha menu
if (item?.to) layoutState.activePath = item.to
if (item?.to) layoutState.activePath = toPath(item.to)
}
const onMouseEnter = () => {
if (isDesktop() && props.root && props.item?.items && layoutState.menuHoverActive) {
layoutState.activePath = fullPath.value
layoutState.activePath = fullPath.value || ''
}
}
/* ---------- POPUP + ---------- */
function togglePopover (event) {
if (isBlocked.value) return
pop.value?.toggle(event)
@@ -143,10 +169,7 @@ function closePopover () {
function abrirCadastroRapido () {
closePopover()
emit('quick-create', {
entity: props.item?.quickCreateEntity || 'patient',
mode: 'rapido'
})
emit('quick-create', { entity: props.item?.quickCreateEntity || 'patient', mode: 'rapido' })
}
async function irCadastroCompleto () {
@@ -157,17 +180,17 @@ async function irCadastroCompleto () {
layoutState.menuHoverActive = false
await nextTick()
router.push('/admin/patients/cadastro')
router.push({ name: 'admin-pacientes-cadastro' })
}
</script>
<template>
<li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
<div v-if="root && item.visible !== false" class="layout-menuitem-root-text">
<li v-show="isVisible" :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
<div v-if="root" class="layout-menuitem-root-text">
{{ item.label }}
</div>
<div v-if="!root && item.visible !== false" class="flex align-items-center justify-content-between w-full">
<div v-if="!root" class="flex align-items-center justify-content-between w-full">
<component
:is="item.to && !item.items ? 'router-link' : 'a'"
v-bind="item.to && !item.items ? { to: item.to } : { href: item.url }"
@@ -183,8 +206,14 @@ async function irCadastroCompleto () {
<span class="layout-menuitem-text">
{{ labelText }}
<!-- (debug) pode remover depois -->
<small style="opacity:.6">[locked={{ isLocked }}]</small>
</span>
<!-- Badge PRO some quando tem entitlements -->
<span
v-if="item.proBadge && showProBadge"
class="ml-2 text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"
>
PRO
</span>
<i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" />
@@ -209,7 +238,7 @@ async function irCadastroCompleto () {
</div>
</Popover>
<Transition v-if="item.items && item.visible !== false" name="layout-submenu">
<Transition v-if="item.items" name="layout-submenu">
<ul v-show="root ? true : isActive" class="layout-submenu">
<app-menu-item
v-for="child in item.items"
@@ -222,4 +251,4 @@ async function irCadastroCompleto () {
</ul>
</Transition>
</li>
</template>
</template>

329
src/layout/AppRail.vue Normal file
View File

@@ -0,0 +1,329 @@
<!-- src/layout/AppRail.vue Mini icon rail (Layout 2) -->
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Popover from 'primevue/popover'
import Button from 'primevue/button'
import { useMenuStore } from '@/stores/menuStore'
import { useLayout } from './composables/layout'
import { sessionUser } from '@/app/session'
import { supabase } from '@/lib/supabase/client'
const menuStore = useMenuStore()
const { layoutConfig, layoutState, isDesktop } = useLayout()
const router = useRouter()
// ── Seções do rail (derivadas do model) ─────────────────────
const railSections = computed(() => {
const model = menuStore.model || []
return model
.filter(s => s.label && Array.isArray(s.items) && s.items.length)
.map(s => ({
key: s.label,
label: s.label,
icon: s.icon || s.items.find(i => i.icon)?.icon || 'pi pi-fw pi-circle',
items: s.items
}))
})
// ── Avatar / iniciais ────────────────────────────────────────
const avatarUrl = computed(() => sessionUser.value?.user_metadata?.avatar_url || null)
const initials = computed(() => {
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || ''
const parts = String(name).trim().split(/\s+/).filter(Boolean)
const a = parts[0]?.[0] || 'U'
const b = parts.length > 1 ? parts[parts.length - 1][0] : ''
return (a + b).toUpperCase()
})
const userName = computed(() => sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || 'Conta')
// ── Seleção de seção ─────────────────────────────────────────
function selectSection (section) {
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) {
layoutState.railPanelOpen = false
} else {
layoutState.railSectionKey = section.key
layoutState.railPanelOpen = true
}
}
function isActiveSectionOrChild (section) {
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) return true
// verifica se algum filho está ativo
const active = String(layoutState.activePath || '')
return section.items.some(i => {
const p = typeof i.to === 'string' ? i.to : ''
return p && active.startsWith(p)
})
}
// ── Popover do usuário (rodapé) ───────────────────────────────
const userPop = ref(null)
function toggleUserPop (e) { userPop.value?.toggle(e) }
function goTo (path) {
try { userPop.value?.hide() } catch {}
router.push(path)
}
async function signOut () {
try { userPop.value?.hide() } catch {}
try { await supabase.auth.signOut() } catch {}
router.push('/auth/login')
}
</script>
<template>
<aside class="rail">
<!-- Brand -->
<div class="rail__brand">
<span class="rail__psi">Ψ</span>
</div>
<!-- Nav icons -->
<nav class="rail__nav" role="navigation" aria-label="Menu principal">
<button
v-for="section in railSections"
:key="section.key"
class="rail__btn"
:class="{ 'rail__btn--active': isActiveSectionOrChild(section) }"
v-tooltip.right="{ value: section.label, showDelay: 400 }"
:aria-label="section.label"
@click="selectSection(section)"
>
<i :class="section.icon" />
</button>
</nav>
<!-- Rodapé -->
<div class="rail__foot">
<!-- Configurações de layout -->
<button
class="rail__btn rail__btn--sm"
v-tooltip.right="{ value: 'Meu Perfil', showDelay: 400 }"
aria-label="Meu Perfil"
@click="goTo('/account/profile')"
>
<i class="pi pi-fw pi-cog" />
</button>
<!-- Avatar / user -->
<button
class="rail__av-btn"
v-tooltip.right="{ value: userName, showDelay: 400 }"
:aria-label="userName"
@click="toggleUserPop"
>
<img v-if="avatarUrl" :src="avatarUrl" class="rail__av-img" :alt="userName" />
<span v-else class="rail__av-init">{{ initials }}</span>
</button>
</div>
<!-- Popover usuário -->
<Popover ref="userPop" appendTo="body">
<div class="rail-pop">
<div class="rail-pop__user">
<div class="rail-pop__av">
<img v-if="avatarUrl" :src="avatarUrl" class="rail-pop__av-img" />
<span v-else class="rail-pop__av-init">{{ initials }}</span>
</div>
<div class="min-w-0">
<div class="rail-pop__name">{{ userName }}</div>
<div class="rail-pop__email">{{ sessionUser?.email }}</div>
</div>
</div>
<div class="rail-pop__divider" />
<Button label="Meu Perfil" icon="pi pi-user" text class="w-full justify-start" @click="goTo('/account/profile')" />
<Button label="Segurança" icon="pi pi-shield" text class="w-full justify-start" @click="goTo('/account/security')" />
<div class="rail-pop__divider" />
<Button label="Sair" icon="pi pi-sign-out" severity="danger" text class="w-full justify-start" @click="signOut" />
</div>
</Popover>
</aside>
</template>
<style scoped>
/* ─── Rail container ─────────────────────────────────────── */
.rail {
width: 60px;
flex-shrink: 0;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
border-right: 1px solid var(--surface-border);
background: var(--surface-card);
z-index: 50;
user-select: none;
}
/* ─── Brand ──────────────────────────────────────────────── */
.rail__brand {
width: 100%;
height: 56px;
display: grid;
place-items: center;
border-bottom: 1px solid var(--surface-border);
flex-shrink: 0;
}
.rail__psi {
font-size: 1.35rem;
font-weight: 800;
color: var(--primary-color);
text-shadow: 0 0 20px color-mix(in srgb, var(--primary-color) 40%, transparent);
line-height: 1;
}
/* ─── Nav ────────────────────────────────────────────────── */
.rail__nav {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
}
.rail__nav::-webkit-scrollbar { display: none; }
/* ─── Buttons ────────────────────────────────────────────── */
.rail__btn {
width: 40px;
height: 40px;
border-radius: 10px;
display: grid;
place-items: center;
border: none;
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
font-size: 1rem;
transition: background 0.15s, color 0.15s, transform 0.12s;
position: relative;
flex-shrink: 0;
}
.rail__btn:hover {
background: var(--surface-ground);
color: var(--text-color);
transform: scale(1.08);
}
.rail__btn--active {
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
}
.rail__btn--active::before {
content: '';
position: absolute;
left: -10px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 22px;
border-radius: 0 3px 3px 0;
background: var(--primary-color);
}
.rail__btn--sm {
width: 36px;
height: 36px;
font-size: 0.875rem;
}
/* ─── Footer ─────────────────────────────────────────────── */
.rail__foot {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px 0 12px;
border-top: 1px solid var(--surface-border);
}
/* ─── Avatar button ──────────────────────────────────────── */
.rail__av-btn {
width: 36px;
height: 36px;
border-radius: 10px;
border: none;
cursor: pointer;
overflow: hidden;
transition: transform 0.12s, box-shadow 0.15s;
background: var(--surface-ground);
display: grid;
place-items: center;
flex-shrink: 0;
}
.rail__av-btn:hover {
transform: scale(1.08);
box-shadow: 0 0 0 2px var(--primary-color);
}
.rail__av-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.rail__av-init {
font-size: 0.78rem;
font-weight: 700;
color: var(--text-color);
}
/* ─── Popover ────────────────────────────────────────────── */
.rail-pop {
min-width: 210px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
.rail-pop__user {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px 10px;
}
.rail-pop__av {
width: 36px;
height: 36px;
border-radius: 9px;
overflow: hidden;
flex-shrink: 0;
background: var(--surface-ground);
display: grid;
place-items: center;
border: 1px solid var(--surface-border);
}
.rail-pop__av-img { width: 100%; height: 100%; object-fit: cover; }
.rail-pop__av-init { font-size: 0.78rem; font-weight: 700; color: var(--text-color); }
.rail-pop__name {
font-size: 0.83rem;
font-weight: 600;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-pop__email {
font-size: 0.68rem;
color: var(--text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-pop__divider {
height: 1px;
background: var(--surface-border);
margin: 2px 0;
}
</style>

246
src/layout/AppRailPanel.vue Normal file
View File

@@ -0,0 +1,246 @@
<!-- src/layout/AppRailPanel.vue Painel expansível do Layout 2 -->
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMenuStore } from '@/stores/menuStore'
import { useLayout } from './composables/layout'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
const menuStore = useMenuStore()
const { layoutState } = useLayout()
const entitlements = useEntitlementsStore()
const router = useRouter()
const route = useRoute()
// ── Seção ativa ──────────────────────────────────────────────
const currentSection = computed(() => {
const model = menuStore.model || []
return model.find(s => s.label === layoutState.railSectionKey) || null
})
// ── Items da seção (com suporte a children) ──────────────────
const sectionItems = computed(() => currentSection.value?.items || [])
function isLocked (item) {
if (!item.proBadge || !item.feature) return false
try { return !entitlements.has(item.feature) } catch { return false }
}
function isActive (item) {
const active = String(layoutState.activePath || route.path || '')
if (!item.to) return false
const p = typeof item.to === 'string' ? item.to : ''
return active === p || active.startsWith(p + '/')
}
function navigate (item) {
if (isLocked(item)) {
router.push({ name: 'upgrade', query: { feature: item.feature || '' } })
return
}
if (item.to) {
layoutState.activePath = typeof item.to === 'string' ? item.to : router.resolve(item.to).path
router.push(item.to)
}
}
function closePanel () {
layoutState.railPanelOpen = false
}
</script>
<template>
<Transition name="panel-slide">
<aside
v-if="layoutState.railPanelOpen && currentSection"
class="rp"
aria-label="Menu lateral"
>
<!-- Header -->
<div class="rp__head">
<span class="rp__title">{{ currentSection.label }}</span>
<button class="rp__close" aria-label="Fechar painel" @click="closePanel">
<i class="pi pi-times" />
</button>
</div>
<!-- Items -->
<nav class="rp__nav">
<template v-for="item in sectionItems" :key="item.to || item.label">
<!-- Item com filhos (sub-seção) -->
<div v-if="item.items?.length" class="rp__group">
<div class="rp__group-label">{{ item.label }}</div>
<button
v-for="child in item.items"
:key="child.to || child.label"
class="rp__item"
:class="{
'rp__item--active': isActive(child),
'rp__item--locked': isLocked(child)
}"
@click="navigate(child)"
>
<i v-if="child.icon" :class="child.icon" class="rp__item-icon" />
<span class="rp__item-label">{{ child.label }}</span>
<span v-if="isLocked(child)" class="rp__pro">PRO</span>
</button>
</div>
<!-- Item folha -->
<button
v-else
class="rp__item"
:class="{
'rp__item--active': isActive(item),
'rp__item--locked': isLocked(item)
}"
@click="navigate(item)"
>
<i v-if="item.icon" :class="item.icon" class="rp__item-icon" />
<span class="rp__item-label">{{ item.label }}</span>
<span v-if="isLocked(item)" class="rp__pro">PRO</span>
</button>
</template>
</nav>
</aside>
</Transition>
</template>
<style scoped>
/* ─── Panel ──────────────────────────────────────────────── */
.rp {
width: 260px;
flex-shrink: 0;
height: 100vh;
display: flex;
flex-direction: column;
border-right: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
}
/* ─── Header ─────────────────────────────────────────────── */
.rp__head {
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--surface-border);
}
.rp__title {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text-color);
}
.rp__close {
width: 28px;
height: 28px;
border-radius: 7px;
border: none;
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
display: grid;
place-items: center;
font-size: 0.75rem;
transition: background 0.15s, color 0.15s;
}
.rp__close:hover {
background: var(--surface-ground);
color: var(--text-color);
}
/* ─── Nav list ───────────────────────────────────────────── */
.rp__nav {
flex: 1;
overflow-y: auto;
padding: 10px 8px;
display: flex;
flex-direction: column;
gap: 2px;
scrollbar-width: thin;
scrollbar-color: var(--surface-border) transparent;
}
/* ─── Group ──────────────────────────────────────────────── */
.rp__group {
display: flex;
flex-direction: column;
gap: 1px;
margin-top: 12px;
}
.rp__group:first-child { margin-top: 0; }
.rp__group-label {
font-size: 0.62rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-color-secondary);
opacity: 0.55;
padding: 2px 10px 6px;
}
/* ─── Item ───────────────────────────────────────────────── */
.rp__item {
width: 100%;
display: flex;
align-items: center;
gap: 9px;
padding: 8px 10px;
border-radius: 9px;
border: none;
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
text-align: left;
font-size: 0.83rem;
font-weight: 500;
transition: background 0.13s, color 0.13s;
}
.rp__item:hover {
background: var(--surface-ground);
color: var(--text-color);
}
.rp__item--active {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
font-weight: 600;
}
.rp__item--locked {
opacity: 0.55;
}
.rp__item-icon {
font-size: 0.85rem;
flex-shrink: 0;
opacity: 0.75;
}
.rp__item-label { flex: 1; }
.rp__pro {
font-size: 0.58rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--surface-border);
color: var(--text-color-secondary);
opacity: 0.7;
}
/* ─── Slide transition ───────────────────────────────────── */
.panel-slide-enter-active,
.panel-slide-leave-active {
transition: width 0.22s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.18s ease;
overflow: hidden;
}
.panel-slide-enter-from,
.panel-slide-leave-to {
width: 0 !important;
opacity: 0;
}
</style>

View File

@@ -0,0 +1,159 @@
<!-- src/layout/AppRailTopbar.vue Topbar leve para Layout 2 (Rail) -->
<script setup>
import { computed, ref, nextTick } from 'vue'
import AppConfigurator from './AppConfigurator.vue'
import { useLayout } from '@/layout/composables/layout'
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
import { useTenantStore } from '@/stores/tenantStore'
const { toggleDarkMode, isDarkTheme } = useLayout()
const { queuePatch } = useUserSettingsPersistence()
const tenantStore = useTenantStore()
const tenantName = computed(() => {
const t =
tenantStore.activeTenant ||
tenantStore.tenant ||
tenantStore.currentTenant ||
null
return (
t?.name ||
t?.nome ||
t?.display_name ||
t?.fantasy_name ||
t?.razao_social ||
null
)
})
function isDarkNow () {
return document.documentElement.classList.contains('app-dark')
}
async function waitForDarkFlip (before, timeoutMs = 900) {
const start = performance.now()
while (performance.now() - start < timeoutMs) {
await nextTick()
await new Promise((r) => requestAnimationFrame(r))
if (isDarkNow() !== before) return isDarkNow()
}
return isDarkNow()
}
async function toggleDarkAndPersist () {
try {
const before = isDarkNow()
toggleDarkMode()
const after = await waitForDarkFlip(before)
await queuePatch({ theme_mode: after ? 'dark' : 'light' }, { flushNow: true })
} catch (e) {
console.error('[RailTopbar][theme] falhou:', e?.message || e)
}
}
</script>
<template>
<header class="rail-topbar">
<!-- Tenant pill -->
<div class="rail-topbar__left">
<span v-if="tenantName" class="rail-topbar__tenant" :title="tenantName">
{{ tenantName }}
</span>
</div>
<!-- Ações -->
<div class="rail-topbar__actions">
<!-- Dark mode -->
<button
type="button"
class="rail-topbar__btn"
:title="isDarkTheme ? 'Modo claro' : 'Modo escuro'"
@click="toggleDarkAndPersist"
>
<i :class="['pi', isDarkTheme ? 'pi-sun' : 'pi-moon']" />
</button>
<!-- Tema / paleta -->
<div class="relative">
<button
type="button"
class="rail-topbar__btn rail-topbar__btn--highlight"
title="Configurar tema"
v-styleclass="{
selector: '@next',
enterFromClass: 'hidden',
enterActiveClass: 'p-anchored-overlay-enter-active',
leaveToClass: 'hidden',
leaveActiveClass: 'p-anchored-overlay-leave-active',
hideOnOutsideClick: true
}"
>
<i class="pi pi-palette" />
</button>
<AppConfigurator />
</div>
</div>
</header>
</template>
<style scoped>
.rail-topbar {
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.25rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-card);
z-index: 20;
}
.rail-topbar__left {
display: flex;
align-items: center;
min-width: 0;
}
.rail-topbar__tenant {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 320px;
padding: 0.2rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.rail-topbar__actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.rail-topbar__btn {
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-color-secondary);
display: grid;
place-items: center;
cursor: pointer;
font-size: 1rem;
transition: background 0.15s, color 0.15s;
}
.rail-topbar__btn:hover {
background: var(--surface-ground);
color: var(--text-color);
}
.rail-topbar__btn--highlight {
color: var(--primary-color);
}
</style>

View File

@@ -1,25 +0,0 @@
<template>
<div class="layout-wrapper">
<AppTopbar @toggleMenu="toggleSidebar" />
<AppSidebar :model="menu" :visible="sidebarVisible" @hide="sidebarVisible=false" />
<div class="layout-main-container">
<div class="layout-main">
<router-view />
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useSessionStore } from '@/app/store/sessionStore'
import { getMenuByRole } from '@/navigation'
import AppTopbar from '@/components/layout/AppTopbar.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue'
const sidebarVisible = ref(true)
function toggleSidebar(){ sidebarVisible.value = !sidebarVisible.value }
const session = useSessionStore()
const menu = computed(() => getMenuByRole(session.role))
</script>

View File

@@ -1,66 +1,73 @@
<script setup>
import { useLayout } from '@/layout/composables/layout';
import { onBeforeUnmount, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import AppMenu from './AppMenu.vue';
import { useLayout } from '@/layout/composables/layout'
import { onBeforeUnmount, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import AppMenu from './AppMenu.vue'
const { layoutState, isDesktop, hasOpenOverlay } = useLayout();
const route = useRoute();
const sidebarRef = ref(null);
let outsideClickListener = null;
const { layoutState, isDesktop, hasOpenOverlay, closeMenuOnNavigate } = useLayout()
const route = useRoute()
const sidebarRef = ref(null)
let outsideClickListener = null
// ✅ rota mudou:
// - atualiza activePath sempre (desktop e mobile)
// - fecha menu SOMENTE no mobile (evita “sumir” no desktop / inconsistências)
watch(
() => route.path,
(newPath) => {
if (isDesktop()) layoutState.activePath = null;
else layoutState.activePath = newPath;
layoutState.overlayMenuActive = false;
layoutState.mobileMenuActive = false;
layoutState.menuHoverActive = false;
},
{ immediate: true }
);
() => route.path,
(newPath) => {
layoutState.activePath = newPath
closeMenuOnNavigate?.()
},
{ immediate: true }
)
// mantém o outside click só quando overlay está aberto e estamos em desktop
watch(hasOpenOverlay, (newVal) => {
if (isDesktop()) {
if (newVal) bindOutsideClickListener();
else unbindOutsideClickListener();
}
});
if (isDesktop()) {
if (newVal) bindOutsideClickListener()
else unbindOutsideClickListener()
}
})
const bindOutsideClickListener = () => {
if (!outsideClickListener) {
outsideClickListener = (event) => {
if (isOutsideClicked(event)) {
layoutState.overlayMenuActive = false;
}
};
document.addEventListener('click', outsideClickListener);
if (!outsideClickListener) {
outsideClickListener = (event) => {
if (isOutsideClicked(event)) {
layoutState.overlayMenuActive = false
}
}
};
document.addEventListener('click', outsideClickListener)
}
}
const unbindOutsideClickListener = () => {
if (outsideClickListener) {
document.removeEventListener('click', outsideClickListener);
outsideClickListener = null;
}
};
if (outsideClickListener) {
document.removeEventListener('click', outsideClickListener)
outsideClickListener = null
}
}
const isOutsideClicked = (event) => {
const topbarButtonEl = document.querySelector('.layout-menu-button');
const topbarButtonEl = document.querySelector('.layout-menu-button')
const el = sidebarRef.value
if (!el) return true
return !(sidebarRef.value.isSameNode(event.target) || sidebarRef.value.contains(event.target) || topbarButtonEl?.isSameNode(event.target) || topbarButtonEl?.contains(event.target));
};
return !(
el.isSameNode(event.target) ||
el.contains(event.target) ||
topbarButtonEl?.isSameNode(event.target) ||
topbarButtonEl?.contains(event.target)
)
}
onBeforeUnmount(() => {
unbindOutsideClickListener();
});
unbindOutsideClickListener()
})
</script>
<template>
<div ref="sidebarRef" class="layout-sidebar">
<AppMenu />
</div>
</template>
<div ref="sidebarRef" class="layout-sidebar">
<AppMenu />
</div>
</template>

View File

@@ -1,66 +1,132 @@
<!-- src/layout/AppTopbar.vue -->
<script setup>
import { computed, ref, onMounted, provide, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useLayout } from '@/layout/composables/layout'
import AppConfigurator from './AppConfigurator.vue'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useTenantStore } from '@/stores/tenantStore'
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
import { useRoleGuard } from '@/composables/useRoleGuard'
const { canSee } = useRoleGuard()
// ✅ engine central
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
import { applyThemeEngine } from '@/theme/theme.options'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
const toast = useToast()
const entitlementsStore = useEntitlementsStore()
const tenantStore = useTenantStore()
const { toggleMenu, toggleDarkMode, isDarkTheme, layoutConfig, changeMenuMode } = useLayout()
const router = useRouter()
const route = useRoute()
const planBtn = ref(null)
/* ----------------------------
Persistência (1 instância)
Persistência
----------------------------- */
const { init: initUserSettings, queuePatch } = useUserSettingsPersistence()
provide('queueUserSettingsPatch', queuePatch)
/* ----------------------------
Contexto (UID/Email/Tenant)
----------------------------- */
const sessionUid = ref(null)
const sessionEmail = ref(null)
async function loadSessionIdentity () {
try {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
sessionUid.value = data?.user?.id || null
sessionEmail.value = data?.user?.email || null
} catch (e) {
sessionUid.value = null
sessionEmail.value = null
console.warn('[Topbar][identity] falhou:', e?.message || e)
}
}
const tenantId = computed(() => tenantStore.activeTenantId || null)
// ✅ tenta achar “nome/email” da clínica do jeito mais tolerante possível
const tenantName = computed(() => {
const t =
tenantStore.activeTenant ||
tenantStore.tenant ||
tenantStore.currentTenant ||
null
return (
t?.name ||
t?.nome ||
t?.display_name ||
t?.fantasy_name ||
t?.razao_social ||
null
)
})
const tenantEmail = computed(() => {
const t =
tenantStore.activeTenant ||
tenantStore.tenant ||
tenantStore.currentTenant ||
null
return (
t?.email ||
t?.clinic_email ||
t?.contact_email ||
null
)
})
const ctxItems = computed(() => {
const items = []
if (tenantName.value) items.push({ k: 'Clínica', v: tenantName.value })
if (tenantEmail.value) items.push({ k: 'Email', v: tenantEmail.value })
// ids (sempre úteis pra debug)
if (tenantId.value) items.push({ k: 'Tenant', v: tenantId.value })
if (sessionUid.value) items.push({ k: 'UID', v: sessionUid.value })
return items
})
/* ----------------------------
Fonte da verdade: DOM
----------------------------- */
function isDarkNow() {
function isDarkNow () {
return document.documentElement.classList.contains('app-dark')
}
function setDarkMode(shouldBeDark) {
function setDarkMode (shouldBeDark) {
const now = isDarkNow()
if (shouldBeDark !== now) toggleDarkMode()
}
async function waitForDarkFlip(before, timeoutMs = 900) {
async function waitForDarkFlip (before, timeoutMs = 900) {
const start = performance.now()
while (performance.now() - start < timeoutMs) {
await nextTick()
await new Promise((r) => requestAnimationFrame(r))
const now = isDarkNow()
if (now !== before) return now
}
return isDarkNow()
}
/* ----------------------------
Bootstrap: carrega e aplica
----------------------------- */
async function loadAndApplyUserSettings() {
async function loadAndApplyUserSettings () {
try {
const { data: u, error: uErr } = await supabase.auth.getUser()
if (uErr) throw uErr
@@ -74,27 +140,27 @@ async function loadAndApplyUserSettings() {
.maybeSingle()
if (error) throw error
if (!settings) {
console.log('[Topbar][bootstrap] sem user_settings ainda')
return
}
if (!settings) return
console.log('[Topbar][bootstrap] settings=', settings)
// dark/light
// 1) dark/light (DOM é a fonte da verdade)
if (settings.theme_mode) setDarkMode(settings.theme_mode === 'dark')
// layoutConfig
// 2) layoutConfig
if (settings.preset) layoutConfig.preset = settings.preset
if (settings.primary_color) layoutConfig.primary = settings.primary_color
if (settings.surface_color) layoutConfig.surface = settings.surface_color
if (settings.menu_mode) layoutConfig.menuMode = settings.menu_mode
// aplica tema via engine única
// 3) aplica engine UMA vez
applyThemeEngine(layoutConfig)
// aplica menu mode
try { changeMenuMode() } catch (e) {
// ✅ IMPORTANTE:
// changeMenuMode NÃO é só "setar menuMode".
// Ele reseta estados do sidebar/overlay/mobile e previne drift.
try {
changeMenuMode(layoutConfig.menuMode)
} catch (e) {
try { changeMenuMode({ value: layoutConfig.menuMode }) } catch {}
console.warn('[Topbar][bootstrap] changeMenuMode falhou:', e?.message || e)
}
} catch (e) {
@@ -102,45 +168,88 @@ async function loadAndApplyUserSettings() {
}
}
/* ----------------------------
Atalho topbar: Dark/Light
----------------------------- */
async function toggleDarkAndPersistSilently() {
async function toggleDarkAndPersistSilently () {
try {
const before = isDarkNow()
console.log('[Topbar][theme] click. before=', before ? 'dark' : 'light')
toggleDarkMode()
const after = await waitForDarkFlip(before)
const theme_mode = after ? 'dark' : 'light'
console.log('[Topbar][theme] after=', theme_mode, 'isDarkTheme=', !!isDarkTheme)
await queuePatch({ theme_mode }, { flushNow: true })
console.log('[Topbar][theme] saved theme_mode=', theme_mode)
} catch (e) {
console.error('[Topbar][theme] falhou:', e?.message || e)
}
}
/* ----------------------------
Plano (teu código intacto)
Plano (DEV) — popup menu
----------------------------- */
const tenantId = computed(() => tenantStore.activeTenantId || null)
const trocandoPlano = ref(false)
async function getPlanIdByKey(planKey) {
const { data, error } = await supabase.from('plans').select('id, key').eq('key', planKey).single()
const enablePlanToggle = computed(() => {
const flag = String(import.meta.env?.VITE_ENABLE_PLAN_TOGGLE || '').toLowerCase()
return Boolean(import.meta.env?.DEV) || flag === 'true'
})
const showPlanDevMenu = computed(() => {
return canSee('settings.view') && enablePlanToggle.value
})
const planMenu = ref()
const planMenuLoading = ref(false)
const planMenuTarget = ref(null) // 'therapist' | 'clinic' | null
const planMenuSub = ref(null) // subscription ativa (obj)
const planMenuPlans = ref([]) // plans ativos do target
async function getMyUserId () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
return data.id
const uid = data?.user?.id
if (!uid) throw new Error('Sessão inválida (sem user).')
return uid
}
async function getActiveSubscriptionByTenant(tid) {
// therapist subscription: user_id — sem filtro de tenant_id (pode estar preenchido)
async function getActiveTherapistSubscription () {
const uid = await getMyUserId()
const { data, error } = await supabase
.from('subscriptions')
.select('id, tenant_id, plan_id, status, created_at, updated_at')
.select('id, tenant_id, user_id, plan_id, status, updated_at')
.eq('user_id', uid)
.order('updated_at', { ascending: false })
.limit(10)
if (error) throw error
const list = data || []
if (!list.length) return null
const priority = (st) => {
const s = String(st || '').toLowerCase()
if (s === 'active') return 1
if (s === 'trialing') return 2
if (s === 'past_due') return 3
if (s === 'unpaid') return 4
if (s === 'incomplete') return 5
if (s === 'canceled' || s === 'cancelled') return 9
return 8
}
return list.slice().sort((a, b) => {
const pa = priority(a?.status)
const pb = priority(b?.status)
if (pa !== pb) return pa - pb
return new Date(b?.updated_at || 0) - new Date(a?.updated_at || 0)
})[0]
}
async function getActiveClinicSubscription () {
const tid = tenantId.value
if (!tid) return null
const { data, error } = await supabase
.from('subscriptions')
.select('id, tenant_id, user_id, plan_id, status, updated_at')
.eq('tenant_id', tid)
.eq('status', 'active')
.order('updated_at', { ascending: false })
@@ -151,69 +260,273 @@ async function getActiveSubscriptionByTenant(tid) {
return data || null
}
async function getPlanKeyById(planId) {
const { data, error } = await supabase.from('plans').select('key').eq('id', planId).single()
async function listActivePlansByTarget (target) {
const { data, error } = await supabase
.from('plans')
.select('id, key, target, is_active')
.eq('target', target)
.eq('is_active', true)
.order('key', { ascending: true })
if (error) throw error
return data.key
return data || []
}
async function alternarPlano() {
async function refreshEntitlementsAfterToggle (target) {
// ✅ aqui NÃO dá pra usar invalidate geral, porque precisamos dos dois caches
// mas durante toggle, é mais seguro forçar recarga do escopo que foi alterado.
if (target === 'clinic') {
const tid = tenantId.value
if (!tid) return
await entitlementsStore.loadForTenant(tid, { force: true })
return
}
// therapist
const uid = await getMyUserId()
await entitlementsStore.loadForUser(uid, { force: true })
}
/**
* ✅ Resolve a subscription ativa levando em conta a área da rota atual.
*
* Áreas de clínica (/admin, /supervisor) → contexto tenant_id (clinic)
* Demais áreas (/therapist, /editor, /portal, etc.) → contexto user_id (pessoal)
*
* Isso evita que um editor que também é membro de uma clínica veja o plano
* da clínica no botão DEV em vez do seu próprio plano.
*/
async function resolveActiveSubscriptionContext () {
const path = route.path || ''
const isClinicContext =
path.startsWith('/admin') ||
path.startsWith('/supervisor')
if (isClinicContext && tenantId.value) {
const clinicSub = await getActiveClinicSubscription()
if (clinicSub) return { sub: clinicSub, target: 'clinic' }
}
const therapistSub = await getActiveTherapistSubscription()
if (therapistSub) return { sub: therapistSub, target: 'therapist' }
// último fallback: clinic (caso não-clínica sem sub pessoal)
if (tenantId.value) {
const clinicSub = await getActiveClinicSubscription()
return { sub: clinicSub || null, target: clinicSub ? 'clinic' : null }
}
return { sub: null, target: null }
}
function normalizeKey (k) {
return String(k || '').trim()
}
// free primeiro, depois o resto por key
function sortPlansSmart (plans) {
const arr = [...(plans || [])]
arr.sort((a, b) => {
const ak = normalizeKey(a?.key).toLowerCase()
const bk = normalizeKey(b?.key).toLowerCase()
const aIsFree = ak.endsWith('_free') || ak === 'free'
const bIsFree = bk.endsWith('_free') || bk === 'free'
if (aIsFree && !bIsFree) return -1
if (!aIsFree && bIsFree) return 1
return ak.localeCompare(bk)
})
return arr
}
async function loadPlanMenuData () {
planMenuLoading.value = true
try {
const { sub, target } = await resolveActiveSubscriptionContext()
planMenuSub.value = sub
planMenuTarget.value = target
if (!sub?.id || !target) {
planMenuPlans.value = []
return
}
const plans = await listActivePlansByTarget(target)
planMenuPlans.value = sortPlansSmart(plans)
} finally {
planMenuLoading.value = false
}
}
const planMenuModel = computed(() => {
const sub = planMenuSub.value
const target = planMenuTarget.value
const plans = planMenuPlans.value || []
if (!sub?.id || !target) {
return [
{ label: 'Sem assinatura ativa', icon: 'pi pi-exclamation-triangle', disabled: true },
{ label: 'Não encontrei subscription ativa nem para therapist (user_id) nem para clinic (tenant_id).', disabled: true }
]
}
const currentPlanId = String(sub.plan_id || '')
const header = {
label: `Planos (${target})`,
icon: target === 'therapist' ? 'pi pi-user' : 'pi pi-building',
disabled: true
}
const subInfo = {
label: `Sub: ${String(sub.id).slice(0, 8)}… • Atual: ${String(currentPlanId).slice(0, 8)}`,
icon: 'pi pi-info-circle',
disabled: true
}
const items = []
let insertedSeparator = false
plans.forEach((p) => {
const isCurrent = String(p.id) === currentPlanId
const keyLower = String(p.key || '').toLowerCase()
const isFree = keyLower.endsWith('_free') || keyLower === 'free'
items.push({
label: isCurrent ? `${p.key} (atual)` : p.key,
icon: isCurrent ? 'pi pi-check' : (isFree ? 'pi pi-star' : 'pi pi-circle'),
disabled: isCurrent || planMenuLoading.value || trocandoPlano.value,
command: async () => {
await changePlanTo(p.id, p.key, target)
}
})
if (!insertedSeparator && isFree) {
items.push({ separator: true })
insertedSeparator = true
}
})
if (items.length && items[items.length - 1]?.separator) items.pop()
if (!plans.length) {
return [header, subInfo, { separator: true }, { label: 'Nenhum plano ativo encontrado', icon: 'pi pi-info-circle', disabled: true }]
}
return [header, subInfo, { separator: true }, ...items]
})
async function openPlanMenu (event) {
if (!showPlanDevMenu.value) return
try {
await loadPlanMenuData()
} catch (err) {
console.error('[PLANO][DEV menu] erro:', err?.message || err)
toast.add({
severity: 'error',
summary: 'Erro ao carregar planos',
detail: err?.message || 'Falha desconhecida.',
life: 5200
})
}
const anchorEl = planBtn.value?.$el || event?.currentTarget || event?.target
if (!anchorEl) {
planMenu.value?.toggle?.(event)
return
}
planMenu.value?.show?.({ currentTarget: anchorEl })
}
async function changePlanTo (newPlanId, newPlanKey, target) {
if (trocandoPlano.value) return
trocandoPlano.value = true
try {
const tid = tenantId.value
if (!tid) {
toast.add({ severity: 'warn', summary: 'Sem tenant ativo', detail: 'Selecione/entre em uma clínica (tenant) antes de trocar o plano.', life: 4500 })
return
}
const sub = await getActiveSubscriptionByTenant(tid)
if (!sub?.id) {
toast.add({ severity: 'warn', summary: 'Sem assinatura ativa', detail: 'Esse tenant ainda não tem subscription ativa. Ative via intenção/pagamento manual.', life: 5000 })
return
}
const atualKey = await getPlanKeyById(sub.plan_id)
const novoKey = atualKey === 'pro' ? 'free' : 'pro'
const novoPlanId = await getPlanIdByKey(novoKey)
const sub = planMenuSub.value
if (!sub?.id) throw new Error('Subscription inválida.')
const { error: rpcError } = await supabase.rpc('change_subscription_plan', {
p_subscription_id: sub.id,
p_new_plan_id: novoPlanId
p_new_plan_id: newPlanId
})
if (rpcError) throw rpcError
entitlementsStore.clear?.()
await entitlementsStore.fetch(tid, { force: true })
planMenuSub.value = { ...sub, plan_id: newPlanId }
toast.add({ severity: 'success', summary: 'Plano alternado', detail: `${String(atualKey).toUpperCase()}${String(novoKey).toUpperCase()}`, life: 3000 })
// ✅ recarrega o escopo certo (tenant ou user)
await refreshEntitlementsAfterToggle(target)
toast.add({
severity: 'success',
summary: 'Plano alterado (DEV)',
detail: `${String(newPlanKey).toUpperCase()} aplicado (${target})`,
life: 3200
})
} catch (err) {
console.error('[PLANO] Erro ao alternar:', err?.message || err)
toast.add({ severity: 'error', summary: 'Erro ao alternar plano', detail: err?.message || 'Falha desconhecida.', life: 5000 })
console.error('[PLANO] Erro ao trocar:', err?.message || err)
toast.add({
severity: 'error',
summary: 'Erro ao trocar plano',
detail: err?.message || 'Falha desconhecida.',
life: 6000
})
} finally {
trocandoPlano.value = false
}
}
async function logout() {
/* ----------------------------
Logout
----------------------------- */
async function logout () {
const tenant = useTenantStore()
const ent = useEntitlementsStore()
const tf = useTenantFeaturesStore()
try {
await supabase.auth.signOut()
} finally {
// limpa possíveis intenções guardadas
sessionStorage.removeItem('redirect_after_login')
sessionStorage.removeItem('intended_area')
tenant.reset()
ent.invalidate()
tf.invalidate()
// ✅ vai para HomeCards
router.replace('/')
// Use router.replace('/') e não push,
// assim o usuário não consegue voltar com o botão "voltar" para uma rota protegida.
sessionStorage.clear()
localStorage.clear()
router.replace('/auth/login')
}
}
/**
* ✅ Bootstrap entitlements (resolve “menu não alterna” sem depender do guard)
* - se tem tenant ativo => carrega tenant entitlements
* - senão => carrega user entitlements
*/
async function bootstrapEntitlements () {
try {
const uid = sessionUid.value || (await getMyUserId())
const tid = tenantId.value
if (tid) {
await entitlementsStore.loadForTenant(tid, { force: false, maxAgeMs: 60_000 })
} else if (uid) {
await entitlementsStore.loadForUser(uid, { force: false, maxAgeMs: 60_000 })
}
} catch (e) {
console.warn('[Topbar][entitlements bootstrap] falhou:', e?.message || e)
}
}
onMounted(async () => {
await initUserSettings()
await loadAndApplyUserSettings()
await loadSessionIdentity()
await bootstrapEntitlements()
})
</script>
@@ -228,10 +541,24 @@ onMounted(async () => {
<router-link to="/" class="layout-topbar-logo">
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- ... SVG gigante ... -->
<!-- ... SVG ... -->
</svg>
<span>SAKAI</span>
</router-link>
<div v-if="ctxItems.length" class="topbar-ctx">
<div class="topbar-ctx-row">
<span
v-for="(it, idx) in ctxItems"
:key="`${it.k}-${idx}`"
class="topbar-ctx-pill"
:title="`${it.k}: ${it.v}`"
>
<b class="topbar-ctx-k">{{ it.k }}:</b>
<span class="topbar-ctx-v">{{ it.v }}</span>
</span>
</div>
</div>
</div>
<div class="layout-topbar-actions">
@@ -277,13 +604,23 @@ onMounted(async () => {
<div class="layout-topbar-menu hidden lg:block">
<div class="layout-topbar-menu-content">
<Button
label="Plano"
icon="pi pi-sync"
v-if="showPlanDevMenu"
ref="planBtn"
label="Plano (DEV)"
icon="pi pi-sliders-h"
severity="contrast"
outlined
:loading="trocandoPlano"
:disabled="trocandoPlano"
@click="alternarPlano"
:loading="planMenuLoading || trocandoPlano"
:disabled="planMenuLoading || trocandoPlano"
@click="openPlanMenu"
/>
<Menu
ref="planMenu"
:model="planMenuModel"
popup
appendTo="body"
:baseZIndex="3000"
/>
<button type="button" class="layout-topbar-action">
@@ -309,3 +646,46 @@ onMounted(async () => {
</div>
</div>
</template>
<style scoped>
.topbar-ctx {
display: flex;
align-items: center;
margin-left: 0.75rem;
max-width: min(62vw, 980px);
}
.topbar-ctx-row {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.topbar-ctx-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.45rem;
border-radius: 999px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
max-width: 320px;
}
.topbar-ctx-k {
font-size: 0.75rem;
opacity: 0.7;
white-space: nowrap;
}
.topbar-ctx-v {
font-size: 0.75rem;
opacity: 0.95;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 240px;
}
</style>

View File

@@ -0,0 +1,172 @@
<!-- src/layout/ConfiguracoesPage.vue -->
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const secoes = [
{
key: 'agenda',
label: 'Agenda',
desc: 'Horários semanais, exceções, duração e intervalo padrão.',
icon: 'pi pi-calendar',
to: '/configuracoes/agenda',
tags: ['Horários', 'Exceções', 'Duração']
},
// Ative quando criar as rotas/páginas
// {
// key: 'clinica',
// label: 'Clínica',
// desc: 'Padrões clínicos, status e preferências de atendimento.',
// icon: 'pi pi-heart',
// to: '/configuracoes/clinica',
// tags: ['Status', 'Modelos', 'Preferências']
// },
// {
// key: 'intake',
// label: 'Cadastros & Intake',
// desc: 'Link externo, campos do formulário e mensagens padrão.',
// icon: 'pi pi-file-edit',
// to: '/configuracoes/intake',
// tags: ['Formulário', 'Campos', 'Textos']
// },
// {
// key: 'conta',
// label: 'Conta',
// desc: 'Perfil, segurança e preferências da conta.',
// icon: 'pi pi-user',
// to: '/configuracoes/conta',
// tags: ['Perfil', 'Segurança', 'Preferências']
// }
]
const activeTo = computed(() => {
const p = route.path || ''
const hit = secoes.find(s => p.startsWith(s.to))
return hit?.to || '/configuracoes/agenda'
})
function ir(to) {
if (!to) return
if (route.path !== to) router.push(to)
}
</script>
<template>
<div class="p-4">
<!-- HEADER CONCEITUAL -->
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute bottom-0 right-24 h-44 w-44 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-900 text-2xl font-semibold leading-none">Configurações</div>
<div class="text-600 mt-2 max-w-2xl">
Defina como sua clínica funciona: agenda, cadastros e preferências. Tudo no mesmo lugar sem espalhar opções pelo sistema.
</div>
</div>
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
class="hidden md:inline-flex"
@click="router.back()"
/>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-12 gap-4">
<!-- SIDEBAR (seções) -->
<div class="col-span-12 lg:col-span-4 xl:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-cog" />
<span>Seções</span>
</div>
</template>
<template #content>
<div class="flex flex-col gap-2">
<button
v-for="s in secoes"
:key="s.key"
type="button"
class="w-full text-left p-3 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] hover:bg-[var(--surface-hover)] transition flex items-start justify-between gap-3"
:class="activeTo === s.to ? 'ring-1 ring-primary/40 border-primary/40' : ''"
@click="ir(s.to)"
>
<div class="flex gap-3">
<div class="mt-1">
<i :class="[s.icon, 'text-lg']" style="opacity:.85" />
</div>
<div>
<div class="text-900 font-medium leading-none">{{ s.label }}</div>
<div class="text-600 text-sm mt-2 leading-snug">{{ s.desc }}</div>
<div v-if="s.tags?.length" class="mt-3 flex flex-wrap gap-2">
<span
v-for="t in s.tags"
:key="t"
class="text-xs px-2 py-1 rounded-full border border-[var(--surface-border)] text-600"
>
{{ t }}
</span>
</div>
</div>
</div>
<i class="pi pi-angle-right mt-1" style="opacity:.55" />
</button>
<Divider class="my-2" />
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
class="w-full md:hidden"
@click="router.back()"
/>
</div>
</template>
</Card>
<!-- Card pequeno atalhos opcional -->
<div class="mt-4 hidden lg:block">
<Card>
<template #content>
<div class="text-900 font-medium">Dica</div>
<div class="text-600 text-sm mt-2 leading-relaxed">
Comece pela <b>Agenda</b>. É ela que tempo ao prontuário: sessão marcada sessão realizada evolução.
</div>
</template>
</Card>
</div>
</div>
<!-- CONTEÚDO (seção selecionada) -->
<div class="col-span-12 lg:col-span-8 xl:col-span-9">
<!-- Aqui entra /configuracoes/agenda etc -->
<router-view />
</div>
</div>
</div>
</template>

View File

@@ -1,15 +1,17 @@
<!-- src/layout/ConfiguracoesPage.vue -->
<script setup>
import { computed } from 'vue'
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
const route = useRoute()
const router = useRouter()
// ── Hero sticky ────────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
const secoes = [
{
key: 'agenda',
@@ -57,41 +59,51 @@ function ir(to) {
if (!to) return
if (route.path !== to) router.push(to)
}
onMounted(() => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
})
onBeforeUnmount(() => { _observer?.disconnect() })
</script>
<template>
<div class="p-4">
<!-- HEADER CONCEITUAL -->
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute bottom-0 right-24 h-44 w-44 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<!-- Sentinel -->
<div ref="headerSentinelRef" class="cfg-sentinel" />
<div class="relative">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-900 text-2xl font-semibold leading-none">Configurações</div>
<div class="text-600 mt-2 max-w-2xl">
Defina como sua clínica funciona: agenda, cadastros e preferências. Tudo no mesmo lugar sem espalhar opções pelo sistema.
</div>
</div>
<!-- Hero sticky -->
<div ref="headerEl" class="cfg-hero mb-4" :class="{ 'cfg-hero--stuck': headerStuck }">
<div class="cfg-hero__blobs" aria-hidden="true">
<div class="cfg-hero__blob cfg-hero__blob--1" />
<div class="cfg-hero__blob cfg-hero__blob--2" />
<div class="cfg-hero__blob cfg-hero__blob--3" />
</div>
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
class="hidden md:inline-flex"
@click="router.back()"
/>
</div>
<div class="cfg-hero__row1">
<div class="cfg-hero__brand">
<div class="cfg-hero__icon"><i class="pi pi-cog text-lg" /></div>
<div class="min-w-0">
<div class="cfg-hero__title">Configurações</div>
<div class="cfg-hero__sub">Defina como sua agenda e clínica funcionam</div>
</div>
</div>
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" @click="router.back()" />
</div>
<div class="flex xl:hidden items-center shrink-0">
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="router.back()" />
</div>
</div>
</div>
<div class="pt-0">
<div class="grid grid-cols-12 gap-4">
<!-- SIDEBAR (seções) -->
@@ -151,18 +163,6 @@ function ir(to) {
</div>
</template>
</Card>
<!-- Card pequeno atalhos opcional -->
<div class="mt-4 hidden lg:block">
<Card>
<template #content>
<div class="text-900 font-medium">Dica</div>
<div class="text-600 text-sm mt-2 leading-relaxed">
Comece pela <b>Agenda</b>. É ela que tempo ao prontuário: sessão marcada sessão realizada evolução.
</div>
</template>
</Card>
</div>
</div>
<!-- CONTEÚDO (seção selecionada) -->
@@ -173,3 +173,45 @@ function ir(to) {
</div>
</div>
</template>
<style scoped>
.cfg-sentinel { height: 1px; }
.cfg-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.cfg-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
.cfg-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.cfg-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.cfg-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
.cfg-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
.cfg-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 8rem; background: rgba(217,70,239,0.07); }
.cfg-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.cfg-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.cfg-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.cfg-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.cfg-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
</style>

View File

@@ -1,7 +0,0 @@
<template>
<AppShellLayout />
</template>
<script setup>
import AppShellLayout from './AppShellLayout.vue'
</script>

View File

@@ -1,7 +0,0 @@
<template>
<AppShellLayout />
</template>
<script setup>
import AppShellLayout from './AppShellLayout.vue'
</script>

View File

@@ -0,0 +1,4 @@
<template><AppLayout area="admin" /></template>
<script setup>
import AppLayout from '../AppLayout.vue'
</script>

View File

@@ -0,0 +1,4 @@
<template><AppLayout area="portal" /></template>
<script setup>
import AppLayout from '../AppLayout.vue'
</script>

View File

@@ -0,0 +1,4 @@
<template><AppLayout area="therapist" /></template>
<script setup>
import AppLayout from '../AppLayout.vue'
</script>

View File

@@ -1,38 +1,76 @@
import { computed, reactive } from 'vue'
// ── resolve variant salvo no localStorage ───────────────────
function _loadVariant () {
try {
const v = localStorage.getItem('layout_variant')
if (v === 'rail' || v === 'classic') return v
} catch {}
return 'classic'
}
const layoutConfig = reactive({
preset: 'Aura',
primary: 'emerald',
surface: null,
darkTheme: false,
menuMode: 'static'
menuMode: 'static',
variant: _loadVariant() // 'classic' | 'rail'
})
const layoutState = reactive({
staticMenuInactive: false,
overlayMenuActive: false,
mobileMenuActive: false, // ✅ ADICIONA (estava faltando)
mobileMenuActive: false,
profileSidebarVisible: false,
configSidebarVisible: false,
sidebarExpanded: false,
menuHoverActive: false,
anchored: false,
activeMenuItem: null,
activePath: null
activePath: null,
// ── Layout 2 (rail) ─────────────────────────────────────
railSectionKey: null, // qual seção está ativa no rail
railPanelOpen: false // painel lateral expandido
})
/**
* ✅ Fonte da verdade do dark:
* - DOM class: .app-dark (usado pelo PrimeUI/PrimeVue)
* - layoutConfig.darkTheme: refletir o DOM (pra UI reagir)
*
* Motivo: você aplica tema cedo (main.js / user_settings) e depois
* usa o composable em páginas/Topbar/Configurator. Se não sincronizar,
* isDarkTheme pode ficar “mentindo”.
*/
let _syncedDarkFromDomOnce = false
function syncDarkFromDomOnce () {
if (_syncedDarkFromDomOnce) return
_syncedDarkFromDomOnce = true
try {
layoutConfig.darkTheme = document.documentElement.classList.contains('app-dark')
} catch {}
}
export function useLayout () {
// ✅ garante coerência sempre que alguém usar useLayout()
syncDarkFromDomOnce()
const executeDarkModeToggle = () => {
layoutConfig.darkTheme = !layoutConfig.darkTheme
// ✅ garante consistência (não depende do estado anterior do DOM)
document.documentElement.classList.toggle('app-dark', layoutConfig.darkTheme)
}
const toggleDarkMode = () => {
if (!document.startViewTransition) {
executeDarkModeToggle()
return
}
document.startViewTransition(() => executeDarkModeToggle(event))
}
const executeDarkModeToggle = () => {
layoutConfig.darkTheme = !layoutConfig.darkTheme
document.documentElement.classList.toggle('app-dark')
// ✅ não usa "event" (undefined) e mantém transição suave quando suportado
document.startViewTransition(() => executeDarkModeToggle())
}
const isDesktop = () => window.innerWidth > 991
@@ -57,6 +95,8 @@ export function useLayout () {
const hideMobileMenu = () => {
layoutState.mobileMenuActive = false
layoutState.overlayMenuActive = false
layoutState.menuHoverActive = false
}
// ✅ use isso ao navegar: mantém menu aberto no desktop, fecha só no mobile
@@ -68,15 +108,41 @@ export function useLayout () {
}
}
const changeMenuMode = (event) => {
layoutConfig.menuMode = event.value
/**
* ✅ aceita:
* - changeMenuMode({ value: 'static' })
* - changeMenuMode('static')
*
* Motivo: você chama isso de lugares diferentes (Topbar, Configurator, Profile).
*/
const changeMenuMode = (eventOrValue) => {
const nextMode = typeof eventOrValue === 'string'
? eventOrValue
: eventOrValue?.value
// ✅ não deixa setar undefined / vazio
if (!nextMode) return
layoutConfig.menuMode = nextMode
// ✅ reset consistente (evita drift quando alterna overlay/static)
layoutState.staticMenuInactive = false
layoutState.overlayMenuActive = false
layoutState.mobileMenuActive = false
layoutState.sidebarExpanded = false
layoutState.menuHoverActive = false
layoutState.anchored = false
}
const setVariant = (v) => {
if (v !== 'classic' && v !== 'rail') return
layoutConfig.variant = v
try { localStorage.setItem('layout_variant', v) } catch {}
// reset rail state ao trocar
layoutState.railSectionKey = null
layoutState.railPanelOpen = false
}
const isDarkTheme = computed(() => layoutConfig.darkTheme)
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive)
@@ -88,9 +154,10 @@ export function useLayout () {
toggleConfigSidebar,
toggleMenu,
hideMobileMenu,
closeMenuOnNavigate, // ✅ exporta
closeMenuOnNavigate,
changeMenuMode,
setVariant,
isDesktop,
hasOpenOverlay
}
}
}

View File

@@ -0,0 +1,272 @@
<!-- src/layout/concepcoes/ex-header-conceitual.vue -->
<!-- ===========================================================
TEMPLATE DE REFERÊNCIA Hero Header Sticky
Padrão utilizado em: AgendaTerapeutaPage, ProfilePage
===========================================================
ESTRUTURA GERAL
1. Sentinel (div 1px) + IntersectionObserver detecta quando o header
"cola" no topo da viewport e ativa a classe --stuck.
2. Hero div com position:sticky, top: var(--layout-sticky-top, 56px).
Layout 1 (classic): 56px (topbar fixed). Layout 2 (rail): 0px (topbar no fluxo).
3. Dois estados:
- Expandido : blobs decorativos visíveis, subtítulo, filtros e busca
- Colado : comprimido (max-height), apenas brand + ações essenciais
4. Responsividade:
- 1200px : todos os controles inline (ag-hero__desktop-controls)
- <1200px : botão "Ações" abre Menu popup (ag-hero__mobile-controls)
O menu mobile DEVE incluir "Buscar" abrindo um Dialog com input + resultados.
SCRIPT (refs + onMounted)
const headerSentinelRef = ref(null)
const headerEl = ref(null)
const headerStuck = ref(false)
const headerMenuRef = ref(null)
const headerMenuItems = computed(() => [
{ label: 'Ação principal', icon: 'pi pi-plus', command: () => acaoPrincipal() },
{ label: 'Buscar', icon: 'pi pi-search', command: () => { searchModalOpen.value = true } },
{ separator: true },
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => refetch() },
{ label: 'Configurações', icon: 'pi pi-cog', command: () => goSettings() },
])
onMounted(() => {
if (headerSentinelRef.value) {
const io = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px` }
)
io.observe(headerSentinelRef.value)
}
})
CSS (scoped)
Ver seção <style> ao final deste arquivo.
BUSCA MOBILE
O Dialog de busca deve ter o InputText DENTRO do dialog (não resultados),
com autofocus, compartilhando o mesmo v-model="search" do header desktop.
Estados: sem texto instrução | buscando loading | sem resultado | lista.
=========================================================== -->
<!-- SENTINEL -->
<div ref="headerSentinelRef" class="pg-sentinel" />
<!-- HERO HEADER -->
<div ref="headerEl" class="pg-hero mb-4" :class="{ 'pg-hero--stuck': headerStuck }">
<!-- Blobs decorativos (some automaticamente quando colado via overflow:hidden) -->
<div class="pg-hero__blobs" aria-hidden="true">
<div class="pg-hero__blob pg-hero__blob--1" />
<div class="pg-hero__blob pg-hero__blob--2" />
<div class="pg-hero__blob pg-hero__blob--3" />
</div>
<!-- Linha 1: brand + controles -->
<div class="pg-hero__row1">
<!-- Brand: ícone + título + subtítulo (some quando colado) -->
<div class="pg-hero__brand">
<div class="pg-hero__icon">
<i class="pi pi-ICON_AQUI text-lg" />
</div>
<div class="min-w-0">
<div class="pg-hero__title">Título da Página</div>
<div v-if="!headerStuck" class="pg-hero__sub">Subtítulo ou data/contexto atual</div>
</div>
</div>
<!-- Controles desktop (1200px) -->
<div class="pg-hero__desktop-controls">
<!-- Grupo de busca (oculto quando colado) -->
<div v-if="!headerStuck" class="w-[260px]">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="search" class="w-full" autocomplete="off" @keyup.enter="searchModalOpen = true" />
</IconField>
<label>Buscar</label>
</FloatLabel>
</div>
<!-- Ações secundárias (ocultas quando colado, ex: filtros contextuais) -->
<div v-if="!headerStuck" class="flex items-center gap-2">
<!-- SplitButton, Dropdown, etc. -->
</div>
<!-- Ações primárias (sempre visíveis) -->
<div class="flex items-center gap-1">
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" title="Ação principal" @click="acaoPrincipal" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recarregar" @click="refetch" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="goSettings" />
</div>
</div>
<!-- Botão mobile (<1200px) -->
<div class="pg-hero__mobile-controls">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full"
@click="(e) => headerMenuRef.toggle(e)" />
<Menu ref="headerMenuRef" :model="headerMenuItems" :popup="true" />
</div>
</div>
<!-- Linha 2: filtros/KPIs oculta quando colado -->
<div v-if="!headerStuck" class="pg-hero__row2">
<div class="flex flex-wrap items-center gap-2">
<!-- SelectButtons, Tags de filtro, KPIs clicáveis, etc. -->
<Button class="!rounded-full" outlined severity="secondary">
<span class="flex items-center gap-2">
<i class="pi pi-list" /> Total: <b>{{ total }}</b>
</span>
</Button>
</div>
<!-- Chips de filtros ativos + limpar -->
<div v-if="hasActiveFilters" class="flex items-center gap-2">
<Tag value="Filtro ativo" severity="secondary" />
<Button label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined size="small" class="!rounded-full" @click="clearFilters" />
</div>
</div>
</div>
<!-- DIALOG DE BUSCA (mobile + desktop) -->
<!--
REGRA: o InputText de busca FICA DENTRO do dialog.
Isso garante boa UX no mobile (teclado não cobre resultados).
O v-model="search" é o mesmo do header desktop resultados sincronizados.
-->
<Dialog
v-model:visible="searchModalOpen"
modal
header="Buscar"
:style="{ width: '96vw', maxWidth: '720px' }"
:breakpoints="{ '960px': '92vw', '640px': '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-3">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="search" class="w-full" autocomplete="off" autofocus />
</IconField>
<label>Nome, e-mail, título</label>
</FloatLabel>
<Divider class="my-0" />
<div v-if="!searchTrim" class="text-color-secondary text-sm py-2">
Digite para buscar.
</div>
<div v-else-if="searchLoading" class="text-color-secondary text-sm">Buscando</div>
<div v-else-if="!searchResults.length" class="text-color-secondary text-sm">
Nenhum resultado para "<b>{{ searchTrim }}</b>".
</div>
<div v-else class="flex flex-col gap-2 max-h-[60vh] overflow-auto pr-1">
<div class="text-xs text-color-secondary mb-1">{{ searchResults.length }} resultado(s)</div>
<button
v-for="r in searchResults" :key="r.id"
class="text-left rounded-2xl border border-[var(--surface-border)] p-3 transition hover:shadow-sm"
@click="gotoResultFromModal(r)"
>
<div class="font-medium truncate">{{ r.titulo || r.nome }}</div>
<div class="mt-1 text-xs opacity-70 truncate">{{ r.subtitulo }}</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="search = ''; searchModalOpen = false" />
</template>
</Dialog>
<!-- ══════════════════════════════════════════════════════════
CSS DE REFERÊNCIA (copiar para <style scoped> da página)
Prefixo: "pg-" substitua pelo prefixo da página (ag-, prof-, etc.)
-->
<style scoped>
/* Sentinel */
.pg-sentinel { height: 1px; }
/* Hero base */
.pg-hero {
position: sticky;
top: var(--layout-sticky-top, 56px); /* 56px Layout1 / 0px Layout2 (Rail) */
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
max-height: 600px;
}
/* Estado colado */
.pg-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
max-height: 64px;
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
/* Blobs decorativos */
.pg-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.pg-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.pg-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.12); }
.pg-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(52,211,153,0.09); }
.pg-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 22%; background: rgba(217,70,239,0.08); }
/* Linha 1 */
.pg-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.pg-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex-shrink: 0; min-width: 0;
}
.pg-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.pg-hero__title {
font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em;
color: var(--text-color); white-space: nowrap;
}
.pg-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pg-hero__desktop-controls {
flex: 1; display: flex; align-items: center;
justify-content: flex-end; gap: 0.75rem; flex-wrap: wrap;
}
.pg-hero__mobile-controls { display: none; }
/* Linha 2 */
.pg-hero__row2 {
position: relative; z-index: 1;
display: flex; flex-wrap: wrap; align-items: center;
justify-content: space-between; gap: 0.75rem;
margin-top: 0.875rem; padding-top: 0.75rem;
border-top: 1px solid var(--surface-border);
}
/* Mobile < 1200px */
@media (max-width: 1199px) {
.pg-hero__desktop-controls { display: none; }
.pg-hero__mobile-controls { display: flex; margin-left: auto; }
.pg-hero__row2 { display: none; }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -9,12 +9,44 @@ import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
// ── Componentes PrimeVue globais (≥ 10 usos no projeto) ──────────────────────
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Tag from 'primevue/tag'
import FloatLabel from 'primevue/floatlabel'
import Toast from 'primevue/toast'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import Divider from 'primevue/divider'
import Card from 'primevue/card'
import SelectButton from 'primevue/selectbutton'
import Dialog from 'primevue/dialog'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import ConfirmDialog from 'primevue/confirmdialog'
import Menu from 'primevue/menu'
// ─────────────────────────────────────────────────────────────────────────────
import '@/assets/tailwind.css'
import '@/assets/styles.scss'
import { supabase } from '@/lib/supabase/client'
async function applyUserThemeEarly() {
// ✅ pt-BR (PrimeVue locale global)
const ptBR = {
firstDayOfWeek: 1,
dayNames: ['domingo','segunda-feira','terça-feira','quarta-feira','quinta-feira','sexta-feira','sábado'],
dayNamesShort: ['dom','seg','ter','qua','qui','sex','sáb'],
dayNamesMin: ['D','S','T','Q','Q','S','S'],
monthNames: ['janeiro','fevereiro','março','abril','maio','junho','julho','agosto','setembro','outubro','novembro','dezembro'],
monthNamesShort: ['jan','fev','mar','abr','mai','jun','jul','ago','set','out','nov','dez'],
today: 'Hoje',
clear: 'Limpar',
weekHeader: 'Sm',
dateFormat: 'dd/mm/yy'
}
async function applyUserThemeEarly () {
try {
const { data } = await supabase.auth.getUser()
const user = data?.user
@@ -34,7 +66,6 @@ async function applyUserThemeEarly() {
const root = document.documentElement
root.classList.toggle('app-dark', isDark)
// opcional: marca em storage pra teu layout composable ler depois
localStorage.setItem('ui_theme_mode', settings.theme_mode)
} catch {}
}
@@ -49,39 +80,69 @@ window.__fromVisibilityRefresh = false
window.__appBootstrapped = false
// ========================================
// 🛟 ao voltar da aba: refresh leve (sem concorrência + com flag global)
let refreshing = false
let refreshTimer = null
// 🛟 ao voltar da aba: refresh leve, sem martelar e sem rodar antes do app subir
let lastVisibilityRefreshAt = 0
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState !== 'visible') return
// só depois do app montar (evita refresh no meio do bootstrap)
if (!window.__appBootstrapped) return
const now = Date.now()
// evita martelar: no máximo 1 refresh a cada 10s
// no máximo 1 refresh a cada 10s
if (now - lastVisibilityRefreshAt < 10_000) return
// se já tem refresh em andamento, não entra
if (window.__sessionRefreshing) return
// (opcional) se não houver user, não precisa refresh
try {
const { data } = await supabase.auth.getUser()
if (!data?.user) return
} catch {
// se falhar getUser, deixa tentar refreshSession mesmo assim
}
lastVisibilityRefreshAt = now
console.log('[VISIBILITY] Aba voltou -> refreshSession()')
try {
window.__sessionRefreshing = true
window.__fromVisibilityRefresh = true
await refreshSession()
// 🔔 avisa o app inteiro SOMENTE em áreas TENANT.
// Portal (/portal) e área global (/account) NÃO devem rehidratar tenantStore/menu.
try {
const path = router.currentRoute.value?.path || ''
const isTenantArea =
path.startsWith('/admin') ||
path.startsWith('/therapist') ||
path.startsWith('/saas')
if (isTenantArea) {
window.dispatchEvent(
new CustomEvent('app:session-refreshed', { detail: { source: 'visibility' } })
)
} else {
console.log('[VISIBILITY] refresh ok (skip event) - area não-tenant:', path)
}
} catch {
// se algo der errado, não dispare evento global por segurança
}
} finally {
window.__fromVisibilityRefresh = false
window.__sessionRefreshing = false
}
})
async function bootstrap () {
await initSession({ initial: true })
listenAuthChanges()
await applyUserThemeEarly()
const app = createApp(App)
@@ -94,19 +155,39 @@ async function bootstrap () {
// ✅ garante router pronto antes de montar
await router.isReady()
// ✅ PrimeVue global config (tema + locale pt-BR)
app.use(PrimeVue, {
locale: ptBR, // 🔥 isso traduz Calendar/DatePicker
theme: {
preset: Aura,
options: { darkModeSelector: '.app-dark' }
}
})
app.use(ToastService)
app.use(ConfirmationService)
// Registro global de componentes PrimeVue frequentes
app.component('Button', Button)
app.component('InputText', InputText)
app.component('Tag', Tag)
app.component('FloatLabel', FloatLabel)
app.component('Toast', Toast)
app.component('IconField', IconField)
app.component('InputIcon', InputIcon)
app.component('Divider', Divider)
app.component('Card', Card)
app.component('SelectButton', SelectButton)
app.component('Dialog', Dialog)
app.component('DataTable', DataTable)
app.component('Column', Column)
app.component('ConfirmDialog', ConfirmDialog)
app.component('Menu', Menu)
app.mount('#app')
// ✅ marca boot completo
window.__appBootstrapped = true
}
bootstrap()
bootstrap()

View File

@@ -4,42 +4,175 @@
// 📦 Importação dos menus base por área
// ======================================================
import adminMenu from './menus/admin.menu'
import adminMenu from './menus/clinic.menu'
import therapistMenu from './menus/therapist.menu'
import supervisorMenu from './menus/supervisor.menu'
import editorMenu from './menus/editor.menu'
import portalMenu from './menus/portal.menu'
import sakaiDemoMenu from './menus/sakai.demo.menu'
import saasMenu from './menus/saas.menu'
import { useSaasHealthStore } from '@/stores/saasHealthStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
// ======================================================
// 🎭 Mapeamento de role → menu base
// ======================================================
const MENUS = {
// ✅ role real do tenant
clinic_admin: adminMenu,
tenant_admin: adminMenu, // alias
therapist: therapistMenu,
supervisor: supervisorMenu,
editor: editorMenu,
patient: portalMenu,
// ✅ compatibilidade profiles.role
admin: adminMenu,
// ✅ legado
tenant_admin: adminMenu
portal_user: portalMenu, // alias (globalRole do paciente)
saas_admin: saasMenu
}
// ======================================================
// 🧠 Função utilitária
// Permite que o menu seja:
// - Array direto
// - ou função (ctx) => Array
// 🧠 Helpers
// ======================================================
function resolveMenu (builder, ctx) {
if (!builder) return []
return typeof builder === 'function' ? builder(ctx) : builder
try {
return typeof builder === 'function' ? builder(ctx) : builder
} catch (e) {
// se um builder estourar, não derruba o app: cai no fallback
console.warn('[NAV] menu builder error:', e)
return []
}
}
// core menu anti-“sumir”
function coreMenu () {
return [
{
label: 'Geral',
items: [
{ label: 'Início', icon: 'pi pi-home', to: '/' },
{ label: 'Assinatura', icon: 'pi pi-credit-card', to: '/billing' },
{ label: 'Perfil', icon: 'pi pi-user', to: '/profile' }
]
}
]
}
function safeHasFeature (fn, key) {
try { return !!fn?.(key) } catch { return false }
}
// ======================================================
// 🧩 Decorator do menu
// - NÃO remove itens
// - Apenas calcula badge PRO dinâmico (com base em entitlements)
// ======================================================
function decorateMenu (menu, hasFeature) {
const arr = Array.isArray(menu) ? menu : []
return arr.map((group) => {
if (group?.items && Array.isArray(group.items)) {
return { ...group, items: decorateItems(group.items, hasFeature) }
}
return group
})
}
function decorateItems (items, hasFeature) {
return (items || []).map((it) => {
if (it?.items && Array.isArray(it.items)) {
return { ...it, items: decorateItems(it.items, hasFeature) }
}
const featureKey = it?.feature ? String(it.feature) : null
const showPro = !!it?.proBadge && !!featureKey && !safeHasFeature(hasFeature, featureKey)
return { ...it, __showProBadge: showPro }
})
}
// ======================================================
// ✅ Normalização de role (evita menu vazio)
// ======================================================
function normalizeRole (role) {
const r = String(role || '').trim()
if (!r) return null
// aliases comuns (blindagem)
if (r === 'tenant_admin') return 'clinic_admin'
if (r === 'admin') return 'clinic_admin'
if (r === 'clinic') return 'clinic_admin'
return r
}
// ======================================================
// ✅ hasFeature robusto
// - Prioriza sessionCtx (evita flicker)
// - Senão tenta deduzir do store com vários formatos comuns
// ======================================================
function buildHasFeature (sessionCtx, entitlementsStore) {
// 1) se já veio pronto, usa
if (typeof sessionCtx?.hasFeature === 'function') return sessionCtx.hasFeature
// 2) se veio entitlements como objeto { key: true }
if (sessionCtx?.entitlements && typeof sessionCtx.entitlements === 'object') {
const bag = sessionCtx.entitlements
return (k) => !!bag[String(k || '').trim()]
}
// 3) se veio allowedFeatures como array ou Set
if (sessionCtx?.allowedFeatures) {
const af = sessionCtx.allowedFeatures
if (Array.isArray(af)) {
const s = new Set(af.map((x) => String(x).trim()).filter(Boolean))
return (k) => s.has(String(k || '').trim())
}
if (af instanceof Set) {
return (k) => af.has(String(k || '').trim())
}
}
// 4) fallback no store
return (featureKey) => {
const k = String(featureKey || '').trim()
if (!k) return false
try {
if (typeof entitlementsStore?.hasFeature === 'function') return !!entitlementsStore.hasFeature(k)
if (typeof entitlementsStore?.has === 'function') return !!entitlementsStore.has(k)
// formatos comuns:
// - entitlements: { key: true }
// - entitlements: Set([...])
// - entitlements: Array([...])
// - byOwner: { [ownerId]: { key: true } }
const bag =
entitlementsStore?.entitlements ||
entitlementsStore?.features ||
entitlementsStore?.data ||
entitlementsStore?.state
if (bag instanceof Set) return bag.has(k)
if (Array.isArray(bag)) return bag.includes(k)
if (bag && typeof bag === 'object') {
// tenta direto
if (Object.prototype.hasOwnProperty.call(bag, k)) return !!bag[k]
// tenta byOwner (caso exista)
const ownerId = sessionCtx?.ownerId || sessionCtx?.activeTenantId || sessionCtx?.tenantId
if (ownerId && bag[ownerId] && typeof bag[ownerId] === 'object') {
return !!bag[ownerId][k]
}
}
} catch {}
return false
}
}
// ======================================================
@@ -47,50 +180,68 @@ function resolveMenu (builder, ctx) {
// ======================================================
export function getMenuByRole (role, sessionCtx = {}) {
// 🔹 Store de health do SaaS (badge dinâmica)
// ⚠️ Não faz fetch aqui. O AppMenu carrega o store.
const saasHealthStore = useSaasHealthStore()
const mismatchCount = saasHealthStore?.mismatchCount || 0
// 🔹 Store de módulos por tenant (tenant_features)
// ⚠️ Não faz fetch aqui. O guard/app deve carregar. Aqui só lemos cache.
const tenantFeaturesStore = useTenantFeaturesStore()
const entitlementsStore = useEntitlementsStore()
// 🔹 SaaS overlay aparece somente para SaaS master
const isSaas = sessionCtx?.isSaasAdmin === true
// ctx que será passado pros menu builders
const tenantFeatureEnabled =
typeof sessionCtx?.tenantFeatureEnabled === 'function'
? sessionCtx.tenantFeatureEnabled
: (key) => {
try { return !!tenantFeaturesStore?.isEnabled?.(key) } catch { return false }
}
const tenantLoading =
typeof sessionCtx?.tenantLoading === 'function'
? sessionCtx.tenantLoading
: () => false
const tenantFeaturesLoading =
typeof sessionCtx?.tenantFeaturesLoading === 'function'
? sessionCtx.tenantFeaturesLoading
: () => false
const hasFeature = buildHasFeature(sessionCtx, entitlementsStore)
const ctx = {
...sessionCtx,
mismatchCount,
tenantFeaturesStore,
tenantFeatureEnabled: (key) => {
try { return !!tenantFeaturesStore?.isEnabled?.(key) } catch { return false }
}
tenantFeatureEnabled,
tenantLoading,
tenantFeaturesLoading,
entitlementsStore,
hasFeature
}
// 🔹 Menu base da role
const base = resolveMenu(MENUS[role], ctx)
// ✅ role normalizado
const r = normalizeRole(role)
// 🔹 Resolve menu SaaS (array ou função)
const saas = typeof saasMenu === 'function'
const baseRaw = resolveMenu(MENUS[r], ctx)
const base = decorateMenu(baseRaw, hasFeature)
const saasRaw = typeof saasMenu === 'function'
? saasMenu(ctx, { mismatchCount })
: saasMenu
const saas = decorateMenu(saasRaw, hasFeature)
// ======================================================
// 🚀 Menu final
// - base sempre
// - overlay SaaS só para SaaS master
// - Demo Sakai só para SaaS master em DEV
// ======================================================
// 🔒 SaaS master: somente área SaaS
if (isSaas) {
const out = [
...(saas.length ? saas : coreMenu()),
...(import.meta.env.DEV ? [{ separator: true }, ...sakaiDemoMenu] : [])
]
return out
}
return [
...base,
// ✅ fallback: nunca retorna vazio
if (!base || !base.length) {
return coreMenu()
}
...(isSaas && saas.length ? [{ separator: true }, ...saas] : []),
...(isSaas && import.meta.env.DEV
? [{ separator: true }, ...sakaiDemoMenu]
: [])
]
return [...base]
}

View File

@@ -1,105 +0,0 @@
// src/navigation/menus/admin.menu.js
export default function adminMenu (ctx = {}) {
const patientsOn = !!ctx?.tenantFeatureEnabled?.('patients')
const menu = [
// =====================================================
// 📊 OPERAÇÃO
// =====================================================
{
label: 'Operação',
items: [
{
label: 'Dashboard',
icon: 'pi pi-fw pi-home',
to: '/admin'
},
{
label: 'Agenda do Terapeuta',
icon: 'pi pi-fw pi-sitemap',
to: '/therapist/agenda',
feature: 'agenda.view'
}
]
}
]
// =====================================================
// 👥 PACIENTES (somente se módulo ativo)
// =====================================================
if (patientsOn) {
menu.push({
label: 'Pacientes',
items: [
{
label: 'Lista de Pacientes',
icon: 'pi pi-fw pi-users',
to: '/admin/pacientes'
},
{
label: 'Grupos',
icon: 'pi pi-fw pi-users',
to: '/admin/pacientes/grupos'
},
{
label: 'Tags',
icon: 'pi pi-fw pi-tags',
to: '/admin/pacientes/tags'
},
{
label: 'Link Externo',
icon: 'pi pi-fw pi-link',
to: '/admin/pacientes/link-externo'
}
]
})
}
// =====================================================
// ⚙️ GESTÃO DA CLÍNICA
// =====================================================
menu.push({
label: 'Gestão',
items: [
{
label: 'Profissionais',
icon: 'pi pi-fw pi-id-card',
to: '/admin/clinic/professionals'
},
{
label: 'Módulos da Clínica',
icon: 'pi pi-fw pi-sliders-h',
to: '/admin/clinic/features'
},
{
label: 'Assinatura',
icon: 'pi pi-fw pi-credit-card',
to: '/admin/billing'
}
]
})
// =====================================================
// 🔒 SISTEMA
// =====================================================
menu.push({
label: 'Sistema',
items: [
{
label: 'Segurança',
icon: 'pi pi-fw pi-shield',
to: '/admin/settings/security'
},
{
label: 'Agendamento Online (PRO)',
icon: 'pi pi-fw pi-calendar-plus',
to: '/admin/online-scheduling',
feature: 'online_scheduling.manage',
proBadge: true
}
]
})
return menu
}

View File

@@ -0,0 +1,82 @@
// src/navigation/menus/clinic.menu.js
export default function adminMenu (ctx = {}) {
const menu = [
{
label: 'Clínica',
items: [
// ✅ usar name real da rota (evita /admin cair em redirect estranho)
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: { name: 'admin.dashboard' } },
{
label: 'Agenda da Clínica',
icon: 'pi pi-fw pi-calendar',
to: { name: 'admin-agenda-clinica' },
feature: 'agenda.view'
},
// ✅ Compromissos determinísticos (tipos)
{
label: 'Compromissos',
icon: 'pi pi-fw pi-clock',
to: { name: 'admin-agenda-compromissos' },
feature: 'agenda.view'
}
]
},
// ✅ SEM IF: sempre existe, só fica visível quando a feature estiver ligada
{
label: 'Pacientes',
visible: () => {
// 1) enquanto tenant/features estão carregando, NÃO some (evita piscar ao trocar de aba)
if (ctx?.tenantLoading?.()) return true
if (ctx?.tenantFeaturesLoading?.()) return true
// 2) quando estabilizou, aí sim decide pela feature
return !!ctx?.tenantFeatureEnabled?.('patients')
},
items: [
// ✅ usar name real das rotas (você já tem todas no routes.clinic.js)
{ label: 'Lista de Pacientes', icon: 'pi pi-fw pi-users', to: { name: 'admin-pacientes' } },
{ label: 'Grupos', icon: 'pi pi-fw pi-sitemap', to: { name: 'admin-pacientes-grupos' } },
{ label: 'Tags', icon: 'pi pi-fw pi-tags', to: { name: 'admin-pacientes-tags' } },
{ label: 'Link Externo', icon: 'pi pi-fw pi-link', to: { name: 'admin-pacientes-link-externo' } },
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' } }
]
},
{
label: 'Gestão',
items: [
{ label: 'Profissionais', icon: 'pi pi-fw pi-id-card', to: { name: 'admin-clinic-professionals' } },
{
label: 'Tipos de Clínicas',
icon: 'pi pi-fw pi-sliders-h',
to: { name: 'admin-clinic-features' },
visible: () => {
if (ctx?.tenantLoading?.()) return true // ← true durante loading (evita piscar)
const role = ctx?.role?.()
return role === 'clinic_admin' // tenant_admin normaliza para clinic_admin
}
},
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: { name: 'admin-meu-plano' } }
]
},
{
label: 'Sistema',
items: [
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: { name: 'admin-settings-security' } },
{
label: 'Agendamento Online (PRO)',
icon: 'pi pi-fw pi-calendar-plus',
to: { name: 'admin-online-scheduling' },
feature: 'online_scheduling.manage',
proBadge: true
}
]
}
]
return menu
}

View File

@@ -0,0 +1,32 @@
// src/navigation/menus/editor.menu.js
//
// Menu da área de Editor de Conteúdo (plataforma de microlearning).
// O Editor é um papel de PLATAFORMA (não de tenant).
// Indicado pelo saas_admin via platform_roles[] na tabela profiles.
//
export default [
{
label: 'Editor',
items: [
// ======================================================
// 📊 DASHBOARD
// ======================================================
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/editor' },
// ======================================================
// 📚 CONTEÚDO
// ======================================================
{ label: 'Cursos', icon: 'pi pi-fw pi-book', to: '/editor/cursos' },
{ label: 'Módulos', icon: 'pi pi-fw pi-th-large', to: '/editor/modulos' },
{ label: 'Publicados', icon: 'pi pi-fw pi-check-circle', to: '/editor/publicados' },
// ======================================================
// 👤 CONTA
// ======================================================
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: '/editor/meu-plano' },
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' },
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
]
}
]

View File

@@ -6,10 +6,11 @@ export default [
// ✅ Básico (sempre)
// ======================
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/portal' },
{ label: 'Minha Agenda', icon: 'pi pi-fw pi-calendar-plus', to: '/portal/agenda' },
{ label: 'Agendar Sessão', icon: 'pi pi-fw pi-user', to: '/portal/agenda/new' },
{ label: 'Minhas sessões', icon: 'pi pi-fw pi-user', to: '/portal/sessoes' },
// ✅ Conta é global, não do portal
{ label: 'My Account', icon: 'pi pi-fw pi-user', to: '/account/profile' }
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: '/portal/meu-plano' },
{ label: 'Minha Conta', icon: 'pi pi-fw pi-user', to: '/account/profile' },
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
// =====================================================
// 🔒 PRO (exemplos futuros no portal do paciente)

View File

@@ -5,7 +5,6 @@ export default function saasMenu (sessionCtx, opts = {}) {
const mismatchCount = Number(opts?.mismatchCount || 0)
// ✅ helper p/ evitar repetir spread + manter comentários intactos
const mismatchBadge = mismatchCount > 0
? { badge: String(mismatchCount), badgeClass: 'p-badge p-badge-danger' }
: {}
@@ -14,49 +13,40 @@ export default function saasMenu (sessionCtx, opts = {}) {
{
label: 'SaaS',
icon: 'pi pi-building',
path: '/saas', // ✅ necessário p/ expandir e controlar activePath
path: '/saas',
items: [
{ label: 'Dashboard', icon: 'pi pi-chart-bar', to: '/saas' },
{
label: 'Planos',
icon: 'pi pi-star',
path: '/saas/plans', // ✅ absoluto (mais confiável p/ active/expand)
path: '/saas/plans',
items: [
{ label: 'Listagem de Planos', icon: 'pi pi-list', to: '/saas/plans' },
// ✅ vitrine pública (pricing page)
{ label: 'Vitrine Pública', icon: 'pi pi-megaphone', to: '/saas/plans-public' },
{ label: 'Recursos', icon: 'pi pi-bolt', to: '/saas/features' },
{ label: 'Controle de Recursos', icon: 'pi pi-th-large', to: '/saas/plan-features' }
{ label: 'Planos e Preços', icon: 'pi pi-list', to: '/saas/plans' },
{ label: 'Vitrine Pública', icon: 'pi pi-megaphone', to: '/saas/plans-public' },
{ label: 'Recursos', icon: 'pi pi-bolt', to: '/saas/features' },
{ label: 'Controle de Recursos', icon: 'pi pi-th-large', to: '/saas/plan-features' },
{ label: 'Limites por Plano', icon: 'pi pi-sliders-h', to: '/saas/plan-limits' }
]
},
{
label: 'Assinaturas',
icon: 'pi pi-credit-card',
path: '/saas/subscriptions', // ✅ absoluto
path: '/saas/subscriptions',
items: [
{ label: 'Listagem de Assinaturas', icon: 'pi pi-list', to: '/saas/subscriptions' },
{ label: 'Histórico', icon: 'pi pi-history', to: '/saas/subscription-events' },
{ label: 'Listagem de Assinaturas', icon: 'pi pi-list', to: '/saas/subscriptions' },
{ label: 'Intenções', icon: 'pi pi-inbox', to: '/saas/subscription-intents' },
{ label: 'Histórico', icon: 'pi pi-history', to: '/saas/subscription-events' },
{
label: 'Saúde das Assinaturas',
icon: 'pi pi-shield',
to: '/saas/subscription-health',
...(mismatchBadge
? mismatchBadge
: {})
...mismatchBadge
}
]
},
{
label: 'Intenções de Assinatura',
icon: 'pi pi-inbox',
to: '/saas/subscription-intents'
},
{ label: 'Clínicas (Tenants)', icon: 'pi pi-users', to: '/saas/tenants' }
]
}

View File

@@ -1,8 +1,4 @@
export default [
{
label: 'Home',
items: [{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' }]
},
{
label: 'UI Components',
path: '/uikit',
@@ -29,7 +25,6 @@ export default [
icon: 'pi pi-fw pi-prime',
path: '/blocks',
items: [
{ label: 'Free Blocks', icon: 'pi pi-fw pi-eye', to: '/utilities' },
{ label: 'All Blocks', icon: 'pi pi-fw pi-globe', url: 'https://blocks.primevue.org/', target: '_blank' }
]
},
@@ -49,68 +44,15 @@ export default [
{ label: 'Access Denied', icon: 'pi pi-fw pi-lock', to: '/auth/access' }
]
},
{ label: 'Crud', icon: 'pi pi-fw pi-pencil', to: '/pages/crud' },
{ label: 'Not Found', icon: 'pi pi-fw pi-exclamation-circle', to: '/pages/notfound' },
{ label: 'Empty', icon: 'pi pi-fw pi-circle-off', to: '/pages/empty' }
]
},
{
label: 'Hierarchy',
icon: 'pi pi-fw pi-align-left',
path: '/hierarchy',
items: [
{
label: 'Submenu 1',
icon: 'pi pi-fw pi-align-left',
path: '/submenu_1',
items: [
{
label: 'Submenu 1.1',
icon: 'pi pi-fw pi-align-left',
path: '/submenu_1_1',
items: [
{ label: 'Submenu 1.1.1', icon: 'pi pi-fw pi-align-left' },
{ label: 'Submenu 1.1.2', icon: 'pi pi-fw pi-align-left' },
{ label: 'Submenu 1.1.3', icon: 'pi pi-fw pi-align-left' }
]
},
{
label: 'Submenu 1.2',
icon: 'pi pi-fw pi-align-left',
path: '/submenu_1_2',
items: [{ label: 'Submenu 1.2.1', icon: 'pi pi-fw pi-align-left' }]
}
]
},
{
label: 'Submenu 2',
icon: 'pi pi-fw pi-align-left',
path: '/submenu_2',
items: [
{
label: 'Submenu 2.1',
icon: 'pi pi-fw pi-align-left',
path: '/submenu_2_1',
items: [
{ label: 'Submenu 2.1.1', icon: 'pi pi-fw pi-align-left' },
{ label: 'Submenu 2.1.2', icon: 'pi pi-fw pi-align-left' }
]
},
{
label: 'Submenu 2.2',
icon: 'pi pi-fw pi-align-left',
path: '/submenu_2_2',
items: [{ label: 'Submenu 2.2.1', icon: 'pi pi-fw pi-align-left' }]
}
]
}
]
},
{
label: 'Get Started',
path: '/start',
items: [
{ label: 'Documentation', icon: 'pi pi-fw pi-book', to: '/pages' },
{ label: 'Documentation', icon: 'pi pi-fw pi-book', url: 'https://sakai.primevue.org/documentation', target: '_blank' },
{ label: 'View Source', icon: 'pi pi-fw pi-github', url: 'https://github.com/primefaces/sakai-vue', target: '_blank' }
]
}

View File

@@ -0,0 +1,30 @@
// src/navigation/menus/supervisor.menu.js
export default [
{
label: 'Supervisão',
items: [
// ======================================================
// 📊 DASHBOARD
// ======================================================
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/supervisor' },
// ======================================================
// 🎓 SALA DE SUPERVISÃO
// ======================================================
{
label: 'Sala de Supervisão',
icon: 'pi pi-fw pi-users',
to: '/supervisor/sala',
feature: 'supervisor.access'
},
// ======================================================
// 💳 PLANO / CONTA
// ======================================================
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: '/supervisor/meu-plano' },
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' },
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
]
}
]

View File

@@ -2,7 +2,7 @@
export default [
{
label: 'Therapist',
label: 'Terapeuta',
items: [
// ======================================================
// 📊 DASHBOARD
@@ -12,7 +12,10 @@ export default [
// ======================================================
// 📅 AGENDA
// ======================================================
{ label: 'Agenda', icon: 'pi pi-fw pi-calendar', to: '/therapist/agenda' },
{ label: 'Agenda', icon: 'pi pi-fw pi-calendar', to: '/therapist/agenda', feature: 'agenda.view', proBadge: true },
// ✅ NOVO: Compromissos determinísticos (tipos)
{ label: 'Compromissos', icon: 'pi pi-fw pi-clock', to: '/therapist/agenda/compromissos', feature: 'agenda.view', proBadge: true },
// ======================================================
// 👥 PATIENTS
@@ -34,14 +37,16 @@ export default [
label: 'Online Scheduling',
icon: 'pi pi-fw pi-globe',
to: '/therapist/online-scheduling',
feature: 'online_scheduling.manage',
feature: 'online_scheduling',
proBadge: true
},
// ======================================================
// 👤 ACCOUNT
// ======================================================
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' }
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: '/therapist/meu-plano' },
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' },
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
]
}
]

View File

@@ -9,10 +9,13 @@ import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
import { buildUpgradeUrl } from '@/utils/upgradeContext'
import { useMenuStore } from '@/stores/menuStore'
import { getMenuByRole } from '@/navigation'
import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session'
// ✅ separa RBAC (papel) vs Plano (upgrade)
import { denyByRole, denyByPlan } from '@/router/accessRedirects'
import { denyByRole, denyByPlan } from '@/router/accessRedirects' // (denyByPlan pode ficar, mesmo que não use aqui)
// cache simples (evita bater no banco em toda navegação)
let sessionUidCache = null
@@ -38,12 +41,45 @@ function isUuid (v) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''))
}
/**
* ✅ Normaliza roles (aliases) para RBAC.
*
* tenant_admin / admin + kind = 'therapist' → 'therapist'
* tenant_admin / admin + kind = clinic_* → 'clinic_admin'
* tenant_admin / admin + kind desconhecido → 'clinic_admin' (legado)
* qualquer outro role → pass-through
*/
function normalizeRole (role, kind) {
const r = String(role || '').trim()
if (!r) return ''
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 (r === 'clinic_admin') return 'clinic_admin'
// demais
return r
}
function roleToPath (role) {
// ✅ clínica: aceita nomes canônicos e legado
if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin'
if (role === 'therapist') return '/therapist'
// ✅ supervisor (papel de tenant)
if (role === 'supervisor') return '/supervisor'
// ⚠️ legado (se ainda existir em algum lugar)
if (role === 'patient') return '/portal'
if (role === 'portal_user') return '/portal'
// ✅ saas master
if (role === 'saas_admin') return '/saas'
@@ -70,19 +106,22 @@ async function waitSessionIfRefreshing () {
async function isSaasAdmin (uid) {
if (!uid) return false
if (saasAdminCacheUid === uid && typeof saasAdminCacheIsAdmin === 'boolean') {
return saasAdminCacheIsAdmin
}
const { data, error } = await supabase
.from('saas_admins')
.select('user_id')
.eq('user_id', uid)
.maybeSingle()
.from('profiles')
.select('role')
.eq('id', uid)
.single()
const ok = !error && data?.role === 'saas_admin'
const ok = !error && !!data
saasAdminCacheUid = uid
saasAdminCacheIsAdmin = ok
return ok
}
@@ -115,10 +154,121 @@ async function loadEntitlementsSafe (ent, tenantId, force) {
}
}
// util: roles guard (plural)
/**
* wrapper: tenant features store pode não aceitar force:false (ou pode falhar silenciosamente)
* -> tenta sem forçar e, se der ruim, tenta force:true.
*/
async function fetchTenantFeaturesSafe (tf, tenantId) {
if (!tf?.fetchForTenant) return
try {
await tf.fetchForTenant(tenantId, { force: false })
} catch (e) {
console.warn('[guards] tf.fetchForTenant(force:false) falhou, tentando force:true', e)
await tf.fetchForTenant(tenantId, { force: true })
}
}
// util: roles guard (plural) com aliases
function matchesRoles (roles, activeRole) {
if (!Array.isArray(roles) || !roles.length) return true
return roles.includes(activeRole)
const ar = normalizeRole(activeRole)
const wanted = roles.map(normalizeRole)
return wanted.includes(ar)
}
// ======================================================
// ✅ MENU: monta 1x por contexto (sem flicker)
// - O AppMenu lê menuStore.model e não recalcula.
// ======================================================
async function ensureMenuBuilt ({ uid, tenantId, tenantRole, globalRole }) {
try {
const menuStore = useMenuStore()
const isSaas = (globalRole === 'saas_admin')
const roleForMenu = isSaas ? 'saas_admin' : normalizeRole(tenantRole)
// ✅ FIX: inclui o role normalizado E o tenantId no key de forma explícita
// O bug era: em alguns fluxos tenantRole chegava vazio/antigo antes de
// setActiveTenant() ser chamado, fazendo o key bater com o menu errado.
const safeRole = roleForMenu || 'unknown'
const safeTenant = tenantId || 'no-tenant'
const safeGlobal = globalRole || 'no-global'
const key = `${uid}:${safeTenant}:${safeRole}:${safeGlobal}`
// ✅ FIX PRINCIPAL: só considera cache válido se role E tenant baterem.
// Antes, o check era feito antes de garantir que tenant.activeRole
// já tinha sido resolvido corretamente nessa navegação.
if (menuStore.ready && menuStore.key === key && Array.isArray(menuStore.model) && menuStore.model.length > 0) {
// sanity check extra: verifica se o modelo tem itens do role correto
// (evita falso positivo quando key colide por acidente)
const firstLabel = menuStore.model?.[0]?.label || ''
const isClinicMenu = firstLabel === 'Clínica'
const isTherapistMenu = firstLabel === 'Terapeuta'
const isSupervisorMenu = firstLabel === 'Supervisão'
const isEditorMenu = firstLabel === 'Editor'
const isPortalMenu = firstLabel === 'Paciente'
const isSaasMenuCached = firstLabel === 'SaaS'
const expectClinic = safeRole === 'clinic_admin'
const expectTherapist = safeRole === 'therapist'
const expectSupervisor = safeRole === 'supervisor'
const expectEditor = safeRole === 'editor'
const expectPortal = safeRole === 'patient'
const expectSaas = safeRole === 'saas_admin'
const menuMatchesRole =
(expectClinic && isClinicMenu) ||
(expectTherapist && isTherapistMenu) ||
(expectSupervisor && isSupervisorMenu) ||
(expectEditor && isEditorMenu) ||
(expectPortal && isPortalMenu) ||
(expectSaas && isSaasMenuCached) ||
// roles desconhecidos: aceita o cache (coreMenu)
(!expectClinic && !expectTherapist && !expectSupervisor && !expectEditor && !expectPortal && !expectSaas)
if (menuMatchesRole) {
return // cache válido e menu correto
}
// cache com key igual mas menu errado: força rebuild
console.warn('[ensureMenuBuilt] key match mas menu incompatível com role, forçando rebuild:', {
key, safeRole, firstLabel
})
menuStore.reset()
}
// garante tenant_features pronto ANTES de construir
if (!isSaas && tenantId) {
const tfm = useTenantFeaturesStore()
const hasAny = tfm?.features && typeof tfm.features === 'object' && Object.keys(tfm.features).length > 0
const loadedFor = tfm?.loadedForTenantId || null
if (!hasAny || (loadedFor && loadedFor !== tenantId)) {
await fetchTenantFeaturesSafe(tfm, tenantId)
} else if (!loadedFor) {
await fetchTenantFeaturesSafe(tfm, tenantId)
}
}
const tfm2 = useTenantFeaturesStore()
const ctx = {
isSaasAdmin: isSaas,
tenantLoading: () => false,
tenantFeaturesLoading: () => false,
tenantFeatureEnabled: (featureKey) => {
if (!tenantId) return false
try { return !!tfm2.isEnabled(featureKey, tenantId) } catch { return false }
},
role: () => normalizeRole(tenantRole)
}
const model = getMenuByRole(roleForMenu, ctx) || []
menuStore.setMenu(key, model)
} catch (e) {
console.warn('[guards] ensureMenuBuilt failed:', e)
}
}
export function applyGuards (router) {
@@ -165,20 +315,96 @@ export function applyGuards (router) {
return { path: '/auth/login' }
}
const isTenantArea =
to.path.startsWith('/admin') ||
to.path.startsWith('/therapist') ||
to.path.startsWith('/supervisor')
// ======================================
// ✅ IDENTIDADE GLOBAL (1x por navegação)
// - se falhar, NÃO nega por engano: volta pro login (seguro)
// ======================================
const { data: prof, error: profErr } = await supabase
.from('profiles')
.select('role')
.eq('id', uid)
.single()
const globalRole = !profErr ? prof?.role : null
console.timeLog(tlabel, 'profiles.role =', globalRole)
if (!globalRole) {
sessionStorage.setItem('redirect_after_login', to.fullPath)
console.timeEnd(tlabel)
return { path: '/auth/login' }
}
// ======================================
// ✅ TRAVA GLOBAL: portal_user não entra em tenant-app
// ======================================
if (isTenantArea && globalRole === 'portal_user') {
// limpa lixo de tenant herdado
try {
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
localStorage.removeItem('currentTenantId')
} catch (_) {}
console.timeEnd(tlabel)
return { path: '/portal' }
}
// ======================================
// ✅ Portal (identidade global) via meta.profileRole
// ======================================
if (to.meta?.profileRole) {
if (globalRole !== to.meta.profileRole) {
console.timeEnd(tlabel)
return { path: '/pages/access' }
}
// monta menu do portal (patient) antes de liberar
await ensureMenuBuilt({
uid,
tenantId: null,
tenantRole: globalRole, // ex.: 'portal_user'
globalRole
})
console.timeEnd(tlabel)
return true
}
// ======================================
// ✅ ÁREA GLOBAL (não-tenant)
// - /account/* é perfil/config do usuário
// - NÃO pode carregar tenantStore nem trocar contexto de tenant
// ======================================
const isAccountArea = (to.path === '/account' || to.path.startsWith('/account/'))
if (isAccountArea) {
console.timeEnd(tlabel)
return true
}
// (opcional, mas recomendado)
// se não é tenant_member, evita carregar tenant/entitlements sem necessidade
if (globalRole && globalRole !== 'tenant_member') {
try {
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
localStorage.removeItem('currentTenantId')
} catch (_) {}
}
// ==========================================
// ✅ Pending invite (Modelo B): se o usuário logou e tem token pendente,
// redireciona para /accept-invite antes de qualquer load pesado.
// ✅ Pending invite (Modelo B)
// ==========================================
const pendingInviteToken = readPendingInviteToken()
// Se tiver lixo no storage, limpa para não “travar” o app.
if (pendingInviteToken && !isUuid(pendingInviteToken)) {
clearPendingInviteToken()
}
// Evita loop/efeito colateral:
// - não interfere se já está em /accept-invite
// (não precisamos checar /auth aqui porque /auth já retornou lá em cima)
if (
pendingInviteToken &&
isUuid(pendingInviteToken) &&
@@ -199,6 +425,11 @@ export function applyGuards (router) {
const tf0 = useTenantFeaturesStore()
if (typeof tf0.invalidate === 'function') tf0.invalidate()
try {
const menuStore = useMenuStore()
if (typeof menuStore.reset === 'function') menuStore.reset()
} catch {}
}
// ================================
@@ -206,13 +437,103 @@ export function applyGuards (router) {
// ================================
if (to.meta?.saasAdmin) {
console.timeLog(tlabel, 'isSaasAdmin')
const ok = await isSaasAdmin(uid)
// usa identidade global primeiro (evita cache fantasma)
const ok = (globalRole === 'saas_admin') ? true : await isSaasAdmin(uid)
if (!ok) { console.timeEnd(tlabel); return { path: '/pages/access' } }
// ✅ monta menu SaaS 1x (AppMenu lê do menuStore)
await ensureMenuBuilt({
uid,
tenantId: null,
tenantRole: 'saas_admin',
globalRole
})
console.timeEnd(tlabel)
return true
}
// ================================
// ✅ ÁREA DO EDITOR (papel de plataforma)
// Verificado por platform_roles[] em profiles, não por tenant.
// ⚠️ Requer migration: ALTER TABLE profiles ADD COLUMN platform_roles text[] DEFAULT '{}'
// ================================
if (to.meta?.editorArea) {
let platformRoles = []
try {
const { data: pRoles } = await supabase
.from('profiles')
.select('platform_roles')
.eq('id', uid)
.single()
platformRoles = Array.isArray(pRoles?.platform_roles) ? pRoles.platform_roles : []
} catch {
// coluna ainda não existe: acesso negado por padrão
}
if (!platformRoles.includes('editor')) {
console.timeEnd(tlabel)
return { path: '/pages/access' }
}
await ensureMenuBuilt({
uid,
tenantId: null,
tenantRole: 'editor',
globalRole
})
console.timeEnd(tlabel)
return true
}
// ================================
// 🚫 SaaS master: bloqueia tenant-app por padrão
// ✅ Mas libera rotas de DEMO em DEV (Sakai)
// ================================
console.timeLog(tlabel, 'saas.lockdown?')
// 🔥 PATCH: aqui NÃO consulta isSaasAdmin — usa só identidade global
const isSaas = (globalRole === 'saas_admin')
if (isSaas) {
const isSaasArea = to.path === '/saas' || to.path.startsWith('/saas/')
// Rotas do Sakai Demo (no seu caso ficam em /demo/*)
const isDemoArea = import.meta.env.DEV && (
to.path === '/demo' ||
to.path.startsWith('/demo/')
)
// Se for demo em DEV, libera
if (isDemoArea) {
// ✅ ainda assim monta menu SaaS (pra layout não piscar)
await ensureMenuBuilt({
uid,
tenantId: null,
tenantRole: 'saas_admin',
globalRole
})
console.timeEnd(tlabel)
return true
}
// Fora de /saas (e não-demo), não pode
if (!isSaasArea) {
console.timeEnd(tlabel)
return { path: '/saas' }
}
// ✅ estamos no /saas: monta menu SaaS
await ensureMenuBuilt({
uid,
tenantId: null,
tenantRole: 'saas_admin',
globalRole
})
}
// ================================
// ✅ Abaixo daqui é tudo tenant-app
// ================================
@@ -232,21 +553,33 @@ export function applyGuards (router) {
}
// se não tem tenant ativo:
// - se não tem memberships active -> manda pro access (sem clínica)
// - se tem memberships active mas activeTenantId está null -> seta e segue
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))
const wantedNorm = wantedRoles.map(normalizeRole)
const preferred = wantedNorm.length
? mem.find(m =>
m &&
m.status === 'active' &&
m.tenant_id &&
wantedNorm.includes(normalizeRole(m.role, m.kind))
)
: null
// 2) fallback: primeiro active
const firstActive = preferred || mem.find(m => m && m.status === 'active' && m.tenant_id)
if (!firstActive) {
// 🔥 race/sem vínculo: em área tenant, trate como login necessário (não AccessDenied)
if (isTenantArea) {
sessionStorage.setItem('redirect_after_login', to.fullPath)
console.timeEnd(tlabel)
return { path: '/auth/login' }
}
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
console.timeEnd(tlabel)
return { path: '/pages/access' }
@@ -260,17 +593,90 @@ export function applyGuards (router) {
}
}
const tenantId = tenant.activeTenantId
// 🔥 FIX: se ainda assim não resolveu tenant/role e estamos em tenant area, não negue “por engano”
if (isTenantArea && (!tenant.activeTenantId || !tenant.activeRole)) {
sessionStorage.setItem('redirect_after_login', to.fullPath)
console.timeEnd(tlabel)
return { path: '/auth/login' }
}
let tenantId = tenant.activeTenantId
if (!tenantId) {
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
console.timeEnd(tlabel)
return { path: '/pages/access' }
}
// =====================================================
// ✅ tenantScope baseado em tenants.kind (fonte da verdade)
// =====================================================
const scope = to.meta?.tenantScope // 'personal' | 'clinic'
if (scope) {
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
// seleciona membership ativa cujo kind corresponde ao escopo
const desired = mem.find(m =>
m &&
m.status === 'active' &&
m.tenant_id &&
(
(scope === 'personal' && m.kind === 'saas') ||
(scope === 'clinic' && m.kind === 'clinic') ||
(scope === 'supervisor' && m.kind === 'supervisor')
)
)
const desiredTenantId = desired?.tenant_id || null
if (desiredTenantId && tenant.activeTenantId !== desiredTenantId) {
console.timeLog(tlabel, `tenantScope.switch(${scope})`)
// ✅ guarda o tenant antigo para invalidar APENAS ele
const oldTenantId = tenant.activeTenantId
if (typeof tenant.setActiveTenant === 'function') {
tenant.setActiveTenant(desiredTenantId)
} else {
tenant.activeTenantId = desiredTenantId
}
localStorage.setItem('tenant_id', desiredTenantId)
tenantId = desiredTenantId
try {
const entX = useEntitlementsStore()
if (typeof entX.invalidate === 'function') entX.invalidate()
} catch {}
// ✅ NÃO invalidar o cache inteiro — invalida somente o tenant anterior
try {
const tfX = useTenantFeaturesStore()
if (typeof tfX.invalidate === 'function' && oldTenantId) tfX.invalidate(oldTenantId)
} catch {}
// ✅ troca tenant => menu precisa recompôr (contexto mudou)
try {
const menuStore = useMenuStore()
if (typeof menuStore.reset === 'function') menuStore.reset()
} catch {}
} else if (!desiredTenantId) {
console.warn('[guards] tenantScope sem match:', scope, {
memberships: mem.map(x => ({
tenant_id: x?.tenant_id,
role: x?.role,
kind: x?.kind,
status: x?.status
}))
})
}
}
// se trocar tenant, invalida cache de tenant_features (evita ler feature de tenant errado)
const tfSwitch = useTenantFeaturesStore()
if (tfSwitch.loadedForTenantId && tfSwitch.loadedForTenantId !== tenantId) {
tfSwitch.invalidate()
// ✅ invalida só o tenant que estava carregado antes
tfSwitch.invalidate(tfSwitch.loadedForTenantId)
}
// entitlements (✅ carrega só quando precisa)
@@ -280,6 +686,17 @@ export function applyGuards (router) {
await loadEntitlementsSafe(ent, tenantId, true)
}
// ✅ user entitlements: terapeuta pode ter assinatura pessoal (therapist_pro)
// que gera features em v_user_entitlements, não em v_tenant_entitlements.
// user entitlements: therapist e supervisor têm assinatura pessoal (v_user_entitlements)
const activeRoleNormForEnt = normalizeRole(tenant.activeRole)
if (['therapist', 'supervisor'].includes(activeRoleNormForEnt) && uid && ent.loadedForUser !== uid) {
console.timeLog(tlabel, 'ent.loadForUser')
try { await ent.loadForUser(uid) } catch (e) {
console.warn('[guards] ent.loadForUser failed:', e)
}
}
// ================================
// ✅ tenant_features (módulos ativáveis por clínica)
// meta.tenantFeature = 'patients' | ...
@@ -288,10 +705,14 @@ export function applyGuards (router) {
if (requiredTenantFeature) {
const tf = useTenantFeaturesStore()
console.timeLog(tlabel, 'tenantFeatures.fetchForTenant')
await tf.fetchForTenant(tenantId, { force: false })
await fetchTenantFeaturesSafe(tf, tenantId)
if (!tf.isEnabled(requiredTenantFeature)) {
// evita loop
// ✅ IMPORTANTÍSSIMO: passa tenantId
const enabled = typeof tf.isEnabled === 'function'
? tf.isEnabled(requiredTenantFeature, tenantId)
: false
if (!enabled) {
if (to.path === '/admin/clinic/features') { console.timeEnd(tlabel); return true }
console.timeEnd(tlabel)
@@ -304,65 +725,73 @@ export function applyGuards (router) {
// ------------------------------------------------
// ✅ 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 allowedRolesRaw = Array.isArray(to.meta?.roles) ? to.meta.roles : null
const allowedRoles = allowedRolesRaw && allowedRolesRaw.length
? allowedRolesRaw.map(normalizeRole)
: null
const activeRoleNorm = normalizeRole(tenant.activeRole)
if (allowedRoles && allowedRoles.length && !allowedRoles.includes(activeRoleNorm)) {
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)
m &&
m.status === 'active' &&
m.tenant_id === tenantId &&
allowedRoles.includes(normalizeRole(m.role, m.kind))
)
if (compatible) {
// muda role ativo para o compatível (mesmo tenant)
tenant.activeRole = compatible.role
tenant.activeRole = normalizeRole(compatible.role, compatible.kind)
} 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) {
// RBAC singular também é "papel" → cai fora (não é upgrade)
// role guard (singular)
const requiredRoleRaw = to.meta?.role
const requiredRole = requiredRoleRaw ? normalizeRole(requiredRoleRaw) : null
if (requiredRole && normalizeRole(tenant.activeRole) !== requiredRole) {
console.timeEnd(tlabel)
return denyByRole({ to, currentRole: tenant.activeRole })
}
// ------------------------------------------------
// ✅ 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)) {
// evita loop
if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
// Mantém compatibilidade com seu fluxo existente (buildUpgradeUrl)
const url = buildUpgradeUrl({
missingKeys: [requiredFeature],
redirectTo: to.fullPath
})
missingKeys: [requiredFeature],
redirectTo: to.fullPath,
role: normalizeRole(tenant.activeRole) // ✅ passa o role para a UpgradePage detectar
})
// Se quiser padronizar no futuro, você pode trocar por:
// return denyByPlan({ to, missingFeature: requiredFeature, redirectTo: to.fullPath })
console.timeEnd(tlabel)
return url
}
// ======================================================
// ✅ MENU: monta 1x por contexto APÓS estabilizar tenant+role
// ======================================================
await ensureMenuBuilt({
uid,
tenantId,
tenantRole: tenant.activeRole,
globalRole
})
console.timeEnd(tlabel)
return true
} catch (e) {
console.error('[guards] erro no beforeEach:', e)
// fallback seguro
if (to.path.startsWith('/auth')) return true
if (to.meta?.public) return true
if (to.path === '/pages/access') return true
@@ -372,19 +801,87 @@ export function applyGuards (router) {
}
})
// auth listener (reset caches)
// auth listener (reset caches) — ✅ agora com filtro de evento
if (!window.__supabaseAuthListenerBound) {
window.__supabaseAuthListenerBound = true
supabase.auth.onAuthStateChange(() => {
sessionUidCache = null
saasAdminCacheUid = null
saasAdminCacheIsAdmin = null
supabase.auth.onAuthStateChange((event, sess) => {
// ⚠️ NÃO derrubar caches em token refresh / eventos redundantes.
const uid = sess?.user?.id || null
try {
const tf = useTenantFeaturesStore()
if (typeof tf.invalidate === 'function') tf.invalidate()
} catch {}
// ✅ SIGNED_OUT: aqui sim zera tudo
if (event === 'SIGNED_OUT') {
sessionUidCache = null
saasAdminCacheUid = null
saasAdminCacheIsAdmin = null
// ✅ FIX: limpa o localStorage de tenant na saída
// Sem isso, o próximo login restaura o tenant do usuário anterior.
try {
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
localStorage.removeItem('currentTenantId')
} catch (_) {}
try {
const tf = useTenantFeaturesStore()
if (typeof tf.invalidate === 'function') tf.invalidate()
} catch {}
try {
const ent = useEntitlementsStore()
if (typeof ent.invalidate === 'function') ent.invalidate()
} catch {}
try {
const tenant = useTenantStore()
if (typeof tenant.reset === 'function') tenant.reset()
} catch {}
try {
const menuStore = useMenuStore()
if (typeof menuStore.reset === 'function') menuStore.reset()
} catch {}
return
}
// ✅ TOKEN_REFRESHED: NÃO invalida nada (é o caso clássico de trocar de aba)
if (event === 'TOKEN_REFRESHED') return
// ✅ SIGNED_IN / USER_UPDATED:
// só invalida se o usuário mudou de verdade
if (event === 'SIGNED_IN' || event === 'USER_UPDATED') {
if (uid && sessionUidCache && sessionUidCache === uid) {
// mesmo usuário -> não derruba caches
return
}
// user mudou (ou cache vazio) -> invalida dependências
sessionUidCache = uid || null
saasAdminCacheUid = null
saasAdminCacheIsAdmin = null
try {
const tf = useTenantFeaturesStore()
if (typeof tf.invalidate === 'function') tf.invalidate()
} catch {}
try {
const ent = useEntitlementsStore()
if (typeof ent.invalidate === 'function') ent.invalidate()
} catch {}
try {
const menuStore = useMenuStore()
if (typeof menuStore.reset === 'function') menuStore.reset()
} catch {}
// tenantStore carrega de novo no fluxo do guard quando precisar
return
}
// default: não faz nada
})
}
}

View File

@@ -2,15 +2,17 @@ import { createRouter, createWebHistory, isNavigationFailure, NavigationFailureT
import configuracoesRoutes from './routes.configs';
import meRoutes from './routes.account';
import adminRoutes from './routes.admin';
import adminRoutes from './routes.clinic';
import authRoutes from './routes.auth';
import billingRoutes from './routes.billing';
import demoRoutes from './routes.demo';
import miscRoutes from './routes.misc';
import patientRoutes from './routes.portal';
import portalRoutes from './routes.portal';
import publicRoutes from './routes.public';
import saasRoutes from './routes.saas';
import therapistRoutes from './routes.therapist';
import supervisorRoutes from './routes.supervisor';
import editorRoutes from './routes.editor';
import featuresRoutes from './routes.features'
import { applyGuards } from './guards';
@@ -24,7 +26,9 @@ const routes = [
...(Array.isArray(meRoutes) ? meRoutes : [meRoutes]),
...(Array.isArray(adminRoutes) ? adminRoutes : [adminRoutes]),
...(Array.isArray(therapistRoutes) ? therapistRoutes : [therapistRoutes]),
...(Array.isArray(patientRoutes) ? patientRoutes : [patientRoutes]),
...(Array.isArray(supervisorRoutes) ? supervisorRoutes : [supervisorRoutes]),
...(Array.isArray(editorRoutes) ? editorRoutes : [editorRoutes]),
...(Array.isArray(portalRoutes) ? portalRoutes : [portalRoutes]),
...(Array.isArray(demoRoutes) ? demoRoutes : [demoRoutes]),
...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]),
...(Array.isArray(featuresRoutes) ? featuresRoutes : [featuresRoutes]),

View File

@@ -3,7 +3,7 @@ import AppLayout from '@/layout/AppLayout.vue'
export default {
path: '/account',
component: AppLayout,
meta: { requiresAuth: true },
meta: { requiresAuth: true, area: 'account' },
children: [
{
path: '',

View File

@@ -1,28 +1,24 @@
// src/router/routes.admin.js
// src/router/routes.clinic.js
import AppLayout from '@/layout/AppLayout.vue'
export default {
path: '/admin',
component: AppLayout,
meta: {
meta: {
// 🔐 Tudo aqui dentro exige login
requiresAuth: true,
area: 'admin',
requiresAuth: true,
// 👤 Perfil de acesso (tenant-level)
// tenantStore normaliza tenant_admin -> clinic_admin, mas mantemos compatibilidade
roles: ['clinic_admin', 'tenant_admin']
roles: ['clinic_admin']
},
children: [
// ======================================================
// 📊 DASHBOARD
// ======================================================
{
path: '',
name: 'admin-dashboard',
component: () => import('@/views/pages/admin/AdminDashboard.vue')
},
{ path: '', name: 'admin.dashboard', component: () => import('@/views/pages/clinic/ClinicDashboard.vue') },
// ======================================================
// 🧩 CLÍNICA — MÓDULOS (tenant_features)
@@ -30,53 +26,54 @@ export default {
{
path: 'clinic/features',
name: 'admin-clinic-features',
component: () => import('@/views/pages/admin/clinic/ClinicFeaturesPage.vue'),
component: () => import('@/views/pages/clinic/clinic/ClinicFeaturesPage.vue'),
meta: {
// opcional: restringir apenas para admin canônico
roles: ['clinic_admin', 'tenant_admin']
}
},
{
path: 'clinic/professionals',
name: 'admin-clinic-professionals',
component: () => import('@/views/pages/admin/clinic/ClinicProfessionalsPage.vue'),
meta: {
requiresAuth: true,
roles: ['clinic_admin', 'tenant_admin']
}
},
// ======================================================
// 📅 MINHA AGENDA
// ======================================================
// 🔎 Visão geral da agenda
{
path: 'agenda',
name: 'admin-agenda',
component: () => import('@/views/pages/admin/agenda/MyAppointmentsPage.vue'),
path: 'clinic/professionals',
name: 'admin-clinic-professionals',
component: () => import('@/views/pages/clinic/clinic/ClinicProfessionalsPage.vue'),
meta: {
feature: 'agenda.view'
requiresAuth: true,
roles: ['clinic_admin', 'tenant_admin']
}
},
// ======================================================
// 💳 MEU PLANO
// ======================================================
{
path: 'meu-plano',
name: 'admin-meu-plano',
component: () => import('@/views/pages/billing/ClinicMeuPlanoPage.vue')
},
// ======================================================
// 📅 AGENDA DA CLÍNICA
// ======================================================
{
path: 'agenda/clinica',
name: 'admin-agenda-clinica',
component: () => import('@/features/agenda/pages/AgendaClinicaPage.vue'),
meta: {
feature: 'agenda.view',
roles: ['clinic_admin', 'tenant_admin']
}
},
// Adicionar novo compromisso
{
path: 'agenda/adicionar',
name: 'admin-agenda-adicionar',
component: () => import('@/views/pages/admin/agenda/NewAppointmentPage.vue'),
meta: {
feature: 'agenda.manage'
}
},
// ✅ NOVO: Compromissos determinísticos (tipos)
{
path: 'agenda/clinica',
name: 'admin-agenda-clinica',
component: () => import('@/features/agenda/pages/AgendaClinicaPage.vue'),
path: 'agenda/compromissos',
name: 'admin-agenda-compromissos',
component: () => import('@/features/agenda/pages/CompromissosDeterminados.vue'),
meta: {
feature: 'agenda.view',
roles: ['clinic_admin', 'tenant_admin']
// ✅ sem tenantScope: a área /admin já está no tenant da clínica pelo fluxo normal
}
},
@@ -171,7 +168,7 @@ export default {
{
path: 'online-scheduling',
name: 'admin-online-scheduling',
component: () => import('@/views/pages/admin/OnlineSchedulingAdminPage.vue'),
component: () => import('@/views/pages/clinic/OnlineSchedulingAdminPage.vue'),
meta: {
feature: 'online_scheduling.manage'
}

View File

@@ -22,11 +22,6 @@ const configuracoesRoutes = {
name: 'ConfiguracoesAgenda',
component: () => import('@/layout/configuracoes/ConfiguracoesAgendaPage.vue')
}
// Futuro:
// { path: 'clinica', name: 'ConfiguracoesClinica', component: () => import('@/layout/configuracoes/ConfiguracoesClinicaPage.vue') },
// { path: 'intake', name: 'ConfiguracoesIntake', component: () => import('@/layout/configuracoes/ConfiguracoesIntakePage.vue') },
// { path: 'conta', name: 'ConfiguracoesConta', component: () => import('@/layout/configuracoes/ConfiguracoesContaPage.vue') },
]
}
]

View File

@@ -4,7 +4,12 @@ export default {
// ✅ não use '/' aqui (conflita com HomeCards)
path: '/demo',
component: AppLayout,
meta: { requiresAuth: true, role: 'tenant_admin' },
// ✅ DEMO pertence ao backoffice SaaS (somente DEV)
// - assim o guard trata como área SaaS e não cai no tenant-app
// - remove dependência de role tenant_admin / tenant ativo
meta: { requiresAuth: true, saasAdmin: true },
children: [
{ path: 'uikit/formlayout', name: 'uikit-formlayout', component: () => import('@/views/uikit/FormLayout.vue') },
{ path: 'uikit/input', name: 'uikit-input', component: () => import('@/views/uikit/InputDoc.vue') },
@@ -26,4 +31,4 @@ export default {
{ path: 'pages/empty', name: 'pages-empty', component: () => import('@/views/pages/Empty.vue') },
{ path: 'pages/crud', name: 'pages-crud', component: () => import('@/views/pages/Crud.vue') }
]
}
}

View File

@@ -0,0 +1,64 @@
// src/router/routes.editor.js
//
// Área de Editor de Conteúdo — papel de PLATAFORMA.
// Acesso controlado por `platform_roles` no guard (não por tenant role).
// meta.editorArea: true sinaliza ao guard que use a verificação de plataforma.
//
import AppLayout from '@/layout/AppLayout.vue'
export default {
path: '/editor',
component: AppLayout,
meta: { area: 'editor', requiresAuth: true, editorArea: true },
children: [
// ======================================================
// 📊 DASHBOARD
// ======================================================
{
path: '',
name: 'editor.dashboard',
component: () => import('@/views/pages/editor/EditorDashboard.vue')
},
// ======================================================
// 📚 CURSOS
// ======================================================
{
path: 'cursos',
name: 'editor-cursos',
// placeholder — módulo de microlearning a implementar
component: () => import('@/views/pages/editor/EditorDashboard.vue')
},
// ======================================================
// 📦 MÓDULOS
// ======================================================
{
path: 'modulos',
name: 'editor-modulos',
// placeholder
component: () => import('@/views/pages/editor/EditorDashboard.vue')
},
// ======================================================
// ✅ PUBLICADOS
// ======================================================
{
path: 'publicados',
name: 'editor-publicados',
// placeholder
component: () => import('@/views/pages/editor/EditorDashboard.vue')
},
// ======================================================
// 💳 MEU PLANO (assinatura pessoal do editor)
// ======================================================
{
path: 'meu-plano',
name: 'editor-meu-plano',
component: () => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')
}
]
}

View File

@@ -4,24 +4,22 @@ import AppLayout from '@/layout/AppLayout.vue'
export default {
path: '/portal',
component: AppLayout,
meta: { requiresAuth: true, roles: ['patient'] },
meta: { area: 'portal', requiresAuth: true, profileRole: 'portal_user' },
children: [
{
path: '',
name: 'portal-dashboard',
component: () => import('@/views/pages/portal/PortalDashboard.vue')
{ path: '', name: 'portal.dashboard', component: () => import('@/views/pages/portal/PortalDashboard.vue') },
{
path: 'sessoes',
name: 'portal-sessoes',
component: () => import('@/views/pages/portal/MinhasSessoes.vue')
},
// ✅ Appointments (era agenda)
// ======================================================
// 💳 MEU PLANO (assinatura pessoal do paciente)
// ======================================================
{
path: 'agenda',
name: 'portal-agenda',
component: () => import('@/views/pages/portal/agenda/MyAppointmentsPage.vue')
},
{
path: 'agenda/new',
name: 'portal-agenda-new',
component: () => import('@/views/pages/portal/agenda/NewAppointmentPage.vue')
path: 'meu-plano',
name: 'portal-meu-plano',
component: () => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')
}
]
}

View File

@@ -30,6 +30,11 @@ export default {
name: 'saas-plan-features',
component: () => import('@/views/pages/saas/SaasPlanFeaturesMatrixPage.vue')
},
{
path: 'plan-limits',
name: 'saas-plan-limits',
component: () => import('@/views/pages/saas/SaasPlanLimitsPage.vue')
},
{
path: 'subscriptions',
name: 'saas-subscriptions',
@@ -45,7 +50,7 @@ export default {
name: 'saas-subscription-health',
component: () => import('@/views/pages/saas/SaasSubscriptionHealthPage.vue')
},
{
{
path: 'subscription-intents',
name: 'saas.subscriptionIntents',
component: () => import('@/views/pages/saas/SubscriptionIntentsPage.vue'),
@@ -57,4 +62,4 @@ export default {
component: () => import('@/views/pages/saas/SaasPlaceholder.vue')
}
]
}
}

View File

@@ -0,0 +1,46 @@
// src/router/routes.supervisor.js
import AppLayout from '@/layout/AppLayout.vue'
export default {
path: '/supervisor',
component: AppLayout,
// tenantScope: 'supervisor' → o guard troca automaticamente para o tenant
// com kind='supervisor' quando o usuário navega para esta área.
meta: {
area: 'supervisor',
requiresAuth: true,
roles: ['supervisor'],
tenantScope: 'supervisor'
},
children: [
// ======================================================
// 📊 DASHBOARD
// ======================================================
{
path: '',
name: 'supervisor.dashboard',
component: () => import('@/views/pages/supervisor/SupervisorDashboard.vue')
},
// ======================================================
// 🎓 SALA DE SUPERVISÃO
// ======================================================
{
path: 'sala',
name: 'supervisor.sala',
component: () => import('@/views/pages/supervisor/SupervisaoSalaPage.vue'),
meta: { feature: 'supervisor.access' }
},
// ======================================================
// 💳 MEU PLANO
// ======================================================
{
path: 'meu-plano',
name: 'supervisor.meu-plano',
component: () => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')
}
]
}

View File

@@ -5,24 +5,13 @@ export default {
path: '/therapist',
component: AppLayout,
meta: {
// 🔐 Tudo aqui dentro exige login
requiresAuth: true,
// 👤 Perfil de acesso (tenant-level)
roles: ['therapist']
},
meta: { area: 'therapist', requiresAuth: true, roles: ['therapist'] },
children: [
// ======================================================
// 📊 DASHBOARD
// ======================================================
{
path: '',
name: 'therapist-dashboard',
component: () => import('@/views/pages/therapist/TherapistDashboard.vue')
// herda requiresAuth + roles do pai
},
{ path: '', name: 'therapist.dashboard', component: () => import('@/views/pages/therapist/TherapistDashboard.vue') },
// ======================================================
// 📅 AGENDA
@@ -30,81 +19,81 @@ export default {
{
path: 'agenda',
name: 'therapist-agenda',
//component: () => import('@/views/pages/therapist/agenda/MyAppointmentsPage.vue'),
component: () => import('@/features/agenda/pages/AgendaTerapeutaPage.vue'),
component: () => import('@/features/agenda/pages/AgendaTerapeutaPage.vue'),
meta: {
feature: 'agenda.view'
}
},
// ✅ Compromissos determinísticos
{
path: 'agenda/adicionar',
name: 'therapist-agenda-adicionar',
component: () => import('@/views/pages/therapist/agenda/NewAppointmentPage.vue'),
path: 'agenda/compromissos',
name: 'therapist-agenda-compromissos',
component: () => import('@/features/agenda/pages/CompromissosDeterminados.vue'),
meta: {
feature: 'agenda.manage'
feature: 'agenda.view',
roles: ['therapist']
// ✅ sem tenantScope
}
},
// ======================================================
// ======================================================
// 💳 MEU PLANO
// ======================================================
{
path: 'meu-plano',
name: 'therapist-meu-plano',
component: () => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')
},
{
path: 'upgrade',
name: 'therapist-upgrade',
component: () => import('@/views/pages/billing/TherapistUpgradePage.vue')
},
// ======================================================
// 👥 PATIENTS
// ======================================================
{
path: 'patients',
name: 'therapist-patients',
component: () => import('@/features/patients/PatientsListPage.vue')
},
// Create patient
{
path: 'patients/cadastro',
name: 'therapist-patients-create',
component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue')
},
{
path: 'patients/cadastro/:id',
name: 'therapist-patients-edit',
component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue'),
props: true
},
// 👥 Groups
{
path: 'patients/grupos',
name: 'therapist-patients-groups',
component: () => import('@/features/patients/grupos/GruposPacientesPage.vue')
},
// 🏷️ Tags
{
path: 'patients/tags',
name: 'therapist-patients-tags',
component: () => import('@/features/patients/tags/TagsPage.vue')
},
// 🔗 External Link
{
path: 'patients/link-externo',
name: 'therapist-patients-link-externo',
component: () => import('@/features/patients/cadastro/PatientsExternalLinkPage.vue')
},
// 📥 Received Registrations
{
path: 'patients/cadastro/recebidos',
name: 'therapist-patients-recebidos',
component: () =>
import('@/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue')
component: () => import('@/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue')
},
// ======================================================
// 🔒 PRO — Online Scheduling (gestão interna)
// 🔒 PRO — Online Scheduling
// ======================================================
// feature gate via meta.feature:
// - bloqueia rota (guard)
// - menu pode desabilitar/ocultar (entitlementsStore.has)
{
path: 'online-scheduling',
name: 'therapist-online-scheduling',
@@ -115,23 +104,12 @@ export default {
},
// ======================================================
// 🔐 SECURITY (temporário dentro da área)
// 🔐 SECURITY
// ======================================================
// ⚠️ Idealmente mover para /account/security (área global)
{
path: 'settings/security',
name: 'therapist-settings-security',
component: () => import('@/views/pages/auth/SecurityPage.vue')
}
// ======================================================
// 🔒 PRO — Online Scheduling (configuração pública)
// ======================================================
// {
// path: 'online-scheduling/public',
// name: 'therapist-online-scheduling-public',
// component: () => import('@/views/pages/therapist/OnlineSchedulingPublicPage.vue'),
// meta: { feature: 'online_scheduling.public' }
// }
]
}

View File

@@ -13,6 +13,20 @@ async function getOwnerId () {
return uid
}
async function getActiveTenantId (uid) {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('user_id', uid)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.single()
if (error) throw error
if (!data?.tenant_id) throw new Error('Tenant não encontrado.')
return data.tenant_id
}
function normalizeNome (s) {
return String(s || '').trim().toLowerCase().replace(/\s+/g, ' ')
}
@@ -79,6 +93,7 @@ export async function listGroupsWithCounts () {
export async function createGroup (nome, cor = null) {
const ownerId = await getOwnerId()
const tenantId = await getActiveTenantId(ownerId)
const raw = String(nome || '').trim()
if (!raw) throw new Error('Nome do grupo é obrigatório.')
@@ -100,6 +115,7 @@ export async function createGroup (nome, cor = null) {
const payload = {
owner_id: ownerId,
tenant_id: tenantId,
nome: raw,
cor: cor || null
}

View File

@@ -1,7 +1,10 @@
// src/services/subscriptionIntents.js
import { supabase } from '@/lib/supabase/client'
function applyFilters(query, { q, status, planKey, interval }) {
// --------------------------------------
// Helpers
// --------------------------------------
function applyFilters (query, { q, status, planKey, interval }) {
if (q) query = query.ilike('email', `%${q}%`)
if (status) query = query.eq('status', status)
if (planKey) query = query.eq('plan_key', planKey)
@@ -9,9 +12,31 @@ function applyFilters(query, { q, status, planKey, interval }) {
return query
}
export async function listSubscriptionIntents(filters = {}) {
function getWriteTableByTarget (planTarget) {
const t = String(planTarget || '').toLowerCase()
if (t === 'clinic') return 'subscription_intents_tenant'
if (t === 'therapist') return 'subscription_intents_personal'
return null
}
async function fetchIntentFromView (intentId) {
const { data, error } = await supabase
.from('subscription_intents') // ✅ VIEW (read)
.select('*')
.eq('id', intentId)
.maybeSingle()
if (error) throw error
if (!data) throw new Error('Intenção não encontrada.')
return data
}
// --------------------------------------
// Public API
// --------------------------------------
export async function listSubscriptionIntents (filters = {}) {
let query = supabase
.from('subscription_intents')
.from('subscription_intents') // ✅ VIEW
.select('*')
.order('created_at', { ascending: false })
@@ -22,34 +47,144 @@ export async function listSubscriptionIntents(filters = {}) {
return data || []
}
export async function markIntentPaid(intentId, notes = '') {
// 1) marca como pago
const { data: updated, error: upErr } = await supabase
.from('subscription_intents')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
notes: notes || null
/**
* Busca a assinatura mais recente coerente com a intenção.
* Não depende de subscription_id existir.
*
* opts:
* - strictPlanKey (default true): filtra por plan_key (evita pegar plano errado)
* - onlyActive (default false): se true, busca somente status 'active'
*/
export async function findLatestSubscriptionForIntent (intentOrId, opts = {}) {
const { strictPlanKey = true, onlyActive = false } = opts
const intent = typeof intentOrId === 'string'
? await fetchIntentFromView(intentOrId)
: intentOrId
const target = String(intent?.plan_target || '').toLowerCase()
const planKey = intent?.plan_key || null
const intentUserId = intent?.user_id || intent?.created_by_user_id || null
const tenantId = intent?.tenant_id || null
let query = supabase
.from('subscriptions')
.select('*')
.order('created_at', { ascending: false })
.limit(1)
if (onlyActive) query = query.eq('status', 'active')
if (target === 'clinic') {
if (!tenantId) throw new Error('Intenção clinic sem tenant_id.')
query = query.eq('tenant_id', tenantId)
if (strictPlanKey && planKey) query = query.eq('plan_key', planKey)
} else if (target === 'therapist') {
if (!intentUserId) throw new Error('Intenção therapist sem user_id.')
query = query.eq('user_id', intentUserId).is('tenant_id', null)
if (strictPlanKey && planKey) query = query.eq('plan_key', planKey)
} else {
throw new Error('plan_target inválido na intenção (esperado clinic/therapist).')
}
const { data, error } = await query.maybeSingle()
if (error) throw error
return data || null
}
/**
* Retorna a assinatura para uma intenção:
* 1) se existir intent.subscription_id, tenta carregar ela
* 2) fallback: busca a mais recente coerente com o target
*/
export async function getSubscriptionForIntent (intentOrId, opts = {}) {
const intent = typeof intentOrId === 'string'
? await fetchIntentFromView(intentOrId)
: intentOrId
const subId = intent?.subscription_id || null
if (subId) {
const { data, error } = await supabase
.from('subscriptions')
.select('*')
.eq('id', subId)
.maybeSingle()
if (error) throw error
if (data?.id) return data
}
return await findLatestSubscriptionForIntent(intent, opts)
}
export async function markIntentPaid (intentId, notes = '') {
// 0) pega intenção na VIEW (pra descobrir plan_target e validar estado)
const intent = await fetchIntentFromView(intentId)
if (intent.status === 'paid') {
// idempotente: ainda tenta ativar a subscription a partir do intent (caso tenha falhado antes)
const { data: sub, error: rpcErr } = await supabase.rpc('activate_subscription_from_intent', {
p_intent_id: intentId
})
if (rpcErr) throw rpcErr
const merged = await fetchIntentFromView(intentId)
return { intent: merged || intent, subscription: sub || null }
}
if (intent.status === 'canceled') {
throw new Error('Intenção cancelada não pode ser marcada como paga.')
}
const table = getWriteTableByTarget(intent.plan_target)
if (!table) {
throw new Error('plan_target inválido na intenção (esperado clinic/therapist).')
}
// 1) marca como pago na TABELA REAL (write)
const patch = {
status: 'paid',
paid_at: new Date().toISOString(),
notes: notes || null
}
const { data: updated, error: upErr } = await supabase
.from(table)
.update(patch)
.eq('id', intentId)
.select('*')
.maybeSingle()
if (upErr) throw upErr
// 2) ativa subscription do tenant (Modelo B)
// 2) ativa assinatura a partir da intenção
const { data: sub, error: rpcErr } = await supabase.rpc('activate_subscription_from_intent', {
p_intent_id: intentId
})
if (rpcErr) throw rpcErr
return { intent: updated, subscription: sub }
// 3) retorna visão unificada + assinatura
const merged = await fetchIntentFromView(intentId)
return { intent: merged || updated, subscription: sub || null }
}
export async function cancelIntent(intentId, notes = '') {
export async function cancelIntent (intentId, notes = '') {
// 0) pega intenção na VIEW (pra descobrir plan_target e validar estado)
const intent = await fetchIntentFromView(intentId)
if (intent.status === 'canceled') return intent
if (intent.status === 'paid') {
// regra de negócio: se você quiser permitir cancelar paid, mude aqui.
throw new Error('Intenção já paga não deve ser cancelada. Cancele a assinatura, não a intenção.')
}
const table = getWriteTableByTarget(intent.plan_target)
if (!table) {
throw new Error('plan_target inválido na intenção (esperado clinic/therapist).')
}
const { data, error } = await supabase
.from('subscription_intents')
.from(table)
.update({
status: 'canceled',
notes: notes || null
@@ -59,5 +194,8 @@ export async function cancelIntent(intentId, notes = '') {
.maybeSingle()
if (error) throw error
return data
}
// devolve a visão unificada
const merged = await fetchIntentFromView(intentId)
return merged || data
}

View File

@@ -2,64 +2,105 @@
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase/client'
function normalizeKey(k) {
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
}
export const useEntitlementsStore = defineStore('entitlements', {
state: () => ({
loading: false,
// =========================
// Tenant entitlements (B)
// =========================
tenantLoading: false,
loadedForTenant: null,
features: [], // array reativo de feature_key liberadas
raw: [],
error: null,
loadedAt: null
tenantFeatures: [],
tenantRaw: [],
tenantError: null,
tenantLoadedAt: null,
// =========================
// User entitlements (A)
// =========================
userLoading: false,
loadedForUser: null,
userFeatures: [],
userRaw: [],
userError: null,
userLoadedAt: null
}),
getters: {
can: (state) => (featureKey) => state.features.includes(featureKey),
has: (state) => (featureKey) => state.features.includes(featureKey)
/**
* ✅ 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)
// 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)
}
},
actions: {
async fetch(tenantId, opts = {}) {
// =========================
// Compat: fetch() continua existindo
// =========================
async fetch (tenantId, opts = {}) {
return this.loadForTenant(tenantId, opts)
},
clear() {
clear () {
return this.invalidate()
},
/**
* Carrega entitlements do tenant.
* Importante: quando o plano muda, tenantId é o mesmo,
* então você DEVE chamar com { force: true }.
*
* opts:
* - force: ignora cache do tenant
* - maxAgeMs: se definido, recarrega quando loadedAt estiver velho
*/
async loadForTenant(tenantId, { force = false, maxAgeMs = 0 } = {}) {
// =========================
// Tenant (clinic) — view v_tenant_entitlements
// =========================
async loadForTenant (tenantId, { force = false, maxAgeMs = 0 } = {}) {
if (!tenantId) {
this.invalidate()
this.invalidateTenant()
return
}
const sameTenant = this.loadedForTenant === tenantId
const hasLoadedAt = typeof this.loadedAt === 'number'
const isFresh =
sameTenant &&
hasLoadedAt &&
maxAgeMs > 0 &&
Date.now() - this.loadedAt < maxAgeMs
const same = this.loadedForTenant === tenantId
const hasLoadedAt = typeof this.tenantLoadedAt === 'number'
const isFresh = same && hasLoadedAt && maxAgeMs > 0 && (Date.now() - this.tenantLoadedAt < maxAgeMs)
if (!force && sameTenant && (maxAgeMs === 0 || isFresh)) return
if (!force && same && (maxAgeMs === 0 || isFresh)) return
this.loading = true
this.error = null
this.tenantLoading = true
this.tenantError = null
try {
// ✅ Modelo B: entitlements por tenant (view)
const { data, error } = await supabase
.from('v_tenant_entitlements')
.select('feature_key')
@@ -68,40 +109,92 @@ export const useEntitlementsStore = defineStore('entitlements', {
if (error) throw error
const rows = data ?? []
this.tenantRaw = rows
this.tenantFeatures = uniqKeys(rows, 'feature_key')
// normaliza, remove vazios e duplicados
const list = []
const seen = new Set()
for (const r of rows) {
const key = normalizeKey(r?.feature_key)
if (!key) continue
if (seen.has(key)) continue
seen.add(key)
list.push(key)
}
this.raw = rows
this.features = list
this.loadedForTenant = tenantId
this.loadedAt = Date.now()
this.tenantLoadedAt = Date.now()
} catch (e) {
this.error = e
this.raw = []
this.features = []
this.tenantError = e
this.tenantRaw = []
this.tenantFeatures = []
this.loadedForTenant = tenantId
this.loadedAt = Date.now()
this.tenantLoadedAt = Date.now()
} finally {
this.loading = false
this.tenantLoading = false
}
},
invalidate() {
// =========================
// 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.features = []
this.raw = []
this.error = null
this.loadedAt = null
this.loading = false
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()
}
}
})
})

23
src/stores/menuStore.js Normal file
View File

@@ -0,0 +1,23 @@
// src/stores/menuStore.js
import { defineStore } from 'pinia'
export const useMenuStore = defineStore('menuStore', {
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
}
}
})

View File

@@ -1,26 +1,60 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
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)
// Cache por tenant: { [tenantId]: { [feature_key]: boolean } }
const featuresByTenant = ref({})
// Marca o último tenant buscado (útil pra debug)
const loadedForTenantId = ref(null)
const features = ref({}) // { patients: true/false, ... }
function isEnabled(key) {
return !!features.value?.[key]
function getTenantMap (tenantId) {
if (!tenantId) return {}
return featuresByTenant.value?.[tenantId] || {}
}
function invalidate() {
loadedForTenantId.value = null
features.value = {}
// 🔎 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]
}
async function fetchForTenant(tenantId, { force = false } = {}) {
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
if (!force && loadedForTenantId.value === tenantId) return
// se já tem cache e não é force, não busca de novo
if (!force && featuresByTenant.value?.[tenantId]) {
loadedForTenantId.value = tenantId
return
}
loading.value = true
lastError.value = null
try {
const { data, error } = await supabase
.from('tenant_features')
@@ -32,35 +66,60 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
const map = {}
for (const row of data || []) map[row.feature_key] = !!row.enabled
features.value = map
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) {
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(
{ tenant_id: tenantId, feature_key: key, enabled: !!enabled },
{ onConflict: 'tenant_id,feature_key' }
)
.upsert(payload, { onConflict: 'tenant_id,feature_key' })
if (error) throw error
// atualiza cache local
if (loadedForTenantId.value === tenantId) {
features.value = { ...features.value, [key]: !!enabled }
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,
features,
lastError,
lastFetchedAt,
loadedForTenantId,
featuresByTenant,
currentFeatures,
isEnabled,
invalidate,
fetchForTenant,

View File

@@ -2,70 +2,119 @@
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase/client'
// ✅ normaliza roles vindas do backend (tenant_members / RPC my_tenants)
// - seu projeto quer usar clinic_admin como nome canônico
function normalizeTenantRole (role) {
/**
* Normaliza o role de tenant levando em conta o kind do tenant.
*
* Regras:
* tenant_admin / admin + kind = 'therapist' → 'therapist'
* tenant_admin / admin + kind = clinic_* → 'clinic_admin'
* 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
// ✅ legado: alguns bancos / RPCs retornam tenant_admin
if (r === 'tenant_admin') return 'clinic_admin'
const isAdmin = (r === 'tenant_admin' || r === 'admin')
// (opcional) se em algum lugar vier 'admin' (profiles), também normaliza:
if (r === 'admin') 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
}
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 clearPersistedTenant () {
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
}
export const useTenantStore = defineStore('tenant', {
state: () => ({
loading: false,
loaded: false,
user: null, // auth user
memberships: [], // [{ tenant_id, role, status }]
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()
},
async loadSessionAndTenant () {
if (this.loading) return
this.loading = true
this.error = null
try {
// 1) auth user (estável)
const { data, error } = await supabase.auth.getSession()
if (error) throw error
this.user = data?.session?.user ?? null
// sem sessão -> limpa estado e storage
if (!this.user) {
this.memberships = []
this.activeTenantId = null
this.activeRole = null
this.needsTenantLink = false
this.loaded = true
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
clearPersistedTenant()
return
}
// 2) memberships via RPC
const { data: mem, error: mErr } = await supabase.rpc('my_tenants')
if (mErr) throw mErr
this.memberships = Array.isArray(mem) ? mem : []
// 3) tenta restaurar tenant salvo
const savedTenantId = localStorage.getItem('tenant_id')
// ✅ 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
@@ -73,6 +122,10 @@ export const useTenantStore = defineStore('tenant', {
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
@@ -81,37 +134,26 @@ export const useTenantStore = defineStore('tenant', {
}
this.activeTenantId = activeMembership?.tenant_id ?? null
this.activeRole = normalizeTenantRole(activeMembership?.role, activeMembership?.kind)
// ✅ normaliza role aqui (tenant_admin -> clinic_admin)
this.activeRole = normalizeTenantRole(activeMembership?.role)
// persiste tenant se existir
if (this.activeTenantId) {
localStorage.setItem('tenant_id', this.activeTenantId)
localStorage.setItem('tenant', JSON.stringify({
id: this.activeTenantId,
role: this.activeRole
}))
persistTenant(this.activeTenantId, this.activeRole)
} else {
clearPersistedTenant()
}
// se logou mas não tem vínculo ativo
this.needsTenantLink = !this.activeTenantId
this.loaded = true
} catch (e) {
console.warn('[tenantStore] loadSessionAndTenant falhou:', e)
this.error = e
// ⚠️ NÃO zera tudo agressivamente por erro transitório.
// Mantém o que já tinha (se tiver), mas marca loaded pra não travar o app.
if (!this.user) {
this.memberships = []
this.activeTenantId = null
this.activeRole = null
this.needsTenantLink = false
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
clearPersistedTenant()
}
this.loaded = true
@@ -126,25 +168,16 @@ export const useTenantStore = defineStore('tenant', {
)
this.activeTenantId = found?.tenant_id ?? null
// ✅ normaliza role também ao trocar tenant
this.activeRole = normalizeTenantRole(found?.role)
this.activeRole = normalizeTenantRole(found?.role, found?.kind)
this.needsTenantLink = !this.activeTenantId
if (this.activeTenantId) {
localStorage.setItem('tenant_id', this.activeTenantId)
localStorage.setItem('tenant', JSON.stringify({
id: this.activeTenantId,
role: this.activeRole
}))
persistTenant(this.activeTenantId, this.activeRole)
} else {
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
clearPersistedTenant()
}
},
// opcional mas recomendado
reset () {
this.user = null
this.memberships = []
@@ -153,9 +186,7 @@ export const useTenantStore = defineStore('tenant', {
this.needsTenantLink = false
this.error = null
this.loaded = false
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
clearPersistedTenant()
}
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Button from 'primevue/button'
// Se você ainda usa o FloatingConfigurator no template de páginas públicas,
// pode manter. Se não usa, pode remover tranquilamente.

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
<template>
<div class="p-4">
<h1>My Appointments</h1>
</div>
</template>
<script setup>
// temporary placeholder
</script>

View File

@@ -1,9 +0,0 @@
<template>
<div class="p-4">
<h1>Add New Appointments</h1>
</div>
</template>
<script setup>
// temporary placeholder
</script>

View File

@@ -1,330 +0,0 @@
<template>
<div class="p-4 md:p-6">
<Toast />
<!-- Header -->
<div class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5 md:p-7">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-20 h-72 w-72 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-80 w-80 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-20 right-24 h-72 w-72 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative flex flex-col gap-2">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<h1 class="text-xl md:text-2xl font-semibold leading-tight">Módulos da Clínica</h1>
<p class="mt-1 text-sm opacity-80">
Ative/desative recursos por clínica. Isso controla menu, rotas (guard) e acesso no banco (RLS).
</p>
</div>
<div class="shrink-0 flex items-center gap-2">
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
:loading="loading"
@click="reload"
/>
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs opacity-80">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-building" />
Tenant: <b class="font-mono">{{ tenantId || '—' }}</b>
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-user" />
Role: <b>{{ role || '—' }}</b>
</span>
</div>
</div>
</div>
</div>
<!-- Presets -->
<div class="mb-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Coworking</div>
<div class="mt-1 text-xs opacity-80">
Para aluguel de salas: sem pacientes, com salas.
</div>
</div>
<Button size="small" label="Aplicar" severity="secondary" :loading="applyingPreset" @click="applyPreset('coworking')" />
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Clínica com recepção</div>
<div class="mt-1 text-xs opacity-80">
Para secretária gerenciar agenda (pacientes opcional).
</div>
</div>
<Button size="small" label="Aplicar" severity="secondary" :loading="applyingPreset" @click="applyPreset('reception')" />
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Clínica completa</div>
<div class="mt-1 text-xs opacity-80">
Pacientes + recepção + salas (se quiser).
</div>
</div>
<Button size="small" label="Aplicar" severity="secondary" :loading="applyingPreset" @click="applyPreset('full')" />
</div>
</template>
</Card>
</div>
<!-- Modules -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Pacientes"
desc="Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist)."
icon="pi pi-users"
:enabled="isOn('patients')"
:loading="savingKey === 'patients'"
@toggle="toggle('patients')"
/>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
Quando desligado:
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>Menu Pacientes some.</li>
<li>Rotas com <span class="font-mono">meta.tenantFeature = 'patients'</span> redirecionam pra .</li>
<li>RLS bloqueia acesso direto no banco.</li>
</ul>
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Recepção / Secretária"
desc="Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente)."
icon="pi pi-briefcase"
:enabled="isOn('shared_reception')"
:loading="savingKey === 'shared_reception'"
@toggle="toggle('shared_reception')"
/>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
Observação: este módulo é produto (UX + permissões). A base aqui é o toggle.
Depois a gente cria:
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>role <span class="font-mono">secretary</span> em <span class="font-mono">tenant_members</span></li>
<li>policies e telas para a secretária</li>
<li>nível de visibilidade do paciente na agenda</li>
</ul>
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Salas / Coworking"
desc="Habilita cadastro e reserva de salas/recursos no agendamento."
icon="pi pi-building"
:enabled="isOn('rooms')"
:loading="savingKey === 'rooms'"
@toggle="toggle('rooms')"
/>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Link externo de cadastro"
desc="Libera fluxo público de intake/cadastro externo para a clínica."
icon="pi pi-link"
:enabled="isOn('intake_public')"
:loading="savingKey === 'intake_public'"
@toggle="toggle('intake_public')"
/>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
Você tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
</div>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ToggleButton from 'primevue/togglebutton'
import { useTenantStore } from '@/stores/tenantStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
const toast = useToast()
const tenantStore = useTenantStore()
const tf = useTenantFeaturesStore()
const loading = computed(() => tf.loading)
const tenantId = computed(() => tenantStore.activeTenantId || null)
const role = computed(() => tenantStore.activeRole || null)
const savingKey = ref(null)
const applyingPreset = ref(false)
function isOn (key) {
return tf.isEnabled(key)
}
async function reload () {
if (!tenantId.value) return
await tf.fetchForTenant(tenantId.value, { force: true })
}
async function toggle (key) {
if (!tenantId.value) {
toast.add({ severity: 'warn', summary: 'Sem tenant ativo', detail: 'Selecione/ative um tenant primeiro.', life: 2500 })
return
}
savingKey.value = key
try {
const next = !tf.isEnabled(key)
await tf.setForTenant(tenantId.value, key, next)
toast.add({
severity: 'success',
summary: 'Atualizado',
detail: `${labelOf(key)}: ${next ? 'Ativado' : 'Desativado'}`,
life: 2500
})
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao atualizar módulo', life: 3500 })
} finally {
savingKey.value = null
}
}
function labelOf (key) {
if (key === 'patients') return 'Pacientes'
if (key === 'shared_reception') return 'Recepção / Secretária'
if (key === 'rooms') return 'Salas / Coworking'
if (key === 'intake_public') return 'Link externo de cadastro'
return key
}
async function applyPreset (preset) {
if (!tenantId.value) return
applyingPreset.value = true
try {
// Presets = sets mínimos (nada destrói dados; só liga/desliga acesso/UX)
const map = {
coworking: {
patients: false,
shared_reception: false,
rooms: true,
intake_public: false
},
reception: {
patients: false,
shared_reception: true,
rooms: false,
intake_public: false
},
full: {
patients: true,
shared_reception: true,
rooms: true,
intake_public: true
}
}
const cfg = map[preset]
if (!cfg) return
// aplica sequencialmente (simples e previsível)
for (const [k, v] of Object.entries(cfg)) {
await tf.setForTenant(tenantId.value, k, v)
}
toast.add({ severity: 'success', summary: 'Preset aplicado', detail: 'Configuração atualizada.', life: 2500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao aplicar preset', life: 3500 })
} finally {
applyingPreset.value = false
}
}
onMounted(async () => {
if (!tenantStore.loaded && !tenantStore.loading) {
await tenantStore.loadSessionAndTenant()
}
if (tenantId.value) {
await tf.fetchForTenant(tenantId.value, { force: false })
}
})
/**
* Sub-componente local: linha de módulo
* (mantive aqui pra você visualizar rápido sem criar pasta de components)
*/
const ModuleRow = {
props: {
title: String,
desc: String,
icon: String,
enabled: Boolean,
loading: Boolean
},
emits: ['toggle'],
components: { ToggleButton },
template: `
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<i :class="icon" class="opacity-80" />
<div class="text-base font-semibold">{{ title }}</div>
</div>
<div class="mt-1 text-sm opacity-80">{{ desc }}</div>
</div>
<div class="shrink-0">
<ToggleButton
:modelValue="enabled"
onLabel="Ativo"
offLabel="Inativo"
:loading="loading"
@update:modelValue="$emit('toggle')"
/>
</div>
</div>
`
}
</script>

View File

@@ -2,15 +2,12 @@
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
import { useTenantStore } from '@/stores/tenantStore'
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '../../../lib/supabase/client'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Checkbox from 'primevue/checkbox'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { useToast } from 'primevue/usetoast'
// ✅ sessão (fonte de verdade p/ saas admin)
@@ -33,6 +30,47 @@ const recoveryEmail = ref('')
const loadingRecovery = ref(false)
const recoverySent = ref(false)
// carrossel
const slides = [
{
title: 'Gestão clínica simplificada',
body: 'Agendamentos, prontuários e sessões em um único painel. Foco no que importa: seus pacientes.',
icon: 'pi-calendar-clock',
},
{
title: 'Múltiplos profissionais',
body: 'Adicione terapeutas, gerencie vínculos e mantenha tudo organizado por clínica.',
icon: 'pi-users',
},
{
title: 'Agendamento online',
body: 'Seus pacientes marcam sessões diretamente pelo portal, com confirmação automática.',
icon: 'pi-globe',
},
{
title: 'Seguro e privado',
body: 'Dados protegidos com autenticação robusta e controle de acesso por perfil.',
icon: 'pi-shield',
},
]
const currentSlide = ref(0)
let slideInterval = null
function goToSlide (i) {
currentSlide.value = i
}
function startCarousel () {
slideInterval = setInterval(() => {
currentSlide.value = (currentSlide.value + 1) % slides.length
}, 4500)
}
function stopCarousel () {
if (slideInterval) clearInterval(slideInterval)
}
const canSubmit = computed(() => {
return !!email.value?.trim() && !!password.value && !loading.value && !loadingRecovery.value
})
@@ -42,10 +80,11 @@ function isEmail (v) {
}
function roleToPath (role) {
// ✅ aceita os dois nomes (seu banco está devolvendo tenant_admin)
if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin'
if (role === 'therapist') return '/therapist'
if (role === 'patient') return '/portal'
if (role === 'supervisor') return '/supervisor'
if (role === 'portal_user' || role === 'patient') return '/portal'
if (role === 'saas_admin') return '/saas'
return '/'
}
@@ -64,6 +103,14 @@ async function onSubmit () {
loading.value = true
try {
// 🔥 HARD RESET do contexto tenant ANTES de autenticar
try { tenant.reset() } catch {}
try {
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
localStorage.removeItem('currentTenantId')
} catch {}
const mail = String(email.value || '').trim()
const res = await supabase.auth.signInWithPassword({
@@ -74,19 +121,14 @@ async function onSubmit () {
if (res.error) throw res.error
// ✅ garante que sessionIsSaasAdmin esteja hidratado após login
// (evita cair no fluxo de tenant quando o usuário é SaaS master)
try {
await initSession({ initial: false })
} catch (e) {
console.warn('[Login] initSession pós-login falhou:', e)
// não aborta login por isso
}
// lembrar e-mail (não senha)
persistRememberedEmail()
// ✅ prioridade: redirect_after_login (se existir)
// mas antes, se for SaaS admin, NÃO exigir tenant.
const redirect = sessionStorage.getItem('redirect_after_login')
if (sessionIsSaasAdmin.value) {
if (redirect) {
@@ -98,46 +140,94 @@ async function onSubmit () {
return
}
// ✅ agora que está autenticado, garante tenant pessoal (Modelo B)
try {
await supabase.rpc('ensure_personal_tenant')
} catch (e) {
console.warn('[Login] ensure_personal_tenant falhou:', e)
// não aborta login por isso
}
// 🔥 identidade global (profiles.role) define área macro
const { data: profile, error: pErr } = await supabase
.from('profiles')
.select('role')
.eq('id', res.data.user.id)
.single()
// ✅ 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 (pErr) throw pErr
if (!tenant.user) {
authError.value = 'Não foi possível obter a sessão após login.'
const globalRole = profile?.role || null
if (!globalRole) {
authError.value = 'Perfil não configurado corretamente.'
return
}
if (!tenant.activeRole) {
authError.value = 'Sua conta não tem vínculo ativo com uma clínica (tenant_members).'
await supabase.auth.signOut()
return
const safeRedirect = (path) => {
const p = String(path || '')
if (!p) return null
if (globalRole === 'saas_admin') return (p === '/saas' || p.startsWith('/saas/')) ? p : '/saas'
if (globalRole === 'portal_user') return (p === '/portal' || p.startsWith('/portal/')) ? p : '/portal'
if (globalRole === 'tenant_member') {
return (p.startsWith('/admin') || p.startsWith('/therapist') || p.startsWith('/supervisor')) ? p : null
}
return null
}
let tenantTarget = null
if (globalRole === 'tenant_member') {
try {
await supabase.rpc('ensure_personal_tenant')
} catch (e) {
console.warn('[Login] ensure_personal_tenant falhou:', e)
}
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.'
return
}
if (!tenant.activeRole) {
authError.value = 'Sua conta não tem vínculo ativo com uma clínica (tenant_members).'
await supabase.auth.signOut()
return
}
tenantTarget = roleToPath(tenant.activeRole)
} else {
try {
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
localStorage.removeItem('currentTenantId')
} catch {}
}
// ✅ se havia redirect, vai pra ele
if (redirect) {
sessionStorage.removeItem('redirect_after_login')
router.push(redirect)
const sr = safeRedirect(redirect)
if (sr) {
router.push(sr)
return
}
if (globalRole === 'tenant_member' && tenantTarget) {
router.push(tenantTarget)
return
}
router.push(globalRole === 'portal_user' ? '/portal' : globalRole === 'saas_admin' ? '/saas' : '/')
return
}
const intended = sessionStorage.getItem('intended_area')
sessionStorage.removeItem('intended_area')
const target = roleToPath(tenant.activeRole)
let target = '/'
if (intended && intended !== tenant.activeRole) {
if (globalRole === 'tenant_member') target = tenantTarget || '/therapist'
else if (globalRole === 'portal_user') target = '/portal'
else if (globalRole === 'saas_admin') target = '/saas'
if (intended && intended !== globalRole) {
router.push(target)
return
}
@@ -178,11 +268,9 @@ async function sendRecoveryEmail () {
}
onMounted(() => {
// legado: prefill via sessionStorage (mantive)
const preEmail = sessionStorage.getItem('login_prefill_email')
const prePass = sessionStorage.getItem('login_prefill_password')
// lembrar e-mail via localStorage (novo)
let remembered = ''
try {
remembered = localStorage.getItem('remember_login_email') || ''
@@ -197,287 +285,304 @@ onMounted(() => {
sessionStorage.removeItem('login_prefill_email')
sessionStorage.removeItem('login_prefill_password')
startCarousel()
})
onBeforeUnmount(() => {
stopCarousel()
})
</script>
<template>
<FloatingConfigurator />
<div class="relative min-h-screen w-full overflow-hidden bg-[var(--surface-ground)]">
<!-- fundo conceitual -->
<div class="pointer-events-none absolute inset-0">
<!-- grid muito sutil -->
<div class="min-h-screen w-full flex">
<!-- ===== ESQUERDA: CARROSSEL ===== -->
<div class="hidden lg:flex lg:w-1/2 relative overflow-hidden flex-col">
<!-- Fundo gradiente -->
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
<!-- Grade decorativa -->
<div
class="absolute inset-0 opacity-70"
style="
background-image:
linear-gradient(to right, rgba(255,255,255,.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,.05) 1px, transparent 1px);
background-size: 38px 38px;
mask-image: radial-gradient(ellipse at 50% 20%, rgba(0,0,0,.95), transparent 70%);
"
class="absolute inset-0 opacity-[0.08]"
style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px); background-size: 48px 48px;"
/>
<!-- halos -->
<div class="absolute -top-28 -right-28 h-[26rem] w-[26rem] rounded-full blur-3xl bg-indigo-400/10" />
<div class="absolute top-20 -left-28 h-[30rem] w-[30rem] rounded-full blur-3xl bg-emerald-400/10" />
<div class="absolute -bottom-32 right-24 h-[26rem] w-[26rem] rounded-full blur-3xl bg-fuchsia-400/10" />
</div>
<div class="relative grid min-h-screen place-items-center p-4 md:p-8">
<div class="w-full max-w-5xl">
<div class="relative overflow-hidden rounded-[2.75rem] border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-2xl">
<!-- header -->
<div class="relative px-6 pt-7 pb-5 md:px-10 md:pt-10 md:pb-6">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-3">
<div class="grid h-12 w-12 place-items-center rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<i class="pi pi-eye text-lg opacity-80" />
</div>
<!-- Orbs -->
<div class="absolute -top-40 -left-40 h-[32rem] w-[32rem] rounded-full bg-white/10 blur-3xl pointer-events-none" />
<div class="absolute bottom-0 right-0 h-80 w-80 rounded-full bg-violet-300/20 blur-3xl pointer-events-none" />
<div class="absolute top-1/2 -right-20 h-64 w-64 rounded-full bg-indigo-300/10 blur-3xl pointer-events-none" />
<div class="min-w-0">
<div class="text-2xl md:text-3xl font-semibold leading-tight text-[var(--text-color)]">
Entrar
</div>
<div class="mt-1 text-sm md:text-base text-[var(--text-color-secondary)]">
Acesso seguro ao seu painel.
</div>
</div>
</div>
<!-- Conteúdo -->
<div class="relative z-10 flex flex-col h-full p-10 xl:p-14">
<div class="hidden md:flex items-center gap-2">
<RouterLink
to="/"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs font-medium text-[var(--text-color-secondary)] hover:opacity-80"
title="Atalho para a página de logins de desenvolvimento"
>
<i class="pi pi-code text-xs opacity-80" />
Desenvolvedor Logins
</RouterLink>
<RouterLink
:to="{ name: 'resetPassword' }"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs font-medium text-[var(--text-color-secondary)] hover:opacity-80"
>
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400/70" />
Trocar senha
</RouterLink>
</div>
<div class="col-span-12 md:hidden">
<RouterLink
to="/"
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-sm font-medium text-[var(--text-color-secondary)] hover:opacity-80"
>
<i class="pi pi-code opacity-80" />
Desenvolvedor Logins
</RouterLink>
</div>
</div>
</div>
<!-- corpo -->
<div class="relative px-6 pb-7 md:px-10 md:pb-10">
<div class="grid grid-cols-12 gap-4 md:gap-6">
<!-- FORM -->
<div class="col-span-12 md:col-span-7">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 md:p-6">
<form class="grid grid-cols-12 gap-4" @submit.prevent="onSubmit">
<!-- email -->
<div class="col-span-12">
<label class="block text-sm font-semibold text-[var(--text-color)] mb-2">
E-mail
</label>
<InputText
v-model="email"
class="w-full"
placeholder="seuemail@dominio.com"
autocomplete="email"
:disabled="loading || loadingRecovery"
/>
</div>
<!-- senha -->
<div class="col-span-12">
<label class="block text-sm font-semibold text-[var(--text-color)] mb-2">
Senha
</label>
<Password
v-model="password"
placeholder="Sua senha"
toggleMask
:feedback="false"
class="w-full"
inputClass="w-full"
autocomplete="current-password"
:disabled="loading || loadingRecovery"
/>
</div>
<!-- lembrar + esqueci -->
<div class="col-span-12 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center">
<Checkbox
v-model="checked"
inputId="rememberme1"
binary
class="mr-2"
:disabled="loading || loadingRecovery"
/>
<label for="rememberme1" class="text-sm text-[var(--text-color-secondary)]">
Lembrar meu e-mail neste dispositivo
</label>
</div>
<button
type="button"
class="text-sm font-medium text-[var(--primary-color)] hover:opacity-80 text-left"
:disabled="loading || loadingRecovery"
@click="openForgot"
>
Esqueceu sua senha?
</button>
</div>
<!-- erro -->
<div v-if="authError" class="col-span-12">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-sm text-red-500">
<i class="pi pi-exclamation-triangle mr-2 opacity-80" />
{{ authError }}
</div>
</div>
<!-- submit -->
<div class="col-span-12">
<Button
type="submit"
label="Entrar"
class="w-full"
icon="pi pi-sign-in"
:loading="loading"
:disabled="!canSubmit"
/>
</div>
<div class="col-span-12 text-xs text-[var(--text-color-secondary)]">
Ao entrar, você será direcionado para sua área conforme seu perfil e vínculo com a clínica.
</div>
<!-- detalhe minimalista -->
<div class="col-span-12">
<div class="h-px w-full bg-[var(--surface-border)] opacity-70" />
</div>
<div class="col-span-12 text-xs text-[var(--text-color-secondary)]">
Se você estiver testando perfis e cair na mensagem de vínculo, é porque o acesso depende de <span class="font-semibold">tenant_members</span>.
</div>
</form>
</div>
</div>
<!-- LADO DIREITO: editorial / conceito -->
<div class="col-span-12 md:col-span-5">
<div class="h-full rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 md:p-6">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Acesso com lastro</div>
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
A sessão é validada e o vínculo com a clínica define sua área.
</div>
</div>
<i class="pi pi-shield text-sm opacity-70" />
</div>
<div class="mt-5 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="text-xs text-[var(--text-color-secondary)] leading-relaxed">
<span class="font-semibold text-[var(--text-color)]">Como funciona:</span>
você autentica, o sistema carrega seu tenant ativo e então libera o painel correspondente.
Isso evita acesso solto e organiza permissões no lugar certo.
</div>
</div>
<ul class="mt-5 space-y-2 text-xs text-[var(--text-color-secondary)]">
<li class="flex gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400/70"></span>
Recuperação de senha via link (e-mail).
</li>
<li class="flex gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400/70"></span>
Se o link não chegar, cheque spam/lixo eletrônico.
</li>
<li class="flex gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400/70"></span>
O redirecionamento depende da role ativa: admin/therapist/patient.
</li>
</ul>
<div class="mt-5 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-xs text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle mr-2 opacity-70" />
Garanta que o Supabase tenha Redirect URLs incluindo
<span class="font-semibold">/auth/reset-password</span>.
</div>
<div class="mt-6 hidden md:flex items-center justify-between text-xs text-[var(--text-color-secondary)] opacity-80">
<span class="inline-flex items-center gap-2">
<span class="h-1.5 w-1.5 rounded-full bg-primary/60" />
Agência Psi Quasar
</span>
<span class="opacity-80">Acesso clínico</span>
</div>
</div>
</div>
</div>
<!-- Dialog recovery -->
<Dialog
v-model:visible="openRecovery"
modal
header="Recuperar acesso"
:draggable="false"
:style="{ width: '28rem', maxWidth: '92vw' }"
>
<div class="space-y-3">
<div class="text-sm text-[var(--text-color-secondary)]">
Informe seu e-mail. Vamos enviar um link para redefinir sua senha.
</div>
<div class="space-y-2">
<label class="text-sm font-semibold">E-mail</label>
<InputText
v-model="recoveryEmail"
class="w-full"
placeholder="seuemail@dominio.com"
:disabled="loadingRecovery"
/>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:justify-end pt-2">
<Button
label="Cancelar"
severity="secondary"
outlined
:disabled="loadingRecovery"
@click="openRecovery = false"
/>
<Button
label="Enviar link"
icon="pi pi-envelope"
:loading="loadingRecovery"
@click="sendRecoveryEmail"
/>
</div>
<div
v-if="recoverySent"
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3 text-xs text-[var(--text-color-secondary)]"
>
<i class="pi pi-check mr-2 text-emerald-500"></i>
Se o e-mail existir, você receberá o link em instantes. Verifique também spam/lixo eletrônico.
</div>
</div>
</Dialog>
<!-- Brand -->
<div class="flex items-center gap-3">
<div class="grid h-10 w-10 place-items-center rounded-xl bg-white/20 backdrop-blur-sm border border-white/20 shadow-lg">
<i class="pi pi-heart-fill text-white text-sm" />
</div>
<span class="text-white font-bold text-lg tracking-tight">Agência PSI</span>
</div>
<!-- Slides -->
<div class="flex-1 flex flex-col justify-center">
<Transition name="slide-fade" mode="out-in">
<div :key="currentSlide" class="space-y-6">
<div class="grid h-16 w-16 place-items-center rounded-2xl bg-white/15 backdrop-blur-sm border border-white/20 shadow-lg">
<i :class="['pi', slides[currentSlide].icon, 'text-white text-2xl']" />
</div>
<div class="space-y-4">
<h2 class="text-3xl xl:text-4xl font-bold text-white leading-tight">
{{ slides[currentSlide].title }}
</h2>
<p class="text-base xl:text-lg text-white/70 leading-relaxed max-w-sm">
{{ slides[currentSlide].body }}
</p>
</div>
</div>
</Transition>
</div>
<!-- Dots -->
<div class="flex items-center gap-2">
<button
v-for="(_, i) in slides"
:key="i"
class="transition-all duration-300 rounded-full focus:outline-none"
:class="i === currentSlide ? 'w-7 h-2 bg-white shadow' : 'w-2 h-2 bg-white/35 hover:bg-white/60'"
@click="goToSlide(i)"
/>
<span class="ml-3 text-xs text-white/40 tabular-nums">{{ currentSlide + 1 }}/{{ slides.length }}</span>
</div>
</div>
</div>
<!-- ===== DIREITA: FORMULÁRIO ===== -->
<div class="flex-1 lg:w-1/2 flex flex-col min-h-screen bg-[var(--surface-ground)] overflow-y-auto relative">
<!-- Halos sutis de fundo -->
<div class="pointer-events-none absolute inset-0">
<div class="absolute top-10 right-10 h-64 w-64 rounded-full blur-3xl bg-indigo-400/5" />
<div class="absolute bottom-10 left-10 h-56 w-56 rounded-full blur-3xl bg-violet-400/5" />
</div>
<div class="relative flex flex-col flex-1 justify-center px-6 py-10 sm:px-10 lg:px-12 xl:px-16 w-full max-w-lg mx-auto">
<!-- Mobile: Brand -->
<div class="flex lg:hidden items-center gap-2 mb-8">
<div class="grid h-8 w-8 place-items-center rounded-lg bg-indigo-500/10 border border-indigo-500/20">
<i class="pi pi-heart-fill text-indigo-500 text-xs" />
</div>
<span class="text-[var(--text-color)] font-bold text-base tracking-tight">Agência PSI</span>
</div>
<!-- Cabeçalho -->
<div class="mb-7">
<h1 class="text-3xl font-bold text-[var(--text-color)] leading-tight">Bem-vindo de volta</h1>
<p class="mt-1.5 text-sm text-[var(--text-color-secondary)]">Entre com sua conta para continuar</p>
</div>
<!-- Login social (marcação em breve) -->
<div class="grid grid-cols-2 gap-3 mb-5">
<button
type="button"
disabled
title="Em breve"
class="flex items-center justify-center gap-2 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-2.5 text-sm font-medium text-[var(--text-color-secondary)] opacity-50 cursor-not-allowed"
>
<svg class="h-4 w-4 flex-shrink-0" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Google
</button>
<button
type="button"
disabled
title="Em breve"
class="flex items-center justify-center gap-2 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-2.5 text-sm font-medium text-[var(--text-color-secondary)] opacity-50 cursor-not-allowed"
>
<svg class="h-4 w-4 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
</svg>
Apple
</button>
</div>
<!-- Divisor -->
<div class="flex items-center gap-3 mb-5">
<div class="h-px flex-1 bg-[var(--surface-border)]" />
<span class="text-xs text-[var(--text-color-secondary)] font-medium whitespace-nowrap">ou continue com e-mail</span>
<div class="h-px flex-1 bg-[var(--surface-border)]" />
</div>
<!-- Formulário -->
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">E-mail</label>
<InputText
v-model="email"
class="w-full"
placeholder="seuemail@dominio.com"
autocomplete="email"
:disabled="loading || loadingRecovery"
/>
</div>
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">Senha</label>
<Password
v-model="password"
placeholder="Sua senha"
toggleMask
:feedback="false"
class="w-full"
inputClass="w-full"
autocomplete="current-password"
:disabled="loading || loadingRecovery"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Checkbox
v-model="checked"
inputId="rememberme1"
binary
:disabled="loading || loadingRecovery"
/>
<label for="rememberme1" class="text-sm text-[var(--text-color-secondary)] cursor-pointer select-none">
Lembrar e-mail
</label>
</div>
<button
type="button"
class="text-sm font-medium text-indigo-500 hover:text-indigo-600 transition-colors"
:disabled="loading || loadingRecovery"
@click="openForgot"
>
Esqueceu a senha?
</button>
</div>
<!-- Erro -->
<div
v-if="authError"
class="rounded-xl border border-red-200 bg-red-50 dark:border-red-900/30 dark:bg-red-950/20 px-4 py-3 text-sm text-red-600 dark:text-red-400 flex items-center gap-2"
>
<i class="pi pi-exclamation-triangle flex-shrink-0" />
{{ authError }}
</div>
<Button
type="submit"
label="Entrar"
class="w-full"
icon="pi pi-sign-in"
:loading="loading"
:disabled="!canSubmit"
/>
</form>
<!-- Rodapé -->
<div class="mt-8 pt-6 border-t border-[var(--surface-border)] flex flex-wrap items-center justify-between gap-3 text-xs text-[var(--text-color-secondary)]">
<RouterLink
to="/"
class="flex items-center gap-1.5 hover:text-[var(--text-color)] transition-colors"
>
<i class="pi pi-code text-[10px]" />
Dev logins
</RouterLink>
<RouterLink
:to="{ name: 'resetPassword' }"
class="flex items-center gap-1.5 hover:text-[var(--text-color)] transition-colors"
>
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400 inline-block" />
Trocar senha
</RouterLink>
<span class="text-[var(--text-color-secondary)]/60">
Agência PSI &copy; {{ new Date().getFullYear() }}
</span>
</div>
</div>
</div>
</div>
</template>
<!-- Dialog: Recuperar acesso -->
<Dialog
v-model:visible="openRecovery"
modal
header="Recuperar acesso"
:draggable="false"
:style="{ width: '28rem', maxWidth: '92vw' }"
>
<div class="space-y-3">
<div class="text-sm text-[var(--text-color-secondary)]">
Informe seu e-mail. Vamos enviar um link para redefinir sua senha.
</div>
<div class="space-y-2">
<label class="text-sm font-semibold">E-mail</label>
<InputText
v-model="recoveryEmail"
class="w-full"
placeholder="seuemail@dominio.com"
:disabled="loadingRecovery"
/>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:justify-end pt-2">
<Button
label="Cancelar"
severity="secondary"
outlined
:disabled="loadingRecovery"
@click="openRecovery = false"
/>
<Button
label="Enviar link"
icon="pi pi-envelope"
:loading="loadingRecovery"
@click="sendRecoveryEmail"
/>
</div>
<div
v-if="recoverySent"
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3 text-xs text-[var(--text-color-secondary)]"
>
<i class="pi pi-check mr-2 text-emerald-500" />
Se o e-mail existir, você receberá o link em instantes. Verifique também spam/lixo eletrônico.
</div>
</div>
</Dialog>
</template>
<style scoped>
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: opacity 0.4s ease, transform 0.4s ease;
}
.slide-fade-enter-from {
opacity: 0;
transform: translateY(18px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateY(-12px);
}
</style>

View File

@@ -1,226 +1,110 @@
<template>
<div class="min-h-screen p-4 md:p-6 grid place-items-center">
<div class="w-full max-w-lg">
<Card class="overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<template #title>
<div class="relative">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-90">
<div class="absolute -top-20 -right-16 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-10 -left-20 h-72 w-72 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-24 right-10 h-64 w-64 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative p-5 md:p-6">
<div class="flex items-start gap-3">
<div
class="grid h-10 w-10 place-items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)]"
>
<i class="pi pi-key text-lg opacity-80" />
</div>
<div class="min-w-0">
<div class="text-xl md:text-2xl font-semibold leading-tight">
Redefinir senha
</div>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">
Escolha uma nova senha para sua conta. Depois, você fará login novamente.
</div>
<div
v-if="bannerText"
class="mt-3 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-2 text-xs text-[var(--text-color-secondary)]"
>
<i class="pi pi-info-circle mr-2 opacity-70" />
{{ bannerText }}
</div>
</div>
</div>
</div>
</div>
</template>
<template #content>
<div class="p-5 md:p-6 pt-0">
<div class="grid grid-cols-12 gap-4">
<!-- Nova senha -->
<div class="col-span-12">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold">Nova senha</div>
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
Mínimo: 8 caracteres, maiúscula, minúscula e número.
</div>
</div>
<i class="pi pi-lock text-sm opacity-70" />
</div>
<div class="mt-3">
<Password
v-model="newPassword"
toggleMask
:feedback="false"
inputClass="w-full"
:disabled="loading"
placeholder="Crie uma nova senha"
/>
</div>
<div class="mt-2 text-xs">
<span v-if="!newPassword" class="text-[var(--text-color-secondary)]">
Dica: use uma frase curta + número (ex.: NoiteCalma7).
</span>
<span v-else :class="strengthOk ? 'text-emerald-500' : 'text-yellow-500'">
{{ strengthOk ? 'Senha forte o suficiente.' : 'Ainda está fraca — ajuste os critérios.' }}
</span>
</div>
</div>
</div>
<!-- Confirmar -->
<div class="col-span-12">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold">Confirmar nova senha</div>
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
Evita erro de digitação.
</div>
</div>
<i class="pi pi-check-circle text-sm opacity-70" />
</div>
<div class="mt-3">
<Password
v-model="confirmPassword"
toggleMask
:feedback="false"
inputClass="w-full"
:disabled="loading"
placeholder="Repita a nova senha"
/>
</div>
<div class="mt-2 text-xs">
<span v-if="!confirmPassword" class="text-[var(--text-color-secondary)]">
Digite novamente para confirmar.
</span>
<span v-else :class="matchOk ? 'text-emerald-500' : 'text-yellow-500'">
{{ matchOk ? 'Confere.' : 'Não confere com a nova senha.' }}
</span>
</div>
</div>
</div>
</div>
<!-- Ações -->
<div class="mt-5 flex flex-col gap-2">
<Button
class="w-full"
label="Atualizar senha"
icon="pi pi-check"
:loading="loading"
:disabled="loading"
@click="submit"
/>
<button
type="button"
class="w-full rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-2.5 text-sm font-medium text-[var(--text-color-secondary)] hover:opacity-90"
:disabled="loading"
@click="goLogin"
>
Voltar para login
</button>
<div class="mt-1 text-center text-xs text-[var(--text-color-secondary)]">
Se você não solicitou essa redefinição, ignore o e-mail e faça logout em dispositivos desconhecidos.
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import Card from 'primevue/card'
import Password from 'primevue/password'
import Button from 'primevue/button'
import { useToast } from 'primevue/usetoast'
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import Password from 'primevue/password'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
const toast = useToast()
const router = useRouter()
const newPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const bannerText = ref('')
const loading = ref(false)
const done = ref(false)
// estado do link de recovery
const recoveryReady = ref(false)
const linkInvalid = ref(false)
function isStrongEnough (p) {
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(p || '')
}
const strengthOk = computed(() => isStrongEnough(newPassword.value))
const matchOk = computed(() => !!confirmPassword.value && newPassword.value === confirmPassword.value)
const matchOk = computed(() => !!confirmPassword.value && newPassword.value === confirmPassword.value)
onMounted(async () => {
try {
// 1) força leitura da sessão (supabase-js já captura hash automaticamente)
const { data } = await supabase.auth.getSession()
const canSubmit = computed(() =>
recoveryReady.value && strengthOk.value && matchOk.value && !loading.value
)
if (!data?.session) {
bannerText.value =
'Este link parece inválido ou expirado. Solicite um novo e-mail de redefinição.'
} else {
bannerText.value =
'Link validado. Defina sua nova senha abaixo.'
}
// 2) escuta evento específico de recovery
const { data: listener } = supabase.auth.onAuthStateChange((event) => {
if (event === 'PASSWORD_RECOVERY') {
bannerText.value =
'Link validado. Defina sua nova senha abaixo.'
}
})
return () => {
listener?.subscription?.unsubscribe()
}
} catch {
bannerText.value =
'Erro ao validar o link. Solicite um novo e-mail.'
}
// barra de força: 04
const strengthScore = computed(() => {
const p = newPassword.value
if (!p) return 0
let s = 0
if (p.length >= 8) s++
if (/[A-Z]/.test(p)) s++
if (/[a-z]/.test(p)) s++
if (/\d/.test(p)) s++
return s
})
const strengthLabel = computed(() => {
if (!newPassword.value) return ''
const labels = ['', 'Muito fraca', 'Fraca', 'Boa', 'Forte']
return labels[strengthScore.value] || 'Forte'
})
const strengthColor = computed(() => {
const colors = ['', 'bg-red-500', 'bg-yellow-500', 'bg-blue-500', 'bg-emerald-500']
return colors[strengthScore.value] || 'bg-emerald-500'
})
let unsubscribeFn = null
async function syncRecoveryState () {
try {
const { data, error } = await supabase.auth.getSession()
if (error) throw error
if (data?.session) {
recoveryReady.value = true
} else {
linkInvalid.value = true
}
} catch {
linkInvalid.value = true
}
}
onMounted(async () => {
await syncRecoveryState()
const { data: listener } = supabase.auth.onAuthStateChange((event) => {
if (event === 'PASSWORD_RECOVERY' || event === 'SIGNED_IN') {
recoveryReady.value = true
linkInvalid.value = false
}
if (event === 'SIGNED_OUT') {
recoveryReady.value = false
}
})
unsubscribeFn = () => listener?.subscription?.unsubscribe()
})
onBeforeUnmount(() => {
try { unsubscribeFn?.() } catch {}
})
function goLogin () {
router.replace('/auth/login')
}
async function submit () {
if (!newPassword.value || !confirmPassword.value) {
toast.add({ severity: 'warn', summary: 'Campos', detail: 'Preencha todos os campos.', life: 3000 })
if (!recoveryReady.value) {
toast.add({ severity: 'warn', summary: 'Link inválido', detail: 'Solicite um novo e-mail de redefinição.', life: 3500 })
return
}
if (newPassword.value !== confirmPassword.value) {
toast.add({ severity: 'warn', summary: 'Confirmação', detail: 'A confirmação não confere.', life: 3000 })
if (!matchOk.value) {
toast.add({ severity: 'warn', summary: 'Confirmação', detail: 'As senhas não conferem.', life: 3000 })
return
}
if (!isStrongEnough(newPassword.value)) {
toast.add({
severity: 'warn',
summary: 'Senha fraca',
detail: 'Use no mínimo 8 caracteres com maiúscula, minúscula e número.',
life: 4500
})
if (!strengthOk.value) {
toast.add({ severity: 'warn', summary: 'Senha fraca', detail: 'Use no mínimo 8 caracteres com maiúscula, minúscula e número.', life: 4000 })
return
}
@@ -229,25 +113,254 @@ async function submit () {
const { error } = await supabase.auth.updateUser({ password: newPassword.value })
if (error) throw error
toast.add({
severity: 'success',
summary: 'Pronto',
detail: 'Senha redefinida. Faça login novamente.',
life: 3500
})
// encerra sessão do recovery
await supabase.auth.signOut()
router.replace('/auth/login')
done.value = true
try { await supabase.auth.signOut({ scope: 'global' }) } catch {}
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro',
detail: e?.message || 'Não foi possível redefinir a senha.',
life: 4500
})
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível redefinir a senha.', life: 4500 })
} finally {
loading.value = false
}
}
</script>
<template>
<FloatingConfigurator />
<div class="min-h-screen w-full flex">
<!-- ===== ESQUERDA: Painel de segurança ===== -->
<div class="hidden lg:flex lg:w-1/2 relative overflow-hidden flex-col">
<!-- Fundo gradiente -->
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
<!-- Grade decorativa -->
<div
class="absolute inset-0 opacity-[0.08]"
style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px); background-size: 48px 48px;"
/>
<!-- Orbs -->
<div class="absolute -top-40 -left-40 h-[32rem] w-[32rem] rounded-full bg-white/10 blur-3xl pointer-events-none" />
<div class="absolute bottom-0 right-0 h-80 w-80 rounded-full bg-violet-300/20 blur-3xl pointer-events-none" />
<div class="relative z-10 flex flex-col h-full p-10 xl:p-14">
<!-- Brand -->
<div class="flex items-center gap-3">
<div class="grid h-10 w-10 place-items-center rounded-xl bg-white/20 backdrop-blur-sm border border-white/20 shadow-lg">
<i class="pi pi-heart-fill text-white text-sm" />
</div>
<span class="text-white font-bold text-lg tracking-tight">Agência PSI</span>
</div>
<!-- Conteúdo central -->
<div class="flex-1 flex flex-col justify-center space-y-8">
<div class="grid h-16 w-16 place-items-center rounded-2xl bg-white/15 backdrop-blur-sm border border-white/20 shadow-lg">
<i class="pi pi-lock text-white text-2xl" />
</div>
<div class="space-y-3">
<h2 class="text-3xl xl:text-4xl font-bold text-white leading-tight">
Crie uma senha<br>que você não vai<br>esquecer.
</h2>
<p class="text-base text-white/70 leading-relaxed max-w-sm">
Escolha algo único e seguro. Uma boa senha protege seus dados e os de seus pacientes.
</p>
</div>
<!-- Dicas -->
<ul class="space-y-3">
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
Mínimo de 8 caracteres
</li>
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-300 flex-shrink-0" />
Combine letras maiúsculas, minúsculas e números
</li>
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-300 flex-shrink-0" />
Não reutilize a mesma senha de outros serviços
</li>
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-300 flex-shrink-0" />
Exemplo seguro: <span class="font-semibold text-white/90">"Noite#Calma7"</span>
</li>
</ul>
</div>
<!-- Rodapé esquerdo -->
<div class="flex items-center justify-between text-xs text-white/40">
<span>Agência PSI</span>
<span>Acesso seguro</span>
</div>
</div>
</div>
<!-- ===== DIREITA: Formulário ===== -->
<div class="flex-1 lg:w-1/2 flex flex-col min-h-screen bg-[var(--surface-ground)] overflow-y-auto relative">
<!-- Halos sutis -->
<div class="pointer-events-none absolute inset-0">
<div class="absolute top-10 right-10 h-64 w-64 rounded-full blur-3xl bg-indigo-400/5" />
<div class="absolute bottom-10 left-10 h-56 w-56 rounded-full blur-3xl bg-violet-400/5" />
</div>
<div class="relative flex flex-col flex-1 justify-center px-6 py-10 sm:px-10 lg:px-12 xl:px-16 w-full max-w-lg mx-auto">
<!-- Mobile: Brand -->
<div class="flex lg:hidden items-center gap-2 mb-8">
<div class="grid h-8 w-8 place-items-center rounded-lg bg-indigo-500/10 border border-indigo-500/20">
<i class="pi pi-heart-fill text-indigo-500 text-xs" />
</div>
<span class="text-[var(--text-color)] font-bold text-base tracking-tight">Agência PSI</span>
</div>
<!-- Estado: SUCESSO -->
<template v-if="done">
<div class="text-center space-y-6">
<div class="mx-auto grid h-20 w-20 place-items-center rounded-full bg-emerald-500/10 border border-emerald-500/20">
<i class="pi pi-check text-emerald-500 text-3xl" />
</div>
<div>
<h1 class="text-2xl font-bold text-[var(--text-color)]">Senha redefinida!</h1>
<p class="mt-2 text-sm text-[var(--text-color-secondary)] leading-relaxed">
Sua senha foi atualizada com sucesso.<br>Faça login novamente para continuar.
</p>
</div>
<Button
label="Ir para o login"
icon="pi pi-sign-in"
class="w-full"
@click="goLogin"
/>
</div>
</template>
<!-- Estado: LINK INVÁLIDO -->
<template v-else-if="linkInvalid">
<div class="text-center space-y-6">
<div class="mx-auto grid h-20 w-20 place-items-center rounded-full bg-red-500/10 border border-red-500/20">
<i class="pi pi-times text-red-500 text-3xl" />
</div>
<div>
<h1 class="text-2xl font-bold text-[var(--text-color)]">Link inválido</h1>
<p class="mt-2 text-sm text-[var(--text-color-secondary)] leading-relaxed">
Este link expirou ou foi utilizado.<br>Solicite um novo e-mail de redefinição.
</p>
</div>
<Button
label="Voltar para o login"
icon="pi pi-arrow-left"
severity="secondary"
outlined
class="w-full"
@click="goLogin"
/>
</div>
</template>
<!-- Estado: FORMULÁRIO -->
<template v-else>
<!-- Cabeçalho -->
<div class="mb-7">
<h1 class="text-3xl font-bold text-[var(--text-color)] leading-tight">Redefinir senha</h1>
<p class="mt-1.5 text-sm text-[var(--text-color-secondary)]">
Escolha uma senha nova e segura para sua conta.
</p>
</div>
<form class="space-y-5" @submit.prevent="submit">
<!-- Nova senha -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">Nova senha</label>
<Password
v-model="newPassword"
placeholder="Mínimo 8 caracteres"
toggleMask
:feedback="false"
class="w-full"
inputClass="w-full"
:disabled="loading"
/>
<!-- Barra de força -->
<div v-if="newPassword" class="mt-2 space-y-1">
<div class="flex gap-1">
<div
v-for="i in 4"
:key="i"
class="h-1 flex-1 rounded-full transition-all duration-300"
:class="i <= strengthScore ? strengthColor : 'bg-[var(--surface-border)]'"
/>
</div>
<span class="text-xs" :class="{
'text-red-500': strengthScore === 1,
'text-yellow-500': strengthScore === 2,
'text-blue-500': strengthScore === 3,
'text-emerald-500': strengthScore === 4,
}">
{{ strengthLabel }}
</span>
</div>
<p v-else class="mt-1.5 text-xs text-[var(--text-color-secondary)]">
Dica: combine letras, números e símbolos.
</p>
</div>
<!-- Confirmar senha -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">Confirmar senha</label>
<Password
v-model="confirmPassword"
placeholder="Repita a nova senha"
toggleMask
:feedback="false"
class="w-full"
inputClass="w-full"
:disabled="loading"
/>
<div v-if="confirmPassword" class="mt-1.5 flex items-center gap-1.5 text-xs"
:class="matchOk ? 'text-emerald-500' : 'text-yellow-500'"
>
<i :class="matchOk ? 'pi pi-check' : 'pi pi-times'" />
{{ matchOk ? 'As senhas conferem.' : 'As senhas não conferem.' }}
</div>
</div>
<!-- Botão -->
<Button
type="submit"
label="Atualizar senha"
icon="pi pi-check"
class="w-full"
:loading="loading"
:disabled="!canSubmit"
/>
</form>
<!-- Rodapé -->
<div class="mt-8 pt-6 border-t border-[var(--surface-border)]">
<button
type="button"
class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors"
@click="goLogin"
>
<i class="pi pi-arrow-left text-xs" />
Voltar para o login
</button>
<p class="mt-4 text-xs text-[var(--text-color-secondary)] leading-relaxed">
Se você não solicitou essa redefinição, ignore o e-mail e certifique-se de que sua conta está segura.
</p>
</div>
</template>
</div>
</div>
</div>
</template>

View File

@@ -1,370 +1,142 @@
<template>
<div class="min-h-[calc(100vh-8rem)] p-4 md:p-6">
<div class="mx-auto w-full max-w-4xl">
<Card class="overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<template #title>
<div class="relative">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-90">
<div class="absolute -top-20 -right-16 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-6 -left-20 h-72 w-72 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-24 right-10 h-64 w-64 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative flex flex-col gap-2 p-5 md:p-6">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-3">
<div
class="grid h-10 w-10 place-items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)]"
>
<i class="pi pi-shield text-lg opacity-80" />
</div>
<div class="min-w-0">
<div class="text-xl md:text-2xl font-semibold leading-tight">
Segurança
</div>
<div class="mt-0.5 text-sm md:text-base text-[var(--text-color-secondary)]">
Troque sua senha com cuidado. Depois, você será deslogado por segurança.
</div>
</div>
</div>
</div>
<div class="hidden md:flex items-center gap-2">
<span
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs text-[var(--text-color-secondary)]"
>
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400/70" />
sessão ativa
</span>
</div>
</div>
</div>
</div>
</template>
<template #content>
<div class="p-5 md:p-6 pt-0">
<!-- GRID -->
<div class="grid grid-cols-12 gap-4">
<!-- Senha atual -->
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold">Senha atual</div>
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
Necessária para confirmar que é você.
</div>
</div>
<i class="pi pi-lock text-sm opacity-70" />
</div>
<div class="mt-3">
<Password
v-model="currentPassword"
toggleMask
:feedback="false"
inputClass="w-full"
:disabled="loading || loadingReset"
placeholder="Digite sua senha atual"
/>
</div>
</div>
</div>
<!-- Dica lateral -->
<div class="col-span-12 md:col-span-6">
<div class="h-full rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold">Boas práticas</div>
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
Senhas fortes são menos lembráveis, mas mais seguras.
</div>
</div>
<i class="pi pi-info-circle text-sm opacity-70" />
</div>
<ul class="mt-3 space-y-2 text-xs text-[var(--text-color-secondary)]">
<li class="flex gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400/70"></span>
Use pelo menos 8 caracteres, com maiúscula, minúscula e número.
</li>
<li class="flex gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400/70"></span>
Evite datas, nomes e padrões (1234, qwerty).
</li>
<li class="flex gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400/70"></span>
Se estiver em computador público, finalize a sessão depois.
</li>
</ul>
</div>
</div>
<!-- Nova senha -->
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold">Nova senha</div>
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
Deve atender aos critérios mínimos.
</div>
</div>
<i class="pi pi-key text-sm opacity-70" />
</div>
<div class="mt-3">
<Password
v-model="newPassword"
toggleMask
:feedback="false"
inputClass="w-full"
:disabled="loading || loadingReset"
placeholder="Crie uma nova senha"
/>
</div>
<div class="mt-2 text-xs">
<span
v-if="!newPassword"
class="text-[var(--text-color-secondary)]"
>
Critérios: 8+ caracteres, maiúscula, minúscula e número.
</span>
<span
v-else
:class="passwordStrengthOk ? 'text-emerald-500' : 'text-yellow-500'"
>
{{ passwordStrengthOk ? 'Senha forte o suficiente.' : 'Ainda está fraca — ajuste os critérios.' }}
</span>
</div>
</div>
</div>
<!-- Confirmar -->
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold">Confirmar nova senha</div>
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
Evita erro de digitação.
</div>
</div>
<i class="pi pi-check-circle text-sm opacity-70" />
</div>
<div class="mt-3">
<Password
v-model="confirmPassword"
toggleMask
:feedback="false"
inputClass="w-full"
:disabled="loading || loadingReset"
placeholder="Repita a nova senha"
/>
</div>
<div class="mt-2 text-xs">
<span v-if="!confirmPassword" class="text-[var(--text-color-secondary)]">
Digite novamente para confirmar.
</span>
<span
v-else
:class="passwordMatchOk ? 'text-emerald-500' : 'text-yellow-500'"
>
{{ passwordMatchOk ? 'Confere.' : 'Não confere com a nova senha.' }}
</span>
</div>
</div>
</div>
</div>
<!-- Ações -->
<div class="mt-5 flex flex-col-reverse gap-2 md:flex-row md:items-center md:justify-between">
<div class="text-xs text-[var(--text-color-secondary)]">
Ao trocar sua senha, você será desconectado de forma global.
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
label="Esqueci minha senha"
severity="secondary"
outlined
icon="pi pi-envelope"
:loading="loadingReset"
:disabled="loading || loadingReset"
@click="sendResetEmail"
/>
<Button
label="Trocar senha"
icon="pi pi-check"
:loading="loading"
:disabled="loading || loadingReset"
@click="changePassword"
/>
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import Card from 'primevue/card'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import Password from 'primevue/password'
import Button from 'primevue/button'
import Button from 'primevue/button'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import { sessionUser, sessionRole } from '@/app/session'
const toast = useToast()
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const loadingReset = ref(false)
// ── Hero sticky ────────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const loadingReset = ref(false)
const done = ref(false)
// ── validações ────────────────────────────────────────────────────────────
function isStrongEnough (p) {
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(p || '')
}
const passwordStrengthOk = computed(() => isStrongEnough(newPassword.value))
const passwordMatchOk = computed(() => !!confirmPassword.value && newPassword.value === confirmPassword.value)
const strengthScore = computed(() => {
const p = newPassword.value
if (!p) return 0
let s = 0
if (p.length >= 8) s++
if (/[A-Z]/.test(p)) s++
if (/[a-z]/.test(p)) s++
if (/\d/.test(p)) s++
return s
})
const strengthLabel = computed(() => {
const labels = ['', 'Muito fraca', 'Fraca', 'Boa', 'Forte']
return labels[strengthScore.value] || ''
})
const strengthColor = computed(() => {
const colors = ['', 'bg-red-500', 'bg-yellow-500', 'bg-blue-500', 'bg-emerald-500']
return colors[strengthScore.value] || ''
})
const strengthTextColor = computed(() => {
const colors = ['', 'text-red-500', 'text-yellow-500', 'text-blue-500', 'text-emerald-500']
return colors[strengthScore.value] || ''
})
const strengthOk = computed(() => isStrongEnough(newPassword.value))
const matchOk = computed(() => !!confirmPassword.value && newPassword.value === confirmPassword.value)
const canSubmit = computed(() =>
!!currentPassword.value && strengthOk.value && matchOk.value && !loading.value && !loadingReset.value
)
// ── ações ─────────────────────────────────────────────────────────────────
function clearFields () {
currentPassword.value = ''
newPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
}
async function hardLogout () {
// 1) tenta logout normal (se falhar, seguimos)
try { await supabase.auth.signOut({ scope: 'global' }) } catch {}
try {
// DEBUG LOGOUT
console.log('ANTES', (await supabase.auth.getSession()).data.session)
await supabase.auth.signOut({ scope: 'global' })
console.log('DEPOIS', (await supabase.auth.getSession()).data.session)
} catch (e) {
console.warn('[signOut failed]', e)
}
// 2) zera estado reativo global
sessionUser.value = null
sessionRole.value = null
// 3) remove token persistido do supabase-js v2 (sb-*-auth-token)
try {
const keysToRemove = []
const keys = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (!k) continue
if (k.startsWith('sb-') && k.includes('auth-token')) keysToRemove.push(k)
if (k?.startsWith('sb-') && k.includes('auth-token')) keys.push(k)
}
keysToRemove.forEach((k) => localStorage.removeItem(k))
} catch (e) {
console.warn('[storage cleanup failed]', e)
}
// 4) remove redirect pendente
try {
sessionStorage.removeItem('redirect_after_login')
keys.forEach(k => localStorage.removeItem(k))
} catch {}
// 5) redireciona de forma "hard"
try { sessionStorage.removeItem('redirect_after_login') } catch {}
window.location.replace('/auth/login')
}
async function changePassword () {
const user = sessionUser.value
if (!user?.email) {
toast.add({ severity: 'error', summary: 'Sessão', detail: 'Usuário não encontrado na sessão.', life: 3500 })
return
}
if (!currentPassword.value || !newPassword.value || !confirmPassword.value) {
toast.add({ severity: 'warn', summary: 'Campos', detail: 'Preencha todos os campos.', life: 3000 })
return
}
if (newPassword.value !== confirmPassword.value) {
toast.add({ severity: 'warn', summary: 'Confirmação', detail: 'A confirmação não confere.', life: 3000 })
return
}
if (!isStrongEnough(newPassword.value)) {
toast.add({
severity: 'warn',
summary: 'Senha fraca',
detail: 'Use no mínimo 8 caracteres com maiúscula, minúscula e número.',
life: 4500
})
return
}
loading.value = true
try {
// Reautentica (padrão mais previsível)
const { error: signError } = await supabase.auth.signInWithPassword({
email: user.email,
password: currentPassword.value
})
if (signError) throw signError
const { data: uData, error: uErr } = await supabase.auth.getUser()
if (uErr) throw uErr
const { error: upError } = await supabase.auth.updateUser({
password: newPassword.value
})
const email = uData?.user?.email
if (!email) throw new Error('Sessão inválida. Faça login novamente.')
// reautentica para confirmar senha atual
const { error: signError } = await supabase.auth.signInWithPassword({ email, password: currentPassword.value })
if (signError) throw new Error('Senha atual incorreta.')
// atualiza
const { error: upError } = await supabase.auth.updateUser({ password: newPassword.value })
if (upError) throw upError
toast.add({
severity: 'success',
summary: 'Senha atualizada',
detail: 'Por segurança, você será deslogado.',
life: 2500
})
clearFields()
await hardLogout()
done.value = true
toast.add({ severity: 'success', summary: 'Senha atualizada', detail: 'Por segurança, você será deslogado.', life: 2500 })
setTimeout(() => hardLogout(), 2600)
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro',
detail: e?.message || 'Não foi possível trocar a senha.',
life: 4000
})
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível trocar a senha.', life: 4000 })
} finally {
loading.value = false
}
}
async function sendResetEmail () {
const user = sessionUser.value
if (!user?.email) {
toast.add({ severity: 'error', summary: 'Sessão', detail: 'Usuário não encontrado na sessão.', life: 3500 })
return
}
onMounted(() => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
})
onBeforeUnmount(() => { _observer?.disconnect() })
async function sendResetEmail () {
loadingReset.value = true
try {
const { data: uData, error: uErr } = await supabase.auth.getUser()
if (uErr) throw uErr
const email = uData?.user?.email
if (!email) throw new Error('Sessão inválida. Faça login novamente.')
const redirectTo = `${window.location.origin}/auth/reset-password`
const { error } = await supabase.auth.resetPasswordForEmail(user.email, { redirectTo })
const { error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo })
if (error) throw error
toast.add({
severity: 'info',
summary: 'E-mail enviado',
detail: 'Verifique sua caixa de entrada para redefinir a senha.',
life: 5000
})
toast.add({ severity: 'info', summary: 'E-mail enviado', detail: 'Verifique sua caixa de entrada para redefinir a senha.', life: 5000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao enviar e-mail.', life: 4500 })
} finally {
@@ -372,3 +144,244 @@ async function sendResetEmail () {
}
}
</script>
<template>
<Toast />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="sec-sentinel" />
<!-- Hero sticky -->
<div ref="headerEl" class="sec-hero w-full max-w-2xl mx-auto px-3 md:px-5 mb-4" :class="{ 'sec-hero--stuck': headerStuck }">
<div class="sec-hero__blobs" aria-hidden="true">
<div class="sec-hero__blob sec-hero__blob--1" />
<div class="sec-hero__blob sec-hero__blob--2" />
</div>
<div class="sec-hero__row1">
<div class="sec-hero__brand">
<div class="sec-hero__icon"><i class="pi pi-shield text-lg" /></div>
<div class="min-w-0">
<div class="sec-hero__title">Segurança</div>
<div class="sec-hero__sub">Gerencie o acesso e a senha da sua conta</div>
</div>
</div>
<span class="hidden xl:inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border border-emerald-200 text-emerald-700 bg-emerald-50 shrink-0">
<span class="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
Sessão ativa
</span>
</div>
</div>
<div class="px-3 md:px-5 pb-8 flex justify-center">
<div class="w-full max-w-2xl space-y-4">
<!-- Card principal -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<!-- Seção: Trocar senha -->
<div class="px-6 py-5 border-b border-[var(--surface-border)]">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-semibold text-[var(--text-color)]">Trocar senha</p>
<p class="text-xs text-[var(--text-color-secondary)] mt-0.5">
Confirme sua senha atual e defina uma nova.
</p>
</div>
<span class="hidden sm:inline-flex items-center gap-1.5 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs text-[var(--text-color-secondary)]">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400" />
sessão ativa
</span>
</div>
</div>
<!-- Estado: concluído -->
<div v-if="done" class="px-6 py-10 text-center space-y-4">
<div class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-emerald-500/10 border border-emerald-500/20">
<i class="pi pi-check text-emerald-500 text-2xl" />
</div>
<div>
<p class="font-semibold text-[var(--text-color)]">Senha atualizada!</p>
<p class="text-sm text-[var(--text-color-secondary)] mt-1">Redirecionando para o login</p>
</div>
</div>
<!-- Estado: formulário -->
<div v-else class="px-6 py-6 space-y-5">
<!-- Senha atual -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
Senha atual
</label>
<Password
v-model="currentPassword"
placeholder="Digite sua senha atual"
toggleMask
:feedback="false"
class="w-full"
inputClass="w-full"
:disabled="loading || loadingReset"
/>
<p class="mt-1.5 text-xs text-[var(--text-color-secondary)]">
Necessária para confirmar que é você.
</p>
</div>
<div class="h-px bg-[var(--surface-border)]" />
<!-- Nova senha -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
Nova senha
</label>
<Password
v-model="newPassword"
placeholder="Mínimo 8 caracteres"
toggleMask
:feedback="false"
class="w-full"
inputClass="w-full"
:disabled="loading || loadingReset"
/>
<!-- Barra de força -->
<div v-if="newPassword" class="mt-2 space-y-1">
<div class="flex gap-1">
<div
v-for="i in 4"
:key="i"
class="h-1 flex-1 rounded-full transition-all duration-300"
:class="i <= strengthScore ? strengthColor : 'bg-[var(--surface-border)]'"
/>
</div>
<span class="text-xs" :class="strengthTextColor">{{ strengthLabel }}</span>
</div>
<p v-else class="mt-1.5 text-xs text-[var(--text-color-secondary)]">
Critérios: 8+ caracteres, maiúscula, minúscula e número.
</p>
</div>
<!-- Confirmar senha -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
Confirmar nova senha
</label>
<Password
v-model="confirmPassword"
placeholder="Repita a nova senha"
toggleMask
:feedback="false"
class="w-full"
inputClass="w-full"
:disabled="loading || loadingReset"
/>
<div v-if="confirmPassword" class="mt-1.5 flex items-center gap-1.5 text-xs"
:class="matchOk ? 'text-emerald-500' : 'text-yellow-500'"
>
<i :class="matchOk ? 'pi pi-check' : 'pi pi-times'" />
{{ matchOk ? 'As senhas conferem.' : 'As senhas não conferem.' }}
</div>
</div>
<!-- Aviso -->
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
<i class="pi pi-info-circle text-[var(--text-color-secondary)] text-sm mt-0.5 flex-shrink-0" />
<p class="text-xs text-[var(--text-color-secondary)] leading-relaxed">
Ao trocar sua senha, você será desconectado de todos os dispositivos por segurança.
</p>
</div>
<!-- Ações -->
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-between sm:items-center pt-1">
<Button
label="Enviar link por e-mail"
severity="secondary"
outlined
icon="pi pi-envelope"
:loading="loadingReset"
:disabled="loading || loadingReset"
@click="sendResetEmail"
/>
<Button
label="Trocar senha"
icon="pi pi-check"
:loading="loading"
:disabled="!canSubmit"
@click="changePassword"
/>
</div>
</div>
</div>
<!-- Card informativo: dicas -->
<div class="mt-4 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-6 py-5">
<div class="flex items-center gap-2 mb-3">
<i class="pi pi-lightbulb text-sm text-[var(--text-color-secondary)]" />
<span class="text-sm font-semibold text-[var(--text-color)]">Boas práticas</span>
</div>
<ul class="space-y-2">
<li class="flex items-start gap-2.5 text-xs text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400 flex-shrink-0" />
Use pelo menos 8 caracteres com maiúscula, minúscula e número.
</li>
<li class="flex items-start gap-2.5 text-xs text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
Evite datas, nomes e sequências óbvias (1234, qwerty).
</li>
<li class="flex items-start gap-2.5 text-xs text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400 flex-shrink-0" />
Se estiver em computador compartilhado, encerre a sessão depois.
</li>
<li class="flex items-start gap-2.5 text-xs text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-400 flex-shrink-0" />
Não reutilize a mesma senha de outros serviços.
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped>
.sec-sentinel { height: 1px; }
.sec-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.sec-hero--stuck {
border-top-left-radius: 0; border-top-right-radius: 0;
}
.sec-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.sec-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.sec-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
.sec-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
.sec-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.sec-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.sec-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.sec-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.sec-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
</style>

View File

@@ -1,13 +1,10 @@
<!-- src/views/pages/auth/WelcomePage.vue -->
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import Card from 'primevue/card'
import Message from 'primevue/message'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Tag from 'primevue/tag'
import Chip from 'primevue/chip'
import ProgressSpinner from 'primevue/progressspinner'
@@ -20,7 +17,7 @@ const router = useRouter()
const planFromQuery = computed(() => String(route.query.plan || '').trim().toLowerCase())
const intervalFromQuery = computed(() => String(route.query.interval || '').trim().toLowerCase())
function normalizeInterval(v) {
function normalizeInterval (v) {
if (v === 'monthly') return 'month'
if (v === 'annual' || v === 'yearly') return 'year'
return v
@@ -34,6 +31,22 @@ const intervalLabel = computed(() => {
return ''
})
const hasPlanQuery = computed(() => !!planFromQuery.value)
// ============================
// Session (opcional para CTA melhor)
// ============================
const hasSession = ref(false)
async function checkSession () {
try {
const { data } = await supabase.auth.getSession()
hasSession.value = !!data?.session
} catch {
hasSession.value = false
}
}
// ============================
// Pricing
// ============================
@@ -43,29 +56,36 @@ const planRow = ref(null)
const planName = computed(() => planRow.value?.public_name || planRow.value?.plan_name || null)
const planDescription = computed(() => planRow.value?.public_description || null)
const amountCents = computed(() => {
if (!planRow.value) return null
return intervalNormalized.value === 'year'
? planRow.value.yearly_cents
: planRow.value.monthly_cents
})
function amountForInterval (row, interval) {
if (!row) return null
const cents = interval === 'year' ? row.yearly_cents : row.monthly_cents
// fallback inteligente: se não houver preço nesse intervalo, tenta o outro
if (cents == null) return interval === 'year' ? row.monthly_cents : row.yearly_cents
return cents
}
const currency = computed(() => {
if (!planRow.value) return 'BRL'
return intervalNormalized.value === 'year'
? (planRow.value.yearly_currency || 'BRL')
: (planRow.value.monthly_currency || 'BRL')
})
function currencyForInterval (row, interval) {
if (!row) return 'BRL'
const cur = interval === 'year' ? (row.yearly_currency || 'BRL') : (row.monthly_currency || 'BRL')
return cur || 'BRL'
}
const amountCents = computed(() => amountForInterval(planRow.value, intervalNormalized.value))
const currency = computed(() => currencyForInterval(planRow.value, intervalNormalized.value))
const formattedPrice = computed(() => {
if (amountCents.value == null) return null
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: currency.value || 'BRL'
}).format(amountCents.value / 100)
try {
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: currency.value || 'BRL'
}).format(Number(amountCents.value) / 100)
} catch {
return null
}
})
async function loadPlan() {
async function loadPlan () {
planRow.value = null
if (!planFromQuery.value) return
@@ -99,16 +119,31 @@ async function loadPlan() {
}
}
onMounted(loadPlan)
watch(() => planFromQuery.value, () => loadPlan())
function goLogin() {
function goLogin () {
router.push('/auth/login')
}
function goBackLanding() {
function goBackLanding () {
router.push('/lp')
}
function goDashboard () {
router.push('/admin')
}
function goPricing () {
router.push('/lp#pricing')
}
onMounted(async () => {
await checkSession()
await loadPlan()
})
watch(
() => planFromQuery.value,
() => loadPlan()
)
</script>
<template>
@@ -123,7 +158,7 @@ function goBackLanding() {
<div class="relative w-full max-w-6xl">
<div class="rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm overflow-hidden">
<div class="grid grid-cols-12">
<!-- LEFT: boas-vindas (PrimeBlocks-like) -->
<!-- LEFT -->
<div
class="col-span-12 lg:col-span-6 p-6 md:p-10 bg-[color-mix(in_srgb,var(--surface-card),transparent_6%)] border-b lg:border-b-0 lg:border-r border-[var(--surface-border)]"
>
@@ -145,8 +180,8 @@ function goBackLanding() {
Bem-vindo(a).
</div>
<div class="mt-3 text-sm md:text-base text-[var(--text-color-secondary)] max-w-lg">
Sua conta foi criada e a sua intenção de assinatura foi registrada.
<div class="mt-3 text-sm md:text-base text-[var(--text-color-secondary)] max-w-lg leading-relaxed">
Sua conta foi criada e sua intenção de assinatura foi registrada.
Agora o caminho é simples: instruções de pagamento confirmação ativação do plano.
</div>
@@ -171,7 +206,9 @@ function goBackLanding() {
<div>
<div class="text-xs text-[var(--text-color-secondary)]">3) Plano ativo</div>
<div class="font-semibold mt-1">Recursos liberados</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">entitlements PRO quando pago</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
entitlements PRO quando confirmado
</div>
</div>
<i class="pi pi-verified opacity-60" />
</div>
@@ -182,25 +219,25 @@ function goBackLanding() {
<div class="mt-6 flex flex-wrap gap-2">
<Tag severity="secondary" value="Sem cobrança automática" />
<Tag severity="secondary" value="Ativação após confirmação" />
<Tag severity="secondary" value="Fluxo pronto para gateway depois" />
<Tag severity="secondary" value="Gateway depois, sem retrabalho" />
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
* Página de boas-vindas inspirada em layouts PrimeBlocks.
* Boas-vindas inspirada em layouts PrimeBlocks.
</div>
</div>
<!-- RIGHT: resumo + botões -->
<!-- RIGHT -->
<div class="col-span-12 lg:col-span-6 p-6 md:p-10">
<div class="max-w-md mx-auto">
<div class="text-2xl font-semibold">Conta criada 🎉</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
<div class="text-sm text-[var(--text-color-secondary)] mt-1 leading-relaxed">
Você pode entrar. Se o seu plano for PRO, ele será ativado após confirmação do pagamento.
</div>
<div class="mt-5">
<Message severity="success" class="mb-3">
Sua intenção de assinatura foi registrada.
Intenção de assinatura registrada.
</Message>
<div v-if="loading" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
@@ -208,13 +245,13 @@ function goBackLanding() {
Carregando detalhes do plano
</div>
<Card v-else class="overflow-hidden">
<Card v-else class="overflow-hidden rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-xs text-[var(--text-color-secondary)]">Resumo do plano</div>
<div class="text-xs text-[var(--text-color-secondary)]">Resumo</div>
<div class="mt-1 flex items-center gap-2 flex-wrap">
<div v-if="hasPlanQuery" class="mt-1 flex items-center gap-2 flex-wrap">
<div class="text-lg font-semibold truncate">
{{ planName || 'Plano' }}
</div>
@@ -223,18 +260,25 @@ function goBackLanding() {
<Chip v-if="intervalLabel" :label="intervalLabel" />
</div>
<div class="mt-2 text-2xl font-semibold leading-none">
<div v-else class="mt-1">
<div class="text-lg font-semibold">Sem plano selecionado</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
Você pode escolher um plano agora ou seguir no FREE.
</div>
</div>
<div v-if="hasPlanQuery" class="mt-2 text-2xl font-semibold leading-none">
{{ formattedPrice || '' }}
<span v-if="intervalLabel" class="text-sm font-normal text-[var(--text-color-secondary)]">
/{{ intervalNormalized === 'month' ? 'mês' : 'ano' }}
</span>
</div>
<div v-if="planDescription" class="mt-2 text-sm text-[var(--text-color-secondary)]">
<div v-if="planDescription" class="mt-2 text-sm text-[var(--text-color-secondary)] leading-relaxed">
{{ planDescription }}
</div>
<Message v-if="planFromQuery && !planRow" severity="warn" class="mt-3">
<Message v-if="hasPlanQuery && !planRow" severity="warn" class="mt-3">
Não encontrei esse plano na vitrine pública. Você pode continuar normalmente.
</Message>
</div>
@@ -243,15 +287,40 @@ function goBackLanding() {
<Divider class="my-4" />
<Message severity="info" class="mb-0">
Próximo passo: você receberá instruções de pagamento (PIX ou boleto).
Próximo passo: você receberá instruções de pagamento (PIX/boleto).
Assim que confirmado, sua assinatura será ativada.
</Message>
<div v-if="!hasPlanQuery" class="mt-3">
<Button
label="Escolher um plano"
icon="pi pi-credit-card"
severity="secondary"
outlined
class="w-full"
@click="goPricing"
/>
</div>
</template>
</Card>
</div>
<div class="mt-5 gap-2">
<Button label="Ir para login" class="w-full mb-2" icon="pi pi-sign-in" @click="goLogin" />
<Button
v-if="hasSession"
label="Ir para o painel"
class="w-full mb-2"
icon="pi pi-arrow-right"
@click="goDashboard"
/>
<Button
v-else
label="Ir para login"
class="w-full mb-2"
icon="pi pi-sign-in"
@click="goLogin"
/>
<Button
label="Voltar para a página inicial"
severity="secondary"
@@ -271,4 +340,4 @@ function goBackLanding() {
</div>
</div>
</div>
</template>
</template>

View File

@@ -0,0 +1,636 @@
<!-- src/views/pages/billing/ClinicMeuPlanoPage.vue -->
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
const router = useRouter()
const route = useRoute()
const toast = useToast()
const tenantStore = useTenantStore()
const loading = ref(false)
const subscription = ref(null)
const plan = ref(null)
const price = ref(null)
const features = ref([]) // [{ key, description }]
const events = ref([]) // subscription_events
// ✅ para histórico auditável
const plans = ref([]) // [{id,key,name}]
const profiles = ref([]) // profiles de created_by
const tenantId = computed(() =>
tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.currentTenantId || null
)
// -------------------------
// helpers (format)
// -------------------------
function money (currency, amountCents) {
if (amountCents == null) return null
const value = Number(amountCents) / 100
try {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency || 'BRL' }).format(value)
} catch (_) {
return `${value.toFixed(2)} ${currency || ''}`.trim()
}
}
function goUpgradeClinic () {
// ✅ mantém caminho de retorno consistente
const redirectTo = route?.fullPath || '/admin/meu-plano'
router.push(`/upgrade?redirectTo=${encodeURIComponent(redirectTo)}`)
}
function fmtDate (iso) {
if (!iso) return '-'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
return d.toLocaleString('pt-BR')
}
function prettyMeta (meta) {
if (!meta) return null
try {
if (typeof meta === 'string') return meta
return JSON.stringify(meta, null, 2)
} catch (_) {
return String(meta)
}
}
function statusSeverity (st) {
const s = String(st || '').toLowerCase()
if (s === 'active') return 'success'
if (s === 'trialing') return 'info'
if (s === 'past_due') return 'warning'
if (s === 'canceled' || s === 'cancelled') return 'danger'
if (s === 'incomplete' || s === 'incomplete_expired' || s === 'unpaid') return 'warning'
return 'secondary'
}
function statusLabelPretty (st) {
const s = String(st || '').toLowerCase()
if (s === 'active') return 'Ativa'
if (s === 'trialing') return 'Trial'
if (s === 'past_due') return 'Pagamento pendente'
if (s === 'canceled' || s === 'cancelled') return 'Cancelada'
if (s === 'unpaid') return 'Não paga'
if (s === 'incomplete') return 'Incompleta'
if (s === 'incomplete_expired') return 'Incompleta (expirada)'
return st || '-'
}
function eventSeverity (t) {
const k = String(t || '').toLowerCase()
if (k === 'plan_changed') return 'info'
if (k === 'canceled') return 'danger'
if (k === 'reactivated') return 'success'
if (k === 'created') return 'secondary'
if (k === 'status_changed') return 'warning'
return 'secondary'
}
function eventLabel (t) {
const k = String(t || '').toLowerCase()
if (k === 'plan_changed') return 'Plano alterado'
if (k === 'canceled') return 'Cancelada'
if (k === 'reactivated') return 'Reativada'
if (k === 'created') return 'Criada'
if (k === 'status_changed') return 'Status alterado'
return t || '-'
}
// -------------------------
// helpers (plans / profiles)
// -------------------------
const planById = computed(() => {
const m = new Map()
for (const p of plans.value || []) m.set(String(p.id), p)
return m
})
function planKeyOrName (planId) {
if (!planId) return '—'
const p = planById.value.get(String(planId))
return p?.key || p?.name || String(planId)
}
const profileById = computed(() => {
const m = new Map()
for (const p of profiles.value || []) m.set(String(p.id), p)
return m
})
function displayUser (userId) {
if (!userId) return '—'
const p = profileById.value.get(String(userId))
if (!p) return String(userId)
const name = p.nome || p.name || p.full_name || p.display_name || p.username || null
const email = p.email || p.email_principal || p.user_email || null
if (name && email) return `${name} <${email}>`
if (name) return name
if (email) return email
return String(userId)
}
// -------------------------
// computed (header info)
// -------------------------
const planName = computed(() => plan.value?.name || subscription.value?.plan_key || '-')
const statusLabel = computed(() => subscription.value?.status || '-')
const statusLabelPrettyComputed = computed(() => statusLabelPretty(subscription.value?.status))
const intervalLabel = computed(() => {
const i = subscription.value?.interval
if (i === 'month') return 'mês'
if (i === 'year') return 'ano'
return i || '-'
})
const priceLabel = computed(() => {
if (!price.value) return null
return `${money(price.value.currency, price.value.amount_cents)} / ${intervalLabel.value}`
})
const periodLabel = computed(() => {
const s = subscription.value
if (!s?.current_period_start || !s?.current_period_end) return '-'
return `${fmtDate(s.current_period_start)}${fmtDate(s.current_period_end)}`
})
const cancelHint = computed(() => {
const s = subscription.value
if (!s) return null
if (s.cancel_at_period_end) {
const end = s.current_period_end ? fmtDate(s.current_period_end) : null
return end ? `Cancelamento no fim do período (${end}).` : 'Cancelamento no fim do período.'
}
if (s.canceled_at) return `Cancelada em ${fmtDate(s.canceled_at)}.`
return null
})
// -------------------------
// ✅ agrupamento de features por módulo (prefixo)
// -------------------------
function moduleFromKey (key) {
const k = String(key || '').trim()
if (!k) return 'Outros'
// tenta por "."
if (k.includes('.')) {
const head = k.split('.')[0]
return head || 'Outros'
}
// tenta por "_"
if (k.includes('_')) {
const head = k.split('_')[0]
return head || 'Outros'
}
return 'Outros'
}
function moduleLabel (m) {
const s = String(m || '').trim()
if (!s) return 'Outros'
return s.charAt(0).toUpperCase() + s.slice(1)
}
const groupedFeatures = computed(() => {
const list = features.value || []
const map = new Map()
for (const f of list) {
const mod = moduleFromKey(f.key)
if (!map.has(mod)) map.set(mod, [])
map.get(mod).push(f)
}
// ordena módulos e itens
const modules = Array.from(map.keys()).sort((a, b) => {
if (a === 'Outros') return 1
if (b === 'Outros') return -1
return a.localeCompare(b)
})
return modules.map(mod => {
const items = map.get(mod) || []
items.sort((a, b) => String(a.key || '').localeCompare(String(b.key || '')))
return { module: mod, items }
})
})
// -------------------------
// fetch
// -------------------------
async function fetchMeuPlanoClinic () {
loading.value = true
try {
const tid = tenantId.value
if (!tid) throw new Error('Tenant ativo não encontrado.')
// 1) assinatura do tenant (prioriza status "ativo" e afins; cai pro mais recente)
// ✅ depois das mudanças: não assume só "active" (pode estar trialing/past_due etc.)
const sRes = await supabase
.from('subscriptions')
.select('*')
.eq('tenant_id', tid)
.order('created_at', { ascending: false })
.limit(10)
if (sRes.error) throw sRes.error
const list = sRes.data || []
const priority = (st) => {
const s = String(st || '').toLowerCase()
if (s === 'active') return 1
if (s === 'trialing') return 2
if (s === 'past_due') return 3
if (s === 'unpaid') return 4
if (s === 'incomplete') return 5
if (s === 'canceled' || s === 'cancelled') return 9
return 8
}
subscription.value = (list.slice().sort((a, b) => {
const pa = priority(a?.status)
const pb = priority(b?.status)
if (pa !== pb) return pa - pb
// empate: mais recente
return new Date(b?.created_at || 0) - new Date(a?.created_at || 0)
})[0]) || null
if (!subscription.value) {
plan.value = null
price.value = null
features.value = []
events.value = []
plans.value = []
profiles.value = []
return
}
// 2) plano (atual)
if (subscription.value.plan_id) {
const pRes = await supabase
.from('plans')
.select('id, key, name, description')
.eq('id', subscription.value.plan_id)
.maybeSingle()
if (pRes.error) throw pRes.error
plan.value = pRes.data || null
} else {
plan.value = null
}
// 3) preço vigente (intervalo atual)
// ✅ robustez: tenta preço vigente por janela; se não achar, pega o último ativo do intervalo
price.value = null
if (subscription.value.plan_id && subscription.value.interval) {
const nowIso = new Date().toISOString()
const ppRes = await supabase
.from('plan_prices')
.select('currency, interval, amount_cents, is_active, active_from, active_to')
.eq('plan_id', subscription.value.plan_id)
.eq('interval', subscription.value.interval)
.eq('is_active', true)
.lte('active_from', nowIso)
.or(`active_to.is.null,active_to.gte.${nowIso}`)
.order('active_from', { ascending: false })
.limit(1)
.maybeSingle()
if (ppRes.error) throw ppRes.error
price.value = ppRes.data || null
if (!price.value) {
const ppFallback = await supabase
.from('plan_prices')
.select('currency, interval, amount_cents, is_active, active_from, active_to')
.eq('plan_id', subscription.value.plan_id)
.eq('interval', subscription.value.interval)
.eq('is_active', true)
.order('active_from', { ascending: false })
.limit(1)
.maybeSingle()
if (ppFallback.error) throw ppFallback.error
price.value = ppFallback.data || null
}
}
// 4) features do plano
features.value = []
if (subscription.value.plan_id) {
const pfRes = await supabase
.from('plan_features')
.select('feature_id')
.eq('plan_id', subscription.value.plan_id)
if (pfRes.error) throw pfRes.error
const featureIds = (pfRes.data || []).map(r => r.feature_id).filter(Boolean)
if (featureIds.length) {
const fRes = await supabase
.from('features')
.select('id, key, description, descricao')
.in('id', featureIds)
.order('key', { ascending: true })
if (fRes.error) throw fRes.error
features.value = (fRes.data || []).map(f => ({
key: f.key,
description: (f.descricao || f.description || '').trim()
}))
}
}
// 5) histórico (50) — se existir subscription_id
events.value = []
if (subscription.value?.id) {
const eRes = await supabase
.from('subscription_events')
.select('*')
.eq('subscription_id', subscription.value.id)
.order('created_at', { ascending: false })
.limit(50)
if (eRes.error) throw eRes.error
events.value = eRes.data || []
}
// ✅ 6) pré-carrega planos citados em (old/new) + plano atual
const planIds = new Set()
if (subscription.value?.plan_id) planIds.add(String(subscription.value.plan_id))
for (const ev of events.value) {
if (ev?.old_plan_id) planIds.add(String(ev.old_plan_id))
if (ev?.new_plan_id) planIds.add(String(ev.new_plan_id))
}
if (planIds.size) {
const { data: pAll, error: epAll } = await supabase
.from('plans')
.select('id,key,name')
.in('id', Array.from(planIds))
plans.value = epAll ? [] : (pAll || [])
} else {
plans.value = []
}
// ✅ 7) perfis (created_by)
const userIds = new Set()
for (const ev of events.value) {
const by = String(ev.created_by || '').trim()
if (by) userIds.add(by)
}
if (userIds.size) {
const { data: pr, error: epr } = await supabase
.from('profiles')
.select('*')
.in('id', Array.from(userIds))
profiles.value = epr ? [] : (pr || [])
} else {
profiles.value = []
}
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
onMounted(fetchMeuPlanoClinic)
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<!-- Topbar padrão -->
<div class="mb-4 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col">
<div class="text-2xl font-semibold leading-none">Meu plano</div>
<small class="text-color-secondary mt-1">
Plano da clínica (tenant) e recursos habilitados.
</small>
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<Button
label="Alterar plano"
icon="pi pi-arrow-up-right"
:loading="loading"
@click="goUpgradeClinic"
/>
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="fetchMeuPlanoClinic"
/>
</div>
</div>
</div>
<!-- Card resumo -->
<Card class="rounded-[2rem] overflow-hidden">
<template #content>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-xl md:text-2xl font-semibold leading-tight">
{{ planName }}
</div>
<div class="text-sm text-color-secondary mt-1">
<span v-if="priceLabel">{{ priceLabel }}</span>
<span v-else>Preço não encontrado para este intervalo.</span>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<Tag :value="statusLabelPrettyComputed" :severity="statusSeverity(subscription?.status)" />
<Tag
v-if="subscription?.cancel_at_period_end"
severity="warning"
value="Cancelamento agendado"
rounded
/>
<Tag
v-else-if="subscription"
severity="success"
value="Renovação automática"
rounded
/>
</div>
<div class="mt-3 text-sm text-color-secondary">
<b>Período:</b> {{ periodLabel }}
</div>
<div v-if="cancelHint" class="mt-2 text-sm text-color-secondary">
{{ cancelHint }}
</div>
<div v-if="plan?.description" class="mt-3 text-sm opacity-80 max-w-3xl">
{{ plan.description }}
</div>
</div>
<div v-if="subscription" class="flex flex-col items-end gap-2">
<small class="text-color-secondary">subscription_id</small>
<code class="text-xs opacity-80 break-all">
{{ subscription.id }}
</code>
</div>
</div>
<div v-if="!subscription" class="mt-4 rounded-2xl border border-[var(--surface-border)] p-4 text-sm text-color-secondary">
Nenhuma assinatura encontrada para este tenant.
</div>
</template>
</Card>
<Divider class="my-6" />
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Features agrupadas -->
<Card class="rounded-[2rem] overflow-hidden">
<template #title>Seu plano inclui</template>
<template #content>
<div v-if="!subscription" class="text-color-secondary">
Sem assinatura.
</div>
<div v-else-if="!features.length" class="text-color-secondary">
Nenhuma feature vinculada a este plano.
</div>
<div v-else class="space-y-5">
<div
v-for="g in groupedFeatures"
:key="g.module"
class="rounded-2xl border border-[var(--surface-border)] overflow-hidden"
>
<div class="px-4 py-3 bg-[var(--surface-50)] border-b border-[var(--surface-border)] flex items-center justify-between">
<div class="font-semibold">
{{ moduleLabel(g.module) }}
</div>
<Tag :value="`${g.items.length}`" severity="secondary" rounded />
</div>
<div class="p-4">
<ul class="m-0 p-0 list-none space-y-3">
<li
v-for="f in g.items"
:key="f.key"
class="rounded-2xl border border-[var(--surface-border)] p-3"
>
<div class="flex items-start gap-3">
<i class="pi pi-check mt-1 text-sm text-color-secondary"></i>
<div class="min-w-0">
<div class="font-medium break-words">{{ f.key }}</div>
<div class="text-sm text-color-secondary mt-1" v-if="f.description">
{{ f.description }}
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
<div class="text-xs text-color-secondary">
Agrupamento automático por prefixo da key (ex.: <b>agenda.*</b>, <b>patients.*</b>, etc.).
</div>
</div>
</template>
</Card>
<!-- Histórico auditável -->
<Card class="rounded-[2rem] overflow-hidden">
<template #title>Histórico</template>
<template #content>
<div v-if="!subscription" class="text-color-secondary">
Sem histórico (não assinatura).
</div>
<div v-else-if="!events.length" class="text-color-secondary">
Sem eventos registrados.
</div>
<div v-else class="space-y-3">
<div
v-for="ev in events"
:key="ev.id"
class="rounded-2xl border border-[var(--surface-border)] p-3"
>
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<Tag
:value="eventLabel(ev.event_type)"
:severity="eventSeverity(ev.event_type)"
rounded
/>
<span class="text-sm text-color-secondary">
por <b>{{ displayUser(ev.created_by) }}</b>
</span>
</div>
<!-- De Para (quando existir) -->
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mt-2 text-sm">
<span class="text-color-secondary">Plano:</span>
<span class="font-medium ml-2">{{ planKeyOrName(ev.old_plan_id) }}</span>
<i class="pi pi-arrow-right text-color-secondary mx-2" />
<span class="font-medium">{{ planKeyOrName(ev.new_plan_id) }}</span>
</div>
<div v-if="ev.reason" class="mt-2 text-sm opacity-80">
{{ ev.reason }}
</div>
</div>
<div class="text-sm text-color-secondary">
{{ fmtDate(ev.created_at) }}
</div>
</div>
<div class="mt-2 text-xs text-color-secondary" v-if="ev.metadata">
<pre class="m-0 whitespace-pre-wrap break-words">{{ prettyMeta(ev.metadata) }}</pre>
</div>
</div>
</div>
<div class="mt-4 text-xs text-color-secondary">
Mostrando até 50 eventos (mais recentes).
</div>
</template>
</Card>
</div>
</div>
</template>
<style scoped>
/* (intencionalmente vazio) */
</style>

View File

@@ -0,0 +1,537 @@
<!-- src/views/pages/billing/TherapistMeuPlanoPage.vue -->
<script setup>
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
const router = useRouter()
const toast = useToast()
const loading = ref(false)
const subscription = ref(null)
const plan = ref(null)
const price = ref(null)
const features = ref([])
const events = ref([])
const plans = ref([])
const profiles = ref([])
// ── Hero sticky ────────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
// ── Formatters ─────────────────────────────────────────────
function money(currency, amountCents) {
if (amountCents == null) return null
const value = Number(amountCents) / 100
try {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency || 'BRL' }).format(value)
} catch (_) {
return `${value.toFixed(2)} ${currency || ''}`.trim()
}
}
function fmtDate(iso) {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
return d.toLocaleString('pt-BR')
}
function prettyMeta(meta) {
if (!meta) return null
try {
if (typeof meta === 'string') return meta
return JSON.stringify(meta, null, 2)
} catch (_) { return String(meta) }
}
function statusSeverity(st) {
const s = String(st || '').toLowerCase()
if (s === 'active') return 'success'
if (s === 'trialing') return 'info'
if (s === 'past_due') return 'warning'
if (s === 'canceled' || s === 'cancelled') return 'danger'
return 'secondary'
}
function statusLabel(st) {
const s = String(st || '').toLowerCase()
if (s === 'active') return 'Ativo'
if (s === 'trialing') return 'Trial'
if (s === 'past_due') return 'Atrasado'
if (s === 'canceled' || s === 'cancelled') return 'Cancelado'
return st || '—'
}
function eventSeverity(t) {
const k = String(t || '').toLowerCase()
if (k === 'plan_changed') return 'info'
if (k === 'canceled') return 'danger'
if (k === 'reactivated') return 'success'
return 'secondary'
}
function eventLabel(t) {
const k = String(t || '').toLowerCase()
if (k === 'plan_changed') return 'Plano alterado'
if (k === 'canceled') return 'Cancelada'
if (k === 'reactivated') return 'Reativada'
return t || '—'
}
// ── Computed ────────────────────────────────────────────────
const planName = computed(() => plan.value?.name || subscription.value?.plan_key || '—')
const intervalLabel = computed(() => {
const i = subscription.value?.interval
if (i === 'month') return 'mês'
if (i === 'year') return 'ano'
return i || '—'
})
const priceLabel = computed(() => {
if (!price.value) return null
return `${money(price.value.currency, price.value.amount_cents)} / ${intervalLabel.value}`
})
const periodLabel = computed(() => {
const s = subscription.value
if (!s?.current_period_start || !s?.current_period_end) return '—'
return `${fmtDate(s.current_period_start)}${fmtDate(s.current_period_end)}`
})
// ── Features agrupadas ──────────────────────────────────────
function moduleFromKey(key) {
const k = String(key || '').trim()
if (!k) return 'Outros'
if (k.includes('.')) return k.split('.')[0] || 'Outros'
if (k.includes('_')) return k.split('_')[0] || 'Outros'
return 'Outros'
}
function moduleLabel(m) {
const s = String(m || '').trim()
if (!s) return 'Outros'
return s.charAt(0).toUpperCase() + s.slice(1)
}
const groupedFeatures = computed(() => {
const list = features.value || []
const map = new Map()
for (const f of list) {
const mod = moduleFromKey(f.key)
if (!map.has(mod)) map.set(mod, [])
map.get(mod).push(f)
}
const modules = Array.from(map.keys()).sort((a, b) => {
if (a === 'Outros') return 1
if (b === 'Outros') return -1
return a.localeCompare(b)
})
return modules.map(mod => {
const items = map.get(mod) || []
items.sort((a, b) => String(a.key || '').localeCompare(String(b.key || '')))
return { module: mod, items }
})
})
// ── Histórico ───────────────────────────────────────────────
const planById = computed(() => {
const m = new Map()
for (const p of plans.value || []) m.set(String(p.id), p)
return m
})
function planKeyOrName(planId) {
if (!planId) return '—'
const p = planById.value.get(String(planId))
return p?.key || p?.name || String(planId)
}
const profileById = computed(() => {
const m = new Map()
for (const p of profiles.value || []) m.set(String(p.id), p)
return m
})
function displayUser(userId) {
if (!userId) return '—'
const p = profileById.value.get(String(userId))
if (!p) return String(userId)
const name = p.nome || p.name || p.full_name || p.display_name || null
const email = p.email || p.email_principal || null
if (name && email) return `${name} <${email}>`
return name || email || String(userId)
}
// ── Actions ─────────────────────────────────────────────────
function goUpgrade() {
router.push('/therapist/upgrade?redirectTo=/therapist/meu-plano')
}
// ── Fetch ───────────────────────────────────────────────────
async function fetchMeuPlanoTherapist() {
loading.value = true
try {
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 não encontrada.')
const sRes = await supabase
.from('subscriptions')
.select('*')
.eq('user_id', uid)
.order('created_at', { ascending: false })
.limit(10)
if (sRes.error) throw sRes.error
const subList = sRes.data || []
const priority = (st) => {
const s = String(st || '').toLowerCase()
if (s === 'active') return 1
if (s === 'trialing') return 2
if (s === 'past_due') return 3
if (s === 'unpaid') return 4
if (s === 'incomplete') return 5
if (s === 'canceled' || s === 'cancelled') return 9
return 8
}
subscription.value = subList.length
? subList.slice().sort((a, b) => {
const pa = priority(a?.status)
const pb = priority(b?.status)
if (pa !== pb) return pa - pb
return new Date(b?.created_at || 0) - new Date(a?.created_at || 0)
})[0]
: null
if (!subscription.value) {
plan.value = null; price.value = null
features.value = []; events.value = []
plans.value = []; profiles.value = []
return
}
const pRes = await supabase.from('plans').select('id, key, name, description').eq('id', subscription.value.plan_id).maybeSingle()
if (pRes.error) throw pRes.error
plan.value = pRes.data || null
const nowIso = new Date().toISOString()
const ppRes = await supabase
.from('plan_prices')
.select('currency, interval, amount_cents, is_active, active_from, active_to')
.eq('plan_id', subscription.value.plan_id)
.eq('interval', subscription.value.interval)
.eq('is_active', true)
.lte('active_from', nowIso)
.or(`active_to.is.null,active_to.gte.${nowIso}`)
.order('active_from', { ascending: false })
.limit(1)
.maybeSingle()
if (ppRes.error) throw ppRes.error
price.value = ppRes.data || null
const pfRes = await supabase.from('plan_features').select('feature_id').eq('plan_id', subscription.value.plan_id)
if (pfRes.error) throw pfRes.error
const featureIds = (pfRes.data || []).map(r => r.feature_id).filter(Boolean)
if (featureIds.length) {
const fRes = await supabase.from('features').select('id, key, description, descricao').in('id', featureIds).order('key', { ascending: true })
if (fRes.error) throw fRes.error
features.value = (fRes.data || []).map(f => ({ key: f.key, description: (f.descricao || f.description || '').trim() }))
} else {
features.value = []
}
const eRes = await supabase.from('subscription_events').select('*').eq('subscription_id', subscription.value.id).order('created_at', { ascending: false }).limit(50)
if (eRes.error) throw eRes.error
events.value = eRes.data || []
const planIds = new Set()
if (subscription.value?.plan_id) planIds.add(String(subscription.value.plan_id))
for (const ev of events.value) {
if (ev?.old_plan_id) planIds.add(String(ev.old_plan_id))
if (ev?.new_plan_id) planIds.add(String(ev.new_plan_id))
}
if (planIds.size) {
const { data: pAll, error: epAll } = await supabase.from('plans').select('id,key,name').in('id', Array.from(planIds))
plans.value = epAll ? [] : (pAll || [])
} else {
plans.value = []
}
const userIds = new Set()
for (const ev of events.value) {
const by = String(ev.created_by || '').trim()
if (by) userIds.add(by)
}
if (userIds.size) {
const { data: pr, error: epr } = await supabase.from('profiles').select('*').in('id', Array.from(userIds))
profiles.value = epr ? [] : (pr || [])
} else {
profiles.value = []
}
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
onMounted(() => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
fetchMeuPlanoTherapist()
})
onBeforeUnmount(() => { _observer?.disconnect() })
</script>
<template>
<Toast />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="mplan-sentinel" />
<!-- Hero sticky -->
<div ref="headerEl" class="mplan-hero mx-3 md:mx-5 mb-4" :class="{ 'mplan-hero--stuck': headerStuck }">
<div class="mplan-hero__blobs" aria-hidden="true">
<div class="mplan-hero__blob mplan-hero__blob--1" />
<div class="mplan-hero__blob mplan-hero__blob--2" />
</div>
<!-- Row 1 -->
<div class="mplan-hero__row1">
<div class="mplan-hero__brand">
<div class="mplan-hero__icon"><i class="pi pi-credit-card text-lg" /></div>
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<div class="mplan-hero__title">Meu Plano</div>
<Tag v-if="subscription" :value="statusLabel(subscription.status)" :severity="statusSeverity(subscription.status)" />
</div>
<div class="mplan-hero__sub">Plano pessoal do terapeuta gerencie sua assinatura</div>
</div>
</div>
<!-- Desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchMeuPlanoTherapist" />
<Button label="Alterar plano" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgrade" />
</div>
<!-- Mobile (<1200px) -->
<div class="flex xl:hidden items-center gap-2 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchMeuPlanoTherapist" />
<Button label="Alterar plano" icon="pi pi-arrow-up-right" size="small" class="rounded-full" @click="goUpgrade" />
</div>
</div>
<!-- Divider -->
<Divider class="mplan-hero__divider my-2" />
<!-- Row 2: resumo rápido (oculto no mobile) -->
<div class="mplan-hero__row2">
<div v-if="loading" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner text-xs" /> Carregando
</div>
<template v-else-if="subscription">
<div class="flex flex-wrap items-center gap-3">
<span class="font-semibold text-sm text-[var(--text-color)]">{{ planName }}</span>
<span v-if="priceLabel" class="text-sm text-[var(--text-color-secondary)]">{{ priceLabel }}</span>
<span class="text-xs text-[var(--text-color-secondary)] border border-[var(--surface-border)] rounded-full px-3 py-1">
Período: {{ periodLabel }}
</span>
<Tag v-if="subscription.cancel_at_period_end" severity="warning" value="Cancelamento agendado" />
<Tag v-else severity="success" value="Renovação automática" />
</div>
</template>
<div v-else class="text-sm text-[var(--text-color-secondary)]">Nenhuma assinatura ativa.</div>
</div>
</div>
<!-- Conteúdo -->
<div class="px-3 md:px-5 mb-5 flex flex-col gap-4">
<!-- Sem assinatura -->
<div v-if="!loading && !subscription" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-credit-card text-xl" />
</div>
<div class="font-semibold">Nenhuma assinatura encontrada</div>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">Escolha um plano para começar a usar todos os recursos.</div>
<div class="mt-4">
<Button label="Ver planos" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgrade" />
</div>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Seu plano inclui: features compactas -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="px-5 py-4 border-b border-[var(--surface-border)] flex items-center justify-between gap-3">
<div>
<div class="font-semibold text-[var(--text-color)]">Seu plano inclui</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">Recursos disponíveis na sua assinatura atual</div>
</div>
<Tag v-if="features.length" :value="`${features.length}`" severity="secondary" />
</div>
<div class="p-5">
<div v-if="!subscription" class="text-sm text-[var(--text-color-secondary)]">Sem assinatura.</div>
<div v-else-if="!features.length" class="text-sm text-[var(--text-color-secondary)]">Nenhuma feature vinculada a este plano.</div>
<div v-else class="space-y-5">
<div v-for="g in groupedFeatures" :key="g.module">
<!-- Cabeçalho do módulo -->
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-50">
{{ moduleLabel(g.module) }}
</span>
<div class="flex-1 h-px bg-[var(--surface-border)]" />
<span class="text-xs text-[var(--text-color-secondary)]">{{ g.items.length }}</span>
</div>
<!-- Grid compacto de features -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
<div
v-for="f in g.items"
:key="f.key"
class="flex items-start gap-2 py-1 px-2 rounded-lg hover:bg-[var(--surface-ground)] transition-colors"
:title="f.description || f.key"
>
<i class="pi pi-check-circle text-emerald-500 text-sm mt-0.5 shrink-0" />
<div class="min-w-0">
<div class="text-sm font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
<div v-if="f.description" class="text-xs text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Histórico -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="px-5 py-4 border-b border-[var(--surface-border)] flex items-center justify-between gap-3">
<div>
<div class="font-semibold text-[var(--text-color)]">Histórico</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">Últimos 50 eventos da assinatura</div>
</div>
<Tag v-if="events.length" :value="`${events.length}`" severity="secondary" />
</div>
<div class="p-5">
<div v-if="!subscription" class="text-sm text-[var(--text-color-secondary)]">Sem histórico (não assinatura).</div>
<div v-else-if="!events.length" class="py-8 text-center">
<i class="pi pi-history text-2xl text-[var(--text-color-secondary)] opacity-30 mb-2 block" />
<div class="text-sm text-[var(--text-color-secondary)]">Sem eventos registrados.</div>
</div>
<div v-else class="space-y-2">
<div
v-for="ev in events"
:key="ev.id"
class="rounded-xl border border-[var(--surface-border)] p-3 hover:bg-[var(--surface-ground)] transition-colors"
>
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="eventLabel(ev.event_type)" :severity="eventSeverity(ev.event_type)" />
<span class="text-xs text-[var(--text-color-secondary)]">
por <b>{{ displayUser(ev.created_by) }}</b>
</span>
</div>
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mt-1.5 text-xs text-[var(--text-color-secondary)] flex items-center gap-1.5 flex-wrap">
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.old_plan_id) }}</span>
<i class="pi pi-arrow-right text-xs opacity-50" />
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.new_plan_id) }}</span>
</div>
<div v-if="ev.reason" class="mt-1 text-xs text-[var(--text-color-secondary)] opacity-70">{{ ev.reason }}</div>
<div v-if="ev.metadata" class="mt-1.5">
<pre class="m-0 text-xs text-[var(--text-color-secondary)] whitespace-pre-wrap break-words opacity-60">{{ prettyMeta(ev.metadata) }}</pre>
</div>
</div>
<div class="text-xs text-[var(--text-color-secondary)] shrink-0">{{ fmtDate(ev.created_at) }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Rodapé: subscription ID -->
<div v-if="subscription" class="text-xs text-[var(--text-color-secondary)] flex items-center gap-2 flex-wrap">
<span>ID da assinatura:</span>
<code class="font-mono select-all">{{ subscription.id }}</code>
</div>
</div>
</template>
<style scoped>
.mplan-sentinel { height: 1px; }
.mplan-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.mplan-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
.mplan-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.mplan-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.mplan-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
.mplan-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
.mplan-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.mplan-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.mplan-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.mplan-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.mplan-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
.mplan-hero__row2 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;
}
@media (max-width: 767px) {
.mplan-hero__divider,
.mplan-hero__row2 { display: none; }
}
</style>

View File

@@ -0,0 +1,422 @@
<!-- src/views/pages/billing/TherapistUpgradePage.vue -->
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
const toast = useToast()
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const isFetching = ref(false)
const uid = ref(null)
const currentSub = ref(null)
const plans = ref([]) // plans (therapist)
const prices = ref([]) // plan_prices ativos do momento
const q = ref('')
const billingInterval = ref('month') // 'month' | 'year'
const intervalOptions = [
{ label: 'Mensal', value: 'month' },
{ label: 'Anual', value: 'year' }
]
const redirectTo = computed(() => route.query.redirectTo || '/therapist/meu-plano')
function money (currency, amountCents) {
if (amountCents == null) return null
const value = Number(amountCents) / 100
try {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency || 'BRL' }).format(value)
} catch (_) {
return `${value.toFixed(2)} ${currency || ''}`.trim()
}
}
function intervalLabel (i) {
if (i === 'month') return 'mês'
if (i === 'year') return 'ano'
return i || '-'
}
function priceFor (planId, interval) {
return (prices.value || []).find(p => String(p.plan_id) === String(planId) && String(p.interval) === String(interval)) || null
}
function priceLabelForCard (planRow) {
const pp = priceFor(planRow?.id, billingInterval.value)
if (!pp) return '—'
return `${money(pp.currency, pp.amount_cents)} / ${intervalLabel(billingInterval.value)}`
}
const filteredPlans = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return plans.value || []
return (plans.value || []).filter(p => {
const a = String(p.key || '').toLowerCase()
const b = String(p.name || '').toLowerCase()
const c = String(p.description || '').toLowerCase()
return a.includes(term) || b.includes(term) || c.includes(term)
})
})
async function loadData () {
if (isFetching.value) return
isFetching.value = true
loading.value = true
try {
const { data: authData, error: authError } = await supabase.auth.getUser()
if (authError) throw authError
uid.value = authData?.user?.id
if (!uid.value) throw new Error('Sessão não encontrada.')
// assinatura pessoal atual (Modelo A): busca por user_id, prioriza status ativo
const sRes = await supabase
.from('subscriptions')
.select('*')
.eq('user_id', uid.value)
.order('created_at', { ascending: false })
.limit(10)
if (sRes.error) throw sRes.error
const subList = sRes.data || []
const subPriority = (st) => {
const s = String(st || '').toLowerCase()
if (s === 'active') return 1
if (s === 'trialing') return 2
if (s === 'past_due') return 3
if (s === 'unpaid') return 4
if (s === 'incomplete') return 5
if (s === 'canceled' || s === 'cancelled') return 9
return 8
}
currentSub.value = subList.length
? subList.slice().sort((a, b) => {
const pa = subPriority(a?.status)
const pb = subPriority(b?.status)
if (pa !== pb) return pa - pb
return new Date(b?.created_at || 0) - new Date(a?.created_at || 0)
})[0]
: null
// planos do terapeuta (target = therapist)
const pRes = await supabase
.from('plans')
.select('id, key, name, description, target, is_active')
.eq('target', 'therapist')
.order('created_at', { ascending: true })
if (pRes.error) throw pRes.error
plans.value = (pRes.data || []).filter(p => p?.is_active !== false)
// preços ativos (janela de vigência)
const nowIso = new Date().toISOString()
const ppRes = await supabase
.from('plan_prices')
.select('plan_id, currency, interval, amount_cents, is_active, active_from, active_to')
.eq('is_active', true)
.lte('active_from', nowIso)
.or(`active_to.is.null,active_to.gte.${nowIso}`)
.order('active_from', { ascending: false })
if (ppRes.error) throw ppRes.error
prices.value = ppRes.data || []
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
} finally {
loading.value = false
isFetching.value = false
}
}
function preflight (planRow, interval) {
if (!uid.value) return { ok: false, msg: 'Sessão não encontrada.' }
if (!planRow?.id) return { ok: false, msg: 'Plano inválido.' }
if (!['month', 'year'].includes(String(interval))) return { ok: false, msg: 'Intervalo inválido.' }
const pp = priceFor(planRow.id, interval)
if (!pp) {
return { ok: false, msg: `Este plano não tem preço ativo para ${intervalLabel(interval)}.` }
}
// se já estiver nesse plano+intervalo, evita ação
if (currentSub.value?.plan_id === planRow.id && String(currentSub.value?.interval) === String(interval)) {
return { ok: false, msg: 'Você já está nesse plano/intervalo.' }
}
return { ok: true, msg: '' }
}
/**
* ✅ Fluxo seguro:
* - se já existe subscription => chama RPC change_subscription_plan (audita + triggers)
* - se não existe => cria subscription mínima e depois (opcional) roda rebuild_owner_entitlements
*
* Observação:
* - aqui eu assumo que você tem `change_subscription_plan(p_subscription_id, p_new_plan_id)`
* e que interval pode ser alterado por update simples ou outro RPC.
* - se você tiver um RPC específico para intervalo, só troca abaixo.
*/
async function choosePlan (planRow, interval) {
const pf = preflight(planRow, interval)
if (!pf.ok) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: pf.msg, life: 4200 })
return
}
saving.value = true
try {
const nowIso = new Date().toISOString()
if (currentSub.value?.id) {
// 1) troca plano via RPC (auditoria)
const { error: e1 } = await supabase.rpc('change_subscription_plan', {
p_subscription_id: currentSub.value.id,
p_new_plan_id: planRow.id
})
if (e1) throw e1
// 2) atualiza intervalo (se o seu RPC já faz isso, pode remover)
const { error: e2 } = await supabase
.from('subscriptions')
.update({
interval,
updated_at: nowIso,
cancel_at_period_end: false,
status: 'active'
})
.eq('id', currentSub.value.id)
if (e2) throw e2
} else {
// cria subscription pessoal
const { data: ins, error: eIns } = await supabase
.from('subscriptions')
.insert({
user_id: uid.value,
tenant_id: null,
plan_id: planRow.id,
plan_key: planRow.key,
interval,
status: 'active',
cancel_at_period_end: false,
provider: 'manual',
source: 'therapist_upgrade',
started_at: nowIso,
current_period_start: nowIso
})
.select('*')
.maybeSingle()
if (eIns) throw eIns
currentSub.value = ins || null
}
// (opcional) se sua regra de entitlements depende disso, você pode rebuildar aqui:
// await supabase.rpc('rebuild_owner_entitlements', { p_owner_id: uid.value })
toast.add({
severity: 'success',
summary: 'Plano atualizado',
detail: 'Seu plano foi atualizado com sucesso.',
life: 3200
})
// ✅ garante refletir estado real
await loadData()
// redirect
await router.push(String(redirectTo.value))
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
} finally {
saving.value = false
}
}
function goBack () {
router.push(String(redirectTo.value))
}
onMounted(loadData)
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<!-- Topbar padrão -->
<div class="mb-4 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col">
<div class="text-2xl font-semibold leading-none">Upgrade do terapeuta</div>
<small class="text-color-secondary mt-1">
Escolha seu plano pessoal (Modelo A).
</small>
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
:disabled="saving"
@click="goBack"
/>
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
:disabled="saving"
@click="loadData"
/>
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-3">
<Tag
v-if="currentSub"
:value="`Plano atual: ${currentSub.plan_key} • ${intervalLabel(currentSub.interval)} • ${currentSub.status}`"
severity="success"
rounded
/>
<Tag
v-else
value="Você ainda não tem um plano pessoal."
severity="warning"
rounded
/>
<div class="flex items-center gap-2 ml-auto">
<small class="text-color-secondary">Exibição de preço</small>
<SelectButton
v-model="billingInterval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
:disabled="loading || saving"
/>
</div>
</div>
</div>
<!-- Busca padrão FloatLabel -->
<Card class="rounded-[2rem] overflow-hidden mb-4">
<template #content>
<div class="flex flex-wrap items-center gap-3 justify-between">
<div class="flex flex-col">
<div class="font-semibold">Planos disponíveis</div>
<small class="text-color-secondary">
Filtre por nome/key/descrição e selecione.
</small>
</div>
<div class="w-full md:w-[420px]">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="therapist_upgrade_search" class="w-full pr-10" variant="filled" />
</IconField>
<label for="therapist_upgrade_search">Buscar plano...</label>
</FloatLabel>
</div>
</div>
</template>
</Card>
<Divider />
<!-- Cards estilo vitrine -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mt-4">
<Card
v-for="p in filteredPlans"
:key="p.id"
:class="[
'rounded-[2rem] overflow-hidden border border-[var(--surface-border)]',
currentSub?.plan_id === p.id ? 'ring-1 ring-emerald-500/25 md:-translate-y-1 md:scale-[1.01]' : ''
]"
>
<template #title>
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold truncate">{{ p.name || p.key }}</div>
<small class="text-color-secondary">{{ p.key }}</small>
</div>
<Tag v-if="currentSub?.plan_id === p.id" severity="success" value="Atual" rounded />
</div>
</template>
<template #content>
<div class="text-sm text-color-secondary" v-if="p.description">
{{ p.description }}
</div>
<div class="mt-4">
<div class="text-4xl font-semibold leading-none">
{{ priceLabelForCard(p) }}
</div>
<div class="text-xs text-color-secondary mt-1">
Alternar mensal/anual no topo para comparar.
</div>
</div>
<div class="mt-5 flex gap-2 flex-wrap">
<Button
:label="billingInterval === 'month' ? 'Escolher mensal' : 'Escolher anual'"
icon="pi pi-check"
:loading="saving"
:disabled="loading || saving"
@click="choosePlan(p, billingInterval)"
/>
<Button
label="Mensal"
severity="secondary"
outlined
:disabled="loading || saving"
@click="choosePlan(p, 'month')"
/>
<Button
label="Anual"
severity="secondary"
outlined
:disabled="loading || saving"
@click="choosePlan(p, 'year')"
/>
</div>
<div class="mt-3 text-xs text-color-secondary">
<span v-if="priceFor(p.id, billingInterval)">
Preço ativo encontrado para {{ intervalLabel(billingInterval) }}.
</span>
<span v-else>
Sem preço ativo para {{ intervalLabel(billingInterval) }}.
</span>
</div>
</template>
</Card>
</div>
<div v-if="!filteredPlans.length && !loading" class="mt-4 text-sm text-color-secondary">
Nenhum plano encontrado.
</div>
</div>
</template>

View File

@@ -1,13 +1,8 @@
<!-- src/views/pages/upgrade/UpgradePage.vue (ajuste o caminho conforme seu projeto) -->
<!-- src/views/pages/upgrade/UpgradePage.vue -->
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
@@ -21,35 +16,54 @@ const router = useRouter()
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
// ======================================================
// ✅ Detecta contexto: terapeuta (personal) ou clínica (tenant)
// O guard passa ?role=therapist ou ?role=clinic_admin na URL,
// mas também fazemos fallback pelo activeRole do tenantStore.
// ======================================================
const activeRole = computed(() => {
const fromQuery = route.query.role || ''
const fromStore = tenantStore.activeRole || ''
const r = String(fromQuery || fromStore).trim()
if (r === 'therapist') return 'therapist'
if (r === 'clinic_admin' || r === 'tenant_admin') return 'clinic_admin'
return 'clinic_admin' // fallback seguro
})
const isTherapist = computed(() => activeRole.value === 'therapist')
const planTarget = computed(() => isTherapist.value ? 'therapist' : 'clinic')
// Feature que motivou o redirecionamento
const requestedFeature = computed(() => route.query.feature || null)
// nomes amigáveis (fallback se não achar)
const featureLabels = {
'online_scheduling.manage': 'Agendamento Online',
'online_scheduling.public': 'Página pública de agendamento',
'advanced_reports': 'Relatórios avançados',
'sms_reminder': 'Lembretes por SMS',
'intakes_pro': 'Formulários PRO'
// nome vem do banco (features.name), sem hardcode
const featureNameMap = computed(() => {
const m = new Map()
for (const f of features.value) m.set(f.key, f.name || f.key)
return m
})
function friendlyFeatureLabel (key) {
return featureNameMap.value.get(key) || key
}
const requestedFeatureLabel = computed(() => {
if (!requestedFeature.value) return null
return featureLabels[requestedFeature.value] || requestedFeature.value
return featureNameMap.value.get(requestedFeature.value) || requestedFeature.value
})
// estado
const loading = ref(false)
const loading = ref(false)
const upgrading = ref(false)
const plans = ref([]) // plans reais
const features = ref([]) // features reais
const planFeatures = ref([]) // links reais plan_features
const plans = ref([])
const features = ref([])
const planFeatures = ref([])
const prices = ref([])
const subscription = ref(null)
const subscription = ref(null) // subscription ativa do tenant
// ✅ Modelo B: plano é do TENANT
const tenantId = computed(() => tenantStore.activeTenantId || null)
// IDs de contexto — dependem do role
const tenantId = computed(() => isTherapist.value ? null : (tenantStore.activeTenantId || null))
const userId = computed(() => isTherapist.value ? (tenantStore.user?.id || null) : null)
const planById = computed(() => {
const m = new Map()
@@ -58,7 +72,6 @@ const planById = computed(() => {
})
const enabledFeatureIdsByPlanId = computed(() => {
// Map planId -> Set(featureId)
const m = new Map()
for (const row of planFeatures.value) {
const set = m.get(row.plan_id) || new Set()
@@ -68,145 +81,161 @@ const enabledFeatureIdsByPlanId = computed(() => {
return m
})
const currentPlanId = computed(() => subscription.value?.plan_id || null)
const currentPlanId = computed(() => subscription.value?.plan_id || null)
const currentPlanKey = computed(() => planById.value.get(currentPlanId.value)?.key || subscription.value?.plan_key || null)
function planKeyById (id) {
return planById.value.get(id)?.key || null
}
const currentPlanKey = computed(() => {
// ✅ fallback: se não carregou plans ainda, usa o plan_key da subscription
return planKeyById(currentPlanId.value) || subscription.value?.plan_key || null
})
function friendlyFeatureLabel (featureKey) {
return featureLabels[featureKey] || featureKey
const billingInterval = ref('month')
const intervalOptions = [
{ label: 'Mensal', value: 'month' },
{ label: 'Anual', value: 'year' }
]
const q = ref('')
function intervalLabel (i) {
return i === 'month' ? 'mês' : i === 'year' ? 'ano' : (i || '-')
}
function money (currency, amountCents) {
if (amountCents == null) return null
const value = Number(amountCents) / 100
try {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency || 'BRL' }).format(value)
} catch {
return `${value.toFixed(2)} ${currency || ''}`.trim()
}
}
function priceFor (planId, interval) {
return (prices.value || []).find(p =>
String(p.plan_id) === String(planId) && String(p.interval) === String(interval)
) || null
}
function priceLabelForPlan (planId) {
const pp = priceFor(planId, billingInterval.value)
if (!pp) return '—'
return `${money(pp.currency, pp.amount_cents)} / ${intervalLabel(billingInterval.value)}`
}
const sortedPlans = computed(() => {
// ordena free primeiro, pro segundo, resto por key
const arr = [...plans.value]
const rank = (k) => (k === 'free' ? 0 : k === 'pro' ? 1 : 10)
arr.sort((a, b) => {
const ra = rank(a.key), rb = rank(b.key)
if (ra !== rb) return ra - rb
return String(a.key).localeCompare(String(b.key))
})
const term = String(q.value || '').trim().toLowerCase()
let arr = [...plans.value]
if (term) {
arr = arr.filter(p =>
[p.key, p.name, p.description].some(s => String(s || '').toLowerCase().includes(term))
)
}
const rank = k => String(k).toLowerCase() === 'pro' ? 0 : String(k).toLowerCase() === 'free' ? 5 : 10
arr.sort((a, b) => rank(a.key) - rank(b.key) || String(a.key).localeCompare(String(b.key)))
return arr
})
function planBenefits (planId) {
const set = enabledFeatureIdsByPlanId.value.get(planId) || new Set()
const list = features.value.map((f) => ({
ok: set.has(f.id),
key: f.key,
text: friendlyFeatureLabel(f.key)
}))
// coloca as “ok” em cima
list.sort((a, b) => Number(b.ok) - Number(a.ok) || a.text.localeCompare(b.text))
return list
return features.value
.map(f => ({ ok: set.has(f.id), key: f.key, text: friendlyFeatureLabel(f.key) }))
.sort((a, b) => Number(b.ok) - Number(a.ok) || a.text.localeCompare(b.text))
}
function goBack () {
router.back()
}
function goBack () { router.back() }
function goBilling () { router.push(isTherapist.value ? '/therapist/meu-plano' : '/admin/meu-plano') }
function contactSupport () { goBilling() }
function goBilling () {
router.push('/admin/billing')
}
function contactSupport () {
router.push('/admin/billing')
}
// ✅ revalida a rota atual para o guard reavaliar features após troca de plano
async function revalidateCurrentRoute () {
// tenta respeitar um redirectTo (quando usuário veio por recurso bloqueado)
const redirectTo = route.query.redirectTo ? String(route.query.redirectTo) : null
// se existe redirectTo, tente ir para ele (guard decide se entra ou volta ao upgrade)
if (redirectTo) {
try {
await router.replace(redirectTo)
return
} catch (_) {
// se falhar, cai no refresh da rota atual
}
try { await router.replace(redirectTo); return } catch {}
}
// força o vue-router a reprocessar a rota (dispara beforeEach)
try {
await router.replace(router.currentRoute.value.fullPath)
} catch (_) {}
try { await router.replace(router.currentRoute.value.fullPath) } catch {}
}
async function fetchAll () {
loading.value = true
try {
const tid = tenantId.value
if (!tid) throw new Error('Tenant ativo não encontrado.')
if (!tenantStore.loaded) await tenantStore.loadSessionAndTenant()
const [pRes, fRes, pfRes, sRes] = await Promise.all([
supabase.from('plans').select('*').eq('is_active', true).order('key', { ascending: true }),
supabase.from('features').select('*').order('key', { ascending: true }),
supabase.from('plan_features').select('plan_id, feature_id'),
supabase
const nowIso = new Date().toISOString()
// ✅ busca planos do target correto (therapist ou clinic)
const pQuery = supabase
.from('plans')
.select('*')
.eq('is_active', true)
.eq('target', planTarget.value)
.order('key', { ascending: true })
// ✅ busca subscription do contexto correto
let sQuery = supabase
.from('subscriptions')
.select(`id, tenant_id, user_id, plan_id, plan_key, interval, status,
provider, source, started_at, current_period_start,
current_period_end, created_at, updated_at`)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (isTherapist.value) {
const uid = userId.value
if (!uid) throw new Error('Usuário não identificado.')
sQuery = supabase
.from('subscriptions')
// ✅ pega mais campos úteis e faz join do plano (ajuda a exibir e debugar)
.select(`
id,
tenant_id,
user_id,
plan_id,
plan_key,
"interval",
status,
provider,
source,
started_at,
current_period_start,
current_period_end,
created_at,
updated_at,
plan:plan_id (
id,
key,
name,
description,
price_cents,
currency,
billing_interval,
is_active
)
`)
.eq('tenant_id', tid)
.select(`id, tenant_id, user_id, plan_id, plan_key, interval, status,
provider, source, started_at, current_period_start,
current_period_end, created_at, updated_at`)
.eq('user_id', uid)
.is('tenant_id', null)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
} else {
const tid = tenantId.value
if (!tid) throw new Error('Tenant ativo não encontrado.')
sQuery = supabase
.from('subscriptions')
.select(`id, tenant_id, user_id, plan_id, plan_key, interval, status,
provider, source, started_at, current_period_start,
current_period_end, created_at, updated_at`)
.eq('tenant_id', tid)
.eq('status', 'active')
// ✅ created_at é mais confiável que updated_at em assinaturas manuais
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
])
if (pRes.error) throw pRes.error
if (fRes.error) throw fRes.error
if (pfRes.error) throw pfRes.error
// ✅ subscription pode ser null sem quebrar a página
if (sRes.error) {
console.warn('[Upgrade] erro ao buscar subscription:', sRes.error)
}
plans.value = pRes.data || []
features.value = fRes.data || []
planFeatures.value = pfRes.data || []
subscription.value = sRes.data || null
const [pRes, fRes, pfRes, sRes, ppRes] = await Promise.all([
pQuery,
supabase.from('features').select('id, key, name, descricao').order('key', { ascending: true }),
supabase.from('plan_features').select('plan_id, feature_id'),
sQuery,
supabase
.from('plan_prices')
.select('plan_id, currency, interval, amount_cents, is_active, active_from, active_to')
.eq('is_active', true)
.lte('active_from', nowIso)
.or(`active_to.is.null,active_to.gte.${nowIso}`)
.order('active_from', { ascending: false })
])
// pode remover esses logs depois
console.groupCollapsed('[Upgrade] fetchAll')
console.log('tenantId:', tid)
console.log('subscription:', subscription.value)
console.log('currentPlanKey:', currentPlanKey.value)
console.groupEnd()
if (pRes.error) throw pRes.error
if (fRes.error) throw fRes.error
if (pfRes.error) throw pfRes.error
if (ppRes.error) throw ppRes.error
if (sRes.error) console.warn('[Upgrade] erro ao buscar subscription:', sRes.error)
plans.value = pRes.data || []
features.value = fRes.data || []
planFeatures.value = pfRes.data || []
subscription.value = sRes.data || null
prices.value = ppRes.data || []
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
@@ -216,46 +245,42 @@ async function fetchAll () {
}
async function changePlan (targetPlanId) {
if (!targetPlanId || upgrading.value) return
if (!subscription.value?.id) {
toast.add({
severity: 'warn',
summary: 'Sem assinatura ativa',
detail: 'Não encontrei uma assinatura ativa para este tenant. Ative via pagamento manual primeiro.',
life: 4500
detail: 'Não encontrei uma assinatura ativa. Vá em "Assinatura" para ativar/criar um plano.',
life: 5200
})
return
}
if (!targetPlanId) return
if (upgrading.value) return
upgrading.value = true
try {
const tid = tenantId.value
if (!tid) throw new Error('Tenant ativo não encontrado.')
if (String(subscription.value.plan_id) === String(targetPlanId)) return
const current = subscription.value.plan_id
if (current === targetPlanId) return
// ✅ usa o mesmo RPC do seu painel SaaS (transação + histórico)
const { data, error } = await supabase.rpc('change_subscription_plan', {
p_subscription_id: subscription.value.id,
p_new_plan_id: targetPlanId
})
if (error) throw error
// atualiza estado local
subscription.value.plan_id = data?.plan_id || targetPlanId
subscription.value.plan_id = data?.plan_id || targetPlanId
subscription.value.plan_key = data?.plan_key || planKeyById(subscription.value.plan_id) || subscription.value.plan_key
// ✅ recarrega entitlements (sem reload)
// (importante pra refletir o plano imediatamente)
entitlementsStore.clear?.()
// seu store tem loadForTenant no guard; se existir aqui também, use primeiro
if (typeof entitlementsStore.loadForTenant === 'function') {
await entitlementsStore.loadForTenant(tid, { force: true })
} else if (typeof entitlementsStore.fetch === 'function') {
await entitlementsStore.fetch(tid, { force: true })
// ✅ recarrega entitlements do contexto correto
if (isTherapist.value) {
const uid = userId.value
if (uid && typeof entitlementsStore.loadForUser === 'function') {
await entitlementsStore.loadForUser(uid, { force: true })
}
} else {
const tid = tenantId.value
if (tid && typeof entitlementsStore.loadForTenant === 'function') {
await entitlementsStore.loadForTenant(tid, { force: true })
}
}
toast.add({
@@ -265,11 +290,7 @@ async function changePlan (targetPlanId) {
life: 3000
})
// ✅ garante consistência (principalmente se RPC mexer em mais campos)
await fetchAll()
// ✅ dispara o guard novamente: se o usuário perdeu acesso a uma rota PRO,
// ele deve ser redirecionado automaticamente.
await revalidateCurrentRoute()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
@@ -279,28 +300,21 @@ async function changePlan (targetPlanId) {
}
onMounted(async () => {
// ✅ garante que o tenant já foi carregado antes de buscar planos
if (!tenantStore.loaded) await tenantStore.loadSessionAndTenant()
await fetchAll()
})
// se trocar tenant ativo, recarrega
watch(
() => tenantId.value,
() => {
if (tenantId.value) fetchAll()
}
)
watch(() => tenantStore.activeTenantId, () => { fetchAll() })
watch(() => tenantStore.user?.id, () => { fetchAll() })
</script>
<template>
<Toast />
<div class="p-4 md:p-6 lg:p-8">
<!-- HERO CONCEITUAL -->
<div class="mb-6 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<!-- HERO -->
<div class="mb-5 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5 md:p-7">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-20 -right-24 h-80 w-80 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-12 -left-24 h-96 w-96 rounded-full bg-emerald-400/10 blur-3xl" />
@@ -308,116 +322,112 @@ watch(
</div>
<div class="relative flex flex-col gap-4">
<div class="flex items-start justify-between gap-3">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-2xl md:text-3xl font-semibold leading-tight">
Atualize seu plano
</div>
<div class="text-2xl md:text-3xl font-semibold leading-tight">Atualize seu plano</div>
<div class="mt-1 text-sm md:text-base text-[var(--text-color-secondary)]">
Tenant ativo:
<b>{{ tenantId || '—' }}</b>
Contexto: <b>{{ isTherapist ? 'Terapeuta' : 'Clínica' }}</b>
<span class="mx-2 opacity-50"></span>
Você está no plano:
<b>{{ currentPlanKey || '—' }}</b>
Plano atual: <b>{{ currentPlanKey || '—' }}</b>
</div>
</div>
<div class="flex items-center gap-2">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined @click="goBack" />
<Button label="Assinatura" icon="pi pi-credit-card" severity="secondary" outlined @click="goBilling" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
<div class="flex items-center gap-2 flex-wrap">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined :disabled="upgrading" @click="goBack" />
<Button label="Assinatura" icon="pi pi-credit-card" severity="secondary" outlined :disabled="upgrading" @click="goBilling" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" :disabled="upgrading" @click="fetchAll" />
</div>
</div>
<!-- BLOCO: RECURSO BLOQUEADO -->
<!-- recurso bloqueado -->
<div
v-if="requestedFeatureLabel"
class="relative overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4"
>
<div class="absolute inset-0 opacity-60 pointer-events-none">
<div class="absolute -top-10 -right-12 h-40 w-40 rounded-full bg-amber-400/10 blur-2xl" />
<div class="absolute -bottom-10 left-16 h-40 w-40 rounded-full bg-rose-400/10 blur-2xl" />
</div>
<div class="relative flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<Tag severity="warning" value="Recurso bloqueado" />
<div class="font-semibold truncate">
{{ requestedFeatureLabel }}
</div>
<div class="font-semibold truncate">{{ requestedFeatureLabel }}</div>
</div>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">
Esse recurso depende do plano que inclui a feature <b>{{ requestedFeature }}</b>.
Esse recurso depende da feature <b>{{ requestedFeature }}</b>.
</div>
</div>
<div class="flex items-center gap-2">
<Button
label="Ver planos"
icon="pi pi-arrow-down"
severity="secondary"
outlined
@click="() => document.getElementById('plans-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
/>
</div>
<Button
label="Ver planos"
icon="pi pi-arrow-down"
severity="secondary"
outlined
@click="() => document.getElementById('plans-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
/>
</div>
</div>
<div class="text-xs md:text-sm text-[var(--text-color-secondary)]">
A diferença entre ter uma agenda e ter um sistema mora nos detalhes.
<!-- busca + intervalo -->
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
<div class="w-full md:w-[420px]">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="upgrade_search" class="w-full pr-10" variant="filled" :disabled="loading || upgrading" />
</IconField>
<label for="upgrade_search">Buscar plano...</label>
</FloatLabel>
</div>
<div class="flex flex-col items-start md:items-end gap-2">
<small class="text-[var(--text-color-secondary)]">Exibição de preço</small>
<SelectButton v-model="billingInterval" :options="intervalOptions" optionLabel="label" optionValue="value" :disabled="loading || upgrading" />
</div>
</div>
</div>
</div>
</div>
<!-- PLANOS (DINÂMICOS) -->
<!-- PLANOS -->
<div id="plans-grid" class="grid grid-cols-12 gap-4 md:gap-6">
<div v-for="p in sortedPlans" :key="p.id" class="col-span-12 lg:col-span-6">
<!-- card destaque pro PRO -->
<div
:id="p.key === 'pro' ? 'plan-pro' : null"
:class="p.key === 'pro'
:class="String(p.key).toLowerCase() === 'pro'
? 'relative overflow-hidden rounded-[1.75rem] border border-primary/40 bg-[var(--surface-card)]'
: ''"
: 'relative overflow-hidden rounded-[1.75rem] border border-[var(--surface-border)] bg-[var(--surface-card)]'"
>
<div v-if="p.key === 'pro'" class="pointer-events-none absolute inset-0 opacity-80">
<div v-if="String(p.key).toLowerCase() === 'pro'" class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-24 -right-28 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
<div class="absolute -bottom-28 left-12 h-96 w-96 rounded-full bg-emerald-400/10 blur-3xl" />
</div>
<Card :class="p.key === 'pro' ? 'relative border-0' : 'overflow-hidden'">
<Card class="relative border-0">
<template #title>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i :class="p.key === 'pro' ? 'pi pi-sparkles opacity-80' : 'pi pi-leaf opacity-70'" />
<i :class="String(p.key).toLowerCase() === 'pro' ? 'pi pi-sparkles opacity-80' : 'pi pi-leaf opacity-70'" />
<span class="text-xl font-semibold">Plano {{ String(p.key || '').toUpperCase() }}</span>
</div>
<div class="flex items-center gap-2">
<Tag v-if="currentPlanId === p.id" value="Atual" severity="secondary" />
<Tag v-else-if="p.key === 'pro'" value="Recomendado" severity="success" />
<Tag v-else-if="String(p.key).toLowerCase() === 'pro'" value="Recomendado" severity="success" />
</div>
</div>
</template>
<template #subtitle>
<span v-if="p.key === 'free'">O essencial para começar, sem travar seu fluxo.</span>
<span v-else-if="p.key === 'pro'">Para quem quer automatizar, reduzir ruído e ganhar previsibilidade.</span>
<span v-else>Plano personalizado: {{ p.key }}</span>
<div class="flex items-center justify-between gap-3 flex-wrap">
<span class="text-[var(--text-color-secondary)]">
<template v-if="String(p.key).toLowerCase() === 'free'">O essencial para começar, sem travar seu fluxo.</template>
<template v-else-if="String(p.key).toLowerCase() === 'pro'">Para automatizar, reduzir ruído e ganhar previsibilidade.</template>
<template v-else>Plano: {{ p.key }}</template>
</span>
<span class="text-sm font-semibold">{{ priceLabelForPlan(p.id) }}</span>
</div>
</template>
<template #content>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<ul class="list-none p-0 m-0 flex flex-col gap-3">
<li v-for="(b, i) in planBenefits(p.id)" :key="i" class="flex items-start gap-2">
<i
:class="b.ok ? 'pi pi-check-circle text-emerald-500' : 'pi pi-times-circle opacity-50'"
class="mt-0.5"
/>
<span :class="b.ok ? '' : 'text-[var(--text-color-secondary)]'">
{{ b.text }}
</span>
<i :class="b.ok ? 'pi pi-check-circle text-emerald-500' : 'pi pi-times-circle opacity-50'" class="mt-0.5" />
<span :class="b.ok ? '' : 'text-[var(--text-color-secondary)]'">{{ b.text }}</span>
</li>
</ul>
@@ -434,7 +444,6 @@ watch(
:disabled="upgrading || loading"
@click="changePlan(p.id)"
/>
<Button
v-else
label="Você já está neste plano"
@@ -444,20 +453,22 @@ watch(
class="w-full"
disabled
/>
<Button
v-if="p.key !== 'free'"
v-if="String(p.key).toLowerCase() !== 'free'"
label="Falar com suporte"
icon="pi pi-comments"
severity="secondary"
outlined
class="w-full"
:disabled="upgrading"
@click="contactSupport"
/>
<div class="text-center text-xs text-[var(--text-color-secondary)]">
Cancele quando quiser. Sem burocracia.
</div>
<div v-if="!subscription?.id" class="text-center text-xs text-amber-500">
Sem assinatura ativa clique em <b>Assinatura</b> para ativar/criar.
</div>
</div>
</div>
</template>
@@ -467,7 +478,7 @@ watch(
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
Observação: alguns recursos PRO podem depender de configuração inicial (ex.: SMS exige provedor).
Alguns recursos PRO podem depender de configuração inicial (ex.: SMS exige provedor).
</div>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More