Compare commits

...

10 Commits

Author SHA1 Message Date
Leonardo
676042268b first commit 2026-02-18 22:36:45 -03:00
Cagatay Civici
ec6b6ef53a set version as 5 2026-02-02 21:49:03 +03:00
tugcekucukoglu
76a3b60333 cchore: update PrimeVue version 2026-01-30 17:51:18 +03:00
tugcekucukoglu
410c08d693 chore: layout config updates 2025-12-25 10:03:32 +03:00
tugcekucukoglu
a4b2c96b0d submodule added 2025-12-25 09:09:55 +03:00
tugcekucukoglu
a47200fdf7 remove assets 2025-12-25 09:07:45 +03:00
tugcekucukoglu
7c32ae1f6f chore: remove sass warnings 2025-12-09 14:05:01 +03:00
Atakan
db99863fac update transitions & dependencies 2025-12-08 14:36:22 +03:00
Atakan
c2ef85fcab update for tw v4 2025-12-04 12:26:25 +03:00
Atakan
deea8861f8 update 2025-11-18 05:16:32 +03:00
161 changed files with 27098 additions and 2315 deletions

2
.env Normal file
View File

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

2
.env.local Normal file
View File

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

4
.gitignore vendored
View File

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

3
.gitmodules vendored Normal file
View 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
View File

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

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

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

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

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

4688
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "sakai-vue", "name": "sakai-vue",
"version": "4.3.0", "version": "5.0.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
@@ -8,11 +8,13 @@
"lint": "eslint --fix . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore" "lint": "eslint --fix . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
}, },
"dependencies": { "dependencies": {
"@primeuix/themes": "^1.0.0", "@primeuix/themes": "^2.0.0",
"@supabase/supabase-js": "^2.95.3",
"chart.js": "3.3.2", "chart.js": "3.3.2",
"pinia": "^3.0.4",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.3.1", "primevue": "^4.5.4",
"tailwindcss-primeui": "^0.5.0", "tailwindcss-primeui": "^0.6.0",
"vue": "^3.4.34", "vue": "^3.4.34",
"vue-router": "^4.4.0" "vue-router": "^4.4.0"
}, },
@@ -22,11 +24,13 @@
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-prettier": "^9.0.0", "@vue/eslint-config-prettier": "^9.0.0",
"autoprefixer": "^10.4.24",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0", "eslint-plugin-vue": "^9.23.0",
"postcss": "^8.5.6",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"sass": "^1.55.0", "sass": "^1.55.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.18",
"unplugin-vue-components": "^0.27.3", "unplugin-vue-components": "^0.27.3",
"vite": "^5.3.1" "vite": "^5.3.1"
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

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

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

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

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

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

1
src/assets Submodule

Submodule src/assets added at eaa70ece4c

View File

@@ -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;
}
}

View File

@@ -1,2 +0,0 @@
@use './code.scss';
@use './flags/flags.css';

File diff suppressed because one or more lines are too long

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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';

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -1,5 +0,0 @@
:root {
--surface-ground: var(--p-surface-100);
--code-background: var(--p-surface-900);
--code-color: var(--p-surface-200);
}

View File

@@ -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';

View File

