first commit
This commit is contained in:
2
.env
Normal file
2
.env
Normal 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
2
.env.local
Normal 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
4
.gitignore
vendored
@@ -5,10 +5,12 @@ coverage
|
||||
.nitro
|
||||
.cache
|
||||
.output
|
||||
.env
|
||||
# .env
|
||||
dist
|
||||
.DS_Store
|
||||
.idea
|
||||
.eslintcache
|
||||
api-generator/typedoc.json
|
||||
**/.DS_Store
|
||||
Dev-documentacao/
|
||||
supabase/
|
||||
|
||||
31
ARCHITECTURE_NOTES.md
Normal file
31
ARCHITECTURE_NOTES.md
Normal 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
101
O-que-foi-feito.txt
Normal 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
47
checklist-novo-chat.txt
Normal 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
4147
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^2.0.0",
|
||||
"@supabase/supabase-js": "^2.95.3",
|
||||
"chart.js": "3.3.2",
|
||||
"pinia": "^3.0.4",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.5.4",
|
||||
"tailwindcss-primeui": "^0.6.0",
|
||||
@@ -22,11 +24,13 @@
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.23.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.2.5",
|
||||
"sass": "^1.55.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"unplugin-vue-components": "^0.27.3",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
|
||||
24
src/App.vue
24
src/App.vue
@@ -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>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
112
src/app/bootstrapUserSettings.js
vendored
Normal file
112
src/app/bootstrapUserSettings.js
vendored
Normal 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
222
src/app/session.js
Normal 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
|
||||
}
|
||||
}
|
||||
251
src/components/ComponentCadastroRapido.vue
Normal file
251
src/components/ComponentCadastroRapido.vue
Normal 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>
|
||||
220
src/components/agenda/AgendaOnlineGradeCard.vue
Normal file
220
src/components/agenda/AgendaOnlineGradeCard.vue
Normal 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 — só a disponibilidade do online.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
183
src/components/agenda/AgendaSlotsPorDiaCard.vue
Normal file
183
src/components/agenda/AgendaSlotsPorDiaCard.vue
Normal 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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ProductService } from '@/service/ProductService';
|
||||
import { ProductService } from '@/services/ProductService';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const products = ref(null);
|
||||
|
||||
31
src/components/security/FeatureGate.vue
Normal file
31
src/components/security/FeatureGate.vue
Normal 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>
|
||||
19
src/composables/useAuth.js
Normal file
19
src/composables/useAuth.js
Normal 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 }
|
||||
}
|
||||
114
src/composables/useUserSettingsPersistence.js
Normal file
114
src/composables/useUserSettingsPersistence.js
Normal 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
11
src/constants/roles.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export const ROLES = {
|
||||
ADMIN: 'admin',
|
||||
THERAPIST: 'therapist',
|
||||
PATIENT: 'patient'
|
||||
}
|
||||
|
||||
export const ROLE_HOME = {
|
||||
admin: '/admin',
|
||||
therapist: '/therapist',
|
||||
patient: '/patient'
|
||||
}
|
||||
7
src/layout/AdminLayout.vue
Normal file
7
src/layout/AdminLayout.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<AppShellLayout />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppShellLayout from './AppShellLayout.vue'
|
||||
</script>
|
||||
@@ -1,197 +1,61 @@
|
||||
<script setup>
|
||||
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';
|
||||
import { ref } from 'vue';
|
||||
import { computed, inject } from 'vue'
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
|
||||
const { layoutConfig, isDarkTheme, changeMenuMode } = useLayout();
|
||||
import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options'
|
||||
|
||||
const presets = {
|
||||
Aura,
|
||||
Lara,
|
||||
Nora
|
||||
};
|
||||
const preset = ref(layoutConfig.preset);
|
||||
const presetOptions = ref(Object.keys(presets));
|
||||
const { layoutConfig, isDarkTheme, changeMenuMode } = useLayout()
|
||||
|
||||
const menuMode = ref(layoutConfig.menuMode);
|
||||
const menuModeOptions = ref([
|
||||
// ✅ vem do AppTopbar (mesma instância)
|
||||
const queuePatch = inject('queueUserSettingsPatch', null)
|
||||
console.log('[AppConfigurator] queuePatch injected?', !!queuePatch)
|
||||
|
||||
// menu mode options
|
||||
const menuModeOptions = [
|
||||
{ label: 'Static', value: 'static' },
|
||||
{ label: 'Overlay', value: 'overlay' }
|
||||
]);
|
||||
]
|
||||
|
||||
const primaryColors = ref([
|
||||
{ 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: '#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' } }
|
||||
]);
|
||||
// ✅ v-model sincronizado (sem state local)
|
||||
const presetModel = computed({
|
||||
get: () => layoutConfig.preset,
|
||||
set: (val) => {
|
||||
if (!val || val === layoutConfig.preset) return
|
||||
layoutConfig.preset = val
|
||||
|
||||
const surfaces = ref([
|
||||
{
|
||||
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' }
|
||||
applyThemeEngine(layoutConfig)
|
||||
queuePatch?.({ preset: val })
|
||||
}
|
||||
]);
|
||||
})
|
||||
|
||||
function getPresetExt() {
|
||||
const color = primaryColors.value.find((c) => c.name === layoutConfig.primary);
|
||||
const menuModeModel = computed({
|
||||
get: () => layoutConfig.menuMode,
|
||||
set: (val) => {
|
||||
if (!val || val === layoutConfig.menuMode) return
|
||||
layoutConfig.menuMode = val
|
||||
|
||||
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}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
} 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)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
// composable pode aceitar nada (no teu caso, costuma ser isso)
|
||||
try { changeMenuMode() } catch {}
|
||||
|
||||
function updateColors(type, color) {
|
||||
queuePatch?.({ menu_mode: val })
|
||||
}
|
||||
})
|
||||
|
||||
function updateColors(type, item) {
|
||||
if (type === 'primary') {
|
||||
layoutConfig.primary = color.name;
|
||||
} else if (type === 'surface') {
|
||||
layoutConfig.surface = color.name;
|
||||
layoutConfig.primary = item.name
|
||||
applyThemeEngine(layoutConfig)
|
||||
queuePatch?.({ primary_color: item.name })
|
||||
return
|
||||
}
|
||||
|
||||
applyTheme(type, color);
|
||||
}
|
||||
|
||||
function applyTheme(type, color) {
|
||||
if (type === 'primary') {
|
||||
updatePreset(getPresetExt());
|
||||
} else if (type === 'surface') {
|
||||
updateSurfacePalette(color.palette);
|
||||
if (type === 'surface') {
|
||||
layoutConfig.surface = item.name
|
||||
applyThemeEngine(layoutConfig)
|
||||
queuePatch?.({ surface_color: item.name })
|
||||
}
|
||||
}
|
||||
|
||||
function onPresetChange() {
|
||||
layoutConfig.preset = preset.value;
|
||||
const presetValue = presets[preset.value];
|
||||
const surfacePalette = surfaces.value.find((s) => s.name === layoutConfig.surface)?.palette;
|
||||
|
||||
$t().preset(presetValue).preset(getPresetExt()).surfacePalette(surfacePalette).use({ useDefaultOptions: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -203,40 +67,52 @@ function onPresetChange() {
|
||||
<span class="text-sm text-muted-color font-semibold">Primary</span>
|
||||
<div class="pt-2 flex gap-2 flex-wrap justify-between">
|
||||
<button
|
||||
v-for="primaryColor of primaryColors"
|
||||
:key="primaryColor.name"
|
||||
v-for="c of primaryColors"
|
||||
:key="c.name"
|
||||
type="button"
|
||||
:title="primaryColor.name"
|
||||
@click="updateColors('primary', primaryColor)"
|
||||
:class="['border-none w-5 h-5 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>
|
||||
:title="c.name"
|
||||
@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 === c.name }
|
||||
]"
|
||||
:style="{ backgroundColor: `${c.name === 'noir' ? 'var(--text-color)' : c.palette['500']}` }"
|
||||
/>
|
||||
</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="surface of surfaces"
|
||||
:key="surface.name"
|
||||
v-for="s of surfaces"
|
||||
:key="s.name"
|
||||
type="button"
|
||||
:title="surface.name"
|
||||
@click="updateColors('surface', surface)"
|
||||
: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 === surface.name : isDarkTheme ? surface.name === 'zinc' : surface.name === 'slate' }
|
||||
{ 'outline-primary': layoutConfig.surface ? layoutConfig.surface === s.name : (isDarkTheme ? s.name === 'zinc' : s.name === 'slate') }
|
||||
]"
|
||||
:style="{ backgroundColor: `${surface.palette['500']}` }"
|
||||
></button>
|
||||
: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="preset" @change="onPresetChange" :options="presetOptions" :allowEmpty="false" />
|
||||
<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="menuMode" @change="changeMenuMode" :options="menuModeOptions" :allowEmpty="false" optionLabel="label" optionValue="value" />
|
||||
<SelectButton
|
||||
v-model="menuModeModel"
|
||||
:options="menuModeOptions"
|
||||
:allowEmpty="false"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,271 +1,483 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import AppMenuItem from './AppMenuItem.vue';
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
|
||||
const model = ref([
|
||||
{
|
||||
label: 'Home',
|
||||
items: [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon: 'pi pi-fw pi-home',
|
||||
to: '/'
|
||||
import AppMenuItem from './AppMenuItem.vue'
|
||||
import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
|
||||
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
import { sessionRole, sessionIsSaasAdmin } from '@/app/session'
|
||||
import { getMenuByRole } from '@/navigation'
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { layoutState } = useLayout()
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const entitlementsStore = useEntitlementsStore()
|
||||
|
||||
const model = computed(() => {
|
||||
const base = getMenuByRole(sessionRole.value, { isSaasAdmin: sessionIsSaasAdmin.value }) || []
|
||||
|
||||
const normalize = (s) => String(s || '').toLowerCase()
|
||||
const priorityOrder = (group) => {
|
||||
const label = normalize(group?.label)
|
||||
if (label.includes('saas')) return 0
|
||||
if (label.includes('pacientes')) return 1
|
||||
return 99
|
||||
}
|
||||
]
|
||||
|
||||
return [...base].sort((a, b) => priorityOrder(a) - priorityOrder(b))
|
||||
})
|
||||
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || null)
|
||||
|
||||
watch(
|
||||
tenantId,
|
||||
async (id) => {
|
||||
entitlementsStore.invalidate()
|
||||
if (id) await entitlementsStore.loadForTenant(id, { force: true })
|
||||
},
|
||||
{
|
||||
label: 'UI Components',
|
||||
path: '/uikit',
|
||||
items: [
|
||||
{
|
||||
label: 'Form Layout',
|
||||
icon: 'pi pi-fw pi-id-card',
|
||||
to: '/uikit/formlayout'
|
||||
},
|
||||
{
|
||||
label: 'Input',
|
||||
icon: 'pi pi-fw pi-check-square',
|
||||
to: '/uikit/input'
|
||||
},
|
||||
{
|
||||
label: 'Button',
|
||||
icon: 'pi pi-fw pi-mobile',
|
||||
to: '/uikit/button',
|
||||
class: 'rotated-icon'
|
||||
},
|
||||
{
|
||||
label: 'Table',
|
||||
icon: 'pi pi-fw pi-table',
|
||||
to: '/uikit/table'
|
||||
},
|
||||
{
|
||||
label: 'List',
|
||||
icon: 'pi pi-fw pi-list',
|
||||
to: '/uikit/list'
|
||||
},
|
||||
{
|
||||
label: 'Tree',
|
||||
icon: 'pi pi-fw pi-share-alt',
|
||||
to: '/uikit/tree'
|
||||
},
|
||||
{
|
||||
label: 'Panel',
|
||||
icon: 'pi pi-fw pi-tablet',
|
||||
to: '/uikit/panel'
|
||||
},
|
||||
{
|
||||
label: 'Overlay',
|
||||
icon: 'pi pi-fw pi-clone',
|
||||
to: '/uikit/overlay'
|
||||
},
|
||||
{
|
||||
label: 'Media',
|
||||
icon: 'pi pi-fw pi-image',
|
||||
to: '/uikit/media'
|
||||
},
|
||||
{
|
||||
label: 'Menu',
|
||||
icon: 'pi pi-fw pi-bars',
|
||||
to: '/uikit/menu'
|
||||
},
|
||||
{
|
||||
label: 'Message',
|
||||
icon: 'pi pi-fw pi-comment',
|
||||
to: '/uikit/message'
|
||||
},
|
||||
{
|
||||
label: 'File',
|
||||
icon: 'pi pi-fw pi-file',
|
||||
to: '/uikit/file'
|
||||
},
|
||||
{
|
||||
label: 'Chart',
|
||||
icon: 'pi pi-fw pi-chart-bar',
|
||||
to: '/uikit/charts'
|
||||
},
|
||||
{
|
||||
label: 'Timeline',
|
||||
icon: 'pi pi-fw pi-calendar',
|
||||
to: '/uikit/timeline'
|
||||
},
|
||||
{
|
||||
label: 'Misc',
|
||||
icon: 'pi pi-fw pi-circle',
|
||||
to: '/uikit/misc'
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sessionRole.value,
|
||||
async () => {
|
||||
if (!tenantId.value) return
|
||||
entitlementsStore.invalidate()
|
||||
await entitlementsStore.loadForTenant(tenantId.value, { force: true })
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Prime Blocks',
|
||||
icon: 'pi pi-fw pi-prime',
|
||||
path: '/blocks',
|
||||
items: [
|
||||
{
|
||||
label: 'Free Blocks',
|
||||
icon: 'pi pi-fw pi-eye',
|
||||
to: '/blocks/free'
|
||||
},
|
||||
{
|
||||
label: 'All Blocks',
|
||||
icon: 'pi pi-fw pi-globe',
|
||||
url: 'https://blocks.primevue.org/',
|
||||
target: '_blank'
|
||||
)
|
||||
|
||||
// ✅ rota -> activePath (NÃO fecha menu em nenhum cenário)
|
||||
watch(
|
||||
() => route.path,
|
||||
(p) => { layoutState.activePath = p },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// ==============================
|
||||
// 🔎 Busca no menu (flatten + resultados)
|
||||
// ==============================
|
||||
const query = ref('')
|
||||
const showResults = ref(false)
|
||||
const activeIndex = ref(-1)
|
||||
|
||||
// ✅ garante Ctrl/Cmd+K mesmo sem recentes
|
||||
const forcedOpen = ref(false)
|
||||
|
||||
// ref do InputText (pra Ctrl/Cmd + K)
|
||||
const searchEl = ref(null)
|
||||
|
||||
// wrapper pra click-outside
|
||||
const searchWrapEl = ref(null)
|
||||
|
||||
// Recentes
|
||||
const RECENT_KEY = 'menu_search_recent'
|
||||
const recent = ref([])
|
||||
|
||||
function loadRecent () {
|
||||
try { recent.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') } catch { recent.value = [] }
|
||||
}
|
||||
function saveRecent (q) {
|
||||
const v = String(q || '').trim()
|
||||
if (!v) return
|
||||
const list = [v, ...recent.value.filter(x => x !== v)].slice(0, 8)
|
||||
recent.value = list
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(list))
|
||||
}
|
||||
function clearRecent () {
|
||||
recent.value = []
|
||||
try { localStorage.removeItem(RECENT_KEY) } catch {}
|
||||
}
|
||||
loadRecent()
|
||||
|
||||
watch(query, (v) => {
|
||||
const hasText = !!v?.trim()
|
||||
|
||||
// digitou: abre e sai do modo "forced"
|
||||
if (hasText) {
|
||||
forcedOpen.value = false
|
||||
showResults.value = true
|
||||
return
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
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'
|
||||
|
||||
// vazio: mantém aberto apenas se foi "forçado" (Ctrl/Cmd+K ou foco)
|
||||
showResults.value = forcedOpen.value
|
||||
})
|
||||
|
||||
function clearSearch () {
|
||||
query.value = ''
|
||||
activeIndex.value = -1
|
||||
showResults.value = false
|
||||
forcedOpen.value = false
|
||||
}
|
||||
|
||||
function norm (s) {
|
||||
return String(s || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function flattenMenu (items, trail = []) {
|
||||
const out = []
|
||||
for (const it of (items || [])) {
|
||||
if (it?.visible === false) continue
|
||||
|
||||
const nextTrail = [...trail, it?.label].filter(Boolean)
|
||||
|
||||
if (it?.to && !it?.items?.length) {
|
||||
out.push({
|
||||
label: it.label || it.to,
|
||||
to: it.to,
|
||||
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'
|
||||
|
||||
if (it?.items?.length) {
|
||||
out.push(...flattenMenu(it.items, nextTrail))
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
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'
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
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'
|
||||
|
||||
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
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
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'
|
||||
|
||||
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()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
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'
|
||||
}
|
||||
|
||||
// ✅ 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>
|
||||
|
||||
<template>
|
||||
<ul class="layout-menu">
|
||||
<template v-for="(item, i) in model" :key="item">
|
||||
<app-menu-item v-if="!item.separator" :item="item" :index="i"></app-menu-item>
|
||||
<li v-if="item.separator" class="menu-separator"></li>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- 🔎 TOPO FIXO -->
|
||||
<div ref="searchWrapEl" class="pb-2 border-b border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative">
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<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>
|
||||
|
||||
<!-- ✅ 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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<!-- 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>
|
||||
|
||||
189
src/layout/AppMenuFooterPanel.vue
Normal file
189
src/layout/AppMenuFooterPanel.vue
Normal 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>
|
||||
@@ -1,77 +1,224 @@
|
||||
<script setup>
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { computed } from 'vue';
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
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({
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
root: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
parentPath: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
item: { type: Object, default: () => ({}) },
|
||||
root: { type: Boolean, default: false },
|
||||
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(() => {
|
||||
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) => {
|
||||
if (item.disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
// grupo com submenu: active se qualquer descendente estiver ativo
|
||||
if (item?.items?.length) {
|
||||
if (hasActiveDescendant(item, current)) return true
|
||||
|
||||
// fallback pelo "path" (útil para rotas internas tipo /saas/plans/123)
|
||||
return item.path ? current.startsWith(fullPath.value || '') : false
|
||||
}
|
||||
|
||||
if (item.command) {
|
||||
item.command({ originalEvent: event, item: item });
|
||||
// folha: active se rota igual ao to
|
||||
return item?.to ? isSameRoute(current, item.to) : false
|
||||
})
|
||||
|
||||
// ==============================
|
||||
// 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
|
||||
}
|
||||
|
||||
if (item.items) {
|
||||
// 🚫 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 = layoutState.activePath.replace(item.path, '');
|
||||
layoutState.activePath = props.parentPath || ''
|
||||
} else {
|
||||
layoutState.activePath = fullPath.value;
|
||||
layoutState.menuHoverActive = true;
|
||||
layoutState.activePath = fullPath.value
|
||||
layoutState.menuHoverActive = true
|
||||
}
|
||||
} else {
|
||||
layoutState.overlayMenuActive = false;
|
||||
layoutState.mobileMenuActive = false;
|
||||
layoutState.menuHoverActive = false;
|
||||
return
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ leaf: marca ativo e NÃO fecha menu
|
||||
if (item?.to) layoutState.activePath = item.to
|
||||
}
|
||||
|
||||
const onMouseEnter = () => {
|
||||
if (isDesktop() && props.root && props.item.items && layoutState.menuHoverActive) {
|
||||
layoutState.activePath = fullPath.value;
|
||||
if (isDesktop() && props.root && props.item?.items && layoutState.menuHoverActive) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
|
||||
<div v-if="root && item.visible !== false" class="layout-menuitem-root-text">{{ item.label }}</div>
|
||||
<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">
|
||||
<div v-if="root && item.visible !== false" class="layout-menuitem-root-text">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
|
||||
<div v-if="!root && item.visible !== false" class="flex align-items-center justify-content-between w-full">
|
||||
<component
|
||||
:is="item.to && !item.items ? 'router-link' : 'a'"
|
||||
v-bind="item.to && !item.items ? { to: item.to } : { href: item.url }"
|
||||
@click="itemClick($event, item)"
|
||||
:class="[item.class, isBlocked ? 'opacity-60 cursor-pointer' : '']"
|
||||
:target="item.target"
|
||||
tabindex="0"
|
||||
@mouseenter="onMouseEnter"
|
||||
class="flex align-items-center flex-1"
|
||||
:aria-disabled="isBlocked ? 'true' : 'false'"
|
||||
>
|
||||
<i :class="item.icon" class="layout-menuitem-icon" />
|
||||
<span class="layout-menuitem-text">{{ item.label }}</span>
|
||||
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items" />
|
||||
</a>
|
||||
<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">
|
||||
<i :class="item.icon" class="layout-menuitem-icon" />
|
||||
<span class="layout-menuitem-text">{{ item.label }}</span>
|
||||
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items" />
|
||||
</router-link>
|
||||
|
||||
<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.label + '_' + (child.to || child.path)" :item="child" :root="false" :parentPath="fullPath" />
|
||||
<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>
|
||||
|
||||
25
src/layout/AppShellLayout.vue
Normal file
25
src/layout/AppShellLayout.vue
Normal 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>
|
||||
@@ -1,77 +1,302 @@
|
||||
<script setup>
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import AppConfigurator from './AppConfigurator.vue';
|
||||
import { computed, ref, onMounted, provide, nextTick } from '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>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<div class="layout-topbar">
|
||||
<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 gigante ... -->
|
||||
</svg>
|
||||
|
||||
<span>SAKAI</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="layout-topbar-actions">
|
||||
<div class="layout-config-menu">
|
||||
<button type="button" class="layout-topbar-action" @click="toggleDarkMode">
|
||||
<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 }"
|
||||
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 }"
|
||||
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>
|
||||
|
||||
175
src/layout/ConfiguracoesPage.vue
Normal file
175
src/layout/ConfiguracoesPage.vue
Normal 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 dá “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>
|
||||
7
src/layout/PatientLayout.vue
Normal file
7
src/layout/PatientLayout.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<AppShellLayout />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppShellLayout from './AppShellLayout.vue'
|
||||
</script>
|
||||
7
src/layout/TherapistLayout.vue
Normal file
7
src/layout/TherapistLayout.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<AppShellLayout />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppShellLayout from './AppShellLayout.vue'
|
||||
</script>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, reactive } from 'vue';
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
const layoutConfig = reactive({
|
||||
preset: 'Aura',
|
||||
@@ -6,70 +6,79 @@ const layoutConfig = reactive({
|
||||
surface: null,
|
||||
darkTheme: false,
|
||||
menuMode: 'static'
|
||||
});
|
||||
})
|
||||
|
||||
const layoutState = reactive({
|
||||
staticMenuInactive: false,
|
||||
overlayMenuActive: false,
|
||||
mobileMenuActive: false, // ✅ ADICIONA (estava faltando)
|
||||
profileSidebarVisible: false,
|
||||
configSidebarVisible: false,
|
||||
sidebarExpanded: false,
|
||||
menuHoverActive: false,
|
||||
activeMenuItem: null,
|
||||
activePath: null
|
||||
});
|
||||
})
|
||||
|
||||
export function useLayout() {
|
||||
export function useLayout () {
|
||||
const toggleDarkMode = () => {
|
||||
if (!document.startViewTransition) {
|
||||
executeDarkModeToggle();
|
||||
|
||||
return;
|
||||
executeDarkModeToggle()
|
||||
return
|
||||
}
|
||||
|
||||
document.startViewTransition(() => executeDarkModeToggle(event));
|
||||
};
|
||||
document.startViewTransition(() => executeDarkModeToggle(event))
|
||||
}
|
||||
|
||||
const executeDarkModeToggle = () => {
|
||||
layoutConfig.darkTheme = !layoutConfig.darkTheme;
|
||||
document.documentElement.classList.toggle('app-dark');
|
||||
};
|
||||
layoutConfig.darkTheme = !layoutConfig.darkTheme
|
||||
document.documentElement.classList.toggle('app-dark')
|
||||
}
|
||||
|
||||
const isDesktop = () => window.innerWidth > 991
|
||||
|
||||
const toggleMenu = () => {
|
||||
if (isDesktop()) {
|
||||
if (layoutConfig.menuMode === 'static') {
|
||||
layoutState.staticMenuInactive = !layoutState.staticMenuInactive;
|
||||
layoutState.staticMenuInactive = !layoutState.staticMenuInactive
|
||||
}
|
||||
|
||||
if (layoutConfig.menuMode === 'overlay') {
|
||||
layoutState.overlayMenuActive = !layoutState.overlayMenuActive;
|
||||
layoutState.overlayMenuActive = !layoutState.overlayMenuActive
|
||||
}
|
||||
} else {
|
||||
layoutState.mobileMenuActive = !layoutState.mobileMenuActive;
|
||||
layoutState.mobileMenuActive = !layoutState.mobileMenuActive
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleConfigSidebar = () => {
|
||||
layoutState.configSidebarVisible = !layoutState.configSidebarVisible;
|
||||
};
|
||||
layoutState.configSidebarVisible = !layoutState.configSidebarVisible
|
||||
}
|
||||
|
||||
const hideMobileMenu = () => {
|
||||
layoutState.mobileMenuActive = false;
|
||||
};
|
||||
layoutState.mobileMenuActive = false
|
||||
}
|
||||
|
||||
// ✅ use isso ao navegar: mantém menu aberto no desktop, fecha só no mobile
|
||||
const closeMenuOnNavigate = () => {
|
||||
if (!isDesktop()) {
|
||||
layoutState.mobileMenuActive = false
|
||||
layoutState.overlayMenuActive = false
|
||||
layoutState.menuHoverActive = false
|
||||
}
|
||||
}
|
||||
|
||||
const changeMenuMode = (event) => {
|
||||
layoutConfig.menuMode = event.value;
|
||||
layoutState.staticMenuInactive = false;
|
||||
layoutState.mobileMenuActive = false;
|
||||
layoutState.sidebarExpanded = false;
|
||||
layoutState.menuHoverActive = false;
|
||||
layoutState.anchored = false;
|
||||
};
|
||||
layoutConfig.menuMode = event.value
|
||||
layoutState.staticMenuInactive = false
|
||||
layoutState.mobileMenuActive = false
|
||||
layoutState.sidebarExpanded = false
|
||||
layoutState.menuHoverActive = false
|
||||
layoutState.anchored = false
|
||||
}
|
||||
|
||||
const isDarkTheme = computed(() => layoutConfig.darkTheme);
|
||||
const isDesktop = () => window.innerWidth > 991;
|
||||
|
||||
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive);
|
||||
const isDarkTheme = computed(() => layoutConfig.darkTheme)
|
||||
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive)
|
||||
|
||||
return {
|
||||
layoutConfig,
|
||||
@@ -79,8 +88,9 @@ export function useLayout() {
|
||||
toggleConfigSidebar,
|
||||
toggleMenu,
|
||||
hideMobileMenu,
|
||||
closeMenuOnNavigate, // ✅ exporta
|
||||
changeMenuMode,
|
||||
isDesktop,
|
||||
hasOpenOverlay
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
1392
src/layout/configuracoes/ConfiguracoesAgendaPage.vue
Normal file
1392
src/layout/configuracoes/ConfiguracoesAgendaPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
0
src/layout/configuracoes/ConfiguracoesContaPage.vue
Normal file
0
src/layout/configuracoes/ConfiguracoesContaPage.vue
Normal file
123
src/main.js
123
src/main.js
@@ -1,27 +1,112 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from '@/router'
|
||||
import { setOnSignedOut, initSession, listenAuthChanges, refreshSession } from '@/app/session'
|
||||
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import PrimeVue from 'primevue/config';
|
||||
import ConfirmationService from 'primevue/confirmationservice';
|
||||
import ToastService from 'primevue/toastservice';
|
||||
import Aura from '@primeuix/themes/aura'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
|
||||
import '@/assets/tailwind.css';
|
||||
import '@/assets/styles.scss';
|
||||
import '@/assets/tailwind.css'
|
||||
import '@/assets/styles.scss'
|
||||
|
||||
const app = createApp(App);
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
app.use(router);
|
||||
app.use(PrimeVue, {
|
||||
async function applyUserThemeEarly() {
|
||||
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: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: '.app-dark'
|
||||
options: { darkModeSelector: '.app-dark' }
|
||||
}
|
||||
}
|
||||
});
|
||||
app.use(ToastService);
|
||||
app.use(ConfirmationService);
|
||||
})
|
||||
app.use(ToastService)
|
||||
app.use(ConfirmationService)
|
||||
|
||||
app.mount('#app');
|
||||
app.mount('#app')
|
||||
|
||||
// ✅ marca boot completo
|
||||
window.__appBootstrapped = true
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
|
||||
54
src/navigation/index.js
Normal file
54
src/navigation/index.js
Normal 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] : [])
|
||||
]
|
||||
}
|
||||
76
src/navigation/menus/admin.menu.js
Normal file
76
src/navigation/menus/admin.menu.js
Normal 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'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
59
src/navigation/menus/patient.menu.js
Normal file
59
src/navigation/menus/patient.menu.js
Normal 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
|
||||
// }
|
||||
]
|
||||
}
|
||||
]
|
||||
58
src/navigation/menus/saas.menu.js
Normal file
58
src/navigation/menus/saas.menu.js
Normal 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' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
117
src/navigation/menus/sakai.demo.menu.js
Normal file
117
src/navigation/menus/sakai.demo.menu.js
Normal 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' }
|
||||
]
|
||||
}
|
||||
]
|
||||
20
src/navigation/menus/therapist.menu.js
Normal file
20
src/navigation/menus/therapist.menu.js
Normal 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
248
src/router/guards.js
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,146 +1,111 @@
|
||||
import AppLayout from '@/layout/AppLayout.vue';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import {
|
||||
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({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
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')
|
||||
},
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
// volta/avançar do navegador mantém posição
|
||||
if (savedPosition) return savedPosition
|
||||
|
||||
{
|
||||
path: '/uikit/overlay',
|
||||
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')
|
||||
// qualquer navegação normal NÃO altera o scroll
|
||||
return false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/landing',
|
||||
name: 'landing',
|
||||
component: () => import('@/views/pages/Landing.vue')
|
||||
},
|
||||
{
|
||||
path: '/pages/notfound',
|
||||
name: 'notfound',
|
||||
component: () => import('@/views/pages/NotFound.vue')
|
||||
},
|
||||
})
|
||||
|
||||
{
|
||||
path: '/auth/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/pages/auth/Login.vue')
|
||||
},
|
||||
{
|
||||
path: '/auth/access',
|
||||
name: 'accessDenied',
|
||||
component: () => import('@/views/pages/auth/Access.vue')
|
||||
},
|
||||
{
|
||||
path: '/auth/error',
|
||||
name: 'error',
|
||||
component: () => import('@/views/pages/auth/Error.vue')
|
||||
/* 🔎 DEBUG: listar todas as rotas registradas */
|
||||
console.log(
|
||||
'[ROUTES]',
|
||||
router.getRoutes().map(r => r.path).sort()
|
||||
)
|
||||
|
||||
// ===== 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)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export default router;
|
||||
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
|
||||
|
||||
36
src/router/router.configuracoes.js
Normal file
36
src/router/router.configuracoes.js
Normal 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
32
src/router/router.me.js
Normal 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
|
||||
93
src/router/routes.admin.js
Normal file
93
src/router/routes.admin.js
Normal 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
45
src/router/routes.auth.js
Normal 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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
15
src/router/routes.billing.js
Normal file
15
src/router/routes.billing.js
Normal 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
29
src/router/routes.demo.js
Normal 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
15
src/router/routes.misc.js
Normal 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')
|
||||
}
|
||||
]
|
||||
}
|
||||
19
src/router/routes.patient.js
Normal file
19
src/router/routes.patient.js
Normal 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')
|
||||
}
|
||||
]
|
||||
}
|
||||
26
src/router/routes.public.js
Normal file
26
src/router/routes.public.js
Normal 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
60
src/router/routes.saas.js
Normal 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')
|
||||
}
|
||||
]
|
||||
}
|
||||
71
src/router/routes.therapist.js
Normal file
71
src/router/routes.therapist.js
Normal 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' }
|
||||
// }
|
||||
]
|
||||
}
|
||||
0
src/service/CountryService.js → src/services/CountryService.js
Executable file → Normal file
0
src/service/CountryService.js → src/services/CountryService.js
Executable file → Normal file
0
src/service/CustomerService.js → src/services/CustomerService.js
Executable file → Normal file
0
src/service/CustomerService.js → src/services/CustomerService.js
Executable file → Normal file
173
src/services/GruposPacientes.service.js
Normal file
173
src/services/GruposPacientes.service.js
Normal 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
|
||||
}
|
||||
0
src/service/NodeService.js → src/services/NodeService.js
Executable file → Normal file
0
src/service/NodeService.js → src/services/NodeService.js
Executable file → Normal file
0
src/service/PhotoService.js → src/services/PhotoService.js
Executable file → Normal file
0
src/service/PhotoService.js → src/services/PhotoService.js
Executable file → Normal file
76
src/services/agendaConfigService.js
Normal file
76
src/services/agendaConfigService.js
Normal 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
|
||||
}
|
||||
45
src/services/agendaSlotsBloqueadosService.js
Normal file
45
src/services/agendaSlotsBloqueadosService.js
Normal 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
|
||||
}
|
||||
18
src/services/authService.js
Normal file
18
src/services/authService.js
Normal 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
|
||||
}
|
||||
77
src/services/patientTags.service.js
Normal file
77
src/services/patientTags.service.js
Normal 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)
|
||||
}
|
||||
63
src/services/subscriptionIntents.js
Normal file
63
src/services/subscriptionIntents.js
Normal 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
|
||||
}
|
||||
110
src/sql-arquivos/01_profiles.sql
Normal file
110
src/sql-arquivos/01_profiles.sql
Normal 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());
|
||||
212
src/sql-arquivos/supabase_cadastro_externo.sql
Normal file
212
src/sql-arquivos/supabase_cadastro_externo.sql
Normal 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;
|
||||
|
||||
266
src/sql-arquivos/supabase_cadastro_pacientes.sql
Normal file
266
src/sql-arquivos/supabase_cadastro_pacientes.sql
Normal 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
|
||||
-- =========================================================
|
||||
105
src/sql-arquivos/supabase_cadastros_recebidos(intakes).sql
Normal file
105
src/sql-arquivos/supabase_cadastros_recebidos(intakes).sql
Normal 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'));
|
||||
174
src/sql-arquivos/supabase_patient_groups.sql
Normal file
174
src/sql-arquivos/supabase_patient_groups.sql
Normal 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');
|
||||
*/
|
||||
147
src/sql-arquivos/supabase_patient_index_page.sql
Normal file
147
src/sql-arquivos/supabase_patient_index_page.sql
Normal 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);
|
||||
0
src/sql-arquivos/supabase_patients_populate.sql
Normal file
0
src/sql-arquivos/supabase_patients_populate.sql
Normal file
134
src/sql-arquivos/supabase_tags.sql
Normal file
134
src/sql-arquivos/supabase_tags.sql
Normal 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());
|
||||
107
src/stores/entitlementsStore.js
Normal file
107
src/stores/entitlementsStore.js
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
31
src/stores/saasHealthStore.js
Normal file
31
src/stores/saasHealthStore.js
Normal 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
86
src/stores/tenantStore.js
Normal 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
140
src/theme/theme.options.js
Normal 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
24
src/utils/dateBR.js
Normal 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}`
|
||||
}
|
||||
51
src/utils/slotsGenerator.js
Normal file
51
src/utils/slotsGenerator.js
Normal 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()
|
||||
}
|
||||
58
src/utils/upgradeContext.js
Normal file
58
src/utils/upgradeContext.js
Normal 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'
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ProductService } from '@/service/ProductService';
|
||||
import { ProductService } from '@/services/ProductService';
|
||||
import { FilterMatchMode } from '@primevue/core/api';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
293
src/views/pages/HomeCards.vue
Normal file
293
src/views/pages/HomeCards.vue
Normal 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 JÁ 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>
|
||||
@@ -1,61 +1,119 @@
|
||||
<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>
|
||||
|
||||
<template>
|
||||
<FloatingConfigurator />
|
||||
<div class="flex items-center justify-center min-h-screen overflow-hidden">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<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">
|
||||
<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)"
|
||||
|
||||
<div class="relative min-h-screen overflow-hidden bg-[var(--surface-ground)] text-[var(--text-color)]">
|
||||
<!-- fundo conceitual: grid + halos -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-80">
|
||||
<!-- grid sutil -->
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
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: 36px 36px;
|
||||
mask-image: radial-gradient(ellipse at center, rgba(0,0,0,.9), transparent 70%);
|
||||
"
|
||||
/>
|
||||
<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)"
|
||||
<!-- halos -->
|
||||
<div class="absolute -top-24 -right-24 h-80 w-80 rounded-full blur-3xl bg-primary/10" />
|
||||
<div class="absolute -bottom-28 -left-28 h-96 w-96 rounded-full blur-3xl bg-purple-500/10" />
|
||||
</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"
|
||||
/>
|
||||
</g>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
ADMIN DASHBOARD
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<StatsWidget />
|
||||
|
||||
9
src/views/pages/admin/OnlineSchedulingAdminPage.vue
Normal file
9
src/views/pages/admin/OnlineSchedulingAdminPage.vue
Normal 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>
|
||||
1233
src/views/pages/admin/pacientes/PatientsIndexPage.vue
Normal file
1233
src/views/pages/admin/pacientes/PatientsIndexPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
1722
src/views/pages/admin/pacientes/cadastro/PatientsCadastroPage.vue
Normal file
1722
src/views/pages/admin/pacientes/cadastro/PatientsCadastroPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 já 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>
|
||||
@@ -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>
|
||||
617
src/views/pages/admin/pacientes/grupos/GruposPacientesPage.vue
Normal file
617
src/views/pages/admin/pacientes/grupos/GruposPacientesPage.vue
Normal 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 há 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>
|
||||
899
src/views/pages/admin/pacientes/prontuario/PatientProntuario.vue
Normal file
899
src/views/pages/admin/pacientes/prontuario/PatientProntuario.vue
Normal 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>
|
||||
816
src/views/pages/admin/pacientes/tags/TagsPage.vue
Normal file
816
src/views/pages/admin/pacientes/tags/TagsPage.vue
Normal 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>
|
||||
@@ -1,70 +1,453 @@
|
||||
<script setup>
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue';
|
||||
import { ref } from 'vue';
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const checked = ref(false);
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { supabase } from '../../../lib/supabase/client'
|
||||
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<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)"
|
||||
|
||||
<div class="relative min-h-screen w-full overflow-hidden bg-[var(--surface-ground)]">
|
||||
<!-- fundo conceitual -->
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
<!-- grid muito sutil -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-70"
|
||||
style="
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255,255,255,.05) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255,255,255,.05) 1px, transparent 1px);
|
||||
background-size: 38px 38px;
|
||||
mask-image: radial-gradient(ellipse at 50% 20%, rgba(0,0,0,.95), transparent 70%);
|
||||
"
|
||||
/>
|
||||
<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>
|
||||
<!-- halos -->
|
||||
<div class="absolute -top-28 -right-28 h-[26rem] w-[26rem] rounded-full blur-3xl bg-indigo-400/10" />
|
||||
<div class="absolute top-20 -left-28 h-[30rem] w-[30rem] rounded-full blur-3xl bg-emerald-400/10" />
|
||||
<div class="absolute -bottom-32 right-24 h-[26rem] w-[26rem] rounded-full blur-3xl bg-fuchsia-400/10" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email1" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-2">Email</label>
|
||||
<InputText id="email1" type="text" placeholder="Email address" class="w-full md:w-[30rem] mb-8" v-model="email" />
|
||||
<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>
|
||||
|
||||
<label for="password1" class="block text-surface-900 dark:text-surface-0 font-medium text-xl mb-2">Password</label>
|
||||
<Password id="password1" v-model="password" placeholder="Password" :toggleMask="true" class="mb-4" fluid :feedback="false"></Password>
|
||||
<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="flex items-center justify-between mt-2 mb-8 gap-8">
|
||||
<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" id="rememberme1" binary class="mr-2"></Checkbox>
|
||||
<label for="rememberme1">Remember me</label>
|
||||
<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>
|
||||
<span class="font-medium no-underline ml-2 text-right cursor-pointer text-primary">Forgot password?</span>
|
||||
|
||||
<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>
|
||||
<Button label="Sign In" class="w-full" as="router-link" to="/"></Button>
|
||||
|
||||
<!-- 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 só 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>
|
||||
|
||||
<style scoped>
|
||||
.pi-eye {
|
||||
transform: scale(1.6);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.pi-eye-slash {
|
||||
transform: scale(1.6);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
253
src/views/pages/auth/ResetPasswordPage.vue
Normal file
253
src/views/pages/auth/ResetPasswordPage.vue
Normal 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>
|
||||
374
src/views/pages/auth/SecurityPage.vue
Normal file
374
src/views/pages/auth/SecurityPage.vue
Normal 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>
|
||||
274
src/views/pages/auth/Welcome.vue
Normal file
274
src/views/pages/auth/Welcome.vue
Normal 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ê já 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>
|
||||
396
src/views/pages/billing/UpgradePage.vue
Normal file
396
src/views/pages/billing/UpgradePage.vue
Normal 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>
|
||||
954
src/views/pages/me/MeuPerfilPage.vue
Normal file
954
src/views/pages/me/MeuPerfilPage.vue
Normal 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>
|
||||
23
src/views/pages/patient/PatientDashboard.vue
Normal file
23
src/views/pages/patient/PatientDashboard.vue
Normal 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>
|
||||
1387
src/views/pages/public/CadastroPacienteExterno.vue
Normal file
1387
src/views/pages/public/CadastroPacienteExterno.vue
Normal file
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
Reference in New Issue
Block a user