Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda
This commit is contained in:
@@ -1,101 +0,0 @@
|
||||
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.
|
||||
@@ -1,47 +0,0 @@
|
||||
🔁 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
|
||||
|
||||
178
package-lock.json
generated
178
package-lock.json
generated
@@ -8,6 +8,13 @@
|
||||
"name": "sakai-vue",
|
||||
"version": "5.0.0",
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/resource": "^6.1.20",
|
||||
"@fullcalendar/resource-timegrid": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@fullcalendar/vue3": "^6.1.20",
|
||||
"@primeuix/themes": "^2.0.0",
|
||||
"@supabase/supabase-js": "^2.95.3",
|
||||
"chart.js": "3.3.2",
|
||||
@@ -513,6 +520,96 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/core": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
|
||||
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
|
||||
"dependencies": {
|
||||
"preact": "~10.12.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/daygrid": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
|
||||
"integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/interaction": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz",
|
||||
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/premium-common": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.20.tgz",
|
||||
"integrity": "sha512-rT+AitNnRyZuFEtYvsB1OJ2g1Bq2jmTR6qdn/dEU6LwkIj/4L499goLtMOena/JyJ31VBztdHrccX//36QrY3w==",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/resource": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/resource/-/resource-6.1.20.tgz",
|
||||
"integrity": "sha512-vpQs1eYJbc1zGOzF3obVVr+XsHTMTG7STKVQBEGy3AeFgfosRkUz+3DUawmy98vSjJUYOAQHO+pWW0ek0n5g0w==",
|
||||
"dependencies": {
|
||||
"@fullcalendar/premium-common": "~6.1.20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/resource-daygrid": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/resource-daygrid/-/resource-daygrid-6.1.20.tgz",
|
||||
"integrity": "sha512-g1rhNsTiGyx6U/01MCjRjQfpmkHpJABoTLS9TR2jcMa7X0SJd2xNd88phoMhIkYdfp+cZ29VOjhwN+3Xg6aohg==",
|
||||
"dependencies": {
|
||||
"@fullcalendar/daygrid": "~6.1.20",
|
||||
"@fullcalendar/premium-common": "~6.1.20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20",
|
||||
"@fullcalendar/resource": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/resource-timegrid": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/resource-timegrid/-/resource-timegrid-6.1.20.tgz",
|
||||
"integrity": "sha512-uMf9ERh1c/WeYHg5CPNGxYorkamDzfwUh2o9XS+9fR+KypIIovH1ArflOZF42XFsdrvQx61vDF0alt6/cOqT8Q==",
|
||||
"dependencies": {
|
||||
"@fullcalendar/premium-common": "~6.1.20",
|
||||
"@fullcalendar/resource-daygrid": "~6.1.20",
|
||||
"@fullcalendar/timegrid": "~6.1.20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20",
|
||||
"@fullcalendar/resource": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/timegrid": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
|
||||
"integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==",
|
||||
"dependencies": {
|
||||
"@fullcalendar/daygrid": "~6.1.20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/vue3": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.20.tgz",
|
||||
"integrity": "sha512-8qg6pS27II9QBwFkkJC+7SfflMpWqOe7i3ii5ODq9KpLAjwQAd/zjfq8RvKR1Yryoh5UmMCmvRbMB7i4RGtqog==",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20",
|
||||
"vue": "^3.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -3645,6 +3742,15 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.12.1",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -4672,6 +4778,73 @@
|
||||
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
|
||||
"dev": true
|
||||
},
|
||||
"@fullcalendar/core": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
|
||||
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
|
||||
"requires": {
|
||||
"preact": "~10.12.1"
|
||||
}
|
||||
},
|
||||
"@fullcalendar/daygrid": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
|
||||
"integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==",
|
||||
"requires": {}
|
||||
},
|
||||
"@fullcalendar/interaction": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz",
|
||||
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
|
||||
"requires": {}
|
||||
},
|
||||
"@fullcalendar/premium-common": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.20.tgz",
|
||||
"integrity": "sha512-rT+AitNnRyZuFEtYvsB1OJ2g1Bq2jmTR6qdn/dEU6LwkIj/4L499goLtMOena/JyJ31VBztdHrccX//36QrY3w==",
|
||||
"requires": {}
|
||||
},
|
||||
"@fullcalendar/resource": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/resource/-/resource-6.1.20.tgz",
|
||||
"integrity": "sha512-vpQs1eYJbc1zGOzF3obVVr+XsHTMTG7STKVQBEGy3AeFgfosRkUz+3DUawmy98vSjJUYOAQHO+pWW0ek0n5g0w==",
|
||||
"requires": {
|
||||
"@fullcalendar/premium-common": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"@fullcalendar/resource-daygrid": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/resource-daygrid/-/resource-daygrid-6.1.20.tgz",
|
||||
"integrity": "sha512-g1rhNsTiGyx6U/01MCjRjQfpmkHpJABoTLS9TR2jcMa7X0SJd2xNd88phoMhIkYdfp+cZ29VOjhwN+3Xg6aohg==",
|
||||
"requires": {
|
||||
"@fullcalendar/daygrid": "~6.1.20",
|
||||
"@fullcalendar/premium-common": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"@fullcalendar/resource-timegrid": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/resource-timegrid/-/resource-timegrid-6.1.20.tgz",
|
||||
"integrity": "sha512-uMf9ERh1c/WeYHg5CPNGxYorkamDzfwUh2o9XS+9fR+KypIIovH1ArflOZF42XFsdrvQx61vDF0alt6/cOqT8Q==",
|
||||
"requires": {
|
||||
"@fullcalendar/premium-common": "~6.1.20",
|
||||
"@fullcalendar/resource-daygrid": "~6.1.20",
|
||||
"@fullcalendar/timegrid": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"@fullcalendar/timegrid": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
|
||||
"integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==",
|
||||
"requires": {
|
||||
"@fullcalendar/daygrid": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"@fullcalendar/vue3": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.20.tgz",
|
||||
"integrity": "sha512-8qg6pS27II9QBwFkkJC+7SfflMpWqOe7i3ii5ODq9KpLAjwQAd/zjfq8RvKR1Yryoh5UmMCmvRbMB7i4RGtqog==",
|
||||
"requires": {}
|
||||
},
|
||||
"@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -6664,6 +6837,11 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true
|
||||
},
|
||||
"preact": {
|
||||
"version": "10.12.1",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg=="
|
||||
},
|
||||
"prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
"lint": "eslint --fix . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/resource": "^6.1.20",
|
||||
"@fullcalendar/resource-timegrid": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@fullcalendar/vue3": "^6.1.20",
|
||||
"@primeuix/themes": "^2.0.0",
|
||||
"@supabase/supabase-js": "^2.95.3",
|
||||
"chart.js": "3.3.2",
|
||||
|
||||
33
src/App.vue
33
src/App.vue
@@ -6,20 +6,35 @@ import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const tenant = useTenantStore()
|
||||
const ent = useEntitlementsStore()
|
||||
const tenantStore = useTenantStore()
|
||||
const entStore = useEntitlementsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await tenant.loadSessionAndTenant()
|
||||
await ent.loadForTenant(tenant.activeTenantId)
|
||||
// 1) carrega sessão + tenant ativo (do seu fluxo atual)
|
||||
await tenantStore.loadSessionAndTenant()
|
||||
|
||||
// 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'))
|
||||
// 2) carrega permissões do tenant ativo (se existir)
|
||||
if (tenantStore.activeTenantId) {
|
||||
await entStore.loadForTenant(tenantStore.activeTenantId)
|
||||
}
|
||||
|
||||
// 3) debug: localStorage com rótulos
|
||||
console.groupCollapsed('[Debug] Tenant localStorage')
|
||||
console.log('tenant_id:', localStorage.getItem('tenant_id'))
|
||||
console.log('currentTenantId:', localStorage.getItem('currentTenantId'))
|
||||
console.log('tenant:', localStorage.getItem('tenant'))
|
||||
console.groupEnd()
|
||||
|
||||
// 4) debug: stores
|
||||
console.groupCollapsed('[Debug] Tenant stores')
|
||||
console.log('route:', route.fullPath)
|
||||
console.log('activeTenantId:', tenantStore.activeTenantId)
|
||||
console.log('activeRole:', tenantStore.activeRole)
|
||||
console.log("can('online_scheduling.manage'):", entStore.can('online_scheduling.manage'))
|
||||
console.groupEnd()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
</template>
|
||||
@@ -219,4 +219,4 @@ export function stopAuthChanges () {
|
||||
authSubscription.unsubscribe()
|
||||
authSubscription = null
|
||||
}
|
||||
}
|
||||
}
|
||||
246
src/components/agenda/PausasChipsEditor.vue
Normal file
246
src/components/agenda/PausasChipsEditor.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<!-- src/components/agenda/PausasChipsEditor.vue -->
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Tag from 'primevue/tag'
|
||||
import Divider from 'primevue/divider'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] } // [{id,label,inicio,fim}]
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
function isValidHHMM(v) {
|
||||
return /^\d{2}:\d{2}$/.test(String(v || '').trim())
|
||||
}
|
||||
function hhmmToMin(hhmm) {
|
||||
const [h, m] = String(hhmm).split(':').map(Number)
|
||||
return h * 60 + m
|
||||
}
|
||||
function minToHHMM(min) {
|
||||
const h = Math.floor(min / 60) % 24
|
||||
const m = min % 60
|
||||
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0')
|
||||
}
|
||||
function newId() {
|
||||
return Math.random().toString(16).slice(2) + Date.now().toString(16)
|
||||
}
|
||||
|
||||
const internal = ref([])
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
internal.value = (Array.isArray(v) ? v : []).map(p => ({
|
||||
id: p.id || newId(),
|
||||
label: String(p.label || 'Pausa'),
|
||||
inicio: String(p.inicio || '').slice(0, 5),
|
||||
fim: String(p.fim || '').slice(0, 5)
|
||||
}))
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
function pushUpdate(next) {
|
||||
// ordena por início
|
||||
const sorted = [...next].sort((a, b) => hhmmToMin(a.inicio) - hhmmToMin(b.inicio))
|
||||
internal.value = sorted
|
||||
emit('update:modelValue', sorted)
|
||||
}
|
||||
|
||||
// união de intervalos existentes
|
||||
function normalizeIntervals(list) {
|
||||
const intervals = (list || [])
|
||||
.map(p => ({ s: hhmmToMin(p.inicio), e: hhmmToMin(p.fim) }))
|
||||
.sort((a, b) => a.s - b.s)
|
||||
|
||||
const merged = []
|
||||
for (const it of intervals) {
|
||||
if (!merged.length) merged.push({ ...it })
|
||||
else {
|
||||
const last = merged[merged.length - 1]
|
||||
if (it.s <= last.e) last.e = Math.max(last.e, it.e)
|
||||
else merged.push({ ...it })
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// retorna “sobras” de [s,e] depois de remover intervalos ocupados
|
||||
function subtractIntervals(s, e, occupiedMerged) {
|
||||
let segments = [{ s, e }]
|
||||
for (const occ of occupiedMerged) {
|
||||
const next = []
|
||||
for (const seg of segments) {
|
||||
// sem interseção
|
||||
if (seg.e <= occ.s || seg.s >= occ.e) {
|
||||
next.push(seg)
|
||||
continue
|
||||
}
|
||||
// corta esquerda
|
||||
if (seg.s < occ.s) next.push({ s: seg.s, e: Math.min(occ.s, seg.e) })
|
||||
// corta direita
|
||||
if (seg.e > occ.e) next.push({ s: Math.max(occ.e, seg.s), e: seg.e })
|
||||
}
|
||||
segments = next
|
||||
if (!segments.length) break
|
||||
}
|
||||
// remove segmentos muito pequenos (0)
|
||||
return segments.filter(x => x.e > x.s)
|
||||
}
|
||||
|
||||
function addPauseSmart({ label, inicio, fim }) {
|
||||
if (!isValidHHMM(inicio) || !isValidHHMM(fim)) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Horários devem estar em HH:MM.', life: 2200 })
|
||||
return
|
||||
}
|
||||
if (fim <= inicio) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'O fim deve ser maior que o início.', life: 2200 })
|
||||
return
|
||||
}
|
||||
|
||||
const s = hhmmToMin(inicio)
|
||||
const e = hhmmToMin(fim)
|
||||
|
||||
const occupied = normalizeIntervals(internal.value)
|
||||
const segments = subtractIntervals(s, e, occupied)
|
||||
|
||||
if (!segments.length) {
|
||||
toast.add({ severity: 'info', summary: 'Nada a adicionar', detail: 'Esse período já está coberto por outra pausa.', life: 2400 })
|
||||
return
|
||||
}
|
||||
|
||||
const toAdd = segments.map(seg => ({
|
||||
id: newId(),
|
||||
label: label || 'Pausa',
|
||||
inicio: minToHHMM(seg.s),
|
||||
fim: minToHHMM(seg.e)
|
||||
}))
|
||||
|
||||
// se houve “recorte”, avisa
|
||||
if (segments.length !== 1 || (segments[0].s !== s || segments[0].e !== e)) {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Ajuste automático',
|
||||
detail: 'Havia conflito com outra pausa; adicionei apenas o trecho que não sobrepõe.',
|
||||
life: 3200
|
||||
})
|
||||
}
|
||||
|
||||
pushUpdate([...internal.value, ...toAdd])
|
||||
}
|
||||
|
||||
function removePause(id) {
|
||||
pushUpdate(internal.value.filter(p => p.id !== id))
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// UI: presets + custom dialog
|
||||
// ======================================================
|
||||
const presets = [
|
||||
{ label: 'Almoço', inicio: '12:00', fim: '13:00' },
|
||||
{ label: 'Almoço', inicio: '13:00', fim: '14:00' },
|
||||
{ label: 'Janta', inicio: '18:00', fim: '19:00' }
|
||||
]
|
||||
|
||||
const dlg = ref(false)
|
||||
const form = ref({ label: 'Pausa', inicio: '12:00', fim: '13:00' })
|
||||
|
||||
function openCustom() {
|
||||
form.value = { label: 'Pausa', inicio: '12:00', fim: '13:00' }
|
||||
dlg.value = true
|
||||
}
|
||||
function saveCustom() {
|
||||
addPauseSmart(form.value)
|
||||
dlg.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<!-- actions -->
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<Button
|
||||
v-for="(p, idx) in presets"
|
||||
:key="'pre_'+idx"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
icon="pi pi-plus"
|
||||
:label="`${p.label} (${p.inicio}–${p.fim})`"
|
||||
@click="addPauseSmart(p)"
|
||||
/>
|
||||
<Button size="small" icon="pi pi-sliders-h" label="Customizar" @click="openCustom" />
|
||||
</div>
|
||||
|
||||
<Divider class="my-2" />
|
||||
|
||||
<!-- chips/badges -->
|
||||
<div v-if="!internal.length" class="text-600 text-sm">
|
||||
Nenhuma pausa adicionada.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="p in internal"
|
||||
:key="p.id"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-1"
|
||||
>
|
||||
<Tag :value="p.label" severity="secondary" />
|
||||
<span class="text-600 text-sm">{{ p.inicio }}–{{ p.fim }}</span>
|
||||
<Button icon="pi pi-times" text rounded severity="danger" @click="removePause(p.id)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- custom dialog -->
|
||||
<Dialog v-model:visible="dlg" modal header="Adicionar pausa" :style="{ width: '520px' }">
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12">
|
||||
<FloatLabel variant="on">
|
||||
<InputText v-model="form.label" class="w-full" inputId="plabel" placeholder="Ex.: Almoço" />
|
||||
<label for="plabel">Nome</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<FloatLabel variant="on">
|
||||
<InputText v-model="form.inicio" class="w-full" inputId="pinicio" placeholder="12:00" />
|
||||
<label for="pinicio">Início (HH:MM)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<FloatLabel variant="on">
|
||||
<InputText v-model="form.fim" class="w-full" inputId="pfim" placeholder="13:00" />
|
||||
<label for="pfim">Fim (HH:MM)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div v-if="isValidHHMM(form.inicio) && isValidHHMM(form.fim) && form.fim <= form.inicio" class="col-span-12 text-sm text-red-500">
|
||||
O fim precisa ser maior que o início.
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 text-600 text-xs">
|
||||
Se houver conflito com outra pausa, o sistema adiciona automaticamente apenas o trecho que não sobrepõe.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" icon="pi pi-times" severity="secondary" outlined @click="dlg = false" />
|
||||
<Button
|
||||
label="Adicionar"
|
||||
icon="pi pi-check"
|
||||
:disabled="!isValidHHMM(form.inicio) || !isValidHHMM(form.fim) || form.fim <= form.inicio"
|
||||
@click="saveCustom"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,11 +1,49 @@
|
||||
// src/constants/roles.js
|
||||
|
||||
/**
|
||||
* Roles canônicas do sistema (tenant-level)
|
||||
* Esses valores devem refletir exatamente o que existe no banco.
|
||||
*/
|
||||
export const ROLES = {
|
||||
ADMIN: 'admin',
|
||||
CLINIC_ADMIN: 'clinic_admin',
|
||||
TENANT_ADMIN: 'tenant_admin', // legado (compatibilidade)
|
||||
THERAPIST: 'therapist',
|
||||
PATIENT: 'patient'
|
||||
}
|
||||
|
||||
export const ROLE_HOME = {
|
||||
admin: '/admin',
|
||||
therapist: '/therapist',
|
||||
patient: '/patient'
|
||||
|
||||
/**
|
||||
* Retorna a rota base (home) de cada role.
|
||||
* Usado após login, guards e redirecionamentos.
|
||||
*/
|
||||
export function roleToHome(role) {
|
||||
switch (role) {
|
||||
case ROLES.CLINIC_ADMIN:
|
||||
case ROLES.TENANT_ADMIN: // compatibilidade
|
||||
return '/admin'
|
||||
|
||||
case ROLES.THERAPIST:
|
||||
return '/therapist'
|
||||
|
||||
case ROLES.PATIENT:
|
||||
return '/portal'
|
||||
|
||||
default:
|
||||
return '/'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Lista todas as roles válidas
|
||||
* Útil para validações e guards
|
||||
*/
|
||||
export const ALL_ROLES = Object.values(ROLES)
|
||||
|
||||
|
||||
/**
|
||||
* Verifica se uma role é válida
|
||||
*/
|
||||
export function isValidRole(role) {
|
||||
return ALL_ROLES.includes(role)
|
||||
}
|
||||
1004
src/features/agenda/components/AdicionarCompromissoPage.vue
Normal file
1004
src/features/agenda/components/AdicionarCompromissoPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
10
src/features/agenda/components/AgendaCalendar.vue
Normal file
10
src/features/agenda/components/AgendaCalendar.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script setup>
|
||||
defineProps({})
|
||||
defineEmits([])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<b>AgendaCalendar (placeholder)</b>
|
||||
</div>
|
||||
</template>
|
||||
100
src/features/agenda/components/AgendaClinicCalendar.vue
Normal file
100
src/features/agenda/components/AgendaClinicCalendar.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<!-- src/features/agenda/components/AgendaClinicCalendar.vue -->
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import resourceTimeGridPlugin from '@fullcalendar/resource-timegrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
|
||||
const props = defineProps({
|
||||
view: { type: String, default: 'day' }, // 'day' | 'week'
|
||||
timezone: { type: String, default: 'America/Sao_Paulo' },
|
||||
|
||||
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
|
||||
slotDuration: { type: String, default: '00:30:00' },
|
||||
slotMinTime: { type: String, default: '06:00:00' },
|
||||
slotMaxTime: { type: String, default: '22:00:00' },
|
||||
|
||||
resources: { type: Array, default: () => [] }, // [{ id, title }]
|
||||
events: { type: Array, default: () => [] }, // event.resourceId = resource.id
|
||||
|
||||
loading: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'rangeChange',
|
||||
'eventClick',
|
||||
'eventDrop',
|
||||
'eventResize'
|
||||
])
|
||||
|
||||
const calendarRef = ref(null)
|
||||
|
||||
const initialView = computed(() => (props.view === 'week' ? 'resourceTimeGridWeek' : 'resourceTimeGridDay'))
|
||||
|
||||
const computedSlotMinTime = computed(() => (props.mode === 'full_24h' ? '00:00:00' : props.slotMinTime))
|
||||
const computedSlotMaxTime = computed(() => (props.mode === 'full_24h' ? '24:00:00' : props.slotMaxTime))
|
||||
|
||||
const options = computed(() => ({
|
||||
plugins: [resourceTimeGridPlugin, interactionPlugin],
|
||||
initialView: initialView.value,
|
||||
timeZone: props.timezone,
|
||||
|
||||
headerToolbar: false,
|
||||
|
||||
nowIndicator: true,
|
||||
editable: true,
|
||||
|
||||
slotDuration: props.slotDuration,
|
||||
slotMinTime: computedSlotMinTime.value,
|
||||
slotMaxTime: computedSlotMaxTime.value,
|
||||
|
||||
resourceAreaWidth: '280px',
|
||||
resourceAreaHeaderContent: 'Profissionais',
|
||||
resources: props.resources,
|
||||
|
||||
events: props.events,
|
||||
|
||||
datesSet(arg) {
|
||||
emit('rangeChange', {
|
||||
start: arg.start,
|
||||
end: arg.end,
|
||||
startStr: arg.startStr,
|
||||
endStr: arg.endStr,
|
||||
viewType: arg.view.type
|
||||
})
|
||||
},
|
||||
|
||||
eventClick(info) { emit('eventClick', info) },
|
||||
eventDrop(info) { emit('eventDrop', info) },
|
||||
eventResize(info) { emit('eventResize', info) },
|
||||
|
||||
height: 'auto',
|
||||
expandRows: true,
|
||||
allDaySlot: false
|
||||
}))
|
||||
|
||||
function api () {
|
||||
const fc = calendarRef.value
|
||||
return fc?.getApi?.()
|
||||
}
|
||||
|
||||
function goToday () { api()?.today() }
|
||||
function prev () { api()?.prev() }
|
||||
function next () { api()?.next() }
|
||||
function setView (v) { api()?.changeView(v === 'week' ? 'resourceTimeGridWeek' : 'resourceTimeGridDay') }
|
||||
|
||||
defineExpose({ goToday, prev, next, setView })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-[1.5rem] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<div v-if="loading" class="p-4 text-sm opacity-70 border-b border-[var(--surface-border)]">
|
||||
Carregando agenda da clínica…
|
||||
</div>
|
||||
|
||||
<div class="p-2 md:p-3">
|
||||
<FullCalendar ref="calendarRef" :options="options" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
192
src/features/agenda/components/AgendaClinicMosaic.vue
Normal file
192
src/features/agenda/components/AgendaClinicMosaic.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
|
||||
const props = defineProps({
|
||||
view: { type: String, default: 'day' }, // 'day' | 'week'
|
||||
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
|
||||
timezone: { type: String, default: 'America/Sao_Paulo' },
|
||||
|
||||
slotDuration: { type: String, default: '00:30:00' },
|
||||
slotMinTime: { type: String, default: '06:00:00' },
|
||||
slotMaxTime: { type: String, default: '22:00:00' },
|
||||
|
||||
// [{ id, title }]
|
||||
staff: { type: Array, default: () => [] },
|
||||
|
||||
// todos os eventos (com extendedProps.owner_id)
|
||||
events: { type: Array, default: () => [] },
|
||||
|
||||
loading: { type: Boolean, default: false },
|
||||
|
||||
// controla quantas colunas "visíveis" por vez (resto vai por scroll horizontal)
|
||||
minColWidth: { type: Number, default: 360 }
|
||||
})
|
||||
|
||||
// ✅ rangeChange = mudança de range (carregar eventos)
|
||||
// ✅ slotSelect = seleção de intervalo em uma coluna específica (criar evento)
|
||||
// ✅ eventClick/Drop/Resize = ações em evento
|
||||
const emit = defineEmits(['rangeChange', 'slotSelect', 'eventClick', 'eventDrop', 'eventResize'])
|
||||
|
||||
const calendarRefs = ref([])
|
||||
|
||||
function setCalendarRef (el, idx) {
|
||||
if (!el) return
|
||||
calendarRefs.value[idx] = el
|
||||
}
|
||||
|
||||
const initialView = computed(() => (props.view === 'week' ? 'timeGridWeek' : 'timeGridDay'))
|
||||
|
||||
const computedSlotMinTime = computed(() => (props.mode === 'full_24h' ? '00:00:00' : props.slotMinTime))
|
||||
// ✅ 23:59:59 para evitar edge-case de 24:00:00
|
||||
const computedSlotMaxTime = computed(() => (props.mode === 'full_24h' ? '23:59:59' : props.slotMaxTime))
|
||||
|
||||
function apiAt (idx) {
|
||||
const fc = calendarRefs.value[idx]
|
||||
return fc?.getApi?.()
|
||||
}
|
||||
|
||||
function forEachApi (fn) {
|
||||
for (let i = 0; i < calendarRefs.value.length; i++) {
|
||||
const api = apiAt(i)
|
||||
if (api) fn(api, i)
|
||||
}
|
||||
}
|
||||
|
||||
function goToday () { forEachApi(api => api.today()) }
|
||||
function prev () { forEachApi(api => api.prev()) }
|
||||
function next () { forEachApi(api => api.next()) }
|
||||
|
||||
function setView (v) {
|
||||
const target = v === 'week' ? 'timeGridWeek' : 'timeGridDay'
|
||||
forEachApi(api => api.changeView(target))
|
||||
}
|
||||
|
||||
defineExpose({ goToday, prev, next, setView })
|
||||
|
||||
// Eventos por profissional (owner)
|
||||
function eventsFor (ownerId) {
|
||||
const list = props.events || []
|
||||
return list.filter(e => e?.extendedProps?.owner_id === ownerId)
|
||||
}
|
||||
|
||||
// ---- range sync ----
|
||||
let lastRangeKey = ''
|
||||
let suppressSync = false
|
||||
|
||||
function onDatesSet (arg) {
|
||||
const key = `${arg.startStr}__${arg.endStr}__${arg.view?.type || ''}`
|
||||
if (key === lastRangeKey) return
|
||||
lastRangeKey = key
|
||||
|
||||
// dispara carregamento no pai
|
||||
emit('rangeChange', {
|
||||
start: arg.start,
|
||||
end: arg.end,
|
||||
startStr: arg.startStr,
|
||||
endStr: arg.endStr,
|
||||
viewType: arg.view.type
|
||||
})
|
||||
|
||||
// mantém todos os calendários na mesma data
|
||||
if (suppressSync) return
|
||||
suppressSync = true
|
||||
|
||||
const masterDate = arg.start
|
||||
forEachApi((api) => {
|
||||
const cur = api.view?.currentStart
|
||||
if (!cur) return
|
||||
if (cur.getTime() !== masterDate.getTime()) api.gotoDate(masterDate)
|
||||
})
|
||||
|
||||
// libera no próximo tick (evita loops)
|
||||
Promise.resolve().then(() => { suppressSync = false })
|
||||
}
|
||||
|
||||
// Se trocar view, garante que todos estão no mesmo
|
||||
watch(() => props.view, async () => {
|
||||
await nextTick()
|
||||
setView(props.view)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-[1.5rem] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<div v-if="loading" class="p-4 text-sm opacity-70 border-b border-[var(--surface-border)]">
|
||||
Carregando agenda da clínica…
|
||||
</div>
|
||||
|
||||
<!-- Mosaic -->
|
||||
<div
|
||||
class="p-2 md:p-3 overflow-x-auto"
|
||||
:style="{ display: 'grid', gridAutoFlow: 'column', gridAutoColumns: `minmax(${minColWidth}px, 1fr)`, gap: '12px' }"
|
||||
>
|
||||
<div
|
||||
v-for="(p, idx) in staff"
|
||||
:key="p.id"
|
||||
class="rounded-[1.25rem] border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)] overflow-hidden"
|
||||
>
|
||||
<!-- Header da coluna -->
|
||||
<div class="p-3 border-b border-[var(--surface-border)] flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold truncate">{{ p.title }}</div>
|
||||
<div class="text-xs opacity-70 truncate">Visão diária operacional</div>
|
||||
</div>
|
||||
<div class="text-xs opacity-70 whitespace-nowrap">
|
||||
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<FullCalendar
|
||||
:ref="(el) => setCalendarRef(el, idx)"
|
||||
:options="{
|
||||
plugins: [timeGridPlugin, interactionPlugin],
|
||||
initialView: initialView,
|
||||
timeZone: timezone,
|
||||
|
||||
headerToolbar: false,
|
||||
nowIndicator: true,
|
||||
|
||||
editable: true,
|
||||
|
||||
// ✅ seleção para criar evento (por coluna)
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
select: (selection) => {
|
||||
emit('slotSelect', {
|
||||
ownerId: p.id,
|
||||
start: selection.start,
|
||||
end: selection.end,
|
||||
startStr: selection.startStr,
|
||||
endStr: selection.endStr,
|
||||
jsEvent: selection.jsEvent || null,
|
||||
viewType: selection.view?.type || initialView
|
||||
})
|
||||
},
|
||||
|
||||
slotDuration: slotDuration,
|
||||
slotMinTime: computedSlotMinTime,
|
||||
slotMaxTime: computedSlotMaxTime,
|
||||
|
||||
height: 'auto',
|
||||
expandRows: true,
|
||||
allDaySlot: false,
|
||||
|
||||
events: eventsFor(p.id),
|
||||
|
||||
datesSet: onDatesSet,
|
||||
|
||||
eventClick: (info) => emit('eventClick', info),
|
||||
eventDrop: (info) => emit('eventDrop', info),
|
||||
eventResize: (info) => emit('eventResize', info)
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
243
src/features/agenda/components/AgendaEventDialog.vue
Normal file
243
src/features/agenda/components/AgendaEventDialog.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Button from 'primevue/button'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
|
||||
// Para editar
|
||||
eventRow: { type: Object, default: null },
|
||||
|
||||
// Para criar via seleção no calendário
|
||||
initialStartISO: { type: String, default: '' },
|
||||
initialEndISO: { type: String, default: '' },
|
||||
|
||||
// Quem é o dono da agenda (owner_id)
|
||||
ownerId: { type: String, default: '' },
|
||||
|
||||
// Se estiver criando na visão clínica e quiser atribuir a um owner específico
|
||||
allowOwnerEdit: { type: Boolean, default: false },
|
||||
ownerOptions: { type: Array, default: () => [] } // [{ label, value }]
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save', 'delete'])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const tipoOptions = [
|
||||
{ label: 'Sessão', value: 'sessao' },
|
||||
{ label: 'Bloqueio', value: 'bloqueio' },
|
||||
{ label: 'Pessoal', value: 'pessoal' },
|
||||
{ label: 'Clínica', value: 'clinica' }
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Agendado', value: 'agendado' },
|
||||
{ label: 'Realizado', value: 'realizado' },
|
||||
{ label: 'Faltou', value: 'faltou' },
|
||||
{ label: 'Cancelado', value: 'cancelado' }
|
||||
]
|
||||
|
||||
const form = ref(resetForm())
|
||||
|
||||
watch(
|
||||
() => [props.eventRow, props.initialStartISO, props.initialEndISO, props.ownerId],
|
||||
() => {
|
||||
form.value = resetForm()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function resetForm () {
|
||||
const r = props.eventRow
|
||||
|
||||
// ISO strings (timestamptz)
|
||||
const startISO = r?.inicio_em || props.initialStartISO || ''
|
||||
const endISO = r?.fim_em || props.initialEndISO || ''
|
||||
|
||||
return {
|
||||
id: r?.id || null,
|
||||
owner_id: r?.owner_id || props.ownerId || '',
|
||||
terapeuta_id: r?.terapeuta_id ?? null,
|
||||
paciente_id: r?.paciente_id ?? null,
|
||||
|
||||
tipo: r?.tipo || 'sessao',
|
||||
status: r?.status || 'agendado',
|
||||
titulo: r?.titulo || '',
|
||||
observacoes: r?.observacoes || '',
|
||||
|
||||
inicio_em: startISO,
|
||||
fim_em: endISO,
|
||||
|
||||
// ajuda de UX (minutos) caso você queira editar duração fácil
|
||||
duracaoMin: calcMinutes(startISO, endISO) || 50
|
||||
}
|
||||
}
|
||||
|
||||
function calcMinutes (a, b) {
|
||||
try {
|
||||
if (!a || !b) return null
|
||||
const ms = new Date(b).getTime() - new Date(a).getTime()
|
||||
return Math.max(0, Math.round(ms / 60000))
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function addMinutesISO (iso, min) {
|
||||
const d = new Date(iso)
|
||||
d.setMinutes(d.getMinutes() + Number(min || 0))
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
const isEdit = computed(() => !!form.value.id)
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (!form.value.owner_id) return false
|
||||
if (!form.value.inicio_em) return false
|
||||
if (!form.value.fim_em) return false
|
||||
const a = new Date(form.value.inicio_em).getTime()
|
||||
const b = new Date(form.value.fim_em).getTime()
|
||||
return b > a
|
||||
})
|
||||
|
||||
function applyDuration () {
|
||||
if (!form.value.inicio_em) return
|
||||
form.value.fim_em = addMinutesISO(form.value.inicio_em, form.value.duracaoMin || 50)
|
||||
}
|
||||
|
||||
function onSave () {
|
||||
if (!canSave.value) return
|
||||
|
||||
const payload = {
|
||||
owner_id: form.value.owner_id,
|
||||
terapeuta_id: form.value.terapeuta_id,
|
||||
paciente_id: form.value.paciente_id,
|
||||
|
||||
tipo: form.value.tipo,
|
||||
status: form.value.status,
|
||||
titulo: form.value.titulo || null,
|
||||
observacoes: form.value.observacoes || null,
|
||||
|
||||
inicio_em: form.value.inicio_em,
|
||||
fim_em: form.value.fim_em
|
||||
}
|
||||
|
||||
emit('save', { id: form.value.id, payload })
|
||||
}
|
||||
|
||||
function onDelete () {
|
||||
if (!form.value.id) return
|
||||
emit('delete', form.value.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:visible="visible" modal :style="{ width: '720px', maxWidth: '95vw' }" :header="isEdit ? 'Editar evento' : 'Novo evento'">
|
||||
<div class="grid gap-4">
|
||||
<div class="grid md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FloatLabel>
|
||||
<Dropdown
|
||||
id="tipo"
|
||||
class="w-full"
|
||||
:options="tipoOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
v-model="form.tipo"
|
||||
/>
|
||||
<label for="tipo">Tipo</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FloatLabel>
|
||||
<Dropdown
|
||||
id="status"
|
||||
class="w-full"
|
||||
:options="statusOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
v-model="form.status"
|
||||
/>
|
||||
<label for="status">Status</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div v-if="allowOwnerEdit">
|
||||
<FloatLabel>
|
||||
<Dropdown
|
||||
id="owner"
|
||||
class="w-full"
|
||||
:options="ownerOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
v-model="form.owner_id"
|
||||
/>
|
||||
<label for="owner">Profissional</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FloatLabel>
|
||||
<InputText id="titulo" class="w-full" v-model="form.titulo" placeholder=" " />
|
||||
<label for="titulo">Título</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FloatLabel>
|
||||
<InputText id="inicio" class="w-full" v-model="form.inicio_em" placeholder=" " />
|
||||
<label for="inicio">Início (ISO)</label>
|
||||
</FloatLabel>
|
||||
<div class="text-xs opacity-70 mt-1">Por enquanto em ISO. Depois trocamos para DatePicker bonito.</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FloatLabel>
|
||||
<InputText id="fim" class="w-full" v-model="form.fim_em" placeholder=" " />
|
||||
<label for="fim">Fim (ISO)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-3 items-end">
|
||||
<div>
|
||||
<FloatLabel>
|
||||
<InputNumber id="dur" class="w-full" v-model="form.duracaoMin" :min="5" :max="480" />
|
||||
<label for="dur">Duração (min)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Aplicar duração" severity="secondary" outlined icon="pi pi-clock" @click="applyDuration" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FloatLabel>
|
||||
<Textarea id="obs" class="w-full" autoResize rows="3" v-model="form.observacoes" placeholder=" " />
|
||||
<label for="obs">Observações</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center pt-2">
|
||||
<Button v-if="isEdit" label="Excluir" icon="pi pi-trash" severity="danger" outlined @click="onDelete" />
|
||||
|
||||
<div class="flex gap-2 ml-auto">
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="visible = false" />
|
||||
<Button label="Salvar" icon="pi pi-check" :disabled="!canSave" @click="onSave" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
13
src/features/agenda/components/AgendaRightPanel.vue
Normal file
13
src/features/agenda/components/AgendaRightPanel.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
defineProps({})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<b>AgendaRightPanel (placeholder)</b>
|
||||
<div class="mt-3">
|
||||
<slot name="top" />
|
||||
<slot name="bottom" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
115
src/features/agenda/components/AgendaToolbar.vue
Normal file
115
src/features/agenda/components/AgendaToolbar.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Button from 'primevue/button'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import ToggleButton from 'primevue/togglebutton'
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: 'Agenda' },
|
||||
|
||||
// 'day' | 'week'
|
||||
view: { type: String, default: 'day' },
|
||||
|
||||
// 'full_24h' | 'work_hours'
|
||||
mode: { type: String, default: 'work_hours' },
|
||||
|
||||
showSearch: { type: Boolean, default: true },
|
||||
searchPlaceholder: { type: String, default: ' ' },
|
||||
|
||||
// controla se exibe botões de ação
|
||||
showActions: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'today',
|
||||
'prev',
|
||||
'next',
|
||||
'changeView',
|
||||
'toggleMode',
|
||||
'createSession',
|
||||
'createBlock',
|
||||
'search'
|
||||
])
|
||||
|
||||
const viewOptions = [
|
||||
{ label: 'Dia', value: 'day' },
|
||||
{ label: 'Semana', value: 'week' }
|
||||
]
|
||||
|
||||
const search = ref('')
|
||||
|
||||
watch(search, (v) => emit('search', v))
|
||||
|
||||
const modeLabel = computed(() => (props.mode === 'full_24h' ? '24h' : 'Horário'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-4 overflow-hidden rounded-[1.5rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="p-4 md:p-5 flex flex-col gap-3">
|
||||
<!-- topo -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-lg md:text-xl font-semibold truncate">{{ title }}</div>
|
||||
<div class="text-sm opacity-70">Operação do dia com visão e ação.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button label="Hoje" icon="pi pi-calendar" severity="secondary" outlined @click="$emit('today')" />
|
||||
<Button icon="pi pi-chevron-left" severity="secondary" outlined @click="$emit('prev')" />
|
||||
<Button icon="pi pi-chevron-right" severity="secondary" outlined @click="$emit('next')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- controles -->
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-3 md:justify-between">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<SelectButton
|
||||
:modelValue="view"
|
||||
:options="viewOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
@update:modelValue="$emit('changeView', $event)"
|
||||
/>
|
||||
|
||||
<ToggleButton
|
||||
:modelValue="mode === 'full_24h'"
|
||||
onLabel="24h"
|
||||
offLabel="Horário"
|
||||
@update:modelValue="$emit('toggleMode', $event ? 'full_24h' : 'work_hours')"
|
||||
/>
|
||||
|
||||
<div class="text-sm opacity-70">
|
||||
Modo: <b>{{ modeLabel }}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row items-stretch md:items-center gap-2">
|
||||
<template v-if="showActions">
|
||||
<Button label="Nova sessão" icon="pi pi-plus" @click="$emit('createSession')" />
|
||||
<Button label="Bloquear" icon="pi pi-lock" severity="secondary" outlined @click="$emit('createBlock')" />
|
||||
</template>
|
||||
|
||||
<div v-if="showSearch" class="md:w-72 w-full">
|
||||
<FloatLabel>
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
id="agendaSearch"
|
||||
class="w-full"
|
||||
v-model="search"
|
||||
:placeholder="searchPlaceholder"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="agendaSearch">Buscar por paciente/título</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
0
src/features/agenda/components/ConflictBanner.vue
Normal file
0
src/features/agenda/components/ConflictBanner.vue
Normal file
0
src/features/agenda/components/PreviewTimeline.vue
Normal file
0
src/features/agenda/components/PreviewTimeline.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
items: { type: Array, default: () => [] }
|
||||
})
|
||||
defineEmits(['open', 'confirm', 'reschedule'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-3 border rounded-lg">
|
||||
<b>AgendaNextSessionsCardList (placeholder)</b>
|
||||
</div>
|
||||
</template>
|
||||
12
src/features/agenda/components/cards/AgendaPulseCardGrid.vue
Normal file
12
src/features/agenda/components/cards/AgendaPulseCardGrid.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
stats: { type: Object, default: () => ({}) }
|
||||
})
|
||||
defineEmits(['quickBlock', 'quickCreate'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-3 border rounded-lg">
|
||||
<b>AgendaPulseCardGrid (placeholder)</b>
|
||||
</div>
|
||||
</template>
|
||||
24
src/features/agenda/composables/useAgendaClinicStaff.js
Normal file
24
src/features/agenda/composables/useAgendaClinicStaff.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// src/features/agenda/composables/useAgendaClinicStaff.js
|
||||
import { ref } from 'vue'
|
||||
import { listTenantStaff } from '../services/agendaRepository'
|
||||
|
||||
export function useAgendaClinicStaff () {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const staff = ref([])
|
||||
|
||||
async function load (tenantId) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
staff.value = await listTenantStaff(tenantId)
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar profissionais.'
|
||||
staff.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, staff, load }
|
||||
}
|
||||
106
src/features/agenda/composables/useAgendaEvents.js
Normal file
106
src/features/agenda/composables/useAgendaEvents.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// src/features/agenda/composables/useAgendaEvents.js
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
listMyAgendaEvents,
|
||||
listClinicEvents,
|
||||
createAgendaEvento,
|
||||
updateAgendaEvento,
|
||||
deleteAgendaEvento
|
||||
} from '../services/agendaRepository.js'
|
||||
|
||||
export function useAgendaEvents () {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rows = ref([])
|
||||
|
||||
async function loadMyRange (startISO, endISO) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
rows.value = await listMyAgendaEvents({ startISO, endISO })
|
||||
return rows.value
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar eventos.'
|
||||
rows.value = []
|
||||
return []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClinicRange (ownerIds, startISO, endISO) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
// ✅ evita erro "invalid input syntax for type uuid: null"
|
||||
const safeIds = (ownerIds || []).filter(id => typeof id === 'string' && id && id !== 'null' && id !== 'undefined')
|
||||
if (!safeIds.length) {
|
||||
rows.value = []
|
||||
return []
|
||||
}
|
||||
|
||||
rows.value = await listClinicEvents({ ownerIds: safeIds, startISO, endISO })
|
||||
return rows.value
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar eventos da clínica.'
|
||||
rows.value = []
|
||||
return []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create (payload) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const created = await createAgendaEvento(payload)
|
||||
return created
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao criar evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function update (id, patch) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const updated = await updateAgendaEvento(id, patch)
|
||||
return updated
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao atualizar evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove (id) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await deleteAgendaEvento(id)
|
||||
return true
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao excluir evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
rows,
|
||||
loadMyRange,
|
||||
loadClinicRange,
|
||||
create,
|
||||
update,
|
||||
remove
|
||||
}
|
||||
}
|
||||
0
src/features/agenda/composables/useAgendaLimits.js
Normal file
0
src/features/agenda/composables/useAgendaLimits.js
Normal file
0
src/features/agenda/composables/useAgendaQuery.js
Normal file
0
src/features/agenda/composables/useAgendaQuery.js
Normal file
24
src/features/agenda/composables/useAgendaSettings.js
Normal file
24
src/features/agenda/composables/useAgendaSettings.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// src/features/agenda/composables/useAgendaSettings.js
|
||||
import { ref } from 'vue'
|
||||
import { getMyAgendaSettings } from '../services/agendaRepository'
|
||||
|
||||
export function useAgendaSettings () {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const settings = ref(null)
|
||||
|
||||
async function load () {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
settings.value = await getMyAgendaSettings()
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar configurações da agenda.'
|
||||
settings.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, settings, load }
|
||||
}
|
||||
0
src/features/agenda/domain/agenda.mappers.js
Normal file
0
src/features/agenda/domain/agenda.mappers.js
Normal file
0
src/features/agenda/domain/agenda.types.js
Normal file
0
src/features/agenda/domain/agenda.types.js
Normal file
288
src/features/agenda/pages/AgendaClinicaPage.vue
Normal file
288
src/features/agenda/pages/AgendaClinicaPage.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import AgendaToolbar from '../components/AgendaToolbar.vue'
|
||||
import AgendaClinicMosaic from '../components/AgendaClinicMosaic.vue'
|
||||
import AgendaEventDialog from '../components/AgendaEventDialog.vue'
|
||||
|
||||
import { useAgendaEvents } from '../composables/useAgendaEvents.js'
|
||||
import { useAgendaClinicStaff } from '../composables/useAgendaClinicStaff.js'
|
||||
import { mapAgendaEventosToCalendarEvents } from '../services/agendaMappers.js'
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// -------------------- UI state --------------------
|
||||
const view = ref('day')
|
||||
const mode = ref('work_hours')
|
||||
const calendarRef = ref(null)
|
||||
|
||||
const dialogOpen = ref(false)
|
||||
const dialogEventRow = ref(null)
|
||||
const dialogStartISO = ref('')
|
||||
const dialogEndISO = ref('')
|
||||
|
||||
// guardamos o range atual (para recarregar depois)
|
||||
const currentRange = ref({ start: null, end: null })
|
||||
|
||||
// -------------------- data --------------------
|
||||
const { loading: loadingStaff, error: staffError, staff, load: loadStaff } = useAgendaClinicStaff()
|
||||
|
||||
// ✅ agora já pega também create/update/remove (se você já atualizou o composable)
|
||||
const {
|
||||
loading: loadingEvents,
|
||||
error: eventsError,
|
||||
rows,
|
||||
loadClinicRange,
|
||||
create,
|
||||
update,
|
||||
remove
|
||||
} = useAgendaEvents()
|
||||
|
||||
const tenantId = computed(() => {
|
||||
const t = tenantStore.activeTenantId
|
||||
if (!t) return null
|
||||
if (t === 'null' || t === 'undefined') return null
|
||||
return t
|
||||
})
|
||||
|
||||
function isUuid (v) {
|
||||
return typeof v === 'string' &&
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)
|
||||
}
|
||||
|
||||
const staffCols = computed(() => {
|
||||
return (staff.value || [])
|
||||
.filter(s => isUuid(s.user_id))
|
||||
.map(s => ({
|
||||
id: s.user_id,
|
||||
title: s.full_name || s.nome || s.name || s.email || 'Profissional'
|
||||
}))
|
||||
})
|
||||
|
||||
// ✅ AQUI está o que faltava: ownerIds
|
||||
const ownerIds = computed(() => staffCols.value.map(p => p.id))
|
||||
|
||||
const allEvents = computed(() => mapAgendaEventosToCalendarEvents(rows.value || []))
|
||||
|
||||
const ownerOptions = computed(() =>
|
||||
staffCols.value.map(p => ({ label: p.title, value: p.id }))
|
||||
)
|
||||
|
||||
// -------------------- lifecycle --------------------
|
||||
onMounted(async () => {
|
||||
if (!tenantId.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Clínica', detail: 'Nenhum tenant ativo.', life: 4500 })
|
||||
return
|
||||
}
|
||||
|
||||
await loadStaff(tenantId.value)
|
||||
if (staffError.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Profissionais', detail: staffError.value, life: 4500 })
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------- toolbar actions --------------------
|
||||
function onToday () { calendarRef.value?.goToday?.() }
|
||||
function onPrev () { calendarRef.value?.prev?.() }
|
||||
function onNext () { calendarRef.value?.next?.() }
|
||||
|
||||
function onChangeView (v) {
|
||||
view.value = v
|
||||
calendarRef.value?.setView?.(v)
|
||||
}
|
||||
|
||||
function onToggleMode (m) {
|
||||
mode.value = m
|
||||
}
|
||||
|
||||
// -------------------- calendar callbacks --------------------
|
||||
async function onRangeChange ({ start, end }) {
|
||||
currentRange.value = { start, end }
|
||||
|
||||
const ids = ownerIds.value
|
||||
if (!ids.length) return
|
||||
|
||||
await loadClinicRange(ids, new Date(start).toISOString(), new Date(end).toISOString())
|
||||
|
||||
if (eventsError.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Eventos', detail: eventsError.value, life: 4500 })
|
||||
}
|
||||
}
|
||||
|
||||
function onEventClick (info) {
|
||||
const ev = info?.event
|
||||
if (!ev) return
|
||||
|
||||
dialogEventRow.value = {
|
||||
id: ev.id,
|
||||
owner_id: ev.extendedProps?.owner_id,
|
||||
terapeuta_id: ev.extendedProps?.terapeuta_id ?? null,
|
||||
paciente_id: ev.extendedProps?.paciente_id ?? null,
|
||||
tipo: ev.extendedProps?.tipo,
|
||||
status: ev.extendedProps?.status,
|
||||
titulo: ev.title,
|
||||
observacoes: ev.extendedProps?.observacoes ?? null,
|
||||
inicio_em: ev.start?.toISOString?.() || ev.startStr,
|
||||
fim_em: ev.end?.toISOString?.() || ev.endStr
|
||||
}
|
||||
|
||||
dialogStartISO.value = ''
|
||||
dialogEndISO.value = ''
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function persistMoveOrResize (info, actionLabel) {
|
||||
try {
|
||||
const ev = info?.event
|
||||
if (!ev) return
|
||||
|
||||
const id = ev.id
|
||||
const startISO = ev.start ? ev.start.toISOString() : null
|
||||
const endISO = ev.end ? ev.end.toISOString() : null
|
||||
|
||||
if (!startISO || !endISO) throw new Error('Evento sem start/end.')
|
||||
|
||||
await update(id, { inicio_em: startISO, fim_em: endISO })
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: actionLabel,
|
||||
detail: 'Alteração salva.',
|
||||
life: 1800
|
||||
})
|
||||
} catch (e) {
|
||||
// desfaz no calendário
|
||||
info?.revert?.()
|
||||
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Erro',
|
||||
detail: eventsError.value || e?.message || 'Falha ao salvar alteração.',
|
||||
life: 4500
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onEventDrop (info) {
|
||||
persistMoveOrResize(info, 'Movido')
|
||||
}
|
||||
|
||||
function onEventResize (info) {
|
||||
persistMoveOrResize(info, 'Redimensionado')
|
||||
}
|
||||
|
||||
// -------------------- dialog actions (mínimo funcional) --------------------
|
||||
function onCreateClinicEvent () {
|
||||
// cria evento base (depois você troca para "selecionar no calendário", mas aqui é bom pra começar)
|
||||
const start = new Date()
|
||||
const end = new Date(Date.now() + 50 * 60000)
|
||||
|
||||
dialogEventRow.value = null
|
||||
dialogStartISO.value = start.toISOString()
|
||||
dialogEndISO.value = end.toISOString()
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function onDialogSave ({ id, payload }) {
|
||||
try {
|
||||
if (id) await update(id, payload)
|
||||
else await create(payload)
|
||||
|
||||
dialogOpen.value = false
|
||||
|
||||
// recarrega range atual se existir
|
||||
if (currentRange.value.start && currentRange.value.end) {
|
||||
await loadClinicRange(
|
||||
ownerIds.value,
|
||||
new Date(currentRange.value.start).toISOString(),
|
||||
new Date(currentRange.value.end).toISOString()
|
||||
)
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Evento salvo.', life: 2500 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || 'Falha ao salvar.', life: 4500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function onDialogDelete (id) {
|
||||
try {
|
||||
await remove(id)
|
||||
dialogOpen.value = false
|
||||
|
||||
if (currentRange.value.start && currentRange.value.end) {
|
||||
await loadClinicRange(
|
||||
ownerIds.value,
|
||||
new Date(currentRange.value.start).toISOString(),
|
||||
new Date(currentRange.value.end).toISOString()
|
||||
)
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Evento removido.', life: 2500 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || 'Falha ao excluir.', life: 4500 })
|
||||
}
|
||||
}
|
||||
|
||||
function onSlotSelect ({ ownerId, start, end }) {
|
||||
dialogEventRow.value = null
|
||||
dialogStartISO.value = new Date(start).toISOString()
|
||||
dialogEndISO.value = new Date(end).toISOString()
|
||||
// aqui você pode setar o owner default do dialog via ownerId
|
||||
// o Dialog já tem dropdown, mas você pode passar ownerId no payload quando salvar
|
||||
dialogOpen.value = true
|
||||
|
||||
// opcional: guardar pra preselecionar no dialog (se você implementar isso)
|
||||
// dialogOwnerId.value = ownerId
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 md:p-6">
|
||||
<Toast />
|
||||
|
||||
<AgendaToolbar
|
||||
title="Agenda da clínica"
|
||||
:view="view"
|
||||
:mode="mode"
|
||||
@today="onToday"
|
||||
@prev="onPrev"
|
||||
@next="onNext"
|
||||
@changeView="onChangeView"
|
||||
@toggleMode="onToggleMode"
|
||||
@createSession="onCreateClinicEvent"
|
||||
@createBlock="() => toast.add({ severity: 'info', summary: 'Bloqueio', detail: 'Próximo passo: bloqueio da clínica.', life: 2500 })"
|
||||
/>
|
||||
|
||||
<AgendaClinicMosaic
|
||||
ref="calendarRef"
|
||||
:view="view"
|
||||
:mode="mode"
|
||||
:staff="staffCols"
|
||||
:events="allEvents"
|
||||
:loading="loadingStaff || loadingEvents"
|
||||
@rangeChange="onRangeChange"
|
||||
@slotSelect="onSlotSelect"
|
||||
@eventClick="onEventClick"
|
||||
@eventDrop="onEventDrop"
|
||||
@eventResize="onEventResize"
|
||||
/>
|
||||
|
||||
<AgendaEventDialog
|
||||
v-model="dialogOpen"
|
||||
:eventRow="dialogEventRow"
|
||||
:initialStartISO="dialogStartISO"
|
||||
:initialEndISO="dialogEndISO"
|
||||
:ownerId="staffCols?.[0]?.id || ''"
|
||||
:allowOwnerEdit="true"
|
||||
:ownerOptions="ownerOptions"
|
||||
@save="onDialogSave"
|
||||
@delete="onDialogDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
449
src/features/agenda/pages/AgendaTerapeutaPage.vue
Normal file
449
src/features/agenda/pages/AgendaTerapeutaPage.vue
Normal file
@@ -0,0 +1,449 @@
|
||||
<!-- src/features/agenda/pages/AgendaTerapeutaPage.vue -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import AgendaEventDialog from '../components/AgendaEventDialog.vue'
|
||||
|
||||
import AgendaToolbar from '../components/AgendaToolbar.vue'
|
||||
import AgendaCalendar from '../components/AgendaCalendar.vue'
|
||||
import AgendaRightPanel from '../components/AgendaRightPanel.vue'
|
||||
import AgendaNextSessionsCardList from '../components/cards/AgendaNextSessionsCardList.vue'
|
||||
import AgendaPulseCardGrid from '../components/cards/AgendaPulseCardGrid.vue'
|
||||
|
||||
import { useAgendaSettings } from '../composables/useAgendaSettings'
|
||||
import { useAgendaEvents } from '../composables/useAgendaEvents'
|
||||
import {
|
||||
mapAgendaEventosToCalendarEvents,
|
||||
buildNextSessions,
|
||||
buildWeeklyBreakBackgroundEvents,
|
||||
calcDefaultSlotDuration,
|
||||
minutesToDuration
|
||||
} from '../services/agendaMappers'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// -----------------------------
|
||||
// State
|
||||
// -----------------------------
|
||||
const view = ref('day') // 'day' | 'week'
|
||||
const mode = ref('work_hours') // 'full_24h' | 'work_hours'
|
||||
const searchQuery = ref('')
|
||||
|
||||
const calendarRef = ref(null)
|
||||
|
||||
const { loading: loadingSettings, error: settingsError, settings, load: loadSettings } = useAgendaSettings()
|
||||
const { loading: loadingEvents, error: eventsError, rows, loadMyRange, create, update, remove } = useAgendaEvents()
|
||||
|
||||
const dialogOpen = ref(false)
|
||||
const dialogEventRow = ref(null)
|
||||
const dialogStartISO = ref('')
|
||||
const dialogEndISO = ref('')
|
||||
const currentRange = ref({ start: null, end: null })
|
||||
|
||||
// Range atual (FullCalendar)
|
||||
const currentRange = ref({ start: new Date(), end: new Date() })
|
||||
|
||||
// -----------------------------
|
||||
// Derived: settings -> calendar behavior
|
||||
// -----------------------------
|
||||
const timezone = computed(() => settings.value?.timezone || 'America/Sao_Paulo')
|
||||
|
||||
const slotDuration = computed(() => {
|
||||
if (!settings.value) return '00:30:00'
|
||||
return calcDefaultSlotDuration(settings.value)
|
||||
})
|
||||
|
||||
// work hours recorte (visual)
|
||||
const slotMinTime = computed(() => {
|
||||
if (!settings.value) return '06:00:00'
|
||||
// Se estiver no modo "work_hours", você quer mostrar um pouco antes
|
||||
// Aqui respeitamos admin_inicio_visualizacao se usar_horario_admin_custom estiver true,
|
||||
// senão tentamos agenda_custom_start, senão default.
|
||||
const s = settings.value
|
||||
const base =
|
||||
(s.usar_horario_admin_custom && s.admin_inicio_visualizacao) ||
|
||||
s.agenda_custom_start ||
|
||||
'06:00:00'
|
||||
|
||||
// padding -1h
|
||||
return padTime(base, -60)
|
||||
})
|
||||
|
||||
const slotMaxTime = computed(() => {
|
||||
if (!settings.value) return '22:00:00'
|
||||
const s = settings.value
|
||||
const base =
|
||||
(s.usar_horario_admin_custom && s.admin_fim_visualizacao) ||
|
||||
s.agenda_custom_end ||
|
||||
'22:00:00'
|
||||
|
||||
// padding +1h
|
||||
return padTime(base, +60)
|
||||
})
|
||||
|
||||
// business hours “verdadeiro” (sem padding)
|
||||
const businessHours = computed(() => {
|
||||
if (!settings.value) return []
|
||||
const s = settings.value
|
||||
|
||||
const start =
|
||||
(s.usar_horario_admin_custom && s.admin_inicio_visualizacao) ||
|
||||
s.agenda_custom_start ||
|
||||
'08:00:00'
|
||||
|
||||
const end =
|
||||
(s.usar_horario_admin_custom && s.admin_fim_visualizacao) ||
|
||||
s.agenda_custom_end ||
|
||||
'18:00:00'
|
||||
|
||||
// Semana inteira (você pode trocar isso pra algo vindo de agenda_regras_semanais depois)
|
||||
return [
|
||||
{ daysOfWeek: [1,2,3,4,5], startTime: start, endTime: end }
|
||||
]
|
||||
})
|
||||
|
||||
// Eventos do banco -> FullCalendar
|
||||
const calendarEvents = computed(() => {
|
||||
const base = mapAgendaEventosToCalendarEvents(rows.value || [])
|
||||
|
||||
// Pausas semanais (jsonb) -> background events
|
||||
const breaks = settings.value
|
||||
? buildWeeklyBreakBackgroundEvents(
|
||||
settings.value.pausas_semanais,
|
||||
currentRange.value.start,
|
||||
currentRange.value.end
|
||||
)
|
||||
: []
|
||||
|
||||
return [...base, ...breaks]
|
||||
})
|
||||
|
||||
// Cards de próximas sessões
|
||||
const nextSessions = computed(() => buildNextSessions(rows.value || []))
|
||||
|
||||
// Pulse stats (bem inicial, mas já útil)
|
||||
const pulseStats = computed(() => {
|
||||
const list = rows.value || []
|
||||
const totalSessions = list.filter(r => (r.tipo || '').toLowerCase().includes('sess')).length
|
||||
const totalMinutes = list.reduce((acc, r) => {
|
||||
const ms = new Date(r.fim_em).getTime() - new Date(r.inicio_em).getTime()
|
||||
return acc + Math.max(0, Math.round(ms / 60000))
|
||||
}, 0)
|
||||
|
||||
const pending = list.filter(r => (r.status || '').toLowerCase().includes('pend')).length
|
||||
const reschedules = list.filter(r => (r.status || '').toLowerCase().includes('remarc')).length
|
||||
const attentions = pending + reschedules
|
||||
|
||||
// Sugerir encaixes (placeholder): depois vamos calcular via gaps no range.
|
||||
const suggested1 = '—'
|
||||
const suggested2 = '—'
|
||||
|
||||
const nextBreak = '—' // depois calculamos pela pausa semanal + "agora"
|
||||
|
||||
return {
|
||||
totalSessions,
|
||||
totalMinutes,
|
||||
biggestFreeWindow: '—',
|
||||
pending,
|
||||
reschedules,
|
||||
attentions,
|
||||
suggested1,
|
||||
suggested2,
|
||||
nextBreak
|
||||
}
|
||||
})
|
||||
|
||||
// -----------------------------
|
||||
// Lifecycle
|
||||
// -----------------------------
|
||||
onMounted(async () => {
|
||||
await loadSettings()
|
||||
if (settingsError.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Agenda', detail: settingsError.value, life: 4500 })
|
||||
}
|
||||
// aplica modo inicial vindo da config
|
||||
if (settings.value?.agenda_view_mode) {
|
||||
mode.value = settings.value.agenda_view_mode === 'full_24h' ? 'full_24h' : 'work_hours'
|
||||
}
|
||||
})
|
||||
|
||||
// -----------------------------
|
||||
// Actions: toolbar
|
||||
// -----------------------------
|
||||
function onToday() { calendarRef.value?.goToday?.() }
|
||||
function onPrev() { calendarRef.value?.prev?.() }
|
||||
function onNext() { calendarRef.value?.next?.() }
|
||||
|
||||
function onChangeView(v) {
|
||||
view.value = v
|
||||
calendarRef.value?.setView?.(v)
|
||||
}
|
||||
|
||||
function onToggleMode(m) {
|
||||
mode.value = m
|
||||
}
|
||||
|
||||
function onSearch(q) {
|
||||
searchQuery.value = q || ''
|
||||
// Por enquanto a busca não filtra o FullCalendar (isso exige requery ou filtro local).
|
||||
// Vamos plugar isso quando tiver patient join e título mais rico.
|
||||
}
|
||||
|
||||
function onCreateSession() {
|
||||
toast.add({ severity: 'info', summary: 'Nova sessão', detail: 'Abrir modal de criação (próximo passo).', life: 2500 })
|
||||
}
|
||||
function onCreateBlock() {
|
||||
toast.add({ severity: 'info', summary: 'Bloquear horário', detail: 'Abrir modal de bloqueio (próximo passo).', life: 2500 })
|
||||
}
|
||||
|
||||
const staffCols = computed(() => (staff.value || [])
|
||||
.filter(s => typeof s.user_id === 'string' && s.user_id && s.user_id !== 'null' && s.user_id !== 'undefined')
|
||||
.map(s => ({
|
||||
id: s.user_id,
|
||||
title: s.full_name || s.nome || s.name || s.email || 'Profissional'
|
||||
}))
|
||||
)
|
||||
|
||||
const ownerIds = computed(() => staffCols.value.map(s => s.id))
|
||||
|
||||
const allEvents = computed(() => mapAgendaEventosToCalendarEvents(rows.value || []))
|
||||
|
||||
// -----------------------------
|
||||
// FullCalendar callbacks
|
||||
// -----------------------------
|
||||
async function onRangeChange ({ start, end }) {
|
||||
currentRange.value = { start, end }
|
||||
|
||||
const ids = ownerIds.value
|
||||
if (!ids.length) return
|
||||
|
||||
await loadClinicRange(ids, new Date(start).toISOString(), new Date(end).toISOString())
|
||||
|
||||
if (eventsError.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Eventos', detail: eventsError.value, life: 4500 })
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectTime (selection) {
|
||||
const durMin = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50
|
||||
const startISO = new Date(selection.start).toISOString()
|
||||
const endISO = new Date(new Date(selection.start).getTime() + durMin * 60000).toISOString()
|
||||
|
||||
dialogEventRow.value = null
|
||||
dialogStartISO.value = startISO
|
||||
dialogEndISO.value = endISO
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function onEventClick (info) {
|
||||
const ev = info?.event
|
||||
if (!ev) return
|
||||
|
||||
dialogEventRow.value = {
|
||||
id: ev.id,
|
||||
owner_id: ev.extendedProps?.owner_id,
|
||||
terapeuta_id: ev.extendedProps?.terapeuta_id ?? null,
|
||||
paciente_id: ev.extendedProps?.paciente_id ?? null,
|
||||
tipo: ev.extendedProps?.tipo,
|
||||
status: ev.extendedProps?.status,
|
||||
titulo: ev.title,
|
||||
observacoes: ev.extendedProps?.observacoes ?? null,
|
||||
inicio_em: ev.start?.toISOString?.() || ev.startStr,
|
||||
fim_em: ev.end?.toISOString?.() || ev.endStr
|
||||
}
|
||||
|
||||
dialogStartISO.value = ''
|
||||
dialogEndISO.value = ''
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function persistMoveOrResize (info, actionLabel) {
|
||||
try {
|
||||
const ev = info?.event
|
||||
if (!ev) return
|
||||
|
||||
const id = ev.id
|
||||
const startISO = ev.start ? ev.start.toISOString() : null
|
||||
const endISO = ev.end ? ev.end.toISOString() : null
|
||||
|
||||
if (!startISO || !endISO) throw new Error('Evento sem start/end.')
|
||||
|
||||
await update(id, { inicio_em: startISO, fim_em: endISO })
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: actionLabel,
|
||||
detail: 'Alteração salva.',
|
||||
life: 1800
|
||||
})
|
||||
} catch (e) {
|
||||
// desfaz no calendário
|
||||
info?.revert?.()
|
||||
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Erro',
|
||||
detail: eventsError.value || e?.message || 'Falha ao salvar alteração.',
|
||||
life: 4500
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onEventDrop (info) {
|
||||
persistMoveOrResize(info, 'Movido')
|
||||
}
|
||||
|
||||
function onEventResize (info) {
|
||||
persistMoveOrResize(info, 'Redimensionado')
|
||||
}
|
||||
|
||||
function onOpenFromCard (it) {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Evento',
|
||||
detail: it?.title || 'Evento',
|
||||
life: 2500
|
||||
})
|
||||
}
|
||||
|
||||
function onConfirmFromCard () {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Confirmar',
|
||||
detail: 'Ação de confirmar (próximo passo: update no banco).',
|
||||
life: 2500
|
||||
})
|
||||
}
|
||||
|
||||
function onRescheduleFromCard () {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Remarcar',
|
||||
detail: 'Ação de remarcar (próximo passo: fluxo de reagendamento).',
|
||||
life: 2500
|
||||
})
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Utils
|
||||
// -----------------------------
|
||||
function padTime(hhmmss, deltaMin) {
|
||||
// hh:mm:ss
|
||||
const [hh, mm, ss] = String(hhmmss || '00:00:00').split(':').map(Number)
|
||||
let total = (hh * 60 + mm) + deltaMin
|
||||
if (total < 0) total = 0
|
||||
if (total > 24 * 60) total = 24 * 60
|
||||
return minutesToDuration(total)
|
||||
}
|
||||
|
||||
async function onDialogSave ({ id, payload }) {
|
||||
try {
|
||||
if (id) {
|
||||
await update(id, payload)
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Evento atualizado.', life: 2500 })
|
||||
} else {
|
||||
await create(payload)
|
||||
toast.add({ severity: 'success', summary: 'Criado', detail: 'Evento criado.', life: 2500 })
|
||||
}
|
||||
|
||||
dialogOpen.value = false
|
||||
|
||||
// recarrega o range atual
|
||||
await loadMyRange(
|
||||
new Date(currentRange.value.start).toISOString(),
|
||||
new Date(currentRange.value.end).toISOString()
|
||||
)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || 'Falha ao salvar.', life: 4500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function onDialogDelete (id) {
|
||||
try {
|
||||
await remove(id)
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Evento removido.', life: 2500 })
|
||||
|
||||
dialogOpen.value = false
|
||||
await loadMyRange(
|
||||
new Date(currentRange.value.start).toISOString(),
|
||||
new Date(currentRange.value.end).toISOString()
|
||||
)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || 'Falha ao excluir.', life: 4500 })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 md:p-6">
|
||||
<Toast />
|
||||
|
||||
<AgendaToolbar
|
||||
title="Minha agenda"
|
||||
:view="view"
|
||||
:mode="mode"
|
||||
@today="onToday"
|
||||
@prev="onPrev"
|
||||
@next="onNext"
|
||||
@changeView="onChangeView"
|
||||
@toggleMode="onToggleMode"
|
||||
@createSession="onCreateSession"
|
||||
@createBlock="onCreateBlock"
|
||||
@search="onSearch"
|
||||
/>
|
||||
|
||||
<div class="grid gap-3 md:gap-4" style="grid-template-columns: 1fr; align-items: stretch;">
|
||||
<div class="grid gap-3 md:gap-4 md:grid-cols-[1fr_380px]">
|
||||
<!-- LEFT: Calendar -->
|
||||
<AgendaCalendar
|
||||
ref="calendarRef"
|
||||
:view="view"
|
||||
:mode="mode"
|
||||
:timezone="timezone"
|
||||
:slotDuration="slotDuration"
|
||||
:slotMinTime="slotMinTime"
|
||||
:slotMaxTime="slotMaxTime"
|
||||
:businessHours="businessHours"
|
||||
:events="calendarEvents"
|
||||
:loading="loadingSettings || loadingEvents"
|
||||
@rangeChange="onRangeChange"
|
||||
@selectTime="onSelectTime"
|
||||
@eventClick="onEventClick"
|
||||
@eventDrop="onEventDrop"
|
||||
@eventResize="onEventResize"
|
||||
/>
|
||||
|
||||
<!-- RIGHT: Panel -->
|
||||
<AgendaRightPanel>
|
||||
<template #top>
|
||||
<AgendaNextSessionsCardList
|
||||
:items="nextSessions"
|
||||
@open="onOpenFromCard"
|
||||
@confirm="onConfirmFromCard"
|
||||
@reschedule="onRescheduleFromCard"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<AgendaPulseCardGrid
|
||||
:stats="pulseStats"
|
||||
@quickBlock="onCreateBlock"
|
||||
@quickCreate="onCreateSession"
|
||||
/>
|
||||
</template>
|
||||
</AgendaRightPanel>
|
||||
</div>
|
||||
</div>
|
||||
<AgendaEventDialog
|
||||
v-model="dialogOpen"
|
||||
:eventRow="dialogEventRow"
|
||||
:initialStartISO="dialogStartISO"
|
||||
:initialEndISO="dialogEndISO"
|
||||
:ownerId="(settings?.owner_id || '')"
|
||||
@save="onDialogSave"
|
||||
@delete="onDialogDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
0
src/features/agenda/services/agenda.service.js
Normal file
0
src/features/agenda/services/agenda.service.js
Normal file
146
src/features/agenda/services/agendaMappers.js
Normal file
146
src/features/agenda/services/agendaMappers.js
Normal file
@@ -0,0 +1,146 @@
|
||||
// src/features/agenda/services/agendaMappers.js
|
||||
|
||||
export function mapAgendaEventosToCalendarEvents (rows) {
|
||||
return (rows || []).map((r) => ({
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
extendedProps: {
|
||||
tipo: r.tipo,
|
||||
status: r.status,
|
||||
paciente_id: r.paciente_id,
|
||||
terapeuta_id: r.terapeuta_id,
|
||||
observacoes: r.observacoes,
|
||||
owner_id: r.owner_id
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildNextSessions (rows, now = new Date()) {
|
||||
const nowMs = now.getTime()
|
||||
return (rows || [])
|
||||
.filter((r) => new Date(r.fim_em).getTime() >= nowMs)
|
||||
.slice(0, 6)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
startISO: r.inicio_em,
|
||||
endISO: r.fim_em,
|
||||
tipo: r.tipo,
|
||||
status: r.status,
|
||||
pacienteId: r.paciente_id || null
|
||||
}))
|
||||
}
|
||||
|
||||
export function calcDefaultSlotDuration (settings) {
|
||||
const min =
|
||||
((settings?.usar_granularidade_custom && settings?.granularidade_min) || 0) ||
|
||||
settings?.admin_slot_visual_minutos ||
|
||||
30
|
||||
|
||||
return minutesToDuration(min)
|
||||
}
|
||||
|
||||
export function minutesToDuration (min) {
|
||||
const h = Math.floor(min / 60)
|
||||
const m = min % 60
|
||||
const hh = String(h).padStart(2, '0')
|
||||
const mm = String(m).padStart(2, '0')
|
||||
return `${hh}:${mm}:00`
|
||||
}
|
||||
|
||||
export function tituloFallback (tipo) {
|
||||
const t = String(tipo || '').toLowerCase()
|
||||
if (t.includes('sess')) return 'Sessão'
|
||||
if (t.includes('block') || t.includes('bloq')) return 'Bloqueio'
|
||||
if (t.includes('pessoal')) return 'Pessoal'
|
||||
if (t.includes('clin')) return 'Clínica'
|
||||
return 'Compromisso'
|
||||
}
|
||||
|
||||
/**
|
||||
* Pausas semanais (jsonb) -> background events do FullCalendar.
|
||||
* Leitura flexível:
|
||||
* - esperado: [{ weekday: 1..7 ou 0..6, start:"HH:MM", end:"HH:MM", label }]
|
||||
*/
|
||||
export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd) {
|
||||
if (!Array.isArray(pausas) || pausas.length === 0) return []
|
||||
|
||||
const out = []
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
|
||||
for (let ts = startOfDay(rangeStart).getTime(); ts < rangeEnd.getTime(); ts += dayMs) {
|
||||
const d = new Date(ts)
|
||||
const dow = d.getDay() // 0..6
|
||||
|
||||
for (const p of pausas) {
|
||||
const wd = normalizeWeekday(p?.weekday)
|
||||
if (wd === null) continue
|
||||
if (wd !== dow) continue
|
||||
|
||||
const start = asTime(p?.start ?? p?.inicio ?? p?.from)
|
||||
const end = asTime(p?.end ?? p?.fim ?? p?.to)
|
||||
if (!start || !end) continue
|
||||
|
||||
out.push({
|
||||
id: `break-${ts}-${start}-${end}`,
|
||||
start: combineDateTimeISO(d, start),
|
||||
end: combineDateTimeISO(d, end),
|
||||
display: 'background',
|
||||
overlap: false,
|
||||
extendedProps: { kind: 'break', label: p?.label ?? 'Pausa' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function mapAgendaEventosToClinicResourceEvents (rows) {
|
||||
return (rows || []).map((r) => ({
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
resourceId: r.owner_id, // 🔥 coluna = dono da agenda (profissional)
|
||||
extendedProps: {
|
||||
tipo: r.tipo,
|
||||
status: r.status,
|
||||
paciente_id: r.paciente_id,
|
||||
terapeuta_id: r.terapeuta_id,
|
||||
observacoes: r.observacoes,
|
||||
owner_id: r.owner_id
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeWeekday (value) {
|
||||
if (value === null || value === undefined) return null
|
||||
const n = Number(value)
|
||||
if (Number.isNaN(n)) return null
|
||||
if (n >= 0 && n <= 6) return n
|
||||
if (n >= 1 && n <= 7) return n === 7 ? 0 : n
|
||||
return null
|
||||
}
|
||||
|
||||
function asTime (v) {
|
||||
if (!v || typeof v !== 'string') return null
|
||||
const s = v.trim()
|
||||
if (/^\d{2}:\d{2}$/.test(s)) return `${s}:00`
|
||||
if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s
|
||||
return null
|
||||
}
|
||||
|
||||
function startOfDay (d) {
|
||||
const x = new Date(d)
|
||||
x.setHours(0, 0, 0, 0)
|
||||
return x
|
||||
}
|
||||
|
||||
function combineDateTimeISO (date, timeHHMMSS) {
|
||||
const yyyy = date.getFullYear()
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}T${timeHHMMSS}`
|
||||
}
|
||||
98
src/features/agenda/services/agendaRepository.js
Normal file
98
src/features/agenda/services/agendaRepository.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// src/features/agenda/services/agendaRepository.js
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
export async function getMyAgendaSettings () {
|
||||
const { data: userRes, error: userErr } = await supabase.auth.getUser()
|
||||
if (userErr) throw userErr
|
||||
|
||||
const uid = userRes?.user?.id
|
||||
if (!uid) throw new Error('Usuário não autenticado.')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_configuracoes')
|
||||
.select('*')
|
||||
.eq('owner_id', uid)
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listMyAgendaEvents ({ startISO, endISO }) {
|
||||
const { data: userRes, error: userErr } = await supabase.auth.getUser()
|
||||
if (userErr) throw userErr
|
||||
|
||||
const uid = userRes?.user?.id
|
||||
if (!uid) throw new Error('Usuário não autenticado.')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.eq('owner_id', uid)
|
||||
.gte('inicio_em', startISO)
|
||||
.lt('inicio_em', endISO)
|
||||
.order('inicio_em', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
export async function listClinicEvents ({ ownerIds, startISO, endISO }) {
|
||||
if (!ownerIds?.length) return []
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.in('owner_id', ownerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
.lt('inicio_em', endISO)
|
||||
.order('inicio_em', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
export async function listTenantStaff (tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') return []
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('v_tenant_staff')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
export async function createAgendaEvento (payload) {
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.insert(payload)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateAgendaEvento (id, patch) {
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.update(patch)
|
||||
.eq('id', id)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAgendaEvento (id) {
|
||||
const { error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
return true
|
||||
}
|
||||
@@ -610,7 +610,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
@@ -618,10 +618,27 @@ import MultiSelect from 'primevue/multiselect'
|
||||
import Popover from 'primevue/popover'
|
||||
|
||||
|
||||
import PatientProntuario from '@/views/pages/admin/pacientes/prontuario/PatientProntuario.vue'
|
||||
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// ======================================================
|
||||
// 🧭 Area resolver (admin x therapist)
|
||||
// ======================================================
|
||||
// Este componente é uma FEATURE compartilhada (admin + therapist).
|
||||
// Então NÃO pode navegar hardcoded em /admin/...,
|
||||
// senão quebra a experiência do therapist.
|
||||
//
|
||||
// Regra:
|
||||
// - se estiver em /therapist/* → base = /therapist
|
||||
// - caso contrário → base = /admin
|
||||
function getAreaBase() {
|
||||
const seg = String(route.path || '').split('/')[1]
|
||||
return seg === 'therapist' ? '/therapist' : '/admin'
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
@@ -687,7 +704,6 @@ function setAllColumns() {
|
||||
selectedColumns.value = columnCatalogAll.map(c => c.key)
|
||||
}
|
||||
|
||||
|
||||
const sort = reactive({
|
||||
field: 'last_attended_at',
|
||||
order: -1
|
||||
@@ -796,16 +812,20 @@ function onQuickCreated(row) {
|
||||
updateKpis()
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Navigation (shared feature)
|
||||
// -----------------------------
|
||||
function goGroups() {
|
||||
router.push('/admin/pacientes/grupos')
|
||||
router.push(`${getAreaBase()}/patients/grupos`)
|
||||
}
|
||||
|
||||
function goCreateFull() {
|
||||
router.push('/admin/pacientes/cadastro')
|
||||
router.push(`${getAreaBase()}/patients/cadastro`)
|
||||
}
|
||||
|
||||
function goEdit(row) {
|
||||
router.push(`/admin/pacientes/cadastro/${row.id}`)
|
||||
if (!row?.id) return
|
||||
router.push(`${getAreaBase()}/patients/cadastro/${row.id}`)
|
||||
}
|
||||
|
||||
function setStatus(v) {
|
||||
@@ -906,7 +926,7 @@ async function loadUser() {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
uid.value = data?.user?.id || null
|
||||
console.log('[PatientsIndexPage] uid:', uid.value)
|
||||
console.log('[PatientsListPage] uid:', uid.value)
|
||||
}
|
||||
|
||||
function withOwnerFilter(q) {
|
||||
@@ -985,10 +1005,10 @@ async function fetchAll() {
|
||||
patients.value = base
|
||||
|
||||
groups.value = await listGroups()
|
||||
console.log('[PatientsIndexPage] groups loaded:', groups.value)
|
||||
console.log('[PatientsListPage] groups loaded:', groups.value)
|
||||
|
||||
tags.value = await listTags()
|
||||
console.log('[PatientsIndexPage] tags loaded:', tags.value)
|
||||
console.log('[PatientsListPage] tags loaded:', tags.value)
|
||||
|
||||
await hydrateAssociationsSupabase()
|
||||
updateKpis()
|
||||
@@ -535,34 +535,89 @@ function sanitizePayload (raw, ownerId) {
|
||||
// Supabase: lists / get / relations
|
||||
// ------------------------------------------------------
|
||||
async function listGroups () {
|
||||
const probe = await supabase
|
||||
.from('patient_groups')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
|
||||
if (probe.error) throw probe.error
|
||||
|
||||
const row = probe.data?.[0] || {}
|
||||
const hasPT = ('nome' in row) || ('cor' in row)
|
||||
const hasEN = ('name' in row) || ('color' in row)
|
||||
|
||||
if (hasPT) {
|
||||
const { data, error } = await supabase
|
||||
.from('patient_groups')
|
||||
.select('id,nome,descricao,cor,is_system,is_active')
|
||||
.eq('is_active', true)
|
||||
.order('nome', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return (data || []).map(g => ({
|
||||
...g,
|
||||
name: g.nome,
|
||||
color: g.cor
|
||||
}))
|
||||
}
|
||||
|
||||
if (hasEN) {
|
||||
const { data, error } = await supabase
|
||||
.from('patient_groups')
|
||||
.select('id,name,description,color,is_system,is_active')
|
||||
.eq('is_active', true)
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return (data || []).map(g => ({
|
||||
...g,
|
||||
nome: g.name,
|
||||
cor: g.color
|
||||
}))
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('patient_groups')
|
||||
.select('id,nome,descricao,cor,is_system,is_active')
|
||||
.eq('is_active', true)
|
||||
.order('nome', { ascending: true })
|
||||
if (error) throw error
|
||||
.select('*')
|
||||
.order('id', { ascending: true })
|
||||
|
||||
return (data || []).map(g => ({
|
||||
...g,
|
||||
name: g.nome,
|
||||
color: g.cor
|
||||
}))
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
|
||||
async function listTags () {
|
||||
// tenta schema EN
|
||||
{
|
||||
// 1) Pega 1 registro sem order, só pra descobrir o schema real (sem 400)
|
||||
const probe = await supabase
|
||||
.from('patient_tags')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
|
||||
if (probe.error) throw probe.error
|
||||
|
||||
const row = probe.data?.[0] || {}
|
||||
const hasEN = ('name' in row) || ('color' in row)
|
||||
const hasPT = ('nome' in row) || ('cor' in row)
|
||||
|
||||
// 2) Se não tem nada, a tabela pode estar vazia.
|
||||
// Ainda assim, precisamos decidir por qual coluna ordenar.
|
||||
// Vamos descobrir colunas existentes via select de 0 rows (head) NÃO é suportado bem no client,
|
||||
// então usamos uma estratégia safe:
|
||||
// - tenta EN com order se faz sentido
|
||||
// - senão PT
|
||||
// - e por último sem order.
|
||||
|
||||
if (hasEN) {
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.select('id,name,color')
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (!error) return data || []
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
// fallback schema PT-BR
|
||||
{
|
||||
if (hasPT) {
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.select('id,nome,cor')
|
||||
@@ -576,6 +631,20 @@ async function listTags () {
|
||||
color: t.cor
|
||||
}))
|
||||
}
|
||||
|
||||
// 3) fallback final: tabela vazia ou schema incomum
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.select('*')
|
||||
.order('id', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
return (data || []).map(t => ({
|
||||
...t,
|
||||
name: t.name ?? t.nome ?? '',
|
||||
color: t.color ?? t.cor ?? null
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -707,8 +776,17 @@ const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Route base (admin x therapist)
|
||||
// ------------------------------------------------------
|
||||
function getAreaBase () {
|
||||
const seg = String(route.path || '').split('/')[1]
|
||||
return seg === 'therapist' ? '/therapist' : '/admin'
|
||||
}
|
||||
|
||||
function goBack () {
|
||||
router.push({ name: 'admin-pacientes' })
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.push(`${getAreaBase()}/patients`)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
@@ -781,7 +859,7 @@ async function onSubmit () {
|
||||
const created = await createPatient(payload)
|
||||
id = created?.id
|
||||
if (!id) throw new Error('Falha ao obter ID do paciente criado.')
|
||||
router.replace({ name: 'admin-pacientes-cadastro-edit', params: { id } })
|
||||
router.replace(`${getAreaBase()}/patients/cadastro/${id}`)
|
||||
}
|
||||
|
||||
await replacePatientGroups(id, grupoIdSelecionado.value || null)
|
||||
@@ -984,6 +1062,158 @@ const maritalStatusOptions = [
|
||||
{ label: 'Viúvo(a)', value: 'Viúvo(a)' },
|
||||
{ label: 'Prefere não informar', value: 'Prefere não informar' }
|
||||
]
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Dialogs: criar Grupo / Tag
|
||||
// ------------------------------------------------------
|
||||
const createGroupDialog = ref(false)
|
||||
const createGroupSaving = ref(false)
|
||||
const createGroupError = ref('')
|
||||
const newGroup = ref({ name: '', color: '#6366F1' }) // indigo default
|
||||
|
||||
const createTagDialog = ref(false)
|
||||
const createTagSaving = ref(false)
|
||||
const createTagError = ref('')
|
||||
const newTag = ref({ name: '', color: '#22C55E' }) // green default
|
||||
|
||||
function openGroupDlg(mode = 'create') {
|
||||
// por enquanto só create
|
||||
createGroupError.value = ''
|
||||
newGroup.value = { name: '', color: '#6366F1' }
|
||||
createGroupDialog.value = true
|
||||
}
|
||||
|
||||
function openTagDlg(mode = 'create') {
|
||||
// por enquanto só create
|
||||
createTagError.value = ''
|
||||
newTag.value = { name: '', color: '#22C55E' }
|
||||
createTagDialog.value = true
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Persist: Grupo
|
||||
// ------------------------------------------------------
|
||||
async function createGroupPersist() {
|
||||
if (createGroupSaving.value) return
|
||||
createGroupError.value = ''
|
||||
|
||||
const name = String(newGroup.value?.name || '').trim()
|
||||
const color = String(newGroup.value?.color || '').trim() || '#6366F1'
|
||||
|
||||
if (!name) {
|
||||
createGroupError.value = 'Informe um nome para o grupo.'
|
||||
return
|
||||
}
|
||||
|
||||
createGroupSaving.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
|
||||
// Tenta schema PT-BR primeiro (pelo teu listGroups)
|
||||
let createdId = null
|
||||
{
|
||||
const { data, error } = await supabase
|
||||
.from('patient_groups')
|
||||
.insert({
|
||||
owner_id: ownerId,
|
||||
nome: name,
|
||||
descricao: null,
|
||||
cor: color,
|
||||
is_system: false,
|
||||
is_active: true
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (!error) createdId = data?.id || null
|
||||
else {
|
||||
// fallback (caso seu schema seja EN)
|
||||
const { data: d2, error: e2 } = await supabase
|
||||
.from('patient_groups')
|
||||
.insert({
|
||||
owner_id: ownerId,
|
||||
name,
|
||||
description: null,
|
||||
color,
|
||||
is_system: false,
|
||||
is_active: true
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
if (e2) throw e2
|
||||
createdId = d2?.id || null
|
||||
}
|
||||
}
|
||||
|
||||
// Recarrega lista e seleciona o novo
|
||||
groups.value = await listGroups()
|
||||
if (createdId) grupoIdSelecionado.value = createdId
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Grupo', detail: 'Grupo criado.', life: 2500 })
|
||||
createGroupDialog.value = false
|
||||
} catch (e) {
|
||||
createGroupError.value = e?.message || 'Falha ao criar grupo.'
|
||||
} finally {
|
||||
createGroupSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Persist: Tag
|
||||
// ------------------------------------------------------
|
||||
async function createTagPersist() {
|
||||
if (createTagSaving.value) return
|
||||
createTagError.value = ''
|
||||
|
||||
const name = String(newTag.value?.name || '').trim()
|
||||
const color = String(newTag.value?.color || '').trim() || '#22C55E'
|
||||
|
||||
if (!name) {
|
||||
createTagError.value = 'Informe um nome para a tag.'
|
||||
return
|
||||
}
|
||||
|
||||
createTagSaving.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
|
||||
// Tenta schema EN primeiro (pelo teu listTags)
|
||||
let createdId = null
|
||||
{
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.insert({ owner_id: ownerId, name, color })
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (!error) createdId = data?.id || null
|
||||
else {
|
||||
// fallback PT-BR
|
||||
const { data: d2, error: e2 } = await supabase
|
||||
.from('patient_tags')
|
||||
.insert({ owner_id: ownerId, nome: name, cor: color })
|
||||
.select('id')
|
||||
.single()
|
||||
if (e2) throw e2
|
||||
createdId = d2?.id || null
|
||||
}
|
||||
}
|
||||
|
||||
// Recarrega lista e já marca a nova na seleção
|
||||
tags.value = await listTags()
|
||||
if (createdId) {
|
||||
const set = new Set([...(tagIdsSelecionadas.value || []), createdId])
|
||||
tagIdsSelecionadas.value = Array.from(set)
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Tag', detail: 'Tag criada.', life: 2500 })
|
||||
createTagDialog.value = false
|
||||
} catch (e) {
|
||||
createTagError.value = e?.message || 'Falha ao criar tag.'
|
||||
} finally {
|
||||
createTagSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -259,11 +259,14 @@ const rotating = ref(false)
|
||||
*/
|
||||
const PUBLIC_BASE_URL = '' // opcional
|
||||
|
||||
const origin = computed(() => PUBLIC_BASE_URL || window.location.origin)
|
||||
const origin = computed(() => {
|
||||
if (PUBLIC_BASE_URL) return PUBLIC_BASE_URL
|
||||
return typeof window !== 'undefined' ? window.location.origin : ''
|
||||
})
|
||||
|
||||
const publicUrl = computed(() => {
|
||||
if (!inviteToken.value) return ''
|
||||
return `${origin.value}/cadastro/paciente?t=${inviteToken.value}`
|
||||
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
|
||||
})
|
||||
|
||||
function newToken () {
|
||||
@@ -310,9 +313,25 @@ async function loadOrCreateInvite () {
|
||||
async function rotateLink () {
|
||||
rotating.value = true
|
||||
try {
|
||||
const uid = await requireUserId()
|
||||
const t = newToken()
|
||||
const { error } = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
|
||||
if (error) throw error
|
||||
|
||||
// tenta RPC primeiro
|
||||
const rpc = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
|
||||
if (rpc.error) {
|
||||
// fallback: desativa todos os ativos e cria um novo
|
||||
const { error: e1 } = await supabase
|
||||
.from('patient_invites')
|
||||
.update({ active: false, updated_at: new Date().toISOString() })
|
||||
.eq('owner_id', uid)
|
||||
.eq('active', true)
|
||||
if (e1) throw e1
|
||||
|
||||
const { error: e2 } = await supabase
|
||||
.from('patient_invites')
|
||||
.insert({ owner_id: uid, token: t, active: true })
|
||||
if (e2) throw e2
|
||||
}
|
||||
|
||||
inviteToken.value = t
|
||||
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 })
|
||||
@@ -329,7 +348,8 @@ async function copyLink () {
|
||||
await navigator.clipboard.writeText(publicUrl.value)
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado.', life: 1500 })
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
|
||||
// fallback clássico
|
||||
window.prompt('Copie o link:', publicUrl.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Avatar from 'primevue/avatar'
|
||||
|
||||
import { brToISO, isoToBR } from '@/utils/dateBR'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
@@ -96,10 +98,8 @@ function toggleStatusFilter (s) {
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const term = String(q.value || '').trim().toLowerCase()
|
||||
let list = rows.value || []
|
||||
|
||||
let list = rows.value
|
||||
|
||||
// filtro por status (se ativado)
|
||||
if (statusFilter.value) {
|
||||
list = list.filter(r => r.status === statusFilter.value)
|
||||
}
|
||||
@@ -132,7 +132,6 @@ function looksLikeUrl (s) {
|
||||
}
|
||||
|
||||
function getAvatarUrlFromItem (i) {
|
||||
// 0) tenta achar foto em vários lugares (raiz e payload jsonb)
|
||||
const p = i?.payload || i?.data || i?.form || null
|
||||
|
||||
const direct = firstNonEmpty(
|
||||
@@ -140,26 +139,37 @@ function getAvatarUrlFromItem (i) {
|
||||
p?.avatar_url, p?.foto_url, p?.photo_url
|
||||
)
|
||||
|
||||
// Se já for URL completa, usa direto
|
||||
if (direct && looksLikeUrl(direct)) return direct
|
||||
|
||||
// 1) se for path de storage, monta publicUrl
|
||||
const path = firstNonEmpty(
|
||||
i?.avatar_path, i?.photo_path, i?.foto_path, i?.avatar_file_path,
|
||||
p?.avatar_path, p?.photo_path, p?.foto_path, p?.avatar_file_path,
|
||||
// às vezes guardam o path dentro de "direct"
|
||||
direct
|
||||
)
|
||||
|
||||
if (!path) return null
|
||||
|
||||
// se o "path" veio como URL, devolve
|
||||
if (looksLikeUrl(path)) return path
|
||||
|
||||
const { data } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
|
||||
return data?.publicUrl || null
|
||||
}
|
||||
|
||||
// cache simples pra não recalcular 2x por linha (render)
|
||||
const avatarCache = new Map()
|
||||
function avatarUrl (row) {
|
||||
const id = row?.id
|
||||
if (!id) return getAvatarUrlFromItem(row)
|
||||
if (avatarCache.has(id)) return avatarCache.get(id)
|
||||
const url = getAvatarUrlFromItem(row)
|
||||
avatarCache.set(id, url)
|
||||
return url
|
||||
}
|
||||
|
||||
const dlgAvatarUrl = computed(() => {
|
||||
const item = dlg.value?.item
|
||||
if (!item) return null
|
||||
return avatarUrl(item)
|
||||
})
|
||||
|
||||
// -----------------------------
|
||||
// Formatters
|
||||
@@ -193,9 +203,21 @@ function fmtRG (v) {
|
||||
return s ? s : '—'
|
||||
}
|
||||
|
||||
// data nascimento (aceita ISO ou BR)
|
||||
function fmtBirth (v) {
|
||||
if (!v) return '—'
|
||||
return String(v)
|
||||
const s = String(v).trim()
|
||||
|
||||
// já BR
|
||||
if (/^\d{2}-\d{2}-\d{4}$/.test(s)) return s
|
||||
|
||||
// ISO date/datetime
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
|
||||
const iso = s.slice(0, 10)
|
||||
return isoToBR(iso) || s
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
function fmtDate (iso) {
|
||||
@@ -205,6 +227,27 @@ function fmtDate (iso) {
|
||||
return d.toLocaleString('pt-BR')
|
||||
}
|
||||
|
||||
// converte nascimento para ISO date (YYYY-MM-DD) usando teu utils
|
||||
function normalizeBirthToISO (v) {
|
||||
if (!v) return null
|
||||
const s = String(v).trim()
|
||||
if (!s) return null
|
||||
|
||||
// BR -> ISO
|
||||
if (/^\d{2}-\d{2}-\d{4}$/.test(s)) return brToISO(s)
|
||||
|
||||
// ISO date/datetime
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10)
|
||||
|
||||
// fallback: tenta Date
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
const yyyy = String(d.getFullYear()).padStart(4, '0')
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Seções do modal
|
||||
// -----------------------------
|
||||
@@ -212,8 +255,6 @@ const intakeSections = computed(() => {
|
||||
const i = dlg.value.item
|
||||
if (!i) return []
|
||||
|
||||
const avatarUrl = getAvatarUrlFromItem(i)
|
||||
|
||||
const section = (title, rows) => ({
|
||||
title,
|
||||
rows: (rows || []).filter(r => r && r.value !== undefined)
|
||||
@@ -281,7 +322,7 @@ const intakeSections = computed(() => {
|
||||
row('Atualizado em', fmtDate(i.updated_at)),
|
||||
row('ID do intake', dash(i.id))
|
||||
])
|
||||
].map(s => ({ ...s, avatarUrl }))
|
||||
]
|
||||
})
|
||||
|
||||
// -----------------------------
|
||||
@@ -307,6 +348,8 @@ async function fetchIntakes () {
|
||||
const db = new Date(b.created_at || 0).getTime()
|
||||
return db - da
|
||||
})
|
||||
|
||||
avatarCache.clear()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message || String(e), life: 3500 })
|
||||
@@ -383,6 +426,18 @@ async function convertToPatient () {
|
||||
const item = dlg.value?.item
|
||||
if (!item?.id) return
|
||||
if (converting.value) return
|
||||
|
||||
// regra de negócio: só converte "new"
|
||||
if (item.status !== 'new') {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Atenção',
|
||||
detail: 'Só é possível converter cadastros com status "Novo".',
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
converting.value = true
|
||||
|
||||
try {
|
||||
@@ -401,8 +456,8 @@ async function convertToPatient () {
|
||||
return d ? d : null
|
||||
}
|
||||
|
||||
// ✅ tenta reaproveitar a foto do intake, se existir
|
||||
const intakeAvatar = cleanStr(item.avatar_url) || cleanStr(item.foto_url) || null
|
||||
// tenta reaproveitar avatar do intake (se vier url/path)
|
||||
const intakeAvatar = cleanStr(item.avatar_url) || cleanStr(item.foto_url) || cleanStr(item.photo_url) || null
|
||||
|
||||
const patientPayload = {
|
||||
owner_id: ownerId,
|
||||
@@ -416,7 +471,7 @@ async function convertToPatient () {
|
||||
telefone_alternativo: digitsOnly(fTelAlt(item)),
|
||||
|
||||
// pessoais
|
||||
data_nascimento: fNasc(item) || null, // date
|
||||
data_nascimento: normalizeBirthToISO(fNasc(item)), // ✅ agora é sempre ISO date
|
||||
naturalidade: cleanStr(fNaturalidade(item)),
|
||||
genero: cleanStr(fGenero(item)),
|
||||
estado_civil: cleanStr(fEstadoCivil(item)),
|
||||
@@ -449,7 +504,7 @@ async function convertToPatient () {
|
||||
avatar_url: intakeAvatar
|
||||
}
|
||||
|
||||
// limpa undefined
|
||||
// remove undefined
|
||||
Object.keys(patientPayload).forEach(k => {
|
||||
if (patientPayload[k] === undefined) delete patientPayload[k]
|
||||
})
|
||||
@@ -510,112 +565,111 @@ onMounted(fetchIntakes)
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- HEADER -->
|
||||
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative px-5 py-5">
|
||||
<!-- faixa de cor -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-80">
|
||||
<div class="absolute -top-10 -right-12 h-40 w-40 rounded-full bg-emerald-400/20 blur-3xl" />
|
||||
<div class="absolute top-10 -left-16 h-44 w-44 rounded-full bg-indigo-400/20 blur-3xl" />
|
||||
</div>
|
||||
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative px-5 py-5">
|
||||
<!-- faixa de cor -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-80">
|
||||
<div class="absolute -top-10 -right-12 h-40 w-40 rounded-full bg-emerald-400/20 blur-3xl" />
|
||||
<div class="absolute top-10 -left-16 h-44 w-44 rounded-full bg-indigo-400/20 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-inbox text-lg"></i>
|
||||
</div>
|
||||
<div class="relative flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-inbox text-lg"></i>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-xl font-semibold leading-none">Cadastros recebidos</div>
|
||||
<Tag :value="`${totals.total}`" severity="secondary" />
|
||||
</div>
|
||||
<div class="text-color-secondary mt-1">
|
||||
Solicitações de pré-cadastro (cadastro externo) para avaliar e converter.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-xl font-semibold leading-none">Cadastros recebidos</div>
|
||||
<Tag :value="`${totals.total}`" severity="secondary" />
|
||||
</div>
|
||||
<div class="text-color-secondary mt-1">
|
||||
Solicitações de pré-cadastro (cadastro externo) para avaliar e converter.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- filtros (chips clicáveis) -->
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'new'"
|
||||
:severity="statusFilter === 'new' ? 'info' : 'secondary'"
|
||||
@click="toggleStatusFilter('new')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-sparkles" />
|
||||
Novos: <b>{{ totals.nNew }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
<!-- filtros -->
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'new'"
|
||||
:severity="statusFilter === 'new' ? 'info' : 'secondary'"
|
||||
@click="toggleStatusFilter('new')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-sparkles" />
|
||||
Novos: <b>{{ totals.nNew }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'converted'"
|
||||
:severity="statusFilter === 'converted' ? 'success' : 'secondary'"
|
||||
@click="toggleStatusFilter('converted')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-check" />
|
||||
Convertidos: <b>{{ totals.nConv }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'converted'"
|
||||
:severity="statusFilter === 'converted' ? 'success' : 'secondary'"
|
||||
@click="toggleStatusFilter('converted')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-check" />
|
||||
Convertidos: <b>{{ totals.nConv }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'rejected'"
|
||||
:severity="statusFilter === 'rejected' ? 'danger' : 'secondary'"
|
||||
@click="toggleStatusFilter('rejected')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-times" />
|
||||
Rejeitados: <b>{{ totals.nRej }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'rejected'"
|
||||
:severity="statusFilter === 'rejected' ? 'danger' : 'secondary'"
|
||||
@click="toggleStatusFilter('rejected')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-times" />
|
||||
Rejeitados: <b>{{ totals.nRej }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="statusFilter"
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
severity="secondary"
|
||||
outlined
|
||||
icon="pi pi-filter-slash"
|
||||
label="Limpar filtro"
|
||||
@click="statusFilter = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="statusFilter"
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
severity="secondary"
|
||||
outlined
|
||||
icon="pi pi-filter-slash"
|
||||
label="Limpar filtro"
|
||||
@click="statusFilter = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:items-center">
|
||||
<span class="p-input-icon-left w-full sm:w-[360px]">
|
||||
<InputText
|
||||
v-model="q"
|
||||
class="w-full"
|
||||
placeholder="Buscar por nome, e-mail ou telefone…"
|
||||
/>
|
||||
</span>
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:items-center">
|
||||
<span class="p-input-icon-left w-full sm:w-[360px]">
|
||||
<InputText
|
||||
v-model="q"
|
||||
class="w-full"
|
||||
placeholder="Buscar por nome, e-mail ou telefone…"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
label="Atualizar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="loading"
|
||||
@click="fetchIntakes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
label="Atualizar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="loading"
|
||||
@click="fetchIntakes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- TABLE WRAPPER -->
|
||||
<!-- TABLE -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<div v-if="loading" class="flex items-center justify-center py-10">
|
||||
<ProgressSpinner style="width: 38px; height: 38px" />
|
||||
@@ -639,22 +693,18 @@ onMounted(fetchIntakes)
|
||||
</Column>
|
||||
|
||||
<Column header="Paciente">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Avatar
|
||||
v-if="getAvatarUrlFromItem(data)"
|
||||
:image="getAvatarUrlFromItem(data)"
|
||||
shape="circle"
|
||||
/>
|
||||
<Avatar v-else icon="pi pi-user" shape="circle" />
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Avatar v-if="avatarUrl(data)" :image="avatarUrl(data)" shape="circle" />
|
||||
<Avatar v-else icon="pi pi-user" shape="circle" />
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ fNome(data) || '—' }}</div>
|
||||
<div class="text-color-secondary text-sm truncate">{{ fEmail(data) || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ fNome(data) || '—' }}</div>
|
||||
<div class="text-color-secondary text-sm truncate">{{ fEmail(data) || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Contato" style="width: 14rem">
|
||||
<template #body="{ data }">
|
||||
@@ -700,41 +750,39 @@ onMounted(fetchIntakes)
|
||||
:contentStyle="{ padding: 0 }"
|
||||
@hide="closeDlg"
|
||||
>
|
||||
|
||||
<div v-if="dlg.item" class="relative">
|
||||
<div v-if="dlg.item" class="relative">
|
||||
<div class="max-h-[70vh] overflow-auto p-5 bg-[var(--surface-ground)]">
|
||||
<!-- topo conceitual -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 mb-4">
|
||||
<div class="flex flex-col items-center text-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 blur-2xl opacity-30 rounded-full bg-slate-300"></div>
|
||||
<div class="relative">
|
||||
<template v-if="(dlgAvatar = getAvatarUrlFromItem(dlg.item))">
|
||||
<Avatar :image="dlgAvatar" alt="avatar" shape="circle" size="xlarge" />
|
||||
</template>
|
||||
<Avatar v-else icon="pi pi-user" shape="circle" size="xlarge" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- topo -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 mb-4">
|
||||
<div class="flex flex-col items-center text-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 blur-2xl opacity-30 rounded-full bg-slate-300"></div>
|
||||
<div class="relative">
|
||||
<Avatar v-if="dlgAvatarUrl" :image="dlgAvatarUrl" alt="avatar" shape="circle" size="xlarge" />
|
||||
<Avatar v-else icon="pi pi-user" shape="circle" size="xlarge" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="text-xl font-semibold text-slate-900 truncate">
|
||||
{{ fNome(dlg.item) || '—' }}
|
||||
</div>
|
||||
<div class="text-slate-500 text-sm truncate">
|
||||
{{ fEmail(dlg.item) || '—' }} · {{ fmtPhoneBR(fTel(dlg.item)) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xl font-semibold text-slate-900 truncate">
|
||||
{{ fNome(dlg.item) || '—' }}
|
||||
</div>
|
||||
<div class="text-slate-500 text-sm truncate">
|
||||
{{ fEmail(dlg.item) || '—' }} · {{ fmtPhoneBR(fTel(dlg.item)) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<Tag :value="statusLabel(dlg.item.status)" :severity="statusSeverity(dlg.item.status)" />
|
||||
<Tag
|
||||
:value="dlg.item.consent ? 'Consentimento OK' : 'Sem consentimento'"
|
||||
:severity="dlg.item.consent ? 'success' : 'danger'"
|
||||
/>
|
||||
<Tag :value="`Criado: ${fmtDate(dlg.item.created_at)}`" severity="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<Tag :value="statusLabel(dlg.item.status)" :severity="statusSeverity(dlg.item.status)" />
|
||||
<Tag
|
||||
:value="dlg.item.consent ? 'Consentimento OK' : 'Sem consentimento'"
|
||||
:severity="dlg.item.consent ? 'success' : 'danger'"
|
||||
/>
|
||||
<Tag :value="`Criado: ${fmtDate(dlg.item.created_at)}`" severity="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="(sec, sidx) in intakeSections"
|
||||
@@ -831,4 +879,4 @@ onMounted(fetchIntakes)
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -362,15 +362,56 @@ const patientsDialog = reactive({
|
||||
search: ''
|
||||
})
|
||||
|
||||
const cards = computed(() =>
|
||||
(groups.value || [])
|
||||
.filter(g => Number(g.patients_count ?? g.patient_count ?? 0) > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.patients_count ?? b.patient_count ?? 0) -
|
||||
Number(a.patients_count ?? a.patient_count ?? 0)
|
||||
)
|
||||
)
|
||||
function applyRealCountsToGroups (groupsArr, countMap) {
|
||||
return (groupsArr || []).map(g => ({
|
||||
...g,
|
||||
patients_count: Number(countMap[g.id] || 0) // força a verdade aqui
|
||||
}))
|
||||
}
|
||||
|
||||
async function fetchRealGroupCountsForOwner () {
|
||||
const ownerId = (await supabase.auth.getUser())?.data?.user?.id
|
||||
if (!ownerId) throw new Error('Sessão inválida.')
|
||||
|
||||
// Busca todas as associações (group <-> patient) apenas de pacientes do owner logado
|
||||
const { data, error } = await supabase
|
||||
.from('patient_group_patient')
|
||||
.select(`
|
||||
patient_group_id,
|
||||
patient:patients!inner (
|
||||
id,
|
||||
owner_id
|
||||
)
|
||||
`)
|
||||
.eq('patient.owner_id', ownerId)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// Conta em JS por group_id
|
||||
const map = Object.create(null)
|
||||
for (const row of (data || [])) {
|
||||
const gid = row.patient_group_id
|
||||
if (!gid) continue
|
||||
map[gid] = (map[gid] || 0) + 1
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
const cards = computed(() => {
|
||||
const arr = groups.value || []
|
||||
return arr
|
||||
.filter(g => {
|
||||
const raw = g.patients_count ?? g.patient_count ?? 0
|
||||
const n = Number.parseInt(String(raw), 10)
|
||||
return Number.isFinite(n) && n > 0
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const na = Number.parseInt(String(a.patients_count ?? a.patient_count ?? 0), 10) || 0
|
||||
const nb = Number.parseInt(String(b.patients_count ?? b.patient_count ?? 0), 10) || 0
|
||||
return nb - na
|
||||
})
|
||||
})
|
||||
|
||||
const patientsDialogFiltered = computed(() => {
|
||||
const s = String(patientsDialog.search || '').trim().toLowerCase()
|
||||
@@ -402,7 +443,13 @@ function humanizeError (err) {
|
||||
async function fetchAll () {
|
||||
loading.value = true
|
||||
try {
|
||||
groups.value = await listGroupsWithCounts()
|
||||
// 1) carrega grupos (com ou sem count vindo do service)
|
||||
const baseGroups = await listGroupsWithCounts()
|
||||
|
||||
// 2) recalcula counts reais (por owner) e sobrescreve
|
||||
const realCountMap = await fetchRealGroupCountsForOwner()
|
||||
groups.value = applyRealCountsToGroups(baseGroups, realCountMap)
|
||||
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
|
||||
} finally {
|
||||
@@ -603,7 +650,7 @@ async function openGroupPatientsModal (groupRow) {
|
||||
}
|
||||
|
||||
function abrirPaciente (patient) {
|
||||
router.push(`/admin/pacientes/cadastro/${patient.id}`)
|
||||
router.push(`/features/patients/cadastro/${patient.id}`)
|
||||
}
|
||||
|
||||
onMounted(fetchAll)
|
||||
@@ -133,8 +133,10 @@ onBeforeUnmount(() => {
|
||||
// ------------------------------------------------------
|
||||
const loading = ref(false)
|
||||
const patientFull = ref(null)
|
||||
const groupName = ref(null)
|
||||
const tags = ref([])
|
||||
|
||||
// ✅ agora suporta múltiplos grupos
|
||||
const groups = ref([]) // [{id,name}]
|
||||
const tags = ref([]) // [{id,name,color}]
|
||||
|
||||
const patientData = computed(() => patientFull.value || props.patient || {})
|
||||
|
||||
@@ -142,6 +144,19 @@ const fallbackAvatar =
|
||||
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=600&q=60'
|
||||
const avatarUrl = computed(() => patientData.value?.avatar_url || patientData.value?.avatar || fallbackAvatar)
|
||||
|
||||
const groupNames = computed(() => (groups.value || []).map(g => g?.name).filter(Boolean))
|
||||
const groupLabel = computed(() => {
|
||||
const n = groupNames.value.length
|
||||
if (n === 0) return '—'
|
||||
if (n === 1) return groupNames.value[0]
|
||||
return groupNames.value.join(', ')
|
||||
})
|
||||
const groupCountLabel = computed(() => {
|
||||
const n = groupNames.value.length
|
||||
if (n <= 1) return 'Grupo'
|
||||
return 'Grupos'
|
||||
})
|
||||
|
||||
function onlyDigits(v) { return String(v ?? '').replace(/\D/g, '') }
|
||||
|
||||
function fmtCPF(v) {
|
||||
@@ -163,10 +178,49 @@ function fmtRG(v) {
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ Parser tolerante:
|
||||
* - ISO date: YYYY-MM-DD
|
||||
* - ISO datetime: YYYY-MM-DDTHH:mm...
|
||||
* - BR: DD-MM-YYYY
|
||||
* - BR: DD/MM/YYYY
|
||||
*/
|
||||
function parseDateLoose(v) {
|
||||
if (!v) return null
|
||||
const s = String(v).trim()
|
||||
if (!s) return null
|
||||
|
||||
// ISO (date ou datetime)
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
|
||||
const iso = s.slice(0, 10)
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
// BR DD-MM-YYYY
|
||||
let m = s.match(/^(\d{2})-(\d{2})-(\d{4})$/)
|
||||
if (m) {
|
||||
const dd = Number(m[1]); const mm = Number(m[2]); const yy = Number(m[3])
|
||||
const d = new Date(yy, mm - 1, dd)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
// BR DD/MM/YYYY
|
||||
m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
|
||||
if (m) {
|
||||
const dd = Number(m[1]); const mm = Number(m[2]); const yy = Number(m[3])
|
||||
const d = new Date(yy, mm - 1, dd)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
// fallback Date
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
function fmtDateBR(isoOrDate) {
|
||||
if (!isoOrDate) return '—'
|
||||
const d = new Date(isoOrDate)
|
||||
if (Number.isNaN(d.getTime())) return dash(isoOrDate)
|
||||
const d = parseDateLoose(isoOrDate)
|
||||
if (!d) return isoOrDate ? dash(isoOrDate) : '—'
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const yy = d.getFullYear()
|
||||
@@ -174,9 +228,8 @@ function fmtDateBR(isoOrDate) {
|
||||
}
|
||||
|
||||
function calcAge(isoOrDate) {
|
||||
if (!isoOrDate) return null
|
||||
const d = new Date(isoOrDate)
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
const d = parseDateLoose(isoOrDate)
|
||||
if (!d) return null
|
||||
const now = new Date()
|
||||
let age = now.getFullYear() - d.getFullYear()
|
||||
const m = now.getMonth() - d.getMonth()
|
||||
@@ -295,8 +348,7 @@ async function getPatientRelations(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ AQUI estava o erro:
|
||||
* patient_groups NÃO tem "name", tem "nome"
|
||||
* ✅ patient_groups NÃO tem "name", tem "nome"
|
||||
*/
|
||||
async function getGroupsByIds(ids) {
|
||||
if (!ids?.length) return []
|
||||
@@ -304,6 +356,7 @@ async function getGroupsByIds(ids) {
|
||||
.from('patient_groups')
|
||||
.select('id, nome')
|
||||
.in('id', ids)
|
||||
.order('nome', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
@@ -314,8 +367,8 @@ async function getGroupsByIds(ids) {
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ AQUI estava o erro:
|
||||
* patient_tags NÃO tem "name/color", tem "nome/cor"
|
||||
* ✅ patient_tags NÃO tem "name/color", tem "nome/cor"
|
||||
* Mas aceitamos também "color" por segurança.
|
||||
*/
|
||||
async function getTagsByIds(ids) {
|
||||
if (!ids?.length) return []
|
||||
@@ -338,7 +391,7 @@ async function loadProntuario(id) {
|
||||
loadError.value = ''
|
||||
loading.value = true
|
||||
patientFull.value = null
|
||||
groupName.value = null
|
||||
groups.value = []
|
||||
tags.value = []
|
||||
|
||||
try {
|
||||
@@ -349,9 +402,7 @@ async function loadProntuario(id) {
|
||||
|
||||
const rel = await getPatientRelations(id)
|
||||
|
||||
const groups = await getGroupsByIds(rel.groupIds || [])
|
||||
groupName.value = groups?.[0]?.name || null
|
||||
|
||||
groups.value = await getGroupsByIds(rel.groupIds || [])
|
||||
tags.value = await getTagsByIds(rel.tagIds || [])
|
||||
} catch (e) {
|
||||
loadError.value = e?.message || 'Falha ao buscar dados no Supabase.'
|
||||
@@ -377,11 +428,60 @@ function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// ---------------------------------------------
|
||||
// ✅ Tags com cor + contraste
|
||||
// ---------------------------------------------
|
||||
function normalizeHexColor(c) {
|
||||
const s = String(c ?? '').trim()
|
||||
if (!s) return ''
|
||||
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)) return s
|
||||
if (/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)) return `#${s}`
|
||||
return s // pode ser 'rgb(...)' ou nome de cor
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const h = String(hex || '').replace('#', '').trim()
|
||||
if (!/^[0-9a-f]{3}|[0-9a-f]{6}$/i.test(h)) return null
|
||||
const full = h.length === 3 ? h.split('').map(ch => ch + ch).join('') : h
|
||||
const r = parseInt(full.slice(0, 2), 16)
|
||||
const g = parseInt(full.slice(2, 4), 16)
|
||||
const b = parseInt(full.slice(4, 6), 16)
|
||||
if ([r, g, b].some(n => Number.isNaN(n))) return null
|
||||
return { r, g, b }
|
||||
}
|
||||
|
||||
function relativeLuminance({ r, g, b }) {
|
||||
// sRGB -> linear
|
||||
const srgb = [r, g, b].map(v => v / 255).map(c => (
|
||||
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
||||
))
|
||||
return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2]
|
||||
}
|
||||
|
||||
function bestTextColor(bg) {
|
||||
const c = normalizeHexColor(bg)
|
||||
const rgb = hexToRgb(c)
|
||||
if (!rgb) return '#0f172a' // slate-900 fallback
|
||||
const lum = relativeLuminance(rgb)
|
||||
return lum < 0.45 ? '#ffffff' : '#0f172a'
|
||||
}
|
||||
|
||||
function tagStyle(t) {
|
||||
const bg = normalizeHexColor(t?.color || t?.cor)
|
||||
if (!bg) return {}
|
||||
const fg = bestTextColor(bg)
|
||||
return {
|
||||
background: bg,
|
||||
color: fg,
|
||||
borderColor: 'transparent'
|
||||
}
|
||||
}
|
||||
|
||||
async function copyResumo() {
|
||||
const txt =
|
||||
`Paciente: ${dash(nomeCompleto.value)}
|
||||
Idade: ${ageLabel.value}
|
||||
Grupo: ${dash(groupName.value)}
|
||||
${groupNames.value.length <= 1 ? 'Grupo' : 'Grupos'}: ${groupLabel.value}
|
||||
Telefone: ${dash(telefone.value)}
|
||||
Email: ${dash(emailPrincipal.value)}
|
||||
CPF: ${dash(patientData.value?.cpf)}
|
||||
@@ -454,12 +554,18 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
</div>
|
||||
|
||||
<div class="w-full text-sm text-slate-700">
|
||||
<div><b>Grupo:</b> {{ dash(groupName) }}</div>
|
||||
<div><b>{{ groupCountLabel }}:</b> {{ groupLabel }}</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<b>Tags:</b>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<Chip v-for="t in tags" :key="t.id" :label="t.name" />
|
||||
<Chip
|
||||
v-for="t in tags"
|
||||
:key="t.id"
|
||||
:label="t.name"
|
||||
:style="tagStyle(t)"
|
||||
class="!border !rounded-full"
|
||||
/>
|
||||
<span v-if="!tags?.length" class="text-slate-500">—</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -642,9 +748,9 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-folder-open" />
|
||||
<InputText :modelValue="dash(groupName)" class="w-full" variant="filled" readonly />
|
||||
<InputText :modelValue="groupLabel" class="w-full" variant="filled" readonly />
|
||||
</IconField>
|
||||
<label>Grupos</label>
|
||||
<label>{{ groupCountLabel }}</label>
|
||||
</FloatLabel>
|
||||
<small class="text-slate-500">Utilizado para importar o formulário de anamnese</small>
|
||||
</div>
|
||||
@@ -896,4 +1002,4 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
</template>
|
||||
@@ -418,7 +418,7 @@ async function getOwnerId() {
|
||||
|
||||
function normalizarEtiquetaRow(r) {
|
||||
// Compatível com banco antigo (name/color/is_native/patient_count)
|
||||
// e com banco pt-BR (nome/cor/is_padrao/pacientes_count)
|
||||
// e com banco pt-BR (nome/cor/is_padrao/patients_count)
|
||||
const nome = r?.nome ?? r?.name ?? ''
|
||||
const cor = r?.cor ?? r?.color ?? null
|
||||
const is_padrao = Boolean(r?.is_padrao ?? r?.is_native ?? false)
|
||||
@@ -801,9 +801,8 @@ function fmtPhoneBR(v) {
|
||||
return d
|
||||
}
|
||||
|
||||
function abrirPaciente(patient) {
|
||||
// no teu router, a rota de edição é /admin/pacientes/cadastro/:id
|
||||
router.push(`/admin/pacientes/cadastro/${patient.id}`)
|
||||
function abrirPaciente (patient) {
|
||||
router.push(`/features/patients/cadastro/${patient.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -25,8 +25,21 @@ const { layoutState } = useLayout()
|
||||
const tenantStore = useTenantStore()
|
||||
const entitlementsStore = useEntitlementsStore()
|
||||
|
||||
/**
|
||||
* ✅ Role canônico pro MENU:
|
||||
* - Prioriza o role do tenant (mesma fonte usada pelo router guard)
|
||||
* - Faz fallback pro sessionRole (ex.: telas fora de tenant)
|
||||
*/
|
||||
const navRole = computed(() => {
|
||||
return tenantStore.activeRole || sessionRole.value || null
|
||||
})
|
||||
|
||||
const model = computed(() => {
|
||||
const base = getMenuByRole(sessionRole.value, { isSaasAdmin: sessionIsSaasAdmin.value }) || []
|
||||
// ✅ fonte correta: tenant role (clinic_admin/therapist/patient)
|
||||
// fallback: profiles.role (admin/therapist/patient)
|
||||
const effectiveRole = tenantStore.activeRole || sessionRole.value
|
||||
|
||||
const base = getMenuByRole(effectiveRole, { isSaasAdmin: sessionIsSaasAdmin.value }) || []
|
||||
|
||||
const normalize = (s) => String(s || '').toLowerCase()
|
||||
const priorityOrder = (group) => {
|
||||
@@ -41,6 +54,7 @@ const model = computed(() => {
|
||||
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || null)
|
||||
|
||||
// quando troca tenant -> recarrega entitlements
|
||||
watch(
|
||||
tenantId,
|
||||
async (id) => {
|
||||
@@ -50,8 +64,9 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// ✅ quando troca role efetivo do menu (tenant role / session role) -> recarrega entitlements do tenant atual
|
||||
watch(
|
||||
() => sessionRole.value,
|
||||
() => navRole.value,
|
||||
async () => {
|
||||
if (!tenantId.value) return
|
||||
entitlementsStore.invalidate()
|
||||
@@ -480,4 +495,4 @@ function onSearchFocus () {
|
||||
@created="onQuickCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -44,13 +44,13 @@ function close () {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function goMyProfile () {
|
||||
function goMyProfile() {
|
||||
close()
|
||||
|
||||
// navegação segura por name
|
||||
// Navegação segura para Account → Profile
|
||||
safePush(
|
||||
{ name: 'MeuPerfil' },
|
||||
'/me/perfil'
|
||||
{ name: 'account-profile' },
|
||||
'/account/profile'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ function goSettings () {
|
||||
}
|
||||
|
||||
if (r === 'patient') {
|
||||
router.push('/patient/conta')
|
||||
router.push('/patient/portal')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ async function irCadastroCompleto () {
|
||||
layoutState.menuHoverActive = false
|
||||
|
||||
await nextTick()
|
||||
router.push('/admin/pacientes/cadastro')
|
||||
router.push('/admin/patients/cadastro')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -200,7 +200,14 @@ async function logout() {
|
||||
try {
|
||||
await supabase.auth.signOut()
|
||||
} finally {
|
||||
router.push('/auth/login')
|
||||
// limpa possíveis intenções guardadas
|
||||
sessionStorage.removeItem('redirect_after_login')
|
||||
sessionStorage.removeItem('intended_area')
|
||||
|
||||
// ✅ vai para HomeCards
|
||||
router.replace('/')
|
||||
// Use router.replace('/') e não push,
|
||||
// assim o usuário não consegue voltar com o botão "voltar" para uma rota protegida.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,54 +1,96 @@
|
||||
// src/navigation/index.js
|
||||
|
||||
// ======================================================
|
||||
// 📦 Importação dos menus base por área
|
||||
// ======================================================
|
||||
|
||||
import adminMenu from './menus/admin.menu'
|
||||
import therapistMenu from './menus/therapist.menu'
|
||||
import patientMenu from './menus/patient.menu'
|
||||
import portalMenu from './menus/portal.menu'
|
||||
import sakaiDemoMenu from './menus/sakai.demo.menu'
|
||||
import saasMenu from './menus/saas.menu'
|
||||
|
||||
import { useSaasHealthStore } from '@/stores/saasHealthStore'
|
||||
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
||||
|
||||
// ======================================================
|
||||
// 🎭 Mapeamento de role → menu base
|
||||
// ======================================================
|
||||
|
||||
const MENUS = {
|
||||
admin: adminMenu,
|
||||
// ✅ role real do tenant
|
||||
clinic_admin: adminMenu,
|
||||
therapist: therapistMenu,
|
||||
patient: patientMenu
|
||||
patient: portalMenu,
|
||||
|
||||
// ✅ compatibilidade profiles.role
|
||||
admin: adminMenu,
|
||||
|
||||
// ✅ legado
|
||||
tenant_admin: adminMenu
|
||||
}
|
||||
|
||||
// aceita export de menu como ARRAY ou como FUNÇÃO (ctx) => []
|
||||
// ======================================================
|
||||
// 🧠 Função utilitária
|
||||
// Permite que o menu seja:
|
||||
// - Array direto
|
||||
// - ou função (ctx) => Array
|
||||
// ======================================================
|
||||
|
||||
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)
|
||||
// ======================================================
|
||||
// 🎯 getMenuByRole
|
||||
// ======================================================
|
||||
|
||||
// ✅ badge dinâmica do Health (contador vem do store)
|
||||
// ⚠️ não faz fetch aqui: o AppMenu carrega o store.
|
||||
export function getMenuByRole (role, sessionCtx = {}) {
|
||||
// 🔹 Store de health do SaaS (badge dinâmica)
|
||||
// ⚠️ Não faz fetch aqui. O AppMenu carrega o store.
|
||||
const saasHealthStore = useSaasHealthStore()
|
||||
const mismatchCount = saasHealthStore?.mismatchCount || 0
|
||||
|
||||
// ✅ menu SaaS entra como overlay, não depende de role
|
||||
// passa opts com mismatchCount (saas.menu.js vai usar pra badge)
|
||||
// 🔹 Store de módulos por tenant (tenant_features)
|
||||
// ⚠️ Não faz fetch aqui. O guard/app deve carregar. Aqui só lemos cache.
|
||||
const tenantFeaturesStore = useTenantFeaturesStore()
|
||||
|
||||
// 🔹 SaaS overlay aparece somente para SaaS master
|
||||
const isSaas = sessionCtx?.isSaasAdmin === true
|
||||
|
||||
// ctx que será passado pros menu builders
|
||||
const ctx = {
|
||||
...sessionCtx,
|
||||
mismatchCount,
|
||||
tenantFeaturesStore,
|
||||
tenantFeatureEnabled: (key) => {
|
||||
try { return !!tenantFeaturesStore?.isEnabled?.(key) } catch { return false }
|
||||
}
|
||||
}
|
||||
|
||||
// 🔹 Menu base da role
|
||||
const base = resolveMenu(MENUS[role], ctx)
|
||||
|
||||
// 🔹 Resolve menu SaaS (array ou função)
|
||||
const saas = typeof saasMenu === 'function'
|
||||
? saasMenu(sessionCtx, { mismatchCount })
|
||||
? saasMenu(ctx, { 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
|
||||
]
|
||||
}
|
||||
// ======================================================
|
||||
// 🚀 Menu final
|
||||
// - base sempre
|
||||
// - overlay SaaS só para SaaS master
|
||||
// - Demo Sakai só para SaaS master em DEV
|
||||
// ======================================================
|
||||
|
||||
return [
|
||||
...base,
|
||||
...(saas.length ? [{ separator: true }, ...saas] : [])
|
||||
|
||||
...(isSaas && saas.length ? [{ separator: true }, ...saas] : []),
|
||||
|
||||
...(isSaas && import.meta.env.DEV
|
||||
? [{ separator: true }, ...sakaiDemoMenu]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,76 +1,111 @@
|
||||
export default [
|
||||
{
|
||||
label: 'Admin',
|
||||
// src/navigation/menus/admin.menu.js
|
||||
|
||||
export default function adminMenu (ctx = {}) {
|
||||
const patientsOn = !!ctx?.tenantFeatureEnabled?.('patients')
|
||||
|
||||
const menu = [
|
||||
// =====================================================
|
||||
// 📊 OPERAÇÃO
|
||||
// =====================================================
|
||||
{
|
||||
label: 'Operação',
|
||||
items: [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon: 'pi pi-fw pi-home',
|
||||
to: '/admin'
|
||||
},
|
||||
{
|
||||
label: 'Agenda',
|
||||
icon: 'pi pi-fw pi-calendar',
|
||||
to: '/admin/agenda',
|
||||
feature: 'agenda.view'
|
||||
},
|
||||
{
|
||||
label: 'Agenda da Clínica',
|
||||
icon: 'pi pi-fw pi-sitemap',
|
||||
to: '/admin/agenda/clinica',
|
||||
feature: 'agenda.view'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// =====================================================
|
||||
// 👥 PACIENTES (somente se módulo ativo)
|
||||
// =====================================================
|
||||
if (patientsOn) {
|
||||
menu.push({
|
||||
label: 'Pacientes',
|
||||
items: [
|
||||
{
|
||||
label: 'Lista de Pacientes',
|
||||
icon: 'pi pi-fw pi-users',
|
||||
to: '/admin/pacientes'
|
||||
},
|
||||
{
|
||||
label: 'Grupos',
|
||||
icon: 'pi pi-fw pi-users',
|
||||
to: '/admin/pacientes/grupos'
|
||||
},
|
||||
{
|
||||
label: 'Tags',
|
||||
icon: 'pi pi-fw pi-tags',
|
||||
to: '/admin/pacientes/tags'
|
||||
},
|
||||
{
|
||||
label: 'Link Externo',
|
||||
icon: 'pi pi-fw pi-link',
|
||||
to: '/admin/pacientes/link-externo'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// ⚙️ GESTÃO DA CLÍNICA
|
||||
// =====================================================
|
||||
menu.push({
|
||||
label: 'Gestão',
|
||||
items: [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon: 'pi pi-fw pi-home',
|
||||
to: '/admin'
|
||||
label: 'Profissionais',
|
||||
icon: 'pi pi-fw pi-id-card',
|
||||
to: '/admin/clinic/professionals'
|
||||
},
|
||||
{
|
||||
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: 'Módulos da Clínica',
|
||||
icon: 'pi pi-fw pi-sliders-h',
|
||||
to: '/admin/clinic/features'
|
||||
},
|
||||
{
|
||||
label: 'Assinatura',
|
||||
icon: 'pi pi-fw pi-credit-card',
|
||||
to: '/admin/billing'
|
||||
},
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 🔒 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"
|
||||
// =====================================================
|
||||
// 🔒 SISTEMA
|
||||
// =====================================================
|
||||
menu.push({
|
||||
label: 'Sistema',
|
||||
items: [
|
||||
{
|
||||
label: 'Segurança',
|
||||
icon: 'pi pi-fw pi-shield',
|
||||
to: '/admin/configuracoes/seguranca'
|
||||
to: '/admin/settings/security'
|
||||
},
|
||||
{
|
||||
label: 'Agendamento Online (PRO)',
|
||||
icon: 'pi pi-fw pi-calendar-plus',
|
||||
to: '/admin/online-scheduling',
|
||||
feature: 'online_scheduling.manage',
|
||||
proBadge: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
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'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return menu
|
||||
}
|
||||
@@ -5,20 +5,12 @@ export default [
|
||||
// ======================
|
||||
// ✅ 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: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/portal' },
|
||||
{ label: 'Minha Agenda', icon: 'pi pi-fw pi-calendar-plus', to: '/portal/agenda' },
|
||||
{ label: 'Agendar Sessão', icon: 'pi pi-fw pi-user', to: '/portal/agenda/new' },
|
||||
// ✅ Conta é global, não do portal
|
||||
{ label: 'My Account', icon: 'pi pi-fw pi-user', to: '/account/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)
|
||||
// =====================================================
|
||||
@@ -32,7 +24,7 @@ export default [
|
||||
// {
|
||||
// label: 'Agendar online',
|
||||
// icon: 'pi pi-fw pi-globe',
|
||||
// to: '/patient/online-scheduling',
|
||||
// to: '/portal/online-scheduling',
|
||||
// feature: 'online_scheduling.public',
|
||||
// proBadge: true
|
||||
// },
|
||||
@@ -41,7 +33,7 @@ export default [
|
||||
// {
|
||||
// label: 'Documentos',
|
||||
// icon: 'pi pi-fw pi-file',
|
||||
// to: '/patient/documents',
|
||||
// to: '/portal/documents',
|
||||
// feature: 'patient_documents',
|
||||
// proBadge: true
|
||||
// },
|
||||
@@ -50,7 +42,7 @@ export default [
|
||||
// {
|
||||
// label: 'Sala de atendimento',
|
||||
// icon: 'pi pi-fw pi-video',
|
||||
// to: '/patient/telehealth',
|
||||
// to: '/portal/telehealth',
|
||||
// feature: 'telehealth',
|
||||
// proBadge: true
|
||||
// }
|
||||
@@ -1,10 +1,15 @@
|
||||
// src/navigation/menus/saas.menu.js
|
||||
|
||||
export default function saasMenu (authStore, opts = {}) {
|
||||
if (!authStore?.isSaasAdmin) return []
|
||||
export default function saasMenu (sessionCtx, opts = {}) {
|
||||
if (!sessionCtx?.isSaasAdmin) return []
|
||||
|
||||
const mismatchCount = Number(opts?.mismatchCount || 0)
|
||||
|
||||
// ✅ helper p/ evitar repetir spread + manter comentários intactos
|
||||
const mismatchBadge = mismatchCount > 0
|
||||
? { badge: String(mismatchCount), badgeClass: 'p-badge p-badge-danger' }
|
||||
: {}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'SaaS',
|
||||
@@ -16,11 +21,11 @@ export default function saasMenu (authStore, opts = {}) {
|
||||
{
|
||||
label: 'Planos',
|
||||
icon: 'pi pi-star',
|
||||
path: '/plans', // ✅ vira /saas/plans pelo parentPath
|
||||
path: '/saas/plans', // ✅ absoluto (mais confiável p/ active/expand)
|
||||
items: [
|
||||
{ label: 'Listagem de Planos', icon: 'pi pi-list', to: '/saas/plans' },
|
||||
|
||||
// ✅ NOVO: vitrine pública (pricing page)
|
||||
// ✅ 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' },
|
||||
@@ -31,7 +36,7 @@ export default function saasMenu (authStore, opts = {}) {
|
||||
{
|
||||
label: 'Assinaturas',
|
||||
icon: 'pi pi-credit-card',
|
||||
path: '/subscriptions', // ✅ vira /saas/subscriptions
|
||||
path: '/saas/subscriptions', // ✅ absoluto
|
||||
items: [
|
||||
{ label: 'Listagem de Assinaturas', icon: 'pi pi-list', to: '/saas/subscriptions' },
|
||||
{ label: 'Histórico', icon: 'pi pi-history', to: '/saas/subscription-events' },
|
||||
@@ -39,20 +44,21 @@ export default function saasMenu (authStore, opts = {}) {
|
||||
label: 'Saúde das Assinaturas',
|
||||
icon: 'pi pi-shield',
|
||||
to: '/saas/subscription-health',
|
||||
...(mismatchCount > 0
|
||||
? { badge: String(mismatchCount), badgeClass: 'p-badge p-badge-danger' }
|
||||
...(mismatchBadge
|
||||
? mismatchBadge
|
||||
: {})
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Intenções de Assinatura',
|
||||
icon: 'pi pi-inbox',
|
||||
to: '/saas/subscription-intents'
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Intenções de Assinatura',
|
||||
icon: 'pi pi-inbox',
|
||||
to: '/saas/subscription-intents'
|
||||
},
|
||||
|
||||
{ label: 'Clínicas (Tenants)', icon: 'pi pi-users', to: '/saas/tenants' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,47 @@
|
||||
// src/navigation/menus/therapist.menu.js
|
||||
|
||||
export default [
|
||||
{
|
||||
label: 'Terapeuta',
|
||||
label: 'Therapist',
|
||||
items: [
|
||||
// ======================================================
|
||||
// 📊 DASHBOARD
|
||||
// ======================================================
|
||||
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/therapist' },
|
||||
|
||||
// ======================================================
|
||||
// 📅 AGENDA
|
||||
// ======================================================
|
||||
{ label: 'Agenda', icon: 'pi pi-fw pi-calendar', to: '/therapist/agenda' },
|
||||
|
||||
// ✅ PRO
|
||||
// ======================================================
|
||||
// 👥 PATIENTS
|
||||
// ======================================================
|
||||
{ label: 'Meus pacientes', icon: 'pi pi-list', to: '/therapist/patients' },
|
||||
|
||||
{ label: 'Grupo de pacientes', icon: 'pi pi-fw pi-users', to: '/therapist/patients/grupos' },
|
||||
|
||||
{ label: 'Tags', icon: 'pi pi-tags', to: '/therapist/patients/tags' },
|
||||
|
||||
{ label: 'Meu link de cadastro', icon: 'pi pi-link', to: '/therapist/patients/link-externo' },
|
||||
|
||||
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: '/therapist/patients/cadastro/recebidos' },
|
||||
|
||||
// ======================================================
|
||||
// 🔒 PRO — Online Scheduling
|
||||
// ======================================================
|
||||
{
|
||||
label: 'Agendamento online',
|
||||
label: 'Online Scheduling',
|
||||
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' }
|
||||
// ======================================================
|
||||
// 👤 ACCOUNT
|
||||
// ======================================================
|
||||
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' }
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/router/guard.js
|
||||
// ⚠️ Guard depende de sessão estável.
|
||||
// Nunca disparar refresh concorrente durante navegação protegida.
|
||||
// Ver comentário em session.js sobre race condition.
|
||||
@@ -5,6 +6,7 @@
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
||||
import { buildUpgradeUrl } from '@/utils/upgradeContext'
|
||||
|
||||
import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session'
|
||||
@@ -16,11 +18,33 @@ let sessionUidCache = null
|
||||
let saasAdminCacheUid = null
|
||||
let saasAdminCacheIsAdmin = null
|
||||
|
||||
// -----------------------------------------
|
||||
// Pending invite (Modelo B) — retomada pós-login
|
||||
// -----------------------------------------
|
||||
const PENDING_INVITE_TOKEN_KEY = 'pending_invite_token_v1'
|
||||
|
||||
function readPendingInviteToken () {
|
||||
try { return sessionStorage.getItem(PENDING_INVITE_TOKEN_KEY) } catch (_) { return null }
|
||||
}
|
||||
|
||||
function clearPendingInviteToken () {
|
||||
try { sessionStorage.removeItem(PENDING_INVITE_TOKEN_KEY) } catch (_) {}
|
||||
}
|
||||
|
||||
function isUuid (v) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''))
|
||||
}
|
||||
|
||||
function roleToPath (role) {
|
||||
if (role === 'tenant_admin') return '/admin'
|
||||
// ✅ clínica: aceita nomes canônicos e legado
|
||||
if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin'
|
||||
|
||||
if (role === 'therapist') return '/therapist'
|
||||
if (role === 'patient') return '/patient'
|
||||
if (role === 'patient') return '/portal'
|
||||
|
||||
// ✅ saas master
|
||||
if (role === 'saas_admin') return '/saas'
|
||||
|
||||
return '/'
|
||||
}
|
||||
|
||||
@@ -103,12 +127,28 @@ export function applyGuards (router) {
|
||||
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 }
|
||||
// ==========================================
|
||||
// ✅ AUTH SEMPRE LIBERADO (blindagem total)
|
||||
// (ordem importa: /auth antes de meta.public)
|
||||
// ==========================================
|
||||
if (to.path.startsWith('/auth')) {
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ✅ Rotas públicas
|
||||
// ==========================================
|
||||
if (to.meta?.public) {
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
}
|
||||
|
||||
// se rota não exige auth, libera
|
||||
if (!to.meta?.requiresAuth) { console.timeEnd(tlabel); return true }
|
||||
if (!to.meta?.requiresAuth) {
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
}
|
||||
|
||||
// não decide nada no meio do refresh do session.js
|
||||
console.timeLog(tlabel, 'waitSessionIfRefreshing')
|
||||
@@ -122,6 +162,29 @@ export function applyGuards (router) {
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ✅ Pending invite (Modelo B): se o usuário logou e tem token pendente,
|
||||
// redireciona para /accept-invite antes de qualquer load pesado.
|
||||
// ==========================================
|
||||
const pendingInviteToken = readPendingInviteToken()
|
||||
|
||||
// Se tiver lixo no storage, limpa para não “travar” o app.
|
||||
if (pendingInviteToken && !isUuid(pendingInviteToken)) {
|
||||
clearPendingInviteToken()
|
||||
}
|
||||
|
||||
// Evita loop/efeito colateral:
|
||||
// - não interfere se já está em /accept-invite
|
||||
// (não precisamos checar /auth aqui porque /auth já retornou lá em cima)
|
||||
if (
|
||||
pendingInviteToken &&
|
||||
isUuid(pendingInviteToken) &&
|
||||
!to.path.startsWith('/accept-invite')
|
||||
) {
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/accept-invite', query: { token: pendingInviteToken } }
|
||||
}
|
||||
|
||||
// se uid mudou, invalida caches e stores dependentes
|
||||
if (sessionUidCache !== uid) {
|
||||
sessionUidCache = uid
|
||||
@@ -130,15 +193,27 @@ export function applyGuards (router) {
|
||||
|
||||
const ent0 = useEntitlementsStore()
|
||||
if (typeof ent0.invalidate === 'function') ent0.invalidate()
|
||||
|
||||
const tf0 = useTenantFeaturesStore()
|
||||
if (typeof tf0.invalidate === 'function') tf0.invalidate()
|
||||
}
|
||||
|
||||
// saas admin (com cache)
|
||||
// ================================
|
||||
// ✅ SAAS MASTER: não depende de tenant
|
||||
// ================================
|
||||
if (to.meta?.saasAdmin) {
|
||||
console.timeLog(tlabel, 'isSaasAdmin')
|
||||
const ok = await isSaasAdmin(uid)
|
||||
if (!ok) { console.timeEnd(tlabel); return { path: '/pages/access' } }
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
}
|
||||
|
||||
// ================================
|
||||
// ✅ Abaixo daqui é tudo tenant-app
|
||||
// ================================
|
||||
|
||||
// carrega tenant + role
|
||||
const tenant = useTenantStore()
|
||||
console.timeLog(tlabel, 'tenant.loadSessionAndTenant?')
|
||||
@@ -181,6 +256,12 @@ export function applyGuards (router) {
|
||||
return { path: '/pages/access' }
|
||||
}
|
||||
|
||||
// se trocar tenant, invalida cache de tenant_features (evita ler feature de tenant errado)
|
||||
const tfSwitch = useTenantFeaturesStore()
|
||||
if (tfSwitch.loadedForTenantId && tfSwitch.loadedForTenantId !== tenantId) {
|
||||
tfSwitch.invalidate()
|
||||
}
|
||||
|
||||
// entitlements (✅ carrega só quando precisa)
|
||||
const ent = useEntitlementsStore()
|
||||
if (shouldLoadEntitlements(ent, tenantId)) {
|
||||
@@ -188,6 +269,28 @@ export function applyGuards (router) {
|
||||
await loadEntitlementsSafe(ent, tenantId, true)
|
||||
}
|
||||
|
||||
// ================================
|
||||
// ✅ tenant_features (módulos ativáveis por clínica)
|
||||
// meta.tenantFeature = 'patients' | ...
|
||||
// ================================
|
||||
const requiredTenantFeature = to.meta?.tenantFeature
|
||||
if (requiredTenantFeature) {
|
||||
const tf = useTenantFeaturesStore()
|
||||
console.timeLog(tlabel, 'tenantFeatures.fetchForTenant')
|
||||
await tf.fetchForTenant(tenantId, { force: false })
|
||||
|
||||
if (!tf.isEnabled(requiredTenantFeature)) {
|
||||
// evita loop
|
||||
if (to.path === '/admin/clinic/features') { console.timeEnd(tlabel); return true }
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
return {
|
||||
path: '/admin/clinic/features',
|
||||
query: { missing: requiredTenantFeature, redirectTo: to.fullPath }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// roles guard (plural)
|
||||
const allowedRoles = to.meta?.roles
|
||||
if (Array.isArray(allowedRoles) && allowedRoles.length) {
|
||||
@@ -208,18 +311,18 @@ export function applyGuards (router) {
|
||||
return { path: fallback }
|
||||
}
|
||||
|
||||
// feature guard
|
||||
// feature guard (entitlements/plano → upgrade)
|
||||
const requiredFeature = to.meta?.feature
|
||||
if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
|
||||
if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
|
||||
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 }
|
||||
}
|
||||
const url = buildUpgradeUrl({
|
||||
missingKeys: [requiredFeature],
|
||||
redirectTo: to.fullPath
|
||||
})
|
||||
console.timeEnd(tlabel)
|
||||
return url
|
||||
}
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
@@ -227,7 +330,8 @@ export function applyGuards (router) {
|
||||
console.error('[guards] erro no beforeEach:', e)
|
||||
|
||||
// fallback seguro
|
||||
if (to.meta?.public || to.path.startsWith('/auth')) return true
|
||||
if (to.path.startsWith('/auth')) return true
|
||||
if (to.meta?.public) return true
|
||||
if (to.path === '/pages/access') return true
|
||||
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
@@ -243,6 +347,11 @@ export function applyGuards (router) {
|
||||
sessionUidCache = null
|
||||
saasAdminCacheUid = null
|
||||
saasAdminCacheIsAdmin = null
|
||||
|
||||
try {
|
||||
const tf = useTenantFeaturesStore()
|
||||
if (typeof tf.invalidate === 'function') tf.invalidate()
|
||||
} catch {}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +1,120 @@
|
||||
import {
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
isNavigationFailure,
|
||||
NavigationFailureType
|
||||
} from 'vue-router'
|
||||
import { createRouter, createWebHistory, isNavigationFailure, NavigationFailureType } from 'vue-router';
|
||||
|
||||
import publicRoutes from './routes.public'
|
||||
import adminRoutes from './routes.admin'
|
||||
import therapistRoutes from './routes.therapist'
|
||||
import patientRoutes from './routes.patient'
|
||||
import miscRoutes from './routes.misc'
|
||||
import authRoutes from './routes.auth'
|
||||
import configuracoesRoutes from './router.configuracoes'
|
||||
import billingRoutes from './routes.billing'
|
||||
import saasRoutes from './routes.saas'
|
||||
import demoRoutes from './routes.demo'
|
||||
import meRoutes from './router.me'
|
||||
import configuracoesRoutes from './routes.configs';
|
||||
import meRoutes from './routes.account';
|
||||
import adminRoutes from './routes.admin';
|
||||
import authRoutes from './routes.auth';
|
||||
import billingRoutes from './routes.billing';
|
||||
import demoRoutes from './routes.demo';
|
||||
import miscRoutes from './routes.misc';
|
||||
import patientRoutes from './routes.portal';
|
||||
import publicRoutes from './routes.public';
|
||||
import saasRoutes from './routes.saas';
|
||||
import therapistRoutes from './routes.therapist';
|
||||
import featuresRoutes from './routes.features'
|
||||
|
||||
import { applyGuards } from './guards'
|
||||
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]),
|
||||
...(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]),
|
||||
...(Array.isArray(featuresRoutes) ? featuresRoutes : [featuresRoutes]),
|
||||
|
||||
// ✅ compat: rota antiga /login → /auth/login (evita 404 se algum trecho legado usar /login)
|
||||
{
|
||||
path: '/login',
|
||||
redirect: (to) => ({
|
||||
path: '/auth/login',
|
||||
query: to.query || {}
|
||||
})
|
||||
},
|
||||
|
||||
{ path: '/:pathMatch(.*)*', name: 'notfound', component: () => import('@/views/pages/NotFound.vue') }
|
||||
]
|
||||
{ path: '/:pathMatch(.*)*', name: 'notfound', component: () => import('@/views/pages/NotFound.vue') }
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
// volta/avançar do navegador mantém posição
|
||||
if (savedPosition) return savedPosition
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
// volta/avançar do navegador mantém posição
|
||||
if (savedPosition) return savedPosition;
|
||||
|
||||
// qualquer navegação normal NÃO altera o scroll
|
||||
return false
|
||||
}
|
||||
})
|
||||
// qualquer navegação normal NÃO altera o scroll
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
/* 🔎 DEBUG: listar todas as rotas registradas */
|
||||
console.log(
|
||||
'[ROUTES]',
|
||||
router.getRoutes().map(r => r.path).sort()
|
||||
)
|
||||
'[ROUTES]',
|
||||
router
|
||||
.getRoutes()
|
||||
.map((r) => r.path)
|
||||
.sort()
|
||||
);
|
||||
|
||||
// ===== DEBUG NAV + TRACE (remover depois) =====
|
||||
const _push = router.push.bind(router)
|
||||
const _push = router.push.bind(router);
|
||||
router.push = async (loc) => {
|
||||
console.log('[router.push]', loc)
|
||||
console.trace('[push caller]')
|
||||
console.log('[router.push]', loc);
|
||||
console.trace('[push caller]');
|
||||
|
||||
const res = await _push(loc)
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
const _replace = router.replace.bind(router)
|
||||
const _replace = router.replace.bind(router);
|
||||
router.replace = async (loc) => {
|
||||
console.log('[router.replace]', loc)
|
||||
console.trace('[replace caller]')
|
||||
console.log('[router.replace]', loc);
|
||||
console.trace('[replace caller]');
|
||||
|
||||
const res = await _replace(loc)
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
router.onError((e) => console.error('[router.onError]', e))
|
||||
router.onError((e) => console.error('[router.onError]', e));
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
console.log('[beforeEach]', from.fullPath, '->', to.fullPath)
|
||||
return true
|
||||
})
|
||||
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)
|
||||
})
|
||||
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)
|
||||
applyGuards(router);
|
||||
|
||||
export default router
|
||||
export default router;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// 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
|
||||
23
src/router/routes.account.js
Normal file
23
src/router/routes.account.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import AppLayout from '@/layout/AppLayout.vue'
|
||||
|
||||
export default {
|
||||
path: '/account',
|
||||
component: AppLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: { name: 'account-profile' }
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'account-profile',
|
||||
component: () => import('@/views/pages/account/ProfilePage.vue')
|
||||
},
|
||||
{
|
||||
path: 'security',
|
||||
name: 'account-security',
|
||||
component: () => import('@/views/pages/auth/SecurityPage.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,78 +9,165 @@ export default {
|
||||
// 🔐 Tudo aqui dentro exige login
|
||||
requiresAuth: true,
|
||||
|
||||
// 👤 Perfil de acesso
|
||||
role: 'tenant_admin'
|
||||
// 👤 Perfil de acesso (tenant-level)
|
||||
// tenantStore normaliza tenant_admin -> clinic_admin, mas mantemos compatibilidade
|
||||
roles: ['clinic_admin', 'tenant_admin']
|
||||
},
|
||||
|
||||
children: [
|
||||
// DASHBOARD
|
||||
// ======================================================
|
||||
// 📊 DASHBOARD
|
||||
// ======================================================
|
||||
{
|
||||
path: '',
|
||||
name: 'admin-dashboard',
|
||||
component: () => import('@/views/pages/admin/AdminDashboard.vue')
|
||||
},
|
||||
|
||||
// PACIENTES - LISTA
|
||||
// ======================================================
|
||||
// 🧩 CLÍNICA — MÓDULOS (tenant_features)
|
||||
// ======================================================
|
||||
{
|
||||
path: 'clinic/features',
|
||||
name: 'admin-clinic-features',
|
||||
component: () => import('@/views/pages/admin/clinic/ClinicFeaturesPage.vue'),
|
||||
meta: {
|
||||
// opcional: restringir apenas para admin canônico
|
||||
roles: ['clinic_admin', 'tenant_admin']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'clinic/professionals',
|
||||
name: 'admin-clinic-professionals',
|
||||
component: () => import('@/views/pages/admin/clinic/ClinicProfessionalsPage.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
roles: ['clinic_admin', 'tenant_admin']
|
||||
}
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 📅 MINHA AGENDA
|
||||
// ======================================================
|
||||
|
||||
// 🔎 Visão geral da agenda
|
||||
{
|
||||
path: 'agenda',
|
||||
name: 'admin-agenda',
|
||||
component: () => import('@/views/pages/admin/agenda/MyAppointmentsPage.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.view'
|
||||
}
|
||||
},
|
||||
|
||||
// ➕ Adicionar novo compromisso
|
||||
{
|
||||
path: 'agenda/adicionar',
|
||||
name: 'admin-agenda-adicionar',
|
||||
component: () => import('@/views/pages/admin/agenda/NewAppointmentPage.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.manage'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: 'agenda/clinica',
|
||||
name: 'admin-agenda-clinica',
|
||||
component: () => import('@/features/agenda/pages/AgendaClinicaPage.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.view',
|
||||
roles: ['clinic_admin', 'tenant_admin']
|
||||
}
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 👥 PACIENTES (módulo ativável por clínica)
|
||||
// ======================================================
|
||||
|
||||
// 📋 Lista de pacientes
|
||||
{
|
||||
path: 'pacientes',
|
||||
name: 'admin-pacientes',
|
||||
component: () => import('@/views/pages/admin/pacientes/PatientsIndexPage.vue')
|
||||
component: () => import('@/features/patients/PatientsListPage.vue'),
|
||||
meta: {
|
||||
// ✅ depende do tenant_features.patients
|
||||
tenantFeature: 'patients'
|
||||
}
|
||||
},
|
||||
|
||||
// PACIENTES - CADASTRO (NOVO / EDITAR)
|
||||
// ➕ Cadastro de paciente (novo)
|
||||
{
|
||||
path: 'pacientes/cadastro',
|
||||
name: 'admin-pacientes-cadastro',
|
||||
component: () => import('@/views/pages/admin/pacientes/cadastro/PatientsCadastroPage.vue')
|
||||
component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue'),
|
||||
meta: {
|
||||
tenantFeature: 'patients'
|
||||
}
|
||||
},
|
||||
|
||||
// ✏️ Editar paciente
|
||||
{
|
||||
path: 'pacientes/cadastro/:id',
|
||||
name: 'admin-pacientes-cadastro-edit',
|
||||
component: () => import('@/views/pages/admin/pacientes/cadastro/PatientsCadastroPage.vue'),
|
||||
props: true
|
||||
component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
tenantFeature: 'patients'
|
||||
}
|
||||
},
|
||||
|
||||
// GRUPOS DE PACIENTES ✅
|
||||
// 👥 Grupos de pacientes
|
||||
{
|
||||
path: 'pacientes/grupos',
|
||||
name: 'admin-pacientes-grupos',
|
||||
component: () => import('@/views/pages/admin/pacientes/grupos/GruposPacientesPage.vue')
|
||||
component: () => import('@/features/patients/grupos/GruposPacientesPage.vue'),
|
||||
meta: {
|
||||
tenantFeature: 'patients'
|
||||
}
|
||||
},
|
||||
|
||||
// TAGS
|
||||
// 🏷️ Tags de pacientes
|
||||
{
|
||||
path: 'pacientes/tags',
|
||||
name: 'admin-pacientes-tags',
|
||||
component: () => import('@/views/pages/admin/pacientes/tags/TagsPage.vue')
|
||||
component: () => import('@/features/patients/tags/TagsPage.vue'),
|
||||
meta: {
|
||||
tenantFeature: 'patients'
|
||||
}
|
||||
},
|
||||
|
||||
// LINK EXTERNO
|
||||
// 🔗 Link externo para cadastro
|
||||
{
|
||||
path: 'pacientes/link-externo',
|
||||
name: 'admin.pacientes.linkexterno',
|
||||
component: () => import('@/views/pages/admin/pacientes/cadastro/PatientsExternalLinkPage.vue')
|
||||
name: 'admin-pacientes-link-externo',
|
||||
component: () => import('@/features/patients/cadastro/PatientsExternalLinkPage.vue'),
|
||||
meta: {
|
||||
tenantFeature: 'patients'
|
||||
}
|
||||
},
|
||||
|
||||
// CADASTROS RECEBIDOS
|
||||
// 📥 Cadastros recebidos via link externo
|
||||
{
|
||||
path: 'pacientes/cadastro/recebidos',
|
||||
name: 'admin.pacientes.recebidos',
|
||||
component: () => import('@/views/pages/admin/pacientes/cadastro/recebidos/CadastrosRecebidosPage.vue')
|
||||
name: 'admin-pacientes-recebidos',
|
||||
component: () => import('@/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue'),
|
||||
meta: {
|
||||
tenantFeature: 'patients'
|
||||
}
|
||||
},
|
||||
|
||||
// SEGURANÇA
|
||||
// ======================================================
|
||||
// 🔐 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',
|
||||
@@ -90,4 +177,4 @@ export default {
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/router/router.configuracoes.js
|
||||
// src/router/router.configs.js
|
||||
import AppLayout from '@/layout/AppLayout.vue'
|
||||
|
||||
const configuracoesRoutes = {
|
||||
26
src/router/routes.features.js
Normal file
26
src/router/routes.features.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// src/router/routes.features.js
|
||||
import AppLayout from '@/layout/AppLayout.vue'
|
||||
|
||||
export default {
|
||||
path: '/features',
|
||||
component: AppLayout,
|
||||
meta: { requiresAuth: true }, // roles: se você quiser travar aqui também
|
||||
children: [
|
||||
// Patients
|
||||
{
|
||||
path: 'patients',
|
||||
name: 'features.patients.list',
|
||||
component: () => import('@/features/patients/PatientsListPage.vue') // ajuste se seu arquivo tiver outro nome
|
||||
},
|
||||
{
|
||||
path: 'patients/cadastro',
|
||||
name: 'features.patients.create',
|
||||
component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'patients/cadastro/:id',
|
||||
name: 'features.patients.edit',
|
||||
component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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')
|
||||
}
|
||||
]
|
||||
}
|
||||
27
src/router/routes.portal.js
Normal file
27
src/router/routes.portal.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// src/router/router.portal.js
|
||||
import AppLayout from '@/layout/AppLayout.vue'
|
||||
|
||||
export default {
|
||||
path: '/portal',
|
||||
component: AppLayout,
|
||||
meta: { requiresAuth: true, roles: ['patient'] },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'portal-dashboard',
|
||||
component: () => import('@/views/pages/portal/PortalDashboard.vue')
|
||||
},
|
||||
|
||||
// ✅ Appointments (era agenda)
|
||||
{
|
||||
path: 'agenda',
|
||||
name: 'portal-agenda',
|
||||
component: () => import('@/views/pages/portal/agenda/MyAppointmentsPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'agenda/new',
|
||||
name: 'portal-agenda-new',
|
||||
component: () => import('@/views/pages/portal/agenda/NewAppointmentPage.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -21,6 +21,13 @@ export default {
|
||||
name: 'public.patient.intake',
|
||||
component: () => import('@/views/pages/public/CadastroPacienteExterno.vue'),
|
||||
meta: { public: true }
|
||||
}
|
||||
},
|
||||
// ✅ convite de clinicas
|
||||
{
|
||||
path: '/accept-invite',
|
||||
name: 'accept-invite',
|
||||
component: () => import('@/views/pages/public/AcceptInvitePage.vue'),
|
||||
meta: { public: true }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/router/routes.therapist.js
|
||||
import AppLayout from '@/layout/AppLayout.vue'
|
||||
|
||||
export default {
|
||||
@@ -8,34 +9,99 @@ export default {
|
||||
// 🔐 Tudo aqui dentro exige login
|
||||
requiresAuth: true,
|
||||
|
||||
// 👤 Perfil de acesso (seu guard atual usa meta.role)
|
||||
role: 'therapist'
|
||||
// 👤 Perfil de acesso (tenant-level)
|
||||
roles: ['therapist']
|
||||
},
|
||||
|
||||
children: [
|
||||
// ======================
|
||||
// ✅ Dashboard Therapist
|
||||
// ======================
|
||||
// ======================================================
|
||||
// 📊 DASHBOARD
|
||||
// ======================================================
|
||||
{
|
||||
path: '',
|
||||
name: 'therapist-dashboard',
|
||||
component: () => import('@/views/pages/therapist/TherapistDashboard.vue')
|
||||
// herda requiresAuth + role do pai
|
||||
// herda requiresAuth + roles do pai
|
||||
},
|
||||
|
||||
// ======================
|
||||
// ✅ Segurança
|
||||
// ======================
|
||||
// ======================================================
|
||||
// 📅 AGENDA
|
||||
// ======================================================
|
||||
{
|
||||
path: 'settings/security',
|
||||
name: 'therapist-settings-security',
|
||||
component: () => import('@/views/pages/auth/SecurityPage.vue')
|
||||
// herda requiresAuth + role do pai
|
||||
path: 'agenda',
|
||||
name: 'therapist-agenda',
|
||||
//component: () => import('@/views/pages/therapist/agenda/MyAppointmentsPage.vue'),
|
||||
component: () => import('@/features/agenda/pages/AgendaTerapeutaPage.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.view'
|
||||
}
|
||||
},
|
||||
|
||||
// ==========================================
|
||||
{
|
||||
path: 'agenda/adicionar',
|
||||
name: 'therapist-agenda-adicionar',
|
||||
component: () => import('@/views/pages/therapist/agenda/NewAppointmentPage.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.manage'
|
||||
}
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 👥 PATIENTS
|
||||
// ======================================================
|
||||
|
||||
{
|
||||
path: 'patients',
|
||||
name: 'therapist-patients',
|
||||
component: () => import('@/features/patients/PatientsListPage.vue')
|
||||
},
|
||||
|
||||
// ➕ Create patient
|
||||
{
|
||||
path: 'patients/cadastro',
|
||||
name: 'therapist-patients-create',
|
||||
component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue')
|
||||
},
|
||||
|
||||
{
|
||||
path: 'patients/cadastro/:id',
|
||||
name: 'therapist-patients-edit',
|
||||
component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue'),
|
||||
props: true
|
||||
},
|
||||
|
||||
// 👥 Groups
|
||||
{
|
||||
path: 'patients/grupos',
|
||||
name: 'therapist-patients-groups',
|
||||
component: () => import('@/features/patients/grupos/GruposPacientesPage.vue')
|
||||
},
|
||||
|
||||
// 🏷️ Tags
|
||||
{
|
||||
path: 'patients/tags',
|
||||
name: 'therapist-patients-tags',
|
||||
component: () => import('@/features/patients/tags/TagsPage.vue')
|
||||
},
|
||||
|
||||
// 🔗 External Link
|
||||
{
|
||||
path: 'patients/link-externo',
|
||||
name: 'therapist-patients-link-externo',
|
||||
component: () => import('@/features/patients/cadastro/PatientsExternalLinkPage.vue')
|
||||
},
|
||||
|
||||
// 📥 Received Registrations
|
||||
{
|
||||
path: 'patients/cadastro/recebidos',
|
||||
name: 'therapist-patients-recebidos',
|
||||
component: () =>
|
||||
import('@/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🔒 PRO — Online Scheduling (gestão interna)
|
||||
// ==========================================
|
||||
// ======================================================
|
||||
// feature gate via meta.feature:
|
||||
// - bloqueia rota (guard)
|
||||
// - menu pode desabilitar/ocultar (entitlementsStore.has)
|
||||
@@ -44,23 +110,23 @@ export default {
|
||||
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.
|
||||
// ======================================================
|
||||
// 🔐 SECURITY (temporário dentro da área)
|
||||
// ======================================================
|
||||
// ⚠️ Idealmente mover para /account/security (área global)
|
||||
{
|
||||
path: 'settings/security',
|
||||
name: 'therapist-settings-security',
|
||||
component: () => import('@/views/pages/auth/SecurityPage.vue')
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// 🔒 PRO — Online Scheduling (configuração pública)
|
||||
// ======================================================
|
||||
// {
|
||||
// path: 'online-scheduling/public',
|
||||
// name: 'therapist-online-scheduling-public',
|
||||
@@ -68,4 +134,4 @@ export default {
|
||||
// meta: { feature: 'online_scheduling.public' }
|
||||
// }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
-- =========================================================
|
||||
-- pacientesIndexPage.sql
|
||||
-- Views + índices para a tela PatientsIndexPage
|
||||
-- Views + índices para a tela PatientsListPage
|
||||
-- =========================================================
|
||||
|
||||
-- 0) Extensões úteis
|
||||
|
||||
69
src/stores/tenantFeaturesStore.js
Normal file
69
src/stores/tenantFeaturesStore.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
|
||||
const loading = ref(false)
|
||||
const loadedForTenantId = ref(null)
|
||||
const features = ref({}) // { patients: true/false, ... }
|
||||
|
||||
function isEnabled(key) {
|
||||
return !!features.value?.[key]
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
loadedForTenantId.value = null
|
||||
features.value = {}
|
||||
}
|
||||
|
||||
async function fetchForTenant(tenantId, { force = false } = {}) {
|
||||
if (!tenantId) return
|
||||
if (!force && loadedForTenantId.value === tenantId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_features')
|
||||
.select('feature_key, enabled')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const map = {}
|
||||
for (const row of data || []) map[row.feature_key] = !!row.enabled
|
||||
|
||||
features.value = map
|
||||
loadedForTenantId.value = tenantId
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setForTenant(tenantId, key, enabled) {
|
||||
if (!tenantId) throw new Error('tenantId missing')
|
||||
|
||||
const { error } = await supabase
|
||||
.from('tenant_features')
|
||||
.upsert(
|
||||
{ tenant_id: tenantId, feature_key: key, enabled: !!enabled },
|
||||
{ onConflict: 'tenant_id,feature_key' }
|
||||
)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// atualiza cache local
|
||||
if (loadedForTenantId.value === tenantId) {
|
||||
features.value = { ...features.value, [key]: !!enabled }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
features,
|
||||
loadedForTenantId,
|
||||
isEnabled,
|
||||
invalidate,
|
||||
fetchForTenant,
|
||||
setForTenant
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,21 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
// ✅ normaliza roles vindas do backend (tenant_members / RPC my_tenants)
|
||||
// - seu projeto quer usar clinic_admin como nome canônico
|
||||
function normalizeTenantRole (role) {
|
||||
const r = String(role || '').trim()
|
||||
if (!r) return null
|
||||
|
||||
// ✅ legado: alguns bancos / RPCs retornam tenant_admin
|
||||
if (r === 'tenant_admin') return 'clinic_admin'
|
||||
|
||||
// (opcional) se em algum lugar vier 'admin' (profiles), também normaliza:
|
||||
if (r === 'admin') return 'clinic_admin'
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
export const useTenantStore = defineStore('tenant', {
|
||||
state: () => ({
|
||||
loading: false,
|
||||
@@ -11,76 +26,136 @@ export const useTenantStore = defineStore('tenant', {
|
||||
memberships: [], // [{ tenant_id, role, status }]
|
||||
activeTenantId: null,
|
||||
activeRole: null,
|
||||
|
||||
needsTenantLink: false,
|
||||
|
||||
needsTenantLink: false,
|
||||
error: null
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async loadSessionAndTenant () {
|
||||
if (this.loading) return
|
||||
this.loading = true
|
||||
this.error = null
|
||||
if (this.loading) return
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
// 1) auth user (estável)
|
||||
const { data, error } = await supabase.auth.getSession()
|
||||
if (error) throw error
|
||||
try {
|
||||
// 1) auth user (estável)
|
||||
const { data, error } = await supabase.auth.getSession()
|
||||
if (error) throw error
|
||||
|
||||
this.user = data?.session?.user ?? null
|
||||
this.user = data?.session?.user ?? null
|
||||
|
||||
// sem sessão -> não chama RPC, só marca estado
|
||||
if (!this.user) {
|
||||
this.memberships = []
|
||||
this.activeTenantId = null
|
||||
this.activeRole = null
|
||||
this.needsTenantLink = false
|
||||
this.loaded = true
|
||||
return
|
||||
}
|
||||
// sem sessão -> limpa estado e storage
|
||||
if (!this.user) {
|
||||
this.memberships = []
|
||||
this.activeTenantId = null
|
||||
this.activeRole = null
|
||||
this.needsTenantLink = false
|
||||
this.loaded = true
|
||||
|
||||
// 2) memberships via RPC
|
||||
const { data: mem, error: mErr } = await supabase.rpc('my_tenants')
|
||||
if (mErr) throw mErr
|
||||
localStorage.removeItem('tenant_id')
|
||||
localStorage.removeItem('tenant')
|
||||
|
||||
this.memberships = Array.isArray(mem) ? mem : []
|
||||
return
|
||||
}
|
||||
|
||||
// 3) define active tenant (primeiro active)
|
||||
const firstActive = this.memberships.find(x => x.status === 'active')
|
||||
this.activeTenantId = firstActive?.tenant_id ?? null
|
||||
this.activeRole = firstActive?.role ?? null
|
||||
// 2) memberships via RPC
|
||||
const { data: mem, error: mErr } = await supabase.rpc('my_tenants')
|
||||
if (mErr) throw mErr
|
||||
|
||||
// se logou mas não tem vínculo ativo
|
||||
this.needsTenantLink = !this.activeTenantId
|
||||
this.memberships = Array.isArray(mem) ? mem : []
|
||||
|
||||
this.loaded = true
|
||||
} catch (e) {
|
||||
console.warn('[tenantStore] loadSessionAndTenant falhou:', e)
|
||||
this.error = e
|
||||
// 3) tenta restaurar tenant salvo
|
||||
const savedTenantId = localStorage.getItem('tenant_id')
|
||||
|
||||
// ⚠️ NÃO zera tudo agressivamente por erro transitório.
|
||||
// Mantém o que já tinha (se tiver), mas marca loaded pra não travar o app.
|
||||
// Se você preferir ser mais “duro”, só zere quando não houver sessão:
|
||||
// (a sessão já foi lida acima; se der erro antes, user pode estar null)
|
||||
if (!this.user) {
|
||||
this.memberships = []
|
||||
this.activeTenantId = null
|
||||
this.activeRole = null
|
||||
this.needsTenantLink = false
|
||||
}
|
||||
let activeMembership = null
|
||||
|
||||
this.loaded = true
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
,
|
||||
if (savedTenantId) {
|
||||
activeMembership = this.memberships.find(
|
||||
x => x.tenant_id === savedTenantId && x.status === 'active'
|
||||
)
|
||||
}
|
||||
|
||||
// fallback: primeiro active
|
||||
if (!activeMembership) {
|
||||
activeMembership = this.memberships.find(x => x.status === 'active')
|
||||
}
|
||||
|
||||
this.activeTenantId = activeMembership?.tenant_id ?? null
|
||||
|
||||
// ✅ normaliza role aqui (tenant_admin -> clinic_admin)
|
||||
this.activeRole = normalizeTenantRole(activeMembership?.role)
|
||||
|
||||
// persiste tenant se existir
|
||||
if (this.activeTenantId) {
|
||||
localStorage.setItem('tenant_id', this.activeTenantId)
|
||||
localStorage.setItem('tenant', JSON.stringify({
|
||||
id: this.activeTenantId,
|
||||
role: this.activeRole
|
||||
}))
|
||||
}
|
||||
|
||||
// se logou mas não tem vínculo ativo
|
||||
this.needsTenantLink = !this.activeTenantId
|
||||
|
||||
this.loaded = true
|
||||
} catch (e) {
|
||||
console.warn('[tenantStore] loadSessionAndTenant falhou:', e)
|
||||
this.error = e
|
||||
|
||||
// ⚠️ NÃO zera tudo agressivamente por erro transitório.
|
||||
// Mantém o que já tinha (se tiver), mas marca loaded pra não travar o app.
|
||||
if (!this.user) {
|
||||
this.memberships = []
|
||||
this.activeTenantId = null
|
||||
this.activeRole = null
|
||||
this.needsTenantLink = false
|
||||
|
||||
localStorage.removeItem('tenant_id')
|
||||
localStorage.removeItem('tenant')
|
||||
}
|
||||
|
||||
this.loaded = true
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
setActiveTenant (tenantId) {
|
||||
const found = this.memberships.find(x => x.tenant_id === tenantId && x.status === 'active')
|
||||
this.activeTenantId = found?.tenant_id ?? null
|
||||
this.activeRole = found?.role ?? null
|
||||
this.needsTenantLink = !this.activeTenantId
|
||||
}
|
||||
const found = this.memberships.find(
|
||||
x => x.tenant_id === tenantId && x.status === 'active'
|
||||
)
|
||||
|
||||
this.activeTenantId = found?.tenant_id ?? null
|
||||
|
||||
// ✅ normaliza role também ao trocar tenant
|
||||
this.activeRole = normalizeTenantRole(found?.role)
|
||||
|
||||
this.needsTenantLink = !this.activeTenantId
|
||||
|
||||
if (this.activeTenantId) {
|
||||
localStorage.setItem('tenant_id', this.activeTenantId)
|
||||
localStorage.setItem('tenant', JSON.stringify({
|
||||
id: this.activeTenantId,
|
||||
role: this.activeRole
|
||||
}))
|
||||
} else {
|
||||
localStorage.removeItem('tenant_id')
|
||||
localStorage.removeItem('tenant')
|
||||
}
|
||||
},
|
||||
|
||||
// opcional mas recomendado
|
||||
reset () {
|
||||
this.user = null
|
||||
this.memberships = []
|
||||
this.activeTenantId = null
|
||||
this.activeRole = null
|
||||
this.needsTenantLink = false
|
||||
this.error = null
|
||||
this.loaded = false
|
||||
|
||||
localStorage.removeItem('tenant_id')
|
||||
localStorage.removeItem('tenant')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,68 +2,76 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { supabase } from '../../lib/supabase/client' // ajuste se o caminho for outro
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
const router = useRouter()
|
||||
const tenant = useTenantStore()
|
||||
|
||||
const checking = ref(true)
|
||||
const userEmail = ref('')
|
||||
const role = ref(null)
|
||||
const role = ref(null) // aqui vai guardar o role REAL do tenant: clinic_admin/therapist/patient
|
||||
|
||||
const TEST_ACCOUNTS = {
|
||||
admin: { email: 'admin@agenciapsi.com.br', password: '123Mudar@' },
|
||||
clinic_admin: { email: 'clinic@agenciapsi.com.br', password: '123Mudar@' },
|
||||
therapist: { email: 'therapist@agenciapsi.com.br', password: '123Mudar@' },
|
||||
patient: { email: 'patient@agenciapsi.com.br', password: '123Mudar@' }
|
||||
patient: { email: 'patient@agenciapsi.com.br', password: '123Mudar@' },
|
||||
saas: { email: 'saas@agenciapsi.com.br', password: '123Mudar@' }
|
||||
}
|
||||
|
||||
|
||||
function roleToPath(r) {
|
||||
if (r === 'admin') return '/admin'
|
||||
function roleToPath (r) {
|
||||
// ✅ role REAL (tenant_members via my_tenants)
|
||||
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return '/admin'
|
||||
if (r === 'therapist') return '/therapist'
|
||||
if (r === 'patient') return '/patient'
|
||||
if (r === 'patient') return '/portal'
|
||||
return '/'
|
||||
}
|
||||
|
||||
async function fetchMyRole() {
|
||||
async function isSaasAdmin () {
|
||||
const { data: userData, error: userErr } = await supabase.auth.getUser()
|
||||
if (userErr) return null
|
||||
if (userErr) return false
|
||||
const user = userData?.user
|
||||
if (!user) return null
|
||||
|
||||
userEmail.value = user.email || ''
|
||||
if (!user?.id) return false
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
.from('saas_admins')
|
||||
.select('user_id')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return null
|
||||
return data?.role || null
|
||||
if (error) return false
|
||||
return !!data
|
||||
}
|
||||
|
||||
async function go(area) {
|
||||
// Se já estiver logado, respeita role real (não o card)
|
||||
// ✅ carrega tenant/role real (my_tenants) e atualiza UI
|
||||
async function syncTenantRole () {
|
||||
await tenant.loadSessionAndTenant()
|
||||
role.value = tenant.activeRole || null
|
||||
return role.value
|
||||
}
|
||||
|
||||
async function go (area) {
|
||||
// Se já estiver logado:
|
||||
const { data: sessionData } = await supabase.auth.getSession()
|
||||
const session = sessionData?.session
|
||||
|
||||
if (session) {
|
||||
const r = role.value || (await fetchMyRole())
|
||||
userEmail.value = session.user?.email || userEmail.value || ''
|
||||
|
||||
// ✅ se for SaaS master, SEMPRE manda pra /saas (independente do card clicado)
|
||||
const saas = await isSaasAdmin()
|
||||
if (saas) return router.push('/saas')
|
||||
|
||||
const r = role.value || (await syncTenantRole())
|
||||
if (!r) return router.push('/auth/login')
|
||||
return router.push(roleToPath(r))
|
||||
}
|
||||
|
||||
// Se não estiver logado, manda pro login guardando a intenção
|
||||
sessionStorage.setItem('intended_area', area) // admin/therapist/patient
|
||||
sessionStorage.setItem('intended_area', area) // clinic_admin/therapist/patient/saas
|
||||
|
||||
// ✅ Prefill de login (apenas DEV)
|
||||
const DEV_PREFILL = import.meta.env.DEV
|
||||
if (DEV_PREFILL) {
|
||||
const TEST_ACCOUNTS = {
|
||||
admin: { email: 'admin@agenciapsi.com.br', password: '123Mudar@' },
|
||||
therapist: { email: 'therapist@agenciapsi.com.br', password: '123Mudar@' },
|
||||
patient: { email: 'patient@agenciapsi.com.br', password: '123Mudar@' }
|
||||
}
|
||||
|
||||
const acc = TEST_ACCOUNTS[area]
|
||||
if (acc) {
|
||||
sessionStorage.setItem('login_prefill_email', acc.email)
|
||||
@@ -77,15 +85,32 @@ async function go(area) {
|
||||
router.push('/auth/login')
|
||||
}
|
||||
|
||||
async function goMyPanel() {
|
||||
async function goMyPanel () {
|
||||
if (!role.value) return
|
||||
|
||||
// ✅ se for SaaS master, sempre /saas
|
||||
const saas = await isSaasAdmin()
|
||||
if (saas) return router.push('/saas')
|
||||
|
||||
router.push(roleToPath(role.value))
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await supabase.auth.signOut()
|
||||
role.value = null
|
||||
userEmail.value = ''
|
||||
async function logout () {
|
||||
try {
|
||||
await supabase.auth.signOut()
|
||||
} finally {
|
||||
role.value = null
|
||||
userEmail.value = ''
|
||||
|
||||
// limpa qualquer intenção pendente
|
||||
sessionStorage.removeItem('redirect_after_login')
|
||||
sessionStorage.removeItem('intended_area')
|
||||
|
||||
// ✅ força redirecionamento para HomeCards (/)
|
||||
router.replace('/')
|
||||
// Use router.replace('/') e não push,
|
||||
// assim o usuário não consegue voltar com o botão "voltar" para uma rota protegida.
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -94,7 +119,17 @@ onMounted(async () => {
|
||||
const session = sessionData?.session
|
||||
|
||||
if (session) {
|
||||
role.value = await fetchMyRole()
|
||||
userEmail.value = session.user?.email || ''
|
||||
|
||||
// ✅ se for SaaS master, manda direto pro SaaS
|
||||
const saas = await isSaasAdmin()
|
||||
if (saas) {
|
||||
router.replace('/saas')
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ role REAL vem do tenantStore (my_tenants)
|
||||
role.value = await syncTenantRole()
|
||||
|
||||
// Se está logado e tem role, manda direto pro painel
|
||||
if (role.value) {
|
||||
@@ -201,15 +236,15 @@ onMounted(async () => {
|
||||
<div class="px-8 pb-10">
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
|
||||
<!-- ADMIN -->
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<!-- CLÍNICA (antigo ADMIN) -->
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div
|
||||
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
|
||||
@click="go('admin')"
|
||||
@click="go('clinic_admin')"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-xl font-semibold text-[var(--text-color)]">
|
||||
Admin
|
||||
Clínica
|
||||
</div>
|
||||
<i class="pi pi-building text-sm opacity-70" />
|
||||
</div>
|
||||
@@ -225,7 +260,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- TERAPEUTA -->
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div
|
||||
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
|
||||
@click="go('therapist')"
|
||||
@@ -248,7 +283,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- PACIENTE -->
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div
|
||||
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
|
||||
@click="go('patient')"
|
||||
@@ -270,7 +305,98 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SAAS MASTER -->
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div
|
||||
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
|
||||
@click="go('saas')"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-xl font-semibold text-[var(--text-color)]">
|
||||
SaaS (Master)
|
||||
</div>
|
||||
<i class="pi pi-shield text-sm opacity-70" />
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Acesso global: planos, assinaturas, tenants e saúde da plataforma.
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition">
|
||||
Acessar painel →
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- DEV – Usuários cadastrados -->
|
||||
<div class="mt-10 w-full rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6">
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-sm font-semibold text-[var(--text-color)]">
|
||||
Usuários do ambiente (Desenvolvimento)
|
||||
</div>
|
||||
<span class="text-xs text-[var(--text-color-secondary)] opacity-70">
|
||||
Identificadores internos
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs md:text-sm">
|
||||
<thead>
|
||||
<tr class="text-left border-b border-[var(--surface-border)]">
|
||||
<th class="py-2 pr-4 font-medium text-[var(--text-color-secondary)]">ID</th>
|
||||
<th class="py-2 font-medium text-[var(--text-color-secondary)]">E-mail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-[var(--text-color)]">
|
||||
|
||||
<tr class="border-b border-[var(--surface-border)]/60">
|
||||
<td class="py-2 pr-4 font-mono opacity-80">
|
||||
40a4b683-a0c9-4890-a201-20faf41fca06
|
||||
</td>
|
||||
<td class="py-2">
|
||||
saas@agenciapsi.com.br
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr class="border-b border-[var(--surface-border)]/60">
|
||||
<td class="py-2 pr-4 font-mono opacity-80">
|
||||
523003e7-17ab-4375-b912-040027a75c22
|
||||
</td>
|
||||
<td class="py-2">
|
||||
patient@agenciapsi.com.br
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr class="border-b border-[var(--surface-border)]/60">
|
||||
<td class="py-2 pr-4 font-mono opacity-80">
|
||||
816b24fe-a0c3-4409-b79b-c6c0a6935d03
|
||||
</td>
|
||||
<td class="py-2">
|
||||
clinic@agenciapsi.com.br
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="py-2 pr-4 font-mono opacity-80">
|
||||
824f125c-55bb-40f5-a8c4-7a33618b91c7
|
||||
</td>
|
||||
<td class="py-2">
|
||||
therapist@agenciapsi.com.br
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-[11px] text-[var(--text-color-secondary)] opacity-70">
|
||||
Estes usuários existem apenas para fins de teste no ambiente de desenvolvimento.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Rodapé explicativo -->
|
||||
<div class="mt-10 text-center text-xs text-[var(--text-color-secondary)] opacity-80">
|
||||
|
||||
9
src/views/pages/admin/agenda/MyAppointmentsPage.vue
Normal file
9
src/views/pages/admin/agenda/MyAppointmentsPage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h1>My Appointments</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// temporary placeholder
|
||||
</script>
|
||||
9
src/views/pages/admin/agenda/NewAppointmentPage.vue
Normal file
9
src/views/pages/admin/agenda/NewAppointmentPage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h1>Add New Appointments</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// temporary placeholder
|
||||
</script>
|
||||
330
src/views/pages/admin/clinic/ClinicFeaturesPage.vue
Normal file
330
src/views/pages/admin/clinic/ClinicFeaturesPage.vue
Normal file
@@ -0,0 +1,330 @@
|
||||
<template>
|
||||
<div class="p-4 md:p-6">
|
||||
<Toast />
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative p-5 md:p-7">
|
||||
<!-- blobs sutis -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-80">
|
||||
<div class="absolute -top-16 -right-20 h-72 w-72 rounded-full bg-indigo-400/10 blur-3xl" />
|
||||
<div class="absolute top-10 -left-24 h-80 w-80 rounded-full bg-emerald-400/10 blur-3xl" />
|
||||
<div class="absolute -bottom-20 right-24 h-72 w-72 rounded-full bg-fuchsia-400/10 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-xl md:text-2xl font-semibold leading-tight">Módulos da Clínica</h1>
|
||||
<p class="mt-1 text-sm opacity-80">
|
||||
Ative/desative recursos por clínica. Isso controla menu, rotas (guard) e acesso no banco (RLS).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<Button
|
||||
label="Recarregar"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
:loading="loading"
|
||||
@click="reload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs opacity-80">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
|
||||
<i class="pi pi-building" />
|
||||
Tenant: <b class="font-mono">{{ tenantId || '—' }}</b>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
|
||||
<i class="pi pi-user" />
|
||||
Role: <b>{{ role || '—' }}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Presets -->
|
||||
<div class="mb-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Card class="rounded-[2rem]">
|
||||
<template #content>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">Preset: Coworking</div>
|
||||
<div class="mt-1 text-xs opacity-80">
|
||||
Para aluguel de salas: sem pacientes, com salas.
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" label="Aplicar" severity="secondary" :loading="applyingPreset" @click="applyPreset('coworking')" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="rounded-[2rem]">
|
||||
<template #content>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">Preset: Clínica com recepção</div>
|
||||
<div class="mt-1 text-xs opacity-80">
|
||||
Para secretária gerenciar agenda (pacientes opcional).
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" label="Aplicar" severity="secondary" :loading="applyingPreset" @click="applyPreset('reception')" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="rounded-[2rem]">
|
||||
<template #content>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">Preset: Clínica completa</div>
|
||||
<div class="mt-1 text-xs opacity-80">
|
||||
Pacientes + recepção + salas (se quiser).
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" label="Aplicar" severity="secondary" :loading="applyingPreset" @click="applyPreset('full')" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Modules -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<Card class="rounded-[2rem]">
|
||||
<template #content>
|
||||
<ModuleRow
|
||||
title="Pacientes"
|
||||
desc="Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist)."
|
||||
icon="pi pi-users"
|
||||
:enabled="isOn('patients')"
|
||||
:loading="savingKey === 'patients'"
|
||||
@toggle="toggle('patients')"
|
||||
/>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-xs opacity-80 leading-relaxed">
|
||||
Quando desligado:
|
||||
<ul class="mt-2 list-disc pl-5 space-y-1">
|
||||
<li>Menu “Pacientes” some.</li>
|
||||
<li>Rotas com <span class="font-mono">meta.tenantFeature = 'patients'</span> redirecionam pra cá.</li>
|
||||
<li>RLS bloqueia acesso direto no banco.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="rounded-[2rem]">
|
||||
<template #content>
|
||||
<ModuleRow
|
||||
title="Recepção / Secretária"
|
||||
desc="Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente)."
|
||||
icon="pi pi-briefcase"
|
||||
:enabled="isOn('shared_reception')"
|
||||
:loading="savingKey === 'shared_reception'"
|
||||
@toggle="toggle('shared_reception')"
|
||||
/>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-xs opacity-80 leading-relaxed">
|
||||
Observação: este módulo é “produto” (UX + permissões). A base aqui é só o toggle.
|
||||
Depois a gente cria:
|
||||
<ul class="mt-2 list-disc pl-5 space-y-1">
|
||||
<li>role <span class="font-mono">secretary</span> em <span class="font-mono">tenant_members</span></li>
|
||||
<li>policies e telas para a secretária</li>
|
||||
<li>nível de visibilidade do paciente na agenda</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="rounded-[2rem]">
|
||||
<template #content>
|
||||
<ModuleRow
|
||||
title="Salas / Coworking"
|
||||
desc="Habilita cadastro e reserva de salas/recursos no agendamento."
|
||||
icon="pi pi-building"
|
||||
:enabled="isOn('rooms')"
|
||||
:loading="savingKey === 'rooms'"
|
||||
@toggle="toggle('rooms')"
|
||||
/>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-xs opacity-80 leading-relaxed">
|
||||
Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="rounded-[2rem]">
|
||||
<template #content>
|
||||
<ModuleRow
|
||||
title="Link externo de cadastro"
|
||||
desc="Libera fluxo público de intake/cadastro externo para a clínica."
|
||||
icon="pi pi-link"
|
||||
:enabled="isOn('intake_public')"
|
||||
:loading="savingKey === 'intake_public'"
|
||||
@toggle="toggle('intake_public')"
|
||||
/>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-xs opacity-80 leading-relaxed">
|
||||
Você já tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import ToggleButton from 'primevue/togglebutton'
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
const tf = useTenantFeaturesStore()
|
||||
|
||||
const loading = computed(() => tf.loading)
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || null)
|
||||
const role = computed(() => tenantStore.activeRole || null)
|
||||
|
||||
const savingKey = ref(null)
|
||||
const applyingPreset = ref(false)
|
||||
|
||||
function isOn (key) {
|
||||
return tf.isEnabled(key)
|
||||
}
|
||||
|
||||
async function reload () {
|
||||
if (!tenantId.value) return
|
||||
await tf.fetchForTenant(tenantId.value, { force: true })
|
||||
}
|
||||
|
||||
async function toggle (key) {
|
||||
if (!tenantId.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Sem tenant ativo', detail: 'Selecione/ative um tenant primeiro.', life: 2500 })
|
||||
return
|
||||
}
|
||||
|
||||
savingKey.value = key
|
||||
try {
|
||||
const next = !tf.isEnabled(key)
|
||||
await tf.setForTenant(tenantId.value, key, next)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Atualizado',
|
||||
detail: `${labelOf(key)}: ${next ? 'Ativado' : 'Desativado'}`,
|
||||
life: 2500
|
||||
})
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao atualizar módulo', life: 3500 })
|
||||
} finally {
|
||||
savingKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function labelOf (key) {
|
||||
if (key === 'patients') return 'Pacientes'
|
||||
if (key === 'shared_reception') return 'Recepção / Secretária'
|
||||
if (key === 'rooms') return 'Salas / Coworking'
|
||||
if (key === 'intake_public') return 'Link externo de cadastro'
|
||||
return key
|
||||
}
|
||||
|
||||
async function applyPreset (preset) {
|
||||
if (!tenantId.value) return
|
||||
applyingPreset.value = true
|
||||
try {
|
||||
// Presets = sets mínimos (nada destrói dados; só liga/desliga acesso/UX)
|
||||
const map = {
|
||||
coworking: {
|
||||
patients: false,
|
||||
shared_reception: false,
|
||||
rooms: true,
|
||||
intake_public: false
|
||||
},
|
||||
reception: {
|
||||
patients: false,
|
||||
shared_reception: true,
|
||||
rooms: false,
|
||||
intake_public: false
|
||||
},
|
||||
full: {
|
||||
patients: true,
|
||||
shared_reception: true,
|
||||
rooms: true,
|
||||
intake_public: true
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = map[preset]
|
||||
if (!cfg) return
|
||||
|
||||
// aplica sequencialmente (simples e previsível)
|
||||
for (const [k, v] of Object.entries(cfg)) {
|
||||
await tf.setForTenant(tenantId.value, k, v)
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Preset aplicado', detail: 'Configuração atualizada.', life: 2500 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao aplicar preset', life: 3500 })
|
||||
} finally {
|
||||
applyingPreset.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!tenantStore.loaded && !tenantStore.loading) {
|
||||
await tenantStore.loadSessionAndTenant()
|
||||
}
|
||||
if (tenantId.value) {
|
||||
await tf.fetchForTenant(tenantId.value, { force: false })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Sub-componente local: linha de módulo
|
||||
* (mantive aqui pra você visualizar rápido sem criar pasta de components)
|
||||
*/
|
||||
const ModuleRow = {
|
||||
props: {
|
||||
title: String,
|
||||
desc: String,
|
||||
icon: String,
|
||||
enabled: Boolean,
|
||||
loading: Boolean
|
||||
},
|
||||
emits: ['toggle'],
|
||||
components: { ToggleButton },
|
||||
template: `
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<i :class="icon" class="opacity-80" />
|
||||
<div class="text-base font-semibold">{{ title }}</div>
|
||||
</div>
|
||||
<div class="mt-1 text-sm opacity-80">{{ desc }}</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<ToggleButton
|
||||
:modelValue="enabled"
|
||||
onLabel="Ativo"
|
||||
offLabel="Inativo"
|
||||
:loading="loading"
|
||||
@update:modelValue="$emit('toggle')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</script>
|
||||
1113
src/views/pages/admin/clinic/ClinicProfessionalsPage.vue
Normal file
1113
src/views/pages/admin/clinic/ClinicProfessionalsPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,9 @@ import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
// ✅ sessão (fonte de verdade p/ saas admin)
|
||||
import { initSession, sessionIsSaasAdmin } from '@/app/session'
|
||||
|
||||
const tenant = useTenantStore()
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
@@ -39,9 +42,10 @@ function isEmail (v) {
|
||||
}
|
||||
|
||||
function roleToPath (role) {
|
||||
if (role === 'tenant_admin') return '/admin'
|
||||
// ✅ aceita os dois nomes (seu banco está devolvendo tenant_admin)
|
||||
if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin'
|
||||
if (role === 'therapist') return '/therapist'
|
||||
if (role === 'patient') return '/patient'
|
||||
if (role === 'patient') return '/portal'
|
||||
return '/'
|
||||
}
|
||||
|
||||
@@ -69,6 +73,31 @@ async function onSubmit () {
|
||||
|
||||
if (res.error) throw res.error
|
||||
|
||||
// ✅ garante que sessionIsSaasAdmin esteja hidratado após login
|
||||
// (evita cair no fluxo de tenant quando o usuário é SaaS master)
|
||||
try {
|
||||
await initSession({ initial: false })
|
||||
} catch (e) {
|
||||
console.warn('[Login] initSession pós-login falhou:', e)
|
||||
// não aborta login por isso
|
||||
}
|
||||
|
||||
// lembrar e-mail (não senha)
|
||||
persistRememberedEmail()
|
||||
|
||||
// ✅ prioridade: redirect_after_login (se existir)
|
||||
// mas antes, se for SaaS admin, NÃO exigir tenant.
|
||||
const redirect = sessionStorage.getItem('redirect_after_login')
|
||||
if (sessionIsSaasAdmin.value) {
|
||||
if (redirect) {
|
||||
sessionStorage.removeItem('redirect_after_login')
|
||||
router.push(redirect)
|
||||
return
|
||||
}
|
||||
router.push('/saas')
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ agora que está autenticado, garante tenant pessoal (Modelo B)
|
||||
try {
|
||||
await supabase.rpc('ensure_personal_tenant')
|
||||
@@ -91,10 +120,7 @@ async function onSubmit () {
|
||||
return
|
||||
}
|
||||
|
||||
// lembrar e-mail (não senha)
|
||||
persistRememberedEmail()
|
||||
|
||||
const redirect = sessionStorage.getItem('redirect_after_login')
|
||||
// ✅ se havia redirect, vai pra ele
|
||||
if (redirect) {
|
||||
sessionStorage.removeItem('redirect_after_login')
|
||||
router.push(redirect)
|
||||
@@ -119,7 +145,6 @@ async function onSubmit () {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function openForgot () {
|
||||
recoverySent.value = false
|
||||
recoveryEmail.value = email.value?.trim() || ''
|
||||
@@ -214,33 +239,33 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs font-medium text-[var(--text-color-secondary)] hover:opacity-80"
|
||||
title="Atalho para a página de logins de desenvolvimento"
|
||||
>
|
||||
<i class="pi pi-code text-xs opacity-80" />
|
||||
Desenvolvedor Logins
|
||||
</RouterLink>
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs font-medium text-[var(--text-color-secondary)] hover:opacity-80"
|
||||
title="Atalho para a página de logins de desenvolvimento"
|
||||
>
|
||||
<i class="pi pi-code text-xs opacity-80" />
|
||||
Desenvolvedor Logins
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
:to="{ name: 'resetPassword' }"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs font-medium text-[var(--text-color-secondary)] hover:opacity-80"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400/70" />
|
||||
Trocar senha
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col-span-12 md:hidden">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-sm font-medium text-[var(--text-color-secondary)] hover:opacity-80"
|
||||
>
|
||||
<i class="pi pi-code opacity-80" />
|
||||
Desenvolvedor Logins
|
||||
</RouterLink>
|
||||
</div>
|
||||
<RouterLink
|
||||
:to="{ name: 'resetPassword' }"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs font-medium text-[var(--text-color-secondary)] hover:opacity-80"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400/70" />
|
||||
Trocar senha
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col-span-12 md:hidden">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-sm font-medium text-[var(--text-color-secondary)] hover:opacity-80"
|
||||
>
|
||||
<i class="pi pi-code opacity-80" />
|
||||
Desenvolvedor Logins
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,4 +475,4 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -70,13 +70,16 @@ const enabledFeatureIdsByPlanId = computed(() => {
|
||||
|
||||
const currentPlanId = computed(() => subscription.value?.plan_id || null)
|
||||
|
||||
function planKeyById(id) {
|
||||
function planKeyById (id) {
|
||||
return planById.value.get(id)?.key || null
|
||||
}
|
||||
|
||||
const currentPlanKey = computed(() => planKeyById(currentPlanId.value))
|
||||
const currentPlanKey = computed(() => {
|
||||
// ✅ fallback: se não carregou plans ainda, usa o plan_key da subscription
|
||||
return planKeyById(currentPlanId.value) || subscription.value?.plan_key || null
|
||||
})
|
||||
|
||||
function friendlyFeatureLabel(featureKey) {
|
||||
function friendlyFeatureLabel (featureKey) {
|
||||
return featureLabels[featureKey] || featureKey
|
||||
}
|
||||
|
||||
@@ -92,7 +95,7 @@ const sortedPlans = computed(() => {
|
||||
return arr
|
||||
})
|
||||
|
||||
function planBenefits(planId) {
|
||||
function planBenefits (planId) {
|
||||
const set = enabledFeatureIdsByPlanId.value.get(planId) || new Set()
|
||||
const list = features.value.map((f) => ({
|
||||
ok: set.has(f.id),
|
||||
@@ -104,34 +107,82 @@ function planBenefits(planId) {
|
||||
return list
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
function goBack () {
|
||||
router.back()
|
||||
}
|
||||
|
||||
function goBilling() {
|
||||
function goBilling () {
|
||||
router.push('/admin/billing')
|
||||
}
|
||||
|
||||
function contactSupport() {
|
||||
function contactSupport () {
|
||||
router.push('/admin/billing')
|
||||
}
|
||||
|
||||
async function fetchAll() {
|
||||
// ✅ revalida a rota atual para o guard reavaliar features após troca de plano
|
||||
async function revalidateCurrentRoute () {
|
||||
// tenta respeitar um redirectTo (quando usuário veio por recurso bloqueado)
|
||||
const redirectTo = route.query.redirectTo ? String(route.query.redirectTo) : null
|
||||
|
||||
// se existe redirectTo, tente ir para ele (guard decide se entra ou volta ao upgrade)
|
||||
if (redirectTo) {
|
||||
try {
|
||||
await router.replace(redirectTo)
|
||||
return
|
||||
} catch (_) {
|
||||
// se falhar, cai no refresh da rota atual
|
||||
}
|
||||
}
|
||||
|
||||
// força o vue-router a reprocessar a rota (dispara beforeEach)
|
||||
try {
|
||||
await router.replace(router.currentRoute.value.fullPath)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function fetchAll () {
|
||||
loading.value = true
|
||||
try {
|
||||
const tid = tenantId.value
|
||||
if (!tid) throw new Error('Tenant ativo não encontrado.')
|
||||
|
||||
const [pRes, fRes, pfRes, sRes] = await Promise.all([
|
||||
supabase.from('plans').select('*').order('key', { ascending: true }),
|
||||
supabase.from('plans').select('*').eq('is_active', true).order('key', { ascending: true }),
|
||||
supabase.from('features').select('*').order('key', { ascending: true }),
|
||||
supabase.from('plan_features').select('plan_id, feature_id'),
|
||||
supabase
|
||||
.from('subscriptions')
|
||||
.select('id, tenant_id, plan_id, plan_key, interval, status, created_at, updated_at')
|
||||
// ✅ pega mais campos úteis e faz join do plano (ajuda a exibir e debugar)
|
||||
.select(`
|
||||
id,
|
||||
tenant_id,
|
||||
user_id,
|
||||
plan_id,
|
||||
plan_key,
|
||||
"interval",
|
||||
status,
|
||||
provider,
|
||||
source,
|
||||
started_at,
|
||||
current_period_start,
|
||||
current_period_end,
|
||||
created_at,
|
||||
updated_at,
|
||||
plan:plan_id (
|
||||
id,
|
||||
key,
|
||||
name,
|
||||
description,
|
||||
price_cents,
|
||||
currency,
|
||||
billing_interval,
|
||||
is_active
|
||||
)
|
||||
`)
|
||||
.eq('tenant_id', tid)
|
||||
.eq('status', 'active')
|
||||
.order('updated_at', { ascending: false })
|
||||
// ✅ created_at é mais confiável que updated_at em assinaturas manuais
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
])
|
||||
@@ -142,13 +193,20 @@ async function fetchAll() {
|
||||
|
||||
// ✅ subscription pode ser null sem quebrar a página
|
||||
if (sRes.error) {
|
||||
console.warn('[Upgrade] sem subscription ativa (ok):', sRes.error)
|
||||
console.warn('[Upgrade] erro ao buscar subscription:', sRes.error)
|
||||
}
|
||||
|
||||
plans.value = pRes.data || []
|
||||
features.value = fRes.data || []
|
||||
planFeatures.value = pfRes.data || []
|
||||
subscription.value = sRes.data || null
|
||||
|
||||
// pode remover esses logs depois
|
||||
console.groupCollapsed('[Upgrade] fetchAll')
|
||||
console.log('tenantId:', tid)
|
||||
console.log('subscription:', subscription.value)
|
||||
console.log('currentPlanKey:', currentPlanKey.value)
|
||||
console.groupEnd()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
|
||||
@@ -157,7 +215,7 @@ async function fetchAll() {
|
||||
}
|
||||
}
|
||||
|
||||
async function changePlan(targetPlanId) {
|
||||
async function changePlan (targetPlanId) {
|
||||
if (!subscription.value?.id) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
@@ -187,17 +245,32 @@ async function changePlan(targetPlanId) {
|
||||
|
||||
// atualiza estado local
|
||||
subscription.value.plan_id = data?.plan_id || targetPlanId
|
||||
subscription.value.plan_key = data?.plan_key || planKeyById(subscription.value.plan_id) || subscription.value.plan_key
|
||||
|
||||
// ✅ recarrega entitlements (sem reload)
|
||||
// (importante pra refletir o plano imediatamente)
|
||||
entitlementsStore.clear?.()
|
||||
await entitlementsStore.fetch(tid, { force: true })
|
||||
|
||||
// seu store tem loadForTenant no guard; se existir aqui também, use primeiro
|
||||
if (typeof entitlementsStore.loadForTenant === 'function') {
|
||||
await entitlementsStore.loadForTenant(tid, { force: true })
|
||||
} else if (typeof entitlementsStore.fetch === 'function') {
|
||||
await entitlementsStore.fetch(tid, { force: true })
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Plano atualizado',
|
||||
detail: `Agora você está no plano ${planKeyById(subscription.value.plan_id) || ''}`.trim(),
|
||||
detail: `Agora você está no plano ${planKeyById(subscription.value.plan_id) || subscription.value.plan_key || ''}`.trim(),
|
||||
life: 3000
|
||||
})
|
||||
|
||||
// ✅ garante consistência (principalmente se RPC mexer em mais campos)
|
||||
await fetchAll()
|
||||
|
||||
// ✅ dispara o guard novamente: se o usuário perdeu acesso a uma rota PRO,
|
||||
// ele deve ser redirecionado automaticamente.
|
||||
await revalidateCurrentRoute()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
|
||||
} finally {
|
||||
@@ -205,7 +278,11 @@ async function changePlan(targetPlanId) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchAll)
|
||||
onMounted(async () => {
|
||||
// ✅ garante que o tenant já foi carregado antes de buscar planos
|
||||
if (!tenantStore.loaded) await tenantStore.loadSessionAndTenant()
|
||||
await fetchAll()
|
||||
})
|
||||
|
||||
// se trocar tenant ativo, recarrega
|
||||
watch(
|
||||
@@ -393,4 +470,4 @@ watch(
|
||||
Observação: alguns recursos PRO podem depender de configuração inicial (ex.: SMS exige provedor).
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
9
src/views/pages/portal/agenda/MyAppointmentsPage.vue
Normal file
9
src/views/pages/portal/agenda/MyAppointmentsPage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h1>My Appointments</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// temporary placeholder
|
||||
</script>
|
||||
9
src/views/pages/portal/agenda/NewAppointmentPage.vue
Normal file
9
src/views/pages/portal/agenda/NewAppointmentPage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h1>Add New Appointments</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// temporary placeholder
|
||||
</script>
|
||||
270
src/views/pages/public/AcceptInvitePage.vue
Normal file
270
src/views/pages/public/AcceptInvitePage.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-6 bg-[var(--surface-ground)] text-[var(--text-color)]">
|
||||
<div class="w-full max-w-lg rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 shadow-sm">
|
||||
<h1 class="text-xl font-semibold mb-2">Aceitar convite</h1>
|
||||
<p class="text-sm opacity-80 mb-6">
|
||||
Vamos validar seu convite e ativar seu acesso ao tenant.
|
||||
</p>
|
||||
|
||||
<div v-if="state.loading" class="text-sm">
|
||||
Processando convite…
|
||||
</div>
|
||||
|
||||
<div v-else-if="state.success" class="space-y-3">
|
||||
<div class="text-sm">
|
||||
✅ Convite aceito com sucesso. Redirecionando…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="state.error" class="space-y-4">
|
||||
<div class="rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-sm">
|
||||
<div class="font-semibold mb-1">Não foi possível aceitar o convite</div>
|
||||
<div class="opacity-90">{{ state.error }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl border border-[var(--surface-border)] hover:opacity-90 text-sm"
|
||||
@click="retry"
|
||||
>
|
||||
Tentar novamente
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl bg-[var(--primary-color)] text-[var(--primary-color-text)] hover:opacity-90 text-sm"
|
||||
@click="goLogin"
|
||||
>
|
||||
Ir para login
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs opacity-70">
|
||||
Se você recebeu um convite, confirme se está logado com o mesmo e-mail do convite.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-sm opacity-80">
|
||||
Preparando…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
/**
|
||||
* Token persistence (antes do login)
|
||||
* - sessionStorage: some ao fechar a aba (bom para convite)
|
||||
* - se você preferir cross-tab, use localStorage
|
||||
*/
|
||||
const PENDING_INVITE_TOKEN_KEY = 'pending_invite_token_v1'
|
||||
|
||||
function persistPendingToken (token) {
|
||||
try { sessionStorage.setItem(PENDING_INVITE_TOKEN_KEY, token) } catch (_) {}
|
||||
}
|
||||
function readPendingToken () {
|
||||
try { return sessionStorage.getItem(PENDING_INVITE_TOKEN_KEY) } catch (_) { return null }
|
||||
}
|
||||
function clearPendingToken () {
|
||||
try { sessionStorage.removeItem(PENDING_INVITE_TOKEN_KEY) } catch (_) {}
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
success: false,
|
||||
error: ''
|
||||
})
|
||||
|
||||
const tokenFromQuery = computed(() => {
|
||||
const t = route.query?.token
|
||||
return typeof t === 'string' ? t.trim() : ''
|
||||
})
|
||||
|
||||
function isUuid (v) {
|
||||
// UUID v1–v5 (aceita maiúsculas/minúsculas)
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)
|
||||
}
|
||||
|
||||
function friendlyError (err) {
|
||||
const msg = (err?.message || err || '').toString()
|
||||
|
||||
// Ajuste esses “match” conforme as mensagens/raises do seu SQL.
|
||||
if (/expired|expirad/i.test(msg)) return 'Este convite expirou. Peça para a clínica reenviar o convite.'
|
||||
if (/invalid|inval/i.test(msg)) return 'Token inválido. Verifique se você copiou o link corretamente.'
|
||||
if (/not found|não encontrado|nao encontrado/i.test(msg)) return 'Convite não encontrado ou já utilizado.'
|
||||
if (/email/i.test(msg) && /mismatch|diferent|different|bate|match/i.test(msg)) {
|
||||
return 'Você está logado com um e-mail diferente do convite. Faça login com o e-mail correto.'
|
||||
}
|
||||
// cobre Postgres raise not_authenticated (P0001) e mensagens de JWT
|
||||
if (/not_authenticated|not authenticated|jwt|auth/i.test(msg)) {
|
||||
return 'Você precisa estar logado para aceitar este convite.'
|
||||
}
|
||||
return 'Não foi possível concluir o aceite. Tente novamente ou peça para reenviar o convite.'
|
||||
}
|
||||
|
||||
async function goLogin () {
|
||||
const token = tokenFromQuery.value || readPendingToken()
|
||||
if (token) persistPendingToken(token)
|
||||
|
||||
// ✅ garante troca de conta
|
||||
await supabase.auth.signOut()
|
||||
|
||||
// ✅ volta para o accept com token (ou com o storage pendente)
|
||||
// (mantém o link “real” para o login conseguir retornar certo)
|
||||
const returnTo = token ? `/accept-invite?token=${encodeURIComponent(token)}` : '/accept-invite'
|
||||
await router.replace({ path: '/auth/login', query: { redirect: returnTo } })
|
||||
}
|
||||
|
||||
async function acceptInvite (token) {
|
||||
state.loading = true
|
||||
state.error = ''
|
||||
state.success = false
|
||||
|
||||
// 1) sessão
|
||||
// Obs: getSession lê do storage; não use pra “autorizar” no client,
|
||||
// mas aqui é só fluxo/UX; o servidor valida de verdade.
|
||||
const { data: sessionData, error: sessionErr } = await supabase.auth.getSession()
|
||||
if (sessionErr) {
|
||||
state.loading = false
|
||||
state.error = friendlyError(sessionErr)
|
||||
return
|
||||
}
|
||||
|
||||
const session = sessionData?.session
|
||||
if (!session) {
|
||||
// não logado → salva token e vai pro login
|
||||
persistPendingToken(token)
|
||||
|
||||
// ✅ importante: /login dá 404 no seu projeto; use /auth/login
|
||||
// ✅ preserve o returnTo com querystring (token)
|
||||
const returnTo = route.fullPath || `/accept-invite?token=${encodeURIComponent(token)}`
|
||||
await router.replace({ path: '/auth/login', query: { redirect: returnTo } })
|
||||
|
||||
// não seta erro: é fluxo normal
|
||||
state.loading = false
|
||||
return
|
||||
}
|
||||
|
||||
// (debug útil: garante que a aba anônima realmente tem user/session)
|
||||
try {
|
||||
const s = await supabase.auth.getSession()
|
||||
const u = await supabase.auth.getUser()
|
||||
console.log('[accept-invite] session user:', s?.data?.session?.user?.id, s?.data?.session?.user?.email)
|
||||
console.log('[accept-invite] getUser:', u?.data?.user?.id, u?.data?.user?.email)
|
||||
} catch (_) {}
|
||||
|
||||
// 2) chama RPC
|
||||
// IMPORTANTÍSSIMO: a função deve validar:
|
||||
// - token existe, status=invited, não expirou
|
||||
// - email do invite == auth.email do caller
|
||||
// - cria/ativa tenant_members (status=active)
|
||||
// - revoga/consome invite
|
||||
//
|
||||
// A assinatura de args depende do seu SQL:
|
||||
// - se for tenant_accept_invite(token uuid) → { token }
|
||||
// - se for tenant_accept_invite(p_token uuid) → { p_token: token }
|
||||
//
|
||||
// ✅ NO SEU CASO: a assinatura existente é p_token (confirmado no SQL Editor).
|
||||
const { data, error } = await supabase.rpc('tenant_accept_invite', { p_token: token })
|
||||
if (error) {
|
||||
state.loading = false
|
||||
// mostra o motivo real na tela (e não uma mensagem genérica)
|
||||
state.error = error?.message ? error.message : friendlyError(error)
|
||||
return
|
||||
}
|
||||
|
||||
// 3) sucesso → limpa token pendente
|
||||
clearPendingToken()
|
||||
|
||||
// 4) atualiza tenantStore (boa prática: refresh completo do “contexto do usuário”)
|
||||
// Ideal: sua RPC retorna tenant_id (e opcionalmente role/status)
|
||||
const acceptedTenantId = data?.tenant_id || data?.tenantId || null
|
||||
|
||||
try {
|
||||
await refreshTenantContextAfterInvite(acceptedTenantId)
|
||||
} catch (e) {
|
||||
// mesmo que refresh falhe, o aceite ocorreu; ainda redireciona, mas você pode avisar
|
||||
// (mantive silencioso para não “quebrar” o fluxo).
|
||||
}
|
||||
|
||||
state.loading = false
|
||||
state.success = true
|
||||
|
||||
// 5) redireciona
|
||||
await router.replace('/admin')
|
||||
}
|
||||
|
||||
/**
|
||||
* Melhor prática de atualização do tenantStore após aceite:
|
||||
* - 1) refetch “meus tenants + memberships” (fonte da verdade)
|
||||
* - 2) setActiveTenantId (se veio no retorno; senão, escolha um padrão)
|
||||
* - 3) carregar contexto do tenant ativo (permissões/entitlements/branding/etc)
|
||||
*/
|
||||
async function refreshTenantContextAfterInvite (acceptedTenantId) {
|
||||
// Ajuste para os métodos reais do seu tenantStore:
|
||||
// Exemplo recomendado de API do store:
|
||||
// - await tenantStore.fetchMyTenants()
|
||||
// - await tenantStore.fetchMyMemberships()
|
||||
// - tenantStore.setActiveTenantId(...)
|
||||
// - await tenantStore.hydrateActiveTenantContext()
|
||||
|
||||
if (typeof tenantStore.refreshMyTenantsAndMemberships === 'function') {
|
||||
await tenantStore.refreshMyTenantsAndMemberships()
|
||||
} else {
|
||||
if (typeof tenantStore.fetchMyTenants === 'function') await tenantStore.fetchMyTenants()
|
||||
if (typeof tenantStore.fetchMyMemberships === 'function') await tenantStore.fetchMyMemberships()
|
||||
}
|
||||
|
||||
if (acceptedTenantId && typeof tenantStore.setActiveTenantId === 'function') {
|
||||
tenantStore.setActiveTenantId(acceptedTenantId)
|
||||
}
|
||||
|
||||
if (typeof tenantStore.hydrateActiveTenantContext === 'function') {
|
||||
await tenantStore.hydrateActiveTenantContext()
|
||||
} else if (typeof tenantStore.refreshActiveTenant === 'function') {
|
||||
await tenantStore.refreshActiveTenant()
|
||||
}
|
||||
}
|
||||
|
||||
async function run () {
|
||||
state.loading = true
|
||||
state.error = ''
|
||||
state.success = false
|
||||
|
||||
// 1) token: query > pendente (pós-login)
|
||||
const token = tokenFromQuery.value || readPendingToken()
|
||||
|
||||
if (!token) {
|
||||
state.loading = false
|
||||
state.error = 'Token ausente. Abra novamente o link do convite.'
|
||||
return
|
||||
}
|
||||
|
||||
if (!isUuid(token)) {
|
||||
state.loading = false
|
||||
state.error = 'Token inválido. Verifique se o link está completo.'
|
||||
return
|
||||
}
|
||||
|
||||
// Se veio da query, persiste (caso precise atravessar login)
|
||||
if (tokenFromQuery.value) persistPendingToken(token)
|
||||
|
||||
// 2) tenta aceitar
|
||||
await acceptInvite(token)
|
||||
}
|
||||
|
||||
async function retry () {
|
||||
await run()
|
||||
}
|
||||
|
||||
onMounted(run)
|
||||
</script>
|
||||
9
src/views/pages/therapist/agenda/MyAppointmentsPage.vue
Normal file
9
src/views/pages/therapist/agenda/MyAppointmentsPage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h1>My Appointments</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// temporary placeholder
|
||||
</script>
|
||||
9
src/views/pages/therapist/agenda/NewAppointmentPage.vue
Normal file
9
src/views/pages/therapist/agenda/NewAppointmentPage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h1>Add New Appointments</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// temporary placeholder
|
||||
</script>
|
||||
Reference in New Issue
Block a user