@@ -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);
}
}

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ const items = ref([
<div class="font-semibold text-xl">Best Selling Products</div> <div class="font-semibold text-xl">Best Selling Products</div>
<div> <div>
<Button icon="pi pi-ellipsis-v" class="p-button-text p-button-plain p-button-rounded" @click="$refs.menu.toggle($event)"></Button> <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>
</div> </div>
<ul class="list-none p-0 m-0"> <ul class="list-none p-0 m-0">

View File

@@ -15,7 +15,7 @@ const items = ref([
<div class="font-semibold text-xl">Notifications</div> <div class="font-semibold text-xl">Notifications</div>
<div> <div>
<Button icon="pi pi-ellipsis-v" class="p-button-text p-button-plain p-button-rounded" @click="$refs.menu.toggle($event)"></Button> <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>
</div> </div>
@@ -23,7 +23,7 @@ const items = ref([
<ul class="p-0 mx-0 mt-0 mb-6 list-none"> <ul class="p-0 mx-0 mt-0 mb-6 list-none">
<li class="flex items-center py-2 border-b border-surface"> <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"> <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> </div>
<span class="text-surface-900 dark:text-surface-0 leading-normal" <span class="text-surface-900 dark:text-surface-0 leading-normal"
>Richard Jones >Richard Jones
@@ -32,7 +32,7 @@ const items = ref([
</li> </li>
<li class="flex items-center py-2"> <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"> <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> </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> <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> </li>
@@ -42,7 +42,7 @@ const items = ref([
<ul class="p-0 m-0 list-none mb-6"> <ul class="p-0 m-0 list-none mb-6">
<li class="flex items-center py-2 border-b border-surface"> <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"> <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> </div>
<span class="text-surface-900 dark:text-surface-0 leading-normal" <span class="text-surface-900 dark:text-surface-0 leading-normal"
>Keyser Wick >Keyser Wick
@@ -51,7 +51,7 @@ const items = ref([
</li> </li>
<li class="flex items-center py-2 border-b border-surface"> <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"> <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> </div>
<span class="text-surface-900 dark:text-surface-0 leading-normal" <span class="text-surface-900 dark:text-surface-0 leading-normal"
>Jane Davis >Jane Davis
@@ -63,13 +63,13 @@ const items = ref([
<ul class="p-0 m-0 list-none"> <ul class="p-0 m-0 list-none">
<li class="flex items-center py-2 border-b border-surface"> <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"> <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> </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> <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>
<li class="flex items-center py-2 border-b border-surface"> <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"> <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> </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> <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> </li>

View File

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

View File

@@ -2,7 +2,7 @@
import { useLayout } from '@/layout/composables/layout'; import { useLayout } from '@/layout/composables/layout';
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
const { getPrimary, getSurface, isDarkTheme } = useLayout(); const { layoutConfig, isDarkTheme } = useLayout();
const chartData = ref(null); const chartData = ref(null);
const chartOptions = 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(); chartData.value = setChartData();
chartOptions.value = setChartOptions(); chartOptions.value = setChartOptions();
}); });

View File

@@ -7,7 +7,7 @@
<div class="text-surface-900 dark:text-surface-0 font-medium text-xl">152</div> <div class="text-surface-900 dark:text-surface-0 font-medium text-xl">152</div>
</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"> <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>
</div> </div>
<span class="text-primary font-medium">24 new </span> <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 class="text-surface-900 dark:text-surface-0 font-medium text-xl">$2.100</div>
</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"> <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>
</div> </div>
<span class="text-primary font-medium">%52+ </span> <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 class="text-surface-900 dark:text-surface-0 font-medium text-xl">28441</div>
</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"> <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>
</div> </div>
<span class="text-primary font-medium">520 </span> <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 class="text-surface-900 dark:text-surface-0 font-medium text-xl">152 Unread</div>
</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"> <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>
</div> </div>
<span class="text-primary font-medium">85 </span> <span class="text-primary font-medium">85 </span>

View File

@@ -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 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="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"> <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> </div>
<h5 class="mb-2 text-surface-900 dark:text-surface-0">Easy to Use</h5> <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> <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 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="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"> <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> </div>
<h5 class="mb-2 text-surface-900 dark:text-surface-0">Fresh Design</h5> <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> <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 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="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"> <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>
<div class="mt-6 mb-1 text-surface-900 dark:text-surface-0 text-xl font-semibold">Well Documented</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> <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 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="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"> <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>
<div class="mt-6 mb-1 text-surface-900 dark:text-surface-0 text-xl font-semibold">Responsive Layout</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> <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 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="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"> <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>
<div class="mt-6 mb-1 text-surface-900 dark:text-surface-0 text-xl font-semibold">Clean Code</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> <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 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="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"> <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>
<div class="mt-6 mb-1 text-surface-900 dark:text-surface-0 text-xl font-semibold">Dark Mode</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> <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 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="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"> <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>
<div class="mt-6 mb-1 text-surface-900 dark:text-surface-0 text-xl font-semibold">Ready to Use</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> <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 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="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"> <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>
<div class="mt-6 mb-1 text-surface-900 dark:text-surface-0 text-xl font-semibold">Modern Practices</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> <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 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="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"> <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>
<div class="mt-6 mb-1 text-surface-900 dark:text-surface-0 text-xl font-semibold">Privacy</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> <span class="text-surface-600 dark:text-surface-200">Neque egestas congue quisque.</span>

View File

@@ -7,7 +7,7 @@
<div class="mx-6 md:mx-20 mt-0 md:mt-6"> <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> <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> <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>
<div class="flex justify-center md:justify-end"> <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" /> <img src="/demo/images/landing/screen-1.png" alt="Hero Image" class="w-9/12 md:w-auto" />

View File

@@ -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="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"> <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>
<div class="leading-none text-surface-900 dark:text-surface-0 text-3xl font-normal">Congue Quisque Egestas</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" <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="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="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"> <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>
<div class="leading-none text-surface-900 dark:text-surface-0 text-3xl font-normal">Celerisque Eu Ultrices</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" <span class="text-surface-700 dark:text-surface-100 text-2xl leading-normal mr-0 md:mr-2" style="max-width: 650px"

View File

@@ -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> <span class="text-surface-900 dark:text-surface-0 font-medium text-2xl leading-normal mr-20">SAKAI</span>
</a> </a>
<Button <Button
class="lg:!hidden" class="lg:hidden!"
text text
severity="secondary" severity="secondary"
rounded rounded
v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'animate-scalein', leaveToClass: 'hidden', leaveActiveClass: 'animate-fadeout', hideOnOutsideClick: true }" 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> </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"> <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"> <ul class="list-none p-0 m-0 flex lg:items-center select-none flex-col lg:flex-row cursor-pointer gap-8">

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -1,71 +1,34 @@
<script setup> <script setup>
import { useLayout } from '@/layout/composables/layout'; import { useLayout } from '@/layout/composables/layout';
import { computed, ref, watch } from 'vue'; import { computed } from 'vue';
import AppFooter from './AppFooter.vue'; import AppFooter from './AppFooter.vue';
import AppSidebar from './AppSidebar.vue'; import AppSidebar from './AppSidebar.vue';
import AppTopbar from './AppTopbar.vue'; import AppTopbar from './AppTopbar.vue';
const { layoutConfig, layoutState, isSidebarActive } = useLayout(); const { layoutConfig, layoutState, hideMobileMenu } = useLayout();
const outsideClickListener = ref(null);
watch(isSidebarActive, (newVal) => {
if (newVal) {
bindOutsideClickListener();
} else {
unbindOutsideClickListener();
}
});
const containerClass = computed(() => { const containerClass = computed(() => {
return { return {
'layout-overlay': layoutConfig.menuMode === 'overlay', 'layout-overlay': layoutConfig.menuMode === 'overlay',
'layout-static': layoutConfig.menuMode === 'static', 'layout-static': layoutConfig.menuMode === 'static',
'layout-static-inactive': layoutState.staticMenuDesktopInactive && layoutConfig.menuMode === 'static',
'layout-overlay-active': layoutState.overlayMenuActive, '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> </script>
<template> <template>
<div class="layout-wrapper" :class="containerClass"> <div class="layout-wrapper" :class="containerClass">
<app-topbar></app-topbar> <AppTopbar />
<app-sidebar></app-sidebar> <AppSidebar />
<div class="layout-main-container"> <div class="layout-main-container">
<div class="layout-main"> <div class="layout-main">
<router-view></router-view> <router-view />
</div> </div>
<app-footer></app-footer> <AppFooter />
</div> </div>
<div class="layout-mask animate-fadein"></div> <div class="layout-mask animate-fadein" @click="hideMobileMenu" />
</div> </div>
<Toast /> <Toast />
</template> </template>

View File

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

View File

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

View File

@@ -1,92 +1,225 @@
<script setup> <script setup>
import { useLayout } from '@/layout/composables/layout'; import { useLayout } from '@/layout/composables/layout'
import { onBeforeMount, ref, watch } from 'vue'; import { computed, ref, nextTick } from 'vue'
import { useRoute } from 'vue-router'; 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({ const props = defineProps({
item: { item: { type: Object, default: () => ({}) },
type: Object, root: { type: Boolean, default: false },
default: () => ({}) parentPath: { type: String, default: null }
}, })
index: {
type: Number,
default: 0
},
root: {
type: Boolean,
default: true
},
parentItemKey: {
type: String,
default: null
}
});
const isActiveMenu = ref(false); const fullPath = computed(() =>
const itemKey = ref(null); 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); // Active logic: mantém submenu aberto se algum descendente estiver ativo
// ==============================
const activeItem = layoutState.activeMenuItem; function isSameRoute (current, target) {
if (!current || !target) return false
isActiveMenu.value = activeItem === itemKey.value || activeItem ? activeItem.startsWith(itemKey.value + '-') : false; return current === target || current.startsWith(target + '/')
});
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);
} }
function checkActiveRoute(item) { function hasActiveDescendant (node, currentPath) {
return route.path === item.to; 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> </script>
<template> <template>
<li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActiveMenu }"> <li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
<div v-if="root && item.visible !== false" class="layout-menuitem-root-text">{{ item.label }}</div> <div v-if="root && item.visible !== false" class="layout-menuitem-root-text">
<a v-if="(!item.to || item.items) && item.visible !== false" :href="item.url" @click="itemClick($event, item, index)" :class="item.class" :target="item.target" tabindex="0"> {{ item.label }}
<i :class="item.icon" class="layout-menuitem-icon"></i> </div>
<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>
<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>

View File

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

View File

@@ -1,11 +1,66 @@
<script setup> <script setup>
import { useLayout } from '@/layout/composables/layout';
import { onBeforeUnmount, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import AppMenu from './AppMenu.vue'; import 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> </script>
<template> <template>
<div class="layout-sidebar"> <div ref="sidebarRef" class="layout-sidebar">
<app-menu></app-menu> <AppMenu />
</div> </div>
</template> </template>
<style lang="scss" scoped></style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,72 +1,96 @@
import { computed, reactive } from 'vue'; import { computed, reactive } from 'vue'
const layoutConfig = reactive({ const layoutConfig = reactive({
preset: 'Aura', preset: 'Aura',
primary: 'emerald', primary: 'emerald',
surface: null, surface: null,
darkTheme: false, darkTheme: false,
menuMode: 'static' menuMode: 'static'
}); })
const layoutState = reactive({ const layoutState = reactive({
staticMenuDesktopInactive: false, staticMenuInactive: false,
overlayMenuActive: false, overlayMenuActive: false,
profileSidebarVisible: false, mobileMenuActive: false, // ✅ ADICIONA (estava faltando)
configSidebarVisible: false, profileSidebarVisible: false,
staticMenuMobileActive: false, configSidebarVisible: false,
menuHoverActive: false, sidebarExpanded: false,
activeMenuItem: null menuHoverActive: false,
}); activeMenuItem: null,
activePath: null
})
export function useLayout() { export function useLayout () {
const setActiveMenuItem = (item) => { const toggleDarkMode = () => {
layoutState.activeMenuItem = item.value || item; if (!document.startViewTransition) {
}; executeDarkModeToggle()
return
}
const toggleDarkMode = () => { document.startViewTransition(() => executeDarkModeToggle(event))
if (!document.startViewTransition) { }
executeDarkModeToggle();
return; const executeDarkModeToggle = () => {
} layoutConfig.darkTheme = !layoutConfig.darkTheme
document.documentElement.classList.toggle('app-dark')
}
document.startViewTransition(() => executeDarkModeToggle(event)); const isDesktop = () => window.innerWidth > 991
};
const executeDarkModeToggle = () => { const toggleMenu = () => {
layoutConfig.darkTheme = !layoutConfig.darkTheme; if (isDesktop()) {
document.documentElement.classList.toggle('app-dark'); if (layoutConfig.menuMode === 'static') {
}; layoutState.staticMenuInactive = !layoutState.staticMenuInactive
}
const toggleMenu = () => { if (layoutConfig.menuMode === 'overlay') {
if (layoutConfig.menuMode === 'overlay') { layoutState.overlayMenuActive = !layoutState.overlayMenuActive
layoutState.overlayMenuActive = !layoutState.overlayMenuActive; }
} } else {
layoutState.mobileMenuActive = !layoutState.mobileMenuActive
}
}
if (window.innerWidth > 991) { const toggleConfigSidebar = () => {
layoutState.staticMenuDesktopInactive = !layoutState.staticMenuDesktopInactive; layoutState.configSidebarVisible = !layoutState.configSidebarVisible
} else { }
layoutState.staticMenuMobileActive = !layoutState.staticMenuMobileActive;
}
};
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 { return {
layoutConfig, layoutConfig,
layoutState, layoutState,
toggleMenu, isDarkTheme,
isSidebarActive, toggleDarkMode,
isDarkTheme, toggleConfigSidebar,
getPrimary, toggleMenu,
getSurface, hideMobileMenu,
setActiveMenuItem, closeMenuOnNavigate, // ✅ exporta
toggleDarkMode changeMenuMode,
}; isDesktop,
hasOpenOverlay
}
} }

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
// src/navigation/menus/saas.menu.js
export default function saasMenu (authStore, opts = {}) {
if (!authStore?.isSaasAdmin) return []
const mismatchCount = Number(opts?.mismatchCount || 0)
return [
{
label: 'SaaS',
icon: 'pi pi-building',
path: '/saas', // ✅ necessário p/ expandir e controlar activePath
items: [
{ label: 'Dashboard', icon: 'pi pi-chart-bar', to: '/saas' },
{
label: 'Planos',
icon: 'pi pi-star',
path: '/plans', // ✅ vira /saas/plans pelo parentPath
items: [
{ label: 'Listagem de Planos', icon: 'pi pi-list', to: '/saas/plans' },
// ✅ NOVO: vitrine pública (pricing page)
{ label: 'Vitrine Pública', icon: 'pi pi-megaphone', to: '/saas/plans-public' },
{ label: 'Recursos', icon: 'pi pi-bolt', to: '/saas/features' },
{ label: 'Controle de Recursos', icon: 'pi pi-th-large', to: '/saas/plan-features' }
]
},
{
label: 'Assinaturas',
icon: 'pi pi-credit-card',
path: '/subscriptions', // ✅ vira /saas/subscriptions
items: [
{ label: 'Listagem de Assinaturas', icon: 'pi pi-list', to: '/saas/subscriptions' },
{ label: 'Histórico', icon: 'pi pi-history', to: '/saas/subscription-events' },
{
label: 'Saúde das Assinaturas',
icon: 'pi pi-shield',
to: '/saas/subscription-health',
...(mismatchCount > 0
? { badge: String(mismatchCount), badgeClass: 'p-badge p-badge-danger' }
: {})
}
]
},
{
label: 'Intenções de Assinatura',
icon: 'pi pi-inbox',
to: '/saas/subscription-intents'
},
{ label: 'Clínicas (Tenants)', icon: 'pi pi-users', to: '/saas/tenants' }
]
}
]
}

View File

@@ -0,0 +1,117 @@
export default [
{
label: 'Home',
items: [{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' }]
},
{
label: 'UI Components',
path: '/uikit',
items: [
{ label: 'Form Layout', icon: 'pi pi-fw pi-id-card', to: '/demo/uikit/formlayout' },
{ label: 'Input', icon: 'pi pi-fw pi-check-square', to: '/demo/uikit/input' },
{ label: 'Button', icon: 'pi pi-fw pi-mobile', to: '/demo/uikit/button', class: 'rotated-icon' },
{ label: 'Table', icon: 'pi pi-fw pi-table', to: '/demo/uikit/table' },
{ label: 'List', icon: 'pi pi-fw pi-list', to: '/demo/uikit/list' },
{ label: 'Tree', icon: 'pi pi-fw pi-share-alt', to: '/demo/uikit/tree' },
{ label: 'Panel', icon: 'pi pi-fw pi-tablet', to: '/demo/uikit/panel' },
{ label: 'Overlay', icon: 'pi pi-fw pi-clone', to: '/demo/uikit/overlay' },
{ label: 'Media', icon: 'pi pi-fw pi-image', to: '/demo/uikit/media' },
{ label: 'Menu', icon: 'pi pi-fw pi-bars', to: '/demo/uikit/menu' },
{ label: 'Message', icon: 'pi pi-fw pi-comment', to: '/demo/uikit/message' },
{ label: 'File', icon: 'pi pi-fw pi-file', to: '/demo/uikit/file' },
{ label: 'Chart', icon: 'pi pi-fw pi-chart-bar', to: '/demo/uikit/charts' },
{ label: 'Timeline', icon: 'pi pi-fw pi-calendar', to: '/demo/uikit/timeline' },
{ label: 'Misc', icon: 'pi pi-fw pi-circle', to: '/demo/uikit/misc' }
]
},
{
label: 'Prime Blocks',
icon: 'pi pi-fw pi-prime',
path: '/blocks',
items: [
{ label: 'Free Blocks', icon: 'pi pi-fw pi-eye', to: '/utilities' },
{ label: 'All Blocks', icon: 'pi pi-fw pi-globe', url: 'https://blocks.primevue.org/', target: '_blank' }
]
},
{
label: 'Pages',
icon: 'pi pi-fw pi-briefcase',
path: '/pages',
items: [
{ label: 'Landing', icon: 'pi pi-fw pi-globe', to: '/landing' },
{
label: 'Auth',
icon: 'pi pi-fw pi-user',
path: '/auth',
items: [
{ label: 'Login', icon: 'pi pi-fw pi-sign-in', to: '/auth/login' },
{ label: 'Error', icon: 'pi pi-fw pi-times-circle', to: '/auth/error' },
{ label: 'Access Denied', icon: 'pi pi-fw pi-lock', to: '/auth/access' }
]
},
{ label: 'Crud', icon: 'pi pi-fw pi-pencil', to: '/pages/crud' },
{ label: 'Not Found', icon: 'pi pi-fw pi-exclamation-circle', to: '/pages/notfound' },
{ label: 'Empty', icon: 'pi pi-fw pi-circle-off', to: '/pages/empty' }
]
},
{
label: 'Hierarchy',
icon: 'pi pi-fw pi-align-left',
path: '/hierarchy',
items: [
{
label: 'Submenu 1',
icon: 'pi pi-fw pi-align-left',
path: '/submenu_1',
items: [
{
label: 'Submenu 1.1',
icon: 'pi pi-fw pi-align-left',
path: '/submenu_1_1',
items: [
{ label: 'Submenu 1.1.1', icon: 'pi pi-fw pi-align-left' },
{ label: 'Submenu 1.1.2', icon: 'pi pi-fw pi-align-left' },
{ label: 'Submenu 1.1.3', icon: 'pi pi-fw pi-align-left' }
]
},
{
label: 'Submenu 1.2',
icon: 'pi pi-fw pi-align-left',
path: '/submenu_1_2',
items: [{ label: 'Submenu 1.2.1', icon: 'pi pi-fw pi-align-left' }]
}
]
},
{
label: 'Submenu 2',
icon: 'pi pi-fw pi-align-left',
path: '/submenu_2',
items: [
{
label: 'Submenu 2.1',
icon: 'pi pi-fw pi-align-left',
path: '/submenu_2_1',
items: [
{ label: 'Submenu 2.1.1', icon: 'pi pi-fw pi-align-left' },
{ label: 'Submenu 2.1.2', icon: 'pi pi-fw pi-align-left' }
]
},
{
label: 'Submenu 2.2',
icon: 'pi pi-fw pi-align-left',
path: '/submenu_2_2',
items: [{ label: 'Submenu 2.2.1', icon: 'pi pi-fw pi-align-left' }]
}
]
}
]
},
{
label: 'Get Started',
path: '/start',
items: [
{ label: 'Documentation', icon: 'pi pi-fw pi-book', to: '/pages' },
{ label: 'View Source', icon: 'pi pi-fw pi-github', url: 'https://github.com/primefaces/sakai-vue', target: '_blank' }
]
}
]

View File

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

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

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

View File

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

View File

@@ -0,0 +1,36 @@
// src/router/router.configuracoes.js
import AppLayout from '@/layout/AppLayout.vue'
const configuracoesRoutes = {
path: '/configuracoes',
component: AppLayout,
meta: {
requiresAuth: true,
roles: ['admin', 'tenant_admin', 'therapist']
},
children: [
{
path: '',
component: () => import('@/layout/ConfiguracoesPage.vue'),
redirect: { name: 'ConfiguracoesAgenda' },
children: [
{
path: 'agenda',
name: 'ConfiguracoesAgenda',
component: () => import('@/layout/configuracoes/ConfiguracoesAgendaPage.vue')
}
// Futuro:
// { path: 'clinica', name: 'ConfiguracoesClinica', component: () => import('@/layout/configuracoes/ConfiguracoesClinicaPage.vue') },
// { path: 'intake', name: 'ConfiguracoesIntake', component: () => import('@/layout/configuracoes/ConfiguracoesIntakePage.vue') },
// { path: 'conta', name: 'ConfiguracoesConta', component: () => import('@/layout/configuracoes/ConfiguracoesContaPage.vue') },
]
}
]
}
export default configuracoesRoutes

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

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

View File

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

View File

View File

View File

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

View File

View File

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
// src/services/subscriptionIntents.js
import { supabase } from '@/lib/supabase/client'
function applyFilters(query, { q, status, planKey, interval }) {
if (q) query = query.ilike('email', `%${q}%`)
if (status) query = query.eq('status', status)
if (planKey) query = query.eq('plan_key', planKey)
if (interval) query = query.eq('interval', interval)
return query
}
export async function listSubscriptionIntents(filters = {}) {
let query = supabase
.from('subscription_intents')
.select('*')
.order('created_at', { ascending: false })
query = applyFilters(query, filters)
const { data, error } = await query
if (error) throw error
return data || []
}
export async function markIntentPaid(intentId, notes = '') {
// 1) marca como pago
const { data: updated, error: upErr } = await supabase
.from('subscription_intents')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
notes: notes || null
})
.eq('id', intentId)
.select('*')
.maybeSingle()
if (upErr) throw upErr
// 2) ativa subscription do tenant (Modelo B)
const { data: sub, error: rpcErr } = await supabase.rpc('activate_subscription_from_intent', {
p_intent_id: intentId
})
if (rpcErr) throw rpcErr
return { intent: updated, subscription: sub }
}
export async function cancelIntent(intentId, notes = '') {
const { data, error } = await supabase
.from('subscription_intents')
.update({
status: 'canceled',
notes: notes || null
})
.eq('id', intentId)
.select('*')
.maybeSingle()
if (error) throw error
return data
}

View File

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

View File

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

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