first commit

This commit is contained in:
Leonardo
2026-02-18 22:36:45 -03:00
parent ec6b6ef53a
commit 676042268b
122 changed files with 26354 additions and 1615 deletions

2
.env Normal file
View File

@@ -0,0 +1,2 @@
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH

2
.env.local Normal file
View File

@@ -0,0 +1,2 @@
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH

4
.gitignore vendored
View File

@@ -5,10 +5,12 @@ coverage
.nitro .nitro
.cache .cache
.output .output
.env # .env
dist dist
.DS_Store .DS_Store
.idea .idea
.eslintcache .eslintcache
api-generator/typedoc.json api-generator/typedoc.json
**/.DS_Store **/.DS_Store
Dev-documentacao/
supabase/

31
ARCHITECTURE_NOTES.md Normal file
View File

@@ -0,0 +1,31 @@
### Observação sobre `tenant_admin` com UUID coincidente
Foi identificado que o registro de `tenant_members` possui:
- `tenant_id = 816b24fe-a0c3-4409-b79b-c6c0a6935d03`
- `user_id = 816b24fe-a0c3-4409-b79b-c6c0a6935d03`
- `role = tenant_admin`
À primeira vista pode parecer inconsistência, mas não é.
Verificação realizada:
O UUID `816b24fe-a0c3-4409-b79b-c6c0a6935d03` existe em `auth.users`
(email: admin@agenciapsi.com.br).
Portanto:
- `tenant_members.user_id` referencia corretamente `auth.users.id`
- Não há violação de integridade referencial
- O registro é válido
Trata-se de um caso em que:
- O usuário administrador principal possui um UUID específico
- O tenant foi criado com o mesmo UUID
- O administrador é `tenant_admin` desse próprio tenant
Esse padrão não quebra a arquitetura multi-tenant e é funcionalmente válido.
A coincidência entre `tenant_id` e `user_id` é apenas estrutural, não conceitual.
Conclusão:
Nenhuma correção estrutural é necessária.

101
O-que-foi-feito.txt Normal file
View File

@@ -0,0 +1,101 @@
O que foi feito (até agora)
Usuários de teste criados
admin@agenciapsi.com.br
— senha: 123Mudar@
patient@agenciapsi.com.br
— senha: 123Mudar@
therapist@agenciapsi.com.br
— senha: 123Mudar@
Base funcionando
✅ Auth (Supabase) está funcionando
✅ Tabela profiles criada e ok
✅ Trigger automático cria profile após signup
✅ Campo role definido (admin | therapist | patient)
✅ RLS básico ativo
✅ Login funcionando
✅ Logout funcionando
✅ Guard de rota implementado e ativo
✅ RBAC básico operando via meta.role + redirect para painel correto
✅ Home pública / com 3 cards (Admin | Therapist | Patient) levando ao login
✅ Pós-login: busca profiles.role e redireciona para:
/admin
/therapist
/patient
Estrutura implementada agora (menus e sessão para o Sakai)
Sessão central (evita menu errado e if(role) espalhado)
✅ Criado src/app/session.js com:
sessionUser, sessionRole, sessionReady (refs globais)
initSession() (carrega user + role antes de renderizar o layout)
listenAuthChanges() (atualiza sessão ao logar/deslogar)
✅ Ajustado src/main.js para usar bootstrap async:
chama await initSession() antes de app.mount()
liga listenAuthChanges()
mantém PrimeVue, tema Aura, ToastService e ConfirmationService
mantém imports de CSS existentes
Menu dinâmico por role no Sakai
✅ Menus foram estruturados no formato do Sakai (sections com label + items) e separados por role:
src/navigation/menus/admin.menu.js
src/navigation/menus/therapist.menu.js
src/navigation/menus/patient.menu.js
✅ Criado src/navigation/index.js com getMenuByRole(role) para centralizar a escolha do menu (sem if(role) em componentes).
✅ Ajustado o AppMenu.vue (menu do Sakai) para:
usar computed() com sessionRole/sessionReady
carregar dinamicamente getMenuByRole(sessionRole.value)
evitar “piscar” menu errado antes de carregar (sessionReady)
Menu demo do Sakai mantido sem quebrar o produto
✅ Mantivemos o menu demo (UIKit/Blocks/Start etc.) em arquivo separado para não perder as páginas do template:
src/navigation/menus/sakai.demo.menu.js (conteúdo original do template)
✅ Estratégia adotada:
Admin pode ver o menu demo (idealmente só em DEV)
Therapist/Patient ficam com menu limpo (clínico)
Rotas demo do Sakai corrigidas (arquivos com sufixo Doc)
✅ Problema resolvido: itens do menu demo davam 404 porque as rotas/imports não existiam com os nomes esperados (Input.vue etc.).
✅ Ajuste aplicado: rotas demo apontam para arquivos *Doc.vue (ex.: ButtonDoc.vue, InputDoc.vue).
📌 Criado/ajustado src/router/routes.demo.js para mapear:
/uikit/* → @/views/uikit/*Doc.vue
e demais demos conforme existirem
✅ Incluído demoRoutes no router principal para o menu demo funcionar.
Testes
✅ Confirmado que localStorage.clear() limpa sessão para testar outros usuários/roles rapidamente.

47
checklist-novo-chat.txt Normal file
View File

@@ -0,0 +1,47 @@
🔁 CONTEXTO DO PROJETO (SaaS multi-tenant)
Stack:
- Supabase
- Multi-tenant por clinic/tenant
- Assinaturas por tenant (subscriptions.tenant_id)
- Controle de features: features, plan_features, subscription_intents, entitlementsStore, view v_tenant_entitlements
- Ativação manual: activate_subscription_from_intent()
- Merge concluído: agenda_online → online_scheduling.manage
- Entitlements e bloqueio PRO no menu funcionando
- Signup + intent funcionando; ativação cria subscription ativa; view retorna feature correta
Modelo de “Contas” decidido:
- Auth user (login) ≠ Clínica (tenant)
- Clínica = tenant; Usuário pode ser dono/admin de clínica e também profissional
- Clínica convida usuários (tenant_members). Usuário pode aceitar/recusar.
- Profissional pode trabalhar anos e depois sair: clínica mantém registros; profissional mantém histórico (audit trail), sem acesso após saída.
Regras de offboarding:
- Profissional só pode sair se NÃO houver agenda futura atribuída a ele.
- Se houver, cria “pedido de saída” e admin precisa realocar/cancelar; depois finaliza saída.
Tabelas existentes:
- tenant_members: (id uuid pk, tenant_id uuid, user_id uuid, role text, status text, created_at timestamptz)
- UNIQUE (tenant_id, user_id) atualmente
- Agenda: agenda_eventos, agenda_excecoes, agenda_configuracoes, agenda_regras_semanais
- Outros: subscriptions, subscription_intents, plan_features, features, subscription_events
O que estamos fazendo agora:
- Ajustar modelo de membership lifecycle e offboarding (exit_requests)
- Garantir integridade: histórico de vínculos + auditoria + bloqueio de saída com agenda futura
- Implementar SQL + RPC + RLS + UI (passo a passo)
✔ subscriptions
Representa o plano da clínica (tenant)
✔ tenant_members
Define quais usuários pertencem à clínica
✔ entitlements
Define o que aquela clínica pode usar
Dados que faltam confirmar:
1) Estrutura de agenda_eventos (colunas e como relaciona com profissional)
2) Valores usados em tenant_members.status (active/invited/etc)
3) Estratégia de reentrada: remover UNIQUE (tenant_id,user_id) e usar unique parcial por status ativo/convite
4) Se existe tabela public.users como espelho do auth.users

4147
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,9 @@
}, },
"dependencies": { "dependencies": {
"@primeuix/themes": "^2.0.0", "@primeuix/themes": "^2.0.0",
"@supabase/supabase-js": "^2.95.3",
"chart.js": "3.3.2", "chart.js": "3.3.2",
"pinia": "^3.0.4",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.5.4", "primevue": "^4.5.4",
"tailwindcss-primeui": "^0.6.0", "tailwindcss-primeui": "^0.6.0",
@@ -22,11 +24,13 @@
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-prettier": "^9.0.0", "@vue/eslint-config-prettier": "^9.0.0",
"autoprefixer": "^10.4.24",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0", "eslint-plugin-vue": "^9.23.0",
"postcss": "^8.5.6",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"sass": "^1.55.0", "sass": "^1.55.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.18",
"unplugin-vue-components": "^0.27.3", "unplugin-vue-components": "^0.27.3",
"vite": "^5.3.1" "vite": "^5.3.1"
} }

View File

@@ -1,7 +1,25 @@
<script setup></script> <script setup>
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
const route = useRoute()
const tenant = useTenantStore()
const ent = useEntitlementsStore()
onMounted(async () => {
await tenant.loadSessionAndTenant()
await ent.loadForTenant(tenant.activeTenantId)
// pode remover esses logs depois
console.log('tenant.activeTenantId', tenant.activeTenantId)
console.log('role', tenant.activeRole)
console.log('can online_scheduling.manage?', ent.can('online_scheduling.manage'))
})
</script>
<template> <template>
<router-view /> <router-view />
</template> </template>
<style scoped></style>

112
src/app/bootstrapUserSettings.js vendored Normal file
View File

@@ -0,0 +1,112 @@
import { supabase } from '@/lib/supabase/client'
import { useLayout } from '@/layout/composables/layout'
import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes'
import Aura from '@primeuix/themes/aura'
import Lara from '@primeuix/themes/lara'
import Nora from '@primeuix/themes/nora'
const presets = { Aura, Lara, Nora }
function safeEq (a, b) {
return String(a || '').trim() === String(b || '').trim()
}
// copia do seu getPresetExt (ou exporta ele do Perfil pra reutilizar)
function getPresetExt(primaryColors, layoutConfig) {
const color = primaryColors.find((c) => c.name === layoutConfig.primary) || { name: 'noir', palette: {} }
if (color.name === 'noir') {
return {
semantic: {
primary: {
50: '{surface.50}', 100: '{surface.100}', 200: '{surface.200}', 300: '{surface.300}',
400: '{surface.400}', 500: '{surface.500}', 600: '{surface.600}', 700: '{surface.700}',
800: '{surface.800}', 900: '{surface.900}', 950: '{surface.950}'
},
colorScheme: {
light: {
primary: { color: '{primary.950}', contrastColor: '#ffffff', hoverColor: '{primary.800}', activeColor: '{primary.700}' },
highlight: { background: '{primary.950}', focusBackground: '{primary.700}', color: '#ffffff', focusColor: '#ffffff' }
},
dark: {
primary: { color: '{primary.50}', contrastColor: '{primary.950}', hoverColor: '{primary.200}', activeColor: '{primary.300}' },
highlight: { background: '{primary.50}', focusBackground: '{primary.300}', color: '{primary.950}', focusColor: '{primary.950}' }
}
}
}
}
}
return {
semantic: {
primary: color.palette,
colorScheme: {
light: {
primary: { color: '{primary.500}', contrastColor: '#ffffff', hoverColor: '{primary.600}', activeColor: '{primary.700}' },
highlight: { background: '{primary.50}', focusBackground: '{primary.100}', color: '{primary.700}', focusColor: '{primary.800}' }
},
dark: {
primary: { color: '{primary.400}', contrastColor: '{surface.900}', hoverColor: '{primary.300}', activeColor: '{primary.200}' },
highlight: {
background: 'color-mix(in srgb, {primary.400}, transparent 84%)',
focusBackground: 'color-mix(in srgb, {primary.400}, transparent 76%)',
color: 'rgba(255,255,255,.87)',
focusColor: 'rgba(255,255,255,.87)'
}
}
}
}
}
}
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 { data: uRes, error: uErr } = await supabase.auth.getUser()
if (uErr) return
const user = uRes?.user
if (!user) return
const { data: settings, error } = await supabase
.from('user_settings')
.select('theme_mode, preset, primary_color, surface_color, menu_mode')
.eq('user_id', user.id)
.maybeSingle()
if (error || !settings) return
// menu mode
if (settings.menu_mode && settings.menu_mode !== layoutConfig.menuMode) {
layoutConfig.menuMode = settings.menu_mode
changeMenuMode()
}
// preset
if (settings.preset && settings.preset !== layoutConfig.preset) {
layoutConfig.preset = settings.preset
const presetValue = presets[settings.preset] || presets.Aura
const surfacePalette = surfaces.find(s => s.name === layoutConfig.surface)?.palette
$t().preset(presetValue).preset(getPresetExt(primaryColors, layoutConfig)).surfacePalette(surfacePalette).use({ useDefaultOptions: true })
}
// colors
if (settings.primary_color && !safeEq(settings.primary_color, layoutConfig.primary)) {
layoutConfig.primary = settings.primary_color
updatePreset(getPresetExt(primaryColors, layoutConfig))
}
if (settings.surface_color && !safeEq(settings.surface_color, layoutConfig.surface)) {
layoutConfig.surface = settings.surface_color
const surface = surfaces.find(s => s.name === settings.surface_color)
if (surface) updateSurfacePalette(surface.palette)
}
// dark/light
if (settings.theme_mode) {
const shouldBeDark = settings.theme_mode === 'dark'
if (shouldBeDark !== isDarkTheme) toggleDarkMode()
}
}

222
src/app/session.js Normal file
View File

@@ -0,0 +1,222 @@
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
/**
* ⚠️ IMPORTANTE — ESTABILIDADE DE NAVEGAÇÃO
*
* Este módulo controla a sessão global usada pelo router guard.
*
* Já houve uma condição de corrida entre:
* - visibilitychange → refreshSession()
* - supabase.auth.onAuthStateChange (SIGNED_IN)
* - router.beforeEach
*
* Quando a aba voltava ao foco, refreshSession() era disparado,
* o Supabase emitia SIGNED_IN redundante, e o guard aguardava
* tenant + entitlements simultaneamente à re-hidratação da sessão.
*
* Isso fazia a navegação "travar" no meio do beforeEach.
*
* Para evitar isso:
* - initSession usa singleflight (initPromise)
* - refreshSession não roda se já estiver refreshing
* - SIGNED_IN redundante é ignorado quando estado já está consistente
*
* NÃO remover esses controles sem entender o fluxo completo
* entre sessão, guard e carregamento de tenant/entitlements.
*/
export const sessionUser = ref(null)
export const sessionRole = ref(null)
export const sessionIsSaasAdmin = ref(false)
// só no primeiro boot
export const sessionReady = ref(false)
// refresh leve (troca de aba / refresh token) sem desmontar UI
export const sessionRefreshing = ref(false)
let onSignedOutCallback = null
export function setOnSignedOut (cb) {
onSignedOutCallback = typeof cb === 'function' ? cb : null
}
// evita init concorrente
let initPromise = null
async function fetchRole (userId) {
const { data, error } = await supabase
.from('profiles')
.select('role')
.eq('id', userId)
.single()
if (error) return null
return data?.role || null
}
async function fetchIsSaasAdmin (userId) {
const { data, error } = await supabase
.from('saas_admins')
.select('user_id')
.eq('user_id', userId)
.maybeSingle()
if (error) return false
return !!data
}
/**
* Atualiza estado a partir de uma session "confiável" (getSession() ou callback do auth).
* ⚠️ NÃO zera user/role durante refresh enquanto existir sessão.
*/
async function hydrateFromSession (sess) {
const user = sess?.user || null
if (!user?.id) return false
const prevUid = sessionUser.value?.id || null
const uid = user.id
// ✅ pega primeiro hydrate e troca de usuário
const userChanged = prevUid !== uid
// atualiza user imediatamente (sem flicker)
sessionUser.value = user
// ✅ saas admin: calcula no primeiro hydrate e sempre que trocar de user
// (no primeiro hydrate prevUid é null, então userChanged = true)
if (userChanged) {
sessionIsSaasAdmin.value = await fetchIsSaasAdmin(uid)
}
// role: busca se não tem, ou se mudou user
if (!sessionRole.value || userChanged) {
sessionRole.value = await fetchRole(uid)
}
return true
}
/**
* Boot inicial (pode bloquear UI) ou refresh (não pode derrubar menu).
*/
export async function initSession ({ initial = false } = {}) {
if (initPromise) return initPromise
if (initial) sessionReady.value = false
else sessionRefreshing.value = true
initPromise = (async () => {
try {
const { data, error } = await supabase.auth.getSession()
if (error) throw error
const sess = data?.session || null
const ok = await hydrateFromSession(sess)
// se não tem sessão, zera estado (aqui pode, porque é init/refresh controlado)
if (!ok) {
sessionUser.value = null
sessionRole.value = null
sessionIsSaasAdmin.value = false
}
} catch (e) {
console.warn('[initSession] getSession falhou (tratando como sem sessão):', e)
// não deixa estourar pro router guard
sessionUser.value = null
sessionRole.value = null
sessionIsSaasAdmin.value = false
}
})()
try {
await initPromise
} finally {
initPromise = null
if (initial) sessionReady.value = true
sessionRefreshing.value = false
}
}
// refresh leve (troca de aba etc.)
export async function refreshSession () {
// ✅ evita corrida: se já está refreshing/init, não dispara outro
if (sessionRefreshing.value || initPromise) return
const { data, error } = await supabase.auth.getSession()
if (error) return
const sess = data?.session || null
const uid = sess?.user?.id || null
// se não tem sessão, não zera aqui (deixa SIGNED_OUT cuidar)
if (!uid) return
// se já está consistente, não faz nada
if (sessionUser.value?.id === uid && sessionRole.value) return
await initSession({ initial: false })
}
// evita múltiplos listeners
let authSubscription = null
export function listenAuthChanges () {
if (authSubscription) return
const { data } = supabase.auth.onAuthStateChange(async (event, sess) => {
console.log('[AUTH EVENT]', event)
// ✅ SIGNED_OUT: zera e chama callback
if (event === 'SIGNED_OUT') {
sessionUser.value = null
sessionRole.value = null
sessionIsSaasAdmin.value = false
sessionRefreshing.value = false
sessionReady.value = true
if (onSignedOutCallback) onSignedOutCallback()
return
}
// ✅ se já está consistente, ignora SIGNED_IN redundante
if (event === 'SIGNED_IN') {
const uid = sess?.user?.id || null
if (uid && sessionReady.value && sessionUser.value?.id === uid && sessionRole.value) {
return
}
}
// ✅ use a session fornecida no callback
if (sess?.user?.id) {
// evita reentrância
if (sessionRefreshing.value) return
sessionRefreshing.value = true
try {
await hydrateFromSession(sess)
sessionReady.value = true
} catch (e) {
console.warn('[auth hydrate error]', e)
} finally {
sessionRefreshing.value = false
}
return
}
// fallback: refresh leve
try {
await refreshSession()
} catch (e) {
console.error('[refreshSession error]', e)
}
})
authSubscription = data?.subscription || null
}
export function stopAuthChanges () {
if (authSubscription) {
authSubscription.unsubscribe()
authSubscription = null
}
}

View File

@@ -0,0 +1,251 @@
<template>
<Dialog
v-model:visible="isOpen"
modal
:closable="!saving"
:dismissableMask="!saving"
:style="{ width: '34rem', maxWidth: '92vw' }"
@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>
</div>
</template>
<div class="flex flex-col gap-3">
<Message v-if="errorMsg" severity="error" :closable="false">
{{ 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>
<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>
</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>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<Button
label="Cancelar"
severity="secondary"
text
:disabled="saving"
@click="close"
/>
<Button
label="Salvar"
:loading="saving"
:disabled="saving"
@click="submit"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import InputMask from 'primevue/inputmask'
import Message from 'primevue/message'
import { supabase } from '@/lib/supabase/client'
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: 'Cadastro rápido' },
tableName: { type: String, default: 'patients' },
ownerField: { type: String, default: 'owner_id' },
// defaults alinhados com seu schema
nameField: { type: String, default: 'nome_completo' },
emailField: { type: String, default: 'email_principal' },
phoneField: { type: String, default: 'telefone' },
// ✅ NÃO coloque status aqui por padrão (evita violar patients_status_check)
extraPayload: { type: Object, default: () => ({}) },
closeOnCreated: { type: Boolean, default: true },
resetOnOpen: { type: Boolean, default: true }
})
const emit = defineEmits(['update:modelValue', 'created'])
const toast = useToast()
const saving = ref(false)
const touched = ref(false)
const errorMsg = ref('')
const form = reactive({
nome_completo: '',
email_principal: '',
telefone: ''
})
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
watch(
() => props.modelValue,
(v) => {
if (v && props.resetOnOpen) reset()
if (v) {
touched.value = false
errorMsg.value = ''
}
}
)
function reset () {
form.nome_completo = ''
form.email_principal = ''
form.telefone = ''
}
function close () {
isOpen.value = false
}
function onHide () {}
function isValidEmail (v) {
return /.+@.+\..+/.test(String(v || '').trim())
}
function isValidPhone (v) {
const digits = String(v || '').replace(/\D/g, '')
return digits.length === 10 || digits.length === 11
}
function normalizePhoneDigits (v) {
const digits = String(v || '').replace(/\D/g, '')
return digits || null
}
async function getOwnerId () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const user = data?.user
if (!user?.id) throw new Error('Usuário não encontrado. Faça login novamente.')
return user.id
}
async function submit () {
touched.value = true
errorMsg.value = ''
const nome = String(form.nome_completo || '').trim()
const email = String(form.email_principal || '').trim()
const tel = String(form.telefone || '')
if (!nome) return
if (!email) return
if (!isValidEmail(email)) return
if (!tel) return
if (!isValidPhone(tel)) return
saving.value = true
try {
const ownerId = await getOwnerId()
const payload = {
[props.ownerField]: ownerId,
[props.nameField]: nome,
[props.emailField]: email.toLowerCase(),
[props.phoneField]: normalizePhoneDigits(tel),
...props.extraPayload
}
// remove undefined
Object.keys(payload).forEach((k) => {
if (payload[k] === undefined) delete payload[k]
})
const { data, error } = await supabase
.from(props.tableName)
.insert(payload)
.select()
.single()
if (error) throw error
toast.add({
severity: 'success',
summary: 'Paciente criado',
detail: nome,
life: 2500
})
emit('created', data)
if (props.closeOnCreated) close()
} catch (err) {
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.'
errorMsg.value = msg
toast.add({
severity: 'error',
summary: 'Erro ao salvar',
detail: msg,
life: 4500
})
console.error('[ComponentCadastroRapido] insert error:', err)
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1,220 @@
<!-- src/components/agenda/AgendaOnlineGradeCard.vue -->
<script setup>
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'
import { fetchSlotsRegras } from '@/services/agendaConfigService'
import { fetchSlotsBloqueados, setSlotBloqueado } from '@/services/agendaSlotsBloqueadosService'
import { gerarSlotsDoDia } from '@/utils/slotsGenerator'
import { supabase } from '@/lib/supabase/client'
const toast = useToast()
const props = defineProps({
ownerId: { type: String, required: true }
})
const diasSemana = [
{ label: 'Seg', value: 1 },
{ label: 'Ter', value: 2 },
{ label: 'Qua', value: 3 },
{ label: 'Qui', value: 4 },
{ label: 'Sex', value: 5 },
{ label: 'Sáb', value: 6 },
{ label: 'Dom', value: 0 }
]
const loading = ref(false)
const savingSlot = ref(false)
const slotsRegras = ref([]) // agenda_slots_regras
const regrasSemanais = ref([]) // agenda_regras_semanais
const bloqueadosByDia = ref({}) // {dia: Set('09:00'...)}
async function loadRegrasSemanais() {
const { data, error } = await supabase
.from('agenda_regras_semanais')
.select('*')
.eq('owner_id', props.ownerId)
.order('dia_semana', { ascending: true })
.order('hora_inicio', { ascending: true })
if (error) throw error
regrasSemanais.value = data || []
}
async function load() {
loading.value = true
try {
await Promise.all([
loadRegrasSemanais(),
(async () => { slotsRegras.value = await fetchSlotsRegras(props.ownerId) })()
])
// carregue bloqueados de todos os dias
const map = {}
for (const d of diasSemana.map(x => x.value)) {
const rows = await fetchSlotsBloqueados(props.ownerId, d)
map[d] = new Set(rows.map(r => String(r.hora_inicio).slice(0, 5)))
}
bloqueadosByDia.value = map
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível carregar a grade.', life: 3200 })
} finally {
loading.value = false
}
}
function regraDoDia(dia) {
return slotsRegras.value.find(r => r.dia_semana === dia) || null
}
function janelasDoDia(dia) {
return (regrasSemanais.value || []).filter(r => r.dia_semana === dia && r.ativo !== false)
}
function slotsDoDia(dia) {
const regra = regraDoDia(dia)
if (!regra || regra.ativo === false) return []
return gerarSlotsDoDia(janelasDoDia(dia), regra)
}
function isBloqueado(dia, hhmm) {
return !!bloqueadosByDia.value?.[dia]?.has(hhmm)
}
async function toggleSlot(dia, hhmm) {
savingSlot.value = true
try {
const blocked = isBloqueado(dia, hhmm)
await setSlotBloqueado(props.ownerId, dia, hhmm, !blocked)
if (!bloqueadosByDia.value[dia]) bloqueadosByDia.value[dia] = new Set()
if (blocked) bloqueadosByDia.value[dia].delete(hhmm)
else bloqueadosByDia.value[dia].add(hhmm)
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível atualizar o horário.', life: 3200 })
} finally {
savingSlot.value = false
}
}
const resumo = computed(() => {
// só um resumo simples — depois refinamos
const diasAtivos = diasSemana.filter(d => (regraDoDia(d.value)?.ativo !== false)).length
return { diasAtivos }
})
onMounted(load)
</script>
<template>
<Card class="overflow-hidden">
<template #title>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i class="pi pi-globe" />
<span>Grade do online (estilo Altegio)</span>
</div>
<div class="flex gap-2">
<Button icon="pi pi-refresh" text rounded :disabled="loading" @click="load" />
</div>
</div>
</template>
<template #content>
<div v-if="loading" class="flex items-center gap-3 text-600">
<ProgressSpinner style="width:22px;height:22px" />
Carregando
</div>
<div v-else>
<!-- Resumo tipo cards -->
<div class="grid grid-cols-12 gap-3 mb-4">
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
<div class="text-600 text-sm">Tipo de slots</div>
<div class="text-900 font-semibold mt-1">Fixo</div>
</div>
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
<div class="text-600 text-sm">Dias ativos</div>
<div class="text-900 font-semibold mt-1">{{ resumo.diasAtivos }}</div>
</div>
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
<div class="text-600 text-sm">Dica</div>
<div class="text-900 font-semibold mt-1">Clique em um horário para ocultar/exibir</div>
</div>
</div>
<TabView>
<TabPanel v-for="d in diasSemana" :key="d.value" :header="d.label">
<div class="grid grid-cols-12 gap-4">
<!-- Jornada -->
<div class="col-span-12">
<div class="flex items-center justify-between">
<div class="text-900 font-medium">Jornada do dia</div>
<div class="text-600 text-sm">
(Isso vem das suas janelas semanais)
</div>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<template v-if="janelasDoDia(d.value).length">
<Tag
v-for="j in janelasDoDia(d.value)"
:key="j.id"
:value="`${String(j.hora_inicio).slice(0,5)}${String(j.hora_fim).slice(0,5)}`"
/>
</template>
<template v-else>
<span class="text-600 text-sm">Sem jornada ativa neste dia.</span>
</template>
</div>
</div>
<!-- Chips -->
<div class="col-span-12">
<div class="flex items-center justify-between">
<div class="text-900 font-medium">Horários publicados</div>
<div class="text-600 text-sm" v-if="savingSlot">Salvando</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<template v-if="slotsDoDia(d.value).length">
<button
v-for="hh in slotsDoDia(d.value)"
:key="hh"
class="px-3 py-2 rounded-lg border text-sm transition"
:class="isBloqueado(d.value, hh)
? 'border-[var(--surface-border)] text-600 bg-[var(--surface-ground)] line-through opacity-70'
: 'border-[var(--surface-border)] text-900 bg-[var(--surface-card)] hover:bg-[var(--surface-ground)]'"
@click="toggleSlot(d.value, hh)"
>
{{ hh }}
</button>
</template>
<template v-else>
<span class="text-600 text-sm">
Nada para publicar (verifique: jornada do dia + regra de slots ativa).
</span>
</template>
</div>
<div class="text-600 text-sm mt-3 leading-relaxed">
Se algum horário não deve aparecer para o paciente, clique para <b>desativar</b>.
Isso não altera sua agenda interna a disponibilidade do online.
</div>
</div>
</div>
</TabPanel>
</TabView>
</div>
</template>
</Card>
</template>

View File

@@ -0,0 +1,183 @@
<!-- 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'
const toast = useToast()
const props = defineProps({
ownerId: { type: String, required: true }
})
const loading = ref(false)
const saving = ref(false)
const diasSemana = [
{ label: 'Dom', value: 0 },
{ label: 'Seg', value: 1 },
{ label: 'Ter', value: 2 },
{ label: 'Qua', value: 3 },
{ label: 'Qui', value: 4 },
{ label: 'Sex', value: 5 },
{ label: 'Sáb', value: 6 }
]
const passos = [15, 20, 30, 45, 60, 75, 90, 120].map(v => ({ label: `${v} min`, value: v }))
const offsets = [0, 15, 30, 45].map(v => ({ label: v === 0 ? ':00' : `:${String(v).padStart(2, '0')}`, value: v }))
const model = ref({
0: { dia_semana: 0, ativo: false, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
1: { dia_semana: 1, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
2: { dia_semana: 2, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
3: { dia_semana: 3, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
4: { dia_semana: 4, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
5: { dia_semana: 5, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
6: { dia_semana: 6, ativo: true, passo_minutos: 60, offset_minutos: 30, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 }
})
function applyRows(rows) {
for (const r of rows || []) {
model.value[r.dia_semana] = {
dia_semana: r.dia_semana,
ativo: !!r.ativo,
passo_minutos: r.passo_minutos,
offset_minutos: r.offset_minutos,
buffer_antes_min: r.buffer_antes_min,
buffer_depois_min: r.buffer_depois_min,
min_antecedencia_horas: r.min_antecedencia_horas
}
}
}
async function load() {
loading.value = true
try {
const rows = await fetchSlotsRegras(props.ownerId)
applyRows(rows)
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível carregar slots por dia.', life: 3200 })
} finally {
loading.value = false
}
}
async function salvarDia(dia) {
saving.value = true
try {
const p = model.value[dia]
await upsertSlotRegra(props.ownerId, p)
toast.add({ severity: 'success', summary: 'Salvo', detail: `Slots do ${diasSemana.find(x => x.value === dia)?.label} atualizados.`, life: 1600 })
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível salvar.', life: 3200 })
} finally {
saving.value = false
}
}
async function salvarTudo() {
saving.value = true
try {
for (const d of diasSemana.map(x => x.value)) {
await upsertSlotRegra(props.ownerId, model.value[d])
}
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Slots por dia atualizados.', life: 1800 })
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível salvar tudo.', life: 3200 })
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<Card class="overflow-hidden">
<template #title>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i class="pi pi-clock" />
<span>Organização de slots (por dia)</span>
</div>
<div class="flex gap-2">
<Button icon="pi pi-refresh" text rounded :disabled="loading" @click="load" />
<Button label="Salvar tudo" icon="pi pi-check" size="small" :loading="saving" @click="salvarTudo" />
</div>
</div>
</template>
<template #content>
<div class="text-600 text-sm mb-3 leading-relaxed">
Aqui você define <b>de quanto em quanto</b> os horários aparecem e <b>em qual minuto</b> eles alinham (ex.: :00 ou :30).
<span class="ml-1">Ex.: sábado com passo 60 e offset 30 gera 08:30, 09:30, 10:30</span>
</div>
<TabView>
<TabPanel v-for="d in diasSemana" :key="d.value" :header="d.label">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 flex items-center gap-3">
<InputSwitch v-model="model[d.value].ativo" />
<div>
<div class="text-900 font-medium">Ativo</div>
<div class="text-600 text-sm">Se desligado, o online não oferece horários nesse dia.</div>
</div>
</div>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<Dropdown v-model="model[d.value].passo_minutos" :options="passos" optionLabel="label" optionValue="value" class="w-full" inputId="passo" />
<label for="passo">Passo (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<Dropdown v-model="model[d.value].offset_minutos" :options="offsets" optionLabel="label" optionValue="value" class="w-full" inputId="offset" />
<label for="offset">Alinhamento</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<InputNumber v-model="model[d.value].min_antecedencia_horas" class="w-full" :min="0" :max="720" inputId="ante" />
<label for="ante">Antecedência (h)</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<InputNumber v-model="model[d.value].buffer_antes_min" class="w-full" :min="0" :max="240" inputId="ba" />
<label for="ba">Buffer antes (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<InputNumber v-model="model[d.value].buffer_depois_min" class="w-full" :min="0" :max="240" inputId="bd" />
<label for="bd">Buffer depois (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-4 flex items-end">
<Button class="w-full" label="Salvar este dia" icon="pi pi-check" :loading="saving" @click="salvarDia(d.value)" />
</div>
</div>
</TabPanel>
</TabView>
</template>
</Card>
</template>

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ProductService } from '@/service/ProductService'; import { ProductService } from '@/services/ProductService';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
const products = ref(null); const products = ref(null);

View File

@@ -0,0 +1,31 @@
<script setup>
import { computed } from 'vue'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
const props = defineProps({
feature: {
type: String,
required: true
},
fallback: {
type: Boolean,
default: false
}
})
const ent = useEntitlementsStore()
const allowed = computed(() => {
return ent.can(props.feature)
})
</script>
<template>
<template v-if="allowed">
<slot />
</template>
<template v-else-if="fallback">
<slot name="fallback" />
</template>
</template>

View File

@@ -0,0 +1,19 @@
import { ref, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
const user = ref(null)
export function useAuth() {
const init = async () => {
const { data } = await supabase.auth.getSession()
user.value = data.session?.user || null
}
supabase.auth.onAuthStateChange((_, session) => {
user.value = session?.user || null
})
onMounted(init)
return { user }
}

View File

@@ -0,0 +1,114 @@
// src/composables/useUserSettingsPersistence.js
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useLayout } from '@/layout/composables/layout'
export function useUserSettingsPersistence() {
const { layoutConfig } = useLayout()
const userId = ref('')
const saveTimer = ref(null)
const pendingPatch = ref({})
const initializing = ref(false)
function isDarkNow() {
return document.documentElement.classList.contains('app-dark')
}
async function init() {
if (initializing.value) return
initializing.value = true
try {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
userId.value = data?.user?.id || ''
console.log('[user_settings] init userId =', userId.value)
} finally {
initializing.value = false
}
}
async function flush() {
if (!userId.value) {
console.warn('[user_settings] flush cancelado: sem userId')
return
}
const patch = { ...pendingPatch.value }
pendingPatch.value = {}
const payload = {
user_id: userId.value,
theme_mode: patch.theme_mode ?? (isDarkNow() ? 'dark' : 'light'),
preset: patch.preset ?? layoutConfig.preset ?? 'Aura',
primary_color: patch.primary_color ?? layoutConfig.primary ?? 'noir',
surface_color: patch.surface_color ?? layoutConfig.surface ?? 'slate',
menu_mode: patch.menu_mode ?? layoutConfig.menuMode ?? 'static',
updated_at: new Date().toISOString()
}
console.log('[user_settings] flush payload =', payload)
const { error } = await supabase
.from('user_settings')
.upsert(payload, { onConflict: 'user_id' })
if (error) {
console.error('[user_settings] flush falhou:', error.message || error)
throw error
}
}
/**
* @param {object} patch
* @param {object} opts
* @param {number} opts.debounceMs
* @param {boolean} opts.flushNow
*/
function queuePatch(patch, opts = {}) {
const debounceMs = typeof opts.debounceMs === 'number' ? opts.debounceMs : 500
const flushNow = !!opts.flushNow
pendingPatch.value = { ...pendingPatch.value, ...patch }
if (saveTimer.value) clearTimeout(saveTimer.value)
const run = async () => {
if (!userId.value) return
const payload = {
user_id: userId.value,
theme_mode: pendingPatch.value.theme_mode ?? (isDarkNow() ? 'dark' : 'light'),
preset: pendingPatch.value.preset ?? layoutConfig.preset,
primary_color: pendingPatch.value.primary_color ?? layoutConfig.primary,
surface_color: pendingPatch.value.surface_color ?? layoutConfig.surface,
menu_mode: pendingPatch.value.menu_mode ?? layoutConfig.menuMode,
updated_at: new Date().toISOString()
}
pendingPatch.value = {}
const { error } = await supabase
.from('user_settings')
.upsert(payload, { onConflict: 'user_id' })
if (error) {
console.error('[user_settings] save falhou:', error?.message || error, payload)
throw error
}
console.log('[user_settings] saved:', payload)
}
if (flushNow) return run()
saveTimer.value = setTimeout(run, debounceMs)
}
return {
init,
queuePatch,
flush
}
}

11
src/constants/roles.js Normal file
View File

@@ -0,0 +1,11 @@
export const ROLES = {
ADMIN: 'admin',
THERAPIST: 'therapist',
PATIENT: 'patient'
}
export const ROLE_HOME = {
admin: '/admin',
therapist: '/therapist',
patient: '/patient'
}

View File

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

View File

@@ -1,243 +1,119 @@
<script setup> <script setup>
import { useLayout } from '@/layout/composables/layout'; import { computed, inject } from 'vue'
import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes'; import { useLayout } from '@/layout/composables/layout'
import Aura from '@primeuix/themes/aura'; import SelectButton from 'primevue/selectbutton'
import Lara from '@primeuix/themes/lara';
import Nora from '@primeuix/themes/nora';
import { ref } from 'vue';
const { layoutConfig, isDarkTheme, changeMenuMode } = useLayout(); import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options'
const presets = { const { layoutConfig, isDarkTheme, changeMenuMode } = useLayout()
Aura,
Lara,
Nora
};
const preset = ref(layoutConfig.preset);
const presetOptions = ref(Object.keys(presets));
const menuMode = ref(layoutConfig.menuMode); // ✅ vem do AppTopbar (mesma instância)
const menuModeOptions = ref([ const queuePatch = inject('queueUserSettingsPatch', null)
{ label: 'Static', value: 'static' }, console.log('[AppConfigurator] queuePatch injected?', !!queuePatch)
{ label: 'Overlay', value: 'overlay' }
]);
const primaryColors = ref([ // menu mode options
{ name: 'noir', palette: {} }, const menuModeOptions = [
{ name: 'emerald', palette: { 50: '#ecfdf5', 100: '#d1fae5', 200: '#a7f3d0', 300: '#6ee7b7', 400: '#34d399', 500: '#10b981', 600: '#059669', 700: '#047857', 800: '#065f46', 900: '#064e3b', 950: '#022c22' } }, { label: 'Static', value: 'static' },
{ name: 'green', palette: { 50: '#f0fdf4', 100: '#dcfce7', 200: '#bbf7d0', 300: '#86efac', 400: '#4ade80', 500: '#22c55e', 600: '#16a34a', 700: '#15803d', 800: '#166534', 900: '#14532d', 950: '#052e16' } }, { label: 'Overlay', value: 'overlay' }
{ name: 'lime', palette: { 50: '#f7fee7', 100: '#ecfccb', 200: '#d9f99d', 300: '#bef264', 400: '#a3e635', 500: '#84cc16', 600: '#65a30d', 700: '#4d7c0f', 800: '#3f6212', 900: '#365314', 950: '#1a2e05' } }, ]
{ name: 'orange', palette: { 50: '#fff7ed', 100: '#ffedd5', 200: '#fed7aa', 300: '#fdba74', 400: '#fb923c', 500: '#f97316', 600: '#ea580c', 700: '#c2410c', 800: '#9a3412', 900: '#7c2d12', 950: '#431407' } },
{ name: 'amber', palette: { 50: '#fffbeb', 100: '#fef3c7', 200: '#fde68a', 300: '#fcd34d', 400: '#fbbf24', 500: '#f59e0b', 600: '#d97706', 700: '#b45309', 800: '#92400e', 900: '#78350f', 950: '#451a03' } },
{ name: 'yellow', palette: { 50: '#fefce8', 100: '#fef9c3', 200: '#fef08a', 300: '#fde047', 400: '#facc15', 500: '#eab308', 600: '#ca8a04', 700: '#a16207', 800: '#854d0e', 900: '#713f12', 950: '#422006' } },
{ name: 'teal', palette: { 50: '#f0fdfa', 100: '#ccfbf1', 200: '#99f6e4', 300: '#5eead4', 400: '#2dd4bf', 500: '#14b8a6', 600: '#0d9488', 700: '#0f766e', 800: '#115e59', 900: '#134e4a', 950: '#042f2e' } },
{ name: 'cyan', palette: { 50: '#ecfeff', 100: '#cffafe', 200: '#a5f3fc', 300: '#67e8f9', 400: '#22d3ee', 500: '#06b6d4', 600: '#0891b2', 700: '#0e7490', 800: '#155e75', 900: '#164e63', 950: '#083344' } },
{ name: 'sky', palette: { 50: '#f0f9ff', 100: '#e0f2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e', 950: '#082f49' } },
{ name: 'blue', palette: { 50: '#eff6ff', 100: '#dbeafe', 200: '#bfdbfe', 300: '#93c5fd', 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8', 800: '#1e40af', 900: '#1e3a8a', 950: '#172554' } },
{ name: 'indigo', palette: { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc', 400: '#818cf8', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca', 800: '#3730a3', 900: '#312e81', 950: '#1e1b4b' } },
{ name: 'violet', palette: { 50: '#f5f3ff', 100: '#ede9fe', 200: '#ddd6fe', 300: '#c4b5fd', 400: '#a78bfa', 500: '#8b5cf6', 600: '#7c3aed', 700: '#6d28d9', 800: '#5b21b6', 900: '#4c1d95', 950: '#2e1065' } },
{ name: 'purple', palette: { 50: '#faf5ff', 100: '#f3e8ff', 200: '#e9d5ff', 300: '#d8b4fe', 400: '#c084fc', 500: '#a855f7', 600: '#9333ea', 700: '#7e22ce', 800: '#6b21a8', 900: '#581c87', 950: '#3b0764' } },
{ name: 'fuchsia', palette: { 50: '#fdf4ff', 100: '#fae8ff', 200: '#f5d0fe', 300: '#f0abfc', 400: '#e879f9', 500: '#d946ef', 600: '#c026d3', 700: '#a21caf', 800: '#86198f', 900: '#701a75', 950: '#4a044e' } },
{ name: 'pink', palette: { 50: '#fdf2f8', 100: '#fce7f3', 200: '#fbcfe8', 300: '#f9a8d4', 400: '#f472b6', 500: '#ec4899', 600: '#db2777', 700: '#be185d', 800: '#9d174d', 900: '#831843', 950: '#500724' } },
{ name: 'rose', palette: { 50: '#fff1f2', 100: '#ffe4e6', 200: '#fecdd3', 300: '#fda4af', 400: '#fb7185', 500: '#f43f5e', 600: '#e11d48', 700: '#be123c', 800: '#9f1239', 900: '#881337', 950: '#4c0519' } }
]);
const surfaces = ref([ // ✅ v-model sincronizado (sem state local)
{ const presetModel = computed({
name: 'slate', get: () => layoutConfig.preset,
palette: { 0: '#ffffff', 50: '#f8fafc', 100: '#f1f5f9', 200: '#e2e8f0', 300: '#cbd5e1', 400: '#94a3b8', 500: '#64748b', 600: '#475569', 700: '#334155', 800: '#1e293b', 900: '#0f172a', 950: '#020617' } set: (val) => {
}, if (!val || val === layoutConfig.preset) return
{ layoutConfig.preset = val
name: 'gray',
palette: { 0: '#ffffff', 50: '#f9fafb', 100: '#f3f4f6', 200: '#e5e7eb', 300: '#d1d5db', 400: '#9ca3af', 500: '#6b7280', 600: '#4b5563', 700: '#374151', 800: '#1f2937', 900: '#111827', 950: '#030712' }
},
{
name: 'zinc',
palette: { 0: '#ffffff', 50: '#fafafa', 100: '#f4f4f5', 200: '#e4e4e7', 300: '#d4d4d8', 400: '#a1a1aa', 500: '#71717a', 600: '#52525b', 700: '#3f3f46', 800: '#27272a', 900: '#18181b', 950: '#09090b' }
},
{
name: 'neutral',
palette: { 0: '#ffffff', 50: '#fafafa', 100: '#f5f5f5', 200: '#e5e5e5', 300: '#d4d4d4', 400: '#a3a3a3', 500: '#737373', 600: '#525252', 700: '#404040', 800: '#262626', 900: '#171717', 950: '#0a0a0a' }
},
{
name: 'stone',
palette: { 0: '#ffffff', 50: '#fafaf9', 100: '#f5f5f4', 200: '#e7e5e4', 300: '#d6d3d1', 400: '#a8a29e', 500: '#78716c', 600: '#57534e', 700: '#44403c', 800: '#292524', 900: '#1c1917', 950: '#0c0a09' }
},
{
name: 'soho',
palette: { 0: '#ffffff', 50: '#f4f4f4', 100: '#e8e9e9', 200: '#d2d2d4', 300: '#bbbcbe', 400: '#a5a5a9', 500: '#8e8f93', 600: '#77787d', 700: '#616268', 800: '#4a4b52', 900: '#34343d', 950: '#1d1e27' }
},
{
name: 'viva',
palette: { 0: '#ffffff', 50: '#f3f3f3', 100: '#e7e7e8', 200: '#cfd0d0', 300: '#b7b8b9', 400: '#9fa1a1', 500: '#87898a', 600: '#6e7173', 700: '#565a5b', 800: '#3e4244', 900: '#262b2c', 950: '#0e1315' }
},
{
name: 'ocean',
palette: { 0: '#ffffff', 50: '#fbfcfc', 100: '#F7F9F8', 200: '#EFF3F2', 300: '#DADEDD', 400: '#B1B7B6', 500: '#828787', 600: '#5F7274', 700: '#415B61', 800: '#29444E', 900: '#183240', 950: '#0c1920' }
}
]);
function getPresetExt() { applyThemeEngine(layoutConfig)
const color = primaryColors.value.find((c) => c.name === layoutConfig.primary); queuePatch?.({ preset: val })
}
})
if (color.name === 'noir') { const menuModeModel = computed({
return { get: () => layoutConfig.menuMode,
semantic: { set: (val) => {
primary: { if (!val || val === layoutConfig.menuMode) return
50: '{surface.50}', layoutConfig.menuMode = val
100: '{surface.100}',
200: '{surface.200}',
300: '{surface.300}',
400: '{surface.400}',
500: '{surface.500}',
600: '{surface.600}',
700: '{surface.700}',
800: '{surface.800}',
900: '{surface.900}',
950: '{surface.950}'
},
colorScheme: {
light: {
primary: {
color: '{primary.950}',
contrastColor: '#ffffff',
hoverColor: '{primary.800}',
activeColor: '{primary.700}'
},
highlight: {
background: '{primary.950}',
focusBackground: '{primary.700}',
color: '#ffffff',
focusColor: '#ffffff'
}
},
dark: {
primary: {
color: '{primary.50}',
contrastColor: '{primary.950}',
hoverColor: '{primary.200}',
activeColor: '{primary.300}'
},
highlight: {
background: '{primary.50}',
focusBackground: '{primary.300}',
color: '{primary.950}',
focusColor: '{primary.950}'
}
}
}
}
};
} else {
return {
semantic: {
primary: color.palette,
colorScheme: {
light: {
primary: {
color: '{primary.500}',
contrastColor: '#ffffff',
hoverColor: '{primary.600}',
activeColor: '{primary.700}'
},
highlight: {
background: '{primary.50}',
focusBackground: '{primary.100}',
color: '{primary.700}',
focusColor: '{primary.800}'
}
},
dark: {
primary: {
color: '{primary.400}',
contrastColor: '{surface.900}',
hoverColor: '{primary.300}',
activeColor: '{primary.200}'
},
highlight: {
background: 'color-mix(in srgb, {primary.400}, transparent 84%)',
focusBackground: 'color-mix(in srgb, {primary.400}, transparent 76%)',
color: 'rgba(255,255,255,.87)',
focusColor: 'rgba(255,255,255,.87)'
}
}
}
}
};
}
}
function updateColors(type, color) { // composable pode aceitar nada (no teu caso, costuma ser isso)
if (type === 'primary') { try { changeMenuMode() } catch {}
layoutConfig.primary = color.name;
} else if (type === 'surface') {
layoutConfig.surface = color.name;
}
applyTheme(type, color); queuePatch?.({ menu_mode: val })
} }
})
function applyTheme(type, color) { function updateColors(type, item) {
if (type === 'primary') { if (type === 'primary') {
updatePreset(getPresetExt()); layoutConfig.primary = item.name
} else if (type === 'surface') { applyThemeEngine(layoutConfig)
updateSurfacePalette(color.palette); queuePatch?.({ primary_color: item.name })
} return
} }
function onPresetChange() { if (type === 'surface') {
layoutConfig.preset = preset.value; layoutConfig.surface = item.name
const presetValue = presets[preset.value]; applyThemeEngine(layoutConfig)
const surfacePalette = surfaces.value.find((s) => s.name === layoutConfig.surface)?.palette; queuePatch?.({ surface_color: item.name })
}
$t().preset(presetValue).preset(getPresetExt()).surfacePalette(surfacePalette).use({ useDefaultOptions: true });
} }
</script> </script>
<template> <template>
<div <div
class="config-panel hidden absolute top-[3.25rem] right-0 w-64 p-4 bg-surface-0 dark:bg-surface-900 border border-surface rounded-border origin-top shadow-[0px_3px_5px_rgba(0,0,0,0.02),0px_0px_2px_rgba(0,0,0,0.05),0px_1px_4px_rgba(0,0,0,0.08)]" class="config-panel hidden absolute top-[3.25rem] right-0 w-64 p-4 bg-surface-0 dark:bg-surface-900 border border-surface rounded-border origin-top shadow-[0px_3px_5px_rgba(0,0,0,0.02),0px_0px_2px_rgba(0,0,0,0.05),0px_1px_4px_rgba(0,0,0,0.08)]"
> >
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div> <div>
<span class="text-sm text-muted-color font-semibold">Primary</span> <span class="text-sm text-muted-color font-semibold">Primary</span>
<div class="pt-2 flex gap-2 flex-wrap justify-between"> <div class="pt-2 flex gap-2 flex-wrap justify-between">
<button <button
v-for="primaryColor of primaryColors" v-for="c of primaryColors"
:key="primaryColor.name" :key="c.name"
type="button" type="button"
:title="primaryColor.name" :title="c.name"
@click="updateColors('primary', primaryColor)" @click="updateColors('primary', c)"
:class="['border-none w-5 h-5 rounded-full p-0 cursor-pointer outline-none outline-offset-1', { 'outline-primary': layoutConfig.primary === primaryColor.name }]" :class="[
:style="{ backgroundColor: `${primaryColor.name === 'noir' ? 'var(--text-color)' : primaryColor.palette['500']}` }" 'border-none w-5 h-5 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
></button> { 'outline-primary': layoutConfig.primary === c.name }
</div> ]"
</div> :style="{ backgroundColor: `${c.name === 'noir' ? 'var(--text-color)' : c.palette['500']}` }"
<div> />
<span class="text-sm text-muted-color font-semibold">Surface</span>
<div class="pt-2 flex gap-2 flex-wrap justify-between">
<button
v-for="surface of surfaces"
:key="surface.name"
type="button"
:title="surface.name"
@click="updateColors('surface', surface)"
:class="[
'border-none w-5 h-5 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
{ 'outline-primary': layoutConfig.surface ? layoutConfig.surface === surface.name : isDarkTheme ? surface.name === 'zinc' : surface.name === 'slate' }
]"
:style="{ backgroundColor: `${surface.palette['500']}` }"
></button>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-color font-semibold">Presets</span>
<SelectButton v-model="preset" @change="onPresetChange" :options="presetOptions" :allowEmpty="false" />
</div>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-color font-semibold">Menu Mode</span>
<SelectButton v-model="menuMode" @change="changeMenuMode" :options="menuModeOptions" :allowEmpty="false" optionLabel="label" optionValue="value" />
</div>
</div> </div>
</div>
<div>
<span class="text-sm text-muted-color font-semibold">Surface</span>
<div class="pt-2 flex gap-2 flex-wrap justify-between">
<button
v-for="s of surfaces"
:key="s.name"
type="button"
:title="s.name"
@click="updateColors('surface', s)"
:class="[
'border-none w-5 h-5 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
{ 'outline-primary': layoutConfig.surface ? layoutConfig.surface === s.name : (isDarkTheme ? s.name === 'zinc' : s.name === 'slate') }
]"
:style="{ backgroundColor: `${s.palette['500']}` }"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-color font-semibold">Presets</span>
<SelectButton v-model="presetModel" :options="presetOptions" :allowEmpty="false" />
</div>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-color font-semibold">Menu Mode</span>
<SelectButton
v-model="menuModeModel"
:options="menuModeOptions"
:allowEmpty="false"
optionLabel="label"
optionValue="value"
/>
</div>
</div> </div>
</div>
</template> </template>

View File

@@ -1,271 +1,483 @@
<script setup> <script setup>
import { ref } from 'vue'; import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import AppMenuItem from './AppMenuItem.vue'; import { useRoute, useRouter } from 'vue-router'
import { useLayout } from '@/layout/composables/layout'
const model = ref([ import AppMenuItem from './AppMenuItem.vue'
{ import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
label: 'Home', import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
items: [
{ import FloatLabel from 'primevue/floatlabel'
label: 'Dashboard', import IconField from 'primevue/iconfield'
icon: 'pi pi-fw pi-home', import InputIcon from 'primevue/inputicon'
to: '/' import InputText from 'primevue/inputtext'
}
] import { sessionRole, sessionIsSaasAdmin } from '@/app/session'
}, import { getMenuByRole } from '@/navigation'
{
label: 'UI Components', import { useTenantStore } from '@/stores/tenantStore'
path: '/uikit', import { useEntitlementsStore } from '@/stores/entitlementsStore'
items: [
{ const route = useRoute()
label: 'Form Layout', const router = useRouter()
icon: 'pi pi-fw pi-id-card', const { layoutState } = useLayout()
to: '/uikit/formlayout'
}, const tenantStore = useTenantStore()
{ const entitlementsStore = useEntitlementsStore()
label: 'Input',
icon: 'pi pi-fw pi-check-square', const model = computed(() => {
to: '/uikit/input' const base = getMenuByRole(sessionRole.value, { isSaasAdmin: sessionIsSaasAdmin.value }) || []
},
{ const normalize = (s) => String(s || '').toLowerCase()
label: 'Button', const priorityOrder = (group) => {
icon: 'pi pi-fw pi-mobile', const label = normalize(group?.label)
to: '/uikit/button', if (label.includes('saas')) return 0
class: 'rotated-icon' if (label.includes('pacientes')) return 1
}, return 99
{ }
label: 'Table',
icon: 'pi pi-fw pi-table', return [...base].sort((a, b) => priorityOrder(a) - priorityOrder(b))
to: '/uikit/table' })
},
{ const tenantId = computed(() => tenantStore.activeTenantId || null)
label: 'List',
icon: 'pi pi-fw pi-list', watch(
to: '/uikit/list' tenantId,
}, async (id) => {
{ entitlementsStore.invalidate()
label: 'Tree', if (id) await entitlementsStore.loadForTenant(id, { force: true })
icon: 'pi pi-fw pi-share-alt', },
to: '/uikit/tree' { immediate: true }
}, )
{
label: 'Panel', watch(
icon: 'pi pi-fw pi-tablet', () => sessionRole.value,
to: '/uikit/panel' async () => {
}, if (!tenantId.value) return
{ entitlementsStore.invalidate()
label: 'Overlay', await entitlementsStore.loadForTenant(tenantId.value, { force: true })
icon: 'pi pi-fw pi-clone', }
to: '/uikit/overlay' )
},
{ // ✅ rota -> activePath (NÃO fecha menu em nenhum cenário)
label: 'Media', watch(
icon: 'pi pi-fw pi-image', () => route.path,
to: '/uikit/media' (p) => { layoutState.activePath = p },
}, { immediate: true }
{ )
label: 'Menu',
icon: 'pi pi-fw pi-bars', // ==============================
to: '/uikit/menu' // 🔎 Busca no menu (flatten + resultados)
}, // ==============================
{ const query = ref('')
label: 'Message', const showResults = ref(false)
icon: 'pi pi-fw pi-comment', const activeIndex = ref(-1)
to: '/uikit/message'
}, // ✅ garante Ctrl/Cmd+K mesmo sem recentes
{ const forcedOpen = ref(false)
label: 'File',
icon: 'pi pi-fw pi-file', // ref do InputText (pra Ctrl/Cmd + K)
to: '/uikit/file' const searchEl = ref(null)
},
{ // wrapper pra click-outside
label: 'Chart', const searchWrapEl = ref(null)
icon: 'pi pi-fw pi-chart-bar',
to: '/uikit/charts' // Recentes
}, const RECENT_KEY = 'menu_search_recent'
{ const recent = ref([])
label: 'Timeline',
icon: 'pi pi-fw pi-calendar', function loadRecent () {
to: '/uikit/timeline' try { recent.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') } catch { recent.value = [] }
}, }
{ function saveRecent (q) {
label: 'Misc', const v = String(q || '').trim()
icon: 'pi pi-fw pi-circle', if (!v) return
to: '/uikit/misc' const list = [v, ...recent.value.filter(x => x !== v)].slice(0, 8)
} recent.value = list
] localStorage.setItem(RECENT_KEY, JSON.stringify(list))
}, }
{ function clearRecent () {
label: 'Prime Blocks', recent.value = []
icon: 'pi pi-fw pi-prime', try { localStorage.removeItem(RECENT_KEY) } catch {}
path: '/blocks', }
items: [ loadRecent()
{
label: 'Free Blocks', watch(query, (v) => {
icon: 'pi pi-fw pi-eye', const hasText = !!v?.trim()
to: '/blocks/free'
}, // digitou: abre e sai do modo "forced"
{ if (hasText) {
label: 'All Blocks', forcedOpen.value = false
icon: 'pi pi-fw pi-globe', showResults.value = true
url: 'https://blocks.primevue.org/', return
target: '_blank' }
}
] // vazio: mantém aberto apenas se foi "forçado" (Ctrl/Cmd+K ou foco)
}, showResults.value = forcedOpen.value
{ })
label: 'Pages',
icon: 'pi pi-fw pi-briefcase', function clearSearch () {
path: '/pages', query.value = ''
items: [ activeIndex.value = -1
{ showResults.value = false
label: 'Landing', forcedOpen.value = false
icon: 'pi pi-fw pi-globe', }
to: '/landing'
}, function norm (s) {
{ return String(s || '')
label: 'Auth', .toLowerCase()
icon: 'pi pi-fw pi-user', .normalize('NFD')
path: '/auth', .replace(/\p{Diacritic}/gu, '')
items: [ .trim()
{ }
label: 'Login',
icon: 'pi pi-fw pi-sign-in', function flattenMenu (items, trail = []) {
to: '/auth/login' const out = []
}, for (const it of (items || [])) {
{ if (it?.visible === false) continue
label: 'Error',
icon: 'pi pi-fw pi-times-circle', const nextTrail = [...trail, it?.label].filter(Boolean)
to: '/auth/error'
}, if (it?.to && !it?.items?.length) {
{ out.push({
label: 'Access Denied', label: it.label || it.to,
icon: 'pi pi-fw pi-lock', to: it.to,
to: '/auth/access' icon: it.icon,
} trail: nextTrail,
] proBadge: !!it.proBadge,
}, feature: it.feature || null
{ })
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: '/start/documentation'
},
{
label: 'View Source',
icon: 'pi pi-fw pi-github',
url: 'https://github.com/primefaces/sakai-vue',
target: '_blank'
}
]
} }
]);
if (it?.items?.length) {
out.push(...flattenMenu(it.items, nextTrail))
}
}
return out
}
const allLinks = computed(() => flattenMenu(model.value))
const results = computed(() => {
const q = norm(query.value)
if (!q) return []
const wantPro = q === 'pro' || q.startsWith('pro ') || q.includes(' pro')
return allLinks.value
.filter(r => {
const hay = `${norm(r.label)} ${norm(r.trail.join(' > '))} ${norm(r.to)}`
if (hay.includes(q)) return true
if (wantPro && (r.proBadge || r.feature)) return true
return false
})
.slice(0, 12)
})
watch(results, (list) => {
activeIndex.value = list.length ? 0 : -1
})
// ===== highlight =====
function escapeHtml (s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function highlight (text, q) {
const queryNorm = norm(q)
const raw = String(text || '')
if (!queryNorm) return escapeHtml(raw)
const rawNorm = norm(raw)
const idx = rawNorm.indexOf(queryNorm)
if (idx < 0) return escapeHtml(raw)
const before = escapeHtml(raw.slice(0, idx))
const mid = escapeHtml(raw.slice(idx, idx + queryNorm.length))
const after = escapeHtml(raw.slice(idx + queryNorm.length))
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
forcedOpen.value = false
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value + 1) % results.value.length
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length
return
}
if (e.key === 'Enter') {
if (showResults.value && results.value.length && activeIndex.value >= 0) {
e.preventDefault()
goTo(results.value[activeIndex.value])
}
}
}
function isTypingTarget (el) {
if (!el) return false
const tag = (el.tagName || '').toLowerCase()
return tag === 'input' || tag === 'textarea' || el.isContentEditable
}
// ===== Ctrl/Cmd + K =====
function focusSearch () {
forcedOpen.value = true
showResults.value = true
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const inst = searchEl.value
const input =
inst?.$el?.tagName === 'INPUT'
? inst.$el
: inst?.$el?.querySelector?.('input')
input?.focus?.()
})
})
}
function onGlobalKeydown (e) {
if (isTypingTarget(document.activeElement)) return
const isK = e.key?.toLowerCase() === 'k'
const withCmdOrCtrl = e.ctrlKey || e.metaKey
if (withCmdOrCtrl && isK) {
e.preventDefault()
e.stopPropagation()
focusSearch()
}
}
// ✅ 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()
})
}
// 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
}
}
onMounted(() => {
window.addEventListener('keydown', onGlobalKeydown, true)
document.addEventListener('mousedown', onDocMouseDown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onGlobalKeydown, true)
document.removeEventListener('mousedown', onDocMouseDown)
})
async function goTo (r) {
saveRecent(query.value)
query.value = ''
showResults.value = false
activeIndex.value = -1
forcedOpen.value = false
await router.push(r.to)
}
// ==============================
// Quick create
// ==============================
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
showResults.value = true
}
}
</script> </script>
<template> <template>
<ul class="layout-menu"> <div class="flex flex-col h-full">
<template v-for="(item, i) in model" :key="item"> <!-- 🔎 TOPO FIXO -->
<app-menu-item v-if="!item.separator" :item="item" :index="i"></app-menu-item> <div ref="searchWrapEl" class="pb-2 border-b border-[var(--surface-border)] bg-[var(--surface-card)]">
<li v-if="item.separator" class="menu-separator"></li> <div class="relative">
</template> <FloatLabel variant="on" class="w-full">
</ul> <IconField class="w-full">
</template> <InputIcon class="pi pi-search" />
<InputText
ref="searchEl"
id="menu_search"
v-model="query"
class="w-full pr-10"
variant="filled"
@focus="onSearchFocus"
@keydown="onSearchKeydown"
/>
</IconField>
<label for="menu_search">Buscar no menu</label>
</FloatLabel>
<style lang="scss" scoped></style> <!-- botão limpar busca -->
<button
v-if="query.trim()"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 opacity-70 hover:opacity-100"
@mousedown.prevent="clearSearch"
aria-label="Limpar busca"
>
<i class="pi pi-times" />
</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"
>
<div class="px-3 py-2 text-xs opacity-70 flex items-center justify-content-between">
<span>Recentes</span>
<button
type="button"
class="opacity-70 hover:opacity-100"
@mousedown.prevent="clearRecent"
aria-label="Limpar recentes"
>
<i class="pi pi-trash" />
</button>
</div>
<button
v-for="q in recent"
:key="q"
class="w-full text-left px-3 py-2 hover:bg-[var(--surface-hover)] flex items-center gap-2"
type="button"
@click.stop.prevent="applyRecent(q)"
>
<i class="pi pi-history opacity-70" />
<div class="flex-1">{{ q }}</div>
</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"
type="button"
@mousedown.prevent="goTo(r)"
:class="[
'w-full text-left px-3 py-2 flex items-center gap-2',
i === activeIndex ? 'bg-[var(--surface-hover)]' : 'hover:bg-[var(--surface-hover)]'
]"
>
<i v-if="r.icon" :class="r.icon" class="opacity-80" />
<div class="flex flex-col flex-1">
<div class="font-medium leading-tight" v-html="highlight(r.label, query)" />
<small class="opacity-70">{{ r.trail.join(' > ') }}</small>
</div>
<span
v-if="r.proBadge || r.feature"
class="text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"
>
PRO
</span>
</button>
</div>
<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"
/>
</template>
</ul>
</div>
<!-- rodapé fixo -->
<AppMenuFooterPanel />
<ComponentCadastroRapido
v-model="quickDialog"
title="Cadastro Rápido"
table-name="patients"
name-field="nome_completo"
email-field="email_principal"
phone-field="telefone"
:extra-payload="{ status: 'Ativo' }"
@created="onQuickCreated"
/>
</div>
</template>

View File

@@ -0,0 +1,189 @@
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { sessionUser, sessionRole } from '@/app/session'
import { supabase } from '@/lib/supabase/client'
const router = useRouter()
const pop = ref(null)
function isAdminRole (r) {
return r === 'admin' || r === 'tenant_admin'
}
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 label = computed(() => {
const name = sessionUser.value?.user_metadata?.full_name
return name || sessionUser.value?.email || 'Conta'
})
const sublabel = computed(() => {
const r = sessionRole.value
if (!r) return 'Sessão'
if (isAdminRole(r)) return 'Administrador'
if (r === 'therapist') return 'Terapeuta'
if (r === 'patient') return 'Paciente'
return r
})
function toggle (e) {
pop.value?.toggle(e)
}
function close () {
try {
pop.value?.hide()
} catch {}
}
function goMyProfile () {
close()
// navegação segura por name
safePush(
{ name: 'MeuPerfil' },
'/me/perfil'
)
}
function goSettings () {
close()
const r = sessionRole.value
if (isAdminRole(r) || r === 'therapist') {
// rota por name (como você já usa)
router.push({ name: 'ConfiguracoesAgenda' })
return
}
if (r === 'patient') {
router.push('/patient/conta')
return
}
router.push('/')
}
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('/')
}
}
}
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'
)
}
async function signOut () {
close()
try {
await supabase.auth.signOut()
} catch {
// se falhar, ainda assim manda pro login
} finally {
router.push('/auth/login')
}
}
</script>
<template>
<div class="sticky bottom-0 z-20 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
<button
type="button"
class="w-full px-3 py-3 flex items-center gap-3 hover:bg-[var(--surface-ground)] transition"
@click="toggle"
>
<!-- avatar -->
<img
v-if="sessionUser.value?.user_metadata?.avatar_url"
:src="sessionUser.value.user_metadata.avatar_url"
class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]"
/>
<div
v-else
class="h-9 w-9 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center text-sm font-semibold"
>
{{ initials }}
</div>
<!-- labels -->
<div class="min-w-0 flex-1 text-left">
<div class="truncate text-sm font-semibold text-[var(--text-color)]">
{{ label }}
</div>
<div class="truncate text-xs text-[var(--text-color-secondary)]">
{{ sublabel }}
</div>
</div>
<i class="pi pi-angle-up text-xs opacity-70" />
</button>
<Popover ref="pop" appendTo="body">
<div class="min-w-[220px] p-1">
<Button
label="Configurações"
icon="pi pi-cog"
text
class="w-full justify-start"
@click="goSettings"
/>
<Button
label="Segurança"
icon="pi pi-shield"
text
class="w-full justify-start"
@click="goSecurity"
/>
<Button
label="Meu Perfil"
icon="pi pi-user"
text
class="w-full justify-start"
@click="goMyProfile"
/>
<div class="my-1 border-t border-[var(--surface-border)]" />
<Button
label="Sair"
icon="pi pi-sign-out"
severity="danger"
text
class="w-full justify-start"
@click="signOut"
/>
</div>
</Popover>
</div>
</template>

View File

@@ -1,78 +1,225 @@
<script setup> <script setup>
import { useLayout } from '@/layout/composables/layout'; import { useLayout } from '@/layout/composables/layout'
import { computed } from 'vue'; import { computed, ref, nextTick } from 'vue'
import { useRouter } from 'vue-router'
const { layoutState, isDesktop } = useLayout(); import Popover from 'primevue/popover'
import Button from 'primevue/button'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
const { layoutState, isDesktop } = useLayout()
const router = useRouter()
const pop = ref(null)
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
const emit = defineEmits(['quick-create'])
const props = defineProps({ const props = defineProps({
item: { item: { type: Object, default: () => ({}) },
type: Object, root: { type: Boolean, default: false },
default: () => ({}) parentPath: { type: String, default: null }
}, })
root: {
type: Boolean,
default: true
},
parentPath: {
type: String,
default: null
}
});
const fullPath = computed(() => (props.item.path ? (props.parentPath ? props.parentPath + props.item.path : props.item.path) : null)); const fullPath = computed(() =>
props.item?.path
? (props.parentPath ? props.parentPath + props.item.path : props.item.path)
: null
)
// ==============================
// Active logic: mantém submenu aberto se algum descendente estiver ativo
// ==============================
function isSameRoute (current, target) {
if (!current || !target) return false
return current === target || current.startsWith(target + '/')
}
function hasActiveDescendant (node, currentPath) {
const children = node?.items || []
for (const child of children) {
if (child?.to && isSameRoute(currentPath, child.to)) return true
if (child?.items?.length && hasActiveDescendant(child, currentPath)) return true
}
return false
}
const isActive = computed(() => { const isActive = computed(() => {
return props.item.path ? layoutState.activePath?.startsWith(fullPath.value) : layoutState.activePath === props.item.to; const current = layoutState.activePath || ''
}); const item = props.item
const itemClick = (event, item) => { // grupo com submenu: active se qualquer descendente estiver ativo
if (item.disabled) { if (item?.items?.length) {
event.preventDefault(); if (hasActiveDescendant(item, current)) return true
return;
}
if (item.command) { // fallback pelo "path" (útil para rotas internas tipo /saas/plans/123)
item.command({ originalEvent: event, item: item }); return item.path ? current.startsWith(fullPath.value || '') : false
} }
if (item.items) { // folha: active se rota igual ao to
if (isActive.value) { return item?.to ? isSameRoute(current, item.to) : false
layoutState.activePath = layoutState.activePath.replace(item.path, ''); })
} else {
layoutState.activePath = fullPath.value; // ==============================
layoutState.menuHoverActive = true; // Feature lock + label
} // ==============================
const ownerId = computed(() => tenantStore.activeTenantId || null)
const isLocked = computed(() => {
const feature = props.item?.feature
return !!(props.item?.proBadge && feature && ownerId.value && !entitlementsStore.has(feature))
})
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
})
const itemClick = async (event, item) => {
// 🔒 locked -> CTA upgrade
if (props.item?.proBadge && isLocked.value) {
event.preventDefault()
event.stopPropagation()
layoutState.overlayMenuActive = false
layoutState.mobileMenuActive = false
layoutState.menuHoverActive = false
await nextTick()
await router.push({ name: 'upgrade', query: { feature: props.item?.feature || '' } })
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()
if (isActive.value) {
layoutState.activePath = props.parentPath || ''
} else { } else {
layoutState.overlayMenuActive = false; layoutState.activePath = fullPath.value
layoutState.mobileMenuActive = false; layoutState.menuHoverActive = true
layoutState.menuHoverActive = false;
} }
}; return
}
// ✅ leaf: marca ativo e NÃO fecha menu
if (item?.to) layoutState.activePath = item.to
}
const onMouseEnter = () => { const onMouseEnter = () => {
if (isDesktop() && props.root && props.item.items && layoutState.menuHoverActive) { 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)
}
function closePopover () {
try { pop.value?.hide() } catch {}
}
function abrirCadastroRapido () {
closePopover()
emit('quick-create', {
entity: props.item?.quickCreateEntity || 'patient',
mode: 'rapido'
})
}
async function irCadastroCompleto () {
closePopover()
layoutState.overlayMenuActive = false
layoutState.mobileMenuActive = false
layoutState.menuHoverActive = false
await nextTick()
router.push('/admin/pacientes/cadastro')
}
</script> </script>
<template> <template>
<li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }"> <li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
<div v-if="root && item.visible !== false" class="layout-menuitem-root-text">{{ item.label }}</div> <div v-if="root && item.visible !== false" class="layout-menuitem-root-text">
<a v-if="(!item.to || item.items) && item.visible !== false" :href="item.url" @click="itemClick($event, item)" :class="item.class" :target="item.target" tabindex="0" @mouseenter="onMouseEnter"> {{ item.label }}
<i :class="item.icon" class="layout-menuitem-icon" /> </div>
<span class="layout-menuitem-text">{{ item.label }}</span>
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items" /> <div v-if="!root && item.visible !== false" class="flex align-items-center justify-content-between w-full">
</a> <component
<router-link v-if="item.to && !item.items && item.visible !== false" @click="itemClick($event, item)" exactActiveClass="active-route" :class="item.class" tabindex="0" :to="item.to" @mouseenter="onMouseEnter"> :is="item.to && !item.items ? 'router-link' : 'a'"
<i :class="item.icon" class="layout-menuitem-icon" /> v-bind="item.to && !item.items ? { to: item.to } : { href: item.url }"
<span class="layout-menuitem-text">{{ item.label }}</span> @click="itemClick($event, item)"
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items" /> :class="[item.class, isBlocked ? 'opacity-60 cursor-pointer' : '']"
</router-link> :target="item.target"
<Transition v-if="item.items && item.visible !== false" name="layout-submenu"> tabindex="0"
<ul v-show="root ? true : isActive" class="layout-submenu"> @mouseenter="onMouseEnter"
<app-menu-item v-for="child in item.items" :key="child.label + '_' + (child.to || child.path)" :item="child" :root="false" :parentPath="fullPath" /> class="flex align-items-center flex-1"
</ul> :aria-disabled="isBlocked ? 'true' : 'false'"
</Transition> >
</li> <i :class="item.icon" class="layout-menuitem-icon" />
<span class="layout-menuitem-text">
{{ labelText }}
<!-- (debug) pode remover depois -->
<small style="opacity:.6">[locked={{ isLocked }}]</small>
</span>
<i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" />
</component>
<Button
v-if="item.quickCreate"
icon="pi pi-plus"
text
rounded
size="small"
class="ml-2"
:disabled="isBlocked"
@click.stop="togglePopover"
/>
</div>
<Popover v-if="item.quickCreate" ref="pop">
<div class="flex flex-column gap-2 min-w-[180px]">
<Button label="Cadastro rápido" icon="pi pi-bolt" text @click="abrirCadastroRapido" />
<Button label="Cadastro completo" icon="pi pi-user-plus" text @click="irCadastroCompleto" />
</div>
</Popover>
<Transition v-if="item.items && item.visible !== false" name="layout-submenu">
<ul v-show="root ? true : isActive" class="layout-submenu">
<app-menu-item
v-for="child in item.items"
:key="(child.to || '') + '|' + (child.path || '') + '|' + child.label"
:item="child"
:root="false"
:parentPath="fullPath"
@quick-create="emit('quick-create', $event)"
/>
</ul>
</Transition>
</li>
</template> </template>

View File

@@ -0,0 +1,25 @@
<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,79 +1,304 @@
<script setup> <script setup>
import { useLayout } from '@/layout/composables/layout'; import { computed, ref, onMounted, provide, nextTick } from 'vue'
import AppConfigurator from './AppConfigurator.vue'; import { useRouter } from 'vue-router'
const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout(); 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'
// ✅ engine central
import { applyThemeEngine } from '@/theme/theme.options'
const toast = useToast()
const entitlementsStore = useEntitlementsStore()
const tenantStore = useTenantStore()
const { toggleMenu, toggleDarkMode, isDarkTheme, layoutConfig, changeMenuMode } = useLayout()
const router = useRouter()
/* ----------------------------
Persistência (1 instância)
----------------------------- */
const { init: initUserSettings, queuePatch } = useUserSettingsPersistence()
provide('queueUserSettingsPatch', queuePatch)
/* ----------------------------
Fonte da verdade: DOM
----------------------------- */
function isDarkNow() {
return document.documentElement.classList.contains('app-dark')
}
function setDarkMode(shouldBeDark) {
const now = isDarkNow()
if (shouldBeDark !== now) toggleDarkMode()
}
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() {
try {
const { data: u, error: uErr } = await supabase.auth.getUser()
if (uErr) throw uErr
const uid = u?.user?.id
if (!uid) return
const { data: settings, error } = await supabase
.from('user_settings')
.select('theme_mode, preset, primary_color, surface_color, menu_mode')
.eq('user_id', uid)
.maybeSingle()
if (error) throw error
if (!settings) {
console.log('[Topbar][bootstrap] sem user_settings ainda')
return
}
console.log('[Topbar][bootstrap] settings=', settings)
// dark/light
if (settings.theme_mode) setDarkMode(settings.theme_mode === 'dark')
// 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
applyThemeEngine(layoutConfig)
// aplica menu mode
try { changeMenuMode() } catch (e) {
console.warn('[Topbar][bootstrap] changeMenuMode falhou:', e?.message || e)
}
} catch (e) {
console.error('[Topbar][bootstrap] erro:', e?.message || e)
}
}
/* ----------------------------
Atalho topbar: Dark/Light
----------------------------- */
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)
----------------------------- */
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()
if (error) throw error
return data.id
}
async function getActiveSubscriptionByTenant(tid) {
const { data, error } = await supabase
.from('subscriptions')
.select('id, tenant_id, plan_id, status, created_at, updated_at')
.eq('tenant_id', tid)
.eq('status', 'active')
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle()
if (error) throw error
return data || null
}
async function getPlanKeyById(planId) {
const { data, error } = await supabase.from('plans').select('key').eq('id', planId).single()
if (error) throw error
return data.key
}
async function alternarPlano() {
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 { error: rpcError } = await supabase.rpc('change_subscription_plan', {
p_subscription_id: sub.id,
p_new_plan_id: novoPlanId
})
if (rpcError) throw rpcError
entitlementsStore.clear?.()
await entitlementsStore.fetch(tid, { force: true })
toast.add({ severity: 'success', summary: 'Plano alternado', detail: `${String(atualKey).toUpperCase()}${String(novoKey).toUpperCase()}`, life: 3000 })
} 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 })
} finally {
trocandoPlano.value = false
}
}
async function logout() {
try {
await supabase.auth.signOut()
} finally {
router.push('/auth/login')
}
}
onMounted(async () => {
await initUserSettings()
await loadAndApplyUserSettings()
})
</script> </script>
<template> <template>
<div class="layout-topbar"> <Toast />
<div class="layout-topbar-logo-container">
<button class="layout-menu-button layout-topbar-action" @click="toggleMenu">
<i class="pi pi-bars"></i>
</button>
<router-link to="/" class="layout-topbar-logo">
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.1637 19.2467C17.1566 19.4033 17.1529 19.561 17.1529 19.7194C17.1529 25.3503 21.7203 29.915 27.3546 29.915C32.9887 29.915 37.5561 25.3503 37.5561 19.7194C37.5561 19.5572 37.5524 19.3959 37.5449 19.2355C38.5617 19.0801 39.5759 18.9013 40.5867 18.6994L40.6926 18.6782C40.7191 19.0218 40.7326 19.369 40.7326 19.7194C40.7326 27.1036 34.743 33.0896 27.3546 33.0896C19.966 33.0896 13.9765 27.1036 13.9765 19.7194C13.9765 19.374 13.9896 19.0316 14.0154 18.6927L14.0486 18.6994C15.0837 18.9062 16.1223 19.0886 17.1637 19.2467ZM33.3284 11.4538C31.6493 10.2396 29.5855 9.52381 27.3546 9.52381C25.1195 9.52381 23.0524 10.2421 21.3717 11.4603C20.0078 11.3232 18.6475 11.1387 17.2933 10.907C19.7453 8.11308 23.3438 6.34921 27.3546 6.34921C31.36 6.34921 34.9543 8.10844 37.4061 10.896C36.0521 11.1292 34.692 11.3152 33.3284 11.4538ZM43.826 18.0518C43.881 18.6003 43.9091 19.1566 43.9091 19.7194C43.9091 28.8568 36.4973 36.2642 27.3546 36.2642C18.2117 36.2642 10.8 28.8568 10.8 19.7194C10.8 19.1615 10.8276 18.61 10.8816 18.0663L7.75383 17.4411C7.66775 18.1886 7.62354 18.9488 7.62354 19.7194C7.62354 30.6102 16.4574 39.4388 27.3546 39.4388C38.2517 39.4388 47.0855 30.6102 47.0855 19.7194C47.0855 18.9439 47.0407 18.1789 46.9536 17.4267L43.826 18.0518ZM44.2613 9.54743L40.9084 10.2176C37.9134 5.95821 32.9593 3.1746 27.3546 3.1746C21.7442 3.1746 16.7856 5.96385 13.7915 10.2305L10.4399 9.56057C13.892 3.83178 20.1756 0 27.3546 0C34.5281 0 40.8075 3.82591 44.2613 9.54743Z"
fill="var(--primary-color)"
/>
<mask id="mask0_1413_1551" style="mask-type: alpha" maskUnits="userSpaceOnUse" x="0" y="8" width="54" height="11">
<path d="M27 18.3652C10.5114 19.1944 0 8.88892 0 8.88892C0 8.88892 16.5176 14.5866 27 14.5866C37.4824 14.5866 54 8.88892 54 8.88892C54 8.88892 43.4886 17.5361 27 18.3652Z" fill="var(--primary-color)" />
</mask>
<g mask="url(#mask0_1413_1551)">
<path
d="M-4.673e-05 8.88887L3.73084 -1.91434L-8.00806 17.0473L-4.673e-05 8.88887ZM27 18.3652L26.4253 6.95109L27 18.3652ZM54 8.88887L61.2673 17.7127L50.2691 -1.91434L54 8.88887ZM-4.673e-05 8.88887C-8.00806 17.0473 -8.00469 17.0505 -8.00132 17.0538C-8.00018 17.055 -7.99675 17.0583 -7.9944 17.0607C-7.98963 17.0653 -7.98474 17.0701 -7.97966 17.075C-7.96949 17.0849 -7.95863 17.0955 -7.94707 17.1066C-7.92401 17.129 -7.89809 17.1539 -7.86944 17.1812C-7.8122 17.236 -7.74377 17.3005 -7.66436 17.3743C-7.50567 17.5218 -7.30269 17.7063 -7.05645 17.9221C-6.56467 18.3532 -5.89662 18.9125 -5.06089 19.5534C-3.39603 20.83 -1.02575 22.4605 1.98012 24.0457C7.97874 27.2091 16.7723 30.3226 27.5746 29.7793L26.4253 6.95109C20.7391 7.23699 16.0326 5.61231 12.6534 3.83024C10.9703 2.94267 9.68222 2.04866 8.86091 1.41888C8.45356 1.10653 8.17155 0.867278 8.0241 0.738027C7.95072 0.673671 7.91178 0.637576 7.90841 0.634492C7.90682 0.63298 7.91419 0.639805 7.93071 0.65557C7.93897 0.663455 7.94952 0.673589 7.96235 0.686039C7.96883 0.692262 7.97582 0.699075 7.98338 0.706471C7.98719 0.710167 7.99113 0.714014 7.99526 0.718014C7.99729 0.720008 8.00047 0.723119 8.00148 0.724116C8.00466 0.727265 8.00796 0.730446 -4.673e-05 8.88887ZM27.5746 29.7793C37.6904 29.2706 45.9416 26.3684 51.6602 23.6054C54.5296 22.2191 56.8064 20.8465 58.4186 19.7784C59.2265 19.2431 59.873 18.7805 60.3494 18.4257C60.5878 18.2482 60.7841 18.0971 60.9374 17.977C61.014 17.9169 61.0799 17.8645 61.1349 17.8203C61.1624 17.7981 61.1872 17.7781 61.2093 17.7602C61.2203 17.7512 61.2307 17.7427 61.2403 17.7348C61.2452 17.7308 61.2499 17.727 61.2544 17.7233C61.2566 17.7215 61.2598 17.7188 61.261 17.7179C61.2642 17.7153 61.2673 17.7127 54 8.88887C46.7326 0.0650536 46.7357 0.0625219 46.7387 0.0600241C46.7397 0.0592345 46.7427 0.0567658 46.7446 0.0551857C46.7485 0.0520238 46.7521 0.0489887 46.7557 0.0460799C46.7628 0.0402623 46.7694 0.0349487 46.7753 0.0301318C46.7871 0.0204986 46.7966 0.0128495 46.8037 0.00712562C46.818 -0.00431848 46.8228 -0.00808311 46.8184 -0.00463784C46.8096 0.00228345 46.764 0.0378652 46.6828 0.0983779C46.5199 0.219675 46.2165 0.439161 45.7812 0.727519C44.9072 1.30663 43.5257 2.14765 41.7061 3.02677C38.0469 4.79468 32.7981 6.63058 26.4253 6.95109L27.5746 29.7793ZM54 8.88887C50.2691 -1.91433 50.27 -1.91467 50.271 -1.91498C50.2712 -1.91506 50.272 -1.91535 50.2724 -1.9155C50.2733 -1.91581 50.274 -1.91602 50.2743 -1.91616C50.2752 -1.91643 50.275 -1.91636 50.2738 -1.91595C50.2714 -1.91515 50.2652 -1.91302 50.2552 -1.9096C50.2351 -1.90276 50.1999 -1.89078 50.1503 -1.874C50.0509 -1.84043 49.8938 -1.78773 49.6844 -1.71863C49.2652 -1.58031 48.6387 -1.377 47.8481 -1.13035C46.2609 -0.635237 44.0427 0.0249875 41.5325 0.6823C36.215 2.07471 30.6736 3.15796 27 3.15796V26.0151C33.8087 26.0151 41.7672 24.2495 47.3292 22.7931C50.2586 22.026 52.825 21.2618 54.6625 20.6886C55.5842 20.4011 56.33 20.1593 56.8551 19.986C57.1178 19.8993 57.3258 19.8296 57.4735 19.7797C57.5474 19.7548 57.6062 19.7348 57.6493 19.72C57.6709 19.7127 57.6885 19.7066 57.7021 19.7019C57.7089 19.6996 57.7147 19.6976 57.7195 19.696C57.7219 19.6952 57.7241 19.6944 57.726 19.6938C57.7269 19.6934 57.7281 19.693 57.7286 19.6929C57.7298 19.6924 57.7309 19.692 54 8.88887ZM27 3.15796C23.3263 3.15796 17.7849 2.07471 12.4674 0.6823C9.95717 0.0249875 7.73904 -0.635237 6.15184 -1.13035C5.36118 -1.377 4.73467 -1.58031 4.3155 -1.71863C4.10609 -1.78773 3.94899 -1.84043 3.84961 -1.874C3.79994 -1.89078 3.76474 -1.90276 3.74471 -1.9096C3.73469 -1.91302 3.72848 -1.91515 3.72613 -1.91595C3.72496 -1.91636 3.72476 -1.91643 3.72554 -1.91616C3.72593 -1.91602 3.72657 -1.91581 3.72745 -1.9155C3.72789 -1.91535 3.72874 -1.91506 3.72896 -1.91498C3.72987 -1.91467 3.73084 -1.91433 -4.673e-05 8.88887C-3.73093 19.692 -3.72983 19.6924 -3.72868 19.6929C-3.72821 19.693 -3.72698 19.6934 -3.72603 19.6938C-3.72415 19.6944 -3.72201 19.6952 -3.71961 19.696C-3.71482 19.6976 -3.70901 19.6996 -3.7022 19.7019C-3.68858 19.7066 -3.67095 19.7127 -3.6494 19.72C-3.60629 19.7348 -3.54745 19.7548 -3.47359 19.7797C-3.32589 19.8296 -3.11788 19.8993 -2.85516 19.986C-2.33008 20.1593 -1.58425 20.4011 -0.662589 20.6886C1.17485 21.2618 3.74125 22.026 6.67073 22.7931C12.2327 24.2495 20.1913 26.0151 27 26.0151V3.15796Z"
fill="var(--primary-color)"
/>
</g>
</svg>
<span>SAKAI</span> <div class="layout-topbar">
</router-link> <div class="layout-topbar-logo-container">
</div> <button class="layout-menu-button layout-topbar-action" @click="toggleMenu">
<i class="pi pi-bars"></i>
</button>
<div class="layout-topbar-actions"> <router-link to="/" class="layout-topbar-logo">
<div class="layout-config-menu"> <svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<button type="button" class="layout-topbar-action" @click="toggleDarkMode"> <!-- ... SVG gigante ... -->
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i> </svg>
</button> <span>SAKAI</span>
<div class="relative"> </router-link>
<button
v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'p-anchored-overlay-enter-active', leaveToClass: 'hidden', leaveActiveClass: 'p-anchored-overlay-leave-active', hideOnOutsideClick: true }"
type="button"
class="layout-topbar-action layout-topbar-action-highlight"
>
<i class="pi pi-palette"></i>
</button>
<AppConfigurator />
</div>
</div>
<button
class="layout-topbar-menu-button layout-topbar-action"
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-ellipsis-v"></i>
</button>
<div class="layout-topbar-menu hidden lg:block">
<div class="layout-topbar-menu-content">
<button type="button" class="layout-topbar-action">
<i class="pi pi-calendar"></i>
<span>Calendar</span>
</button>
<button type="button" class="layout-topbar-action">
<i class="pi pi-inbox"></i>
<span>Messages</span>
</button>
<button type="button" class="layout-topbar-action">
<i class="pi pi-user"></i>
<span>Profile</span>
</button>
</div>
</div>
</div>
</div> </div>
<div class="layout-topbar-actions">
<div class="layout-config-menu">
<button type="button" class="layout-topbar-action" @click="toggleDarkAndPersistSilently">
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i>
</button>
<div class="relative">
<button
v-styleclass="{
selector: '@next',
enterFromClass: 'hidden',
enterActiveClass: 'p-anchored-overlay-enter-active',
leaveToClass: 'hidden',
leaveActiveClass: 'p-anchored-overlay-leave-active',
hideOnOutsideClick: true
}"
type="button"
class="layout-topbar-action layout-topbar-action-highlight"
>
<i class="pi pi-palette"></i>
</button>
<AppConfigurator />
</div>
</div>
<button
class="layout-topbar-menu-button layout-topbar-action"
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-ellipsis-v"></i>
</button>
<div class="layout-topbar-menu hidden lg:block">
<div class="layout-topbar-menu-content">
<Button
label="Plano"
icon="pi pi-sync"
severity="contrast"
outlined
:loading="trocandoPlano"
:disabled="trocandoPlano"
@click="alternarPlano"
/>
<button type="button" class="layout-topbar-action">
<i class="pi pi-calendar"></i>
<span>Calendar</span>
</button>
<button type="button" class="layout-topbar-action">
<i class="pi pi-inbox"></i>
<span>Messages</span>
</button>
<button type="button" class="layout-topbar-action">
<i class="pi pi-user"></i>
<span>Profile</span>
</button>
<button type="button" class="layout-topbar-action" @click="logout">
<i class="pi pi-sign-out"></i>
</button>
</div>
</div>
</div>
</div>
</template> </template>

View File

@@ -0,0 +1,175 @@
<!-- src/layout/ConfiguracoesPage.vue -->
<script setup>
import { computed } 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()
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

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

View File

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

View File

@@ -1,86 +1,96 @@
import { computed, reactive } from 'vue'; import { computed, reactive } from 'vue'
const layoutConfig = reactive({ const layoutConfig = reactive({
preset: 'Aura', preset: 'Aura',
primary: 'emerald', primary: 'emerald',
surface: null, surface: null,
darkTheme: false, darkTheme: false,
menuMode: 'static' menuMode: 'static'
}); })
const layoutState = reactive({ const layoutState = reactive({
staticMenuInactive: false, staticMenuInactive: false,
overlayMenuActive: false, overlayMenuActive: false,
profileSidebarVisible: false, mobileMenuActive: false, // ✅ ADICIONA (estava faltando)
configSidebarVisible: false, profileSidebarVisible: false,
sidebarExpanded: false, configSidebarVisible: false,
menuHoverActive: false, sidebarExpanded: false,
activeMenuItem: null, menuHoverActive: false,
activePath: null activeMenuItem: null,
}); activePath: null
})
export function useLayout() { export function useLayout () {
const toggleDarkMode = () => { const toggleDarkMode = () => {
if (!document.startViewTransition) { if (!document.startViewTransition) {
executeDarkModeToggle(); executeDarkModeToggle()
return
}
return; document.startViewTransition(() => executeDarkModeToggle(event))
} }
document.startViewTransition(() => executeDarkModeToggle(event)); const executeDarkModeToggle = () => {
}; layoutConfig.darkTheme = !layoutConfig.darkTheme
document.documentElement.classList.toggle('app-dark')
}
const executeDarkModeToggle = () => { const isDesktop = () => window.innerWidth > 991
layoutConfig.darkTheme = !layoutConfig.darkTheme;
document.documentElement.classList.toggle('app-dark');
};
const toggleMenu = () => { const toggleMenu = () => {
if (isDesktop()) { if (isDesktop()) {
if (layoutConfig.menuMode === 'static') { if (layoutConfig.menuMode === 'static') {
layoutState.staticMenuInactive = !layoutState.staticMenuInactive; layoutState.staticMenuInactive = !layoutState.staticMenuInactive
} }
if (layoutConfig.menuMode === 'overlay') { if (layoutConfig.menuMode === 'overlay') {
layoutState.overlayMenuActive = !layoutState.overlayMenuActive; layoutState.overlayMenuActive = !layoutState.overlayMenuActive
} }
} else { } else {
layoutState.mobileMenuActive = !layoutState.mobileMenuActive; layoutState.mobileMenuActive = !layoutState.mobileMenuActive
} }
}; }
const toggleConfigSidebar = () => { const toggleConfigSidebar = () => {
layoutState.configSidebarVisible = !layoutState.configSidebarVisible; layoutState.configSidebarVisible = !layoutState.configSidebarVisible
}; }
const hideMobileMenu = () => { const hideMobileMenu = () => {
layoutState.mobileMenuActive = false; layoutState.mobileMenuActive = false
}; }
const changeMenuMode = (event) => { // ✅ use isso ao navegar: mantém menu aberto no desktop, fecha só no mobile
layoutConfig.menuMode = event.value; const closeMenuOnNavigate = () => {
layoutState.staticMenuInactive = false; if (!isDesktop()) {
layoutState.mobileMenuActive = false; layoutState.mobileMenuActive = false
layoutState.sidebarExpanded = false; layoutState.overlayMenuActive = false
layoutState.menuHoverActive = false; layoutState.menuHoverActive = false
layoutState.anchored = false; }
}; }
const isDarkTheme = computed(() => layoutConfig.darkTheme); const changeMenuMode = (event) => {
const isDesktop = () => window.innerWidth > 991; layoutConfig.menuMode = event.value
layoutState.staticMenuInactive = false
layoutState.mobileMenuActive = false
layoutState.sidebarExpanded = false
layoutState.menuHoverActive = false
layoutState.anchored = false
}
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive); const isDarkTheme = computed(() => layoutConfig.darkTheme)
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive)
return { return {
layoutConfig, layoutConfig,
layoutState, layoutState,
isDarkTheme, isDarkTheme,
toggleDarkMode, toggleDarkMode,
toggleConfigSidebar, toggleConfigSidebar,
toggleMenu, toggleMenu,
hideMobileMenu, hideMobileMenu,
changeMenuMode, closeMenuOnNavigate, // ✅ exporta
isDesktop, changeMenuMode,
hasOpenOverlay isDesktop,
}; hasOpenOverlay
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,112 @@
import { createApp } from 'vue'; import { createApp } from 'vue'
import App from './App.vue'; import { createPinia } from 'pinia'
import router from './router'; import App from './App.vue'
import router from '@/router'
import { setOnSignedOut, initSession, listenAuthChanges, refreshSession } from '@/app/session'
import Aura from '@primeuix/themes/aura'; import Aura from '@primeuix/themes/aura'
import PrimeVue from 'primevue/config'; import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice'; import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'; import ToastService from 'primevue/toastservice'
import '@/assets/tailwind.css'; import '@/assets/tailwind.css'
import '@/assets/styles.scss'; import '@/assets/styles.scss'
const app = createApp(App); import { supabase } from '@/lib/supabase/client'
app.use(router); async function applyUserThemeEarly() {
app.use(PrimeVue, { try {
const { data } = await supabase.auth.getUser()
const user = data?.user
if (!user) return
const { data: settings, error } = await supabase
.from('user_settings')
.select('theme_mode')
.eq('user_id', user.id)
.maybeSingle()
if (error || !settings?.theme_mode) return
const isDark = settings.theme_mode === 'dark'
// o PrimeVue usa o selector .app-dark
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 {}
}
setOnSignedOut(() => {
router.replace('/auth/login')
})
// ===== flags globais (debug/controle) =====
window.__sessionRefreshing = false
window.__fromVisibilityRefresh = false
window.__appBootstrapped = false
// ========================================
// 🛟 ao voltar da aba: refresh leve (sem concorrência + com flag global)
let refreshing = false
let refreshTimer = null
let lastVisibilityRefreshAt = 0
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState !== 'visible') return
const now = Date.now()
// evita martelar: 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
lastVisibilityRefreshAt = now
console.log('[VISIBILITY] Aba voltou -> refreshSession()')
try {
window.__sessionRefreshing = true
await refreshSession()
} finally {
window.__sessionRefreshing = false
}
})
async function bootstrap () {
await initSession({ initial: true })
listenAuthChanges()
await applyUserThemeEarly()
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
// ✅ garante router pronto antes de montar
await router.isReady()
app.use(PrimeVue, {
theme: { theme: {
preset: Aura, preset: Aura,
options: { options: { darkModeSelector: '.app-dark' }
darkModeSelector: '.app-dark'
}
} }
}); })
app.use(ToastService); app.use(ToastService)
app.use(ConfirmationService); app.use(ConfirmationService)
app.mount('#app'); app.mount('#app')
// ✅ marca boot completo
window.__appBootstrapped = true
}
bootstrap()

54
src/navigation/index.js Normal file
View File

@@ -0,0 +1,54 @@
// src/navigation/index.js
import adminMenu from './menus/admin.menu'
import therapistMenu from './menus/therapist.menu'
import patientMenu from './menus/patient.menu'
import sakaiDemoMenu from './menus/sakai.demo.menu'
import saasMenu from './menus/saas.menu'
import { useSaasHealthStore } from '@/stores/saasHealthStore'
const MENUS = {
admin: adminMenu,
therapist: therapistMenu,
patient: patientMenu
}
// aceita export de menu como ARRAY ou como FUNÇÃO (ctx) => []
function resolveMenu (builder, ctx) {
if (!builder) return []
return typeof builder === 'function' ? builder(ctx) : builder
}
/**
* role: vem do seu contexto (admin/therapist/patient)
* sessionCtx: objeto que tenha { isSaasAdmin: boolean } (ex.: authStore, sessionStore, etc.)
*/
export function getMenuByRole (role, sessionCtx) {
const base = resolveMenu(MENUS[role], sessionCtx)
// ✅ badge dinâmica do Health (contador vem do store)
// ⚠️ não faz fetch aqui: o AppMenu carrega o store.
const saasHealthStore = useSaasHealthStore()
const mismatchCount = saasHealthStore?.mismatchCount || 0
// ✅ menu SaaS entra como overlay, não depende de role
// passa opts com mismatchCount (saas.menu.js vai usar pra badge)
const saas = typeof saasMenu === 'function'
? saasMenu(sessionCtx, { mismatchCount })
: saasMenu
// ✅ mantém demos disponíveis para admin em DEV (não polui prod)
if (role === 'admin' && import.meta.env.DEV) {
return [
...base,
...(saas.length ? [{ separator: true }, ...saas] : []),
{ separator: true },
...sakaiDemoMenu
]
}
return [
...base,
...(saas.length ? [{ separator: true }, ...saas] : [])
]
}

View File

@@ -0,0 +1,76 @@
export default [
{
label: 'Admin',
items: [
{
label: 'Dashboard',
icon: 'pi pi-fw pi-home',
to: '/admin'
},
{
label: 'Clínicas',
icon: 'pi pi-fw pi-building',
to: '/admin/clinics'
},
{
label: 'Usuários',
icon: 'pi pi-fw pi-users',
to: '/admin/users'
},
{
label: 'Assinatura',
icon: 'pi pi-fw pi-credit-card',
to: '/admin/billing'
},
// 🔒 MÓDULO PRO (exemplo)
{
label: 'Agendamento Online (PRO)',
icon: 'pi pi-fw pi-calendar',
to: '/admin/online-scheduling',
feature: 'online_scheduling.manage',
proBadge: true
},
// ✅ ajustado para bater com sua rota "configuracoes"
{
label: 'Segurança',
icon: 'pi pi-fw pi-shield',
to: '/admin/configuracoes/seguranca'
}
]
},
{
label: 'Pacientes',
items: [
{
label: 'Meus Pacientes',
icon: 'pi pi-list',
to: '/admin/pacientes',
quickCreate: true,
quickCreateFullTo: '/admin/pacientes/novo',
quickCreateEntity: 'patient'
},
{
label: 'Grupos de pacientes',
icon: 'pi pi-fw pi-users',
to: '/admin/pacientes/grupos'
},
{
label: 'Tags',
icon: 'pi pi-tags',
to: '/admin/pacientes/tags'
},
{
label: 'Link externo (Cadastro)',
icon: 'pi pi-link',
to: '/admin/pacientes/link-externo'
},
{
label: 'Cadastros Recebidos',
icon: 'pi pi-inbox',
to: '/admin/pacientes/cadastro/recebidos'
}
]
}
]

View File

@@ -0,0 +1,59 @@
export default [
{
label: 'Paciente',
items: [
// ======================
// ✅ Básico (sempre)
// ======================
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/patient' },
{ label: 'Consultas', icon: 'pi pi-fw pi-calendar-plus', to: '/patient/appointments' },
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/patient/profile' },
{
label: 'Agendamento online',
icon: 'pi pi-fw pi-globe',
to: '/patient/online-scheduling',
feature: 'online_scheduling.manage',
proBadge: true
},
// =====================================================
// 🔒 PRO (exemplos futuros no portal do paciente)
// =====================================================
// A lógica do AppMenuItem que ajustamos suporta:
// - feature: 'chave_da_feature'
// - proBadge: true -> aparece "PRO" quando bloqueado
//
// ⚠️ Só descomente quando a rota existir.
//
// 1) Página pública de agendamento (se você criar um “link do paciente”)
// {
// label: 'Agendar online',
// icon: 'pi pi-fw pi-globe',
// to: '/patient/online-scheduling',
// feature: 'online_scheduling.public',
// proBadge: true
// },
//
// 2) Documentos/Arquivos (muito comum em SaaS clínico)
// {
// label: 'Documentos',
// icon: 'pi pi-fw pi-file',
// to: '/patient/documents',
// feature: 'patient_documents',
// proBadge: true
// },
//
// 3) Teleatendimento / Sala (se for ter)
// {
// label: 'Sala de atendimento',
// icon: 'pi pi-fw pi-video',
// to: '/patient/telehealth',
// feature: 'telehealth',
// proBadge: true
// }
]
}
]

View File

@@ -0,0 +1,58 @@
// src/navigation/menus/saas.menu.js
export default function saasMenu (authStore, opts = {}) {
if (!authStore?.isSaasAdmin) return []
const mismatchCount = Number(opts?.mismatchCount || 0)
return [
{
label: 'SaaS',
icon: 'pi pi-building',
path: '/saas', // ✅ necessário p/ expandir e controlar activePath
items: [
{ label: 'Dashboard', icon: 'pi pi-chart-bar', to: '/saas' },
{
label: 'Planos',
icon: 'pi pi-star',
path: '/plans', // ✅ vira /saas/plans pelo parentPath
items: [
{ label: 'Listagem de Planos', icon: 'pi pi-list', to: '/saas/plans' },
// ✅ NOVO: 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: 'Assinaturas',
icon: 'pi pi-credit-card',
path: '/subscriptions', // ✅ vira /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: 'Saúde das Assinaturas',
icon: 'pi pi-shield',
to: '/saas/subscription-health',
...(mismatchCount > 0
? { badge: String(mismatchCount), badgeClass: 'p-badge p-badge-danger' }
: {})
}
]
},
{
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

@@ -0,0 +1,117 @@
export default [
{
label: 'Home',
items: [{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' }]
},
{
label: 'UI Components',
path: '/uikit',
items: [
{ label: 'Form Layout', icon: 'pi pi-fw pi-id-card', to: '/demo/uikit/formlayout' },
{ label: 'Input', icon: 'pi pi-fw pi-check-square', to: '/demo/uikit/input' },
{ label: 'Button', icon: 'pi pi-fw pi-mobile', to: '/demo/uikit/button', class: 'rotated-icon' },
{ label: 'Table', icon: 'pi pi-fw pi-table', to: '/demo/uikit/table' },
{ label: 'List', icon: 'pi pi-fw pi-list', to: '/demo/uikit/list' },
{ label: 'Tree', icon: 'pi pi-fw pi-share-alt', to: '/demo/uikit/tree' },
{ label: 'Panel', icon: 'pi pi-fw pi-tablet', to: '/demo/uikit/panel' },
{ label: 'Overlay', icon: 'pi pi-fw pi-clone', to: '/demo/uikit/overlay' },
{ label: 'Media', icon: 'pi pi-fw pi-image', to: '/demo/uikit/media' },
{ label: 'Menu', icon: 'pi pi-fw pi-bars', to: '/demo/uikit/menu' },
{ label: 'Message', icon: 'pi pi-fw pi-comment', to: '/demo/uikit/message' },
{ label: 'File', icon: 'pi pi-fw pi-file', to: '/demo/uikit/file' },
{ label: 'Chart', icon: 'pi pi-fw pi-chart-bar', to: '/demo/uikit/charts' },
{ label: 'Timeline', icon: 'pi pi-fw pi-calendar', to: '/demo/uikit/timeline' },
{ label: 'Misc', icon: 'pi pi-fw pi-circle', to: '/demo/uikit/misc' }
]
},
{
label: 'Prime Blocks',
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' }
]
},
{
label: 'Pages',
icon: 'pi pi-fw pi-briefcase',
path: '/pages',
items: [
{ label: 'Landing', icon: 'pi pi-fw pi-globe', to: '/landing' },
{
label: 'Auth',
icon: 'pi pi-fw pi-user',
path: '/auth',
items: [
{ label: 'Login', icon: 'pi pi-fw pi-sign-in', to: '/auth/login' },
{ label: 'Error', icon: 'pi pi-fw pi-times-circle', to: '/auth/error' },
{ 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: 'View Source', icon: 'pi pi-fw pi-github', url: 'https://github.com/primefaces/sakai-vue', target: '_blank' }
]
}
]

View File

@@ -0,0 +1,20 @@
export default [
{
label: 'Terapeuta',
items: [
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/therapist' },
{ label: 'Agenda', icon: 'pi pi-fw pi-calendar', to: '/therapist/agenda' },
// ✅ PRO
{
label: 'Agendamento online',
icon: 'pi pi-fw pi-globe',
to: '/therapist/online-scheduling',
feature: 'online_scheduling.manage',
proBadge: true
},
{ label: 'Pacientes', icon: 'pi pi-fw pi-id-card', to: '/therapist/patients' }
]
}
]

248
src/router/guards.js Normal file
View File

@@ -0,0 +1,248 @@
// ⚠️ Guard depende de sessão estável.
// Nunca disparar refresh concorrente durante navegação protegida.
// Ver comentário em session.js sobre race condition.
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { buildUpgradeUrl } from '@/utils/upgradeContext'
import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session'
// cache simples (evita bater no banco em toda navegação)
let sessionUidCache = null
// cache de saas admin por uid (pra não consultar tabela toda vez)
let saasAdminCacheUid = null
let saasAdminCacheIsAdmin = null
function roleToPath (role) {
if (role === 'tenant_admin') return '/admin'
if (role === 'therapist') return '/therapist'
if (role === 'patient') return '/patient'
if (role === 'saas_admin') return '/saas'
return '/'
}
function sleep (ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function waitSessionIfRefreshing () {
if (!sessionReady.value) {
try { await initSession({ initial: true }) } catch (e) {
console.warn('[guards] initSession falhou:', e)
}
}
for (let i = 0; i < 30; i++) {
if (!sessionRefreshing.value) return
await sleep(50)
}
}
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()
const ok = !error && !!data
saasAdminCacheUid = uid
saasAdminCacheIsAdmin = ok
return ok
}
// heurística segura: carrega entitlements se ainda não carregou ou mudou tenant
function shouldLoadEntitlements (ent, tenantId) {
if (!tenantId) return false
const loaded = typeof ent.loaded === 'boolean' ? ent.loaded : false
const entTenantId = ent.activeTenantId ?? ent.tenantId ?? null
if (!loaded) return true
if (entTenantId && entTenantId !== tenantId) return true
return false
}
// wrapper: chama loadForTenant sem depender de force:false existir
async function loadEntitlementsSafe (ent, tenantId, force) {
if (!ent?.loadForTenant) return
try {
await ent.loadForTenant(tenantId, { force: !!force })
} catch (e) {
// se quebrou tentando force false (store não suporta), tenta força true uma vez
if (!force) {
console.warn('[guards] ent.loadForTenant(force:false) falhou, tentando force:true', e)
await ent.loadForTenant(tenantId, { force: true })
return
}
throw e
}
}
// util: roles guard (plural)
function matchesRoles (roles, activeRole) {
if (!Array.isArray(roles) || !roles.length) return true
return roles.includes(activeRole)
}
export function applyGuards (router) {
if (window.__guardsBound) return
window.__guardsBound = true
router.beforeEach(async (to) => {
const tlabel = `[guard] ${to.fullPath}`
console.time(tlabel)
try {
// públicos
if (to.meta?.public) { console.timeEnd(tlabel); return true }
if (to.path.startsWith('/auth')) { console.timeEnd(tlabel); return true }
// se rota não exige auth, libera
if (!to.meta?.requiresAuth) { console.timeEnd(tlabel); return true }
// não decide nada no meio do refresh do session.js
console.timeLog(tlabel, 'waitSessionIfRefreshing')
await waitSessionIfRefreshing()
// precisa estar logado (fonte estável do session.js)
const uid = sessionUser.value?.id || null
if (!uid) {
sessionStorage.setItem('redirect_after_login', to.fullPath)
console.timeEnd(tlabel)
return { path: '/auth/login' }
}
// se uid mudou, invalida caches e stores dependentes
if (sessionUidCache !== uid) {
sessionUidCache = uid
saasAdminCacheUid = null
saasAdminCacheIsAdmin = null
const ent0 = useEntitlementsStore()
if (typeof ent0.invalidate === 'function') ent0.invalidate()
}
// saas admin (com cache)
if (to.meta?.saasAdmin) {
console.timeLog(tlabel, 'isSaasAdmin')
const ok = await isSaasAdmin(uid)
if (!ok) { console.timeEnd(tlabel); return { path: '/pages/access' } }
}
// carrega tenant + role
const tenant = useTenantStore()
console.timeLog(tlabel, 'tenant.loadSessionAndTenant?')
if (!tenant.loaded && !tenant.loading) {
await tenant.loadSessionAndTenant()
}
// se não tem user no store, trata como não logado
if (!tenant.user) {
sessionStorage.setItem('redirect_after_login', to.fullPath)
console.timeEnd(tlabel)
return { path: '/auth/login' }
}
// 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 : []
const firstActive = mem.find(m => m && m.status === 'active' && m.tenant_id)
if (!firstActive) {
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
console.timeEnd(tlabel)
return { path: '/pages/access' }
}
if (typeof tenant.setActiveTenant === 'function') {
tenant.setActiveTenant(firstActive.tenant_id)
} else {
tenant.activeTenantId = firstActive.tenant_id
tenant.activeRole = firstActive.role
}
}
const tenantId = tenant.activeTenantId
if (!tenantId) {
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
console.timeEnd(tlabel)
return { path: '/pages/access' }
}
// entitlements (✅ carrega só quando precisa)
const ent = useEntitlementsStore()
if (shouldLoadEntitlements(ent, tenantId)) {
console.timeLog(tlabel, 'ent.loadForTenant')
await loadEntitlementsSafe(ent, tenantId, true)
}
// roles guard (plural)
const allowedRoles = to.meta?.roles
if (Array.isArray(allowedRoles) && allowedRoles.length) {
if (!matchesRoles(allowedRoles, tenant.activeRole)) {
const fallback = roleToPath(tenant.activeRole)
if (to.path === fallback) { console.timeEnd(tlabel); return { path: '/pages/access' } }
console.timeEnd(tlabel)
return { path: fallback }
}
}
// role guard (singular) - mantém compatibilidade
const requiredRole = to.meta?.role
if (requiredRole && tenant.activeRole !== requiredRole) {
const fallback = roleToPath(tenant.activeRole)
if (to.path === fallback) { console.timeEnd(tlabel); return { path: '/pages/access' } }
console.timeEnd(tlabel)
return { path: fallback }
}
// feature guard
const requiredFeature = to.meta?.feature
if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
const url = buildUpgradeUrl({
missingKeys: [requiredFeature],
redirectTo: to.fullPath
})
console.timeEnd(tlabel)
return { path: url }
}
console.timeEnd(tlabel)
return true
} catch (e) {
console.error('[guards] erro no beforeEach:', e)
// fallback seguro
if (to.meta?.public || to.path.startsWith('/auth')) return true
if (to.path === '/pages/access') return true
sessionStorage.setItem('redirect_after_login', to.fullPath)
return { path: '/auth/login' }
}
})
// auth listener (reset caches)
if (!window.__supabaseAuthListenerBound) {
window.__supabaseAuthListenerBound = true
supabase.auth.onAuthStateChange(() => {
sessionUidCache = null
saasAdminCacheUid = null
saasAdminCacheIsAdmin = null
})
}
}

View File

@@ -1,146 +1,111 @@
import AppLayout from '@/layout/AppLayout.vue'; import {
import { createRouter, createWebHistory } from 'vue-router'; createRouter,
createWebHistory,
isNavigationFailure,
NavigationFailureType
} from 'vue-router'
import publicRoutes from './routes.public'
import adminRoutes from './routes.admin'
import therapistRoutes from './routes.therapist'
import patientRoutes from './routes.patient'
import miscRoutes from './routes.misc'
import authRoutes from './routes.auth'
import configuracoesRoutes from './router.configuracoes'
import billingRoutes from './routes.billing'
import saasRoutes from './routes.saas'
import demoRoutes from './routes.demo'
import meRoutes from './router.me'
import { applyGuards } from './guards'
const routes = [
...(Array.isArray(publicRoutes) ? publicRoutes : [publicRoutes]),
...(Array.isArray(authRoutes) ? authRoutes : [authRoutes]),
...(Array.isArray(miscRoutes) ? miscRoutes : [miscRoutes]),
...(Array.isArray(billingRoutes) ? billingRoutes : [billingRoutes]),
...(Array.isArray(saasRoutes) ? saasRoutes : [saasRoutes]),
...(Array.isArray(meRoutes) ? meRoutes : [meRoutes]),
...(Array.isArray(adminRoutes) ? adminRoutes : [adminRoutes]),
...(Array.isArray(therapistRoutes) ? therapistRoutes : [therapistRoutes]),
...(Array.isArray(patientRoutes) ? patientRoutes : [patientRoutes]),
...(Array.isArray(demoRoutes) ? demoRoutes : [demoRoutes]),
...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]),
{ path: '/:pathMatch(.*)*', name: 'notfound', component: () => import('@/views/pages/NotFound.vue') }
]
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes,
{ scrollBehavior(to, from, savedPosition) {
path: '/', // volta/avançar do navegador mantém posição
component: AppLayout, if (savedPosition) return savedPosition
children: [
{
path: '/',
name: 'dashboard',
component: () => import('@/views/Dashboard.vue')
},
{
path: '/uikit/formlayout',
name: 'formlayout',
component: () => import('@/views/uikit/FormLayout.vue')
},
{
path: '/uikit/input',
name: 'input',
component: () => import('@/views/uikit/InputDoc.vue')
},
{
path: '/uikit/button',
name: 'button',
component: () => import('@/views/uikit/ButtonDoc.vue')
},
{
path: '/uikit/table',
name: 'table',
component: () => import('@/views/uikit/TableDoc.vue')
},
{
path: '/uikit/list',
name: 'list',
component: () => import('@/views/uikit/ListDoc.vue')
},
{
path: '/uikit/tree',
name: 'tree',
component: () => import('@/views/uikit/TreeDoc.vue')
},
{
path: '/uikit/panel',
name: 'panel',
component: () => import('@/views/uikit/PanelsDoc.vue')
},
{ // qualquer navegação normal NÃO altera o scroll
path: '/uikit/overlay', return false
name: 'overlay', }
component: () => import('@/views/uikit/OverlayDoc.vue') })
},
{
path: '/uikit/media',
name: 'media',
component: () => import('@/views/uikit/MediaDoc.vue')
},
{
path: '/uikit/message',
name: 'message',
component: () => import('@/views/uikit/MessagesDoc.vue')
},
{
path: '/uikit/file',
name: 'file',
component: () => import('@/views/uikit/FileDoc.vue')
},
{
path: '/uikit/menu',
name: 'menu',
component: () => import('@/views/uikit/MenuDoc.vue')
},
{
path: '/uikit/charts',
name: 'charts',
component: () => import('@/views/uikit/ChartDoc.vue')
},
{
path: '/uikit/misc',
name: 'misc',
component: () => import('@/views/uikit/MiscDoc.vue')
},
{
path: '/uikit/timeline',
name: 'timeline',
component: () => import('@/views/uikit/TimelineDoc.vue')
},
{
path: '/blocks/free',
name: 'blocks',
meta: {
breadcrumb: ['Prime Blocks', 'Free Blocks']
},
component: () => import('@/views/utilities/Blocks.vue')
},
{
path: '/pages/empty',
name: 'empty',
component: () => import('@/views/pages/Empty.vue')
},
{
path: '/pages/crud',
name: 'crud',
component: () => import('@/views/pages/Crud.vue')
},
{
path: '/start/documentation',
name: 'documentation',
component: () => import('@/views/pages/Documentation.vue')
}
]
},
{
path: '/landing',
name: 'landing',
component: () => import('@/views/pages/Landing.vue')
},
{
path: '/pages/notfound',
name: 'notfound',
component: () => import('@/views/pages/NotFound.vue')
},
{ /* 🔎 DEBUG: listar todas as rotas registradas */
path: '/auth/login', console.log(
name: 'login', '[ROUTES]',
component: () => import('@/views/pages/auth/Login.vue') router.getRoutes().map(r => r.path).sort()
}, )
{
path: '/auth/access',
name: 'accessDenied',
component: () => import('@/views/pages/auth/Access.vue')
},
{
path: '/auth/error',
name: 'error',
component: () => import('@/views/pages/auth/Error.vue')
}
]
});
export default router; // ===== DEBUG NAV + TRACE (remover depois) =====
const _push = router.push.bind(router)
router.push = async (loc) => {
console.log('[router.push]', loc)
console.trace('[push caller]')
const res = await _push(loc)
if (isNavigationFailure(res, NavigationFailureType.duplicated)) {
console.warn('[NAV FAIL] duplicated', res)
} else if (isNavigationFailure(res, NavigationFailureType.cancelled)) {
console.warn('[NAV FAIL] cancelled', res)
} else if (isNavigationFailure(res, NavigationFailureType.aborted)) {
console.warn('[NAV FAIL] aborted', res)
} else if (isNavigationFailure(res, NavigationFailureType.redirected)) {
console.warn('[NAV FAIL] redirected', res)
}
return res
}
const _replace = router.replace.bind(router)
router.replace = async (loc) => {
console.log('[router.replace]', loc)
console.trace('[replace caller]')
const res = await _replace(loc)
if (isNavigationFailure(res, NavigationFailureType.cancelled)) {
console.warn('[NAV FAIL replace] cancelled', res)
} else if (isNavigationFailure(res, NavigationFailureType.aborted)) {
console.warn('[NAV FAIL replace] aborted', res)
} else if (isNavigationFailure(res, NavigationFailureType.redirected)) {
console.warn('[NAV FAIL replace] redirected', res)
}
return res
}
router.onError((e) => console.error('[router.onError]', e))
router.beforeEach((to, from) => {
console.log('[beforeEach]', from.fullPath, '->', to.fullPath)
return true
})
router.afterEach((to, from, failure) => {
if (failure) console.warn('[afterEach failure]', failure)
else console.log('[afterEach ok]', from.fullPath, '->', to.fullPath)
})
// ===== /DEBUG NAV + TRACE =====
// ✅ mantém seus guards, mas agora a landing tem meta.public
applyGuards(router)
export default router

View File

@@ -0,0 +1,36 @@
// src/router/router.configuracoes.js
import AppLayout from '@/layout/AppLayout.vue'
const configuracoesRoutes = {
path: '/configuracoes',
component: AppLayout,
meta: {
requiresAuth: true,
roles: ['admin', 'tenant_admin', 'therapist']
},
children: [
{
path: '',
component: () => import('@/layout/ConfiguracoesPage.vue'),
redirect: { name: 'ConfiguracoesAgenda' },
children: [
{
path: 'agenda',
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') },
]
}
]
}
export default configuracoesRoutes

32
src/router/router.me.js Normal file
View File

@@ -0,0 +1,32 @@
// src/router/router.me.js
import AppLayout from '@/layout/AppLayout.vue'
const meRoutes = {
path: '/me',
component: AppLayout,
meta: {
requiresAuth: true,
roles: ['admin', 'tenant_admin', 'therapist', 'patient']
},
children: [
{
// ✅ quando entrar em /me, manda pro perfil
path: '',
redirect: { name: 'MeuPerfil' }
},
{
path: 'perfil',
name: 'MeuPerfil',
component: () => import('@/views/pages/me/MeuPerfilPage.vue')
}
// Futuro:
// { path: 'preferencias', name: 'MePreferencias', component: () => import('@/pages/me/PreferenciasPage.vue') },
// { path: 'notificacoes', name: 'MeNotificacoes', component: () => import('@/pages/me/NotificacoesPage.vue') },
]
}
export default meRoutes

View File

@@ -0,0 +1,93 @@
// src/router/routes.admin.js
import AppLayout from '@/layout/AppLayout.vue'
export default {
path: '/admin',
component: AppLayout,
meta: {
// 🔐 Tudo aqui dentro exige login
requiresAuth: true,
// 👤 Perfil de acesso
role: 'tenant_admin'
},
children: [
// DASHBOARD
{
path: '',
name: 'admin-dashboard',
component: () => import('@/views/pages/admin/AdminDashboard.vue')
},
// PACIENTES - LISTA
{
path: 'pacientes',
name: 'admin-pacientes',
component: () => import('@/views/pages/admin/pacientes/PatientsIndexPage.vue')
},
// PACIENTES - CADASTRO (NOVO / EDITAR)
{
path: 'pacientes/cadastro',
name: 'admin-pacientes-cadastro',
component: () => import('@/views/pages/admin/pacientes/cadastro/PatientsCadastroPage.vue')
},
{
path: 'pacientes/cadastro/:id',
name: 'admin-pacientes-cadastro-edit',
component: () => import('@/views/pages/admin/pacientes/cadastro/PatientsCadastroPage.vue'),
props: true
},
// GRUPOS DE PACIENTES ✅
{
path: 'pacientes/grupos',
name: 'admin-pacientes-grupos',
component: () => import('@/views/pages/admin/pacientes/grupos/GruposPacientesPage.vue')
},
// TAGS
{
path: 'pacientes/tags',
name: 'admin-pacientes-tags',
component: () => import('@/views/pages/admin/pacientes/tags/TagsPage.vue')
},
// LINK EXTERNO
{
path: 'pacientes/link-externo',
name: 'admin.pacientes.linkexterno',
component: () => import('@/views/pages/admin/pacientes/cadastro/PatientsExternalLinkPage.vue')
},
// CADASTROS RECEBIDOS
{
path: 'pacientes/cadastro/recebidos',
name: 'admin.pacientes.recebidos',
component: () => import('@/views/pages/admin/pacientes/cadastro/recebidos/CadastrosRecebidosPage.vue')
},
// SEGURANÇA
{
path: 'settings/security',
name: 'admin-settings-security',
component: () => import('@/views/pages/auth/SecurityPage.vue')
},
// ================================
// 🔒 MÓDULO PRO — Online Scheduling
// ================================
// Admin também gerencia agendamento online; mesma feature de gestão.
// Você pode ter uma página admin para isso, ou reaproveitar a do therapist.
{
path: 'online-scheduling',
name: 'admin-online-scheduling',
component: () => import('@/views/pages/admin/OnlineSchedulingAdminPage.vue'),
meta: {
feature: 'online_scheduling.manage'
}
}
]
}

45
src/router/routes.auth.js Normal file
View File

@@ -0,0 +1,45 @@
export default {
path: '/auth',
children: [
{
path: 'login',
name: 'login',
component: () => import('@/views/pages/auth/Login.vue'),
meta: { public: true }
},
// ✅ Signup público, mas com URL /auth/signup
{
path: 'signup',
name: 'signup',
component: () => import('@/views/pages/public/Signup.vue'),
meta: { public: true }
},
{
path: 'welcome',
name: 'auth.welcome',
component: () => import('@/views/pages/auth/Welcome.vue'),
meta: { public: true }
},
{
path: 'reset-password',
name: 'resetPassword',
component: () => import('@/views/pages/auth/ResetPasswordPage.vue'),
meta: { public: true }
},
{
path: 'access',
name: 'accessDenied',
component: () => import('@/views/pages/auth/Access.vue'),
meta: { public: true }
},
{
path: 'error',
name: 'error',
component: () => import('@/views/pages/auth/Error.vue'),
meta: { public: true }
}
]
}

View File

@@ -0,0 +1,15 @@
// src/router/routes.billing.js
import AppLayout from '@/layout/AppLayout.vue'
export default {
path: '/upgrade',
component: AppLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'upgrade',
component: () => import('@/views/pages/billing/UpgradePage.vue')
}
]
}

29
src/router/routes.demo.js Normal file
View File

@@ -0,0 +1,29 @@
import AppLayout from '@/layout/AppLayout.vue'
export default {
// ✅ não use '/' aqui (conflita com HomeCards)
path: '/demo',
component: AppLayout,
meta: { requiresAuth: true, role: 'tenant_admin' },
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') },
{ path: 'uikit/button', name: 'uikit-button', component: () => import('@/views/uikit/ButtonDoc.vue') },
{ path: 'uikit/table', name: 'uikit-table', component: () => import('@/views/uikit/TableDoc.vue') },
{ path: 'uikit/list', name: 'uikit-list', component: () => import('@/views/uikit/ListDoc.vue') },
{ path: 'uikit/tree', name: 'uikit-tree', component: () => import('@/views/uikit/TreeDoc.vue') },
{ path: 'uikit/panel', name: 'uikit-panel', component: () => import('@/views/uikit/PanelsDoc.vue') },
{ path: 'uikit/overlay', name: 'uikit-overlay', component: () => import('@/views/uikit/OverlayDoc.vue') },
{ path: 'uikit/media', name: 'uikit-media', component: () => import('@/views/uikit/MediaDoc.vue') },
{ path: 'uikit/menu', name: 'uikit-menu', component: () => import('@/views/uikit/MenuDoc.vue') },
{ path: 'uikit/message', name: 'uikit-message', component: () => import('@/views/uikit/MessagesDoc.vue') },
{ path: 'uikit/file', name: 'uikit-file', component: () => import('@/views/uikit/FileDoc.vue') },
{ path: 'uikit/charts', name: 'uikit-charts', component: () => import('@/views/uikit/ChartDoc.vue') },
{ path: 'uikit/timeline', name: 'uikit-timeline', component: () => import('@/views/uikit/TimelineDoc.vue') },
{ path: 'uikit/misc', name: 'uikit-misc', component: () => import('@/views/uikit/MiscDoc.vue') },
{ path: 'utilities', name: 'blocks', component: () => import('@/views/utilities/Blocks.vue') },
{ path: 'pages', name: 'start-documentation', component: () => import('@/views/pages/Documentation.vue') },
{ path: 'pages/empty', name: 'pages-empty', component: () => import('@/views/pages/Empty.vue') },
{ path: 'pages/crud', name: 'pages-crud', component: () => import('@/views/pages/Crud.vue') }
]
}

15
src/router/routes.misc.js Normal file
View File

@@ -0,0 +1,15 @@
export default {
path: '/',
children: [
{
path: 'landing',
name: 'landing',
component: () => import('@/views/pages/Landing.vue')
},
{
path: 'pages/notfound',
name: 'notfound',
component: () => import('@/views/pages/NotFound.vue')
}
]
}

View File

@@ -0,0 +1,19 @@
import AppLayout from '@/layout/AppLayout.vue'
export default {
path: '/patient',
component: AppLayout,
meta: { requiresAuth: true, role: 'patient' },
children: [
{
path: '',
name: 'patient-dashboard',
component: () => import('@/views/pages/patient/PatientDashboard.vue')
},
{
path: 'settings/security',
name: 'patient-settings-security',
component: () => import('@/views/pages/auth/SecurityPage.vue')
}
]
}

View File

@@ -0,0 +1,26 @@
export default {
path: '/',
children: [
{
path: '',
name: 'home',
component: () => import('@/views/pages/HomeCards.vue')
},
// ✅ LP (página separada da landing do template)
{
path: 'lp',
name: 'lp',
component: () => import('@/views/pages/public/landingpage-v1.vue'),
meta: { public: true }
},
// ✅ cadastro externo
{
path: 'cadastro/paciente',
name: 'public.patient.intake',
component: () => import('@/views/pages/public/CadastroPacienteExterno.vue'),
meta: { public: true }
}
]
}

60
src/router/routes.saas.js Normal file
View File

@@ -0,0 +1,60 @@
import AppLayout from '@/layout/AppLayout.vue'
export default {
path: '/saas',
component: AppLayout,
meta: { requiresAuth: true, saasAdmin: true },
children: [
{
path: '',
name: 'saas-dashboard',
component: () => import('@/views/pages/saas/SaasDashboard.vue')
},
{
path: 'plans',
name: 'saas-plans',
component: () => import('@/views/pages/saas/SaasPlansPage.vue')
},
{
path: 'plans-public',
name: 'saas-plans-public',
component: () => import('@/views/pages/saas/SaasPlansPublicPage.vue')
},
{
path: 'features',
name: 'saas-features',
component: () => import('@/views/pages/saas/SaasFeaturesPage.vue')
},
{
path: 'plan-features',
name: 'saas-plan-features',
component: () => import('@/views/pages/saas/SaasPlanFeaturesMatrixPage.vue')
},
{
path: 'subscriptions',
name: 'saas-subscriptions',
component: () => import('@/views/pages/saas/SaasSubscriptionsPage.vue')
},
{
path: 'subscription-events',
name: 'saas-subscription-events',
component: () => import('@/views/pages/saas/SaasSubscriptionEventsPage.vue')
},
{
path: 'subscription-health',
name: 'saas-subscription-health',
component: () => import('@/views/pages/saas/SaasSubscriptionHealthPage.vue')
},
{
path: 'subscription-intents',
name: 'saas.subscriptionIntents',
component: () => import('@/views/pages/saas/SubscriptionIntentsPage.vue'),
meta: { requiresAuth: true, saasAdmin: true }
},
{
path: 'tenants',
name: 'saas-tenants',
component: () => import('@/views/pages/saas/SaasPlaceholder.vue')
}
]
}

View File

@@ -0,0 +1,71 @@
import AppLayout from '@/layout/AppLayout.vue'
export default {
path: '/therapist',
component: AppLayout,
meta: {
// 🔐 Tudo aqui dentro exige login
requiresAuth: true,
// 👤 Perfil de acesso (seu guard atual usa meta.role)
role: 'therapist'
},
children: [
// ======================
// ✅ Dashboard Therapist
// ======================
{
path: '',
name: 'therapist-dashboard',
component: () => import('@/views/pages/therapist/TherapistDashboard.vue')
// herda requiresAuth + role do pai
},
// ======================
// ✅ Segurança
// ======================
{
path: 'settings/security',
name: 'therapist-settings-security',
component: () => import('@/views/pages/auth/SecurityPage.vue')
// herda requiresAuth + role do pai
},
// ==========================================
// 🔒 PRO — Online Scheduling (gestão interna)
// ==========================================
// feature gate via meta.feature:
// - bloqueia rota (guard)
// - menu pode desabilitar/ocultar (entitlementsStore.has)
{
path: 'online-scheduling',
name: 'therapist-online-scheduling',
component: () => import('@/views/pages/therapist/OnlineSchedulingPage.vue'),
meta: {
// ✅ herda requiresAuth + role do pai
feature: 'online_scheduling.manage'
}
},
// =================================================
// 🔒 PRO — Online Scheduling (página pública/config)
// =================================================
// Se você tiver/for criar a tela para configurar/visualizar a página pública,
// use a chave granular:
// - online_scheduling.public
//
// Dica de produto:
// - "manage" = operação interna
// - "public" = ajustes/preview/links
//
// Quando criar o arquivo, descomente.
// {
// path: 'online-scheduling/public',
// name: 'therapist-online-scheduling-public',
// component: () => import('@/views/pages/therapist/OnlineSchedulingPublicPage.vue'),
// meta: { feature: 'online_scheduling.public' }
// }
]
}

View File

View File

View File

@@ -0,0 +1,173 @@
// src/services/patientGroups.js
import { supabase } from '@/lib/supabase/client'
function pickCount (row) {
return row?.patients_count ?? row?.patient_count ?? 0
}
async function getOwnerId () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
if (!uid) throw new Error('Sessão inválida.')
return uid
}
function normalizeNome (s) {
return String(s || '').trim().toLowerCase().replace(/\s+/g, ' ')
}
function isUniqueViolation (err) {
if (!err) return false
if (err.code === '23505') return true
const msg = String(err.message || '')
return /duplicate key value violates unique constraint/i.test(msg)
}
/**
* Lista grupos do usuário + grupos do sistema, já com contagem.
* Usa a view v_patient_groups_with_counts (preferencial).
* Fallback: tabela patient_groups + contagem pela pivot.
*/
export async function listGroupsWithCounts () {
const ownerId = await getOwnerId()
// 1) View (preferencial) — agora já é a fonte correta
const { data: vData, error: vErr } = await supabase
.from('v_patient_groups_with_counts')
.select('*')
.or(`owner_id.eq.${ownerId},is_system.eq.true`)
.order('nome', { ascending: true })
if (!vErr) {
return (vData || []).map(r => ({
...r,
patients_count: pickCount(r)
}))
}
// 2) Fallback (caso view não exista / erro de schema)
const { data: groups, error: gErr } = await supabase
.from('patient_groups')
.select('id,nome,cor,is_system,is_active,owner_id,created_at,updated_at')
.or(`owner_id.eq.${ownerId},is_system.eq.true`)
.order('nome', { ascending: true })
if (gErr) throw gErr
const ids = (groups || []).map(g => g.id).filter(Boolean)
if (!ids.length) return []
// conta pacientes por grupo na pivot
const { data: rel, error: rErr } = await supabase
.from('patient_group_patient')
.select('patient_group_id')
.in('patient_group_id', ids)
if (rErr) throw rErr
const counts = new Map()
for (const row of rel || []) {
const gid = row.patient_group_id
if (!gid) continue
counts.set(gid, (counts.get(gid) || 0) + 1)
}
return (groups || []).map(g => ({
...g,
patients_count: counts.get(g.id) || 0
}))
}
export async function createGroup (nome, cor = null) {
const ownerId = await getOwnerId()
const raw = String(nome || '').trim()
if (!raw) throw new Error('Nome do grupo é obrigatório.')
const nNorm = normalizeNome(raw)
// proteção extra no front: busca por igualdade "normalizada"
// (mantém RLS como autoridade final, mas evita UX ruim)
const { data: existing, error: exErr } = await supabase
.from('patient_groups')
.select('id,nome')
.eq('owner_id', ownerId)
.eq('is_system', false)
.limit(50)
if (!exErr && (existing || []).some(r => normalizeNome(r.nome) === nNorm)) {
throw new Error('Já existe um grupo com esse nome.')
}
const payload = {
owner_id: ownerId,
nome: raw,
cor: cor || null
}
const { data, error } = await supabase
.from('patient_groups')
.insert(payload)
.select('id,nome,cor,is_system,owner_id,is_active,created_at,updated_at')
.single()
if (error) {
if (isUniqueViolation(error)) throw new Error('Já existe um grupo com esse nome.')
throw error
}
return data
}
export async function updateGroup (id, nome, cor = null) {
const ownerId = await getOwnerId()
const raw = String(nome || '').trim()
if (!id) throw new Error('ID inválido.')
if (!raw) throw new Error('Nome do grupo é obrigatório.')
// (opcional) valida duplicidade entre os grupos do owner (não-system)
const nNorm = normalizeNome(raw)
const { data: existing, error: exErr } = await supabase
.from('patient_groups')
.select('id,nome')
.eq('owner_id', ownerId)
.eq('is_system', false)
.neq('id', id)
.limit(80)
if (!exErr && (existing || []).some(r => normalizeNome(r.nome) === nNorm)) {
throw new Error('Já existe um grupo com esse nome.')
}
const { data, error } = await supabase
.from('patient_groups')
.update({ nome: raw, cor: cor || null, updated_at: new Date().toISOString() })
.eq('id', id)
.eq('owner_id', ownerId)
.eq('is_system', false)
.select('id,nome,cor,is_system,owner_id,is_active,created_at,updated_at')
.single()
if (error) {
if (isUniqueViolation(error)) throw new Error('Já existe um grupo com esse nome.')
throw error
}
return data
}
export async function deleteGroup (id) {
const ownerId = await getOwnerId()
if (!id) throw new Error('ID inválido.')
const { error } = await supabase
.from('patient_groups')
.delete()
.eq('id', id)
.eq('owner_id', ownerId)
.eq('is_system', false)
if (error) throw error
return true
}

View File

View File

View File

@@ -0,0 +1,76 @@
// src/services/agendaConfigService.js
import { supabase } from '@/lib/supabase/client'
export async function getOwnerId() {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
if (!uid) throw new Error('Sessão inválida.')
return uid
}
export async function fetchSlotsRegras(ownerId) {
const { data, error } = await supabase
.from('agenda_slots_regras')
.select('*')
.eq('owner_id', ownerId)
.order('dia_semana', { ascending: true })
if (error) throw error
return data || []
}
export async function upsertSlotRegra(ownerId, payload) {
const row = {
owner_id: ownerId,
dia_semana: Number(payload.dia_semana),
passo_minutos: Number(payload.passo_minutos),
offset_minutos: Number(payload.offset_minutos),
buffer_antes_min: Number(payload.buffer_antes_min || 0),
buffer_depois_min: Number(payload.buffer_depois_min || 0),
min_antecedencia_horas: Number(payload.min_antecedencia_horas || 0),
ativo: !!payload.ativo
}
const { data, error } = await supabase
.from('agenda_slots_regras')
.upsert(row, { onConflict: 'owner_id,dia_semana' })
.select('*')
.single()
if (error) throw error
return data
}
export function normalizeHHMM(v) {
if (v == null) return null
const s = String(v).trim()
if (/^\d{2}:\d{2}$/.test(s)) return s
if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s.slice(0, 5)
return s
}
export function ruleKey(r) {
return [
r.dia_semana,
normalizeHHMM(r.hora_inicio),
normalizeHHMM(r.hora_fim),
(r.modalidade || 'ambos'),
!!r.ativo
].join('|')
}
/**
* Remove duplicados exatos antes de mandar pro banco.
* (DB já tem UNIQUE, mas isso evita erro e deixa UX melhor)
*/
export function dedupeRegrasSemanais(regras) {
const seen = new Set()
const out = []
for (const r of regras || []) {
const k = ruleKey(r)
if (seen.has(k)) continue
seen.add(k)
out.push(r)
}
return out
}

View File

@@ -0,0 +1,45 @@
// src/services/agendaSlotsBloqueadosService.js
import { supabase } from '@/lib/supabase/client'
export async function fetchSlotsBloqueados(ownerId, diaSemana) {
const { data, error } = await supabase
.from('agenda_slots_bloqueados_semanais')
.select('*')
.eq('owner_id', ownerId)
.eq('dia_semana', diaSemana)
.eq('ativo', true)
.order('hora_inicio', { ascending: true })
if (error) throw error
return data || []
}
export async function setSlotBloqueado(ownerId, diaSemana, horaInicio, isBloqueado, motivo = null) {
if (isBloqueado) {
const { error } = await supabase
.from('agenda_slots_bloqueados_semanais')
.upsert(
{
owner_id: ownerId,
dia_semana: diaSemana,
hora_inicio: horaInicio,
motivo: motivo || null,
ativo: true
},
{ onConflict: 'owner_id,dia_semana,hora_inicio' }
)
if (error) throw error
return true
}
// “desbloquear”: deletar (ou marcar ativo=false; aqui vou deletar por simplicidade)
const { error } = await supabase
.from('agenda_slots_bloqueados_semanais')
.delete()
.eq('owner_id', ownerId)
.eq('dia_semana', diaSemana)
.eq('hora_inicio', horaInicio)
if (error) throw error
return true
}

View File

@@ -0,0 +1,18 @@
import { supabase } from '@/lib/supabase/client'
export const signIn = async (email, password) => {
return await supabase.auth.signInWithPassword({ email, password })
}
export const signUp = async (email, password) => {
return await supabase.auth.signUp({ email, password })
}
export const signOut = async () => {
return await supabase.auth.signOut()
}
export const getUser = async () => {
const { data } = await supabase.auth.getUser()
return data.user
}

View File

@@ -0,0 +1,77 @@
// src/services/patientTags.js
import { supabase } from '@/lib/supabase/client'
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const user = data?.user
if (!user) throw new Error('Você precisa estar logado.')
return user.id
}
export async function listTagsWithCounts() {
const ownerId = await getOwnerId()
const v = await supabase
.from('v_tag_patient_counts')
.select('*')
.eq('owner_id', ownerId)
.order('name', { ascending: true })
if (!v.error) return v.data || []
const t = await supabase
.from('patient_tags')
.select('id, owner_id, name, color, is_native, created_at, updated_at')
.eq('owner_id', ownerId)
.order('name', { ascending: true })
if (t.error) throw t.error
return (t.data || []).map(r => ({ ...r, patient_count: 0 }))
}
export async function createTag({ name, color = null }) {
const ownerId = await getOwnerId()
const { error } = await supabase.from('patient_tags').insert({ owner_id: ownerId, name, color })
if (error) throw error
}
export async function updateTag({ id, name, color = null }) {
const ownerId = await getOwnerId()
const { error } = await supabase
.from('patient_tags')
.update({ name, color, updated_at: new Date().toISOString() })
.eq('id', id)
.eq('owner_id', ownerId)
if (error) throw error
}
export async function deleteTagsByIds(ids = []) {
const ownerId = await getOwnerId()
if (!ids.length) return
const pivotDel = await supabase
.from('patient_patient_tag')
.delete()
.eq('owner_id', ownerId)
.in('tag_id', ids)
if (pivotDel.error) throw pivotDel.error
const tagDel = await supabase
.from('patient_tags')
.delete()
.eq('owner_id', ownerId)
.in('id', ids)
if (tagDel.error) throw tagDel.error
}
export async function fetchPatientsByTagId(tagId) {
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('patient_patient_tag')
.select('patient_id, patients:patients(id, name, email, phone)')
.eq('owner_id', ownerId)
.eq('tag_id', tagId)
if (error) throw error
return (data || []).map(r => r.patients).filter(Boolean)
}

View File

@@ -0,0 +1,63 @@
// src/services/subscriptionIntents.js
import { supabase } from '@/lib/supabase/client'
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)
if (interval) query = query.eq('interval', interval)
return query
}
export async function listSubscriptionIntents(filters = {}) {
let query = supabase
.from('subscription_intents')
.select('*')
.order('created_at', { ascending: false })
query = applyFilters(query, filters)
const { data, error } = await query
if (error) throw error
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
})
.eq('id', intentId)
.select('*')
.maybeSingle()
if (upErr) throw upErr
// 2) ativa subscription do tenant (Modelo B)
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 }
}
export async function cancelIntent(intentId, notes = '') {
const { data, error } = await supabase
.from('subscription_intents')
.update({
status: 'canceled',
notes: notes || null
})
.eq('id', intentId)
.select('*')
.maybeSingle()
if (error) throw error
return data
}

View File

@@ -0,0 +1,110 @@
-- =========================================================
-- Agência PSI — Profiles (v2) + Trigger + RLS
-- - 1 profile por auth.users.id
-- - role base (admin|therapist|patient)
-- - pronto para evoluir p/ multi-tenant depois
-- =========================================================
-- 0) Função padrão updated_at (se já existir, mantém)
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
-- 1) Tabela profiles
create table if not exists public.profiles (
id uuid primary key, -- = auth.users.id
email text,
full_name text,
avatar_url text,
role text not null default 'patient',
status text not null default 'active',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint profiles_role_check check (role in ('admin','therapist','patient')),
constraint profiles_status_check check (status in ('active','inactive','invited'))
);
-- FK opcional (em Supabase costuma ser ok)
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'profiles_id_fkey'
) then
alter table public.profiles
add constraint profiles_id_fkey
foreign key (id) references auth.users(id)
on delete cascade;
end if;
end $$;
-- Índices úteis
create index if not exists profiles_role_idx on public.profiles(role);
create index if not exists profiles_status_idx on public.profiles(status);
-- 2) Trigger updated_at
drop trigger if exists t_profiles_set_updated_at on public.profiles;
create trigger t_profiles_set_updated_at
before update on public.profiles
for each row execute function public.set_updated_at();
-- 3) Trigger pós-signup: cria profile automático
-- Observação: roda como SECURITY DEFINER
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, email, role, status)
values (new.id, new.email, 'patient', 'active')
on conflict (id) do update
set email = excluded.email;
return new;
end;
$$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- 4) RLS
alter table public.profiles enable row level security;
-- Leitura do próprio profile
drop policy if exists "profiles_select_own" on public.profiles;
create policy "profiles_select_own"
on public.profiles
for select
to authenticated
using (id = auth.uid());
-- Update do próprio profile (campos não-sensíveis)
drop policy if exists "profiles_update_own" on public.profiles;
create policy "profiles_update_own"
on public.profiles
for update
to authenticated
using (id = auth.uid())
with check (id = auth.uid());
-- Insert só do próprio (na prática quem insere é trigger, mas deixa coerente)
drop policy if exists "profiles_insert_own" on public.profiles;
create policy "profiles_insert_own"
on public.profiles
for insert
to authenticated
with check (id = auth.uid());

View File

@@ -0,0 +1,212 @@
-- =========================================================
-- Agência PSI Quasar — Cadastro Externo de Paciente (Supabase/Postgres)
-- Objetivo:
-- - Ter um link público com TOKEN que o terapeuta envia ao paciente
-- - Paciente preenche um formulário público
-- - Salva em "intake requests" (pré-cadastro)
-- - Terapeuta revisa e converte em paciente dentro do sistema
--
-- Tabelas:
-- - patient_invites
-- - patient_intake_requests
--
-- Funções:
-- - create_patient_intake_request (RPC pública - anon)
--
-- Segurança:
-- - RLS habilitada
-- - Público (anon) não lê nada, só executa RPC
-- - Terapeuta (authenticated) lê/atualiza somente seus registros
-- =========================================================
-- 0) Tabelas
create table if not exists public.patient_invites (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null,
token text not null unique,
active boolean not null default true,
expires_at timestamptz null,
max_uses int null,
uses int not null default 0,
created_at timestamptz not null default now()
);
create index if not exists patient_invites_owner_id_idx on public.patient_invites(owner_id);
create index if not exists patient_invites_token_idx on public.patient_invites(token);
create table if not exists public.patient_intake_requests (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null,
token text not null,
name text not null,
email text null,
phone text null,
notes text null,
consent boolean not null default false,
status text not null default 'new', -- new | converted | rejected
created_at timestamptz not null default now()
);
create index if not exists patient_intake_owner_id_idx on public.patient_intake_requests(owner_id);
create index if not exists patient_intake_token_idx on public.patient_intake_requests(token);
create index if not exists patient_intake_status_idx on public.patient_intake_requests(status);
-- 1) RLS
alter table public.patient_invites enable row level security;
alter table public.patient_intake_requests enable row level security;
-- 2) Fechar acesso direto para anon (público)
revoke all on table public.patient_invites from anon;
revoke all on table public.patient_intake_requests from anon;
-- 3) Policies: terapeuta (authenticated) - somente próprios registros
-- patient_invites
drop policy if exists invites_select_own on public.patient_invites;
create policy invites_select_own
on public.patient_invites for select
to authenticated
using (owner_id = auth.uid());
drop policy if exists invites_insert_own on public.patient_invites;
create policy invites_insert_own
on public.patient_invites for insert
to authenticated
with check (owner_id = auth.uid());
drop policy if exists invites_update_own on public.patient_invites;
create policy invites_update_own
on public.patient_invites for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
-- patient_intake_requests
drop policy if exists intake_select_own on public.patient_intake_requests;
create policy intake_select_own
on public.patient_intake_requests for select
to authenticated
using (owner_id = auth.uid());
drop policy if exists intake_update_own on public.patient_intake_requests;
create policy intake_update_own
on public.patient_intake_requests for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
-- 4) RPC pública para criar intake (página pública)
-- Importantíssimo: security definer + search_path fixo
create or replace function public.create_patient_intake_request(
p_token text,
p_name text,
p_email text default null,
p_phone text default null,
p_notes text default null,
p_consent boolean default false
)
returns uuid
language plpgsql
security definer
set search_path = public
as $$
declare
v_owner uuid;
v_active boolean;
v_expires timestamptz;
v_max_uses int;
v_uses int;
v_id uuid;
begin
select owner_id, active, expires_at, max_uses, uses
into v_owner, v_active, v_expires, v_max_uses, v_uses
from public.patient_invites
where token = p_token
limit 1;
if v_owner is null then
raise exception 'Token inválido';
end if;
if v_active is not true then
raise exception 'Link desativado';
end if;
if v_expires is not null and now() > v_expires then
raise exception 'Link expirado';
end if;
if v_max_uses is not null and v_uses >= v_max_uses then
raise exception 'Limite de uso atingido';
end if;
if p_name is null or length(trim(p_name)) = 0 then
raise exception 'Nome é obrigatório';
end if;
insert into public.patient_intake_requests
(owner_id, token, name, email, phone, notes, consent, status)
values
(v_owner, p_token, trim(p_name),
nullif(lower(trim(p_email)), ''),
nullif(trim(p_phone), ''),
nullif(trim(p_notes), ''),
coalesce(p_consent, false),
'new')
returning id into v_id;
update public.patient_invites
set uses = uses + 1
where token = p_token;
return v_id;
end;
$$;
grant execute on function public.create_patient_intake_request(text, text, text, text, text, boolean) to anon;
grant execute on function public.create_patient_intake_request(text, text, text, text, text, boolean) to authenticated;
-- 5) (Opcional) helper para rotacionar token no painel (somente authenticated)
-- Você pode usar no front via supabase.rpc('rotate_patient_invite_token')
create or replace function public.rotate_patient_invite_token(
p_new_token text
)
returns uuid
language plpgsql
security definer
set search_path = public
as $$
declare
v_uid uuid;
v_id uuid;
begin
-- pega o usuário logado
v_uid := auth.uid();
if v_uid is null then
raise exception 'Usuário não autenticado';
end if;
-- desativa tokens antigos ativos do usuário
update public.patient_invites
set active = false
where owner_id = v_uid
and active = true;
-- cria novo token
insert into public.patient_invites (owner_id, token, active)
values (v_uid, p_new_token, true)
returning id into v_id;
return v_id;
end;
$$;
grant execute on function public.rotate_patient_invite_token(text) to authenticated;
grant select, insert, update, delete on table public.patient_invites to authenticated;
grant select, insert, update, delete on table public.patient_intake_requests to authenticated;
-- anon não precisa acessar tabelas diretamente
revoke all on table public.patient_invites from anon;
revoke all on table public.patient_intake_requests from anon;

View File

@@ -0,0 +1,266 @@
-- =========================================================
-- PATCH — Completar cadastro para bater com PatientsCadastroPage.vue
-- (rode DEPOIS do seu supabase_cadastro_pacientes.sql)
-- =========================================================
create extension if not exists pgcrypto;
-- ---------------------------------------------------------
-- 1) Completar colunas que o front usa e hoje faltam em patients
-- ---------------------------------------------------------
do $$
begin
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='patients' and column_name='email_alt'
) then
alter table public.patients add column email_alt text;
end if;
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='patients' and column_name='phones'
) then
-- array de textos (Postgres). No JS você manda ["...","..."] normalmente.
alter table public.patients add column phones text[];
end if;
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='patients' and column_name='gender'
) then
alter table public.patients add column gender text;
end if;
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='patients' and column_name='marital_status'
) then
alter table public.patients add column marital_status text;
end if;
end $$;
-- (opcional) índices úteis pra busca/filtro por nome/email
create index if not exists idx_patients_owner_name on public.patients(owner_id, name);
create index if not exists idx_patients_owner_email on public.patients(owner_id, email);
-- ---------------------------------------------------------
-- 2) patient_groups
-- ---------------------------------------------------------
create table if not exists public.patient_groups (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) on delete cascade,
name text not null,
color text,
is_system boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- nome único por owner
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'patient_groups_owner_name_uniq'
and conrelid = 'public.patient_groups'::regclass
) then
alter table public.patient_groups
add constraint patient_groups_owner_name_uniq unique(owner_id, name);
end if;
end $$;
drop trigger if exists trg_patient_groups_set_updated_at on public.patient_groups;
create trigger trg_patient_groups_set_updated_at
before update on public.patient_groups
for each row execute function public.set_updated_at();
create index if not exists idx_patient_groups_owner on public.patient_groups(owner_id);
alter table public.patient_groups enable row level security;
drop policy if exists "patient_groups_select_own" on public.patient_groups;
create policy "patient_groups_select_own"
on public.patient_groups for select
to authenticated
using (owner_id = auth.uid());
drop policy if exists "patient_groups_insert_own" on public.patient_groups;
create policy "patient_groups_insert_own"
on public.patient_groups for insert
to authenticated
with check (owner_id = auth.uid());
drop policy if exists "patient_groups_update_own" on public.patient_groups;
create policy "patient_groups_update_own"
on public.patient_groups for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
drop policy if exists "patient_groups_delete_own" on public.patient_groups;
create policy "patient_groups_delete_own"
on public.patient_groups for delete
to authenticated
using (owner_id = auth.uid());
grant select, insert, update, delete on public.patient_groups to authenticated;
-- ---------------------------------------------------------
-- 3) patient_tags
-- ---------------------------------------------------------
create table if not exists public.patient_tags (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) on delete cascade,
name text not null,
color text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'patient_tags_owner_name_uniq'
and conrelid = 'public.patient_tags'::regclass
) then
alter table public.patient_tags
add constraint patient_tags_owner_name_uniq unique(owner_id, name);
end if;
end $$;
drop trigger if exists trg_patient_tags_set_updated_at on public.patient_tags;
create trigger trg_patient_tags_set_updated_at
before update on public.patient_tags
for each row execute function public.set_updated_at();
create index if not exists idx_patient_tags_owner on public.patient_tags(owner_id);
alter table public.patient_tags enable row level security;
drop policy if exists "patient_tags_select_own" on public.patient_tags;
create policy "patient_tags_select_own"
on public.patient_tags for select
to authenticated
using (owner_id = auth.uid());
drop policy if exists "patient_tags_insert_own" on public.patient_tags;
create policy "patient_tags_insert_own"
on public.patient_tags for insert
to authenticated
with check (owner_id = auth.uid());
drop policy if exists "patient_tags_update_own" on public.patient_tags;
create policy "patient_tags_update_own"
on public.patient_tags for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
drop policy if exists "patient_tags_delete_own" on public.patient_tags;
create policy "patient_tags_delete_own"
on public.patient_tags for delete
to authenticated
using (owner_id = auth.uid());
grant select, insert, update, delete on public.patient_tags to authenticated;
-- ---------------------------------------------------------
-- 4) pivôs (patient_group_patient / patient_patient_tag)
-- ---------------------------------------------------------
create table if not exists public.patient_group_patient (
patient_id uuid not null references public.patients(id) on delete cascade,
patient_group_id uuid not null references public.patient_groups(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (patient_id, patient_group_id)
);
create index if not exists idx_pgp_patient on public.patient_group_patient(patient_id);
create index if not exists idx_pgp_group on public.patient_group_patient(patient_group_id);
alter table public.patient_group_patient enable row level security;
-- a pivot “herda” tenant via join; policy usando exists pra validar owner do patient
drop policy if exists "pgp_select_own" on public.patient_group_patient;
create policy "pgp_select_own"
on public.patient_group_patient for select
to authenticated
using (
exists (
select 1 from public.patients p
where p.id = patient_group_patient.patient_id
and p.owner_id = auth.uid()
)
);
drop policy if exists "pgp_write_own" on public.patient_group_patient;
create policy "pgp_write_own"
on public.patient_group_patient for all
to authenticated
using (
exists (
select 1 from public.patients p
where p.id = patient_group_patient.patient_id
and p.owner_id = auth.uid()
)
)
with check (
exists (
select 1 from public.patients p
where p.id = patient_group_patient.patient_id
and p.owner_id = auth.uid()
)
);
grant select, insert, update, delete on public.patient_group_patient to authenticated;
-- tags pivot (ATENÇÃO: coluna é tag_id, como teu Vue usa!)
create table if not exists public.patient_patient_tag (
patient_id uuid not null references public.patients(id) on delete cascade,
tag_id uuid not null references public.patient_tags(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (patient_id, tag_id)
);
create index if not exists idx_ppt_patient on public.patient_patient_tag(patient_id);
create index if not exists idx_ppt_tag on public.patient_patient_tag(tag_id);
alter table public.patient_patient_tag enable row level security;
drop policy if exists "ppt_select_own" on public.patient_patient_tag;
create policy "ppt_select_own"
on public.patient_patient_tag for select
to authenticated
using (
exists (
select 1 from public.patients p
where p.id = patient_patient_tag.patient_id
and p.owner_id = auth.uid()
)
);
drop policy if exists "ppt_write_own" on public.patient_patient_tag;
create policy "ppt_write_own"
on public.patient_patient_tag for all
to authenticated
using (
exists (
select 1 from public.patients p
where p.id = patient_patient_tag.patient_id
and p.owner_id = auth.uid()
)
)
with check (
exists (
select 1 from public.patients p
where p.id = patient_patient_tag.patient_id
and p.owner_id = auth.uid()
)
);
grant select, insert, update, delete on public.patient_patient_tag to authenticated;
-- =========================================================
-- FIM PATCH
-- =========================================================

View File

@@ -0,0 +1,105 @@
-- =========================================================
-- INTakes / Cadastros Recebidos - Supabase Local
-- =========================================================
-- 0) Extensões úteis (geralmente já existem no Supabase, mas é seguro)
create extension if not exists pgcrypto;
-- 1) Função padrão para updated_at
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
-- 2) Tabela patient_intake_requests (espelhando nuvem)
create table if not exists public.patient_intake_requests (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null,
token text,
name text,
email text,
phone text,
notes text,
consent boolean not null default false,
status text not null default 'new',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
payload jsonb
);
-- 3) Índices (performance em listagem e filtros)
create index if not exists idx_intakes_owner_created
on public.patient_intake_requests (owner_id, created_at desc);
create index if not exists idx_intakes_owner_status_created
on public.patient_intake_requests (owner_id, status, created_at desc);
create index if not exists idx_intakes_status_created
on public.patient_intake_requests (status, created_at desc);
-- 4) Trigger updated_at
drop trigger if exists trg_patient_intake_requests_updated_at on public.patient_intake_requests;
create trigger trg_patient_intake_requests_updated_at
before update on public.patient_intake_requests
for each row execute function public.set_updated_at();
-- 5) RLS
alter table public.patient_intake_requests enable row level security;
-- 6) Policies (iguais às que você mostrou na nuvem)
drop policy if exists intake_select_own on public.patient_intake_requests;
create policy intake_select_own
on public.patient_intake_requests
for select
to authenticated
using (owner_id = auth.uid());
drop policy if exists intake_update_own on public.patient_intake_requests;
create policy intake_update_own
on public.patient_intake_requests
for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
drop policy if exists "delete own intake requests" on public.patient_intake_requests;
create policy "delete own intake requests"
on public.patient_intake_requests
for delete
to authenticated
using (owner_id = auth.uid());
-- =========================================================
-- OPCIONAL (RECOMENDADO): registrar conversão
-- =========================================================
-- Se você pretende marcar intake como convertido e guardar o patient_id:
alter table public.patient_intake_requests
add column if not exists converted_patient_id uuid;
create index if not exists idx_intakes_converted_patient_id
on public.patient_intake_requests (converted_patient_id);
-- Opcional: impedir delete de intakes convertidos (melhor para auditoria)
-- (Se quiser manter delete liberado como na nuvem, comente este bloco.)
drop policy if exists "delete own intake requests" on public.patient_intake_requests;
create policy "delete_own_intakes_not_converted"
on public.patient_intake_requests
for delete
to authenticated
using (owner_id = auth.uid() and status <> 'converted');
-- =========================================================
-- OPCIONAL: check de status (evita status inválido)
-- =========================================================
alter table public.patient_intake_requests
drop constraint if exists chk_intakes_status;
alter table public.patient_intake_requests
add constraint chk_intakes_status
check (status in ('new', 'converted', 'rejected'));

View File

@@ -0,0 +1,174 @@
/*
patient_groups_setup.sql
Setup completo para:
- public.patient_groups
- public.patient_group_patient (tabela ponte)
- view public.v_patient_groups_with_counts
- índice único por owner + nome (case-insensitive)
- 3 grupos padrão do sistema (Crianças, Adolescentes, Idosos) NÃO editáveis / NÃO removíveis
- triggers de proteção
Observação (importante):
- Os grupos padrão são criados com owner_id = '00000000-0000-0000-0000-000000000000' (SYSTEM_OWNER),
para ficarem "globais" e não dependerem de auth.uid() em migrations.
- Se você quiser que os grupos padrão pertençam a um owner específico (tenant),
basta trocar o SYSTEM_OWNER abaixo por esse UUID.
*/
begin;
-- ===========================
-- 0) Constante de "dono do sistema"
-- ===========================
-- Troque aqui se você quiser que os grupos padrão pertençam a um owner específico.
-- Ex.: '816b24fe-a0c3-4409-b79b-c6c0a6935d03'
do $$
begin
-- só para documentar; não cria nada
end $$;
-- ===========================
-- 1) Tabela principal: patient_groups
-- ===========================
create table if not exists public.patient_groups (
id uuid primary key default gen_random_uuid(),
name text not null,
description text,
color text,
is_active boolean not null default true,
is_system boolean not null default false,
owner_id uuid not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- (Opcional, mas recomendado) Garante que name não seja só espaços
-- e evita nomes vazios.
alter table public.patient_groups
drop constraint if exists patient_groups_name_not_blank_check;
alter table public.patient_groups
add constraint patient_groups_name_not_blank_check
check (length(btrim(name)) > 0);
-- ===========================
-- 2) Tabela ponte: patient_group_patient
-- ===========================
-- Se você já tiver essa tabela com FKs, ajuste aqui conforme seu schema.
create table if not exists public.patient_group_patient (
patient_group_id uuid not null references public.patient_groups(id) on delete cascade,
patient_id uuid not null references public.patients(id) on delete cascade,
created_at timestamptz not null default now()
);
-- Evita duplicar vínculo paciente<->grupo
create unique index if not exists patient_group_patient_unique
on public.patient_group_patient (patient_group_id, patient_id);
-- ===========================
-- 3) View com contagem
-- ===========================
create or replace view public.v_patient_groups_with_counts as
select
g.*,
coalesce(count(distinct pgp.patient_id), 0)::int as patients_count
from public.patient_groups g
left join public.patient_group_patient pgp
on pgp.patient_group_id = g.id
group by g.id;
-- ===========================
-- 4) Índice único: não permitir mesmo nome por owner (case-insensitive)
-- ===========================
-- Atenção: se já existirem duplicados, este índice pode falhar ao criar.
create unique index if not exists patient_groups_owner_name_unique
on public.patient_groups (owner_id, (lower(name)));
-- ===========================
-- 5) Triggers de proteção: system não edita / não remove
-- ===========================
create or replace function public.prevent_system_group_changes()
returns trigger
language plpgsql
as $$
begin
if old.is_system = true then
raise exception 'Grupos padrão do sistema não podem ser alterados ou excluídos.';
end if;
if tg_op = 'DELETE' then
return old;
end if;
return new;
end;
$$;
drop trigger if exists trg_prevent_system_group_changes on public.patient_groups;
create trigger trg_prevent_system_group_changes
before update or delete on public.patient_groups
for each row
execute function public.prevent_system_group_changes();
-- Impede "promover" um grupo comum para system
create or replace function public.prevent_promoting_to_system()
returns trigger
language plpgsql
as $$
begin
if new.is_system = true and old.is_system is distinct from true then
raise exception 'Não é permitido transformar um grupo comum em grupo do sistema.';
end if;
return new;
end;
$$;
drop trigger if exists trg_prevent_promoting_to_system on public.patient_groups;
create trigger trg_prevent_promoting_to_system
before update on public.patient_groups
for each row
execute function public.prevent_promoting_to_system();
-- ===========================
-- 6) Inserir 3 grupos padrão (imutáveis)
-- ===========================
-- Dono "global" do sistema (mude se quiser):
-- 00000000-0000-0000-0000-000000000000
with sys_owner as (
select '00000000-0000-0000-0000-000000000000'::uuid as owner_id
)
insert into public.patient_groups (name, description, color, is_active, is_system, owner_id)
select v.name, v.description, v.color, v.is_active, v.is_system, s.owner_id
from sys_owner s
join (values
('Crianças', 'Grupo padrão do sistema', null, true, true),
('Adolescentes', 'Grupo padrão do sistema', null, true, true),
('Idosos', 'Grupo padrão do sistema', null, true, true)
) as v(name, description, color, is_active, is_system)
on true
where not exists (
select 1
from public.patient_groups g
where g.owner_id = s.owner_id
and lower(g.name) = lower(v.name)
);
commit;
/*
Testes rápidos:
1) Ver tudo:
select * from public.v_patient_groups_with_counts order by is_system desc, name;
2) Tentar editar um system (deve falhar):
update public.patient_groups set name='X' where name='Crianças';
3) Tentar deletar um system (deve falhar):
delete from public.patient_groups where name='Crianças';
4) Tentar duplicar nome no mesmo owner (deve falhar por índice único):
insert into public.patient_groups (name, is_active, is_system, owner_id)
values ('teste22', true, false, '816b24fe-a0c3-4409-b79b-c6c0a6935d03');
*/

View File

@@ -0,0 +1,147 @@
-- =========================================================
-- pacientesIndexPage.sql
-- Views + índices para a tela PatientsIndexPage
-- =========================================================
-- 0) Extensões úteis
create extension if not exists pg_trgm;
-- 1) updated_at automático (se você quiser manter updated_at sempre correto)
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_patients_set_updated_at on public.patients;
create trigger trg_patients_set_updated_at
before update on public.patients
for each row execute function public.set_updated_at();
-- =========================================================
-- 2) Views de contagem (usadas em KPIs e telas auxiliares)
-- =========================================================
-- 2.1) Grupos com contagem de pacientes
create or replace view public.v_patient_groups_with_counts as
select
g.id,
g.name,
g.color,
coalesce(count(pgp.patient_id), 0)::int as patients_count
from public.patient_groups g
left join public.patient_group_patient pgp
on pgp.patient_group_id = g.id
group by g.id, g.name, g.color;
-- 2.2) Tags com contagem de pacientes
create or replace view public.v_tag_patient_counts as
select
t.id,
t.name,
t.color,
coalesce(count(ppt.patient_id), 0)::int as patients_count
from public.patient_tags t
left join public.patient_patient_tag ppt
on ppt.tag_id = t.id
group by t.id, t.name, t.color;
-- =========================================================
-- 3) View principal da Index (pacientes + grupos/tags agregados)
-- =========================================================
create or replace view public.v_patients_index as
select
p.*,
-- array JSON com os grupos do paciente
coalesce(gx.groups, '[]'::jsonb) as groups,
-- array JSON com as tags do paciente
coalesce(tx.tags, '[]'::jsonb) as tags,
-- contagens para UI/KPIs
coalesce(gx.groups_count, 0)::int as groups_count,
coalesce(tx.tags_count, 0)::int as tags_count
from public.patients p
left join lateral (
select
jsonb_agg(
distinct jsonb_build_object(
'id', g.id,
'name', g.name,
'color', g.color
)
) filter (where g.id is not null) as groups,
count(distinct g.id) as groups_count
from public.patient_group_patient pgp
join public.patient_groups g
on g.id = pgp.patient_group_id
where pgp.patient_id = p.id
) gx on true
left join lateral (
select
jsonb_agg(
distinct jsonb_build_object(
'id', t.id,
'name', t.name,
'color', t.color
)
) filter (where t.id is not null) as tags,
count(distinct t.id) as tags_count
from public.patient_patient_tag ppt
join public.patient_tags t
on t.id = ppt.tag_id
where ppt.patient_id = p.id
) tx on true;
-- =========================================================
-- 4) Índices recomendados (performance real na listagem/filtros)
-- =========================================================
-- Patients
create index if not exists idx_patients_owner_id
on public.patients (owner_id);
create index if not exists idx_patients_created_at
on public.patients (created_at desc);
create index if not exists idx_patients_status
on public.patients (status);
create index if not exists idx_patients_last_attended_at
on public.patients (last_attended_at desc);
-- Busca rápida (name/email/phone)
create index if not exists idx_patients_name_trgm
on public.patients using gin (name gin_trgm_ops);
create index if not exists idx_patients_email_trgm
on public.patients using gin (email gin_trgm_ops);
create index if not exists idx_patients_phone_trgm
on public.patients using gin (phone gin_trgm_ops);
-- Pivot: grupos
create index if not exists idx_pgp_patient_id
on public.patient_group_patient (patient_id);
create index if not exists idx_pgp_group_id
on public.patient_group_patient (patient_group_id);
-- Pivot: tags
create index if not exists idx_ppt_patient_id
on public.patient_patient_tag (patient_id);
create index if not exists idx_ppt_tag_id
on public.patient_patient_tag (tag_id);

View File

@@ -0,0 +1,134 @@
create extension if not exists pgcrypto;
-- ===============================
-- TABELA: patient_tags
-- ===============================
create table if not exists public.patient_tags (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null,
name text not null,
color text,
is_native boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz
);
create unique index if not exists patient_tags_owner_name_uq
on public.patient_tags (owner_id, lower(name));
-- ===============================
-- TABELA: patient_patient_tag (pivot)
-- ===============================
create table if not exists public.patient_patient_tag (
owner_id uuid not null,
patient_id uuid not null,
tag_id uuid not null,
created_at timestamptz not null default now(),
primary key (patient_id, tag_id)
);
create index if not exists ppt_owner_idx on public.patient_patient_tag(owner_id);
create index if not exists ppt_tag_idx on public.patient_patient_tag(tag_id);
create index if not exists ppt_patient_idx on public.patient_patient_tag(patient_id);
-- ===============================
-- FOREIGN KEYS (com checagem)
-- ===============================
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'ppt_tag_fk'
and conrelid = 'public.patient_patient_tag'::regclass
) then
alter table public.patient_patient_tag
add constraint ppt_tag_fk
foreign key (tag_id)
references public.patient_tags(id)
on delete cascade;
end if;
end $$;
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'ppt_patient_fk'
and conrelid = 'public.patient_patient_tag'::regclass
) then
alter table public.patient_patient_tag
add constraint ppt_patient_fk
foreign key (patient_id)
references public.patients(id)
on delete cascade;
end if;
end $$;
-- ===============================
-- VIEW: contagem por tag
-- ===============================
create or replace view public.v_tag_patient_counts as
select
t.id,
t.owner_id,
t.name,
t.color,
t.is_native,
t.created_at,
t.updated_at,
coalesce(count(ppt.patient_id), 0)::int as patient_count
from public.patient_tags t
left join public.patient_patient_tag ppt
on ppt.tag_id = t.id
and ppt.owner_id = t.owner_id
group by
t.id, t.owner_id, t.name, t.color, t.is_native, t.created_at, t.updated_at;
-- ===============================
-- RLS
-- ===============================
alter table public.patient_tags enable row level security;
alter table public.patient_patient_tag enable row level security;
drop policy if exists tags_select_own on public.patient_tags;
create policy tags_select_own
on public.patient_tags
for select
using (owner_id = auth.uid());
drop policy if exists tags_insert_own on public.patient_tags;
create policy tags_insert_own
on public.patient_tags
for insert
with check (owner_id = auth.uid());
drop policy if exists tags_update_own on public.patient_tags;
create policy tags_update_own
on public.patient_tags
for update
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
drop policy if exists tags_delete_own on public.patient_tags;
create policy tags_delete_own
on public.patient_tags
for delete
using (owner_id = auth.uid());
drop policy if exists ppt_select_own on public.patient_patient_tag;
create policy ppt_select_own
on public.patient_patient_tag
for select
using (owner_id = auth.uid());
drop policy if exists ppt_insert_own on public.patient_patient_tag;
create policy ppt_insert_own
on public.patient_patient_tag
for insert
with check (owner_id = auth.uid());
drop policy if exists ppt_delete_own on public.patient_patient_tag;
create policy ppt_delete_own
on public.patient_patient_tag
for delete
using (owner_id = auth.uid());

View File

@@ -0,0 +1,107 @@
// src/stores/entitlementsStore.js
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase/client'
function normalizeKey(k) {
return String(k || '').trim()
}
export const useEntitlementsStore = defineStore('entitlements', {
state: () => ({
loading: false,
loadedForTenant: null,
features: [], // array reativo de feature_key liberadas
raw: [],
error: null,
loadedAt: null
}),
getters: {
can: (state) => (featureKey) => state.features.includes(featureKey),
has: (state) => (featureKey) => state.features.includes(featureKey)
},
actions: {
async fetch(tenantId, opts = {}) {
return this.loadForTenant(tenantId, opts)
},
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 } = {}) {
if (!tenantId) {
this.invalidate()
return
}
const sameTenant = this.loadedForTenant === tenantId
const hasLoadedAt = typeof this.loadedAt === 'number'
const isFresh =
sameTenant &&
hasLoadedAt &&
maxAgeMs > 0 &&
Date.now() - this.loadedAt < maxAgeMs
if (!force && sameTenant && (maxAgeMs === 0 || isFresh)) return
this.loading = true
this.error = null
try {
// ✅ Modelo B: entitlements por tenant (view)
const { data, error } = await supabase
.from('v_tenant_entitlements')
.select('feature_key')
.eq('tenant_id', tenantId)
if (error) throw error
const rows = data ?? []
// 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()
} catch (e) {
this.error = e
this.raw = []
this.features = []
this.loadedForTenant = tenantId
this.loadedAt = Date.now()
} finally {
this.loading = false
}
},
invalidate() {
this.loadedForTenant = null
this.features = []
this.raw = []
this.error = null
this.loadedAt = null
this.loading = false
}
}
})

View File

@@ -0,0 +1,31 @@
// src/stores/saasHealthStore.js
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase/client'
export const useSaasHealthStore = defineStore('saasHealth', {
state: () => ({
mismatchCount: 0,
loading: false,
lastLoadedAt: null
}),
actions: {
async loadMismatchCount ({ force = false } = {}) {
if (this.loading) return
if (!force && this.lastLoadedAt && (Date.now() - this.lastLoadedAt) < 30_000) return // cache 30s
this.loading = true
try {
const { count, error } = await supabase
.from('v_subscription_feature_mismatch')
.select('*', { count: 'exact', head: true })
if (error) throw error
this.mismatchCount = Number(count || 0)
this.lastLoadedAt = Date.now()
} finally {
this.loading = false
}
}
}
})

86
src/stores/tenantStore.js Normal file
View File

@@ -0,0 +1,86 @@
// src/stores/tenantStore.js
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase/client'
export const useTenantStore = defineStore('tenant', {
state: () => ({
loading: false,
loaded: false,
user: null, // auth user
memberships: [], // [{ tenant_id, role, status }]
activeTenantId: null,
activeRole: null,
needsTenantLink: false,
error: null
}),
actions: {
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 -> não chama RPC, só marca estado
if (!this.user) {
this.memberships = []
this.activeTenantId = null
this.activeRole = null
this.needsTenantLink = false
this.loaded = true
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) define active tenant (primeiro active)
const firstActive = this.memberships.find(x => x.status === 'active')
this.activeTenantId = firstActive?.tenant_id ?? null
this.activeRole = firstActive?.role ?? null
// 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.
// Se você preferir ser mais “duro”, só zere quando não houver sessão:
// (a sessão já foi lida acima; se der erro antes, user pode estar null)
if (!this.user) {
this.memberships = []
this.activeTenantId = null
this.activeRole = null
this.needsTenantLink = false
}
this.loaded = true
} finally {
this.loading = false
}
}
,
setActiveTenant (tenantId) {
const found = this.memberships.find(x => x.tenant_id === tenantId && x.status === 'active')
this.activeTenantId = found?.tenant_id ?? null
this.activeRole = found?.role ?? null
this.needsTenantLink = !this.activeTenantId
}
}
})

140
src/theme/theme.options.js Normal file
View File

@@ -0,0 +1,140 @@
// src/theme/theme.options.js
import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes'
import Aura from '@primeuix/themes/aura'
import Lara from '@primeuix/themes/lara'
import Nora from '@primeuix/themes/nora'
/**
* Presets
*/
export const presetsMap = { Aura, Lara, Nora }
export const presetOptions = Object.keys(presetsMap)
/**
* Colors (Primary)
*/
export const primaryColors = [
{ name: 'noir', palette: {} },
{ name: 'emerald', palette: { 50: '#ecfdf5', 100: '#d1fae5', 200: '#a7f3d0', 300: '#6ee7b7', 400: '#34d399', 500: '#10b981', 600: '#059669', 700: '#047857', 800: '#065f46', 900: '#064e3b', 950: '#022c22' } },
{ name: 'green', palette: { 50: '#f0fdf4', 100: '#dcfce7', 200: '#bbf7d0', 300: '#86efac', 400: '#4ade80', 500: '#22c55e', 600: '#16a34a', 700: '#15803d', 800: '#166534', 900: '#14532d', 950: '#052e16' } },
{ name: 'lime', palette: { 50: '#f7fee7', 100: '#ecfccb', 200: '#d9f99d', 300: '#bef264', 400: '#a3e635', 500: '#84cc16', 600: '#65a30d', 700: '#4d7c0f', 800: '#3f6212', 900: '#365314', 950: '#1a2e05' } },
{ name: 'orange', palette: { 50: '#fff7ed', 100: '#ffedd5', 200: '#fed7aa', 300: '#fdba74', 400: '#fb923c', 500: '#f97316', 600: '#ea580c', 700: '#c2410c', 800: '#9a3412', 900: '#7c2d12', 950: '#431407' } },
{ name: 'amber', palette: { 50: '#fffbeb', 100: '#fef3c7', 200: '#fde68a', 300: '#fcd34d', 400: '#fbbf24', 500: '#f59e0b', 600: '#d97706', 700: '#b45309', 800: '#92400e', 900: '#78350f', 950: '#451a03' } },
{ name: 'yellow', palette: { 50: '#fefce8', 100: '#fef9c3', 200: '#fef08a', 300: '#fde047', 400: '#facc15', 500: '#eab308', 600: '#ca8a04', 700: '#a16207', 800: '#854d0e', 900: '#713f12', 950: '#422006' } },
{ name: 'teal', palette: { 50: '#f0fdfa', 100: '#ccfbf1', 200: '#99f6e4', 300: '#5eead4', 400: '#2dd4bf', 500: '#14b8a6', 600: '#0d9488', 700: '#0f766e', 800: '#115e59', 900: '#134e4a', 950: '#042f2e' } },
{ name: 'cyan', palette: { 50: '#ecfeff', 100: '#cffafe', 200: '#a5f3fc', 300: '#67e8f9', 400: '#22d3ee', 500: '#06b6d4', 600: '#0891b2', 700: '#0e7490', 800: '#155e75', 900: '#164e63', 950: '#083344' } },
{ name: 'sky', palette: { 50: '#f0f9ff', 100: '#e0e2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e', 950: '#082f49' } },
{ name: 'blue', palette: { 50: '#eff6ff', 100: '#dbeafe', 200: '#bfdbfe', 300: '#93c5fd', 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8', 800: '#1e40af', 900: '#1e3a8a', 950: '#172554' } },
{ name: 'indigo', palette: { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc', 400: '#818cf8', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca', 800: '#3730a3', 900: '#312e81', 950: '#1e1b4b' } },
{ name: 'violet', palette: { 50: '#f5f3ff', 100: '#ede9fe', 200: '#ddd6fe', 300: '#c4b5fd', 400: '#a78bfa', 500: '#8b5cf6', 600: '#7c3aed', 700: '#6d28d9', 800: '#5b21b6', 900: '#4c1d95', 950: '#2e1065' } },
{ name: 'purple', palette: { 50: '#faf5ff', 100: '#f3e8ff', 200: '#e9d5ff', 300: '#d8b4fe', 400: '#c084fc', 500: '#a855f7', 600: '#9333ea', 700: '#7e22ce', 800: '#6b21a8', 900: '#581c87', 950: '#3b0764' } },
{ name: 'fuchsia', palette: { 50: '#fdf4ff', 100: '#fae8ff', 200: '#f5d0fe', 300: '#f0abfc', 400: '#e879f9', 500: '#d946ef', 600: '#c026d3', 700: '#a21caf', 800: '#86198f', 900: '#701a75', 950: '#4a044e' } },
{ name: 'pink', palette: { 50: '#fdf2f8', 100: '#fce7f3', 200: '#fbcfe8', 300: '#f9a8d4', 400: '#f472b6', 500: '#ec4899', 600: '#db2777', 700: '#be185d', 800: '#9d174d', 900: '#831843', 950: '#500724' } },
{ name: 'rose', palette: { 50: '#fff1f2', 100: '#ffe4e6', 200: '#fecdd3', 300: '#fda4af', 400: '#fb7185', 500: '#f43f5e', 600: '#e11d48', 700: '#be123c', 800: '#9f1239', 900: '#881337', 950: '#4c0519' } }
]
/**
* Surfaces
*/
export const surfaces = [
{ name: 'slate', palette: { 0: '#ffffff', 50: '#f8fafc', 100: '#f1f5f9', 200: '#e2e8f0', 300: '#cbd5e1', 400: '#94a3b8', 500: '#64748b', 600: '#475569', 700: '#334155', 800: '#1e293b', 900: '#0f172a', 950: '#020617' } },
{ name: 'gray', palette: { 0: '#ffffff', 50: '#f9fafb', 100: '#f3f4f6', 200: '#e5e7eb', 300: '#d1d5db', 400: '#9ca3af', 500: '#6b7280', 600: '#4b5563', 700: '#374151', 800: '#1f2937', 900: '#111827', 950: '#030712' } },
{ name: 'zinc', palette: { 0: '#ffffff', 50: '#fafafa', 100: '#f4f4f5', 200: '#e4e4e7', 300: '#d4d4d8', 400: '#a1a1aa', 500: '#71717a', 600: '#52525b', 700: '#3f3f46', 800: '#27272a', 900: '#18181b', 950: '#09090b' } },
{ name: 'neutral', palette: { 0: '#ffffff', 50: '#fafafa', 100: '#f5f5f5', 200: '#e5e5e5', 300: '#d4d4d4', 400: '#a3a3a3', 500: '#737373', 600: '#525252', 700: '#404040', 800: '#262626', 900: '#171717', 950: '#0a0a0a' } },
{ name: 'stone', palette: { 0: '#ffffff', 50: '#fafaf9', 100: '#f5f5f4', 200: '#e7e5e4', 300: '#d6d3d1', 400: '#a8a29e', 500: '#78716c', 600: '#57534e', 700: '#44403c', 800: '#292524', 900: '#1c1917', 950: '#0c0a09' } },
{ name: 'soho', palette: { 0: '#ffffff', 50: '#f4f4f4', 100: '#e8e9e9', 200: '#d2d2d4', 300: '#bbbcbe', 400: '#a5a5a9', 500: '#8e8f93', 600: '#77787d', 700: '#616268', 800: '#4a4b52', 900: '#34343d', 950: '#1d1e27' } },
{ name: 'viva', palette: { 0: '#ffffff', 50: '#f3f3f3', 100: '#e7e7e8', 200: '#cfd0d0', 300: '#b7b8b9', 400: '#9fa1a1', 500: '#87898a', 600: '#6e7173', 700: '#565a5b', 800: '#3e4244', 900: '#262b2c', 950: '#0e1315' } },
{ name: 'ocean', palette: { 0: '#ffffff', 50: '#fbfcfc', 100: '#F7F9F8', 200: '#EFF3F2', 300: '#DADEDD', 400: '#B1B7B6', 500: '#828787', 600: '#5F7274', 700: '#415B61', 800: '#29444E', 900: '#183240', 950: '#0c1920' } }
]
/**
* ✅ noir: primary “vira” surface (o bloco que você pediu pra ficar aqui)
*/
export const noirPrimaryFromSurface = {
50: '{surface.50}',
100: '{surface.100}',
200: '{surface.200}',
300: '{surface.300}',
400: '{surface.400}',
500: '{surface.500}',
600: '{surface.600}',
700: '{surface.700}',
800: '{surface.800}',
900: '{surface.900}',
950: '{surface.950}'
}
/**
* Helpers
*/
export function getSurfacePalette(surfaceName) {
return surfaces.find(s => s.name === surfaceName)?.palette
}
/**
* ✅ Ponto único: “Preset Extension” baseado no layoutConfig atual
* Use assim: updatePreset(getPresetExt(layoutConfig))
*/
export function getPresetExt(layoutConfig) {
const primaryName = layoutConfig?.primary || 'noir'
const color = primaryColors.find(c => c.name === primaryName) || { name: 'noir', palette: {} }
if (color.name === 'noir') {
return {
semantic: {
primary: noirPrimaryFromSurface,
colorScheme: {
light: {
primary: { color: '{primary.950}', contrastColor: '#ffffff', hoverColor: '{primary.800}', activeColor: '{primary.700}' },
highlight: { background: '{primary.950}', focusBackground: '{primary.700}', color: '#ffffff', focusColor: '#ffffff' }
},
dark: {
primary: { color: '{primary.50}', contrastColor: '{primary.950}', hoverColor: '{primary.200}', activeColor: '{primary.300}' },
highlight: { background: '{primary.50}', focusBackground: '{primary.300}', color: '{primary.950}', focusColor: '{primary.950}' }
}
}
}
}
}
return {
semantic: {
primary: color.palette,
colorScheme: {
light: {
primary: { color: '{primary.500}', contrastColor: '#ffffff', hoverColor: '{primary.600}', activeColor: '{primary.700}' },
highlight: { background: '{primary.50}', focusBackground: '{primary.100}', color: '{primary.700}', focusColor: '{primary.800}' }
},
dark: {
primary: { color: '{primary.400}', contrastColor: '{surface.900}', hoverColor: '{primary.300}', activeColor: '{primary.200}' },
highlight: {
background: 'color-mix(in srgb, {primary.400}, transparent 84%)',
focusBackground: 'color-mix(in srgb, {primary.400}, transparent 76%)',
color: 'rgba(255,255,255,.87)',
focusColor: 'rgba(255,255,255,.87)'
}
}
}
}
}
}
export function applyThemeEngine(layoutConfig) {
const presetValue = presetsMap?.[layoutConfig?.preset] || presetsMap.Aura
const surfacePalette = getSurfacePalette(layoutConfig?.surface)
const ext = getPresetExt(layoutConfig)
// 1) motor principal
$t()
.preset(presetValue)
.preset(ext)
.surfacePalette(surfacePalette)
.use({ useDefaultOptions: true })
// 2) redundante/seguro
updatePreset(ext)
if (surfacePalette) updateSurfacePalette(surfacePalette)
return { presetValue, surfacePalette, ext }
}

24
src/utils/dateBR.js Normal file
View File

@@ -0,0 +1,24 @@
// src/utils/dateBR.js
export function pad2(n) {
return String(n).padStart(2, '0')
}
// ISO (YYYY-MM-DD) -> BR (DD-MM-YYYY)
export function isoToBR(iso) {
if (!iso) return ''
const s = String(iso).slice(0, 10)
const [y, m, d] = s.split('-')
if (!y || !m || !d) return ''
return `${pad2(d)}-${pad2(m)}-${y}`
}
// BR (DD-MM-YYYY) -> ISO (YYYY-MM-DD)
export function brToISO(br) {
if (!br) return null
const s = String(br).trim()
const m = s.match(/^(\d{2})-(\d{2})-(\d{4})$/)
if (!m) return null
const [, dd, mm, yyyy] = m
return `${yyyy}-${mm}-${dd}`
}

View File

@@ -0,0 +1,51 @@
// src/utils/slotsGenerator.js
function toMinutes(hhmm) {
const [h, m] = String(hhmm).slice(0, 5).split(':').map(Number)
return (h * 60) + m
}
function toHHMM(min) {
const h = Math.floor(min / 60)
const m = min % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
/**
* Gera lista de horários (HH:MM) a partir:
* - janelas: [{hora_inicio:'08:00', hora_fim:'12:00', ativo:true}]
* - regra: {passo_minutos:60, offset_minutos:30, ativo:true}
*
* Retorna horários de INÍCIO (igual Altegio)
*/
export function gerarSlotsDoDia(janelas, regra) {
const passo = Number(regra?.passo_minutos || 60)
const offset = Number(regra?.offset_minutos || 0)
const ativos = (janelas || []).filter(j => j?.ativo !== false)
const out = []
for (const j of ativos) {
const start = toMinutes(j.hora_inicio)
const end = toMinutes(j.hora_fim)
// encontra o primeiro t >= start que respeita offset
// condição: t % passo == offset (mod passo), mas offset é dentro da hora.
// Implementação simples: alinhar pelo minuto do dia:
// alvo: t ≡ offset (mod passo) quando offset é interpretado no ciclo do passo.
// Para seu uso (passo 60, offset 30), funciona perfeito.
let t = start
const mod = ((t % passo) + passo) % passo
const need = ((offset - mod) + passo) % passo
t = t + need
while (t + 1 <= end) {
// só inclui se dentro do intervalo
if (t >= start && t < end) out.push(toHHMM(t))
t += passo
}
}
// unique + sort
return Array.from(new Set(out)).sort()
}

View File

@@ -0,0 +1,58 @@
// src/utils/upgradeContext.js
/**
* Parse "missing" query param into array of unique feature keys.
* Accepts:
* - "feature_a"
* - "feature_a,feature_b"
* - ["feature_a", "feature_b"] (vue-router pode entregar array)
*/
export function parseMissingKeys (missing) {
if (!missing) return []
const raw = Array.isArray(missing) ? missing.join(',') : String(missing)
const keys = raw
.split(',')
.map(s => s.trim())
.filter(Boolean)
// unique preserving order
const seen = new Set()
const unique = []
for (const k of keys) {
if (!seen.has(k)) {
seen.add(k)
unique.push(k)
}
}
return unique
}
/**
* Parse redirect param. Only allow internal app paths to avoid open-redirect.
* - Must start with "/"
* - Must NOT start with "//"
*/
export function parseRedirectTo (redirect) {
if (!redirect) return null
const s = Array.isArray(redirect) ? redirect[0] : String(redirect)
const trimmed = s.trim()
if (!trimmed) return null
if (!trimmed.startsWith('/')) return null
if (trimmed.startsWith('//')) return null
return trimmed
}
/**
* Build /upgrade URL with missing feature and redirect target.
*/
export function buildUpgradeUrl ({ missingKeys = [], redirectTo = null } = {}) {
const keys = Array.isArray(missingKeys) ? missingKeys.filter(Boolean) : []
const q = new URLSearchParams()
if (keys.length) q.set('missing', keys.join(','))
if (redirectTo) q.set('redirect', redirectTo)
const qs = q.toString()
return qs ? `/upgrade?${qs}` : '/upgrade'
}

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ProductService } from '@/service/ProductService'; import { ProductService } from '@/services/ProductService';
import { FilterMatchMode } from '@primevue/core/api'; import { FilterMatchMode } from '@primevue/core/api';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';

View File

@@ -0,0 +1,293 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '../../lib/supabase/client' // ajuste se o caminho for outro
const router = useRouter()
const checking = ref(true)
const userEmail = ref('')
const role = ref(null)
const TEST_ACCOUNTS = {
admin: { email: 'admin@agenciapsi.com.br', password: '123Mudar@' },
therapist: { email: 'therapist@agenciapsi.com.br', password: '123Mudar@' },
patient: { email: 'patient@agenciapsi.com.br', password: '123Mudar@' }
}
function roleToPath(r) {
if (r === 'admin') return '/admin'
if (r === 'therapist') return '/therapist'
if (r === 'patient') return '/patient'
return '/'
}
async function fetchMyRole() {
const { data: userData, error: userErr } = await supabase.auth.getUser()
if (userErr) return null
const user = userData?.user
if (!user) return null
userEmail.value = user.email || ''
const { data, error } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
if (error) return null
return data?.role || null
}
async function go(area) {
// Se já estiver logado, respeita role real (não o card)
const { data: sessionData } = await supabase.auth.getSession()
const session = sessionData?.session
if (session) {
const r = role.value || (await fetchMyRole())
if (!r) return router.push('/auth/login')
return router.push(roleToPath(r))
}
// Se não estiver logado, manda pro login guardando a intenção
sessionStorage.setItem('intended_area', area) // admin/therapist/patient
// ✅ Prefill de login (apenas DEV)
const DEV_PREFILL = import.meta.env.DEV
if (DEV_PREFILL) {
const TEST_ACCOUNTS = {
admin: { email: 'admin@agenciapsi.com.br', password: '123Mudar@' },
therapist: { email: 'therapist@agenciapsi.com.br', password: '123Mudar@' },
patient: { email: 'patient@agenciapsi.com.br', password: '123Mudar@' }
}
const acc = TEST_ACCOUNTS[area]
if (acc) {
sessionStorage.setItem('login_prefill_email', acc.email)
sessionStorage.setItem('login_prefill_password', acc.password)
} else {
sessionStorage.removeItem('login_prefill_email')
sessionStorage.removeItem('login_prefill_password')
}
}
router.push('/auth/login')
}
async function goMyPanel() {
if (!role.value) return
router.push(roleToPath(role.value))
}
async function logout() {
await supabase.auth.signOut()
role.value = null
userEmail.value = ''
}
onMounted(async () => {
try {
const { data: sessionData } = await supabase.auth.getSession()
const session = sessionData?.session
if (session) {
role.value = await fetchMyRole()
// Se está logado e tem role, manda direto pro painel
if (role.value) {
router.replace(roleToPath(role.value))
return
}
}
} finally {
checking.value = false
}
})
</script>
<template>
<!-- Estado carregando sessão -->
<div
v-if="checking"
class="relative min-h-screen flex items-center justify-center bg-[var(--surface-ground)]"
>
<div class="text-[var(--text-color-secondary)] text-sm animate-pulse">
Verificando sessão
</div>
</div>
<!-- Página -->
<div
v-else
class="relative min-h-screen overflow-hidden bg-[var(--surface-ground)]"
>
<!-- fundo conceitual -->
<div class="pointer-events-none absolute inset-0">
<!-- grid sutil -->
<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: 40px 40px;
mask-image: radial-gradient(ellipse at 50% 15%, rgba(0,0,0,.95), transparent 70%);
"
/>
<!-- halos -->
<div class="absolute -top-32 -right-32 h-[28rem] w-[28rem] rounded-full blur-3xl bg-indigo-400/10" />
<div class="absolute top-20 -left-32 h-[32rem] w-[32rem] rounded-full blur-3xl bg-emerald-400/10" />
<div class="absolute -bottom-36 right-24 h-[28rem] w-[28rem] rounded-full blur-3xl bg-fuchsia-400/10" />
</div>
<div class="relative flex items-center justify-center min-h-screen p-6">
<div class="w-full max-w-6xl">
<div class="overflow-hidden rounded-[2.75rem] border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-2xl">
<!-- HEADER -->
<div class="px-8 pt-10 pb-8">
<div class="flex items-start justify-between gap-6">
<div>
<div class="text-3xl md:text-4xl font-semibold text-[var(--text-color)]">
Agência PSI
</div>
<div class="mt-2 text-[var(--text-color-secondary)] text-sm md:text-base">
Ambiente de acesso e testes de perfis
</div>
</div>
<div class="hidden md:flex items-center gap-2 text-xs text-[var(--text-color-secondary)]">
<span class="inline-block h-1.5 w-1.5 rounded-full bg-primary/60" />
Dev Mode
</div>
</div>
<div class="mt-8 h-px w-full bg-[var(--surface-border)] opacity-70" />
</div>
<!-- SE ESTIVER LOGADO -->
<div
v-if="role"
class="mx-8 mb-6 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-5 flex flex-col md:flex-row md:items-center md:justify-between gap-4"
>
<div>
<div class="font-semibold text-[var(--text-color)]">
Sessão ativa
</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
{{ userEmail }} perfil: <span class="font-medium">{{ role }}</span>
</div>
</div>
<div class="flex gap-3">
<Button
label="Ir para meu painel"
icon="pi pi-arrow-right"
@click="goMyPanel"
/>
<Button
label="Sair"
severity="secondary"
outlined
@click="logout"
/>
</div>
</div>
<!-- CARDS -->
<div class="px-8 pb-10">
<div class="grid grid-cols-12 gap-6">
<!-- ADMIN -->
<div class="col-span-12 md:col-span-4">
<div
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
@click="go('admin')"
>
<div class="flex items-center justify-between mb-4">
<div class="text-xl font-semibold text-[var(--text-color)]">
Admin
</div>
<i class="pi pi-building text-sm opacity-70" />
</div>
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Gestão da clínica, controle de usuários, permissões, planos e configurações globais.
</div>
<div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition">
Acessar painel
</div>
</div>
</div>
<!-- TERAPEUTA -->
<div class="col-span-12 md:col-span-4">
<div
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
@click="go('therapist')"
>
<div class="flex items-center justify-between mb-4">
<div class="text-xl font-semibold text-[var(--text-color)]">
Terapeuta
</div>
<i class="pi pi-calendar text-sm opacity-70" />
</div>
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Agenda, prontuários, evolução clínica, gestão de pacientes e atendimentos.
</div>
<div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition">
Acessar painel
</div>
</div>
</div>
<!-- PACIENTE -->
<div class="col-span-12 md:col-span-4">
<div
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
@click="go('patient')"
>
<div class="flex items-center justify-between mb-4">
<div class="text-xl font-semibold text-[var(--text-color)]">
Paciente
</div>
<i class="pi pi-user text-sm opacity-70" />
</div>
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Visualização de informações pessoais, documentos e interações com a clínica.
</div>
<div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition">
Acessar painel
</div>
</div>
</div>
</div>
<!-- Rodapé explicativo -->
<div class="mt-10 text-center text-xs text-[var(--text-color-secondary)] opacity-80">
Você será redirecionado para o login (se necessário) e, após autenticação,
encaminhado automaticamente ao painel correspondente.
</div>
</div>
</div>
<!-- assinatura visual -->
<div class="mt-6 flex items-center justify-center gap-2 text-xs text-[var(--text-color-secondary)] opacity-70">
<span class="inline-block h-1.5 w-1.5 rounded-full bg-primary/60" />
<span>Ambiente de desenvolvimento</span>
<span class="inline-block h-1.5 w-1.5 rounded-full bg-primary/60" />
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,63 +1,121 @@
<script setup> <script setup>
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'; 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.
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
const router = useRouter()
const route = useRoute()
const attemptedPath = computed(() => route.fullPath || '')
function goDashboard () {
// Em muitos projetos, '/' redireciona para o dashboard correto conforme role.
router.push('/admin')
}
</script> </script>
<template> <template>
<FloatingConfigurator /> <FloatingConfigurator />
<div class="flex items-center justify-center min-h-screen overflow-hidden">
<div class="flex flex-col items-center justify-center"> <div class="relative min-h-screen overflow-hidden bg-[var(--surface-ground)] text-[var(--text-color)]">
<svg width="54" height="40" viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="mb-8 w-32 shrink-0"> <!-- fundo conceitual: grid + halos -->
<path <div class="pointer-events-none absolute inset-0 opacity-80">
fill-rule="evenodd" <!-- grid sutil -->
clip-rule="evenodd" <div
d="M17.1637 19.2467C17.1566 19.4033 17.1529 19.561 17.1529 19.7194C17.1529 25.3503 21.7203 29.915 27.3546 29.915C32.9887 29.915 37.5561 25.3503 37.5561 19.7194C37.5561 19.5572 37.5524 19.3959 37.5449 19.2355C38.5617 19.0801 39.5759 18.9013 40.5867 18.6994L40.6926 18.6782C40.7191 19.0218 40.7326 19.369 40.7326 19.7194C40.7326 27.1036 34.743 33.0896 27.3546 33.0896C19.966 33.0896 13.9765 27.1036 13.9765 19.7194C13.9765 19.374 13.9896 19.0316 14.0154 18.6927L14.0486 18.6994C15.0837 18.9062 16.1223 19.0886 17.1637 19.2467ZM33.3284 11.4538C31.6493 10.2396 29.5855 9.52381 27.3546 9.52381C25.1195 9.52381 23.0524 10.2421 21.3717 11.4603C20.0078 11.3232 18.6475 11.1387 17.2933 10.907C19.7453 8.11308 23.3438 6.34921 27.3546 6.34921C31.36 6.34921 34.9543 8.10844 37.4061 10.896C36.0521 11.1292 34.692 11.3152 33.3284 11.4538ZM43.826 18.0518C43.881 18.6003 43.9091 19.1566 43.9091 19.7194C43.9091 28.8568 36.4973 36.2642 27.3546 36.2642C18.2117 36.2642 10.8 28.8568 10.8 19.7194C10.8 19.1615 10.8276 18.61 10.8816 18.0663L7.75383 17.4411C7.66775 18.1886 7.62354 18.9488 7.62354 19.7194C7.62354 30.6102 16.4574 39.4388 27.3546 39.4388C38.2517 39.4388 47.0855 30.6102 47.0855 19.7194C47.0855 18.9439 47.0407 18.1789 46.9536 17.4267L43.826 18.0518ZM44.2613 9.54743L40.9084 10.2176C37.9134 5.95821 32.9593 3.1746 27.3546 3.1746C21.7442 3.1746 16.7856 5.96385 13.7915 10.2305L10.4399 9.56057C13.892 3.83178 20.1756 0 27.3546 0C34.5281 0 40.8075 3.82591 44.2613 9.54743Z" class="absolute inset-0"
fill="var(--primary-color)" style="
/> background-image:
<mask id="mask0_1413_1551" style="mask-type: alpha" maskUnits="userSpaceOnUse" x="0" y="8" width="54" height="11"> linear-gradient(to right, rgba(255,255,255,.05) 1px, transparent 1px),
<path d="M27 18.3652C10.5114 19.1944 0 8.88892 0 8.88892C0 8.88892 16.5176 14.5866 27 14.5866C37.4824 14.5866 54 8.88892 54 8.88892C54 8.88892 43.4886 17.5361 27 18.3652Z" fill="var(--primary-color)" /> linear-gradient(to bottom, rgba(255,255,255,.05) 1px, transparent 1px);
</mask> background-size: 36px 36px;
<g mask="url(#mask0_1413_1551)"> mask-image: radial-gradient(ellipse at center, rgba(0,0,0,.9), transparent 70%);
<path "
d="M-4.673e-05 8.88887L3.73084 -1.91434L-8.00806 17.0473L-4.673e-05 8.88887ZM27 18.3652L26.4253 6.95109L27 18.3652ZM54 8.88887L61.2673 17.7127L50.2691 -1.91434L54 8.88887ZM-4.673e-05 8.88887C-8.00806 17.0473 -8.00469 17.0505 -8.00132 17.0538C-8.00018 17.055 -7.99675 17.0583 -7.9944 17.0607C-7.98963 17.0653 -7.98474 17.0701 -7.97966 17.075C-7.96949 17.0849 -7.95863 17.0955 -7.94707 17.1066C-7.92401 17.129 -7.89809 17.1539 -7.86944 17.1812C-7.8122 17.236 -7.74377 17.3005 -7.66436 17.3743C-7.50567 17.5218 -7.30269 17.7063 -7.05645 17.9221C-6.56467 18.3532 -5.89662 18.9125 -5.06089 19.5534C-3.39603 20.83 -1.02575 22.4605 1.98012 24.0457C7.97874 27.2091 16.7723 30.3226 27.5746 29.7793L26.4253 6.95109C20.7391 7.23699 16.0326 5.61231 12.6534 3.83024C10.9703 2.94267 9.68222 2.04866 8.86091 1.41888C8.45356 1.10653 8.17155 0.867278 8.0241 0.738027C7.95072 0.673671 7.91178 0.637576 7.90841 0.634492C7.90682 0.63298 7.91419 0.639805 7.93071 0.65557C7.93897 0.663455 7.94952 0.673589 7.96235 0.686039C7.96883 0.692262 7.97582 0.699075 7.98338 0.706471C7.98719 0.710167 7.99113 0.714014 7.99526 0.718014C7.99729 0.720008 8.00047 0.723119 8.00148 0.724116C8.00466 0.727265 8.00796 0.730446 -4.673e-05 8.88887ZM27.5746 29.7793C37.6904 29.2706 45.9416 26.3684 51.6602 23.6054C54.5296 22.2191 56.8064 20.8465 58.4186 19.7784C59.2265 19.2431 59.873 18.7805 60.3494 18.4257C60.5878 18.2482 60.7841 18.0971 60.9374 17.977C61.014 17.9169 61.0799 17.8645 61.1349 17.8203C61.1624 17.7981 61.1872 17.7781 61.2093 17.7602C61.2203 17.7512 61.2307 17.7427 61.2403 17.7348C61.2452 17.7308 61.2499 17.727 61.2544 17.7233C61.2566 17.7215 61.2598 17.7188 61.261 17.7179C61.2642 17.7153 61.2673 17.7127 54 8.88887C46.7326 0.0650536 46.7357 0.0625219 46.7387 0.0600241C46.7397 0.0592345 46.7427 0.0567658 46.7446 0.0551857C46.7485 0.0520238 46.7521 0.0489887 46.7557 0.0460799C46.7628 0.0402623 46.7694 0.0349487 46.7753 0.0301318C46.7871 0.0204986 46.7966 0.0128495 46.8037 0.00712562C46.818 -0.00431848 46.8228 -0.00808311 46.8184 -0.00463784C46.8096 0.00228345 46.764 0.0378652 46.6828 0.0983779C46.5199 0.219675 46.2165 0.439161 45.7812 0.727519C44.9072 1.30663 43.5257 2.14765 41.7061 3.02677C38.0469 4.79468 32.7981 6.63058 26.4253 6.95109L27.5746 29.7793ZM54 8.88887C50.2691 -1.91433 50.27 -1.91467 50.271 -1.91498C50.2712 -1.91506 50.272 -1.91535 50.2724 -1.9155C50.2733 -1.91581 50.274 -1.91602 50.2743 -1.91616C50.2752 -1.91643 50.275 -1.91636 50.2738 -1.91595C50.2714 -1.91515 50.2652 -1.91302 50.2552 -1.9096C50.2351 -1.90276 50.1999 -1.89078 50.1503 -1.874C50.0509 -1.84043 49.8938 -1.78773 49.6844 -1.71863C49.2652 -1.58031 48.6387 -1.377 47.8481 -1.13035C46.2609 -0.635237 44.0427 0.0249875 41.5325 0.6823C36.215 2.07471 30.6736 3.15796 27 3.15796V26.0151C33.8087 26.0151 41.7672 24.2495 47.3292 22.7931C50.2586 22.026 52.825 21.2618 54.6625 20.6886C55.5842 20.4011 56.33 20.1593 56.8551 19.986C57.1178 19.8993 57.3258 19.8296 57.4735 19.7797C57.5474 19.7548 57.6062 19.7348 57.6493 19.72C57.6709 19.7127 57.6885 19.7066 57.7021 19.7019C57.7089 19.6996 57.7147 19.6976 57.7195 19.696C57.7219 19.6952 57.7241 19.6944 57.726 19.6938C57.7269 19.6934 57.7281 19.693 57.7286 19.6929C57.7298 19.6924 57.7309 19.692 54 8.88887ZM27 3.15796C23.3263 3.15796 17.7849 2.07471 12.4674 0.6823C9.95717 0.0249875 7.73904 -0.635237 6.15184 -1.13035C5.36118 -1.377 4.73467 -1.58031 4.3155 -1.71863C4.10609 -1.78773 3.94899 -1.84043 3.84961 -1.874C3.79994 -1.89078 3.76474 -1.90276 3.74471 -1.9096C3.73469 -1.91302 3.72848 -1.91515 3.72613 -1.91595C3.72496 -1.91636 3.72476 -1.91643 3.72554 -1.91616C3.72593 -1.91602 3.72657 -1.91581 3.72745 -1.9155C3.72789 -1.91535 3.72874 -1.91506 3.72896 -1.91498C3.72987 -1.91467 3.73084 -1.91433 -4.673e-05 8.88887C-3.73093 19.692 -3.72983 19.6924 -3.72868 19.6929C-3.72821 19.693 -3.72698 19.6934 -3.72603 19.6938C-3.72415 19.6944 -3.72201 19.6952 -3.71961 19.696C-3.71482 19.6976 -3.70901 19.6996 -3.7022 19.7019C-3.68858 19.7066 -3.67095 19.7127 -3.6494 19.72C-3.60629 19.7348 -3.54745 19.7548 -3.47359 19.7797C-3.32589 19.8296 -3.11788 19.8993 -2.85516 19.986C-2.33008 20.1593 -1.58425 20.4011 -0.662589 20.6886C1.17485 21.2618 3.74125 22.026 6.67073 22.7931C12.2327 24.2495 20.1913 26.0151 27 26.0151V3.15796Z" />
fill="var(--primary-color)" <!-- halos -->
/> <div class="absolute -top-24 -right-24 h-80 w-80 rounded-full blur-3xl bg-primary/10" />
</g> <div class="absolute -bottom-28 -left-28 h-96 w-96 rounded-full blur-3xl bg-purple-500/10" />
</svg>
<div style="border-radius: 56px; padding: 0.3rem; background: linear-gradient(180deg, color-mix(in srgb, var(--primary-color), transparent 60%) 10%, var(--surface-ground) 30%)">
<div class="w-full bg-surface-0 dark:bg-surface-900 py-20 px-8 sm:px-20 flex flex-col items-center" style="border-radius: 53px">
<span class="text-primary font-bold text-3xl">404</span>
<h1 class="text-surface-900 dark:text-surface-0 font-bold text-3xl lg:text-5xl mb-2">Not Found</h1>
<div class="text-surface-600 dark:text-surface-200 mb-8">Requested resource is not available.</div>
<router-link to="/" class="w-full flex items-center py-8 border-surface-300 dark:border-surface-500 border-b">
<span class="flex justify-center items-center border-2 border-primary text-primary rounded-border" style="height: 3.5rem; width: 3.5rem">
<i class="pi pi-fw pi-table text-2xl!"></i>
</span>
<span class="ml-6 flex flex-col">
<span class="text-surface-900 dark:text-surface-0 lg:text-xl font-medium mb-0 block">Frequently Asked Questions</span>
<span class="text-surface-600 dark:text-surface-200 lg:text-xl">Ultricies mi quis hendrerit dolor.</span>
</span>
</router-link>
<router-link to="/" class="w-full flex items-center py-8 border-surface-300 dark:border-surface-500 border-b">
<span class="flex justify-center items-center border-2 border-primary text-primary rounded-border" style="height: 3.5rem; width: 3.5rem">
<i class="pi pi-fw pi-question-circle text-2xl!"></i>
</span>
<span class="ml-6 flex flex-col">
<span class="text-surface-900 dark:text-surface-0 lg:text-xl font-medium mb-0">Solution Center</span>
<span class="text-surface-600 dark:text-surface-200 lg:text-xl">Phasellus faucibus scelerisque eleifend.</span>
</span>
</router-link>
<router-link to="/" class="w-full flex items-center mb-8 py-8 border-surface-300 dark:border-surface-500 border-b">
<span class="flex justify-center items-center border-2 border-primary text-primary rounded-border" style="height: 3.5rem; width: 3.5rem">
<i class="pi pi-fw pi-unlock text-2xl!"></i>
</span>
<span class="ml-6 flex flex-col">
<span class="text-surface-900 dark:text-surface-0 lg:text-xl font-medium mb-0">Permission Manager</span>
<span class="text-surface-600 dark:text-surface-200 lg:text-xl">Accumsan in nisl nisi scelerisque</span>
</span>
</router-link>
<Button as="router-link" label="Go to Dashboard" to="/" />
</div>
</div>
</div>
</div> </div>
<div class="relative flex min-h-screen items-center justify-center p-6">
<div class="w-full max-w-xl">
<div
class="overflow-hidden rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-2xl"
>
<!-- faixa superior -->
<div class="relative px-8 pt-10 pb-7">
<div class="flex items-start justify-between gap-6">
<div class="flex flex-col">
<div class="tracking-tight font-semibold text-5xl sm:text-6xl leading-none">
<span class="text-primary">404</span>
</div>
<div class="mt-3 text-xl sm:text-2xl font-medium text-[var(--text-color)]">
Página não encontrada
</div>
<p class="mt-3 text-[var(--text-color-secondary)] leading-relaxed">
A rota que você tentou acessar não existe (ou foi movida).
Se você chegou aqui por um link interno, vale revisar o caminho.
</p>
</div>
<!-- selo minimalista -->
<div
class="shrink-0 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4"
>
<div class="flex items-center gap-3">
<span
class="flex h-11 w-11 items-center justify-center rounded-xl border border-primary/30 bg-primary/10"
aria-hidden="true"
>
<i class="pi pi-compass text-xl text-primary" />
</span>
<div class="leading-tight">
<div class="text-sm font-semibold">Rota</div>
<div class="max-w-[12rem] truncate text-sm text-[var(--text-color-secondary)]" :title="attemptedPath">
{{ attemptedPath || '—' }}
</div>
</div>
</div>
</div>
</div>
<!-- linha suave -->
<div class="mt-8 h-px w-full bg-[var(--surface-border)] opacity-70" />
</div>
<!-- ações -->
<div class="px-8 pb-10">
<div class="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div class="text-sm text-[var(--text-color-secondary)]">
Dica: você pode voltar ao fluxo principal do sistema pelo dashboard.
</div>
<Button
label="Voltar ao Dashboard"
icon="pi pi-home"
class="w-full sm:w-auto"
@click="goDashboard"
/>
</div>
<!-- rodapé noir discreto -->
<div class="mt-6 text-xs text-[var(--text-color-secondary)] opacity-80">
Se isso estiver acontecendo com frequência, pode ser um problema de rota ou permissão.
</div>
</div>
</div>
<!-- assinatura visual sutil -->
<div class="mt-6 flex items-center justify-center gap-2 text-xs text-[var(--text-color-secondary)] opacity-70">
<span class="inline-block h-1.5 w-1.5 rounded-full bg-primary/60" />
<span>Agência Psi Quasar</span>
<span class="inline-block h-1.5 w-1.5 rounded-full bg-primary/60" />
</div>
</div>
</div>
</div>
</template> </template>

View File

@@ -7,6 +7,7 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
</script> </script>
<template> <template>
ADMIN DASHBOARD
<div class="grid grid-cols-12 gap-8"> <div class="grid grid-cols-12 gap-8">
<StatsWidget /> <StatsWidget />

View File

@@ -0,0 +1,9 @@
<template>
<div class="p-4">
<h1>Online Scheduling (Manage)</h1>
</div>
</template>
<script setup>
// placeholder para futura implementação
</script>

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,361 @@
<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>
<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>
</div>
</div>
<div class="flex flex-wrap gap-2 justify-start md:justify-end">
<Button
label="Gerar novo link"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="rotating"
@click="rotateLink"
/>
</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">
<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>
<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>
</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>
</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>
</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>
</div>
</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 Message from 'primevue/message'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
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
const origin = computed(() => PUBLIC_BASE_URL || window.location.origin)
const publicUrl = computed(() => {
if (!inviteToken.value) return ''
return `${origin.value}/cadastro/paciente?t=${inviteToken.value}`
})
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 () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
if (!uid) throw new Error('Usuário não autenticado')
return uid
}
async function loadOrCreateInvite () {
const uid = await requireUserId()
const { data, error } = await supabase
.from('patient_invites')
.select('token, active')
.eq('owner_id', uid)
.eq('active', true)
.order('created_at', { ascending: false })
.limit(1)
if (error) throw error
const token = data?.[0]?.token
if (token) {
inviteToken.value = token
return
}
const t = newToken()
const { error: insErr } = await supabase
.from('patient_invites')
.insert({ owner_id: uid, token: t, active: true })
if (insErr) throw insErr
inviteToken.value = t
}
async function rotateLink () {
rotating.value = true
try {
const t = newToken()
const { error } = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
if (error) throw error
inviteToken.value = t
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 })
} finally {
rotating.value = false
}
}
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 })
} catch {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
}
}
function openLink () {
if (!publicUrl.value) return
window.open(publicUrl.value, '_blank', 'noopener')
}
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}`
await navigator.clipboard.writeText(msg)
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada.', life: 1500 })
} catch {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
}
}
onMounted(async () => {
try {
await loadOrCreateInvite()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 })
}
})
</script>

View File

@@ -0,0 +1,834 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
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'
const toast = useToast()
const confirm = useConfirm()
const converting = ref(false)
const loading = ref(false)
const rows = ref([])
const q = ref('')
const dlg = ref({
open: false,
saving: false,
mode: 'view',
item: null,
reject_note: ''
})
function statusSeverity (s) {
if (s === 'new') return 'info'
if (s === 'converted') return 'success'
if (s === 'rejected') return 'danger'
return 'secondary'
}
function statusLabel (s) {
if (s === 'new') return 'Novo'
if (s === 'converted') return 'Convertido'
if (s === 'rejected') return 'Rejeitado'
return s || '—'
}
// -----------------------------
// Helpers de campo: PT primeiro, fallback EN
// -----------------------------
function pickField (obj, keys) {
for (const k of keys) {
const v = obj?.[k]
if (v !== undefined && v !== null && String(v).trim() !== '') return v
}
return null
}
const fNome = (i) => pickField(i, ['nome_completo', 'name'])
const fEmail = (i) => pickField(i, ['email_principal', 'email'])
const fEmailAlt = (i) => pickField(i, ['email_alternativo', 'email_alt'])
const fTel = (i) => pickField(i, ['telefone', 'phone'])
const fTelAlt = (i) => pickField(i, ['telefone_alternativo', 'phone_alt'])
const fNasc = (i) => pickField(i, ['data_nascimento', 'birth_date'])
const fGenero = (i) => pickField(i, ['genero', 'gender'])
const fEstadoCivil = (i) => pickField(i, ['estado_civil', 'marital_status'])
const fProf = (i) => pickField(i, ['profissao', 'profession'])
const fNacionalidade = (i) => pickField(i, ['nacionalidade', 'nationality'])
const fNaturalidade = (i) => pickField(i, ['naturalidade', 'place_of_birth'])
const fEscolaridade = (i) => pickField(i, ['escolaridade', 'education_level'])
const fOndeConheceu = (i) => pickField(i, ['onde_nos_conheceu', 'lead_source'])
const fEncaminhado = (i) => pickField(i, ['encaminhado_por', 'referred_by'])
const fCep = (i) => pickField(i, ['cep'])
const fEndereco = (i) => pickField(i, ['endereco', 'address_street'])
const fNumero = (i) => pickField(i, ['numero', 'address_number'])
const fComplemento = (i) => pickField(i, ['complemento', 'address_complement'])
const fBairro = (i) => pickField(i, ['bairro', 'address_neighborhood'])
const fCidade = (i) => pickField(i, ['cidade', 'address_city'])
const fEstado = (i) => pickField(i, ['estado', 'address_state'])
const fPais = (i) => pickField(i, ['pais', 'country']) || 'Brasil'
const fObs = (i) => pickField(i, ['observacoes', 'notes_short'])
const fNotas = (i) => pickField(i, ['notas_internas', 'notes'])
// -----------------------------
// Filtro
// -----------------------------
const statusFilter = ref('')
function toggleStatusFilter (s) {
statusFilter.value = (statusFilter.value === s) ? '' : s
}
const filteredRows = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
let list = rows.value
// filtro por status (se ativado)
if (statusFilter.value) {
list = list.filter(r => r.status === statusFilter.value)
}
if (!term) return list
return list.filter(r => {
const nome = String(fNome(r) || '').toLowerCase()
const email = String(fEmail(r) || '').toLowerCase()
const tel = String(fTel(r) || '').toLowerCase()
return nome.includes(term) || email.includes(term) || tel.includes(term)
})
})
// -----------------------------
// Avatar
// -----------------------------
const AVATAR_BUCKET = 'avatars'
function firstNonEmpty (...vals) {
for (const v of vals) {
const s = String(v ?? '').trim()
if (s) return s
}
return ''
}
function looksLikeUrl (s) {
return /^https?:\/\//i.test(String(s || ''))
}
function getAvatarUrlFromItem (i) {
// 0) tenta achar foto em vários lugares (raiz e payload jsonb)
const p = i?.payload || i?.data || i?.form || null
const direct = firstNonEmpty(
i?.avatar_url, i?.foto_url, i?.photo_url,
p?.avatar_url, p?.foto_url, p?.photo_url
)
// Se já for URL completa, usa direto
if (direct && looksLikeUrl(direct)) return direct
// 1) se for path de storage, monta publicUrl
const path = firstNonEmpty(
i?.avatar_path, i?.photo_path, i?.foto_path, i?.avatar_file_path,
p?.avatar_path, p?.photo_path, p?.foto_path, p?.avatar_file_path,
// às vezes guardam o path dentro de "direct"
direct
)
if (!path) return null
// se o "path" veio como URL, devolve
if (looksLikeUrl(path)) return path
const { data } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
return data?.publicUrl || null
}
// -----------------------------
// Formatters
// -----------------------------
function dash (v) {
const s = String(v ?? '').trim()
return s ? s : '—'
}
function onlyDigits (v) {
return String(v ?? '').replace(/\D/g, '')
}
function fmtPhoneBR (v) {
const d = onlyDigits(v)
if (!d) return '—'
if (d.length === 11) return `(${d.slice(0,2)}) ${d.slice(2,7)}-${d.slice(7,11)}`
if (d.length === 10) return `(${d.slice(0,2)}) ${d.slice(2,6)}-${d.slice(6,10)}`
return d
}
function fmtCPF (v) {
const d = onlyDigits(v)
if (!d) return '—'
if (d.length !== 11) return d
return `${d.slice(0,3)}.${d.slice(3,6)}.${d.slice(6,9)}-${d.slice(9,11)}`
}
function fmtRG (v) {
const s = String(v ?? '').trim()
return s ? s : '—'
}
function fmtBirth (v) {
if (!v) return '—'
return String(v)
}
function fmtDate (iso) {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
return d.toLocaleString('pt-BR')
}
// -----------------------------
// Seções do modal
// -----------------------------
const intakeSections = computed(() => {
const i = dlg.value.item
if (!i) return []
const avatarUrl = getAvatarUrlFromItem(i)
const section = (title, rows) => ({
title,
rows: (rows || []).filter(r => r && r.value !== undefined)
})
const row = (label, value, opts = {}) => ({
label,
value,
pre: !!opts.pre
})
return [
section('Identificação', [
row('Nome completo', dash(fNome(i))),
row('Email principal', dash(fEmail(i))),
row('Email alternativo', dash(fEmailAlt(i))),
row('Telefone', fmtPhoneBR(fTel(i))),
row('Telefone alternativo', fmtPhoneBR(fTelAlt(i)))
]),
section('Informações pessoais', [
row('Data de nascimento', fmtBirth(fNasc(i))),
row('Gênero', dash(fGenero(i))),
row('Estado civil', dash(fEstadoCivil(i))),
row('Profissão', dash(fProf(i))),
row('Nacionalidade', dash(fNacionalidade(i))),
row('Naturalidade', dash(fNaturalidade(i))),
row('Escolaridade', dash(fEscolaridade(i))),
row('Onde nos conheceu?', dash(fOndeConheceu(i))),
row('Encaminhado por', dash(fEncaminhado(i)))
]),
section('Documentos', [
row('CPF', fmtCPF(i.cpf)),
row('RG', fmtRG(i.rg))
]),
section('Endereço', [
row('CEP', dash(fCep(i))),
row('Endereço', dash(fEndereco(i))),
row('Número', dash(fNumero(i))),
row('Complemento', dash(fComplemento(i))),
row('Bairro', dash(fBairro(i))),
row('Cidade', dash(fCidade(i))),
row('Estado', dash(fEstado(i))),
row('País', dash(fPais(i)))
]),
section('Observações', [
row('Observações', dash(fObs(i)), { pre: true }),
row('Notas internas', dash(fNotas(i)), { pre: true })
]),
section('Administração', [
row('Status', statusLabel(i.status)),
row('Consentimento', i.consent ? 'Aceito' : 'Não aceito'),
row('Motivo da rejeição', dash(i.rejected_reason), { pre: true }),
row('Paciente convertido (ID)', dash(i.converted_patient_id))
]),
section('Metadados', [
row('Owner ID', dash(i.owner_id)),
row('Token', dash(i.token)),
row('Criado em', fmtDate(i.created_at)),
row('Atualizado em', fmtDate(i.updated_at)),
row('ID do intake', dash(i.id))
])
].map(s => ({ ...s, avatarUrl }))
})
// -----------------------------
// Fetch
// -----------------------------
async function fetchIntakes () {
loading.value = true
try {
const { data, error } = await supabase
.from('patient_intake_requests')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
const weight = (s) => (s === 'new' ? 0 : s === 'converted' ? 1 : s === 'rejected' ? 2 : 9)
rows.value = (data || []).slice().sort((a, b) => {
const wa = weight(a.status)
const wb = weight(b.status)
if (wa !== wb) return wa - wb
const da = new Date(a.created_at || 0).getTime()
const db = new Date(b.created_at || 0).getTime()
return db - da
})
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message || String(e), life: 3500 })
} finally {
loading.value = false
}
}
// -----------------------------
// Dialog
// -----------------------------
function openDetails (row) {
dlg.value.open = true
dlg.value.mode = 'view'
dlg.value.item = row
dlg.value.reject_note = row?.rejected_reason || ''
}
function closeDlg () {
dlg.value.open = false
dlg.value.saving = false
dlg.value.item = null
dlg.value.reject_note = ''
}
// -----------------------------
// Rejeitar
// -----------------------------
async function markRejected () {
const item = dlg.value.item
if (!item) return
confirm.require({
message: 'Marcar este cadastro como rejeitado?',
header: 'Confirmar rejeição',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Rejeitar',
rejectLabel: 'Cancelar',
accept: async () => {
dlg.value.saving = true
try {
const reason = String(dlg.value.reject_note || '').trim() || null
const { error } = await supabase
.from('patient_intake_requests')
.update({
status: 'rejected',
rejected_reason: reason,
updated_at: new Date().toISOString()
})
.eq('id', item.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Rejeitado', detail: 'Solicitação rejeitada.', life: 2500 })
await fetchIntakes()
const updated = rows.value.find(r => r.id === item.id)
if (updated) openDetails(updated)
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 3500 })
} finally {
dlg.value.saving = false
}
}
})
}
// -----------------------------
// Converter
// -----------------------------
async function convertToPatient () {
const item = dlg.value?.item
if (!item?.id) return
if (converting.value) return
converting.value = true
try {
const { data: userData, error: userErr } = await supabase.auth.getUser()
if (userErr) throw userErr
const ownerId = userData?.user?.id
if (!ownerId) throw new Error('Sessão inválida.')
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 a foto do intake, se existir
const intakeAvatar = cleanStr(item.avatar_url) || cleanStr(item.foto_url) || null
const patientPayload = {
owner_id: ownerId,
// identificação/contato
nome_completo: cleanStr(fNome(item)),
email_principal: cleanStr(fEmail(item))?.toLowerCase() || null,
email_alternativo: cleanStr(fEmailAlt(item))?.toLowerCase() || null,
telefone: digitsOnly(fTel(item)),
telefone_alternativo: digitsOnly(fTelAlt(item)),
// pessoais
data_nascimento: fNasc(item) || null, // date
naturalidade: cleanStr(fNaturalidade(item)),
genero: cleanStr(fGenero(item)),
estado_civil: cleanStr(fEstadoCivil(item)),
// docs
cpf: digitsOnly(item.cpf),
rg: cleanStr(item.rg),
// endereço (PT)
pais: cleanStr(fPais(item)) || 'Brasil',
cep: digitsOnly(fCep(item)),
cidade: cleanStr(fCidade(item)),
estado: cleanStr(fEstado(item)) || 'SP',
endereco: cleanStr(fEndereco(item)),
numero: cleanStr(fNumero(item)),
bairro: cleanStr(fBairro(item)),
complemento: cleanStr(fComplemento(item)),
// adicionais (PT)
escolaridade: cleanStr(fEscolaridade(item)),
profissao: cleanStr(fProf(item)),
onde_nos_conheceu: cleanStr(fOndeConheceu(item)),
encaminhado_por: cleanStr(fEncaminhado(item)),
// observações (PT)
observacoes: cleanStr(fObs(item)),
notas_internas: cleanStr(fNotas(item)),
// avatar
avatar_url: intakeAvatar
}
// limpa undefined
Object.keys(patientPayload).forEach(k => {
if (patientPayload[k] === undefined) delete patientPayload[k]
})
const { data: created, error: insErr } = await supabase
.from('patients')
.insert(patientPayload)
.select('id')
.single()
if (insErr) throw insErr
const patientId = created?.id
if (!patientId) throw new Error('Falha ao obter ID do paciente criado.')
const { error: upErr } = await supabase
.from('patient_intake_requests')
.update({
status: 'converted',
converted_patient_id: patientId,
updated_at: new Date().toISOString()
})
.eq('id', item.id)
.eq('owner_id', ownerId)
if (upErr) throw upErr
toast.add({ severity: 'success', summary: 'Convertido', detail: 'Cadastro convertido em paciente.', life: 2500 })
dlg.value.open = false
await fetchIntakes()
} catch (err) {
toast.add({
severity: 'error',
summary: 'Falha ao converter',
detail: err?.message || 'Não foi possível converter o cadastro.',
life: 4500
})
} finally {
converting.value = false
}
}
const totals = computed(() => {
const all = rows.value || []
const total = all.length
const nNew = all.filter(r => r.status === 'new').length
const nConv = all.filter(r => r.status === 'converted').length
const nRej = all.filter(r => r.status === 'rejected').length
return { total, nNew, nConv, nRej }
})
onMounted(fetchIntakes)
</script>
<template>
<div class="p-4">
<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>
<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>
<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 (chips clicáveis) -->
<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>
</div>
</div>
</div>
</div>
<!-- TABLE WRAPPER -->
<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" />
</div>
<DataTable
v-else
:value="filteredRows"
dataKey="id"
paginator
:rows="10"
:rowsPerPageOptions="[10, 20, 50]"
responsiveLayout="scroll"
stripedRows
class="!border-0"
>
<Column header="Status" style="width: 10rem">
<template #body="{ data }">
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
</template>
</Column>
<Column header="Paciente">
<template #body="{ data }">
<div class="flex items-center gap-3 min-w-0">
<Avatar
v-if="getAvatarUrlFromItem(data)"
:image="getAvatarUrlFromItem(data)"
shape="circle"
/>
<Avatar v-else icon="pi pi-user" shape="circle" />
<div class="min-w-0">
<div class="font-medium truncate">{{ fNome(data) || '—' }}</div>
<div class="text-color-secondary text-sm truncate">{{ fEmail(data) || '—' }}</div>
</div>
</div>
</template>
</Column>
<Column header="Contato" style="width: 14rem">
<template #body="{ data }">
<div class="text-sm">
<div class="font-medium">{{ fmtPhoneBR(fTel(data)) }}</div>
<div class="text-color-secondary">{{ fTelAlt(data) ? fmtPhoneBR(fTelAlt(data)) : '—' }}</div>
</div>
</template>
</Column>
<Column header="Criado em" style="width: 14rem">
<template #body="{ data }">
<span class="text-color-secondary">{{ fmtDate(data.created_at) }}</span>
</template>
</Column>
<Column header="" style="width: 10rem; text-align: right">
<template #body="{ data }">
<Button
icon="pi pi-eye"
label="Ver"
severity="secondary"
outlined
@click="openDetails(data)"
/>
</template>
</Column>
<template #empty>
<div class="text-color-secondary py-6 text-center">
Nenhum cadastro encontrado.
</div>
</template>
</DataTable>
</div>
<!-- MODAL -->
<Dialog
v-model:visible="dlg.open"
modal
:header="null"
:style="{ width: 'min(940px, 96vw)' }"
:contentStyle="{ padding: 0 }"
@hide="closeDlg"
>
<div v-if="dlg.item" class="relative">
<div class="max-h-[70vh] overflow-auto p-5 bg-[var(--surface-ground)]">
<!-- topo conceitual -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 mb-4">
<div class="flex flex-col items-center text-center gap-3">
<div class="relative">
<div class="absolute inset-0 blur-2xl opacity-30 rounded-full bg-slate-300"></div>
<div class="relative">
<template v-if="(dlgAvatar = getAvatarUrlFromItem(dlg.item))">
<Avatar :image="dlgAvatar" alt="avatar" shape="circle" size="xlarge" />
</template>
<Avatar v-else icon="pi pi-user" shape="circle" size="xlarge" />
</div>
</div>
<div class="min-w-0">
<div class="text-xl font-semibold text-slate-900 truncate">
{{ fNome(dlg.item) || '—' }}
</div>
<div class="text-slate-500 text-sm truncate">
{{ fEmail(dlg.item) || '—' }} · {{ fmtPhoneBR(fTel(dlg.item)) }}
</div>
</div>
<div class="flex flex-wrap justify-center gap-2">
<Tag :value="statusLabel(dlg.item.status)" :severity="statusSeverity(dlg.item.status)" />
<Tag
:value="dlg.item.consent ? 'Consentimento OK' : 'Sem consentimento'"
:severity="dlg.item.consent ? 'success' : 'danger'"
/>
<Tag :value="`Criado: ${fmtDate(dlg.item.created_at)}`" severity="secondary" />
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="(sec, sidx) in intakeSections"
:key="sidx"
class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
>
<div class="font-semibold text-slate-900 mb-3">
{{ sec.title }}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div
v-for="(r, ridx) in sec.rows"
:key="ridx"
class="min-w-0"
>
<div class="text-xs text-slate-500 mb-1">
{{ r.label }}
</div>
<div
class="text-sm text-slate-900"
:class="r.pre ? 'whitespace-pre-wrap leading-relaxed' : 'truncate'"
>
{{ r.value }}
</div>
</div>
</div>
</div>
</div>
<!-- rejeição: nota -->
<div class="mt-5 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="font-semibold text-slate-900">Rejeição</div>
<Tag
:value="dlg.item.status === 'rejected' ? 'Este cadastro já foi rejeitado' : 'Opcional'"
:severity="dlg.item.status === 'rejected' ? 'danger' : 'secondary'"
/>
</div>
<div class="mt-3">
<label class="block text-sm text-slate-600 mb-2">Motivo (anotação interna)</label>
<Textarea
v-model="dlg.reject_note"
autoResize
rows="2"
class="w-full"
:disabled="dlg.saving || converting"
placeholder="Ex.: dados incompletos, pediu para não seguir, duplicado…"
/>
</div>
</div>
<div class="h-24"></div>
</div>
<!-- ações fixas -->
<div class="sticky bottom-0 z-10 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="px-5 py-4 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<Tag :value="statusLabel(dlg.item.status)" :severity="statusSeverity(dlg.item.status)" />
</div>
<div class="flex gap-2 justify-end flex-wrap">
<Button
label="Rejeitar"
icon="pi pi-times"
severity="danger"
outlined
:disabled="dlg.saving || dlg.item.status === 'rejected' || converting"
@click="markRejected"
/>
<Button
label="Converter"
icon="pi pi-check"
severity="success"
:loading="converting"
:disabled="dlg.item.status === 'converted' || dlg.saving || converting"
@click="convertToPatient"
/>
<Button
label="Fechar"
icon="pi pi-times-circle"
severity="secondary"
outlined
:disabled="dlg.saving || converting"
@click="closeDlg"
/>
</div>
</div>
</div>
</div>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,617 @@
<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>
<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>
<div class="flex flex-col lg:flex-row gap-4">
<!-- LEFT: TABLE -->
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
<Card class="h-full">
<template #content>
<DataTable
ref="dt"
v-model:selection="selectedGroups"
:value="groups"
dataKey="id"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 25]"
stripedRows
responsiveLayout="scroll"
:filters="filters"
filterDisplay="menu"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
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>
</template>
<!-- seleção (desabilita grupos do sistema) -->
<Column selectionMode="multiple" style="width: 3rem" :exportable="false">
<template #body="{ data }">
<Checkbox
:binary="true"
:disabled="!!data.is_system"
:modelValue="isSelected(data)"
@update:modelValue="toggleRowSelection(data, $event)"
/>
</template>
</Column>
<Column field="nome" header="Nome" sortable style="min-width: 16rem" />
<Column header="Origem" sortable sortField="is_system" style="min-width: 12rem">
<template #body="{ data }">
<Tag
:value="data.is_system ? 'Padrão' : 'Criado por você'"
:severity="data.is_system ? 'info' : 'success'"
/>
</template>
</Column>
<Column header="Pacientes" sortable sortField="patients_count" style="min-width: 10rem">
<template #body="{ data }">
<span class="text-color-secondary">
{{ patientsLabel(Number(data.patients_count ?? data.patient_count ?? 0)) }}
</span>
</template>
</Column>
<Column :exportable="false" header="Ações" style="min-width: 12rem">
<template #body="{ data }">
<div class="flex justify-end gap-2">
<Button
v-if="!data.is_system"
icon="pi pi-pencil"
outlined
rounded
@click="openEdit(data)"
/>
<Button
v-if="!data.is_system"
icon="pi pi-trash"
outlined
rounded
severity="danger"
@click="confirmDeleteOne(data)"
/>
<Button
v-if="data.is_system"
icon="pi pi-lock"
outlined
rounded
disabled
v-tooltip.top="'Grupo padrão do sistema (inalterável)'"
/>
</div>
</template>
</Column>
<template #empty>
Nenhum grupo encontrado.
</template>
</DataTable>
</template>
</Card>
</div>
<!-- RIGHT: CARDS -->
<div class="w-full lg:basis-[30%] lg:max-w-[30%]">
<Card class="h-full">
<template #title>Pacientes por grupo</template>
<template #subtitle>Os cards aparecem apenas quando 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-users text-3xl"></i>
<div class="mt-1 font-medium">Sem pacientes associados</div>
<small class="text-color-secondary">
Quando um grupo tiver pacientes vinculados, ele aparecerá aqui.
</small>
</div>
<div v-else class="flex flex-col gap-3">
<div
v-for="g in cards"
:key="g.id"
class="relative p-4 rounded-xl border border-[var(--surface-border)] transition-all duration-150 hover:-translate-y-1 hover:shadow-[var(--card-shadow)]"
@mouseenter="hovered = g.id"
@mouseleave="hovered = null"
>
<div class="flex justify-between items-start gap-3">
<div class="min-w-0">
<div class="font-bold truncate max-w-[230px]">
{{ g.nome }}
</div>
<small class="text-color-secondary">
{{ patientsLabel(Number(g.patients_count ?? g.patient_count ?? 0)) }}
</small>
</div>
<Tag
:value="g.is_system ? 'Padrão' : 'Criado por você'"
:severity="g.is_system ? 'info' : 'success'"
/>
</div>
<Transition name="fade">
<div
v-if="hovered === g.id"
class="absolute inset-0 rounded-xl bg-emerald-500/15 backdrop-blur-sm flex items-center justify-center"
>
<Button
label="Ver pacientes"
icon="pi pi-users"
severity="success"
:disabled="!(g.patients_count ?? g.patient_count)"
@click="openGroupPatientsModal(g)"
/>
</div>
</Transition>
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- 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>
<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>
<!-- DIALOG PACIENTES (com botão Abrir) -->
<Dialog
v-model:visible="patientsDialog.open"
:header="patientsDialog.group?.nome ? `Pacientes do grupo: ${patientsDialog.group.nome}` : 'Pacientes do grupo'"
modal
:style="{ width: '900px', maxWidth: '95vw' }"
>
<div class="flex flex-col gap-3">
<div class="text-color-secondary">
Grupo: <span class="font-medium text-color">{{ patientsDialog.group?.nome || '—' }}</span>
</div>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<IconField class="w-full md:w-80">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText
v-model="patientsDialog.search"
placeholder="Buscar paciente..."
class="w-full"
:disabled="patientsDialog.loading"
/>
</IconField>
<div class="flex items-center gap-2 justify-end">
<Tag v-if="!patientsDialog.loading" :value="`${patientsDialog.items.length} paciente(s)`" severity="secondary" />
</div>
</div>
<div v-if="patientsDialog.loading" class="text-color-secondary">Carregando</div>
<Message v-else-if="patientsDialog.error" severity="error">
{{ patientsDialog.error }}
</Message>
<div v-else>
<div v-if="patientsDialog.items.length === 0" class="text-color-secondary">
Nenhum paciente associado a este grupo.
</div>
<div v-else>
<DataTable
:value="patientsDialogFiltered"
dataKey="id"
stripedRows
responsiveLayout="scroll"
paginator
:rows="8"
:rowsPerPageOptions="[8, 15, 30]"
>
<Column header="Paciente" sortable>
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="circle" />
<Avatar v-else :label="initials(data.full_name)" shape="circle" />
<div class="min-w-0">
<div class="font-medium truncate max-w-[420px]">{{ data.full_name }}</div>
<small class="text-color-secondary truncate">{{ data.email || '—' }}</small>
</div>
</div>
</template>
</Column>
<Column header="Telefone" style="min-width: 12rem">
<template #body="{ data }">
<span class="text-color-secondary">{{ fmtPhone(data.phone) }}</span>
</template>
</Column>
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<Button
label="Abrir"
icon="pi pi-external-link"
size="small"
outlined
@click="abrirPaciente(data)"
/>
</template>
</Column>
<template #empty>
<div class="text-color-secondary py-5">Nenhum resultado para "{{ patientsDialog.search }}".</div>
</template>
</DataTable>
</div>
</div>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="patientsDialog.open = false" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Checkbox from 'primevue/checkbox'
import { supabase } from '@/lib/supabase/client'
import {
listGroupsWithCounts,
createGroup,
updateGroup,
deleteGroup
} from '@/services/GruposPacientes.service.js'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const dt = ref(null)
const loading = ref(false)
const groups = ref([])
const selectedGroups = ref([])
const hovered = ref(null)
const filters = ref({
global: { value: null, matchMode: 'contains' }
})
const dlg = reactive({
open: false,
mode: 'create', // 'create' | 'edit'
id: '',
nome: '',
saving: false
})
const patientsDialog = reactive({
open: false,
loading: false,
error: '',
group: null,
items: [],
search: ''
})
const cards = computed(() =>
(groups.value || [])
.filter(g => Number(g.patients_count ?? g.patient_count ?? 0) > 0)
.sort(
(a, b) =>
Number(b.patients_count ?? b.patient_count ?? 0) -
Number(a.patients_count ?? a.patient_count ?? 0)
)
)
const patientsDialogFiltered = computed(() => {
const s = String(patientsDialog.search || '').trim().toLowerCase()
if (!s) return patientsDialog.items || []
return (patientsDialog.items || []).filter(p => {
const name = String(p.full_name || '').toLowerCase()
const email = String(p.email || '').toLowerCase()
const phone = String(p.phone || '').toLowerCase()
return name.includes(s) || email.includes(s) || phone.includes(s)
})
})
function patientsLabel (n) {
return n === 1 ? '1 paciente' : `${n} pacientes`
}
function humanizeError (err) {
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.'
const code = err?.code
if (code === '23505' || /duplicate key value/i.test(msg)) {
return 'Já existe um grupo com esse nome (para você). Tente outro nome.'
}
if (/Grupo padrão/i.test(msg)) {
return 'Esse é um grupo padrão do sistema e não pode ser alterado.'
}
return msg
}
async function fetchAll () {
loading.value = true
try {
groups.value = await listGroupsWithCounts()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
} finally {
loading.value = false
}
}
/* -------------------------------
Seleção: ignora grupos do sistema
-------------------------------- */
function isSelected (row) {
return (selectedGroups.value || []).some(s => s.id === row.id)
}
function toggleRowSelection (row, checked) {
if (row.is_system) return
const sel = selectedGroups.value || []
if (checked) {
if (!sel.some(s => s.id === row.id)) selectedGroups.value = [...sel, row]
} else {
selectedGroups.value = sel.filter(s => s.id !== row.id)
}
}
/* -------------------------------
CRUD
-------------------------------- */
function openCreate () {
dlg.open = true
dlg.mode = 'create'
dlg.id = ''
dlg.nome = ''
}
function openEdit (row) {
dlg.open = true
dlg.mode = 'edit'
dlg.id = row.id
dlg.nome = row.nome
}
async function saveDialog () {
const nome = String(dlg.nome || '').trim()
if (!nome) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe um nome.', life: 2500 })
return
}
if (nome.length < 2) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome muito curto.', life: 2500 })
return
}
dlg.saving = true
try {
if (dlg.mode === 'create') {
await createGroup(nome)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 })
} else {
await updateGroup(dlg.id, nome)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo atualizado.', life: 2500 })
}
dlg.open = false
await fetchAll()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
} finally {
dlg.saving = false
}
}
function confirmDeleteOne (row) {
confirm.require({
message: `Excluir "${row.nome}"?`,
header: 'Excluir grupo',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: async () => {
try {
await deleteGroup(row.id)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo excluído.', life: 2500 })
await fetchAll()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
}
}
})
}
function confirmDeleteSelected () {
const sel = selectedGroups.value || []
if (!sel.length) return
const deletables = sel.filter(g => !g.is_system)
const blocked = sel.filter(g => g.is_system)
if (!deletables.length) {
toast.add({
severity: 'warn',
summary: 'Atenção',
detail: 'Os itens selecionados são grupos do sistema e não podem ser excluídos.',
life: 3500
})
return
}
const msgBlocked = blocked.length ? ` (${blocked.length} grupo(s) padrão serão ignorados)` : ''
confirm.require({
message: `Excluir ${deletables.length} grupo(s) selecionado(s)?${msgBlocked}`,
header: 'Excluir selecionados',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: async () => {
try {
for (const g of deletables) await deleteGroup(g.id)
selectedGroups.value = []
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Exclusão concluída.', life: 2500 })
await fetchAll()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
}
}
})
}
/* -------------------------------
Helpers (avatar/telefone)
-------------------------------- */
function initials (name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
if (!parts.length) return '—'
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
function onlyDigits (v) {
return String(v ?? '').replace(/\D/g, '')
}
function fmtPhone (v) {
const d = onlyDigits(v)
if (!d) return '—'
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`
return d
}
/* -------------------------------
Modal: Pacientes do Grupo
-------------------------------- */
async function openGroupPatientsModal (groupRow) {
patientsDialog.open = true
patientsDialog.loading = true
patientsDialog.error = ''
patientsDialog.group = groupRow
patientsDialog.items = []
patientsDialog.search = ''
try {
const { data, error } = await supabase
.from('patient_group_patient')
.select(`
patient_id,
patient:patients (
id,
nome_completo,
email_principal,
telefone,
avatar_url
)
`)
.eq('patient_group_id', groupRow.id)
if (error) throw error
const patients = (data || [])
.map(r => r.patient)
.filter(Boolean)
patientsDialog.items = patients
.map(p => ({
id: p.id,
full_name: p.nome_completo || '—',
email: p.email_principal || '—',
phone: p.telefone || '—',
avatar_url: p.avatar_url || null
}))
.sort((a, b) => String(a.full_name).localeCompare(String(b.full_name), 'pt-BR'))
} catch (err) {
patientsDialog.error = humanizeError(err)
} finally {
patientsDialog.loading = false
}
}
function abrirPaciente (patient) {
router.push(`/admin/pacientes/cadastro/${patient.id}`)
}
onMounted(fetchAll)
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active { transition: opacity .14s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,899 @@
<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'
import AccordionContent from 'primevue/accordioncontent'
import Popover from 'primevue/popover'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
const toast = useToast()
const loadError = ref('')
const props = defineProps({
modelValue: { type: Boolean, default: false },
patient: { type: Object, default: () => ({}) } // precisa ter id
})
const emit = defineEmits(['update:modelValue', 'close'])
const model = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
function isEmpty(v) {
if (v === null || v === undefined) return true
const s = String(v).trim()
return !s
}
function dash(v) {
const s = String(v ?? '').trim()
return s ? s : '—'
}
/**
* Pega o primeiro campo "existente e não-vazio" em ordem.
* Útil pra transição EN -> PT sem quebrar o prontuário.
*/
function pick(obj, keys = []) {
for (const k of keys) {
const v = obj?.[k]
if (!isEmpty(v)) return v
}
return null
}
// ------------------------------------------------------
// accordion (pode abrir vários) + scroll
// ------------------------------------------------------
const accordionValues = ['0', '1', '2', '3', '4']
const activeValues = ref(['0']) // começa com o primeiro aberto
const activeValue = computed(() => activeValues.value?.[0] ?? null)
const panelHeaderRefs = ref([])
function setPanelHeaderRef(el, idx) {
if (!el) return
panelHeaderRefs.value[idx] = el
}
const allOpen = computed(() => accordionValues.every(v => activeValues.value.includes(v)))
function toggleAllAccordions() {
activeValues.value = allOpen.value ? [] : [...accordionValues]
}
/**
* Abre o painel clicado (e fecha os outros).
*/
async function openPanel(i) {
const v = String(i)
activeValues.value = [v]
await nextTick()
const headerRef = panelHeaderRefs.value?.[i]
const el = headerRef?.$el ?? headerRef
if (el && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
const navItems = [
{ value: '0', label: 'Cadastro', icon: 'pi pi-pencil' },
{ value: '1', label: 'Endereço', icon: 'pi pi-map-marker' },
{ value: '2', label: 'Dados adicionais', icon: 'pi pi-tags' },
{ value: '3', label: 'Responsável', icon: 'pi pi-users' },
{ value: '4', label: 'Anotações', icon: 'pi pi-file-edit' }
]
const navPopover = ref(null)
function toggleNav(event) { navPopover.value?.toggle(event) }
function selectNav(item) { openPanel(Number(item.value)); navPopover.value?.hide() }
const selectedNav = computed(() => navItems.find(i => i.value === activeValue.value) || null)
// Responsivo < 1200px
const isCompact = ref(false)
let mql = null
let mqlHandler = null
function syncCompact() { isCompact.value = !!mql?.matches }
onMounted(() => {
mql = window.matchMedia('(max-width: 1199px)')
mqlHandler = () => syncCompact()
mql.addEventListener?.('change', mqlHandler)
mql.addListener?.(mqlHandler)
syncCompact()
})
onBeforeUnmount(() => {
mql?.removeEventListener?.('change', mqlHandler)
mql?.removeListener?.(mqlHandler)
})
// ------------------------------------------------------
// Data load (read-only)
// ------------------------------------------------------
const loading = ref(false)
const patientFull = ref(null)
const groupName = ref(null)
const tags = ref([])
const patientData = computed(() => patientFull.value || props.patient || {})
const fallbackAvatar =
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=600&q=60'
const avatarUrl = computed(() => patientData.value?.avatar_url || patientData.value?.avatar || fallbackAvatar)
function onlyDigits(v) { return String(v ?? '').replace(/\D/g, '') }
function fmtCPF(v) {
const d = onlyDigits(v)
if (!d) return '—'
if (d.length !== 11) return d
return `${d.slice(0, 3)}.${d.slice(3, 6)}.${d.slice(6, 9)}-${d.slice(9, 11)}`
}
function onlyRgChars(v) {
return String(v ?? '').toUpperCase().replace(/[^0-9X]/g, '')
}
function fmtRG(v) {
const s = onlyRgChars(v)
if (!s) return '—'
if (s.length === 9) return `${s.slice(0, 2)}.${s.slice(2, 5)}.${s.slice(5, 8)}-${s.slice(8)}`
if (s.length === 8) return `${s.slice(0, 2)}.${s.slice(2, 5)}.${s.slice(5, 8)}`
return s
}
function fmtDateBR(isoOrDate) {
if (!isoOrDate) return '—'
const d = new Date(isoOrDate)
if (Number.isNaN(d.getTime())) return dash(isoOrDate)
const dd = String(d.getDate()).padStart(2, '0')
const mm = String(d.getMonth() + 1).padStart(2, '0')
const yy = d.getFullYear()
return `${dd}/${mm}/${yy}`
}
function calcAge(isoOrDate) {
if (!isoOrDate) return null
const d = new Date(isoOrDate)
if (Number.isNaN(d.getTime())) return null
const now = new Date()
let age = now.getFullYear() - d.getFullYear()
const m = now.getMonth() - d.getMonth()
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--
return age
}
function fmtGender(v) {
const s = String(v ?? '').trim()
if (!s) return '—'
const x = s.toLowerCase()
if (['m', 'masc', 'masculino', 'male', 'man', 'homem'].includes(x)) return 'Masculino'
if (['f', 'fem', 'feminino', 'female', 'woman', 'mulher'].includes(x)) return 'Feminino'
if (['nb', 'nao-binario', 'não-binário', 'nonbinary', 'non-binary', 'non_binary', 'genderqueer'].includes(x)) return 'Não-binário'
if (['outro', 'other'].includes(x)) return 'Outro'
if (['na', 'n/a', 'none', 'unknown'].includes(x)) return 'Não informado'
return s
}
function fmtMarital(v) {
const s = String(v ?? '').trim()
if (!s) return '—'
const x = s.toLowerCase()
if (['solteiro', 'solteira', 'single'].includes(x)) return 'Solteiro(a)'
if (['casado', 'casada', 'married'].includes(x)) return 'Casado(a)'
if (['divorciado', 'divorciada', 'divorced'].includes(x)) return 'Divorciado(a)'
if (['viuvo', 'viúva', 'viuvo(a)', 'widowed'].includes(x)) return 'Viúvo(a)'
if (['uniao estavel', 'união estável', 'civil union'].includes(x)) return 'União estável'
if (['na', 'n/a', 'none', 'unknown'].includes(x)) return 'Não informado'
return s
}
function onlyDigitsPhone(v) { return String(v ?? '').replace(/\D/g, '') }
function fmtPhoneMobile(v) {
const d = onlyDigitsPhone(v)
if (!d) return '—'
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7, 11)}`
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6, 10)}`
return d
}
const birthValue = computed(() => pick(patientData.value, ['data_nascimento', 'birth_date']))
const ageLabel = computed(() => {
const age = calcAge(birthValue.value)
return age == null ? '—' : `${age} anos`
})
// cadastro (fallback PT/EN)
const nomeCompleto = computed(() => pick(patientData.value, ['nome_completo', 'name']))
const telefone = computed(() => pick(patientData.value, ['telefone', 'phone']))
const emailPrincipal = computed(() => pick(patientData.value, ['email_principal', 'email']))
const emailAlternativo = computed(() => pick(patientData.value, ['email_alternativo', 'email_alt', 'emailAlt']))
const telefoneAlternativo = computed(() => pick(patientData.value, ['telefone_alternativo', 'phone_alt', 'phoneAlt']))
const genero = computed(() => pick(patientData.value, ['genero', 'gender']))
const estadoCivil = computed(() => pick(patientData.value, ['estado_civil', 'marital_status']))
const naturalidade = computed(() => pick(patientData.value, ['naturalidade', 'birthplace', 'place_of_birth']))
const observacoes = computed(() => pick(patientData.value, ['observacoes', 'notes_short']))
const ondeNosConheceu = computed(() => pick(patientData.value, ['onde_nos_conheceu', 'lead_source']))
const encaminhadoPor = computed(() => pick(patientData.value, ['encaminhado_por', 'referred_by']))
// endereço
const cep = computed(() => pick(patientData.value, ['cep', 'postal_code']))
const pais = computed(() => pick(patientData.value, ['pais', 'country']))
const cidade = computed(() => pick(patientData.value, ['cidade', 'city']))
const estado = computed(() => pick(patientData.value, ['estado', 'state']))
const endereco = computed(() => pick(patientData.value, ['endereco', 'address_line']))
const numero = computed(() => pick(patientData.value, ['numero', 'address_number']))
const bairro = computed(() => pick(patientData.value, ['bairro', 'neighborhood']))
const complemento = computed(() => pick(patientData.value, ['complemento', 'address_complement']))
// dados adicionais
const escolaridade = computed(() => pick(patientData.value, ['escolaridade', 'education', 'education_level']))
const profissao = computed(() => pick(patientData.value, ['profissao', 'profession']))
const nomeParente = computed(() => pick(patientData.value, ['nome_parente', 'relative_name']))
const grauParentesco = computed(() => pick(patientData.value, ['grau_parentesco', 'relative_relation']))
const telefoneParente = computed(() => pick(patientData.value, ['telefone_parente', 'relative_phone']))
// responsável
const nomeResponsavel = computed(() => pick(patientData.value, ['nome_responsavel', 'guardian_name']))
const cpfResponsavel = computed(() => pick(patientData.value, ['cpf_responsavel', 'guardian_cpf']))
const telefoneResponsavel = computed(() => pick(patientData.value, ['telefone_responsavel', 'guardian_phone']))
const observacaoResponsavel = computed(() => pick(patientData.value, ['observacao_responsavel', 'guardian_note']))
// notas internas
const notasInternas = computed(() => pick(patientData.value, ['notas_internas', 'notes']))
async function getPatientById(id) {
const { data, error } = await supabase
.from('patients')
.select('*')
.eq('id', id)
.maybeSingle()
if (error) throw error
return data
}
async function getPatientRelations(id) {
const { data: g, error: ge } = await supabase
.from('patient_group_patient')
.select('patient_group_id')
.eq('patient_id', id)
if (ge) throw ge
const { data: t, error: te } = await supabase
.from('patient_patient_tag')
.select('tag_id')
.eq('patient_id', id)
if (te) throw te
return {
groupIds: (g || []).map(x => x.patient_group_id).filter(Boolean),
tagIds: (t || []).map(x => x.tag_id).filter(Boolean)
}
}
/**
* ✅ AQUI estava o erro:
* patient_groups NÃO tem "name", tem "nome"
*/
async function getGroupsByIds(ids) {
if (!ids?.length) return []
const { data, error } = await supabase
.from('patient_groups')
.select('id, nome')
.in('id', ids)
if (error) throw error
return (data || []).map(g => ({
id: g.id,
name: g.nome
}))
}
/**
* ✅ AQUI estava o erro:
* patient_tags NÃO tem "name/color", tem "nome/cor"
*/
async function getTagsByIds(ids) {
if (!ids?.length) return []
const { data, error } = await supabase
.from('patient_tags')
.select('id, nome, cor')
.in('id', ids)
.order('nome', { ascending: true })
if (error) throw error
return (data || []).map(t => ({
id: t.id,
name: t.nome,
color: t.cor
}))
}
async function loadProntuario(id) {
loadError.value = ''
loading.value = true
patientFull.value = null
groupName.value = null
tags.value = []
try {
const p = await getPatientById(id)
if (!p) throw new Error('Paciente não retornou dados (RLS bloqueando ou ID não existe no banco).')
patientFull.value = p
const rel = await getPatientRelations(id)
const groups = await getGroupsByIds(rel.groupIds || [])
groupName.value = groups?.[0]?.name || null
tags.value = await getTagsByIds(rel.tagIds || [])
} catch (e) {
loadError.value = e?.message || 'Falha ao buscar dados no Supabase.'
toast.add({ severity: 'error', summary: 'Erro ao carregar prontuário', detail: loadError.value, life: 4500 })
} finally {
loading.value = false
}
}
watch(
[() => props.modelValue, () => props.patient?.id],
async ([open, id], [prevOpen, prevId]) => {
if (!open || !id) return
if (open === prevOpen && id === prevId) return
activeValues.value = ['0']
await loadProntuario(id)
},
{ immediate: true }
)
function close() {
model.value = false
emit('close')
}
async function copyResumo() {
const txt =
`Paciente: ${dash(nomeCompleto.value)}
Idade: ${ageLabel.value}
Grupo: ${dash(groupName.value)}
Telefone: ${dash(telefone.value)}
Email: ${dash(emailPrincipal.value)}
CPF: ${dash(patientData.value?.cpf)}
RG: ${dash(patientData.value?.rg)}
Nascimento: ${fmtDateBR(birthValue.value)}
Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
`
try {
await navigator.clipboard.writeText(txt)
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Resumo copiado para a área de transferência.', life: 2200 })
} catch {
toast.add({ severity: 'error', summary: 'Falha', detail: 'Não foi possível copiar.', life: 3000 })
}
}
</script>
<template>
<Dialog
v-model:visible="model"
modal
maximizable
:style="{ width: '96vw', maxWidth: '1400px' }"
:contentStyle="{ padding: 0 }"
@hide="close"
>
<Toast />
<div class="bg-gray-100">
<div class="p-3">
<Card class="shadow-sm rounded-2xl overflow-hidden">
<template #title>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div class="text-lg font-semibold leading-none">Prontuário</div>
<div class="mt-1 text-sm text-slate-600">
Paciente: <b>{{ dash(nomeCompleto) }}</b> · Idade: <b>{{ ageLabel }}</b>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button
:label="allOpen ? 'Fechar seções' : 'Abrir seções'"
:icon="allOpen ? 'pi pi-angle-double-up' : 'pi pi-angle-double-down'"
severity="secondary"
outlined
@click="toggleAllAccordions"
/>
<Button label="Copiar resumo" icon="pi pi-copy" severity="secondary" outlined @click="copyResumo" />
<Button label="Fechar" icon="pi pi-times" severity="secondary" outlined @click="close" />
</div>
</div>
</template>
<template #content>
<div v-if="loadError" class="m-3 rounded-xl border border-red-200 bg-white p-3">
<div class="font-semibold text-red-600">Falha ao carregar</div>
<div class="mt-1 text-sm text-slate-700">{{ loadError }}</div>
</div>
<div v-if="loading" class="p-4 text-slate-600">Carregando</div>
<div v-else class="grid grid-cols-1 xl:grid-cols-[260px_1fr] gap-4 p-3">
<!-- sidebar -->
<aside class="xl:sticky xl:top-2 self-start">
<div class="rounded-2xl border border-slate-200 bg-white p-3">
<div class="flex flex-col items-center gap-3">
<div class="h-24 w-24 rounded-full overflow-hidden border border-slate-200 bg-slate-50">
<img :src="avatarUrl" alt="avatar" class="h-full w-full object-cover" />
</div>
<div class="w-full text-sm text-slate-700">
<div><b>Grupo:</b> {{ dash(groupName) }}</div>
<div class="mt-3">
<b>Tags:</b>
<div class="mt-2 flex flex-wrap gap-2">
<Chip v-for="t in tags" :key="t.id" :label="t.name" />
<span v-if="!tags?.length" class="text-slate-500"></span>
</div>
</div>
</div>
</div>
</div>
<!-- >=1200px -->
<div v-if="!isCompact" class="mt-3 flex flex-col gap-2">
<button
v-for="item in navItems"
:key="item.value"
type="button"
class="w-full rounded-xl border bg-white px-3 py-2 text-left flex items-center gap-2 transition
hover:-translate-y-[1px] hover:bg-slate-50"
:class="activeValue === item.value ? 'border-primary-300 bg-primary-50' : 'border-slate-200'"
@click="openPanel(Number(item.value))"
>
<i :class="item.icon" class="opacity-80"></i>
<span class="font-medium">{{ item.label }}</span>
</button>
</div>
</aside>
<!-- main -->
<main class="min-w-0">
<!-- <1200px -->
<div v-if="isCompact" class="sticky top-2 z-10 rounded-2xl border border-slate-200 bg-white p-3 mb-3">
<Button
type="button"
class="w-full"
icon="pi pi-chevron-down"
iconPos="right"
:label="selectedNav ? selectedNav.label : 'Selecionar seção'"
@click="toggleNav($event)"
/>
<Popover ref="navPopover">
<div class="min-w-[260px] flex flex-col gap-3">
<span class="font-medium block">Seções</span>
<ul class="list-none p-0 m-0 flex flex-col gap-1">
<li
v-for="item in navItems"
:key="item.value"
class="flex items-center gap-2 px-2 py-2 cursor-pointer rounded-lg hover:bg-slate-100"
:class="activeValue === item.value ? 'bg-slate-100' : ''"
@click="selectNav(item)"
>
<i :class="item.icon" class="opacity-85"></i>
<span class="font-medium">{{ item.label }}</span>
</li>
</ul>
</div>
</Popover>
</div>
<Accordion multiple v-model:value="activeValues">
<AccordionPanel value="0">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 0)">1. INFORMAÇÕES PESSOAIS</AccordionHeader>
<AccordionContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="md:col-span-2 min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user" />
<InputText :modelValue="dash(nomeCompleto)" class="w-full" variant="filled" readonly />
</IconField>
<label>Nome completo</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputText :modelValue="fmtPhoneMobile(telefone)" class="w-full" variant="filled" readonly />
</IconField>
<label>Telefone / Celular</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-envelope" />
<InputText :modelValue="dash(emailPrincipal)" class="w-full" variant="filled" readonly />
</IconField>
<label>Email principal</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-envelope" />
<InputText :modelValue="dash(emailAlternativo)" class="w-full" variant="filled" readonly />
</IconField>
<label>Email alternativo</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputText :modelValue="fmtPhoneMobile(telefoneAlternativo)" class="w-full" variant="filled" readonly />
</IconField>
<label>Telefone alternativo</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-calendar" />
<InputText :modelValue="fmtDateBR(birthValue)" class="w-full" variant="filled" readonly />
</IconField>
<label>Data de nascimento</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user" />
<InputText :modelValue="fmtGender(genero)" class="w-full" variant="filled" readonly />
</IconField>
<label>Gênero</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-heart" />
<InputText :modelValue="fmtMarital(estadoCivil)" class="w-full" variant="filled" readonly />
</IconField>
<label>Estado civil</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-id-card" />
<InputText :modelValue="fmtCPF(patientData.cpf)" class="w-full" variant="filled" readonly />
</IconField>
<label>CPF</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-id-card" />
<InputText :modelValue="fmtRG(patientData.rg)" class="w-full" variant="filled" readonly />
</IconField>
<label>RG</label>
</FloatLabel>
</div>
<div class="md:col-span-2 min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-map" />
<InputText :modelValue="dash(naturalidade)" class="w-full" variant="filled" readonly />
</IconField>
<label>Naturalidade</label>
</FloatLabel>
</div>
<div class="md:col-span-2 min-w-0">
<FloatLabel variant="on">
<Textarea :modelValue="dash(observacoes)" rows="3" class="w-full" variant="filled" readonly />
<label>Observações</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-folder-open" />
<InputText :modelValue="dash(groupName)" class="w-full" variant="filled" readonly />
</IconField>
<label>Grupos</label>
</FloatLabel>
<small class="text-slate-500">Utilizado para importar o formulário de anamnese</small>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText
:modelValue="(tags || []).map(t => t.name).filter(Boolean).join(', ') || '—'"
class="w-full"
variant="filled"
readonly
/>
</IconField>
<label>Tags</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-megaphone" />
<InputText :modelValue="dash(ondeNosConheceu)" class="w-full" variant="filled" readonly />
</IconField>
<label>Onde nos conheceu?</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-share-alt" />
<InputText :modelValue="dash(encaminhadoPor)" class="w-full" variant="filled" readonly />
</IconField>
<label>Encaminhado por</label>
</FloatLabel>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="1">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 1)">2. ENDEREÇO</AccordionHeader>
<AccordionContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-map-marker" />
<InputText :modelValue="dash(cep)" class="w-full" variant="filled" readonly />
</IconField>
<label>CEP</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-map" />
<InputText :modelValue="dash(pais)" class="w-full" variant="filled" readonly />
</IconField>
<label>País</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-building" />
<InputText :modelValue="dash(cidade)" class="w-full" variant="filled" readonly />
</IconField>
<label>Cidade</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-compass" />
<InputText :modelValue="dash(estado)" class="w-full" variant="filled" readonly />
</IconField>
<label>Estado</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-home" />
<InputText :modelValue="dash(endereco)" class="w-full" variant="filled" readonly />
</IconField>
<label>Endereço</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-sort-numeric-up" />
<InputText :modelValue="dash(numero)" class="w-full" variant="filled" readonly />
</IconField>
<label>Número</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-map-marker" />
<InputText :modelValue="dash(bairro)" class="w-full" variant="filled" readonly />
</IconField>
<label>Bairro</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-align-left" />
<InputText :modelValue="dash(complemento)" class="w-full" variant="filled" readonly />
</IconField>
<label>Complemento</label>
</FloatLabel>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="2">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 2)">3. DADOS ADICIONAIS</AccordionHeader>
<AccordionContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-book" />
<InputText :modelValue="dash(escolaridade)" class="w-full" variant="filled" readonly />
</IconField>
<label>Escolaridade</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-briefcase" />
<InputText :modelValue="dash(profissao)" class="w-full" variant="filled" readonly />
</IconField>
<label>Profissão</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user" />
<InputText :modelValue="dash(nomeParente)" class="w-full" variant="filled" readonly />
</IconField>
<label>Nome de um parente</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-users" />
<InputText :modelValue="dash(grauParentesco)" class="w-full" variant="filled" readonly />
</IconField>
<label>Grau de parentesco</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputText :modelValue="fmtPhoneMobile(telefoneParente)" class="w-full" variant="filled" readonly />
</IconField>
<label>Telefone do parente</label>
</FloatLabel>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="3">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 3)">4. RESPONSÁVEL</AccordionHeader>
<AccordionContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="md:col-span-2 min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user" />
<InputText :modelValue="dash(nomeResponsavel)" class="w-full" variant="filled" readonly />
</IconField>
<label>Nome do responsável</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-id-card" />
<InputText :modelValue="fmtCPF(cpfResponsavel)" class="w-full" variant="filled" readonly />
</IconField>
<label>CPF do responsável</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputText :modelValue="fmtPhoneMobile(telefoneResponsavel)" class="w-full" variant="filled" readonly />
</IconField>
<label>Telefone do responsável</label>
</FloatLabel>
</div>
<div class="md:col-span-2 min-w-0">
<FloatLabel variant="on">
<Textarea :modelValue="dash(observacaoResponsavel)" rows="3" class="w-full" variant="filled" readonly />
<label>Observação do responsável</label>
</FloatLabel>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="4">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 4)">5. ANOTAÇÕES INTERNAS</AccordionHeader>
<AccordionContent>
<small class="block mb-3 text-slate-500">
Este campo é interno e NÃO aparece no cadastro externo.
</small>
<FloatLabel variant="on">
<Textarea :modelValue="dash(notasInternas)" rows="7" class="w-full" variant="filled" readonly />
<label>Notas internas</label>
</FloatLabel>
</AccordionContent>
</AccordionPanel>
</Accordion>
</main>
</div>
</template>
</Card>
</div>
</div>
</Dialog>
</template>

View File

@@ -0,0 +1,816 @@
<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>
<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>
<div class="flex flex-col lg:flex-row gap-4">
<!-- LEFT: tabela -->
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
<Card class="h-full">
<template #content>
<DataTable
ref="dt"
:value="etiquetas"
dataKey="id"
:loading="carregando"
paginator
:rows="10"
:rowsPerPageOptions="[10, 20, 50]"
stripedRows
responsiveLayout="scroll"
:filters="filtros"
filterDisplay="menu"
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 }">
<Checkbox
:binary="true"
:disabled="!!data.is_padrao"
:modelValue="estaSelecionada(data)"
@update:modelValue="alternarSelecao(data, $event)"
/>
</template>
</Column>
<Column field="nome" header="Tag" sortable style="min-width: 18rem;">
<template #body="{ data }">
<div class="flex items-center gap-2 min-w-0">
<span
class="inline-block rounded-full"
:style="{
width: '10px',
height: '10px',
background: data.cor || '#94a3b8'
}"
/>
<span class="font-medium truncate">{{ data.nome }}</span>
<span v-if="data.is_padrao" class="text-xs text-color-secondary">(padrão)</span>
</div>
</template>
</Column>
<Column header="Pacientes" sortable sortField="pacientes_count" style="width: 10rem;">
<template #body="{ data }">
<Button
class="p-0"
link
:label="String(data.pacientes_count ?? 0)"
:disabled="Number(data.pacientes_count ?? 0) <= 0"
@click="abrirModalPacientesDaTag(data)"
/>
</template>
</Column>
<Column header="Ações" style="width: 10.5rem;">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Button
icon="pi pi-pencil"
severity="secondary"
outlined
size="small"
:disabled="data.is_padrao"
v-tooltip.top="data.is_padrao ? 'Tags padrão não podem ser editadas' : 'Editar'"
@click="abrirEditar(data)"
/>
<Button
icon="pi pi-trash"
severity="danger"
outlined
size="small"
:disabled="data.is_padrao"
v-tooltip.top="data.is_padrao ? 'Tags padrão não podem ser excluídas' : 'Excluir'"
@click="confirmarExclusaoUma(data)"
/>
</div>
</template>
</Column>
<template #empty>
<div class="text-color-secondary py-5">Nenhuma tag encontrada.</div>
</template>
</DataTable>
</template>
</Card>
</div>
<!-- RIGHT: cards -->
<div class="w-full lg:basis-[30%] lg:max-w-[30%]">
<Card class="h-full">
<template #title>Mais usadas</template>
<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>
<div v-else class="flex flex-col gap-3">
<div
v-for="t in cards"
:key="t.id"
class="relative p-4 rounded-xl border border-[var(--surface-border)] transition-all duration-150 hover:-translate-y-1 hover:shadow-[var(--card-shadow)]"
@mouseenter="hovered = t.id"
@mouseleave="hovered = null"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2 min-w-0">
<span
class="inline-block rounded-full"
:style="{
width: '10px',
height: '10px',
background: t.cor || '#94a3b8'
}"
/>
<div class="font-semibold truncate">{{ t.nome }}</div>
<span v-if="t.is_padrao" class="text-xs text-color-secondary">(padrão)</span>
</div>
<div class="text-sm text-color-secondary mt-1">
{{ Number(t.pacientes_count ?? 0) }} paciente(s)
</div>
</div>
<Transition name="fade">
<div v-if="hovered === t.id" class="flex items-center justify-content-center">
<Button
label="Ver pacientes"
icon="pi pi-users"
severity="success"
:disabled="Number(t.pacientes_count ?? 0) <= 0"
@click.stop="abrirModalPacientesDaTag(t)"
/>
</div>
</Transition>
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- DIALOG CREATE / EDIT -->
<Dialog
v-model:visible="dlg.open"
:header="dlg.mode === 'create' ? 'Criar Tag' : 'Editar Tag'"
modal
:style="{ width: '520px', maxWidth: '92vw' }"
>
<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"
/>
<span
class="inline-block rounded-lg"
:style="{
width: '34px',
height: '34px',
border: '1px solid var(--surface-border)',
background: corPreview(dlg.cor)
}"
/>
</div>
<small class="text-color-secondary">
Pode usar HEX (#rrggbb). Se vazio, usamos uma cor neutra.
</small>
</div>
</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>
</Dialog>
<!-- MODAL: pacientes da tag -->
<Dialog
v-model:visible="modalPacientes.open"
:header="modalPacientes.tag ? `Pacientes — ${modalPacientes.tag.nome}` : 'Pacientes'"
modal
:style="{ width: '900px', maxWidth: '96vw' }"
>
<div class="flex flex-col gap-3">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<IconField class="w-full md:w-80">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="modalPacientes.search" placeholder="Buscar paciente..." class="w-full" />
</IconField>
<div class="flex items-center gap-2 justify-end">
<Button icon="pi pi-refresh" severity="secondary" outlined @click="recarregarModalPacientes" />
</div>
</div>
<Message v-if="modalPacientes.error" severity="error">
{{ modalPacientes.error }}
</Message>
<DataTable
:value="modalPacientesFiltrado"
:loading="modalPacientes.loading"
dataKey="id"
paginator
:rows="8"
:rowsPerPageOptions="[8, 15, 30]"
stripedRows
responsiveLayout="scroll"
>
<Column field="name" header="Paciente" sortable>
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="circle" />
<Avatar v-else icon="pi pi-user" shape="circle" />
<div class="flex flex-col min-w-0">
<span class="font-medium truncate">{{ data.name }}</span>
<small class="text-color-secondary truncate">{{ data.email || '—' }}</small>
</div>
</div>
</template>
</Column>
<Column header="Telefone" style="width: 14rem;">
<template #body="{ data }">
<span class="text-color-secondary">{{ fmtPhoneBR(data.phone) }}</span>
</template>
</Column>
<Column header="Ações" style="width: 12rem;">
<template #body="{ data }">
<Button
label="Abrir"
icon="pi pi-external-link"
size="small"
outlined
@click="abrirPaciente(data)"
/>
</template>
</Column>
<template #empty>
<div class="text-color-secondary py-5">Nenhum paciente encontrado.</div>
</template>
</DataTable>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="modalPacientes.open = false" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } 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 { supabase } from '@/lib/supabase/client'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const dt = ref(null)
const carregando = ref(false)
const etiquetas = ref([])
const etiquetasSelecionadas = ref([])
const hovered = ref(null)
const filtros = ref({
global: { value: null, matchMode: 'contains' }
})
const dlg = reactive({
open: false,
mode: 'create', // 'create' | 'edit'
id: '',
nome: '',
cor: '',
saving: false
})
const modalPacientes = reactive({
open: false,
loading: false,
error: '',
tag: null,
items: [],
search: ''
})
const cards = computed(() =>
(etiquetas.value || [])
.filter(t => Number(t.pacientes_count ?? 0) > 0)
.sort((a, b) => Number(b.pacientes_count ?? 0) - Number(a.pacientes_count ?? 0))
)
const modalPacientesFiltrado = computed(() => {
const s = String(modalPacientes.search || '').trim().toLowerCase()
if (!s) return modalPacientes.items || []
return (modalPacientes.items || []).filter(p => {
const name = String(p.name || '').toLowerCase()
const email = String(p.email || '').toLowerCase()
const phone = String(p.phone || '').toLowerCase()
return name.includes(s) || email.includes(s) || phone.includes(s)
})
})
onMounted(() => {
buscarEtiquetas()
})
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const user = data?.user
if (!user) throw new Error('Você precisa estar logado.')
return user.id
}
function normalizarEtiquetaRow(r) {
// Compatível com banco antigo (name/color/is_native/patient_count)
// e com banco pt-BR (nome/cor/is_padrao/pacientes_count)
const nome = r?.nome ?? r?.name ?? ''
const cor = r?.cor ?? r?.color ?? null
const is_padrao = Boolean(r?.is_padrao ?? r?.is_native ?? false)
const pacientes_count = Number(
r?.pacientes_count ?? r?.patient_count ?? r?.patients_count ?? 0
)
return {
...r,
nome,
cor,
is_padrao,
pacientes_count
}
}
function isUniqueViolation(e) {
return e?.code === '23505' || /duplicate key value/i.test(String(e?.message || ''))
}
function friendlyDupMessage(nome) {
return `Já existe uma tag chamada “${nome}”. Tente outro nome.`
}
function corPreview(raw) {
const r = String(raw || '').trim()
if (!r) return '#94a3b8'
const hex = r.replace('#', '')
return `#${hex}`
}
/* -------------------------------
Seleção (bloqueia tags padrão)
-------------------------------- */
function estaSelecionada(row) {
return (etiquetasSelecionadas.value || []).some(s => s.id === row.id)
}
function alternarSelecao(row, checked) {
if (row.is_padrao) return
const sel = etiquetasSelecionadas.value || []
if (checked) {
if (!sel.some(s => s.id === row.id)) etiquetasSelecionadas.value = [...sel, row]
} else {
etiquetasSelecionadas.value = sel.filter(s => s.id !== row.id)
}
}
/* -------------------------------
Fetch tags
-------------------------------- */
async function buscarEtiquetas() {
carregando.value = true
try {
const ownerId = await getOwnerId()
// 1) tenta view (contagem pronta)
const v = await supabase
.from('v_tag_patient_counts')
.select('*')
.eq('owner_id', ownerId)
.order('nome', { ascending: true })
if (!v.error) {
etiquetas.value = (v.data || []).map(normalizarEtiquetaRow)
return
}
// 2) fallback tabela
const t = await supabase
.from('patient_tags')
.select('id, owner_id, nome, cor, is_padrao, name, color, is_native, created_at, updated_at')
.eq('owner_id', ownerId)
.order('nome', { ascending: true })
// se der erro porque ainda não tem 'nome', tenta por 'name'
if (t.error && /column .*nome/i.test(String(t.error.message || ''))) {
const t2 = await supabase
.from('patient_tags')
.select('id, owner_id, name, color, is_native, created_at, updated_at')
.eq('owner_id', ownerId)
.order('name', { ascending: true })
if (t2.error) throw t2.error
etiquetas.value = (t2.data || []).map(r => normalizarEtiquetaRow({ ...r, patient_count: 0 }))
return
}
if (t.error) throw t.error
etiquetas.value = (t.data || []).map(r => normalizarEtiquetaRow({ ...r, pacientes_count: 0 }))
} catch (e) {
console.error('[TagsPacientesPage] buscarEtiquetas error', e)
toast.add({
severity: 'error',
summary: 'Erro ao carregar tags',
detail: e?.message || 'Não consegui carregar as tags. Verifique se as tabelas/views existem no Supabase local.',
life: 6000
})
} finally {
carregando.value = false
}
}
/* -------------------------------
Dialog create/edit
-------------------------------- */
function abrirCriar() {
dlg.open = true
dlg.mode = 'create'
dlg.id = ''
dlg.nome = ''
dlg.cor = ''
}
function abrirEditar(row) {
dlg.open = true
dlg.mode = 'edit'
dlg.id = row.id
dlg.nome = row.nome || ''
dlg.cor = row.cor || ''
}
function fecharDlg() {
dlg.open = false
}
async function salvarDlg() {
const nome = String(dlg.nome || '').trim()
if (!nome) return
dlg.saving = true
try {
const ownerId = await getOwnerId()
// salva sempre "#rrggbb" ou null
const raw = String(dlg.cor || '').trim()
const hex = raw ? raw.replace('#', '') : ''
const cor = hex ? `#${hex}` : null
if (dlg.mode === 'create') {
// tenta pt-BR
let res = await supabase.from('patient_tags').insert({
owner_id: ownerId,
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 {
// update pt-BR
let res = await supabase
.from('patient_tags')
.update({
nome,
cor,
updated_at: new Date().toISOString()
})
.eq('id', dlg.id)
.eq('owner_id', ownerId)
// legado
if (res.error && /column .*nome/i.test(String(res.error.message || ''))) {
res = await supabase
.from('patient_tags')
.update({
name: nome,
color: cor,
updated_at: new Date().toISOString()
})
.eq('id', dlg.id)
.eq('owner_id', ownerId)
}
if (res.error) throw res.error
toast.add({ severity: 'success', summary: 'Tag atualizada', detail: nome, life: 2500 })
}
dlg.open = false
await buscarEtiquetas()
} catch (e) {
console.error('[TagsPacientesPage] salvarDlg error', e)
const nome = String(dlg.nome || '').trim()
if (isUniqueViolation(e)) {
toast.add({
severity: 'warn',
summary: 'Tag já existe',
detail: friendlyDupMessage(nome),
life: 4500
})
return
}
toast.add({
severity: 'error',
summary: 'Não consegui salvar',
detail: e?.message || 'Erro ao salvar a tag.',
life: 6000
})
} finally {
dlg.saving = false
}
}
/* -------------------------------
Delete
-------------------------------- */
function confirmarExclusaoUma(row) {
confirm.require({
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',
rejectLabel: 'Cancelar',
accept: async () => excluirTags([row])
})
}
function confirmarExclusaoSelecionadas() {
const rows = etiquetasSelecionadas.value || []
if (!rows.length) return
const nomes = rows.slice(0, 5).map(r => r.nome).join(', ')
confirm.require({
message:
rows.length <= 5
? `Excluir: ${nomes}? (remove também os vínculos)`
: `Excluir ${rows.length} tags selecionadas? (remove também os vínculos)`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: async () => excluirTags(rows)
})
}
async function excluirTags(rows) {
if (!rows?.length) return
try {
const ownerId = await getOwnerId()
const ids = rows.filter(r => !r.is_padrao).map(r => r.id)
if (!ids.length) {
toast.add({
severity: 'warn',
summary: 'Nada para excluir',
detail: 'Tags padrão não podem ser removidas.',
life: 4000
})
return
}
// 1) apaga pivots
const pivotDel = await supabase
.from('patient_patient_tag')
.delete()
.eq('owner_id', ownerId)
.in('tag_id', ids)
if (pivotDel.error) throw pivotDel.error
// 2) apaga tags
const tagDel = await supabase
.from('patient_tags')
.delete()
.eq('owner_id', ownerId)
.in('id', ids)
if (tagDel.error) throw tagDel.error
etiquetasSelecionadas.value = []
toast.add({ severity: 'success', summary: 'Excluído', detail: `${ids.length} tag(s) removida(s).`, life: 3000 })
await buscarEtiquetas()
} catch (e) {
console.error('[TagsPacientesPage] excluirTags error', e)
toast.add({
severity: 'error',
summary: 'Não consegui excluir',
detail: e?.message || 'Erro ao excluir tags.',
life: 6000
})
}
}
/* -------------------------------
Modal pacientes
-------------------------------- */
async function abrirModalPacientesDaTag(tag) {
modalPacientes.open = true
modalPacientes.tag = tag
modalPacientes.items = []
modalPacientes.search = ''
modalPacientes.error = ''
await carregarPacientesDaTag(tag)
}
async function recarregarModalPacientes() {
if (!modalPacientes.tag) return
await carregarPacientesDaTag(modalPacientes.tag)
}
async function carregarPacientesDaTag(tag) {
modalPacientes.loading = true
modalPacientes.error = ''
try {
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('patient_patient_tag')
.select(`
patient_id,
patients:patients(
id,
nome_completo,
email_principal,
telefone,
avatar_url
)
`)
.eq('owner_id', ownerId)
.eq('tag_id', tag.id)
if (error) throw error
const normalizados = (data || [])
.map(r => r.patients)
.filter(Boolean)
.map(p => ({
id: p.id,
name: p.nome_completo || '—',
email: p.email_principal || '—',
phone: p.telefone || '—',
avatar_url: p.avatar_url || null
}))
.sort((a, b) => String(a.name).localeCompare(String(b.name), 'pt-BR'))
modalPacientes.items = normalizados
} catch (e) {
console.error('[TagsPacientesPage] carregarPacientesDaTag error', e)
modalPacientes.error =
e?.message ||
'Não consegui carregar os pacientes desta tag. Verifique RLS/policies e se as tabelas existem.'
} finally {
modalPacientes.loading = false
}
}
function onlyDigits(v) {
return String(v ?? '').replace(/\D/g, '')
}
function fmtPhoneBR(v) {
const d = onlyDigits(v)
if (!d) return '—'
// opcional: se vier com DDI 55 grudado (ex.: 5511999999999)
if ((d.length === 12 || d.length === 13) && d.startsWith('55')) {
return fmtPhoneBR(d.slice(2))
}
// (11) 9xxxx-xxxx
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7, 11)}`
// (11) xxxx-xxxx
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6, 10)}`
return d
}
function abrirPaciente(patient) {
// no teu router, a rota de edição é /admin/pacientes/cadastro/:id
router.push(`/admin/pacientes/cadastro/${patient.id}`)
}
</script>
<style scoped>
/* Mantido apenas porque Transition name="fade" precisa das classes */
.fade-enter-active,
.fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>

View File

@@ -1,70 +1,453 @@
<script setup> <script setup>
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'; import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
import { ref } from 'vue'; import { useTenantStore } from '@/stores/tenantStore'
const email = ref(''); import { ref, onMounted, computed } from 'vue'
const password = ref(''); import { useRouter } from 'vue-router'
const checked = ref(false); 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'
const tenant = useTenantStore()
const toast = useToast()
const router = useRouter()
const email = ref('')
const password = ref('')
const checked = ref(false)
const loading = ref(false)
const authError = ref('')
// recovery
const openRecovery = ref(false)
const recoveryEmail = ref('')
const loadingRecovery = ref(false)
const recoverySent = ref(false)
const canSubmit = computed(() => {
return !!email.value?.trim() && !!password.value && !loading.value && !loadingRecovery.value
})
function isEmail (v) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v || '').trim())
}
function roleToPath (role) {
if (role === 'tenant_admin') return '/admin'
if (role === 'therapist') return '/therapist'
if (role === 'patient') return '/patient'
return '/'
}
function persistRememberedEmail () {
const mail = String(email.value || '').trim()
try {
if (checked.value && mail) localStorage.setItem('remember_login_email', mail)
else localStorage.removeItem('remember_login_email')
} catch {
// ignora storage bloqueado
}
}
async function onSubmit () {
authError.value = ''
loading.value = true
try {
const mail = String(email.value || '').trim()
const res = await supabase.auth.signInWithPassword({
email: mail,
password: password.value
})
if (res.error) throw res.error
// ✅ 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
}
// ✅ fonte de verdade: tenant_members (rpc my_tenants)
await tenant.loadSessionAndTenant()
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
}
// lembrar e-mail (não senha)
persistRememberedEmail()
const redirect = sessionStorage.getItem('redirect_after_login')
if (redirect) {
sessionStorage.removeItem('redirect_after_login')
router.push(redirect)
return
}
const intended = sessionStorage.getItem('intended_area')
sessionStorage.removeItem('intended_area')
const target = roleToPath(tenant.activeRole)
if (intended && intended !== tenant.activeRole) {
router.push(target)
return
}
router.push(target)
} catch (e) {
authError.value = e?.message || 'Não foi possível entrar.'
} finally {
loading.value = false
}
}
function openForgot () {
recoverySent.value = false
recoveryEmail.value = email.value?.trim() || ''
openRecovery.value = true
}
async function sendRecoveryEmail () {
const mail = String(recoveryEmail.value || '').trim()
if (!mail || !isEmail(mail)) {
toast.add({ severity: 'warn', summary: 'E-mail', detail: 'Digite um e-mail válido.', life: 3000 })
return
}
loadingRecovery.value = true
recoverySent.value = false
try {
const redirectTo = `${window.location.origin}/auth/reset-password`
const { error } = await supabase.auth.resetPasswordForEmail(mail, { redirectTo })
if (error) throw error
recoverySent.value = true
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao enviar e-mail.', life: 4500 })
} finally {
loadingRecovery.value = false
}
}
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') || ''
} catch {}
if (preEmail) email.value = preEmail
else if (remembered) email.value = remembered
if (prePass) password.value = prePass
checked.value = !!remembered
sessionStorage.removeItem('login_prefill_email')
sessionStorage.removeItem('login_prefill_password')
})
</script> </script>
<template> <template>
<FloatingConfigurator /> <FloatingConfigurator />
<div class="bg-surface-50 dark:bg-surface-950 flex items-center justify-center min-h-screen min-w-[100vw] overflow-hidden">
<div class="flex flex-col items-center justify-center">
<div style="border-radius: 56px; padding: 0.3rem; background: linear-gradient(180deg, var(--primary-color) 10%, rgba(33, 150, 243, 0) 30%)">
<div class="w-full bg-surface-0 dark:bg-surface-900 py-20 px-8 sm:px-20" style="border-radius: 53px">
<div class="text-center mb-8">
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="mb-8 w-16 shrink-0 mx-auto">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.1637 19.2467C17.1566 19.4033 17.1529 19.561 17.1529 19.7194C17.1529 25.3503 21.7203 29.915 27.3546 29.915C32.9887 29.915 37.5561 25.3503 37.5561 19.7194C37.5561 19.5572 37.5524 19.3959 37.5449 19.2355C38.5617 19.0801 39.5759 18.9013 40.5867 18.6994L40.6926 18.6782C40.7191 19.0218 40.7326 19.369 40.7326 19.7194C40.7326 27.1036 34.743 33.0896 27.3546 33.0896C19.966 33.0896 13.9765 27.1036 13.9765 19.7194C13.9765 19.374 13.9896 19.0316 14.0154 18.6927L14.0486 18.6994C15.0837 18.9062 16.1223 19.0886 17.1637 19.2467ZM33.3284 11.4538C31.6493 10.2396 29.5855 9.52381 27.3546 9.52381C25.1195 9.52381 23.0524 10.2421 21.3717 11.4603C20.0078 11.3232 18.6475 11.1387 17.2933 10.907C19.7453 8.11308 23.3438 6.34921 27.3546 6.34921C31.36 6.34921 34.9543 8.10844 37.4061 10.896C36.0521 11.1292 34.692 11.3152 33.3284 11.4538ZM43.826 18.0518C43.881 18.6003 43.9091 19.1566 43.9091 19.7194C43.9091 28.8568 36.4973 36.2642 27.3546 36.2642C18.2117 36.2642 10.8 28.8568 10.8 19.7194C10.8 19.1615 10.8276 18.61 10.8816 18.0663L7.75383 17.4411C7.66775 18.1886 7.62354 18.9488 7.62354 19.7194C7.62354 30.6102 16.4574 39.4388 27.3546 39.4388C38.2517 39.4388 47.0855 30.6102 47.0855 19.7194C47.0855 18.9439 47.0407 18.1789 46.9536 17.4267L43.826 18.0518ZM44.2613 9.54743L40.9084 10.2176C37.9134 5.95821 32.9593 3.1746 27.3546 3.1746C21.7442 3.1746 16.7856 5.96385 13.7915 10.2305L10.4399 9.56057C13.892 3.83178 20.1756 0 27.3546 0C34.5281 0 40.8075 3.82591 44.2613 9.54743Z"
fill="var(--primary-color)"
/>
<mask id="mask0_1413_1551" style="mask-type: alpha" maskUnits="userSpaceOnUse" x="0" y="8" width="54" height="11">
<path d="M27 18.3652C10.5114 19.1944 0 8.88892 0 8.88892C0 8.88892 16.5176 14.5866 27 14.5866C37.4824 14.5866 54 8.88892 54 8.88892C54 8.88892 43.4886 17.5361 27 18.3652Z" fill="var(--primary-color)" />
</mask>
<g mask="url(#mask0_1413_1551)">
<path
d="M-4.673e-05 8.88887L3.73084 -1.91434L-8.00806 17.0473L-4.673e-05 8.88887ZM27 18.3652L26.4253 6.95109L27 18.3652ZM54 8.88887L61.2673 17.7127L50.2691 -1.91434L54 8.88887ZM-4.673e-05 8.88887C-8.00806 17.0473 -8.00469 17.0505 -8.00132 17.0538C-8.00018 17.055 -7.99675 17.0583 -7.9944 17.0607C-7.98963 17.0653 -7.98474 17.0701 -7.97966 17.075C-7.96949 17.0849 -7.95863 17.0955 -7.94707 17.1066C-7.92401 17.129 -7.89809 17.1539 -7.86944 17.1812C-7.8122 17.236 -7.74377 17.3005 -7.66436 17.3743C-7.50567 17.5218 -7.30269 17.7063 -7.05645 17.9221C-6.56467 18.3532 -5.89662 18.9125 -5.06089 19.5534C-3.39603 20.83 -1.02575 22.4605 1.98012 24.0457C7.97874 27.2091 16.7723 30.3226 27.5746 29.7793L26.4253 6.95109C20.7391 7.23699 16.0326 5.61231 12.6534 3.83024C10.9703 2.94267 9.68222 2.04866 8.86091 1.41888C8.45356 1.10653 8.17155 0.867278 8.0241 0.738027C7.95072 0.673671 7.91178 0.637576 7.90841 0.634492C7.90682 0.63298 7.91419 0.639805 7.93071 0.65557C7.93897 0.663455 7.94952 0.673589 7.96235 0.686039C7.96883 0.692262 7.97582 0.699075 7.98338 0.706471C7.98719 0.710167 7.99113 0.714014 7.99526 0.718014C7.99729 0.720008 8.00047 0.723119 8.00148 0.724116C8.00466 0.727265 8.00796 0.730446 -4.673e-05 8.88887ZM27.5746 29.7793C37.6904 29.2706 45.9416 26.3684 51.6602 23.6054C54.5296 22.2191 56.8064 20.8465 58.4186 19.7784C59.2265 19.2431 59.873 18.7805 60.3494 18.4257C60.5878 18.2482 60.7841 18.0971 60.9374 17.977C61.014 17.9169 61.0799 17.8645 61.1349 17.8203C61.1624 17.7981 61.1872 17.7781 61.2093 17.7602C61.2203 17.7512 61.2307 17.7427 61.2403 17.7348C61.2452 17.7308 61.2499 17.727 61.2544 17.7233C61.2566 17.7215 61.2598 17.7188 61.261 17.7179C61.2642 17.7153 61.2673 17.7127 54 8.88887C46.7326 0.0650536 46.7357 0.0625219 46.7387 0.0600241C46.7397 0.0592345 46.7427 0.0567658 46.7446 0.0551857C46.7485 0.0520238 46.7521 0.0489887 46.7557 0.0460799C46.7628 0.0402623 46.7694 0.0349487 46.7753 0.0301318C46.7871 0.0204986 46.7966 0.0128495 46.8037 0.00712562C46.818 -0.00431848 46.8228 -0.00808311 46.8184 -0.00463784C46.8096 0.00228345 46.764 0.0378652 46.6828 0.0983779C46.5199 0.219675 46.2165 0.439161 45.7812 0.727519C44.9072 1.30663 43.5257 2.14765 41.7061 3.02677C38.0469 4.79468 32.7981 6.63058 26.4253 6.95109L27.5746 29.7793ZM54 8.88887C50.2691 -1.91433 50.27 -1.91467 50.271 -1.91498C50.2712 -1.91506 50.272 -1.91535 50.2724 -1.9155C50.2733 -1.91581 50.274 -1.91602 50.2743 -1.91616C50.2752 -1.91643 50.275 -1.91636 50.2738 -1.91595C50.2714 -1.91515 50.2652 -1.91302 50.2552 -1.9096C50.2351 -1.90276 50.1999 -1.89078 50.1503 -1.874C50.0509 -1.84043 49.8938 -1.78773 49.6844 -1.71863C49.2652 -1.58031 48.6387 -1.377 47.8481 -1.13035C46.2609 -0.635237 44.0427 0.0249875 41.5325 0.6823C36.215 2.07471 30.6736 3.15796 27 3.15796V26.0151C33.8087 26.0151 41.7672 24.2495 47.3292 22.7931C50.2586 22.026 52.825 21.2618 54.6625 20.6886C55.5842 20.4011 56.33 20.1593 56.8551 19.986C57.1178 19.8993 57.3258 19.8296 57.4735 19.7797C57.5474 19.7548 57.6062 19.7348 57.6493 19.72C57.6709 19.7127 57.6885 19.7066 57.7021 19.7019C57.7089 19.6996 57.7147 19.6976 57.7195 19.696C57.7219 19.6952 57.7241 19.6944 57.726 19.6938C57.7269 19.6934 57.7281 19.693 57.7286 19.6929C57.7298 19.6924 57.7309 19.692 54 8.88887ZM27 3.15796C23.3263 3.15796 17.7849 2.07471 12.4674 0.6823C9.95717 0.0249875 7.73904 -0.635237 6.15184 -1.13035C5.36118 -1.377 4.73467 -1.58031 4.3155 -1.71863C4.10609 -1.78773 3.94899 -1.84043 3.84961 -1.874C3.79994 -1.89078 3.76474 -1.90276 3.74471 -1.9096C3.73469 -1.91302 3.72848 -1.91515 3.72613 -1.91595C3.72496 -1.91636 3.72476 -1.91643 3.72554 -1.91616C3.72593 -1.91602 3.72657 -1.91581 3.72745 -1.9155C3.72789 -1.91535 3.72874 -1.91506 3.72896 -1.91498C3.72987 -1.91467 3.73084 -1.91433 -4.673e-05 8.88887C-3.73093 19.692 -3.72983 19.6924 -3.72868 19.6929C-3.72821 19.693 -3.72698 19.6934 -3.72603 19.6938C-3.72415 19.6944 -3.72201 19.6952 -3.71961 19.696C-3.71482 19.6976 -3.70901 19.6996 -3.7022 19.7019C-3.68858 19.7066 -3.67095 19.7127 -3.6494 19.72C-3.60629 19.7348 -3.54745 19.7548 -3.47359 19.7797C-3.32589 19.8296 -3.11788 19.8993 -2.85516 19.986C-2.33008 20.1593 -1.58425 20.4011 -0.662589 20.6886C1.17485 21.2618 3.74125 22.026 6.67073 22.7931C12.2327 24.2495 20.1913 26.0151 27 26.0151V3.15796Z"
fill="var(--primary-color)"
/>
</g>
</svg>
<div class="text-surface-900 dark:text-surface-0 text-3xl font-medium mb-4">Welcome to PrimeLand!</div>
<span class="text-muted-color font-medium">Sign in to continue</span>
</div>
<div> <div class="relative min-h-screen w-full overflow-hidden bg-[var(--surface-ground)]">
<label for="email1" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-2">Email</label> <!-- fundo conceitual -->
<InputText id="email1" type="text" placeholder="Email address" class="w-full md:w-[30rem] mb-8" v-model="email" /> <div class="pointer-events-none absolute inset-0">
<!-- grid muito sutil -->
<label for="password1" class="block text-surface-900 dark:text-surface-0 font-medium text-xl mb-2">Password</label> <div
<Password id="password1" v-model="password" placeholder="Password" :toggleMask="true" class="mb-4" fluid :feedback="false"></Password> class="absolute inset-0 opacity-70"
style="
<div class="flex items-center justify-between mt-2 mb-8 gap-8"> background-image:
<div class="flex items-center"> linear-gradient(to right, rgba(255,255,255,.05) 1px, transparent 1px),
<Checkbox v-model="checked" id="rememberme1" binary class="mr-2"></Checkbox> linear-gradient(to bottom, rgba(255,255,255,.05) 1px, transparent 1px);
<label for="rememberme1">Remember me</label> background-size: 38px 38px;
</div> mask-image: radial-gradient(ellipse at 50% 20%, rgba(0,0,0,.95), transparent 70%);
<span class="font-medium no-underline ml-2 text-right cursor-pointer text-primary">Forgot password?</span> "
</div> />
<Button label="Sign In" class="w-full" as="router-link" to="/"></Button> <!-- halos -->
</div> <div class="absolute -top-28 -right-28 h-[26rem] w-[26rem] rounded-full blur-3xl bg-indigo-400/10" />
</div> <div class="absolute top-20 -left-28 h-[30rem] w-[30rem] rounded-full blur-3xl bg-emerald-400/10" />
</div> <div class="absolute -bottom-32 right-24 h-[26rem] w-[26rem] rounded-full blur-3xl bg-fuchsia-400/10" />
</div>
</div> </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>
<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>
<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>
</div>
</div>
</div>
</div>
</div>
</template> </template>
<style scoped>
.pi-eye {
transform: scale(1.6);
margin-right: 1rem;
}
.pi-eye-slash {
transform: scale(1.6);
margin-right: 1rem;
}
</style>

View File

@@ -0,0 +1,253 @@
<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 { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
const toast = useToast()
const router = useRouter()
const newPassword = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const bannerText = ref('')
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)
onMounted(async () => {
try {
// 1) força leitura da sessão (supabase-js já captura hash automaticamente)
const { data } = await supabase.auth.getSession()
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.'
}
})
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 })
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 {
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')
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro',
detail: e?.message || 'Não foi possível redefinir a senha.',
life: 4500
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,374 @@
<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 Password from 'primevue/password'
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)
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)
function clearFields () {
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
}
async function hardLogout () {
// 1) tenta logout normal (se falhar, seguimos)
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 = []
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)
}
keysToRemove.forEach((k) => localStorage.removeItem(k))
} catch (e) {
console.warn('[storage cleanup failed]', e)
}
// 4) remove redirect pendente
try {
sessionStorage.removeItem('redirect_after_login')
} catch {}
// 5) redireciona de forma "hard"
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 { 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()
} catch (e) {
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
}
loadingReset.value = true
try {
const redirectTo = `${window.location.origin}/auth/reset-password`
const { error } = await supabase.auth.resetPasswordForEmail(user.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
})
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao enviar e-mail.', life: 4500 })
} finally {
loadingReset.value = false
}
}
</script>

View File

@@ -0,0 +1,274 @@
<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'
const route = useRoute()
const router = useRouter()
// ============================
// Query
// ============================
const planFromQuery = computed(() => String(route.query.plan || '').trim().toLowerCase())
const intervalFromQuery = computed(() => String(route.query.interval || '').trim().toLowerCase())
function normalizeInterval(v) {
if (v === 'monthly') return 'month'
if (v === 'annual' || v === 'yearly') return 'year'
return v
}
const intervalNormalized = computed(() => normalizeInterval(intervalFromQuery.value))
const intervalLabel = computed(() => {
if (intervalNormalized.value === 'year') return 'Anual'
if (intervalNormalized.value === 'month') return 'Mensal'
return ''
})
// ============================
// Pricing
// ============================
const loading = ref(false)
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
})
const currency = computed(() => {
if (!planRow.value) return 'BRL'
return intervalNormalized.value === 'year'
? (planRow.value.yearly_currency || 'BRL')
: (planRow.value.monthly_currency || 'BRL')
})
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)
})
async function loadPlan() {
planRow.value = null
if (!planFromQuery.value) return
loading.value = true
try {
const { data, error } = await supabase
.from('v_public_pricing')
.select(`
plan_key,
plan_name,
public_name,
public_description,
badge,
is_featured,
monthly_cents,
yearly_cents,
monthly_currency,
yearly_currency,
is_visible
`)
.eq('plan_key', planFromQuery.value)
.eq('is_visible', true)
.maybeSingle()
if (error) throw error
if (data) planRow.value = data
} catch (err) {
console.error('[Welcome] loadPlan:', err)
} finally {
loading.value = false
}
}
onMounted(loadPlan)
watch(() => planFromQuery.value, () => loadPlan())
function goLogin() {
router.push('/auth/login')
}
function goBackLanding() {
router.push('/lp')
}
</script>
<template>
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)] flex items-center justify-center p-4">
<!-- fundo suave (noir glow) -->
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<div class="absolute -top-28 -left-28 h-96 w-96 rounded-full blur-3xl opacity-60 bg-indigo-400/10" />
<div class="absolute top-24 -right-24 h-[28rem] w-[28rem] rounded-full blur-3xl opacity-60 bg-emerald-400/10" />
<div class="absolute -bottom-40 left-1/3 h-[34rem] w-[34rem] rounded-full blur-3xl opacity-60 bg-fuchsia-400/10" />
</div>
<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) -->
<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)]"
>
<div class="flex items-center gap-3">
<div
class="h-11 w-11 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i class="pi pi-sparkles opacity-80 text-lg" />
</div>
<div class="min-w-0">
<div class="font-semibold leading-tight truncate">Psi Quasar</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">Gestão clínica sem ruído.</div>
</div>
</div>
<Divider class="my-6" />
<div class="text-3xl md:text-4xl font-semibold leading-tight">
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.
Agora o caminho é simples: instruções de pagamento confirmação ativação do plano.
</div>
<div class="mt-6 grid grid-cols-12 gap-3">
<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="text-xs text-[var(--text-color-secondary)]">1) Pagamento</div>
<div class="text-xl font-semibold mt-1">Manual</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">PIX ou boleto</div>
</div>
</div>
<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="text-xs text-[var(--text-color-secondary)]">2) Confirmação</div>
<div class="text-xl font-semibold mt-1">Rápida</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">verificação e liberação</div>
</div>
</div>
<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-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>
<i class="pi pi-verified opacity-60" />
</div>
</div>
</div>
</div>
<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" />
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
* Página de boas-vindas inspirada em layouts PrimeBlocks.
</div>
</div>
<!-- RIGHT: resumo + botões -->
<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">
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.
</Message>
<div v-if="loading" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
<ProgressSpinner style="width: 18px; height: 18px" strokeWidth="4" />
Carregando detalhes do plano
</div>
<Card v-else class="overflow-hidden">
<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="mt-1 flex items-center gap-2 flex-wrap">
<div class="text-lg font-semibold truncate">
{{ planName || 'Plano' }}
</div>
<Tag v-if="planRow?.is_featured" severity="success" value="Popular" />
<Tag v-if="planRow?.badge" severity="secondary" :value="planRow.badge" />
<Chip v-if="intervalLabel" :label="intervalLabel" />
</div>
<div 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)]">
{{ planDescription }}
</div>
<Message v-if="planFromQuery && !planRow" severity="warn" class="mt-3">
Não encontrei esse plano na vitrine pública. Você pode continuar normalmente.
</Message>
</div>
</div>
<Divider class="my-4" />
<Message severity="info" class="mb-0">
Próximo passo: você receberá instruções de pagamento (PIX ou boleto).
Assim que confirmado, sua assinatura será ativada.
</Message>
</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
label="Voltar para a página inicial"
severity="secondary"
outlined
class="w-full"
icon="pi pi-arrow-left"
@click="goBackLanding"
/>
</div>
<div class="text-center text-xs text-[var(--text-color-secondary)] mt-4">
Psi Quasar gestão clínica sem ruído.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,396 @@
<!-- src/views/pages/upgrade/UpgradePage.vue (ajuste o caminho conforme seu projeto) -->
<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'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
const toast = useToast()
const route = useRoute()
const router = useRouter()
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
// 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'
}
const requestedFeatureLabel = computed(() => {
if (!requestedFeature.value) return null
return featureLabels[requestedFeature.value] || requestedFeature.value
})
// estado
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 subscription = ref(null) // subscription ativa do tenant
// ✅ Modelo B: plano é do TENANT
const tenantId = computed(() => tenantStore.activeTenantId || null)
const planById = computed(() => {
const m = new Map()
for (const p of plans.value) m.set(p.id, p)
return m
})
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()
set.add(row.feature_id)
m.set(row.plan_id, set)
}
return m
})
const currentPlanId = computed(() => subscription.value?.plan_id || null)
function planKeyById(id) {
return planById.value.get(id)?.key || null
}
const currentPlanKey = computed(() => planKeyById(currentPlanId.value))
function friendlyFeatureLabel(featureKey) {
return featureLabels[featureKey] || featureKey
}
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))
})
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
}
function goBack() {
router.back()
}
function goBilling() {
router.push('/admin/billing')
}
function contactSupport() {
router.push('/admin/billing')
}
async function fetchAll() {
loading.value = true
try {
const tid = tenantId.value
if (!tid) throw new Error('Tenant ativo não encontrado.')
const [pRes, fRes, pfRes, sRes] = await Promise.all([
supabase.from('plans').select('*').order('key', { ascending: true }),
supabase.from('features').select('*').order('key', { ascending: true }),
supabase.from('plan_features').select('plan_id, feature_id'),
supabase
.from('subscriptions')
.select('id, tenant_id, plan_id, plan_key, interval, status, created_at, updated_at')
.eq('tenant_id', tid)
.eq('status', 'active')
.order('updated_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] sem subscription ativa (ok):', sRes.error)
}
plans.value = pRes.data || []
features.value = fRes.data || []
planFeatures.value = pfRes.data || []
subscription.value = sRes.data || null
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
async function changePlan(targetPlanId) {
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
})
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.')
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
// ✅ recarrega entitlements (sem reload)
entitlementsStore.clear?.()
await entitlementsStore.fetch(tid, { force: true })
toast.add({
severity: 'success',
summary: 'Plano atualizado',
detail: `Agora você está no plano ${planKeyById(subscription.value.plan_id) || ''}`.trim(),
life: 3000
})
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
upgrading.value = false
}
}
onMounted(fetchAll)
// se trocar tenant ativo, recarrega
watch(
() => tenantId.value,
() => {
if (tenantId.value) 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)]">
<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" />
<div class="absolute -bottom-20 right-32 h-80 w-80 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative flex flex-col gap-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<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>
<span class="mx-2 opacity-50"></span>
Você está no plano:
<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>
</div>
<!-- BLOCO: 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>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">
Esse recurso depende do plano que inclui a 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>
</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.
</div>
</div>
</div>
</div>
<!-- PLANOS (DINÂMICOS) -->
<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'
? 'relative overflow-hidden rounded-[1.75rem] border border-primary/40 bg-[var(--surface-card)]'
: ''"
>
<div v-if="p.key === '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'">
<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'" />
<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" />
</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>
</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>
</li>
</ul>
<Divider class="my-4" />
<div class="flex flex-col gap-3">
<Button
v-if="currentPlanId !== p.id"
:label="`Mudar para ${String(p.key || '').toUpperCase()}`"
icon="pi pi-arrow-up"
size="large"
class="w-full"
:loading="upgrading"
:disabled="upgrading || loading"
@click="changePlan(p.id)"
/>
<Button
v-else
label="Você já está neste plano"
icon="pi pi-check"
severity="secondary"
outlined
class="w-full"
disabled
/>
<Button
v-if="p.key !== 'free'"
label="Falar com suporte"
icon="pi pi-comments"
severity="secondary"
outlined
class="w-full"
@click="contactSupport"
/>
<div class="text-center text-xs text-[var(--text-color-secondary)]">
Cancele quando quiser. Sem burocracia.
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</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).
</div>
</div>
</template>

View File

@@ -0,0 +1,954 @@
<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 items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-2xl md:text-3xl font-semibold leading-tight">Meu Perfil</div>
<div class="mt-1 text-sm md:text-base text-[var(--text-color-secondary)]">
Gerencie suas informações, preferências e segurança da conta.
</div>
</div>
<div class="flex items-center gap-2">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined @click="router.back()" />
<Button label="Salvar" icon="pi pi-check" :loading="saving" :disabled="!dirty" @click="saveAll" />
</div>
</div>
</div>
</div>
<!-- Layout -->
<div class="grid grid-cols-12 gap-4 md:gap-6">
<!-- Sidebar -->
<div class="col-span-12 lg:col-span-4 xl:col-span-3">
<div class="sticky top-4">
<Card class="overflow-hidden">
<template #content>
<div class="flex items-center gap-3">
<div class="relative">
<img
v-if="ui.avatarPreview"
:src="ui.avatarPreview"
class="h-12 w-12 rounded-2xl object-cover border border-[var(--surface-border)]"
alt="avatar"
/>
<div
v-else
class="h-12 w-12 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center font-semibold"
>
{{ initials }}
</div>
<span class="absolute -bottom-1 -right-1 h-4 w-4 rounded-full border border-[var(--surface-card)] bg-emerald-400/80" />
</div>
<div class="min-w-0">
<div class="font-semibold truncate">{{ form.full_name || userEmail || 'Conta' }}</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">
{{ userEmail || '—' }}
</div>
</div>
</div>
<div class="mt-4 space-y-1">
<button
v-for="s in sections"
:key="s.id"
type="button"
class="w-full text-left px-3 py-2 rounded-xl border border-transparent hover:border-[var(--surface-border)] hover:bg-[var(--surface-ground)] transition flex items-center gap-2"
:class="activeSection === s.id ? 'bg-[var(--surface-ground)] border-[var(--surface-border)]' : ''"
@click="scrollTo(s.id)"
>
<i :class="s.icon" class="text-sm opacity-80" />
<span class="text-sm font-medium">{{ s.label }}</span>
</button>
</div>
<Divider class="my-4" />
<div class="flex flex-col gap-2">
<Button label="Trocar senha" icon="pi pi-key" severity="secondary" outlined @click="openPasswordDialog" />
<Button label="Sair" icon="pi pi-sign-out" severity="danger" outlined @click="confirmSignOut" />
</div>
<div class="mt-4 text-xs text-[var(--text-color-secondary)]">
Dica: alterações em <b>Nome</b> e <b>Avatar</b> atualizam sua identidade no app.
</div>
</template>
</Card>
</div>
</div>
<!-- Content -->
<div class="col-span-12 lg:col-span-8 xl:col-span-9 space-y-4 md:space-y-6">
<!-- Conta -->
<Card id="conta" class="scroll-mt-24">
<template #title>Conta</template>
<template #subtitle>Informações básicas e identidade.</template>
<template #content>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-7">
<label class="block text-sm font-semibold mb-2">Nome</label>
<InputText v-model="form.full_name" class="w-full" placeholder="Seu nome" @input="markDirty" />
<small class="text-[var(--text-color-secondary)]">Aparece no menu, cabeçalhos e registros.</small>
</div>
<div class="col-span-12 md:col-span-5">
<label class="block text-sm font-semibold mb-2">E-mail</label>
<InputText :modelValue="userEmail" class="w-full" disabled />
<small class="text-[var(--text-color-secondary)]">E-mail vem do login (Supabase Auth).</small>
</div>
<div class="col-span-12 md:col-span-7">
<label class="block text-sm font-semibold mb-2">Bio</label>
<Textarea
v-model="form.bio"
class="w-full"
rows="5"
maxlength="2000"
placeholder="Uma breve descrição sobre você…"
@input="markDirty"
/>
<div class="mt-1 flex items-center justify-between text-xs text-[var(--text-color-secondary)]">
<span>Máximo de 2000 caracteres.</span>
<span>{{ (form.bio || '').length }}/2000</span>
</div>
</div>
<div class="col-span-12 md:col-span-5">
<label class="block text-sm font-semibold mb-2">Telefone</label>
<InputMask
v-model="form.phone"
class="w-full"
mask="(99) 99999-9999"
:autoClear="false"
placeholder="(11) 99999-9999"
@update:modelValue="markDirty"
/>
<small class="text-[var(--text-color-secondary)]">Opcional.</small>
</div>
<div class="col-span-12">
<Divider />
<div class="grid grid-cols-12 gap-4 items-start">
<!-- Upload -->
<div class="col-span-12 md:col-span-7">
<label class="block text-sm font-semibold mb-2">Enviar avatar (arquivo)</label>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3">
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
<input
ref="fileInput"
type="file"
accept="image/*"
class="block w-full text-sm"
@change="onAvatarFileSelected"
/>
<div class="flex items-center gap-2">
<Button
label="Limpar"
icon="pi pi-times"
severity="secondary"
outlined
size="small"
:disabled="!ui.avatarFile"
@click="clearAvatarFile"
/>
<Button
label="Usar preview"
icon="pi pi-image"
severity="secondary"
outlined
size="small"
:disabled="!ui.avatarFile"
@click="applyFilePreviewOnly"
/>
</div>
</div>
<div class="mt-2 text-xs text-[var(--text-color-secondary)]">
Ao salvar, tentamos subir no Storage (<b>{{ AVATAR_BUCKET }}</b>) e atualizamos o Avatar URL automaticamente.
</div>
</div>
</div>
<!-- Avatar URL -->
<div class="col-span-12 md:col-span-5">
<label class="block text-sm font-semibold mb-2">Avatar (URL)</label>
<InputText v-model="form.avatar_url" class="w-full" placeholder="https://…" @input="onAvatarUrlChange" />
<small class="text-[var(--text-color-secondary)]">
Cole uma URL de imagem (PNG/JPG). Se vazio, usamos iniciais.
</small>
</div>
<!-- Preview -->
<div class="col-span-12">
<div class="mt-1 flex items-center gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3">
<img
v-if="ui.avatarPreview"
:src="ui.avatarPreview"
class="h-12 w-12 rounded-2xl object-cover border border-[var(--surface-border)]"
alt="preview"
/>
<div
v-else
class="h-12 w-12 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center font-semibold"
>
{{ initials }}
</div>
<div class="min-w-0">
<div class="font-medium truncate">{{ form.full_name || '—' }}</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">
Preview atual (será aplicado ao salvar)
</div>
</div>
<div class="ml-auto flex items-center gap-2">
<Button
label="Remover avatar"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
:disabled="!form.avatar_url && !ui.avatarFile && !ui.avatarPreview"
@click="removeAvatar"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</Card>
<!-- Aparência / Layout -->
<Card id="layout" class="scroll-mt-24">
<template #title>Aparência</template>
<template #subtitle>Tema, cores e modo do menu.</template>
<template #content>
<div class="grid grid-cols-12 gap-4">
<!-- Row 1 -->
<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-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Primary</div>
<div class="text-xs text-[var(--text-color-secondary)]">Cor principal do tema.</div>
</div>
<i class="pi pi-palette opacity-70" />
</div>
<div class="pt-3 flex gap-2 flex-wrap">
<button
v-for="primaryColor of primaryColors"
:key="primaryColor.name"
type="button"
:title="primaryColor.name"
@click="updateColors('primary', primaryColor)"
:class="[
'border-none w-6 h-6 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
{ 'outline-primary': layoutConfig.primary === primaryColor.name }
]"
:style="{ backgroundColor: `${primaryColor.name === 'noir' ? 'var(--text-color)' : primaryColor.palette['500']}` }"
></button>
</div>
</div>
</div>
<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-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Surface</div>
<div class="text-xs text-[var(--text-color-secondary)]">Base de fundo/superfícies.</div>
</div>
<i class="pi pi-circle-fill opacity-70" />
</div>
<div class="pt-3 flex gap-2 flex-wrap">
<button
v-for="surface of surfaces"
:key="surface.name"
type="button"
:title="surface.name"
@click="updateColors('surface', surface)"
:class="[
'border-none w-6 h-6 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
{
'outline-primary': layoutConfig.surface
? layoutConfig.surface === surface.name
: (isDarkNow() ? surface.name === 'zinc' : surface.name === 'slate')
}
]"
:style="{ backgroundColor: `${surface.palette['500']}` }"
></button>
</div>
</div>
</div>
<!-- Row 2 -->
<div class="col-span-12 md:col-span-4">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 h-full">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Presets</div>
<div class="text-xs text-[var(--text-color-secondary)]">Aura / Lara / Nora.</div>
</div>
<i class="pi pi-sparkles opacity-70" />
</div>
<div class="pt-3">
<SelectButton v-model="presetModel" :options="presetOptions" :allowEmpty="false" />
</div>
</div>
</div>
<div class="col-span-12 md:col-span-4">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 h-full">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Menu Mode</div>
<div class="text-xs text-[var(--text-color-secondary)]">Static ou Overlay.</div>
</div>
<i class="pi pi-bars opacity-70" />
</div>
<div class="pt-3">
<SelectButton
v-model="menuModeModel"
:options="menuModeOptions"
:allowEmpty="false"
optionLabel="label"
optionValue="value"
/>
</div>
</div>
</div>
<div class="col-span-12 md:col-span-4">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 h-full">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Tema</div>
<div class="text-xs text-[var(--text-color-secondary)]">Alternar claro/escuro.</div>
</div>
<i class="pi pi-moon opacity-70" />
</div>
<div class="pt-3">
<SelectButton
v-model="themeModeModel"
:options="themeModeOptions"
:allowEmpty="false"
optionLabel="label"
optionValue="value"
/>
</div>
</div>
</div>
<div class="col-span-12 text-xs text-[var(--text-color-secondary)]">
Essas opções aplicam o tema imediatamente (igual ao configurator).
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- Dialog: Trocar senha -->
<Dialog
v-model:visible="openPassword"
modal
header="Trocar senha"
:draggable="false"
:style="{ width: '28rem', maxWidth: '92vw' }"
>
<div class="space-y-3">
<div class="text-sm text-[var(--text-color-secondary)]">
Vamos enviar um link de redefinição para seu e-mail.
</div>
<div class="space-y-2">
<label class="text-sm font-semibold">E-mail</label>
<InputText :modelValue="userEmail" class="w-full" disabled />
</div>
<div
v-if="passwordSent"
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.
</div>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="openPassword = false" />
<Button label="Enviar link" icon="pi pi-envelope" :loading="sendingPassword" @click="sendPasswordReset" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Toast from 'primevue/toast'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Dialog from 'primevue/dialog'
import ConfirmDialog from 'primevue/confirmdialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import InputMask from 'primevue/inputmask'
import SelectButton from 'primevue/selectbutton'
import { supabase } from '@/lib/supabase/client'
import { useLayout } from '@/layout/composables/layout'
import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes'
import {
presetsMap,
presetOptions,
primaryColors,
surfaces,
getPresetExt,
getSurfacePalette
} from '@/theme/theme.options'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
/** trava para não marcar dirty durante o load */
const silentApplying = ref(true)
/** Storage bucket do avatar */
const AVATAR_BUCKET = 'avatars'
/* ----------------------------
Estado geral
----------------------------- */
const saving = ref(false)
const dirty = ref(false)
const openPassword = ref(false)
const sendingPassword = ref(false)
const passwordSent = ref(false)
const userEmail = ref('')
const userId = ref('')
const fileInput = ref(null)
const ui = reactive({
avatarPreview: '',
avatarFile: null,
avatarFilePreviewUrl: ''
})
// Perfil (MVP)
const form = reactive({
full_name: '',
avatar_url: '',
bio: '',
phone: '',
language: 'pt-BR',
timezone: 'America/Sao_Paulo',
notify_system_email: true,
notify_reminders: true,
notify_news: false
})
const sections = [
{ id: 'conta', label: 'Conta', icon: 'pi pi-user' },
{ id: 'layout', label: 'Aparência', icon: 'pi pi-palette' },
{ id: 'preferencias', label: 'Preferências', icon: 'pi pi-sliders-h' },
{ id: 'seguranca', label: 'Segurança', icon: 'pi pi-shield' }
]
const activeSection = ref('conta')
const initials = computed(() => {
const name = form.full_name || userEmail.value || ''
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()
})
function markDirty () {
dirty.value = true
}
/* ----------------------------
Navegação (sidebar)
----------------------------- */
function scrollTo (id) {
activeSection.value = id
const el = document.getElementById(id)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function observeSections () {
const ids = sections.map(s => s.id)
const els = ids.map(id => document.getElementById(id)).filter(Boolean)
const io = new IntersectionObserver(
entries => {
const visible = entries
.filter(e => e.isIntersecting)
.sort((a, b) => (b.intersectionRatio || 0) - (a.intersectionRatio || 0))[0]
if (visible?.target?.id) activeSection.value = visible.target.id
},
{ root: null, threshold: [0.2, 0.35, 0.5], rootMargin: '-15% 0px -70% 0px' }
)
els.forEach(el => io.observe(el))
return () => io.disconnect()
}
let disconnectObserver = null
/* ----------------------------
Avatar: URL
----------------------------- */
function onAvatarUrlChange () {
ui.avatarPreview = String(form.avatar_url || '').trim()
markDirty()
}
function removeAvatar () {
form.avatar_url = ''
ui.avatarPreview = ''
clearAvatarFile()
markDirty()
}
/* ----------------------------
Avatar: upload arquivo
----------------------------- */
function clearAvatarFile () {
ui.avatarFile = null
if (ui.avatarFilePreviewUrl) {
try { URL.revokeObjectURL(ui.avatarFilePreviewUrl) } catch {}
}
ui.avatarFilePreviewUrl = ''
if (fileInput.value) fileInput.value.value = ''
}
function onAvatarFileSelected (ev) {
const file = ev?.target?.files?.[0]
if (!file) return
if (!file.type?.startsWith('image/')) {
toast.add({ severity: 'warn', summary: 'Arquivo inválido', detail: 'Escolha uma imagem (PNG/JPG/WebP).', life: 3500 })
clearAvatarFile()
return
}
if (file.size > 5 * 1024 * 1024) {
toast.add({ severity: 'warn', summary: 'Arquivo grande', detail: 'Máximo recomendado: 5MB.', life: 3500 })
clearAvatarFile()
return
}
ui.avatarFile = file
if (ui.avatarFilePreviewUrl) {
try { URL.revokeObjectURL(ui.avatarFilePreviewUrl) } catch {}
}
ui.avatarFilePreviewUrl = URL.createObjectURL(file)
ui.avatarPreview = ui.avatarFilePreviewUrl
markDirty()
}
function applyFilePreviewOnly () {
if (!ui.avatarFilePreviewUrl) return
ui.avatarPreview = ui.avatarFilePreviewUrl
markDirty()
}
function extFromMime (mime) {
if (!mime) return 'png'
if (mime.includes('jpeg')) return 'jpg'
if (mime.includes('png')) return 'png'
if (mime.includes('webp')) return 'webp'
return 'png'
}
async function uploadAvatarIfNeeded () {
if (!ui.avatarFile) return null
if (!userId.value) throw new Error('Sessão inválida para upload.')
const file = ui.avatarFile
const ext = extFromMime(file.type)
const path = `${userId.value}/avatar-${Date.now()}.${ext}`
const { error: upErr } = await supabase.storage
.from(AVATAR_BUCKET)
.upload(path, file, { upsert: true, contentType: file.type })
if (upErr) throw upErr
const { data } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
const url = data?.publicUrl
if (!url) throw new Error('Upload ok, mas não consegui obter a URL pública.')
return url
}
/* ----------------------------
Aparência (SEM duplicar engine)
----------------------------- */
const { layoutConfig, toggleDarkMode, changeMenuMode } = useLayout()
function isDarkNow () {
return document.documentElement.classList.contains('app-dark')
}
function setDarkMode (shouldBeDark) {
if (shouldBeDark !== isDarkNow()) toggleDarkMode()
}
/** ✅ motor único (igual topbar) */
function applyThemeEngine () {
const presetValue = presetsMap?.[layoutConfig.preset] || presetsMap?.Aura
const surfacePalette = getSurfacePalette(layoutConfig.surface) || surfaces.find(s => s.name === layoutConfig.surface)?.palette
$t()
.preset(presetValue)
.preset(getPresetExt(layoutConfig))
.surfacePalette(surfacePalette)
.use({ useDefaultOptions: true })
updatePreset(getPresetExt(layoutConfig))
if (surfacePalette) updateSurfacePalette(surfacePalette)
}
/** v-models diretos no layoutConfig */
const presetModel = computed({
get: () => layoutConfig.preset,
set: (val) => {
if (!val || val === layoutConfig.preset) return
layoutConfig.preset = val
applyThemeEngine()
if (!silentApplying.value) markDirty()
}
})
const menuModeOptions = [
{ label: 'Static', value: 'static' },
{ label: 'Overlay', value: 'overlay' }
]
const menuModeModel = computed({
get: () => layoutConfig.menuMode,
set: (val) => {
if (!val || val === layoutConfig.menuMode) return
layoutConfig.menuMode = val
try { changeMenuMode?.() } catch {}
if (!silentApplying.value) markDirty()
}
})
const themeModeOptions = [
{ label: 'Claro', value: 'light' },
{ label: 'Escuro', value: 'dark' }
]
const themeModeModel = computed({
get: () => (isDarkNow() ? 'dark' : 'light'),
set: async (val) => {
if (!val) return
setDarkMode(val === 'dark')
await nextTick()
if (!silentApplying.value) markDirty()
}
})
function updateColors (type, item) {
if (type === 'primary') {
layoutConfig.primary = item.name
applyThemeEngine()
if (!silentApplying.value) markDirty()
return
}
if (type === 'surface') {
layoutConfig.surface = item.name
applyThemeEngine()
if (!silentApplying.value) markDirty()
}
}
/* ----------------------------
DB: carregar/aplicar settings
----------------------------- */
function safeEq (a, b) {
return String(a || '').trim() === String(b || '').trim()
}
async function loadUserSettings (uid) {
const { data: settings, error } = await supabase
.from('user_settings')
.select('theme_mode, preset, primary_color, surface_color, menu_mode')
.eq('user_id', uid)
.maybeSingle()
if (error) {
const msg = String(error.message || '')
const tolerant =
/does not exist/i.test(msg) ||
/relation .* does not exist/i.test(msg) ||
/permission denied/i.test(msg) ||
/violates row-level security/i.test(msg)
if (!tolerant) throw error
return null
}
if (!settings) return null
// 1) dark/light
if (settings.theme_mode) setDarkMode(settings.theme_mode === 'dark')
// 2) layoutConfig
if (settings.preset && !safeEq(settings.preset, layoutConfig.preset)) layoutConfig.preset = settings.preset
if (settings.primary_color && !safeEq(settings.primary_color, layoutConfig.primary)) layoutConfig.primary = settings.primary_color
if (settings.surface_color && !safeEq(settings.surface_color, layoutConfig.surface)) layoutConfig.surface = settings.surface_color
if (settings.menu_mode && !safeEq(settings.menu_mode, layoutConfig.menuMode)) {
layoutConfig.menuMode = settings.menu_mode
try { changeMenuMode?.() } catch {}
}
// 3) aplica UMA vez
applyThemeEngine()
return settings
}
/* ----------------------------
Load / Save (perfil)
----------------------------- */
async function loadProfile () {
silentApplying.value = true
const { data: u, error: uErr } = await supabase.auth.getUser()
if (uErr) throw uErr
const user = u?.user
if (!user) throw new Error('Você precisa estar logado.')
userId.value = user.id
userEmail.value = user.email || ''
const meta = user.user_metadata || {}
form.full_name = meta.full_name || ''
form.avatar_url = meta.avatar_url || ''
ui.avatarPreview = form.avatar_url
const { data: prof, error: pErr } = await supabase
.from('profiles')
.select('full_name, avatar_url, phone, bio, language, timezone, notify_system_email, notify_reminders, notify_news')
.eq('id', user.id)
.maybeSingle()
if (!pErr && prof) {
form.full_name = prof.full_name ?? form.full_name
form.avatar_url = prof.avatar_url ?? form.avatar_url
form.phone = prof.phone ?? ''
form.bio = prof.bio ?? ''
form.language = prof.language ?? form.language
form.timezone = prof.timezone ?? form.timezone
if (typeof prof.notify_system_email === 'boolean') form.notify_system_email = prof.notify_system_email
if (typeof prof.notify_reminders === 'boolean') form.notify_reminders = prof.notify_reminders
if (typeof prof.notify_news === 'boolean') form.notify_news = prof.notify_news
ui.avatarPreview = form.avatar_url
}
await loadUserSettings(user.id)
silentApplying.value = false
dirty.value = false
}
async function saveAll () {
saving.value = true
try {
if (ui.avatarFile) {
try {
const uploadedUrl = await uploadAvatarIfNeeded()
if (uploadedUrl) {
form.avatar_url = uploadedUrl
ui.avatarPreview = uploadedUrl
}
} catch (e) {
toast.add({
severity: 'warn',
summary: 'Avatar não subiu',
detail: `Não consegui enviar o arquivo (bucket "${AVATAR_BUCKET}"). Você pode usar Avatar URL. (${e?.message || 'erro'})`,
life: 6500
})
}
}
const metaPayload = {
full_name: String(form.full_name || '').trim(),
avatar_url: String(form.avatar_url || '').trim() || null
}
const { error: upErr } = await supabase.auth.updateUser({ data: metaPayload })
if (upErr) throw upErr
const profilePayload = {
id: userId.value,
full_name: metaPayload.full_name,
avatar_url: metaPayload.avatar_url,
phone: String(form.phone || '').trim() || null,
bio: String(form.bio || '').trim() || null,
language: form.language || 'pt-BR',
timezone: form.timezone || 'America/Sao_Paulo',
notify_system_email: !!form.notify_system_email,
notify_reminders: !!form.notify_reminders,
notify_news: !!form.notify_news,
updated_at: new Date().toISOString()
}
const { error: pErr2 } = await supabase
.from('profiles')
.upsert(profilePayload, { onConflict: 'id' })
if (pErr2) {
const msg = String(pErr2.message || '')
const tolerant =
/does not exist/i.test(msg) ||
/column .* does not exist/i.test(msg) ||
/relation .* does not exist/i.test(msg) ||
/permission denied/i.test(msg) ||
/violates row-level security/i.test(msg)
if (!tolerant) throw pErr2
}
const settingsPayload = {
user_id: userId.value,
theme_mode: isDarkNow() ? 'dark' : 'light',
preset: layoutConfig.preset || 'Aura',
primary_color: layoutConfig.primary || 'noir',
surface_color: layoutConfig.surface || 'slate',
menu_mode: layoutConfig.menuMode || 'static',
updated_at: new Date().toISOString()
}
const { error: sErr } = await supabase
.from('user_settings')
.upsert(settingsPayload, { onConflict: 'user_id' })
if (sErr) {
const msg = String(sErr.message || '')
const tolerant =
/does not exist/i.test(msg) ||
/relation .* does not exist/i.test(msg) ||
/permission denied/i.test(msg) ||
/violates row-level security/i.test(msg)
if (!tolerant) throw sErr
}
clearAvatarFile()
dirty.value = false
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Seu perfil foi atualizado.', life: 2500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e?.message || 'Não consegui salvar.', life: 6000 })
} finally {
saving.value = false
}
}
/* ----------------------------
Segurança: reset + signout
----------------------------- */
function openPasswordDialog () {
passwordSent.value = false
openPassword.value = true
}
async function sendPasswordReset () {
if (!userEmail.value) return
sendingPassword.value = true
passwordSent.value = false
try {
const redirectTo = `${window.location.origin}/auth/reset-password`
const { error } = await supabase.auth.resetPasswordForEmail(userEmail.value, { redirectTo })
if (error) throw error
passwordSent.value = true
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao enviar e-mail.', life: 5000 })
} finally {
sendingPassword.value = false
}
}
function confirmSignOut () {
confirm.require({
header: 'Sair',
message: 'Deseja sair da sua conta neste dispositivo?',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sair',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-danger',
accept: async () => {
try {
await supabase.auth.signOut()
} finally {
router.push('/auth/login')
}
}
})
}
/* ----------------------------
Lifecycle
----------------------------- */
onMounted(async () => {
try {
await loadProfile()
disconnectObserver = observeSections()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não consegui carregar o perfil.', life: 6000 })
}
})
onBeforeUnmount(() => {
try { disconnectObserver?.() } catch {}
clearAvatarFile()
})
</script>

View File

@@ -0,0 +1,23 @@
<script setup>
import BestSellingWidget from '@/components/dashboard/BestSellingWidget.vue';
import NotificationsWidget from '@/components/dashboard/NotificationsWidget.vue';
import RecentSalesWidget from '@/components/dashboard/RecentSalesWidget.vue';
import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue';
import StatsWidget from '@/components/dashboard/StatsWidget.vue';
</script>
<template>
PATIENT DASHBOARD
<div class="grid grid-cols-12 gap-8">
<StatsWidget />
<div class="col-span-12 xl:col-span-6">
<RecentSalesWidget />
<BestSellingWidget />
</div>
<div class="col-span-12 xl:col-span-6">
<RevenueStreamWidget />
<NotificationsWidget />
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

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