Compare commits
10 Commits
319f976d2b
...
676042268b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
676042268b | ||
|
|
ec6b6ef53a | ||
|
|
76a3b60333 | ||
|
|
410c08d693 | ||
|
|
a4b2c96b0d | ||
|
|
a47200fdf7 | ||
|
|
7c32ae1f6f | ||
|
|
db99863fac | ||
|
|
c2ef85fcab | ||
|
|
deea8861f8 |
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/
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "src/assets"]
|
||||
path = src/assets
|
||||
url = https://github.com/primefaces/sakai-assets.git
|
||||
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
|
||||
|
||||
4688
package-lock.json
generated
4688
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sakai-vue",
|
||||
"version": "4.3.0",
|
||||
"version": "5.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@@ -8,11 +8,13 @@
|
||||
"lint": "eslint --fix . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.0.0",
|
||||
"@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.3.1",
|
||||
"tailwindcss-primeui": "^0.5.0",
|
||||
"primevue": "^4.5.4",
|
||||
"tailwindcss-primeui": "^0.6.0",
|
||||
"vue": "^3.4.34",
|
||||
"vue-router": "^4.4.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"
|
||||
}
|
||||
|
||||
BIN
public/demo/images/blocks/landing-blocks-dark.jpg
Normal file
BIN
public/demo/images/blocks/landing-blocks-dark.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
BIN
public/demo/images/blocks/landing-blocks.jpg
Normal file
BIN
public/demo/images/blocks/landing-blocks.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
26
src/App.vue
26
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 />
|
||||
<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
|
||||
}
|
||||
}
|
||||
1
src/assets
Submodule
1
src/assets
Submodule
Submodule src/assets added at eaa70ece4c
@@ -1,17 +0,0 @@
|
||||
pre.app-code {
|
||||
background-color: var(--code-background);
|
||||
margin: 0 0 1rem 0;
|
||||
padding: 0;
|
||||
border-radius: var(--content-border-radius);
|
||||
overflow: auto;
|
||||
|
||||
code {
|
||||
color: var(--code-color);
|
||||
padding: 1rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
font-weight: semibold;
|
||||
font-family: monaco, Consolas, monospace;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
@use './code.scss';
|
||||
@use './flags/flags.css';
|
||||
File diff suppressed because one or more lines are too long
@@ -1,24 +0,0 @@
|
||||
html {
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Lato', sans-serif;
|
||||
color: var(--text-color);
|
||||
background-color: var(--surface-ground);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.layout-wrapper {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
.layout-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem 0 1rem 0;
|
||||
gap: 0.5rem;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
.layout-main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
justify-content: space-between;
|
||||
padding: 6rem 2rem 0 2rem;
|
||||
transition: margin-left var(--layout-section-transition-duration);
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
flex: 1 1 auto;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
@use 'mixins' as *;
|
||||
|
||||
.layout-sidebar {
|
||||
position: fixed;
|
||||
width: 20rem;
|
||||
height: calc(100vh - 8rem);
|
||||
z-index: 999;
|
||||
overflow-y: auto;
|
||||
user-select: none;
|
||||
top: 6rem;
|
||||
left: 2rem;
|
||||
transition:
|
||||
transform var(--layout-section-transition-duration),
|
||||
left var(--layout-section-transition-duration);
|
||||
background-color: var(--surface-overlay);
|
||||
border-radius: var(--content-border-radius);
|
||||
padding: 0.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.layout-menu {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
|
||||
.layout-root-menuitem {
|
||||
> .layout-menuitem-root-text {
|
||||
font-size: 0.857rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
> a {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
user-select: none;
|
||||
|
||||
&.active-menuitem {
|
||||
> .layout-submenu-toggler {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
li.active-menuitem {
|
||||
> a {
|
||||
.layout-submenu-toggler {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
outline: 0 none;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--content-border-radius);
|
||||
transition:
|
||||
background-color var(--element-transition-duration),
|
||||
box-shadow var(--element-transition-duration);
|
||||
|
||||
.layout-menuitem-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.layout-submenu-toggler {
|
||||
font-size: 75%;
|
||||
margin-left: auto;
|
||||
transition: transform var(--element-transition-duration);
|
||||
}
|
||||
|
||||
&.active-route {
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
@include focused-inset();
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
overflow: hidden;
|
||||
border-radius: var(--content-border-radius);
|
||||
|
||||
li {
|
||||
a {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
li {
|
||||
a {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
li {
|
||||
a {
|
||||
margin-left: 2.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
a {
|
||||
margin-left: 3rem;
|
||||
}
|
||||
|
||||
li {
|
||||
a {
|
||||
margin-left: 3.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
a {
|
||||
margin-left: 4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-submenu-enter-from,
|
||||
.layout-submenu-leave-to {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.layout-submenu-enter-to,
|
||||
.layout-submenu-leave-from {
|
||||
max-height: 1000px;
|
||||
}
|
||||
|
||||
.layout-submenu-leave-active {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.45s cubic-bezier(0, 1, 0, 1);
|
||||
}
|
||||
|
||||
.layout-submenu-enter-active {
|
||||
overflow: hidden;
|
||||
transition: max-height 1s ease-in-out;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
@mixin focused() {
|
||||
outline-width: var(--focus-ring-width);
|
||||
outline-style: var(--focus-ring-style);
|
||||
outline-color: var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
box-shadow: var(--focus-ring-shadow);
|
||||
transition:
|
||||
box-shadow var(--transition-duration),
|
||||
outline-color var(--transition-duration);
|
||||
}
|
||||
|
||||
@mixin focused-inset() {
|
||||
outline-offset: -1px;
|
||||
box-shadow: inset var(--focus-ring-shadow);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
.preloader {
|
||||
position: fixed;
|
||||
z-index: 999999;
|
||||
background: #edf1f5;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.preloader-content {
|
||||
border: 0 solid transparent;
|
||||
border-radius: 50%;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
position: absolute;
|
||||
top: calc(50vh - 75px);
|
||||
left: calc(50vw - 75px);
|
||||
}
|
||||
|
||||
.preloader-content:before, .preloader-content:after{
|
||||
content: '';
|
||||
border: 1em solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
animation: loader 2s linear infinite;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.preloader-content:before{
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
@keyframes loader{
|
||||
0%{
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
50%{
|
||||
opacity: 1;
|
||||
}
|
||||
100%{
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
@media screen and (min-width: 1960px) {
|
||||
.layout-main,
|
||||
.landing-wrapper {
|
||||
width: 1504px;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.layout-wrapper {
|
||||
&.layout-overlay {
|
||||
.layout-main-container {
|
||||
margin-left: 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
transform: translateX(-100%);
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-right: 1px solid var(--surface-border);
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99),
|
||||
left 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99);
|
||||
box-shadow:
|
||||
0px 3px 5px rgba(0, 0, 0, 0.02),
|
||||
0px 0px 2px rgba(0, 0, 0, 0.05),
|
||||
0px 1px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&.layout-overlay-active {
|
||||
.layout-sidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.layout-static {
|
||||
.layout-main-container {
|
||||
margin-left: 22rem;
|
||||
}
|
||||
|
||||
&.layout-static-inactive {
|
||||
.layout-sidebar {
|
||||
transform: translateX(-100%);
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.layout-main-container {
|
||||
margin-left: 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-mask {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.blocked-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-wrapper {
|
||||
.layout-main-container {
|
||||
margin-left: 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
transform: translateX(-100%);
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99),
|
||||
left 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99);
|
||||
}
|
||||
|
||||
.layout-mask {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 998;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--maskbg);
|
||||
}
|
||||
|
||||
&.layout-mobile-active {
|
||||
.layout-sidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.layout-mask {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
@use 'mixins' as *;
|
||||
|
||||
.layout-topbar {
|
||||
position: fixed;
|
||||
height: 4rem;
|
||||
z-index: 997;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
padding: 0 2rem;
|
||||
background-color: var(--surface-card);
|
||||
transition: left var(--layout-section-transition-duration);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.layout-topbar-logo-container {
|
||||
width: 20rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.layout-topbar-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 1.5rem;
|
||||
border-radius: var(--content-border-radius);
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
gap: 0.5rem;
|
||||
|
||||
svg {
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focused();
|
||||
}
|
||||
}
|
||||
|
||||
.layout-topbar-action {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
color: var(--text-color);
|
||||
transition: background-color var(--element-transition-duration);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focused();
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 1rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.layout-topbar-action-highlight {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--primary-contrast-color);
|
||||
}
|
||||
}
|
||||
|
||||
.layout-menu-button {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.layout-topbar-menu-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.layout-topbar-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.layout-topbar-menu-content {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.layout-config-menu {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.layout-topbar {
|
||||
padding: 0 2rem;
|
||||
|
||||
.layout-topbar-logo-container {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.layout-menu-button {
|
||||
margin-left: 0;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.layout-topbar-menu-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.layout-topbar-menu {
|
||||
position: absolute;
|
||||
background-color: var(--surface-overlay);
|
||||
transform-origin: top;
|
||||
box-shadow:
|
||||
0px 3px 5px rgba(0, 0, 0, 0.02),
|
||||
0px 0px 2px rgba(0, 0, 0, 0.05),
|
||||
0px 1px 4px rgba(0, 0, 0, 0.08);
|
||||
border-radius: var(--content-border-radius);
|
||||
padding: 1rem;
|
||||
right: 2rem;
|
||||
top: 4rem;
|
||||
min-width: 15rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
|
||||
.layout-topbar-menu-content {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.layout-topbar-action {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
justify-content: flex-start;
|
||||
border-radius: var(--content-border-radius);
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
i {
|
||||
font-size: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: medium;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-topbar-menu-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
.config-panel-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary-color);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.config-panel-colors {
|
||||
> div {
|
||||
padding-top: 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
|
||||
button {
|
||||
border: none;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
outline-color: transparent;
|
||||
outline-width: 2px;
|
||||
outline-style: solid;
|
||||
outline-offset: 1px;
|
||||
|
||||
&.active-color {
|
||||
outline-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.config-panel-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 1.5rem 0 1rem 0;
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
color: var(--text-color);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
mark {
|
||||
background: #fff8e1;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: var(--content-border-radius);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1rem 0;
|
||||
padding: 0 2rem;
|
||||
border-left: 4px solid #90a4ae;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top: solid var(--surface-border);
|
||||
border-width: 1px 0 0 0;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.5;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/* Utils */
|
||||
.clearfix:after {
|
||||
content: ' ';
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface-card);
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: var(--content-border-radius);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.p-toast {
|
||||
&.p-toast-top-right,
|
||||
&.p-toast-top-left,
|
||||
&.p-toast-top-center {
|
||||
top: 100px;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
@use './variables/_common';
|
||||
@use './variables/_light';
|
||||
@use './variables/_dark';
|
||||
@use './_mixins';
|
||||
@use './_preloading';
|
||||
@use './_core';
|
||||
@use './_main';
|
||||
@use './_topbar';
|
||||
@use './_menu';
|
||||
@use './_footer';
|
||||
@use './_responsive';
|
||||
@use './_utils';
|
||||
@use './_typography';
|
||||
@@ -1,20 +0,0 @@
|
||||
:root {
|
||||
--primary-color: var(--p-primary-color);
|
||||
--primary-contrast-color: var(--p-primary-contrast-color);
|
||||
--text-color: var(--p-text-color);
|
||||
--text-color-secondary: var(--p-text-muted-color);
|
||||
--surface-border: var(--p-content-border-color);
|
||||
--surface-card: var(--p-content-background);
|
||||
--surface-hover: var(--p-content-hover-background);
|
||||
--surface-overlay: var(--p-overlay-popover-background);
|
||||
--transition-duration: var(--p-transition-duration);
|
||||
--maskbg: var(--p-mask-background);
|
||||
--content-border-radius: var(--p-content-border-radius);
|
||||
--layout-section-transition-duration: 0.2s;
|
||||
--element-transition-duration: var(--p-transition-duration);
|
||||
--focus-ring-width: var(--p-focus-ring-width);
|
||||
--focus-ring-style: var(--p-focus-ring-style);
|
||||
--focus-ring-color: var(--p-focus-ring-color);
|
||||
--focus-ring-offset: var(--p-focus-ring-offset);
|
||||
--focus-ring-shadow: var(--p-focus-ring-shadow);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
:root[class*='app-dark'] {
|
||||
--surface-ground: var(--p-surface-950);
|
||||
--code-background: var(--p-surface-800);
|
||||
--code-color: var(--p-surface-100);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
:root {
|
||||
--surface-ground: var(--p-surface-100);
|
||||
--code-background: var(--p-surface-900);
|
||||
--code-color: var(--p-surface-200);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
@use 'primeicons/primeicons.css';
|
||||
@use '@/assets/layout/layout.scss';
|
||||
@use '@/assets/demo/demo.scss';
|
||||
@@ -1,32 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@plugin 'tailwindcss-primeui';
|
||||
|
||||
@custom-variant dark (&:where([class*="app-dark"], [class*="app-dark"] *));
|
||||
|
||||
@theme {
|
||||
--breakpoint-*: initial;
|
||||
--breakpoint-sm: 576px;
|
||||
--breakpoint-md: 768px;
|
||||
--breakpoint-lg: 992px;
|
||||
--breakpoint-xl: 1200px;
|
||||
--breakpoint-2xl: 1920px;
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
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>
|
||||
@@ -15,7 +15,7 @@ const items = ref([
|
||||
<div class="font-semibold text-xl">Best Selling Products</div>
|
||||
<div>
|
||||
<Button icon="pi pi-ellipsis-v" class="p-button-text p-button-plain p-button-rounded" @click="$refs.menu.toggle($event)"></Button>
|
||||
<Menu ref="menu" popup :model="items" class="!min-w-40"></Menu>
|
||||
<Menu ref="menu" popup :model="items" class="min-w-40!"></Menu>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-none p-0 m-0">
|
||||
|
||||
@@ -15,7 +15,7 @@ const items = ref([
|
||||
<div class="font-semibold text-xl">Notifications</div>
|
||||
<div>
|
||||
<Button icon="pi pi-ellipsis-v" class="p-button-text p-button-plain p-button-rounded" @click="$refs.menu.toggle($event)"></Button>
|
||||
<Menu ref="menu" popup :model="items" class="!min-w-40"></Menu>
|
||||
<Menu ref="menu" popup :model="items" class="min-w-40!"></Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ const items = ref([
|
||||
<ul class="p-0 mx-0 mt-0 mb-6 list-none">
|
||||
<li class="flex items-center py-2 border-b border-surface">
|
||||
<div class="w-12 h-12 flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-full mr-4 shrink-0">
|
||||
<i class="pi pi-dollar !text-xl text-blue-500"></i>
|
||||
<i class="pi pi-dollar text-xl! text-blue-500"></i>
|
||||
</div>
|
||||
<span class="text-surface-900 dark:text-surface-0 leading-normal"
|
||||
>Richard Jones
|
||||
@@ -32,7 +32,7 @@ const items = ref([
|
||||
</li>
|
||||
<li class="flex items-center py-2">
|
||||
<div class="w-12 h-12 flex items-center justify-center bg-orange-100 dark:bg-orange-400/10 rounded-full mr-4 shrink-0">
|
||||
<i class="pi pi-download !text-xl text-orange-500"></i>
|
||||
<i class="pi pi-download text-xl! text-orange-500"></i>
|
||||
</div>
|
||||
<span class="text-surface-700 dark:text-surface-100 leading-normal">Your request for withdrawal of <span class="text-primary font-bold">$2500.00</span> has been initiated.</span>
|
||||
</li>
|
||||
@@ -42,7 +42,7 @@ const items = ref([
|
||||
<ul class="p-0 m-0 list-none mb-6">
|
||||
<li class="flex items-center py-2 border-b border-surface">
|
||||
<div class="w-12 h-12 flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-full mr-4 shrink-0">
|
||||
<i class="pi pi-dollar !text-xl text-blue-500"></i>
|
||||
<i class="pi pi-dollar text-xl! text-blue-500"></i>
|
||||
</div>
|
||||
<span class="text-surface-900 dark:text-surface-0 leading-normal"
|
||||
>Keyser Wick
|
||||
@@ -51,7 +51,7 @@ const items = ref([
|
||||
</li>
|
||||
<li class="flex items-center py-2 border-b border-surface">
|
||||
<div class="w-12 h-12 flex items-center justify-center bg-pink-100 dark:bg-pink-400/10 rounded-full mr-4 shrink-0">
|
||||
<i class="pi pi-question !text-xl text-pink-500"></i>
|
||||
<i class="pi pi-question text-xl! text-pink-500"></i>
|
||||
</div>
|
||||
<span class="text-surface-900 dark:text-surface-0 leading-normal"
|
||||
>Jane Davis
|
||||
@@ -63,13 +63,13 @@ const items = ref([
|
||||
<ul class="p-0 m-0 list-none">
|
||||
<li class="flex items-center py-2 border-b border-surface">
|
||||
<div class="w-12 h-12 flex items-center justify-center bg-green-100 dark:bg-green-400/10 rounded-full mr-4 shrink-0">
|
||||
<i class="pi pi-arrow-up !text-xl text-green-500"></i>
|
||||
<i class="pi pi-arrow-up text-xl! text-green-500"></i>
|
||||
</div>
|
||||
<span class="text-surface-900 dark:text-surface-0 leading-normal">Your revenue has increased by <span class="text-primary font-bold">%25</span>.</span>
|
||||
</li>
|
||||
<li class="flex items-center py-2 border-b border-surface">
|
||||
<div class="w-12 h-12 flex items-center justify-center bg-purple-100 dark:bg-purple-400/10 rounded-full mr-4 shrink-0">
|
||||
<i class="pi pi-heart !text-xl text-purple-500"></i>
|
||||
<i class="pi pi-heart text-xl! text-purple-500"></i>
|
||||
</div>
|
||||
<span class="text-surface-900 dark:text-surface-0 leading-normal"><span class="text-primary font-bold">12</span> users have added your products to their wishlist.</span>
|
||||
</li>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
const { getPrimary, getSurface, isDarkTheme } = useLayout();
|
||||
const { layoutConfig, isDarkTheme } = useLayout();
|
||||
|
||||
const chartData = ref(null);
|
||||
const chartOptions = ref(null);
|
||||
@@ -77,7 +77,7 @@ function setChartOptions() {
|
||||
};
|
||||
}
|
||||
|
||||
watch([getPrimary, getSurface, isDarkTheme], () => {
|
||||
watch([() => layoutConfig.primary, () => layoutConfig.surface, isDarkTheme], () => {
|
||||
chartData.value = setChartData();
|
||||
chartOptions.value = setChartOptions();
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="text-surface-900 dark:text-surface-0 font-medium text-xl">152</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
|
||||
<i class="pi pi-shopping-cart text-blue-500 !text-xl"></i>
|
||||
<i class="pi pi-shopping-cart text-blue-500 text-xl!"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-primary font-medium">24 new </span>
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="text-surface-900 dark:text-surface-0 font-medium text-xl">$2.100</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center bg-orange-100 dark:bg-orange-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
|
||||
<i class="pi pi-dollar text-orange-500 !text-xl"></i>
|
||||
<i class="pi pi-dollar text-orange-500 text-xl!"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-primary font-medium">%52+ </span>
|
||||
@@ -37,7 +37,7 @@
|
||||
<div class="text-surface-900 dark:text-surface-0 font-medium text-xl">28441</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center bg-cyan-100 dark:bg-cyan-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
|
||||
<i class="pi pi-users text-cyan-500 !text-xl"></i>
|
||||
<i class="pi pi-users text-cyan-500 text-xl!"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-primary font-medium">520 </span>
|
||||
@@ -52,7 +52,7 @@
|
||||
<div class="text-surface-900 dark:text-surface-0 font-medium text-xl">152 Unread</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center bg-purple-100 dark:bg-purple-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
|
||||
<i class="pi pi-comment text-purple-500 !text-xl"></i>
|
||||
<i class="pi pi-comment text-purple-500 text-xl!"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-primary font-medium">85 </span>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div style="height: 160px; padding: 2px; border-radius: 10px; background: linear-gradient(90deg, rgba(253, 228, 165, 0.2), rgba(187, 199, 205, 0.2)), linear-gradient(180deg, rgba(253, 228, 165, 0.2), rgba(187, 199, 205, 0.2))">
|
||||
<div class="p-4 bg-surface-0 dark:bg-surface-900 h-full" style="border-radius: 8px">
|
||||
<div class="flex items-center justify-center bg-yellow-200 mb-4" style="width: 3.5rem; height: 3.5rem; border-radius: 10px">
|
||||
<i class="pi pi-fw pi-users !text-2xl text-yellow-700"></i>
|
||||
<i class="pi pi-fw pi-users text-2xl! text-yellow-700"></i>
|
||||
</div>
|
||||
<h5 class="mb-2 text-surface-900 dark:text-surface-0">Easy to Use</h5>
|
||||
<span class="text-surface-600 dark:text-surface-200">Posuere morbi leo urna molestie.</span>
|
||||
@@ -22,7 +22,7 @@
|
||||
<div style="height: 160px; padding: 2px; border-radius: 10px; background: linear-gradient(90deg, rgba(145, 226, 237, 0.2), rgba(251, 199, 145, 0.2)), linear-gradient(180deg, rgba(253, 228, 165, 0.2), rgba(172, 180, 223, 0.2))">
|
||||
<div class="p-4 bg-surface-0 dark:bg-surface-900 h-full" style="border-radius: 8px">
|
||||
<div class="flex items-center justify-center bg-cyan-200 mb-4" style="width: 3.5rem; height: 3.5rem; border-radius: 10px">
|
||||
<i class="pi pi-fw pi-palette !text-2xl text-cyan-700"></i>
|
||||
<i class="pi pi-fw pi-palette text-2xl! text-cyan-700"></i>
|
||||
</div>
|
||||
<h5 class="mb-2 text-surface-900 dark:text-surface-0">Fresh Design</h5>
|
||||
<span class="text-surface-600 dark:text-surface-200">Semper risus in hendrerit.</span>
|
||||
@@ -34,7 +34,7 @@
|
||||
<div style="height: 160px; padding: 2px; border-radius: 10px; background: linear-gradient(90deg, rgba(145, 226, 237, 0.2), rgba(172, 180, 223, 0.2)), linear-gradient(180deg, rgba(172, 180, 223, 0.2), rgba(246, 158, 188, 0.2))">
|
||||
<div class="p-4 bg-surface-0 dark:bg-surface-900 h-full" style="border-radius: 8px">
|
||||
<div class="flex items-center justify-center bg-indigo-200" style="width: 3.5rem; height: 3.5rem; border-radius: 10px">
|
||||
<i class="pi pi-fw pi-map !text-2xl text-indigo-700"></i>
|
||||
<i class="pi pi-fw pi-map text-2xl! text-indigo-700"></i>
|
||||
</div>
|
||||
<div class="mt-6 mb-1 text-surface-900 dark:text-surface-0 text-xl font-semibold">Well Documented</div>
|
||||
<span class="text-surface-600 dark:text-surface-200">Non arcu risus quis varius quam quisque.</span>
|
||||
@@ -46,7 +46,7 @@
|
||||
<div style="height: 160px; padding: 2px; border-radius: 10px; background: linear-gradient(90deg, rgba(187, 199, 205, 0.2), rgba(251, 199, 145, 0.2)), linear-gradient(180deg, rgba(253, 228, 165, 0.2), rgba(145, 210, 204, 0.2))">
|
||||
<div class="p-4 bg-surface-0 dark:bg-surface-900 h-full" style="border-radius: 8px">
|
||||
<div class="flex items-center justify-center bg-slate-200 mb-4" style="width: 3.5rem; height: 3.5rem; border-radius: 10px">
|
||||
<i class="pi pi-fw pi-id-card !text-2xl text-slate-700"></i>
|
||||
<i class="pi pi-fw pi-id-card text-2xl! text-slate-700"></i>
|
||||
</div>
|
||||
<div class="mt-6 mb-1 text-surface-900 dark:text-surface-0 text-xl font-semibold">Responsive Layout</div>
|
||||
<span class="text-surface-600 dark:text-surface-200">Nulla malesuada pellentesque elit.</span>
|
||||
@@ -58,7 +58,7 @@
|
||||
<div style="height: 160px; padding: 2px; border-radius: 10px; background: linear-gradient(90deg, rgba(187, 199, 205, 0.2), rgba(246, 158, 188, 0.2)), linear-gradient(180deg, rgba(145, 226, 237, 0.2), rgba(160, 210, 250, 0.2))">
|
||||
<div class="p-4 bg-surface-0 dark:bg-surface-900 h-full" style="border-radius: 8px">
|
||||
<div class="flex items-center justify-center bg-orange-200 mb-4" style="width: 3.5rem; height: 3.5rem; border-radius: 10px">
|
||||
<i class="pi pi-fw pi-star !text-2xl text-orange-700"></i>
|
||||
<i class="pi pi-fw pi-star text-2xl! text-orange-700"></i>
|
||||
</div>
|
||||
<div class="mt-6 mb-1 text-surface-900 dark:text-surface-0 text-xl font-semibold">Clean Code</div>
|
||||
<span class="text-surface-600 dark:text-surface-200">Condimentum lacinia quis vel eros.</span>
|
||||
@@ -70,7 +70,7 @@
|
||||
<div style="height: 160px; padding: 2px; border-radius: 10px; background: linear-gradient(90deg, rgba(251, 199, 145, 0.2), rgba(246, 158, 188, 0.2)), linear-gradient(180deg, rgba(172, 180, 223, 0.2), rgba(212, 162, 221, 0.2))">
|
||||
<div class="p-4 bg-surface-0 dark:bg-surface-900 h-full" style="border-radius: 8px">
|
||||
<div class="flex items-center justify-center bg-pink-200 mb-4" style="width: 3.5rem; height: 3.5rem; border-radius: 10px">
|
||||
<i class="pi pi-fw pi-moon !text-2xl text-pink-700"></i>
|
||||
<i class="pi pi-fw pi-moon text-2xl! text-pink-700"></i>
|
||||
</div>
|
||||
<div class="mt-6 mb-1 text-surface-900 dark:text-surface-0 text-xl font-semibold">Dark Mode</div>
|
||||
<span class="text-surface-600 dark:text-surface-200">Convallis tellus id interdum velit laoreet.</span>
|
||||
@@ -82,7 +82,7 @@
|
||||
<div style="height: 160px; padding: 2px; border-radius: 10px; background: linear-gradient(90deg, rgba(145, 210, 204, 0.2), rgba(160, 210, 250, 0.2)), linear-gradient(180deg, rgba(187, 199, 205, 0.2), rgba(145, 210, 204, 0.2))">
|
||||
<div class="p-4 bg-surface-0 dark:bg-surface-900 h-full" style="border-radius: 8px">
|
||||
<div class="flex items-center justify-center bg-teal-200 mb-4" style="width: 3.5rem; height: 3.5rem; border-radius: 10px">
|
||||
<i class="pi pi-fw pi-shopping-cart !text-2xl text-teal-700"></i>
|
||||
<i class="pi pi-fw pi-shopping-cart text-2xl! text-teal-700"></i>
|
||||
</div>
|
||||
<div class="mt-6 mb-1 text-surface-900 dark:text-surface-0 text-xl font-semibold">Ready to Use</div>
|
||||
<span class="text-surface-600 dark:text-surface-200">Mauris sit amet massa vitae.</span>
|
||||
@@ -94,7 +94,7 @@
|
||||
<div style="height: 160px; padding: 2px; border-radius: 10px; background: linear-gradient(90deg, rgba(145, 210, 204, 0.2), rgba(212, 162, 221, 0.2)), linear-gradient(180deg, rgba(251, 199, 145, 0.2), rgba(160, 210, 250, 0.2))">
|
||||
<div class="p-4 bg-surface-0 dark:bg-surface-900 h-full" style="border-radius: 8px">
|
||||
<div class="flex items-center justify-center bg-blue-200 mb-4" style="width: 3.5rem; height: 3.5rem; border-radius: 10px">
|
||||
<i class="pi pi-fw pi-globe !text-2xl text-blue-700"></i>
|
||||
<i class="pi pi-fw pi-globe text-2xl! text-blue-700"></i>
|
||||
</div>
|
||||
<div class="mt-6 mb-1 text-surface-900 dark:text-surface-0 text-xl font-semibold">Modern Practices</div>
|
||||
<span class="text-surface-600 dark:text-surface-200">Elementum nibh tellus molestie nunc non.</span>
|
||||
@@ -106,7 +106,7 @@
|
||||
<div style="height: 160px; padding: 2px; border-radius: 10px; background: linear-gradient(90deg, rgba(160, 210, 250, 0.2), rgba(212, 162, 221, 0.2)), linear-gradient(180deg, rgba(246, 158, 188, 0.2), rgba(212, 162, 221, 0.2))">
|
||||
<div class="p-4 bg-surface-0 dark:bg-surface-900 h-full" style="border-radius: 8px">
|
||||
<div class="flex items-center justify-center bg-purple-200 mb-4" style="width: 3.5rem; height: 3.5rem; border-radius: 10px">
|
||||
<i class="pi pi-fw pi-eye !text-2xl text-purple-700"></i>
|
||||
<i class="pi pi-fw pi-eye text-2xl! text-purple-700"></i>
|
||||
</div>
|
||||
<div class="mt-6 mb-1 text-surface-900 dark:text-surface-0 text-xl font-semibold">Privacy</div>
|
||||
<span class="text-surface-600 dark:text-surface-200">Neque egestas congue quisque.</span>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="mx-6 md:mx-20 mt-0 md:mt-6">
|
||||
<h1 class="text-6xl font-bold text-gray-900 leading-tight"><span class="font-light block">Eu sem integer</span>eget magna fermentum</h1>
|
||||
<p class="font-normal text-2xl leading-normal md:mt-4 text-gray-700">Sed blandit libero volutpat sed cras. Fames ac turpis egestas integer. Placerat in egestas erat...</p>
|
||||
<Button label="Get Started" as="router-link" to="/" rounded class="!text-xl mt-8 !px-4"></Button>
|
||||
<Button label="Get Started" as="router-link" to="/" rounded class="text-xl! mt-8 px-4!"></Button>
|
||||
</div>
|
||||
<div class="flex justify-center md:justify-end">
|
||||
<img src="/demo/images/landing/screen-1.png" alt="Hero Image" class="w-9/12 md:w-auto" />
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<div class="col-span-12 lg:col-span-6 my-auto flex flex-col lg:items-end text-center lg:text-right gap-4">
|
||||
<div class="flex items-center justify-center bg-purple-200 self-center lg:self-end" style="width: 4.2rem; height: 4.2rem; border-radius: 10px">
|
||||
<i class="pi pi-fw pi-mobile !text-4xl text-purple-700"></i>
|
||||
<i class="pi pi-fw pi-mobile text-4xl! text-purple-700"></i>
|
||||
</div>
|
||||
<div class="leading-none text-surface-900 dark:text-surface-0 text-3xl font-normal">Congue Quisque Egestas</div>
|
||||
<span class="text-surface-700 dark:text-surface-100 text-2xl leading-normal ml-0 md:ml-2" style="max-width: 650px"
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="grid grid-cols-12 gap-4 my-20 pt-2 md:pt-20">
|
||||
<div class="col-span-12 lg:col-span-6 my-auto flex flex-col text-center lg:text-left lg:items-start gap-4">
|
||||
<div class="flex items-center justify-center bg-yellow-200 self-center lg:self-start" style="width: 4.2rem; height: 4.2rem; border-radius: 10px">
|
||||
<i class="pi pi-fw pi-desktop !text-3xl text-yellow-700"></i>
|
||||
<i class="pi pi-fw pi-desktop text-3xl! text-yellow-700"></i>
|
||||
</div>
|
||||
<div class="leading-none text-surface-900 dark:text-surface-0 text-3xl font-normal">Celerisque Eu Ultrices</div>
|
||||
<span class="text-surface-700 dark:text-surface-100 text-2xl leading-normal mr-0 md:mr-2" style="max-width: 650px"
|
||||
|
||||
@@ -33,13 +33,13 @@ function smoothScroll(id) {
|
||||
<span class="text-surface-900 dark:text-surface-0 font-medium text-2xl leading-normal mr-20">SAKAI</span>
|
||||
</a>
|
||||
<Button
|
||||
class="lg:!hidden"
|
||||
class="lg:hidden!"
|
||||
text
|
||||
severity="secondary"
|
||||
rounded
|
||||
v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'animate-scalein', leaveToClass: 'hidden', leaveActiveClass: 'animate-fadeout', hideOnOutsideClick: true }"
|
||||
>
|
||||
<i class="pi pi-bars !text-2xl"></i>
|
||||
<i class="pi pi-bars text-2xl!"></i>
|
||||
</Button>
|
||||
<div class="items-center bg-surface-0 dark:bg-surface-900 grow justify-between hidden lg:flex absolute lg:static w-full left-0 top-full px-12 lg:px-0 z-20 rounded-border">
|
||||
<ul class="list-none p-0 m-0 flex lg:items-center select-none flex-col lg:flex-row cursor-pointer gap-8">
|
||||
|
||||
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,247 +1,119 @@
|
||||
<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 } = 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([
|
||||
{ label: 'Static', value: 'static' },
|
||||
{ label: 'Overlay', value: 'overlay' }
|
||||
]);
|
||||
// ✅ vem do AppTopbar (mesma instância)
|
||||
const queuePatch = inject('queueUserSettingsPatch', null)
|
||||
console.log('[AppConfigurator] queuePatch injected?', !!queuePatch)
|
||||
|
||||
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' } }
|
||||
]);
|
||||
// menu mode options
|
||||
const menuModeOptions = [
|
||||
{ label: 'Static', value: 'static' },
|
||||
{ label: 'Overlay', value: 'overlay' }
|
||||
]
|
||||
|
||||
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' }
|
||||
}
|
||||
]);
|
||||
// ✅ v-model sincronizado (sem state local)
|
||||
const presetModel = computed({
|
||||
get: () => layoutConfig.preset,
|
||||
set: (val) => {
|
||||
if (!val || val === layoutConfig.preset) return
|
||||
layoutConfig.preset = val
|
||||
|
||||
function getPresetExt() {
|
||||
const color = primaryColors.value.find((c) => c.name === layoutConfig.primary);
|
||||
applyThemeEngine(layoutConfig)
|
||||
queuePatch?.({ preset: 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)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
const menuModeModel = computed({
|
||||
get: () => layoutConfig.menuMode,
|
||||
set: (val) => {
|
||||
if (!val || val === layoutConfig.menuMode) return
|
||||
layoutConfig.menuMode = val
|
||||
|
||||
function updateColors(type, color) {
|
||||
if (type === 'primary') {
|
||||
layoutConfig.primary = color.name;
|
||||
} else if (type === 'surface') {
|
||||
layoutConfig.surface = color.name;
|
||||
}
|
||||
// composable pode aceitar nada (no teu caso, costuma ser isso)
|
||||
try { changeMenuMode() } catch {}
|
||||
|
||||
applyTheme(type, color);
|
||||
}
|
||||
queuePatch?.({ menu_mode: val })
|
||||
}
|
||||
})
|
||||
|
||||
function applyTheme(type, color) {
|
||||
if (type === 'primary') {
|
||||
updatePreset(getPresetExt());
|
||||
} else if (type === 'surface') {
|
||||
updateSurfacePalette(color.palette);
|
||||
}
|
||||
}
|
||||
function updateColors(type, item) {
|
||||
if (type === 'primary') {
|
||||
layoutConfig.primary = item.name
|
||||
applyThemeEngine(layoutConfig)
|
||||
queuePatch?.({ primary_color: item.name })
|
||||
return
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
function onMenuModeChange() {
|
||||
layoutConfig.menuMode = menuMode.value;
|
||||
if (type === 'surface') {
|
||||
layoutConfig.surface = item.name
|
||||
applyThemeEngine(layoutConfig)
|
||||
queuePatch?.({ surface_color: item.name })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="config-panel hidden absolute top-[3.25rem] right-0 w-64 p-4 bg-surface-0 dark:bg-surface-900 border border-surface rounded-border origin-top shadow-[0px_3px_5px_rgba(0,0,0,0.02),0px_0px_2px_rgba(0,0,0,0.05),0px_1px_4px_rgba(0,0,0,0.08)]"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<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"
|
||||
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>
|
||||
</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"
|
||||
type="button"
|
||||
:title="surface.name"
|
||||
@click="updateColors('surface', surface)"
|
||||
:class="[
|
||||
'border-none w-5 h-5 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
|
||||
{ 'outline-primary': layoutConfig.surface ? layoutConfig.surface === surface.name : isDarkTheme ? surface.name === 'zinc' : surface.name === 'slate' }
|
||||
]"
|
||||
:style="{ backgroundColor: `${surface.palette['500']}` }"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-muted-color font-semibold">Presets</span>
|
||||
<SelectButton v-model="preset" @change="onPresetChange" :options="presetOptions" :allowEmpty="false" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-muted-color font-semibold">Menu Mode</span>
|
||||
<SelectButton v-model="menuMode" @change="onMenuModeChange" :options="menuModeOptions" :allowEmpty="false" optionLabel="label" optionValue="value" />
|
||||
</div>
|
||||
<div
|
||||
class="config-panel hidden absolute top-[3.25rem] right-0 w-64 p-4 bg-surface-0 dark:bg-surface-900 border border-surface rounded-border origin-top shadow-[0px_3px_5px_rgba(0,0,0,0.02),0px_0px_2px_rgba(0,0,0,0.05),0px_1px_4px_rgba(0,0,0,0.08)]"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<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="c of primaryColors"
|
||||
:key="c.name"
|
||||
type="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="s of surfaces"
|
||||
:key="s.name"
|
||||
type="button"
|
||||
:title="s.name"
|
||||
@click="updateColors('surface', s)"
|
||||
:class="[
|
||||
'border-none w-5 h-5 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
|
||||
{ 'outline-primary': layoutConfig.surface ? layoutConfig.surface === s.name : (isDarkTheme ? s.name === 'zinc' : s.name === 'slate') }
|
||||
]"
|
||||
:style="{ backgroundColor: `${s.palette['500']}` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-muted-color font-semibold">Presets</span>
|
||||
<SelectButton v-model="presetModel" :options="presetOptions" :allowEmpty="false" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-muted-color font-semibold">Menu Mode</span>
|
||||
<SelectButton
|
||||
v-model="menuModeModel"
|
||||
:options="menuModeOptions"
|
||||
:allowEmpty="false"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,71 +1,34 @@
|
||||
<script setup>
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import AppFooter from './AppFooter.vue';
|
||||
import AppSidebar from './AppSidebar.vue';
|
||||
import AppTopbar from './AppTopbar.vue';
|
||||
|
||||
const { layoutConfig, layoutState, isSidebarActive } = useLayout();
|
||||
|
||||
const outsideClickListener = ref(null);
|
||||
|
||||
watch(isSidebarActive, (newVal) => {
|
||||
if (newVal) {
|
||||
bindOutsideClickListener();
|
||||
} else {
|
||||
unbindOutsideClickListener();
|
||||
}
|
||||
});
|
||||
const { layoutConfig, layoutState, hideMobileMenu } = useLayout();
|
||||
|
||||
const containerClass = computed(() => {
|
||||
return {
|
||||
'layout-overlay': layoutConfig.menuMode === 'overlay',
|
||||
'layout-static': layoutConfig.menuMode === 'static',
|
||||
'layout-static-inactive': layoutState.staticMenuDesktopInactive && layoutConfig.menuMode === 'static',
|
||||
'layout-overlay-active': layoutState.overlayMenuActive,
|
||||
'layout-mobile-active': layoutState.staticMenuMobileActive
|
||||
'layout-mobile-active': layoutState.mobileMenuActive,
|
||||
'layout-static-inactive': layoutState.staticMenuInactive
|
||||
};
|
||||
});
|
||||
|
||||
function bindOutsideClickListener() {
|
||||
if (!outsideClickListener.value) {
|
||||
outsideClickListener.value = (event) => {
|
||||
if (isOutsideClicked(event)) {
|
||||
layoutState.overlayMenuActive = false;
|
||||
layoutState.staticMenuMobileActive = false;
|
||||
layoutState.menuHoverActive = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', outsideClickListener.value);
|
||||
}
|
||||
}
|
||||
|
||||
function unbindOutsideClickListener() {
|
||||
if (outsideClickListener.value) {
|
||||
document.removeEventListener('click', outsideClickListener);
|
||||
outsideClickListener.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function isOutsideClicked(event) {
|
||||
const sidebarEl = document.querySelector('.layout-sidebar');
|
||||
const topbarEl = document.querySelector('.layout-menu-button');
|
||||
|
||||
return !(sidebarEl.isSameNode(event.target) || sidebarEl.contains(event.target) || topbarEl.isSameNode(event.target) || topbarEl.contains(event.target));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-wrapper" :class="containerClass">
|
||||
<app-topbar></app-topbar>
|
||||
<app-sidebar></app-sidebar>
|
||||
<AppTopbar />
|
||||
<AppSidebar />
|
||||
<div class="layout-main-container">
|
||||
<div class="layout-main">
|
||||
<router-view></router-view>
|
||||
<router-view />
|
||||
</div>
|
||||
<app-footer></app-footer>
|
||||
<AppFooter />
|
||||
</div>
|
||||
<div class="layout-mask animate-fadein"></div>
|
||||
<div class="layout-mask animate-fadein" @click="hideMobileMenu" />
|
||||
</div>
|
||||
<Toast />
|
||||
</template>
|
||||
|
||||
@@ -1,168 +1,483 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
|
||||
import AppMenuItem from './AppMenuItem.vue';
|
||||
import AppMenuItem from './AppMenuItem.vue'
|
||||
import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
|
||||
|
||||
const model = ref([
|
||||
{
|
||||
label: 'Home',
|
||||
items: [{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' }]
|
||||
},
|
||||
{
|
||||
label: 'UI Components',
|
||||
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' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Prime Blocks',
|
||||
icon: 'pi pi-fw pi-prime',
|
||||
items: [
|
||||
{
|
||||
label: 'Free Blocks',
|
||||
icon: 'pi pi-fw pi-eye',
|
||||
to: '/blocks'
|
||||
},
|
||||
{
|
||||
label: 'All Blocks',
|
||||
icon: 'pi pi-fw pi-globe',
|
||||
url: 'https://blocks.primevue.org/',
|
||||
target: '_blank'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Pages',
|
||||
icon: 'pi pi-fw pi-briefcase',
|
||||
to: '/pages',
|
||||
items: [
|
||||
{
|
||||
label: 'Landing',
|
||||
icon: 'pi pi-fw pi-globe',
|
||||
to: '/landing'
|
||||
},
|
||||
{
|
||||
label: 'Auth',
|
||||
icon: 'pi pi-fw pi-user',
|
||||
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',
|
||||
items: [
|
||||
{
|
||||
label: 'Submenu 1',
|
||||
icon: 'pi pi-fw pi-bookmark',
|
||||
items: [
|
||||
{
|
||||
label: 'Submenu 1.1',
|
||||
icon: 'pi pi-fw pi-bookmark',
|
||||
items: [
|
||||
{ label: 'Submenu 1.1.1', icon: 'pi pi-fw pi-bookmark' },
|
||||
{ label: 'Submenu 1.1.2', icon: 'pi pi-fw pi-bookmark' },
|
||||
{ label: 'Submenu 1.1.3', icon: 'pi pi-fw pi-bookmark' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Submenu 1.2',
|
||||
icon: 'pi pi-fw pi-bookmark',
|
||||
items: [{ label: 'Submenu 1.2.1', icon: 'pi pi-fw pi-bookmark' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Submenu 2',
|
||||
icon: 'pi pi-fw pi-bookmark',
|
||||
items: [
|
||||
{
|
||||
label: 'Submenu 2.1',
|
||||
icon: 'pi pi-fw pi-bookmark',
|
||||
items: [
|
||||
{ label: 'Submenu 2.1.1', icon: 'pi pi-fw pi-bookmark' },
|
||||
{ label: 'Submenu 2.1.2', icon: 'pi pi-fw pi-bookmark' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Submenu 2.2',
|
||||
icon: 'pi pi-fw pi-bookmark',
|
||||
items: [{ label: 'Submenu 2.2.1', icon: 'pi pi-fw pi-bookmark' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Get Started',
|
||||
items: [
|
||||
{
|
||||
label: 'Documentation',
|
||||
icon: 'pi pi-fw pi-book',
|
||||
to: '/documentation'
|
||||
},
|
||||
{
|
||||
label: 'View Source',
|
||||
icon: 'pi pi-fw pi-github',
|
||||
url: 'https://github.com/primefaces/sakai-vue',
|
||||
target: '_blank'
|
||||
}
|
||||
]
|
||||
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 })
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sessionRole.value,
|
||||
async () => {
|
||||
if (!tenantId.value) return
|
||||
entitlementsStore.invalidate()
|
||||
await entitlementsStore.loadForTenant(tenantId.value, { force: true })
|
||||
}
|
||||
)
|
||||
|
||||
// ✅ 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
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
]);
|
||||
|
||||
if (it?.items?.length) {
|
||||
out.push(...flattenMenu(it.items, nextTrail))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const allLinks = computed(() => flattenMenu(model.value))
|
||||
|
||||
const results = computed(() => {
|
||||
const q = norm(query.value)
|
||||
if (!q) return []
|
||||
|
||||
const wantPro = q === 'pro' || q.startsWith('pro ') || q.includes(' pro')
|
||||
|
||||
return allLinks.value
|
||||
.filter(r => {
|
||||
const hay = `${norm(r.label)} ${norm(r.trail.join(' > '))} ${norm(r.to)}`
|
||||
if (hay.includes(q)) return true
|
||||
if (wantPro && (r.proBadge || r.feature)) return true
|
||||
return false
|
||||
})
|
||||
.slice(0, 12)
|
||||
})
|
||||
|
||||
watch(results, (list) => {
|
||||
activeIndex.value = list.length ? 0 : -1
|
||||
})
|
||||
|
||||
// ===== highlight =====
|
||||
function escapeHtml (s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.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
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (!results.value.length) return
|
||||
showResults.value = true
|
||||
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
if (showResults.value && results.value.length && activeIndex.value >= 0) {
|
||||
e.preventDefault()
|
||||
goTo(results.value[activeIndex.value])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isTypingTarget (el) {
|
||||
if (!el) return false
|
||||
const tag = (el.tagName || '').toLowerCase()
|
||||
return tag === 'input' || tag === 'textarea' || el.isContentEditable
|
||||
}
|
||||
|
||||
// ===== Ctrl/Cmd + K =====
|
||||
function focusSearch () {
|
||||
forcedOpen.value = true
|
||||
showResults.value = true
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const inst = searchEl.value
|
||||
const input =
|
||||
inst?.$el?.tagName === 'INPUT'
|
||||
? inst.$el
|
||||
: inst?.$el?.querySelector?.('input')
|
||||
|
||||
input?.focus?.()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function onGlobalKeydown (e) {
|
||||
if (isTypingTarget(document.activeElement)) return
|
||||
|
||||
const isK = e.key?.toLowerCase() === 'k'
|
||||
const withCmdOrCtrl = e.ctrlKey || e.metaKey
|
||||
|
||||
if (withCmdOrCtrl && isK) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
focusSearch()
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Recentes: aplicar query + abrir + focar (sem depender de watch timing)
|
||||
function applyRecent (q) {
|
||||
query.value = q
|
||||
forcedOpen.value = true
|
||||
showResults.value = true
|
||||
activeIndex.value = 0
|
||||
|
||||
nextTick(() => {
|
||||
// garante foco e teclado funcionando
|
||||
focusSearch()
|
||||
})
|
||||
}
|
||||
|
||||
// click outside para fechar painel
|
||||
function onDocMouseDown (e) {
|
||||
if (!showResults.value) return
|
||||
const root = searchWrapEl.value
|
||||
if (!root) return
|
||||
|
||||
if (!root.contains(e.target)) {
|
||||
showResults.value = false
|
||||
forcedOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onGlobalKeydown, true)
|
||||
document.addEventListener('mousedown', onDocMouseDown)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onGlobalKeydown, true)
|
||||
document.removeEventListener('mousedown', onDocMouseDown)
|
||||
})
|
||||
|
||||
async function goTo (r) {
|
||||
saveRecent(query.value)
|
||||
|
||||
query.value = ''
|
||||
showResults.value = false
|
||||
activeIndex.value = -1
|
||||
forcedOpen.value = false
|
||||
|
||||
await router.push(r.to)
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// Quick create
|
||||
// ==============================
|
||||
const quickDialog = ref(false)
|
||||
function onQuickCreate () { quickDialog.value = true }
|
||||
function onQuickCreated () { quickDialog.value = false }
|
||||
|
||||
// controle de “recentes”: mostrar ao focar (mesmo sem recentes, para exibir dicas)
|
||||
function onSearchFocus () {
|
||||
if (!query.value?.trim()) {
|
||||
forcedOpen.value = true
|
||||
showResults.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
<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>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<!-- ✅ botão limpar busca -->
|
||||
<button
|
||||
v-if="query.trim()"
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 opacity-70 hover:opacity-100"
|
||||
@mousedown.prevent="clearSearch"
|
||||
aria-label="Limpar busca"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Recentes (quando query vazio) -->
|
||||
<div
|
||||
v-if="showResults && !query.trim() && recent.length"
|
||||
class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
|
||||
>
|
||||
<div class="px-3 py-2 text-xs opacity-70 flex items-center justify-content-between">
|
||||
<span>Recentes</span>
|
||||
<button
|
||||
type="button"
|
||||
class="opacity-70 hover:opacity-100"
|
||||
@mousedown.prevent="clearRecent"
|
||||
aria-label="Limpar recentes"
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="q in recent"
|
||||
:key="q"
|
||||
class="w-full text-left px-3 py-2 hover:bg-[var(--surface-hover)] flex items-center gap-2"
|
||||
type="button"
|
||||
@click.stop.prevent="applyRecent(q)"
|
||||
>
|
||||
<i class="pi pi-history opacity-70" />
|
||||
<div class="flex-1">{{ q }}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Resultados -->
|
||||
<div
|
||||
v-else-if="showResults && results.length"
|
||||
class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
|
||||
>
|
||||
<button
|
||||
v-for="(r, i) in results"
|
||||
:key="r.to"
|
||||
type="button"
|
||||
@mousedown.prevent="goTo(r)"
|
||||
:class="[
|
||||
'w-full text-left px-3 py-2 flex items-center gap-2',
|
||||
i === activeIndex ? 'bg-[var(--surface-hover)]' : 'hover:bg-[var(--surface-hover)]'
|
||||
]"
|
||||
>
|
||||
<i v-if="r.icon" :class="r.icon" class="opacity-80" />
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="font-medium leading-tight" v-html="highlight(r.label, query)" />
|
||||
<small class="opacity-70">{{ r.trail.join(' > ') }}</small>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="r.proBadge || r.feature"
|
||||
class="text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"
|
||||
>
|
||||
PRO
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="showResults && query && !results.length"
|
||||
class="mt-2 px-3 py-2 text-sm opacity-70"
|
||||
>
|
||||
Nenhum item encontrado.
|
||||
</div>
|
||||
|
||||
<!-- ✅ instruções embaixo quando houver recentes/resultados/uso -->
|
||||
<div
|
||||
v-if="showResults && (recent.length || results.length || query.trim())"
|
||||
class="mt-2 px-3 text-xs opacity-70 flex flex-wrap gap-x-3 gap-y-1"
|
||||
>
|
||||
<span><b>Ctrl+K</b>/<b>Cmd+K</b> focar</span>
|
||||
<span><b>↑↓</b> navegar</span>
|
||||
<span><b>Enter</b> abrir</span>
|
||||
<span><b>Esc</b> fechar</span>
|
||||
</div>
|
||||
|
||||
<!-- fallback quando não tem nada -->
|
||||
<div
|
||||
v-else-if="showResults && !query.trim() && !recent.length"
|
||||
class="mt-2 px-3 py-2 text-xs opacity-60"
|
||||
>
|
||||
Dica: pressione <b>Ctrl + K</b> (ou <b>Cmd + K</b>) para buscar.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ SOMENTE O MENU ROLA -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<ul class="layout-menu pb-20">
|
||||
<template v-for="(item, i) in model" :key="i">
|
||||
<AppMenuItem
|
||||
:item="item"
|
||||
:index="i"
|
||||
:root="true"
|
||||
@quick-create="onQuickCreate"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- rodapé fixo -->
|
||||
<AppMenuFooterPanel />
|
||||
|
||||
<ComponentCadastroRapido
|
||||
v-model="quickDialog"
|
||||
title="Cadastro Rápido"
|
||||
table-name="patients"
|
||||
name-field="nome_completo"
|
||||
email-field="email_principal"
|
||||
phone-field="telefone"
|
||||
:extra-payload="{ status: 'Ativo' }"
|
||||
@created="onQuickCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
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,92 +1,225 @@
|
||||
<script setup>
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { onBeforeMount, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
import { computed, ref, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute();
|
||||
import Popover from 'primevue/popover'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const { layoutState, setActiveMenuItem, toggleMenu } = useLayout();
|
||||
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: () => ({})
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
root: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
parentItemKey: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
item: { type: Object, default: () => ({}) },
|
||||
root: { type: Boolean, default: false },
|
||||
parentPath: { type: String, default: null }
|
||||
})
|
||||
|
||||
const isActiveMenu = ref(false);
|
||||
const itemKey = ref(null);
|
||||
const fullPath = computed(() =>
|
||||
props.item?.path
|
||||
? (props.parentPath ? props.parentPath + props.item.path : props.item.path)
|
||||
: null
|
||||
)
|
||||
|
||||
onBeforeMount(() => {
|
||||
itemKey.value = props.parentItemKey ? props.parentItemKey + '-' + props.index : String(props.index);
|
||||
|
||||
const activeItem = layoutState.activeMenuItem;
|
||||
|
||||
isActiveMenu.value = activeItem === itemKey.value || activeItem ? activeItem.startsWith(itemKey.value + '-') : false;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => layoutState.activeMenuItem,
|
||||
(newVal) => {
|
||||
isActiveMenu.value = newVal === itemKey.value || newVal.startsWith(itemKey.value + '-');
|
||||
}
|
||||
);
|
||||
|
||||
function itemClick(event, item) {
|
||||
if (item.disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((item.to || item.url) && (layoutState.staticMenuMobileActive || layoutState.overlayMenuActive)) {
|
||||
toggleMenu();
|
||||
}
|
||||
|
||||
if (item.command) {
|
||||
item.command({ originalEvent: event, item: item });
|
||||
}
|
||||
|
||||
const foundItemKey = item.items ? (isActiveMenu.value ? props.parentItemKey : itemKey) : itemKey.value;
|
||||
|
||||
setActiveMenuItem(foundItemKey);
|
||||
// ==============================
|
||||
// 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 checkActiveRoute(item) {
|
||||
return route.path === item.to;
|
||||
function hasActiveDescendant (node, currentPath) {
|
||||
const children = node?.items || []
|
||||
for (const child of children) {
|
||||
if (child?.to && isSameRoute(currentPath, child.to)) return true
|
||||
if (child?.items?.length && hasActiveDescendant(child, currentPath)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const isActive = computed(() => {
|
||||
const current = layoutState.activePath || ''
|
||||
const item = props.item
|
||||
|
||||
// grupo com submenu: active se qualquer descendente estiver ativo
|
||||
if (item?.items?.length) {
|
||||
if (hasActiveDescendant(item, current)) return true
|
||||
|
||||
// fallback pelo "path" (útil para rotas internas tipo /saas/plans/123)
|
||||
return item.path ? current.startsWith(fullPath.value || '') : false
|
||||
}
|
||||
|
||||
// folha: active se rota igual ao to
|
||||
return item?.to ? isSameRoute(current, item.to) : false
|
||||
})
|
||||
|
||||
// ==============================
|
||||
// Feature lock + label
|
||||
// ==============================
|
||||
const ownerId = computed(() => tenantStore.activeTenantId || null)
|
||||
|
||||
const isLocked = computed(() => {
|
||||
const feature = props.item?.feature
|
||||
return !!(props.item?.proBadge && feature && ownerId.value && !entitlementsStore.has(feature))
|
||||
})
|
||||
|
||||
const itemDisabled = computed(() => !!props.item?.disabled)
|
||||
const isBlocked = computed(() => itemDisabled.value || isLocked.value)
|
||||
|
||||
const labelText = computed(() => {
|
||||
const base = props.item?.label || ''
|
||||
return props.item?.proBadge && isLocked.value ? `${base} (PRO)` : base
|
||||
})
|
||||
|
||||
const itemClick = async (event, item) => {
|
||||
// 🔒 locked -> CTA upgrade
|
||||
if (props.item?.proBadge && isLocked.value) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
layoutState.overlayMenuActive = false
|
||||
layoutState.mobileMenuActive = false
|
||||
layoutState.menuHoverActive = false
|
||||
|
||||
await nextTick()
|
||||
await router.push({ name: 'upgrade', query: { feature: props.item?.feature || '' } })
|
||||
return
|
||||
}
|
||||
|
||||
// 🚫 disabled -> bloqueia
|
||||
if (itemDisabled.value) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
// commands
|
||||
if (item?.command) item.command({ originalEvent: event, item })
|
||||
|
||||
// ✅ submenu: expande/colapsa e não navega
|
||||
if (item?.items?.length) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (isActive.value) {
|
||||
layoutState.activePath = props.parentPath || ''
|
||||
} else {
|
||||
layoutState.activePath = fullPath.value
|
||||
layoutState.menuHoverActive = true
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 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': isActiveMenu }">
|
||||
<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, index)" :class="item.class" :target="item.target" tabindex="0">
|
||||
<i :class="item.icon" class="layout-menuitem-icon"></i>
|
||||
<span class="layout-menuitem-text">{{ item.label }}</span>
|
||||
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items"></i>
|
||||
</a>
|
||||
<router-link v-if="item.to && !item.items && item.visible !== false" @click="itemClick($event, item, index)" :class="[item.class, { 'active-route': checkActiveRoute(item) }]" tabindex="0" :to="item.to">
|
||||
<i :class="item.icon" class="layout-menuitem-icon"></i>
|
||||
<span class="layout-menuitem-text">{{ item.label }}</span>
|
||||
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items"></i>
|
||||
</router-link>
|
||||
<Transition v-if="item.items && item.visible !== false" name="layout-submenu">
|
||||
<ul v-show="root ? true : isActiveMenu" class="layout-submenu">
|
||||
<app-menu-item v-for="(child, i) in item.items" :key="child" :index="i" :item="child" :parentItemKey="itemKey" :root="false"></app-menu-item>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
</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>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<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">
|
||||
{{ labelText }}
|
||||
<!-- (debug) pode remover depois -->
|
||||
<small style="opacity:.6">[locked={{ isLocked }}]</small>
|
||||
</span>
|
||||
|
||||
<i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" />
|
||||
</component>
|
||||
|
||||
<Button
|
||||
v-if="item.quickCreate"
|
||||
icon="pi pi-plus"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="ml-2"
|
||||
:disabled="isBlocked"
|
||||
@click.stop="togglePopover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Popover v-if="item.quickCreate" ref="pop">
|
||||
<div class="flex flex-column gap-2 min-w-[180px]">
|
||||
<Button label="Cadastro rápido" icon="pi pi-bolt" text @click="abrirCadastroRapido" />
|
||||
<Button label="Cadastro completo" icon="pi pi-user-plus" text @click="irCadastroCompleto" />
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<Transition v-if="item.items && item.visible !== false" name="layout-submenu">
|
||||
<ul v-show="root ? true : isActive" class="layout-submenu">
|
||||
<app-menu-item
|
||||
v-for="child in item.items"
|
||||
:key="(child.to || '') + '|' + (child.path || '') + '|' + child.label"
|
||||
:item="child"
|
||||
:root="false"
|
||||
:parentPath="fullPath"
|
||||
@quick-create="emit('quick-create', $event)"
|
||||
/>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
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,11 +1,66 @@
|
||||
<script setup>
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import AppMenu from './AppMenu.vue';
|
||||
|
||||
const { layoutState, isDesktop, hasOpenOverlay } = useLayout();
|
||||
const route = useRoute();
|
||||
const sidebarRef = ref(null);
|
||||
let outsideClickListener = null;
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
if (isDesktop()) layoutState.activePath = null;
|
||||
else layoutState.activePath = newPath;
|
||||
|
||||
layoutState.overlayMenuActive = false;
|
||||
layoutState.mobileMenuActive = false;
|
||||
layoutState.menuHoverActive = false;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(hasOpenOverlay, (newVal) => {
|
||||
if (isDesktop()) {
|
||||
if (newVal) bindOutsideClickListener();
|
||||
else unbindOutsideClickListener();
|
||||
}
|
||||
});
|
||||
|
||||
const bindOutsideClickListener = () => {
|
||||
if (!outsideClickListener) {
|
||||
outsideClickListener = (event) => {
|
||||
if (isOutsideClicked(event)) {
|
||||
layoutState.overlayMenuActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', outsideClickListener);
|
||||
}
|
||||
};
|
||||
|
||||
const unbindOutsideClickListener = () => {
|
||||
if (outsideClickListener) {
|
||||
document.removeEventListener('click', outsideClickListener);
|
||||
outsideClickListener = null;
|
||||
}
|
||||
};
|
||||
|
||||
const isOutsideClicked = (event) => {
|
||||
const topbarButtonEl = document.querySelector('.layout-menu-button');
|
||||
|
||||
return !(sidebarRef.value.isSameNode(event.target) || sidebarRef.value.contains(event.target) || topbarButtonEl?.isSameNode(event.target) || topbarButtonEl?.contains(event.target));
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unbindOutsideClickListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-sidebar">
|
||||
<app-menu></app-menu>
|
||||
<div ref="sidebarRef" class="layout-sidebar">
|
||||
<AppMenu />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -1,79 +1,304 @@
|
||||
<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>
|
||||
<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>
|
||||
<Toast />
|
||||
|
||||
<span>SAKAI</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="layout-topbar-actions">
|
||||
<div class="layout-config-menu">
|
||||
<button type="button" class="layout-topbar-action" @click="toggleDarkMode">
|
||||
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i>
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button
|
||||
v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'animate-scalein', leaveToClass: 'hidden', leaveActiveClass: 'animate-fadeout', 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: 'animate-scalein', leaveToClass: 'hidden', leaveActiveClass: 'animate-fadeout', hideOnOutsideClick: true }"
|
||||
>
|
||||
<i class="pi pi-ellipsis-v"></i>
|
||||
</button>
|
||||
|
||||
<div class="layout-topbar-menu hidden lg:block">
|
||||
<div class="layout-topbar-menu-content">
|
||||
<button type="button" class="layout-topbar-action">
|
||||
<i class="pi pi-calendar"></i>
|
||||
<span>Calendar</span>
|
||||
</button>
|
||||
<button type="button" class="layout-topbar-action">
|
||||
<i class="pi pi-inbox"></i>
|
||||
<span>Messages</span>
|
||||
</button>
|
||||
<button type="button" class="layout-topbar-action">
|
||||
<i class="pi pi-user"></i>
|
||||
<span>Profile</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<router-link to="/" class="layout-topbar-logo">
|
||||
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- ... SVG gigante ... -->
|
||||
</svg>
|
||||
<span>SAKAI</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="layout-topbar-actions">
|
||||
<div class="layout-config-menu">
|
||||
<button type="button" class="layout-topbar-action" @click="toggleDarkAndPersistSilently">
|
||||
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i>
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
v-styleclass="{
|
||||
selector: '@next',
|
||||
enterFromClass: 'hidden',
|
||||
enterActiveClass: 'p-anchored-overlay-enter-active',
|
||||
leaveToClass: 'hidden',
|
||||
leaveActiveClass: 'p-anchored-overlay-leave-active',
|
||||
hideOnOutsideClick: true
|
||||
}"
|
||||
type="button"
|
||||
class="layout-topbar-action layout-topbar-action-highlight"
|
||||
>
|
||||
<i class="pi pi-palette"></i>
|
||||
</button>
|
||||
|
||||
<AppConfigurator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="layout-topbar-menu-button layout-topbar-action"
|
||||
v-styleclass="{
|
||||
selector: '@next',
|
||||
enterFromClass: 'hidden',
|
||||
enterActiveClass: 'p-anchored-overlay-enter-active',
|
||||
leaveToClass: 'hidden',
|
||||
leaveActiveClass: 'p-anchored-overlay-leave-active',
|
||||
hideOnOutsideClick: true
|
||||
}"
|
||||
>
|
||||
<i class="pi pi-ellipsis-v"></i>
|
||||
</button>
|
||||
|
||||
<div class="layout-topbar-menu hidden lg:block">
|
||||
<div class="layout-topbar-menu-content">
|
||||
<Button
|
||||
label="Plano"
|
||||
icon="pi pi-sync"
|
||||
severity="contrast"
|
||||
outlined
|
||||
:loading="trocandoPlano"
|
||||
:disabled="trocandoPlano"
|
||||
@click="alternarPlano"
|
||||
/>
|
||||
|
||||
<button type="button" class="layout-topbar-action">
|
||||
<i class="pi pi-calendar"></i>
|
||||
<span>Calendar</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="layout-topbar-action">
|
||||
<i class="pi pi-inbox"></i>
|
||||
<span>Messages</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="layout-topbar-action">
|
||||
<i class="pi pi-user"></i>
|
||||
<span>Profile</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="layout-topbar-action" @click="logout">
|
||||
<i class="pi pi-sign-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
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,72 +1,96 @@
|
||||
import { computed, reactive } from 'vue';
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
const layoutConfig = reactive({
|
||||
preset: 'Aura',
|
||||
primary: 'emerald',
|
||||
surface: null,
|
||||
darkTheme: false,
|
||||
menuMode: 'static'
|
||||
});
|
||||
preset: 'Aura',
|
||||
primary: 'emerald',
|
||||
surface: null,
|
||||
darkTheme: false,
|
||||
menuMode: 'static'
|
||||
})
|
||||
|
||||
const layoutState = reactive({
|
||||
staticMenuDesktopInactive: false,
|
||||
overlayMenuActive: false,
|
||||
profileSidebarVisible: false,
|
||||
configSidebarVisible: false,
|
||||
staticMenuMobileActive: false,
|
||||
menuHoverActive: false,
|
||||
activeMenuItem: null
|
||||
});
|
||||
staticMenuInactive: false,
|
||||
overlayMenuActive: false,
|
||||
mobileMenuActive: false, // ✅ ADICIONA (estava faltando)
|
||||
profileSidebarVisible: false,
|
||||
configSidebarVisible: false,
|
||||
sidebarExpanded: false,
|
||||
menuHoverActive: false,
|
||||
activeMenuItem: null,
|
||||
activePath: null
|
||||
})
|
||||
|
||||
export function useLayout() {
|
||||
const setActiveMenuItem = (item) => {
|
||||
layoutState.activeMenuItem = item.value || item;
|
||||
};
|
||||
export function useLayout () {
|
||||
const toggleDarkMode = () => {
|
||||
if (!document.startViewTransition) {
|
||||
executeDarkModeToggle()
|
||||
return
|
||||
}
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
if (!document.startViewTransition) {
|
||||
executeDarkModeToggle();
|
||||
document.startViewTransition(() => executeDarkModeToggle(event))
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
const executeDarkModeToggle = () => {
|
||||
layoutConfig.darkTheme = !layoutConfig.darkTheme
|
||||
document.documentElement.classList.toggle('app-dark')
|
||||
}
|
||||
|
||||
document.startViewTransition(() => executeDarkModeToggle(event));
|
||||
};
|
||||
const isDesktop = () => window.innerWidth > 991
|
||||
|
||||
const executeDarkModeToggle = () => {
|
||||
layoutConfig.darkTheme = !layoutConfig.darkTheme;
|
||||
document.documentElement.classList.toggle('app-dark');
|
||||
};
|
||||
const toggleMenu = () => {
|
||||
if (isDesktop()) {
|
||||
if (layoutConfig.menuMode === 'static') {
|
||||
layoutState.staticMenuInactive = !layoutState.staticMenuInactive
|
||||
}
|
||||
|
||||
const toggleMenu = () => {
|
||||
if (layoutConfig.menuMode === 'overlay') {
|
||||
layoutState.overlayMenuActive = !layoutState.overlayMenuActive;
|
||||
}
|
||||
if (layoutConfig.menuMode === 'overlay') {
|
||||
layoutState.overlayMenuActive = !layoutState.overlayMenuActive
|
||||
}
|
||||
} else {
|
||||
layoutState.mobileMenuActive = !layoutState.mobileMenuActive
|
||||
}
|
||||
}
|
||||
|
||||
if (window.innerWidth > 991) {
|
||||
layoutState.staticMenuDesktopInactive = !layoutState.staticMenuDesktopInactive;
|
||||
} else {
|
||||
layoutState.staticMenuMobileActive = !layoutState.staticMenuMobileActive;
|
||||
}
|
||||
};
|
||||
const toggleConfigSidebar = () => {
|
||||
layoutState.configSidebarVisible = !layoutState.configSidebarVisible
|
||||
}
|
||||
|
||||
const isSidebarActive = computed(() => layoutState.overlayMenuActive || layoutState.staticMenuMobileActive);
|
||||
const hideMobileMenu = () => {
|
||||
layoutState.mobileMenuActive = false
|
||||
}
|
||||
|
||||
const isDarkTheme = computed(() => layoutConfig.darkTheme);
|
||||
// ✅ 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 getPrimary = computed(() => layoutConfig.primary);
|
||||
const changeMenuMode = (event) => {
|
||||
layoutConfig.menuMode = event.value
|
||||
layoutState.staticMenuInactive = false
|
||||
layoutState.mobileMenuActive = false
|
||||
layoutState.sidebarExpanded = false
|
||||
layoutState.menuHoverActive = false
|
||||
layoutState.anchored = false
|
||||
}
|
||||
|
||||
const getSurface = computed(() => layoutConfig.surface);
|
||||
const isDarkTheme = computed(() => layoutConfig.darkTheme)
|
||||
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive)
|
||||
|
||||
return {
|
||||
layoutConfig,
|
||||
layoutState,
|
||||
toggleMenu,
|
||||
isSidebarActive,
|
||||
isDarkTheme,
|
||||
getPrimary,
|
||||
getSurface,
|
||||
setActiveMenuItem,
|
||||
toggleDarkMode
|
||||
};
|
||||
return {
|
||||
layoutConfig,
|
||||
layoutState,
|
||||
isDarkTheme,
|
||||
toggleDarkMode,
|
||||
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
125
src/main.js
125
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'
|
||||
}
|
||||
preset: Aura,
|
||||
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')
|
||||
},
|
||||
history: createWebHistory(),
|
||||
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',
|
||||
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: '/documentation',
|
||||
name: 'documentation',
|
||||
component: () => import('@/views/pages/Documentation.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/landing',
|
||||
name: 'landing',
|
||||
component: () => import('@/views/pages/Landing.vue')
|
||||
},
|
||||
{
|
||||
path: '/pages/notfound',
|
||||
name: 'notfound',
|
||||
component: () => import('@/views/pages/NotFound.vue')
|
||||
},
|
||||
// qualquer navegação normal NÃO altera o scroll
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
{
|
||||
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()
|
||||
)
|
||||
|
||||
export default router;
|
||||
// ===== DEBUG NAV + TRACE (remover depois) =====
|
||||
const _push = router.push.bind(router)
|
||||
router.push = async (loc) => {
|
||||
console.log('[router.push]', loc)
|
||||
console.trace('[push caller]')
|
||||
|
||||
const res = await _push(loc)
|
||||
|
||||
if (isNavigationFailure(res, NavigationFailureType.duplicated)) {
|
||||
console.warn('[NAV FAIL] duplicated', res)
|
||||
} else if (isNavigationFailure(res, NavigationFailureType.cancelled)) {
|
||||
console.warn('[NAV FAIL] cancelled', res)
|
||||
} else if (isNavigationFailure(res, NavigationFailureType.aborted)) {
|
||||
console.warn('[NAV FAIL] aborted', res)
|
||||
} else if (isNavigationFailure(res, NavigationFailureType.redirected)) {
|
||||
console.warn('[NAV FAIL] redirected', res)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
const _replace = router.replace.bind(router)
|
||||
router.replace = async (loc) => {
|
||||
console.log('[router.replace]', loc)
|
||||
console.trace('[replace caller]')
|
||||
|
||||
const res = await _replace(loc)
|
||||
|
||||
if (isNavigationFailure(res, NavigationFailureType.cancelled)) {
|
||||
console.warn('[NAV FAIL replace] cancelled', res)
|
||||
} else if (isNavigationFailure(res, NavigationFailureType.aborted)) {
|
||||
console.warn('[NAV FAIL replace] aborted', res)
|
||||
} else if (isNavigationFailure(res, NavigationFailureType.redirected)) {
|
||||
console.warn('[NAV FAIL replace] redirected', res)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
router.onError((e) => console.error('[router.onError]', e))
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
console.log('[beforeEach]', from.fullPath, '->', to.fullPath)
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to, from, failure) => {
|
||||
if (failure) console.warn('[afterEach failure]', failure)
|
||||
else console.log('[afterEach ok]', from.fullPath, '->', to.fullPath)
|
||||
})
|
||||
// ===== /DEBUG NAV + TRACE =====
|
||||
|
||||
// ✅ mantém seus guards, mas agora a landing tem meta.public
|
||||
applyGuards(router)
|
||||
|
||||
export default router
|
||||
|
||||
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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user