ZERADO
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
VITE_SUPABASE_URL=http://127.0.0.1:54321
|
||||
VITE_SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH
|
||||
VITE_QA_MODE=true
|
||||
VITE_QA_PASS=123Mudar@
|
||||
|
||||
163
CHANGELOG.md
163
CHANGELOG.md
@@ -1,70 +1,155 @@
|
||||
# Changelog
|
||||
# CHANGELOG — Banco de Dados AgênciaPsi
|
||||
|
||||
## 4.3.0 (2025-02-26)
|
||||
Registro histórico de todas as migrations aplicadas no banco.
|
||||
Formato: data | arquivo | o que mudou | por quê
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
---
|
||||
|
||||
- Update PrimeVue version
|
||||
## [001] — 2026-03-03
|
||||
**Arquivo:** `migration_001.sql`
|
||||
**Seed:** `seed_001.sql`
|
||||
|
||||
## 4.2.0 (2024-12-09)
|
||||
### Contexto
|
||||
O schema original foi construído de forma incremental e acumulou
|
||||
inconsistências no modelo de identidade. Usuários não tinham um
|
||||
tipo de conta definido formalmente, tenants não distinguiam
|
||||
terapeuta de clínica, e não existia suporte a paciente como
|
||||
tipo de conta de plataforma.
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
### O que mudou
|
||||
|
||||
- Refactored dashboard sections to components
|
||||
- Migrate sass from @import to @use
|
||||
#### `profiles`
|
||||
- ✅ Adicionada coluna `account_type text NOT NULL DEFAULT 'free'`
|
||||
- Valores: `free | patient | therapist | clinic`
|
||||
- Imutável após sair de `free` (trigger `trg_account_type_immutable`)
|
||||
- Usuários com role=`patient` migrados para `account_type='patient'`
|
||||
- Usuários com tenant `saas` ativo migrados para `account_type='therapist'`
|
||||
|
||||
## 4.1.0 (2024-07-29)
|
||||
#### `tenants`
|
||||
- ✅ Novos valores aceitos em `kind`:
|
||||
- `therapist` → terapeuta individual (substitui `saas`)
|
||||
- `clinic_coworking` → clínica tipo 1: gestão de salas
|
||||
- `clinic_reception` → clínica tipo 2: secretaria + múltiplas agendas
|
||||
- `clinic_full` → clínica tipo 3: coworking + secretaria
|
||||
- ✅ `kind` agora é imutável após criação (trigger `trg_tenant_kind_immutable`)
|
||||
- ✅ 10 tenants `saas` órfãos (sem admin, sem subscriptions) deletados
|
||||
- ✅ Tenants `saas` com admin ativo migrados para `kind='therapist'`
|
||||
- ⚠️ `saas` e `clinic` (legados) mantidos no CHECK por compatibilidade.
|
||||
Não criar novos tenants com esses kinds.
|
||||
|
||||
- Changed menu button location at topbar
|
||||
- Add border to overlay menu
|
||||
- Animation for mobile mask
|
||||
- Fixed chart colors
|
||||
#### `plans`
|
||||
- ✅ Adicionado `patient` como valor válido em `target`
|
||||
- ✅ Inserido plano `patient_free` (gratuito, target=patient)
|
||||
|
||||
## 4.0.0 (2024-07-29)
|
||||
#### Novas funções
|
||||
| Função | Descrição |
|
||||
|--------|-----------|
|
||||
| `provision_account_tenant(user_id, kind, name?)` | Cria tenant + membership + atualiza account_type. Chamar no onboarding. |
|
||||
| `is_therapist_tenant(tenant_id)` | Retorna true se tenant é do tipo therapist |
|
||||
| `is_clinic_tenant(tenant_id)` | Atualizada: inclui todos os subtipos de clínica |
|
||||
| `guard_tenant_kind_immutable()` | Trigger: bloqueia alteração de tenants.kind |
|
||||
| `guard_account_type_immutable()` | Trigger: bloqueia alteração de account_type após escolha |
|
||||
| `guard_patient_cannot_own_tenant()` | Trigger: bloqueia paciente de ser tenant_admin/therapist |
|
||||
|
||||
- Updated to PrimeVue v4
|
||||
#### Funções atualizadas
|
||||
| Função | O que mudou |
|
||||
|--------|-------------|
|
||||
| `handle_new_user()` | Agora insere `account_type='free'` |
|
||||
| `handle_new_user_create_personal_tenant()` | Desabilitada — tenant criado no onboarding |
|
||||
| `ensure_personal_tenant()` | Busca por `kind IN ('therapist','saas')` e delega para `provision_account_tenant` |
|
||||
|
||||
## 3.10.0 (2024-03-11)
|
||||
### Regras de negócio agora garantidas no banco
|
||||
1. **Paciente é para sempre paciente** — `account_type` imutável após escolha
|
||||
2. **Terapeuta nunca vira clínica e vice-versa** — `tenants.kind` imutável
|
||||
3. **Paciente não pode ter tenant** — trigger bloqueia na inserção
|
||||
4. **Cada tipo de conta tem seu tipo de tenant** — `provision_account_tenant` garante
|
||||
|
||||
**Migration Guide**
|
||||
### Usuários de seed (apenas dev/staging)
|
||||
| Email | Tipo | Tenant |
|
||||
|-------|------|--------|
|
||||
| paciente@agenciapsi.com.br | patient | nenhum |
|
||||
| terapeuta@agenciapsi.com.br | therapist | tenant próprio (therapist) + vinculado à Clínica 3 |
|
||||
| clinica1@agenciapsi.com.br | clinic | clinic_coworking |
|
||||
| clinica2@agenciapsi.com.br | clinic | clinic_reception |
|
||||
| clinica3@agenciapsi.com.br | clinic | clinic_full |
|
||||
| saas@agenciapsi.com.br | saas_admin | nenhum |
|
||||
> Senha de todos: `Teste@123`
|
||||
|
||||
- Update theme files.
|
||||
---
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
## [002] — seed_002.sql
|
||||
|
||||
- Upgrade to PrimeVue 3.49.1
|
||||
**Arquivo:** `Novo-DB/seed_002.sql`
|
||||
|
||||
## 3.9.0 (2023-11-01)
|
||||
### O que cria
|
||||
|
||||
**Migration Guide**
|
||||
#### Migration embutida
|
||||
- ✅ `profiles.platform_roles text[] NOT NULL DEFAULT '{}'` — adicionada via `ADD COLUMN IF NOT EXISTS` (idempotente)
|
||||
|
||||
- Update theme files.
|
||||
#### Usuários de teste
|
||||
| Email | Senha | Papel | Tenant |
|
||||
|-------|-------|-------|--------|
|
||||
| `supervisor@agenciapsi.com.br` | `Teste@123` | `supervisor` em `tenant_members` | Clínica Bem Estar (Full) |
|
||||
| `editor@agenciapsi.com.br` | `Teste@123` | `therapist` em `tenant_members` + `platform_roles = '{editor}'` | Clínica Bem Estar (Full) |
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
UUIDs reservados:
|
||||
- Supervisor: `aaaaaaaa-0007-0007-0007-000000000007`
|
||||
- Editor: `aaaaaaaa-0008-0008-0008-000000000008`
|
||||
|
||||
- Upgrade to PrimeVue 3.39.0
|
||||
---
|
||||
|
||||
## 3.8.0 (2023-07-24)
|
||||
## [PENDENTE] — Migration necessária: `platform_roles` em `profiles`
|
||||
|
||||
**Migration Guide**
|
||||
**Contexto:**
|
||||
Implementação das áreas de **Supervisor** (papel de tenant) e **Editor** (papel de plataforma).
|
||||
O papel de Editor é atribuído pelo `saas_admin` e armazenado diretamente no perfil do usuário,
|
||||
independente de qual tenant ele pertence.
|
||||
|
||||
- Update theme files.
|
||||
- Update assets style files
|
||||
- Remove code highlight
|
||||
### O que precisa ser aplicado no banco
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
#### `profiles`
|
||||
- ⚠️ **Adicionar coluna** `platform_roles text[] NOT NULL DEFAULT '{}'`
|
||||
- Armazena papéis globais de plataforma. Ex.: `'{editor}'`
|
||||
- Quem pode escrever: somente `saas_admin` (via RLS ou função privilegiada)
|
||||
- Quem pode ter: qualquer usuário autenticado, **exceto** `account_type = 'patient'`
|
||||
- Valores previstos: `editor` (mais podem ser adicionados futuramente)
|
||||
|
||||
- Upgrade to PrimeVue 3.30.2
|
||||
#### SQL sugerido
|
||||
```sql
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN IF NOT EXISTS platform_roles text[] NOT NULL DEFAULT '{}';
|
||||
|
||||
## 3.7.0 (2023-05-06)
|
||||
-- Comentário descritivo
|
||||
COMMENT ON COLUMN public.profiles.platform_roles IS
|
||||
'Papéis globais de plataforma, independentes de tenant. Ex: editor de microlearning. Atribuído pelo saas_admin.';
|
||||
|
||||
- Upgrade to PrimeVue 3.28.0
|
||||
-- RLS: somente saas_admin pode atualizar platform_roles (exemplo)
|
||||
-- CREATE POLICY "saas_admin pode atualizar platform_roles"
|
||||
-- ON public.profiles FOR UPDATE
|
||||
-- USING (auth.uid() IN (SELECT id FROM public.profiles WHERE role = 'saas_admin'))
|
||||
-- WITH CHECK (true);
|
||||
```
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
#### `tenant_members` (sem alteração necessária)
|
||||
- O papel `supervisor` já é suportado como valor text em `tenant_members.role`.
|
||||
- Nenhuma alteração de schema é necessária — basta inserir memberships com `role = 'supervisor'`.
|
||||
|
||||
## 3.6.0 (2023-04-12)
|
||||
### Impacto se não aplicado
|
||||
- Área do Editor (`/editor`) fica inacessível a todos (coluna ausente → `platform_roles` vem `null` → acesso negado).
|
||||
- Área do Supervisor (`/supervisor`) funciona normalmente — não depende desta migration.
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
---
|
||||
|
||||
- Upgrade to PrimeVue 3.26.1
|
||||
- Upgrade to vite 4.2.1
|
||||
## Futuro — registrado mas não implementado
|
||||
|
||||
### Vínculo Terapeuta ↔ Clínica (a implementar)
|
||||
- Terapeuta autoriza explicitamente que secretaria gerencie suas sessões
|
||||
- Permissão só válida se clínica tiver `kind IN ('clinic_reception', 'clinic_full')`
|
||||
- Secretaria acessa apenas sessões — não prontuário nem anotações
|
||||
- Dissociação bloqueada se houver `agenda_eventos` futuros (`inicio_em > now()`)
|
||||
- Após dissociação: cada parte fica com seus próprios pacientes
|
||||
- Requer: coluna de permissão no vínculo + função de dissociação com validação
|
||||
|
||||
---
|
||||
|
||||
*Última atualização: 2026-03-03*
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Documento Mestre — Billing (Plans, Pricing, Subscriptions, Entitlements) v2.0 | Agência PSI</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg0:#f6f8fc;
|
||||
--bg1:#eef2f8;
|
||||
--panel:rgba(255,255,255,.78);
|
||||
--panel2:rgba(255,255,255,.92);
|
||||
--border:rgba(15,23,42,.10);
|
||||
--text:rgba(15,23,42,.92);
|
||||
--muted:rgba(15,23,42,.70);
|
||||
--muted2:rgba(15,23,42,.56);
|
||||
--accent:#2563eb;
|
||||
--accent2:#4f46e5;
|
||||
--warn:#b45309;
|
||||
--danger:#b91c1c;
|
||||
--ok:#047857;
|
||||
--shadow: 0 18px 60px rgba(2,6,23,.10);
|
||||
--radius: 16px;
|
||||
--radius2: 22px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{
|
||||
margin:0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
background: radial-gradient(1200px 600px at 10% 0%, rgba(79,70,229,.10), transparent 55%),
|
||||
radial-gradient(1100px 500px at 90% 10%, rgba(37,99,235,.10), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
color:var(--text);
|
||||
}
|
||||
a{color:inherit; text-decoration:none;}
|
||||
a:hover{text-decoration:underline;}
|
||||
.layout{
|
||||
display:grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 20px;
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 18px 42px;
|
||||
}
|
||||
header{
|
||||
grid-column: 1 / -1;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.92), rgba(255,255,255,.72));
|
||||
border-radius: var(--radius2);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.kicker{
|
||||
font-size:12px;
|
||||
letter-spacing:.08em;
|
||||
text-transform:uppercase;
|
||||
color:var(--muted2);
|
||||
margin:0 0 8px;
|
||||
}
|
||||
h1{
|
||||
margin:0 0 8px;
|
||||
font-size:30px;
|
||||
letter-spacing:-0.02em;
|
||||
}
|
||||
.subtitle{
|
||||
margin:0;
|
||||
color:var(--muted);
|
||||
max-width:980px;
|
||||
line-height:1.55;
|
||||
font-size:14px;
|
||||
}
|
||||
.meta-row{
|
||||
margin-top:12px;
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
}
|
||||
.pill{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
padding:8px 12px;
|
||||
border-radius:999px;
|
||||
border:1px solid var(--border);
|
||||
background:rgba(255,255,255,.72);
|
||||
color:var(--muted);
|
||||
font-size:12px;
|
||||
}
|
||||
.dot{
|
||||
width:8px;height:8px;border-radius:50%;
|
||||
background:var(--accent);
|
||||
box-shadow:0 0 0 4px rgba(37,99,235,.12);
|
||||
}
|
||||
aside{
|
||||
position:sticky;
|
||||
top:18px;
|
||||
align-self:start;
|
||||
border:1px solid var(--border);
|
||||
background:var(--panel2);
|
||||
border-radius:var(--radius);
|
||||
box-shadow:var(--shadow);
|
||||
overflow:hidden;
|
||||
}
|
||||
.toc-head{
|
||||
padding:14px;
|
||||
border-bottom:1px solid var(--border);
|
||||
background:rgba(15,23,42,.02);
|
||||
}
|
||||
.toc-title{ margin:0 0 6px; font-weight:700; font-size:14px; }
|
||||
.toc-sub{ margin:0; color:var(--muted); font-size:12px; line-height:1.45; }
|
||||
nav{ padding: 10px 6px 14px; }
|
||||
nav a{
|
||||
display:block;
|
||||
padding:10px 12px;
|
||||
margin:4px 6px;
|
||||
border-radius:12px;
|
||||
color:var(--muted);
|
||||
font-size:13px;
|
||||
border:1px solid transparent;
|
||||
}
|
||||
nav a:hover{
|
||||
background:rgba(37,99,235,.06);
|
||||
border-color:rgba(37,99,235,.12);
|
||||
color:var(--text);
|
||||
text-decoration:none;
|
||||
}
|
||||
.nav-sec{
|
||||
margin:10px 12px 6px;
|
||||
color:var(--muted2);
|
||||
font-size:11px;
|
||||
letter-spacing:.08em;
|
||||
text-transform:uppercase;
|
||||
}
|
||||
main{
|
||||
border:1px solid var(--border);
|
||||
background:var(--panel);
|
||||
border-radius:var(--radius2);
|
||||
box-shadow:var(--shadow);
|
||||
overflow:hidden;
|
||||
}
|
||||
.content{ padding: 18px 18px 22px; }
|
||||
.section{
|
||||
padding: 18px;
|
||||
border:1px solid var(--border);
|
||||
border-radius: var(--radius2);
|
||||
background: rgba(255,255,255,.80);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section h2{
|
||||
margin:0 0 10px;
|
||||
font-size:18px;
|
||||
letter-spacing:-0.01em;
|
||||
}
|
||||
.section h3{
|
||||
margin:14px 0 8px;
|
||||
font-size:14px;
|
||||
}
|
||||
.section p, .section li{
|
||||
color:var(--muted);
|
||||
line-height:1.65;
|
||||
font-size:13.5px;
|
||||
}
|
||||
.grid{
|
||||
display:grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 980px){
|
||||
.layout{ grid-template-columns: 1fr; }
|
||||
aside{ position:relative; top:auto; }
|
||||
.grid{ grid-template-columns: 1fr; }
|
||||
}
|
||||
.callout{
|
||||
border-radius: 16px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
border:1px solid var(--border);
|
||||
background: rgba(15,23,42,.02);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.callout strong{color:var(--text);}
|
||||
.callout.ok{ border-left: 4px solid var(--ok); background: rgba(4,120,87,.06); }
|
||||
.callout.warn{ border-left: 4px solid var(--warn); background: rgba(180,83,9,.08); }
|
||||
.callout.danger{ border-left: 4px solid var(--danger); background: rgba(185,28,28,.08); }
|
||||
.callout.info{ border-left: 4px solid var(--accent); background: rgba(37,99,235,.08); }
|
||||
pre{
|
||||
margin: 10px 0 0;
|
||||
padding: 14px;
|
||||
background: #0b1220;
|
||||
color:#e2e8f0;
|
||||
border-radius: 16px;
|
||||
overflow:auto;
|
||||
border: 1px solid rgba(226,232,240,.08);
|
||||
}
|
||||
code{ font-family: var(--mono); font-size:12.5px; }
|
||||
.kbd{
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255,255,255,.65);
|
||||
color: var(--text);
|
||||
}
|
||||
.hr{
|
||||
height:1px;
|
||||
background: var(--border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
footer{
|
||||
margin-top: 12px;
|
||||
padding: 14px 18px 18px;
|
||||
color: var(--muted2);
|
||||
font-size: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: rgba(255,255,255,.70);
|
||||
}
|
||||
.small{ font-size:12px; color:var(--muted2); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<header>
|
||||
<p class="kicker">Documento Mestre • Billing • Agência PSI</p>
|
||||
<h1>Plans, Pricing, Subscriptions & Entitlements — v2.0</h1>
|
||||
<p class="subtitle">
|
||||
Documento institucional do domínio <strong>Billing</strong>. Unifica: catálogo de planos, preços vigentes,
|
||||
assinatura (clínica/terapeuta), guardrails e entitlements (features + limits). Este material é pensado para
|
||||
reduzir regressões e orientar o operador/dev quando algo “parecer impossível” (ex.: corrigir plano core sem
|
||||
desativar triggers).
|
||||
</p>
|
||||
<div class="meta-row">
|
||||
<span class="pill"><span class="dot"></span><strong>Estado:</strong> operacional (MVP)</span>
|
||||
<span class="pill"><strong>Atualizado:</strong> 2026-03-01 10:43:18 UTC</span>
|
||||
<span class="pill"><strong>Padrão:</strong> Supabase + Postgres + Vue/PrimeVue</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside>
|
||||
<div class="toc-head">
|
||||
<div class="toc-title">Sumário</div>
|
||||
<p class="toc-sub">Links rápidos para leitura e execução (SQL/Seeder/Views).</p>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="nav-sec">Visão geral</div>
|
||||
<a href="#01">1. Contexto e objetivos</a>
|
||||
<a href="#02">2. Entidades e conceitos</a>
|
||||
|
||||
<div class="nav-sec">Plans & Pricing</div>
|
||||
<a href="#03">3. Tabela plans e planos core</a>
|
||||
<a href="#04">4. Pricing e vigência (plan_prices + views)</a>
|
||||
|
||||
<div class="nav-sec">Subscriptions</div>
|
||||
<a href="#05">5. Subscriptions: schema e regras</a>
|
||||
<a href="#06">6. Views: active_subscription</a>
|
||||
<a href="#07">7. Operações: change, cancel, reactivate</a>
|
||||
<a href="#08">8. Auditoria: subscription_events</a>
|
||||
|
||||
<div class="nav-sec">Entitlements</div>
|
||||
<a href="#09">9. Features + plan_features</a>
|
||||
<a href="#10">10. Views de entitlements (com limits)</a>
|
||||
|
||||
<div class="nav-sec">Guardrails</div>
|
||||
<a href="#11">11. Triggers de proteção</a>
|
||||
<a href="#12">12. Correção segura de plano core (bypass controlado)</a>
|
||||
|
||||
<div class="nav-sec">Seeders</div>
|
||||
<a href="#13">13. Seeder idempotente: features + plan_features</a>
|
||||
<a href="#14">14. Seeder idempotente: subscription de teste</a>
|
||||
|
||||
<div class="nav-sec">Front-end</div>
|
||||
<a href="#15">15. Padrões de UI e telas (Subscriptions / Eventos)</a>
|
||||
|
||||
<div class="nav-sec">Apêndices</div>
|
||||
<a href="#16">16. Troubleshooting (erros reais)</a>
|
||||
<a href="#17">17. Checklist de validação</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="content">
|
||||
|
||||
<section class="section" id="01">
|
||||
<h2>1. Contexto e objetivos</h2>
|
||||
<p>
|
||||
O MVP do SaaS precisa garantir que o sistema “respeite o plano”. Para isso, o domínio Billing opera em camadas:
|
||||
<strong>Plans</strong> (catálogo), <strong>Pricing</strong> (vigência), <strong>Subscriptions</strong> (plano vigente por tenant/user)
|
||||
e <strong>Entitlements</strong> (features + limites).
|
||||
</p>
|
||||
<div class="callout info">
|
||||
<strong>Regra de ouro:</strong> o front nunca deve “inferir plano” por role. O plano vigente vem de <code>subscriptions</code>
|
||||
e os limites/flags vêm de <code>plan_features</code>.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="02">
|
||||
<h2>2. Entidades e conceitos</h2>
|
||||
<div class="grid">
|
||||
<div class="callout">
|
||||
<strong>plans</strong>
|
||||
<p>Catálogo de planos (core e custom). Guarda <code>key</code>, <code>target</code>, preço base e metadados.</p>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>plan_prices</strong>
|
||||
<p>Preço com vigência. Preço vigente: <span class="kbd">is_active=true</span> e <span class="kbd">active_to is null</span>.</p>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>subscriptions</strong>
|
||||
<p>Assinatura ativa por tenant (clínica) ou por user (terapeuta). A view escolhe a mais recente por owner.</p>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>features / plan_features</strong>
|
||||
<p>Mapa de capabilities e limites (<code>limits jsonb</code>). É daqui que o front deve “gatear” menus/ações.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="03">
|
||||
<h2>3. Tabela plans e planos core</h2>
|
||||
<p><strong>Planos core do MVP</strong> (devem existir e permanecer): <code>clinic_free</code>, <code>clinic_pro</code>, <code>therapist_free</code>, <code>therapist_pro</code>.</p>
|
||||
<pre><code>-- estrutura confirmada (resumo)
|
||||
-- plans (public)
|
||||
-- id, key, name, description, is_active, price_cents, currency, billing_interval, target</code></pre>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Observação importante:</strong> planos core têm guardrails: não podem ser deletados e sua <code>key</code> não pode ser alterada.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="04">
|
||||
<h2>4. Pricing e vigência</h2>
|
||||
<p>
|
||||
A UI pública de preços deve consumir <code>v_public_pricing</code>. A vigência de preço vem de <code>plan_prices</code>:
|
||||
preço vigente é aquele com <code>is_active=true</code> e <code>active_to is null</code>. Para planos FREE, a UI pode exibir “Grátis”
|
||||
mesmo sem registro em <code>plan_prices</code>.
|
||||
</p>
|
||||
<div class="callout info">
|
||||
<strong>Boas práticas:</strong> a tela pública não deve depender do schema “cru”. Mantenha a view como contrato.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="05">
|
||||
<h2>5. Subscriptions: schema e regras</h2>
|
||||
<p>Schema confirmado via <code>information_schema</code> e constraints:</p>
|
||||
<pre><code>-- subscriptions (public) - colunas relevantes
|
||||
id uuid primary key default gen_random_uuid()
|
||||
tenant_id uuid null
|
||||
user_id uuid null
|
||||
plan_id uuid not null references plans(id) on delete restrict
|
||||
status text not null default 'active'
|
||||
"interval" text null check ("interval" in ('month','year'))
|
||||
current_period_start timestamptz null
|
||||
current_period_end timestamptz null
|
||||
plan_key text null
|
||||
provider text not null default 'manual'
|
||||
source text not null default 'manual'
|
||||
started_at timestamptz not null default now()
|
||||
created_at timestamptz not null default now()
|
||||
updated_at timestamptz not null default now()</code></pre>
|
||||
|
||||
<div class="callout ok">
|
||||
<strong>Modelagem:</strong> clínica → usa <code>tenant_id</code>. Terapeuta → usa <code>user_id</code> (com <code>tenant_id</code> nulo).
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="06">
|
||||
<h2>6. View: v_tenant_active_subscription</h2>
|
||||
<p>
|
||||
Esta view define “o plano vigente” do tenant. Regra: status <code>active</code> e período ainda válido.
|
||||
Escolhe a assinatura mais recente por tenant (created_at DESC).
|
||||
</p>
|
||||
<pre><code>select distinct on (tenant_id)
|
||||
tenant_id,
|
||||
plan_id,
|
||||
plan_key,
|
||||
"interval",
|
||||
status,
|
||||
current_period_start,
|
||||
current_period_end,
|
||||
created_at
|
||||
from subscriptions s
|
||||
where tenant_id is not null
|
||||
and status = 'active'
|
||||
and (current_period_end is null or current_period_end > now())
|
||||
order by tenant_id, created_at desc;</code></pre>
|
||||
|
||||
<div class="callout info">
|
||||
<strong>Diagnóstico rápido:</strong> se views de entitlements estiverem “vazias”, primeiro verifique se existe subscription ativa nesta view.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="07">
|
||||
<h2>7. Operações de assinatura: change / cancel / reactivate</h2>
|
||||
<p>
|
||||
O front chama RPCs, mantendo a regra de ouro: “a verdade vem do banco”.
|
||||
Depois de operar, a tela recarrega para refletir o estado real.
|
||||
</p>
|
||||
<pre><code>-- RPCs usadas no front
|
||||
-- change_subscription_plan(p_subscription_id uuid, p_new_plan_id uuid)
|
||||
-- cancel_subscription(p_subscription_id uuid)
|
||||
-- reactivate_subscription(p_subscription_id uuid)</code></pre>
|
||||
<div class="callout warn">
|
||||
<strong>Nota:</strong> se o RPC atualizar apenas <code>plan_id</code>, é recomendável manter <code>plan_key</code> e <code>interval</code>
|
||||
consistentes (quando for relevante), para facilitar auditoria e debugging.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="08">
|
||||
<h2>8. Auditoria: subscription_events</h2>
|
||||
<p>
|
||||
Tela “Histórico de assinaturas” é read-only e mostra até 500 eventos mais recentes. Eventos típicos:
|
||||
<code>plan_changed</code>, <code>canceled</code>, <code>reactivated</code>.
|
||||
</p>
|
||||
<div class="callout info">
|
||||
<strong>UX operacional:</strong> o histórico deve permitir navegar de volta para o owner (Subscriptions) via query <code>?q=clinic:<uuid></code>.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="09">
|
||||
<h2>9. Features e plan_features</h2>
|
||||
<p>O MVP já possui <code>features</code> com keys (ex.: <code>online_scheduling</code>, <code>reports_basic</code>, etc.) e tabela <code>plan_features</code>:</p>
|
||||
<pre><code>-- plan_features(plan_id uuid not null, feature_id uuid not null,
|
||||
-- enabled boolean not null default true, limits jsonb null, created_at timestamptz default now())
|
||||
-- PK: (plan_id, feature_id)
|
||||
-- FK: feature_id → features(id) ON DELETE CASCADE
|
||||
-- FK: plan_id → plans(id) ON DELETE CASCADE</code></pre>
|
||||
|
||||
<div class="callout ok">
|
||||
<strong>Importante:</strong> <code>limits</code> é um contrato com o front. Ex.: <code>{"max_patients":30}</code>, <code>{"sessions_per_month":40}</code>.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="10">
|
||||
<h2>10. Views de entitlements (com limits)</h2>
|
||||
<p>Para atender o front com 1 query, criamos views “full” e “json”.</p>
|
||||
|
||||
<h3>10.1 v_tenant_entitlements_full</h3>
|
||||
<pre><code>create or replace view public.v_tenant_entitlements_full as
|
||||
select
|
||||
a.tenant_id,
|
||||
f.key as feature_key,
|
||||
(pf.enabled = true) as allowed,
|
||||
pf.limits,
|
||||
a.plan_id,
|
||||
p.key as plan_key
|
||||
from public.v_tenant_active_subscription a
|
||||
join public.plan_features pf on pf.plan_id = a.plan_id
|
||||
join public.features f on f.id = pf.feature_id
|
||||
join public.plans p on p.id = a.plan_id;</code></pre>
|
||||
|
||||
<h3>10.2 v_tenant_entitlements_json</h3>
|
||||
<pre><code>create or replace view public.v_tenant_entitlements_json as
|
||||
select
|
||||
tenant_id,
|
||||
max(plan_key) as plan_key,
|
||||
jsonb_object_agg(
|
||||
feature_key,
|
||||
jsonb_build_object(
|
||||
'allowed', allowed,
|
||||
'limits', coalesce(limits, '{}'::jsonb)
|
||||
)
|
||||
order by feature_key
|
||||
) as entitlements
|
||||
from public.v_tenant_entitlements_full
|
||||
group by tenant_id;</code></pre>
|
||||
|
||||
<div class="callout info">
|
||||
<strong>Uso no front:</strong> uma única consulta retorna <code>plan_key</code> + mapa de entitlements com limits.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="11">
|
||||
<h2>11. Triggers de proteção (Guardrails)</h2>
|
||||
<p>Triggers confirmadas em <code>public.plans</code>:</p>
|
||||
<pre><code>trg_no_delete_core_plans
|
||||
trg_no_change_plan_target
|
||||
trg_no_change_core_plan_key</code></pre>
|
||||
|
||||
<h3>11.1 Funções (versões base)</h3>
|
||||
<pre><code>-- guard_no_delete_core_plans(): impede deletar planos core
|
||||
-- guard_no_change_core_plan_key(): impede alterar key dos planos core
|
||||
-- guard_no_change_plan_target(): impede alterar target de qualquer plano</code></pre>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Armadilha comum:</strong> tentar “corrigir plano core” via UPDATE direto. O trigger bloqueia e isso é desejável.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="12">
|
||||
<h2>12. Correção segura de plano core (bypass controlado)</h2>
|
||||
<p>
|
||||
Caso real desta sessão: <code>clinic_free</code> estava com <code>target</code> incorreto.
|
||||
O objetivo foi corrigir sem “desligar guardrails”.
|
||||
</p>
|
||||
|
||||
<h3>12.1 Patch do guardrail para bypass por transação</h3>
|
||||
<pre><code>create or replace function public.guard_no_change_plan_target()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
declare
|
||||
v_bypass text;
|
||||
begin
|
||||
v_bypass := current_setting('app.plan_migration_bypass', true);
|
||||
|
||||
if v_bypass = '1' then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
if new.target is distinct from old.target then
|
||||
raise exception 'Não é permitido alterar target do plano (%) de % para %.',
|
||||
old.key, old.target, new.target
|
||||
using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end
|
||||
$$;</code></pre>
|
||||
|
||||
<h3>12.2 Função administrativa (SECURITY DEFINER)</h3>
|
||||
<pre><code>create or replace function public.admin_fix_plan_target(
|
||||
p_plan_key text,
|
||||
p_new_target text
|
||||
) returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_plan_id uuid;
|
||||
begin
|
||||
if p_new_target not in ('clinic','therapist') then
|
||||
raise exception 'Target inválido: %', p_new_target using errcode='P0001';
|
||||
end if;
|
||||
|
||||
select id into v_plan_id
|
||||
from public.plans
|
||||
where key = p_plan_key
|
||||
for update;
|
||||
|
||||
if v_plan_id is null then
|
||||
raise exception 'Plano não encontrado: %', p_plan_key using errcode='P0001';
|
||||
end if;
|
||||
|
||||
if exists (select 1 from public.subscriptions s where s.plan_id = v_plan_id) then
|
||||
raise exception 'Plano % possui subscriptions. Migração bloqueada.', p_plan_key using errcode='P0001';
|
||||
end if;
|
||||
|
||||
perform set_config('app.plan_migration_bypass', '1', true);
|
||||
|
||||
update public.plans
|
||||
set target = p_new_target
|
||||
where id = v_plan_id;
|
||||
end
|
||||
$$;</code></pre>
|
||||
|
||||
<h3>12.3 Execução (caso real)</h3>
|
||||
<pre><code>select public.admin_fix_plan_target('clinic_free', 'clinic');</code></pre>
|
||||
|
||||
<div class="callout ok">
|
||||
<strong>Resultado:</strong> plano core corrigido, guardrail permanece ativo. Bypass vale apenas na transação.
|
||||
</div>
|
||||
|
||||
<h3>12.4 Hardening recomendado</h3>
|
||||
<pre><code>revoke execute on function public.admin_fix_plan_target(text, text) from public;
|
||||
-- depois conceder apenas ao role administrativo apropriado</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="section" id="13">
|
||||
<h2>13. Seeder idempotente: features + plan_features</h2>
|
||||
<p>
|
||||
O banco já continha features. O mapeamento MVP de plan_features foi validado e segue a ideia:
|
||||
PRO habilita tudo e limites “altos”; FREE habilita subset e limites menores.
|
||||
</p>
|
||||
<pre><code>-- padrão do seed (exemplo):
|
||||
-- insert into features(key, descricao, description) values (...)
|
||||
-- on conflict (key) do update set ...
|
||||
|
||||
-- insert into plan_features(plan_id, feature_id, enabled, limits) values (...)
|
||||
-- on conflict (plan_id, feature_id) do update set enabled=excluded.enabled, limits=excluded.limits;</code></pre>
|
||||
<div class="callout info">
|
||||
<strong>Dica operacional:</strong> manter seed idempotente evita “duplicação” e reduz bugs em ambientes de teste.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="14">
|
||||
<h2>14. Seeder idempotente: subscription de teste</h2>
|
||||
<p>Como as views dependem de uma subscription ativa, criamos uma assinatura manual de teste para um tenant real.</p>
|
||||
<pre><code>insert into public.subscriptions (
|
||||
tenant_id,
|
||||
plan_id,
|
||||
status,
|
||||
plan_key,
|
||||
"interval",
|
||||
current_period_start,
|
||||
current_period_end,
|
||||
provider,
|
||||
source
|
||||
)
|
||||
values (
|
||||
'<TENANT_UUID>',
|
||||
(select id from public.plans where key = 'clinic_free'),
|
||||
'active',
|
||||
'clinic_free',
|
||||
'month',
|
||||
now(),
|
||||
null,
|
||||
'manual',
|
||||
'manual'
|
||||
);</code></pre>
|
||||
|
||||
<div class="callout ok">
|
||||
<strong>Validação:</strong> após inserir, <code>v_tenant_active_subscription</code> e <code>v_tenant_entitlements_json</code> devem retornar dados.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="15">
|
||||
<h2>15. Front-end: padrões e telas</h2>
|
||||
<p><strong>Padrões adotados nesta sessão:</strong></p>
|
||||
<ul>
|
||||
<li>Em arquivos Vue: <strong>script</strong> → <strong>template</strong> → <strong>style</strong>.</li>
|
||||
<li>Busca com <code>FloatLabel</code> + <code>IconField</code> + <code>InputIcon</code>.</li>
|
||||
<li>Telas operacionais: DataTable com paginação, estados empty e UX “foco” via <code>?q=...</code>.</li>
|
||||
</ul>
|
||||
|
||||
<div class="callout info">
|
||||
<strong>Melhorias aplicadas:</strong> Cards para “foco”, botão voltar no topo, textos mais claros e layout mais estável.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="16">
|
||||
<h2>16. Troubleshooting (erros reais)</h2>
|
||||
|
||||
<h3>16.1 “Não é permitido alterar target do plano …”</h3>
|
||||
<p>Causa: trigger <code>guard_no_change_plan_target</code>. Solução: bypass controlado + função admin (seção 12).</p>
|
||||
|
||||
<h3>16.2 “Não é permitido alterar a key do plano padrão …”</h3>
|
||||
<p>Causa: trigger <code>guard_no_change_core_plan_key</code>. Solução: não renomear core; criar novo plano se necessário.</p>
|
||||
|
||||
<h3>16.3 Entitlements view vazia</h3>
|
||||
<p>Causa: ausência de subscription ativa em <code>v_tenant_active_subscription</code>. Solução: inserir subscription de teste (seção 14).</p>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Lembrete:</strong> quando algo “não retorna nada”, primeiro verifique as views-base antes de mexer no front.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="17">
|
||||
<h2>17. Checklist de validação</h2>
|
||||
<ul>
|
||||
<li><strong>Plans:</strong> core existe e está ativo; targets corretos.</li>
|
||||
<li><strong>Pricing:</strong> PRO tem preço vigente (active_to null); FREE pode ficar sem price.</li>
|
||||
<li><strong>Subscriptions:</strong> existe ao menos 1 assinatura ativa para testar.</li>
|
||||
<li><strong>Entitlements:</strong> <code>v_tenant_entitlements_json</code> retorna mapa com <code>allowed</code> + <code>limits</code>.</li>
|
||||
<li><strong>Guardrails:</strong> triggers ativas; correção de core somente via função admin.</li>
|
||||
<li><strong>Front:</strong> telas operacionais OK; foco via query; layout consistente.</li>
|
||||
</ul>
|
||||
<div class="callout ok">
|
||||
<strong>Meta:</strong> com este checklist, qualquer dev/operador consegue diagnosticar Billing em minutos.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<footer>
|
||||
<div><strong>Agência PSI — Documento Mestre Billing v2.0</strong></div>
|
||||
<div class="small">Gerado em 2026-03-01 10:43:18 UTC. Estrutura inspirada no padrão interno com sidebar + anchors.</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,206 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Agência PSI — Billing & Subscriptions v1.2</title>
|
||||
<style>
|
||||
body{
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
margin:0;
|
||||
padding:40px;
|
||||
background:#f6f8fc;
|
||||
color:#0f172a;
|
||||
}
|
||||
h1{font-size:28px;margin-bottom:8px;}
|
||||
h2{margin-top:40px;font-size:20px;}
|
||||
h3{margin-top:24px;font-size:16px;}
|
||||
p{line-height:1.6;color:#334155;}
|
||||
pre{
|
||||
background:#0f172a;
|
||||
color:#e2e8f0;
|
||||
padding:16px;
|
||||
border-radius:12px;
|
||||
overflow:auto;
|
||||
}
|
||||
code{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;}
|
||||
.section{margin-bottom:40px;}
|
||||
.badge{
|
||||
display:inline-block;
|
||||
padding:4px 10px;
|
||||
border-radius:999px;
|
||||
background:#e2e8f0;
|
||||
font-size:12px;
|
||||
margin-right:6px;
|
||||
}
|
||||
.rule{
|
||||
background:#e0f2fe;
|
||||
padding:14px;
|
||||
border-left:4px solid #0284c7;
|
||||
border-radius:10px;
|
||||
margin-top:12px;
|
||||
}
|
||||
.warn{
|
||||
background:#fef3c7;
|
||||
padding:14px;
|
||||
border-left:4px solid #d97706;
|
||||
border-radius:10px;
|
||||
margin-top:12px;
|
||||
}
|
||||
.danger{
|
||||
background:#fee2e2;
|
||||
padding:14px;
|
||||
border-left:4px solid #dc2626;
|
||||
border-radius:10px;
|
||||
margin-top:12px;
|
||||
}
|
||||
.ok{
|
||||
background:#dcfce7;
|
||||
padding:14px;
|
||||
border-left:4px solid #16a34a;
|
||||
border-radius:10px;
|
||||
margin-top:12px;
|
||||
}
|
||||
footer{
|
||||
margin-top:60px;
|
||||
font-size:12px;
|
||||
color:#64748b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Billing & Subscriptions — v1.2</h1>
|
||||
<p><strong>Agência PSI</strong> — Documento consolidado da sessão técnica sobre Subscriptions, Guardrails e Seeder.</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>1. Escopo desta versão</h2>
|
||||
<p>Este documento consolida tudo o que foi tratado nesta sessão:</p>
|
||||
<ul>
|
||||
<li>Modelagem real da tabela <code>subscriptions</code></li>
|
||||
<li>Histórico via <code>subscription_events</code></li>
|
||||
<li>Triggers (guardrails) de proteção</li>
|
||||
<li>Views oficiais</li>
|
||||
<li>Seeder completo (planos + preços + metadata pública)</li>
|
||||
<li>Erros reais encontrados e solução aplicada</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. Estrutura confirmada — subscriptions</h2>
|
||||
<pre><code>id uuid PK
|
||||
tenant_id uuid NULL
|
||||
user_id uuid NULL
|
||||
plan_id uuid NOT NULL
|
||||
plan_key text NULL
|
||||
interval text CHECK ('month','year')
|
||||
status text DEFAULT 'active'
|
||||
current_period_start timestamptz
|
||||
current_period_end timestamptz
|
||||
provider text DEFAULT 'manual'
|
||||
cancel_at_period_end boolean DEFAULT false
|
||||
created_at timestamptz DEFAULT now()
|
||||
updated_at timestamptz DEFAULT now()</code></pre>
|
||||
|
||||
<div class="rule">
|
||||
Assinatura de clínica exige <strong>tenant_id</strong>.
|
||||
Assinatura de terapeuta pode usar <strong>user_id</strong>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. Guardrails (Proteções de Integridade)</h2>
|
||||
|
||||
<h3>3.1 Impedir deletar planos core</h3>
|
||||
<pre><code>create or replace function guard_no_delete_core_plans()
|
||||
returns trigger language plpgsql as $$
|
||||
begin
|
||||
if old.key in ('clinic_free','clinic_pro','therapist_free','therapist_pro') then
|
||||
raise exception 'Plano padrão (%) não pode ser removido.', old.key;
|
||||
end if;
|
||||
return old;
|
||||
end $$;</code></pre>
|
||||
|
||||
<h3>3.2 Impedir alterar target</h3>
|
||||
<pre><code>create or replace function guard_no_change_plan_target()
|
||||
returns trigger language plpgsql as $$
|
||||
begin
|
||||
if new.target is distinct from old.target then
|
||||
raise exception 'Não é permitido alterar target do plano.';
|
||||
end if;
|
||||
return new;
|
||||
end $$;</code></pre>
|
||||
|
||||
<h3>3.3 Impedir alterar key core</h3>
|
||||
<pre><code>create or replace function guard_no_change_core_plan_key()
|
||||
returns trigger language plpgsql as $$
|
||||
begin
|
||||
if old.key in ('clinic_free','clinic_pro','therapist_free','therapist_pro')
|
||||
and new.key is distinct from old.key then
|
||||
raise exception 'Não é permitido alterar a key do plano padrão.';
|
||||
end if;
|
||||
return new;
|
||||
end $$;</code></pre>
|
||||
|
||||
<div class="warn">
|
||||
Esses guardrails impediram alterações indevidas quando tentamos renomear planos core.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Views Oficiais</h2>
|
||||
<p><strong>v_public_pricing</strong> — Tela pública de preços.</p>
|
||||
<p><strong>v_tenant_active_subscription</strong> — Plano vigente do tenant.</p>
|
||||
<p><strong>v_subscription_health</strong> — Diagnóstico de inconsistências.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. Seeder Oficial (MVP)</h2>
|
||||
|
||||
<pre><code>insert into plans (key,name,target,is_active)
|
||||
values
|
||||
('clinic_free','Clinic Free','clinic',true),
|
||||
('clinic_pro','Clinic Pro','clinic',true),
|
||||
('therapist_free','Therapist Free','therapist',true),
|
||||
('therapist_pro','Therapist Pro','therapist',true)
|
||||
on conflict (key) do update set
|
||||
name=excluded.name,
|
||||
target=excluded.target,
|
||||
is_active=excluded.is_active;</code></pre>
|
||||
|
||||
<div class="ok">
|
||||
Seeder é idempotente. Pode rodar múltiplas vezes sem duplicar.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>6. Incidentes reais resolvidos</h2>
|
||||
|
||||
<h3>6.1 Pricing retornando null</h3>
|
||||
<p>Causa: não havia preço vigente (is_active=true e active_to is null).</p>
|
||||
|
||||
<h3>6.2 Erro ao alterar plano padrão</h3>
|
||||
<p>Causa: trigger guard_no_change_core_plan_key bloqueando alteração.</p>
|
||||
|
||||
<h3>6.3 Assinatura sem tenant_id</h3>
|
||||
<p>Causa: regra de negócio no banco impedindo clinic sem tenant.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>7. Diretrizes finais</h2>
|
||||
<ul>
|
||||
<li>Plano nunca deve ser inferido do role.</li>
|
||||
<li>UI deve consumir apenas views oficiais.</li>
|
||||
<li>Plano core nunca deve ser renomeado.</li>
|
||||
<li>Preço sempre deve ter vigência ativa.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Agência PSI — Billing & Subscriptions v1.2<br>
|
||||
Documento gerado automaticamente.
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,308 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Arquitetura Técnica Completa — Billing & Assinaturas | Agência PSI</title>
|
||||
<style>
|
||||
body{font-family:Arial,Helvetica,sans-serif;margin:40px;line-height:1.65;color:#111}
|
||||
h1,h2,h3{margin-top:36px}
|
||||
code,pre{background:#f4f4f4;padding:12px;border-radius:8px;display:block;overflow:auto;font-size:13px}
|
||||
.section{margin-bottom:40px}
|
||||
.small{font-size:13px;color:#555}
|
||||
ul{margin-left:20px}
|
||||
.diagram{background:#fafafa;border:1px solid #ddd;padding:16px;border-radius:8px;font-family:monospace;font-size:13px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Arquitetura Técnica Completa — Billing & Assinaturas</h1>
|
||||
<p class="small">Projeto: Agência PSI • Documento estrutural definitivo do domínio de Billing.</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>1. Visão Arquitetural Geral</h2>
|
||||
|
||||
<div class="diagram">
|
||||
USUÁRIO
|
||||
│
|
||||
▼
|
||||
subscription_intents (VIEW unificada)
|
||||
│
|
||||
▼ (RPC activate_subscription_from_intent)
|
||||
subscriptions
|
||||
│
|
||||
▼
|
||||
subscription_events (auditoria)
|
||||
│
|
||||
▼
|
||||
entitlements (derivados do plano)
|
||||
</div>
|
||||
|
||||
<p>Separação estrutural:</p>
|
||||
<ul>
|
||||
<li><strong>plans</strong> → catálogo</li>
|
||||
<li><strong>plan_prices</strong> → preço versionado</li>
|
||||
<li><strong>subscription_intents_*</strong> → intenção pré-pagamento</li>
|
||||
<li><strong>subscriptions</strong> → assinatura ativa</li>
|
||||
<li><strong>subscription_events</strong> → histórico</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. Estados Oficiais da Assinatura</h2>
|
||||
<pre>
|
||||
pending
|
||||
active
|
||||
past_due
|
||||
suspended
|
||||
cancelled
|
||||
expired
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. Índice de Integridade de Preços</h2>
|
||||
<pre>
|
||||
create unique index if not exists uq_plan_price_active
|
||||
on plan_prices (plan_id, interval, currency)
|
||||
where is_active = true and active_to is null;
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Função Completa — activate_subscription_from_intent</h2>
|
||||
<pre>
|
||||
CREATE OR REPLACE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid)
|
||||
RETURNS subscriptions
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $function$
|
||||
declare
|
||||
v_intent record;
|
||||
v_sub public.subscriptions;
|
||||
v_days int;
|
||||
v_user_id uuid;
|
||||
v_plan_id uuid;
|
||||
v_target text;
|
||||
begin
|
||||
|
||||
select * into v_intent
|
||||
from public.subscription_intents
|
||||
where id = p_intent_id;
|
||||
|
||||
if not found then
|
||||
raise exception 'Intent não encontrada';
|
||||
end if;
|
||||
|
||||
if v_intent.status <> 'paid' then
|
||||
raise exception 'Intent precisa estar paid';
|
||||
end if;
|
||||
|
||||
select p.id, p.target
|
||||
into v_plan_id, v_target
|
||||
from public.plans p
|
||||
where p.key = v_intent.plan_key
|
||||
limit 1;
|
||||
|
||||
if v_plan_id is null then
|
||||
raise exception 'Plano não encontrado';
|
||||
end if;
|
||||
|
||||
v_target := lower(coalesce(v_target,''));
|
||||
|
||||
if v_target = 'clinic' and v_intent.tenant_id is null then
|
||||
raise exception 'Intent clinic exige tenant_id';
|
||||
end if;
|
||||
|
||||
if v_target = 'therapist' and v_intent.tenant_id is not null then
|
||||
raise exception 'Intent therapist não deve ter tenant_id';
|
||||
end if;
|
||||
|
||||
v_days := case when v_intent.interval = 'year' then 365 else 30 end;
|
||||
|
||||
v_user_id := coalesce(v_intent.created_by_user_id, v_intent.user_id);
|
||||
|
||||
if v_user_id is null then
|
||||
raise exception 'user_id obrigatório';
|
||||
end if;
|
||||
|
||||
if v_target = 'clinic' then
|
||||
update subscriptions
|
||||
set status = 'cancelled',
|
||||
cancelled_at = now()
|
||||
where tenant_id = v_intent.tenant_id
|
||||
and status = 'active';
|
||||
else
|
||||
update subscriptions
|
||||
set status = 'cancelled',
|
||||
cancelled_at = now()
|
||||
where user_id = v_user_id
|
||||
and tenant_id is null
|
||||
and status = 'active';
|
||||
end if;
|
||||
|
||||
insert into subscriptions (
|
||||
user_id,
|
||||
plan_id,
|
||||
status,
|
||||
current_period_start,
|
||||
current_period_end,
|
||||
tenant_id,
|
||||
plan_key,
|
||||
interval,
|
||||
provider,
|
||||
started_at,
|
||||
activated_at
|
||||
)
|
||||
values (
|
||||
v_user_id,
|
||||
v_plan_id,
|
||||
'active',
|
||||
now(),
|
||||
now() + make_interval(days => v_days),
|
||||
case when v_target='clinic' then v_intent.tenant_id else null end,
|
||||
v_intent.plan_key,
|
||||
v_intent.interval,
|
||||
'manual',
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
returning * into v_sub;
|
||||
|
||||
return v_sub;
|
||||
|
||||
end;
|
||||
$function$;
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. Função Completa — transition_subscription (segura)</h2>
|
||||
<pre>
|
||||
create or replace function public.transition_subscription(
|
||||
p_subscription_id uuid,
|
||||
p_to_status text,
|
||||
p_reason text default null,
|
||||
p_metadata jsonb default null
|
||||
)
|
||||
returns subscriptions
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_sub subscriptions;
|
||||
v_uid uuid;
|
||||
v_allowed boolean := false;
|
||||
begin
|
||||
|
||||
v_uid := auth.uid();
|
||||
|
||||
select * into v_sub
|
||||
from subscriptions
|
||||
where id = p_subscription_id;
|
||||
|
||||
if not found then
|
||||
raise exception 'Assinatura não encontrada';
|
||||
end if;
|
||||
|
||||
if is_saas_admin() then
|
||||
v_allowed := true;
|
||||
end if;
|
||||
|
||||
if not v_allowed
|
||||
and v_sub.tenant_id is null
|
||||
and v_sub.user_id = v_uid then
|
||||
v_allowed := true;
|
||||
end if;
|
||||
|
||||
if not v_allowed
|
||||
and v_sub.tenant_id is not null then
|
||||
|
||||
if exists (
|
||||
select 1 from tenant_members tm
|
||||
where tm.tenant_id = v_sub.tenant_id
|
||||
and tm.user_id = v_uid
|
||||
and tm.status = 'active'
|
||||
and tm.role = 'tenant_admin'
|
||||
) then
|
||||
v_allowed := true;
|
||||
end if;
|
||||
|
||||
end if;
|
||||
|
||||
if not v_allowed then
|
||||
raise exception 'Sem permissão';
|
||||
end if;
|
||||
|
||||
update subscriptions
|
||||
set status = p_to_status,
|
||||
updated_at = now()
|
||||
where id = p_subscription_id
|
||||
returning * into v_sub;
|
||||
|
||||
insert into subscription_events (
|
||||
subscription_id,
|
||||
owner_id,
|
||||
event_type,
|
||||
created_at,
|
||||
created_by,
|
||||
source,
|
||||
reason,
|
||||
metadata
|
||||
)
|
||||
values (
|
||||
v_sub.id,
|
||||
coalesce(v_sub.tenant_id, v_sub.user_id),
|
||||
'status_changed',
|
||||
now(),
|
||||
v_uid,
|
||||
'manual_transition',
|
||||
p_reason,
|
||||
p_metadata
|
||||
);
|
||||
|
||||
return v_sub;
|
||||
|
||||
end;
|
||||
$$;
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>6. Máquina de Estados Recomendada</h2>
|
||||
|
||||
<div class="diagram">
|
||||
pending → active → past_due → suspended → cancelled
|
||||
↓
|
||||
expired
|
||||
</div>
|
||||
|
||||
<p>Recomendação futura: validar allowed_transitions em tabela dedicada.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>7. Checklist de Validação Estrutural</h2>
|
||||
<ul>
|
||||
<li>Intent paga gera subscription ativa</li>
|
||||
<li>subscription_id vinculado corretamente</li>
|
||||
<li>Cancelamento gera evento</li>
|
||||
<li>Reativação preserva histórico</li>
|
||||
<li>Tenant isolation validado</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>8. Roadmap Estrutural Futuro</h2>
|
||||
<ul>
|
||||
<li>State machine formal com allowed_transitions</li>
|
||||
<li>Automação de expiração por cron</li>
|
||||
<li>Integração Stripe mantendo arquitetura</li>
|
||||
<li>Health monitor automatizado</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
<p class="small">Documento técnico estrutural consolidado após implementação real validada.</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,231 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Agência PSI — Billing (Arquitetura Oficial v1.1)</title>
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--bg0:#f6f8fc;
|
||||
--bg1:#eef2f8;
|
||||
--panel:rgba(255,255,255,.85);
|
||||
--border:rgba(15,23,42,.10);
|
||||
--text:rgba(15,23,42,.92);
|
||||
--muted:rgba(15,23,42,.70);
|
||||
--accent:#2563eb;
|
||||
--ok:#047857;
|
||||
--warn:#b45309;
|
||||
--danger:#b91c1c;
|
||||
--radius:18px;
|
||||
--shadow:0 18px 60px rgba(2,6,23,.10);
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{
|
||||
margin:0;
|
||||
font-family:var(--sans);
|
||||
background:linear-gradient(180deg,var(--bg0),var(--bg1));
|
||||
color:var(--text);
|
||||
}
|
||||
.layout{
|
||||
max-width:1100px;
|
||||
margin:0 auto;
|
||||
padding:40px 20px 80px;
|
||||
}
|
||||
header{
|
||||
border:1px solid var(--border);
|
||||
background:var(--panel);
|
||||
border-radius:var(--radius);
|
||||
padding:28px;
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
h1{margin:0 0 12px;font-size:30px}
|
||||
h2{margin-top:40px;font-size:20px}
|
||||
p{color:var(--muted);line-height:1.6}
|
||||
.section{
|
||||
margin-top:30px;
|
||||
border:1px solid var(--border);
|
||||
background:var(--panel);
|
||||
padding:24px;
|
||||
border-radius:var(--radius);
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
.rule{
|
||||
border-left:4px solid var(--accent);
|
||||
background:rgba(37,99,235,.08);
|
||||
padding:14px;
|
||||
border-radius:12px;
|
||||
margin:18px 0;
|
||||
}
|
||||
.ok{
|
||||
border-left:4px solid var(--ok);
|
||||
background:rgba(4,120,87,.08);
|
||||
padding:14px;
|
||||
border-radius:12px;
|
||||
margin:18px 0;
|
||||
}
|
||||
.warn{
|
||||
border-left:4px solid var(--warn);
|
||||
background:rgba(180,83,9,.10);
|
||||
padding:14px;
|
||||
border-radius:12px;
|
||||
margin:18px 0;
|
||||
}
|
||||
.danger{
|
||||
border-left:4px solid var(--danger);
|
||||
background:rgba(185,28,28,.08);
|
||||
padding:14px;
|
||||
border-radius:12px;
|
||||
margin:18px 0;
|
||||
}
|
||||
code,pre{font-family:var(--mono);font-size:13px}
|
||||
pre{
|
||||
background:rgba(2,6,23,.05);
|
||||
padding:14px;
|
||||
border-radius:12px;
|
||||
overflow:auto;
|
||||
}
|
||||
table{
|
||||
width:100%;
|
||||
border-collapse:collapse;
|
||||
margin-top:16px;
|
||||
}
|
||||
th,td{
|
||||
border:1px solid var(--border);
|
||||
padding:10px;
|
||||
font-size:13px;
|
||||
}
|
||||
th{background:rgba(15,23,42,.04)}
|
||||
footer{
|
||||
text-align:center;
|
||||
margin-top:50px;
|
||||
font-size:12px;
|
||||
color:var(--muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="layout">
|
||||
|
||||
<header>
|
||||
<h1>Billing — Arquitetura Oficial v1.1</h1>
|
||||
<p>Versão 1.1 inclui procedimento formal de migração controlada para planos core, mantendo guardrails ativos e auditáveis.</p>
|
||||
</header>
|
||||
|
||||
<div class="section">
|
||||
<h2>1. Fundamentos do Domínio</h2>
|
||||
<p>Billing define recursos e limites do produto. Não é camada de UI. É camada estrutural.</p>
|
||||
<div class="rule"><strong>Princípio:</strong> Role (RBAC) ≠ Plano (Billing). Plano dirige features e limites; role dirige acesso.</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. Planos Core (MVP)</h2>
|
||||
<ul>
|
||||
<li>clinic_free</li>
|
||||
<li>clinic_pro</li>
|
||||
<li>therapist_free</li>
|
||||
<li>therapist_pro</li>
|
||||
</ul>
|
||||
<div class="ok"><strong>Política:</strong> Planos core são estruturalmente protegidos por triggers.</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. Governança de Guardrails</h2>
|
||||
<ul>
|
||||
<li>Impedem alterar key de plano core</li>
|
||||
<li>Impedem alterar target de plano core</li>
|
||||
<li>Impedem deletar plano com subscription ativa</li>
|
||||
</ul>
|
||||
<div class="danger"><strong>Proibido:</strong> desabilitar triggers diretamente em produção.</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Procedimento Oficial de Correção de Plano Core</h2>
|
||||
<p>Correções estruturais devem ocorrer via função administrativa controlada.</p>
|
||||
|
||||
<pre>
|
||||
create or replace function admin_fix_plan_target(
|
||||
p_plan_key text,
|
||||
p_new_target text
|
||||
) returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_plan_id uuid;
|
||||
begin
|
||||
select id into v_plan_id
|
||||
from plans
|
||||
where key = p_plan_key
|
||||
for update;
|
||||
|
||||
if v_plan_id is null then
|
||||
raise exception 'Plano não encontrado.';
|
||||
end if;
|
||||
|
||||
if exists (
|
||||
select 1 from subscriptions where plan_id = v_plan_id
|
||||
) then
|
||||
raise exception 'Plano possui subscriptions ativas.';
|
||||
end if;
|
||||
|
||||
update plans
|
||||
set target = p_new_target
|
||||
where id = v_plan_id;
|
||||
|
||||
end;
|
||||
$$;
|
||||
</pre>
|
||||
|
||||
<div class="warn">
|
||||
Esta função deve ser executada apenas por role administrativa e registrada em log de auditoria.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. Entitlements (Contrato Oficial)</h2>
|
||||
<p>Entitlements são derivados exclusivamente de <code>plan_features</code>.</p>
|
||||
<pre>
|
||||
plan_features (
|
||||
plan_id uuid,
|
||||
feature_id uuid,
|
||||
enabled boolean,
|
||||
limits jsonb
|
||||
)
|
||||
</pre>
|
||||
<div class="rule">
|
||||
Formato oficial de limits:
|
||||
{"max": 30}
|
||||
{"per_month": 40}
|
||||
{"max_users": 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>6. Preço Vigente</h2>
|
||||
<pre>
|
||||
create unique index uq_plan_price_active
|
||||
on plan_prices (plan_id, interval, currency)
|
||||
where is_active = true and active_to is null;
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>7. Onboarding</h2>
|
||||
<ul>
|
||||
<li>Tenant clinic → clinic_free</li>
|
||||
<li>Tenant therapist → therapist_free</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Agência PSI — Billing Arquitetura Oficial v1.1
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
957
Nova-Dev-Doc/Planos/dev-documentacao-planos-seeder-v1.html
Normal file
957
Nova-Dev-Doc/Planos/dev-documentacao-planos-seeder-v1.html
Normal file
@@ -0,0 +1,957 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Documentação Interna — Planos, Assinaturas e Seeder (Billing) | Agência PSI</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg0:#f6f8fc;
|
||||
--bg1:#eef2f8;
|
||||
--panel:rgba(255,255,255,.78);
|
||||
--panel2:rgba(255,255,255,.92);
|
||||
--border:rgba(15,23,42,.10);
|
||||
--text:rgba(15,23,42,.92);
|
||||
--muted:rgba(15,23,42,.70);
|
||||
--muted2:rgba(15,23,42,.56);
|
||||
--accent:#2563eb;
|
||||
--warn:#b45309;
|
||||
--danger:#b91c1c;
|
||||
--ok:#047857;
|
||||
--shadow: 0 18px 60px rgba(2,6,23,.10);
|
||||
--radius: 16px;
|
||||
--radius2: 22px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
}
|
||||
*{ box-sizing:border-box; }
|
||||
body{
|
||||
margin:0;
|
||||
font-family: var(--sans);
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(1200px 600px at 18% -10%, rgba(37,99,235,.10) 0%, transparent 60%),
|
||||
radial-gradient(900px 520px at 90% 10%, rgba(2,132,199,.10) 0%, transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
}
|
||||
.layout{
|
||||
display:grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 20px;
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 18px 42px;
|
||||
}
|
||||
header{
|
||||
grid-column: 1 / -1;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.92), rgba(255,255,255,.72));
|
||||
border-radius: var(--radius2);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.kicker{
|
||||
font-size:12px;
|
||||
letter-spacing:.08em;
|
||||
text-transform:uppercase;
|
||||
color:var(--muted2);
|
||||
margin:0 0 8px;
|
||||
}
|
||||
h1{
|
||||
margin:0 0 8px;
|
||||
font-size:30px;
|
||||
letter-spacing:-0.02em;
|
||||
}
|
||||
.subtitle{
|
||||
margin:0;
|
||||
color:var(--muted);
|
||||
max-width:980px;
|
||||
line-height:1.55;
|
||||
font-size:14px;
|
||||
}
|
||||
aside{
|
||||
position:sticky;
|
||||
top:18px;
|
||||
align-self:start;
|
||||
border:1px solid var(--border);
|
||||
background:var(--panel2);
|
||||
border-radius:var(--radius);
|
||||
box-shadow:var(--shadow);
|
||||
overflow:hidden;
|
||||
}
|
||||
.toc-head{
|
||||
padding:14px;
|
||||
border-bottom:1px solid var(--border);
|
||||
background:rgba(15,23,42,.02);
|
||||
}
|
||||
.toc-title{ margin:0 0 6px; font-weight:800; }
|
||||
.toc-desc{ margin:0; font-size:12px; color:var(--muted); }
|
||||
.toc{ padding:10px; }
|
||||
.toc a{
|
||||
display:block;
|
||||
padding:8px 10px;
|
||||
border-radius:12px;
|
||||
font-size:13px;
|
||||
color:rgba(15,23,42,.88);
|
||||
text-decoration:none;
|
||||
}
|
||||
.toc a:hover{ background:rgba(37,99,235,.06); }
|
||||
main{
|
||||
border:1px solid var(--border);
|
||||
background:var(--panel);
|
||||
backdrop-filter: blur(6px);
|
||||
border-radius:var(--radius);
|
||||
box-shadow:var(--shadow);
|
||||
overflow:hidden;
|
||||
}
|
||||
.section{ padding:18px; border-top:1px solid var(--border); }
|
||||
.section:first-child{ border-top:none; }
|
||||
h2{ margin:0 0 10px; font-size:18px; }
|
||||
h3{ margin:12px 0 8px; font-size:14px; color:rgba(15,23,42,.86); letter-spacing:.01em; }
|
||||
p{ margin:0 0 10px; color:var(--muted); line-height:1.65; }
|
||||
ul{ margin:10px 0 0 18px; color:var(--muted); }
|
||||
li{ margin:6px 0; }
|
||||
.card{
|
||||
border:1px solid var(--border);
|
||||
background:rgba(255,255,255,.72);
|
||||
border-radius:var(--radius);
|
||||
padding:14px;
|
||||
}
|
||||
.rule{
|
||||
border-left:4px solid var(--accent);
|
||||
background:rgba(37,99,235,.08);
|
||||
padding:12px;
|
||||
border-radius:12px;
|
||||
margin:12px 0;
|
||||
color: rgba(15,23,42,.82);
|
||||
}
|
||||
.ok{
|
||||
border-left:4px solid var(--ok);
|
||||
background:rgba(4,120,87,.08);
|
||||
padding:12px;
|
||||
border-radius:12px;
|
||||
margin:12px 0;
|
||||
color: rgba(15,23,42,.82);
|
||||
}
|
||||
.warn{
|
||||
border-left:4px solid var(--warn);
|
||||
background:rgba(180,83,9,.10);
|
||||
padding:12px;
|
||||
border-radius:12px;
|
||||
margin:12px 0;
|
||||
color: rgba(15,23,42,.82);
|
||||
}
|
||||
.danger{
|
||||
border-left:4px solid var(--danger);
|
||||
background:rgba(185,28,28,.08);
|
||||
padding:12px;
|
||||
border-radius:12px;
|
||||
margin:12px 0;
|
||||
color: rgba(15,23,42,.82);
|
||||
}
|
||||
.table{
|
||||
width:100%;
|
||||
border-collapse:separate;
|
||||
border-spacing:0;
|
||||
margin-top:10px;
|
||||
border:1px solid var(--border);
|
||||
border-radius:var(--radius);
|
||||
overflow:hidden;
|
||||
background: rgba(255,255,255,.72);
|
||||
}
|
||||
.table th, .table td{
|
||||
padding:10px 12px;
|
||||
border-bottom:1px solid rgba(15,23,42,.08);
|
||||
font-size:13px;
|
||||
color:rgba(15,23,42,.88);
|
||||
vertical-align: top;
|
||||
}
|
||||
.table th{
|
||||
background:rgba(15,23,42,.03);
|
||||
font-weight:800;
|
||||
color: rgba(15,23,42,.72);
|
||||
}
|
||||
.table tr:last-child td{ border-bottom:none; }
|
||||
code, pre{ font-family:var(--mono); font-size:12px; }
|
||||
pre{
|
||||
background:rgba(2,6,23,.04);
|
||||
border:1px solid var(--border);
|
||||
border-radius:var(--radius);
|
||||
padding:12px;
|
||||
margin-top:10px;
|
||||
overflow:auto;
|
||||
line-height: 1.55;
|
||||
color: rgba(15,23,42,.90);
|
||||
}
|
||||
.pill{
|
||||
display:inline-block;
|
||||
padding:6px 10px;
|
||||
border-radius:999px;
|
||||
border:1px solid var(--border);
|
||||
background:rgba(255,255,255,.72);
|
||||
font-size:12px;
|
||||
margin:4px 6px 0 0;
|
||||
color: rgba(15,23,42,.78);
|
||||
}
|
||||
.grid2{
|
||||
display:grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.kv{
|
||||
display:flex;
|
||||
gap:10px;
|
||||
align-items:flex-start;
|
||||
justify-content:space-between;
|
||||
border:1px solid rgba(15,23,42,.08);
|
||||
background:rgba(255,255,255,.72);
|
||||
border-radius:14px;
|
||||
padding:12px;
|
||||
}
|
||||
.kv b{ color: rgba(15,23,42,.88); }
|
||||
.kv span{ color: var(--muted); font-size:12px; }
|
||||
.path{
|
||||
display:inline-block;
|
||||
padding:3px 8px;
|
||||
border-radius:10px;
|
||||
border:1px solid rgba(15,23,42,.12);
|
||||
background:rgba(255,255,255,.72);
|
||||
color:rgba(15,23,42,.88);
|
||||
font-family:var(--mono);
|
||||
font-size:12px;
|
||||
margin:2px 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
footer{
|
||||
grid-column:1 / -1;
|
||||
margin-top:14px;
|
||||
text-align:center;
|
||||
font-size:12px;
|
||||
color:var(--muted2);
|
||||
}
|
||||
@media (max-width: 980px){
|
||||
.layout{ grid-template-columns:1fr; }
|
||||
aside{ position:relative; top:0; }
|
||||
.grid2{ grid-template-columns: 1fr; }
|
||||
}
|
||||
@media print{
|
||||
header, aside, main{ box-shadow:none; }
|
||||
.section{ page-break-inside:avoid; }
|
||||
body{ background:white; }
|
||||
main, aside{ background:white; }
|
||||
.rule,.ok,.warn,.danger{ background:white; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="layout">
|
||||
|
||||
<header>
|
||||
<p class="kicker">Agência PSI • Documento interno</p>
|
||||
<h1>Planos, Assinaturas e Seeder — Billing (MVP)</h1>
|
||||
<p class="subtitle">
|
||||
Documentação interna do <strong>domínio de Billing</strong> do SaaS multi-tenant (Agência PSI),
|
||||
cobrindo <strong>modelo de dados</strong>, <strong>views oficiais</strong>, <strong>catálogo de planos</strong>,
|
||||
<strong>princípios de produto</strong> e um <strong>seeder idempotente</strong> para instalação nova.
|
||||
O objetivo é impedir divergência entre <em>UI</em>, <em>backend</em> e <em>banco</em> (e evitar pricing nulo, upgrade quebrado e gating inconsistente).<br><br><strong>Atualizado em:</strong> 2026-03-01 (após validações reais do schema e execução do seeder).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<aside>
|
||||
<div class="toc-head">
|
||||
<div class="toc-title">Sumário</div>
|
||||
<p class="toc-desc">Navegação rápida entre as seções.</p>
|
||||
</div>
|
||||
<nav class="toc">
|
||||
<a href="#1-visao-geral">1. Visão geral do domínio</a>
|
||||
<a href="#2-principios">2. Princípios e decisões</a>
|
||||
<a href="#3-conceitos">3. Conceitos: role vs target vs plano vs feature</a>
|
||||
<a href="#4-modelo">4. Modelo de dados (Postgres/Supabase)</a>
|
||||
<a href="#5-views">5. Views oficiais (fonte de verdade)</a>
|
||||
<a href="#6-catalogo">6. Catálogo de Planos (MVP)</a>
|
||||
<a href="#7-precos">7. Preços (MVP) e vigência</a>
|
||||
<a href="#8-seeder">8. Seeder (nova instalação) — SQL idempotente</a>
|
||||
<a href="#9-onboarding">9. Onboarding & Upgrade (fluxo)</a>
|
||||
<a href="#10-runbook">10. Operação (runbook rápido)</a>
|
||||
<a href="#11-qa">11. Checklist de QA</a>
|
||||
<a href="#12-prompt">12. Prompt Mestre — continuação (Billing)</a>
|
||||
<a href="#13-tags">Tags</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
|
||||
<section class="section" id="1-visao-geral">
|
||||
<h2>1. Visão geral do domínio</h2>
|
||||
<p>
|
||||
O Billing define <strong>o que pode</strong> e <strong>o quanto pode</strong> dentro do produto.
|
||||
Ele não é “uma tela de preço”: é a camada que decide
|
||||
<strong>limites</strong> (quantidade), <strong>habilitações</strong> (booleanos) e <strong>estado de assinatura</strong>.
|
||||
</p>
|
||||
<div class="rule">
|
||||
<strong>Definição operacional:</strong> o Billing é composto por (1) catálogo de planos, (2) preços vigentes,
|
||||
(3) assinatura ativa por tenant/usuário, e (4) entitlements derivados do plano.
|
||||
</div>
|
||||
<div class="ok">
|
||||
<strong>Objetivo do MVP:</strong> todo mundo começa no <strong>FREE</strong> (clínica e terapeuta).
|
||||
Paciente não é pagante; o “portal do paciente” é um recurso habilitado pelo plano do terapeuta/clínica.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="2-principios">
|
||||
<h2>2. Princípios e decisões</h2>
|
||||
<ul>
|
||||
<li><strong>Separação rígida</strong>: Role (RBAC) não é Plano (Billing). Plano define recursos; role define permissões de acesso.</li>
|
||||
<li><strong>Planos por target</strong>: existe plano de <code>clinic</code> e plano de <code>therapist</code>. Isso impede aplicar plano errado em outro tipo de conta.</li>
|
||||
<li><strong>Tudo começa gratuito</strong>: criação de tenant atribui automaticamente um plano <code>*_free</code>.</li>
|
||||
<li><strong>Pricing público por View</strong>: a UI de preços deve consumir <code>v_public_pricing</code> (não montar preço manual no front).</li>
|
||||
<li><strong>Preço é temporal</strong>: preço tem vigência (<code>active_from</code>/<code>active_to</code>) e um “ativo atual”.</li>
|
||||
<li><strong>Seeder é padrão</strong>: nova instalação do banco deve nascer com os 4 planos do MVP + public metadata + preços PRO.</li>
|
||||
</ul>
|
||||
<div class="warn">
|
||||
<strong>Problema real observado:</strong> a view <code>v_public_pricing</code> retornou preços <code>null</code> porque havia histórico em <code>plan_prices</code> mas nenhum registro vigente (todos com <code>is_active=false</code> e <code>active_to</code> preenchido).
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="3-conceitos">
|
||||
<h2>3. Conceitos: role vs target vs plano vs feature</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="grid2">
|
||||
<div class="kv"><b>Role (RBAC)</b><span>permissão de UI/rotas (clinic_admin, therapist, patient etc.)</span></div>
|
||||
<div class="kv"><b>Target (produto)</b><span>tipo de conta: <code>clinic</code> ou <code>therapist</code></span></div>
|
||||
<div class="kv"><b>Plano (billing)</b><span>free/pro por target; é o “pacote” contratado</span></div>
|
||||
<div class="kv"><b>Feature / Limite</b><span>entitlements: booleanos e limites numéricos derivados do plano</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>3.1 Regra do produto: “um usuário pode ser paciente e terapeuta”</h3>
|
||||
<p>
|
||||
Essa regra é de <strong>identidade</strong> (um mesmo <em>user</em> pode estar em múltiplos contextos),
|
||||
mas o plano é aplicado ao <strong>tenant</strong> (clínica/terapeuta). Assim, um usuário pode:
|
||||
</p>
|
||||
<ul>
|
||||
<li>estar em um tenant therapist (com <code>therapist_free/pro</code>)</li>
|
||||
<li>estar em um tenant clinic (com <code>clinic_free/pro</code>)</li>
|
||||
<li>acessar portal de paciente como consumidor do serviço (sem plano próprio)</li>
|
||||
</ul>
|
||||
|
||||
<div class="rule">
|
||||
<strong>Consequência:</strong> plano nunca deve ser inferido do role.
|
||||
O role dirige menus/rotas; o plano dirige features/limites.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="4-modelo">
|
||||
<h2>4. Modelo de dados (Postgres/Supabase)</h2>
|
||||
|
||||
<h3>4.1 Tabelas mapeadas (schema: public)</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tabela</th>
|
||||
<th>Responsabilidade</th>
|
||||
<th>Observações práticas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>plans</code></td>
|
||||
<td>Catálogo interno de planos (id, <code>key</code>, <code>target</code>, flags e campos legados de preço)</td>
|
||||
<td><strong>Não</strong> usar <code>plans.price_cents</code> como preço público; é legado/fallback.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>plan_prices</code></td>
|
||||
<td>Preços por intervalo e moeda, com vigência</td>
|
||||
<td>Fonte do valor monetário; a view pública agrega mensal/anual.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>plan_features</code></td>
|
||||
<td>Entitlements por plano (limites e habilitações)</td>
|
||||
<td>Define o que o produto permite no runtime (gating).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>plan_public</code></td>
|
||||
<td>Marketing/metadata do plano (nome público, descrição, badge, destaque, visibilidade)</td>
|
||||
<td>Direciona a tela de preços e o “tom” comercial.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>plan_public_bullets</code></td>
|
||||
<td>Bullets de venda por plano</td>
|
||||
<td>Lista simples; a view pode agregá-las em array.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>subscriptions</code></td>
|
||||
<td>Assinatura ativa (por tenant ou user) e status</td>
|
||||
<td>Fonte de verdade do plano vigente do tenant.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>subscription_events</code></td>
|
||||
<td>Histórico de mudanças (old/new plan)</td>
|
||||
<td>Útil para auditoria e debug de upgrades.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>subscription_intents</code></td>
|
||||
<td>Intenção/checkout pendente</td>
|
||||
<td>Controla upgrade antes de virar subscription.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>entitlements_invalidation</code></td>
|
||||
<td>Invalidação de cache de entitlements</td>
|
||||
<td>Garante refresh quando plano muda.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>4.2 Padrão de “preço vigente”</h3>
|
||||
<div class="warn">
|
||||
<strong>Armada clássica:</strong> se não existir pelo menos 1 preço vigente por <code>(plan_id, interval, currency)</code>,
|
||||
a tela de pricing pode retornar <code>null</code> e o checkout fica sem referência.
|
||||
</div>
|
||||
|
||||
<pre><code>-- Um preço é considerado vigente quando:
|
||||
-- is_active = true
|
||||
-- AND active_to IS NULL
|
||||
-- AND now() >= active_from (se active_from existir)
|
||||
</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="section" id="5-views">
|
||||
<h2>5. Views oficiais (fonte de verdade)</h2>
|
||||
|
||||
<h3>5.1 View pública de pricing (UI deve consumir)</h3>
|
||||
<div class="rule">
|
||||
<strong>UI MUST:</strong> a tela de preços deve consultar <code>v_public_pricing</code>.
|
||||
Evitar compor preços no front com join manual, pois isso cria divergência e bugs silenciosos.
|
||||
</div>
|
||||
<pre><code>select
|
||||
plan_key,
|
||||
plan_name,
|
||||
public_name,
|
||||
public_description,
|
||||
badge,
|
||||
is_featured,
|
||||
is_visible,
|
||||
sort_order,
|
||||
monthly_cents,
|
||||
yearly_cents,
|
||||
monthly_currency,
|
||||
yearly_currency,
|
||||
bullets,
|
||||
plan_target
|
||||
from v_public_pricing
|
||||
order by plan_target, sort_order;</code></pre>
|
||||
|
||||
<h3>5.2 View de preços ativos (infra/diagnóstico)</h3>
|
||||
<pre><code>select *
|
||||
from v_plan_active_prices
|
||||
order by plan_id;</code></pre>
|
||||
|
||||
<h3>5.3 View de assinatura do tenant (gating/RBAC por plano)</h3>
|
||||
<pre><code>select *
|
||||
from v_tenant_active_subscription;</code></pre>
|
||||
|
||||
<h3>5.4 View de saúde de assinaturas (debug)</h3>
|
||||
<pre><code>select *
|
||||
from v_subscription_health
|
||||
where status <> 'healthy';</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="section" id="6-catalogo">
|
||||
<h2>6. Catálogo de Planos (MVP)</h2>
|
||||
|
||||
<div class="ok">
|
||||
<strong>Decisão fechada:</strong> MVP com 4 planos (2 targets × free/pro).
|
||||
Os planos antigos (ex.: <code>pro</code>, <code>plano_2</code>) podem ser descontinuados e ficar invisíveis.
|
||||
</div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>plan_key</th>
|
||||
<th>target</th>
|
||||
<th>Tipo</th>
|
||||
<th>Objetivo</th>
|
||||
<th>Notas de produto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>clinic_free</code></td>
|
||||
<td><code>clinic</code></td>
|
||||
<td>FREE</td>
|
||||
<td>Entrada de clínicas pequenas (começar sem cartão)</td>
|
||||
<td>Usável, mas com teto claro para gerar upgrade natural.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>clinic_pro</code></td>
|
||||
<td><code>clinic</code></td>
|
||||
<td>PRO</td>
|
||||
<td>Clínica completa</td>
|
||||
<td>Habilita secretária, relatórios, automações etc. (conforme evolução).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>therapist_free</code></td>
|
||||
<td><code>therapist</code></td>
|
||||
<td>FREE</td>
|
||||
<td>Entrada de terapeuta solo</td>
|
||||
<td>Permite operar, mas limita escala (pacientes/sessões).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>therapist_pro</code></td>
|
||||
<td><code>therapist</code></td>
|
||||
<td>PRO</td>
|
||||
<td>Profissional estabelecido</td>
|
||||
<td>Expande limites e libera automações/relatórios conforme roadmap.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>6.1 Limites sugeridos (MVP — ajustável)</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Entitlement</th>
|
||||
<th>clinic_free</th>
|
||||
<th>clinic_pro</th>
|
||||
<th>therapist_free</th>
|
||||
<th>therapist_pro</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>therapists_limit</code></td>
|
||||
<td>1</td>
|
||||
<td>ilimitado</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>patients_limit</code></td>
|
||||
<td>30</td>
|
||||
<td>ilimitado</td>
|
||||
<td>10</td>
|
||||
<td>ilimitado</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>sessions_month_limit</code></td>
|
||||
<td>100</td>
|
||||
<td>ilimitado</td>
|
||||
<td>40</td>
|
||||
<td>ilimitado</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>secretary_enabled</code></td>
|
||||
<td>false</td>
|
||||
<td>true</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>reports_enabled</code></td>
|
||||
<td>false</td>
|
||||
<td>true</td>
|
||||
<td>false</td>
|
||||
<td>true</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>reminders_enabled</code></td>
|
||||
<td>false</td>
|
||||
<td>true</td>
|
||||
<td>false</td>
|
||||
<td>true</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>patient_portal_enabled</code></td>
|
||||
<td>true</td>
|
||||
<td>true</td>
|
||||
<td>true</td>
|
||||
<td>true</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="warn">
|
||||
<strong>Observação:</strong> nomes de entitlements dependem da sua tabela de <code>features</code> (se existir).
|
||||
A lógica do seeder abaixo separa “chaves sugeridas” da implementação final.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="7-precos">
|
||||
<h2>7. Preços (MVP) e vigência</h2>
|
||||
|
||||
<h3>7.1 Preços sugeridos</h3>
|
||||
<div class="card">
|
||||
<ul>
|
||||
<li><strong>clinic_free</strong>: Grátis (sem preço, ou <code>0</code> se o front exigir número)</li>
|
||||
<li><strong>clinic_pro</strong>: mensal R$ 149 (<code>14900</code>), anual R$ 1490 (<code>149000</code>)</li>
|
||||
<li><strong>therapist_free</strong>: Grátis (sem preço, ou <code>0</code>)</li>
|
||||
<li><strong>therapist_pro</strong>: mensal R$ 49 (<code>4900</code>), anual R$ 490 (<code>49000</code>)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h3>7.2 Regras de vigência</h3>
|
||||
<div class="rule">
|
||||
<strong>Regra recomendada:</strong> 1 preço vigente por <code>(plan_id, interval, currency)</code>.
|
||||
Para prevenir inconsistência, criar índice único parcial.
|
||||
</div>
|
||||
|
||||
<pre><code>create unique index if not exists uq_plan_price_active
|
||||
on plan_prices (plan_id, interval, currency)
|
||||
where is_active = true and active_to is null;</code></pre>
|
||||
|
||||
<div class="danger">
|
||||
<strong>Anti-padrão:</strong> encerrar todos preços e esquecer de inserir os novos. Resultado: <code>v_public_pricing</code> com <code>null</code>.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="8-seeder">
|
||||
<h2>8. Seeder (nova instalação) — SQL idempotente</h2>
|
||||
|
||||
<div class="ok">
|
||||
<strong>Objetivo do seeder:</strong> instalar (1) planos, (2) metadata pública, (3) bullets, (4) preços PRO vigentes
|
||||
e (opcional) (5) entitlements iniciais.
|
||||
O script deve ser <strong>idempotente</strong>: rodar várias vezes sem duplicar registros.
|
||||
</div>
|
||||
|
||||
<h3>8.1 Convenções do seeder</h3>
|
||||
<ul>
|
||||
<li>Usar <code>plans.key</code> como chave estável (única). A view pública expõe isso como <code>plan_key</code>.</li>
|
||||
<li>Para inserts, preferir <code>insert ... on conflict ... do update</code> quando houver unique constraint.</li>
|
||||
<li>Para preços: encerrar preço vigente anterior e inserir um novo (ou atualizar, conforme sua política).</li>
|
||||
<li>Manter <code>source='manual'</code> no MVP (provider pode entrar depois com Stripe).</li>
|
||||
</ul>
|
||||
|
||||
<h3>8.2 Seeder completo (MVP)</h3>
|
||||
<pre><code>-- ============================================================
|
||||
-- SEEDER — BILLING (MVP) • SCHEMA REAL (confirmado)
|
||||
-- Planos finais: clinic_free, clinic_pro, therapist_free, therapist_pro
|
||||
-- Observação: v_public_pricing expõe (plan_key/plan_target), mas na tabela base é (plans.key / plans.target).
|
||||
-- ============================================================
|
||||
|
||||
-- 0) Proteção: 1 preço vigente por (plan_id, interval, currency)
|
||||
create unique index if not exists uq_plan_price_active
|
||||
on plan_prices (plan_id, interval, currency)
|
||||
where is_active = true and active_to is null;
|
||||
|
||||
-- 1) Plans (public.plans) — usa colunas reais: key, name, target
|
||||
insert into plans (key, name, description, is_active, price_cents, currency, billing_interval, target)
|
||||
values
|
||||
('clinic_free', 'CLINIC FREE', 'Plano gratuito para clínicas iniciarem.', true, 0, 'BRL', 'month', 'clinic'),
|
||||
('clinic_pro', 'CLINIC PRO', 'Plano completo para clínicas.', true, 14900, 'BRL', 'month', 'clinic'),
|
||||
('therapist_free', 'THERAPIST FREE', 'Plano gratuito para terapeutas.', true, 0, 'BRL', 'month', 'therapist'),
|
||||
('therapist_pro', 'THERAPIST PRO', 'Plano completo para terapeutas.', true, 4900, 'BRL', 'month', 'therapist')
|
||||
on conflict (key) do update
|
||||
set name = excluded.name,
|
||||
description = excluded.description,
|
||||
is_active = excluded.is_active,
|
||||
price_cents = excluded.price_cents,
|
||||
currency = excluded.currency,
|
||||
billing_interval = excluded.billing_interval,
|
||||
target = excluded.target;
|
||||
|
||||
-- 2) Plan public (public.plan_public) — metadata de pricing
|
||||
with p as (
|
||||
select id, key from plans
|
||||
where key in ('clinic_free','clinic_pro','therapist_free','therapist_pro')
|
||||
)
|
||||
insert into plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
|
||||
select
|
||||
id,
|
||||
case key
|
||||
when 'clinic_free' then 'Clínica — Free'
|
||||
when 'clinic_pro' then 'Clínica — PRO'
|
||||
when 'therapist_free' then 'Terapeuta — Free'
|
||||
when 'therapist_pro' then 'Terapeuta — PRO'
|
||||
end,
|
||||
case key
|
||||
when 'clinic_free' then 'Para clínicas pequenas começarem sem cartão.'
|
||||
when 'clinic_pro' then 'Para clínicas que querem recursos completos.'
|
||||
when 'therapist_free' then 'Para começar e organizar sua prática.'
|
||||
when 'therapist_pro' then 'Para expandir com automações e escala.'
|
||||
end,
|
||||
case key
|
||||
when 'clinic_free' then 'Grátis'
|
||||
when 'therapist_free' then 'Grátis'
|
||||
else null
|
||||
end,
|
||||
case key
|
||||
when 'clinic_pro' then true
|
||||
when 'therapist_pro' then true
|
||||
else false
|
||||
end,
|
||||
true,
|
||||
case key
|
||||
when 'clinic_free' then 10
|
||||
when 'clinic_pro' then 20
|
||||
when 'therapist_free' then 10
|
||||
when 'therapist_pro' then 20
|
||||
end
|
||||
from p
|
||||
on conflict (plan_id) do update
|
||||
set public_name = excluded.public_name,
|
||||
public_description = excluded.public_description,
|
||||
badge = excluded.badge,
|
||||
is_featured = excluded.is_featured,
|
||||
is_visible = excluded.is_visible,
|
||||
sort_order = excluded.sort_order;
|
||||
|
||||
-- 3) Bullets (public.plan_public_bullets) — reset simples para MVP
|
||||
delete from plan_public_bullets
|
||||
where plan_id in (select id from plans where key in ('clinic_free','clinic_pro','therapist_free','therapist_pro'));
|
||||
|
||||
insert into plan_public_bullets (plan_id, text, highlight, sort_order)
|
||||
values
|
||||
((select id from plans where key='clinic_free'), '1 terapeuta incluído', false, 10),
|
||||
((select id from plans where key='clinic_free'), 'Até 30 pacientes', false, 20),
|
||||
((select id from plans where key='clinic_free'), 'Até 100 sessões/mês', false, 30),
|
||||
|
||||
((select id from plans where key='clinic_pro'), 'Terapeutas ilimitados', true, 10),
|
||||
((select id from plans where key='clinic_pro'), 'Pacientes ilimitados', true, 20),
|
||||
((select id from plans where key='clinic_pro'), 'Relatórios e lembretes', false, 30),
|
||||
|
||||
((select id from plans where key='therapist_free'), 'Até 10 pacientes', false, 10),
|
||||
((select id from plans where key='therapist_free'), 'Até 40 sessões/mês', false, 20),
|
||||
((select id from plans where key='therapist_free'), 'Portal do paciente', false, 30),
|
||||
|
||||
((select id from plans where key='therapist_pro'), 'Pacientes ilimitados', true, 10),
|
||||
((select id from plans where key='therapist_pro'), 'Sessões ilimitadas', true, 20),
|
||||
((select id from plans where key='therapist_pro'), 'Relatórios e lembretes', false, 30);
|
||||
|
||||
-- 4) Preços vigentes (public.plan_prices) — somente PRO
|
||||
do $$
|
||||
declare
|
||||
v_clinic_pro uuid;
|
||||
v_therapist_pro uuid;
|
||||
begin
|
||||
select id into v_clinic_pro from plans where key='clinic_pro';
|
||||
select id into v_therapist_pro from plans where key='therapist_pro';
|
||||
|
||||
update plan_prices
|
||||
set is_active = false, active_to = now()
|
||||
where plan_id in (v_clinic_pro, v_therapist_pro)
|
||||
and is_active = true
|
||||
and active_to is null;
|
||||
|
||||
insert into plan_prices (plan_id, currency, interval, amount_cents, is_active, active_from, active_to, source, provider, provider_price_id)
|
||||
values
|
||||
(v_clinic_pro, 'BRL', 'month', 14900, true, now(), null, 'manual', null, null),
|
||||
(v_clinic_pro, 'BRL', 'year', 149000, true, now(), null, 'manual', null, null),
|
||||
(v_therapist_pro, 'BRL', 'month', 4900, true, now(), null, 'manual', null, null),
|
||||
(v_therapist_pro, 'BRL', 'year', 49000, true, now(), null, 'manual', null, null);
|
||||
exception
|
||||
when unique_violation then
|
||||
raise notice 'Preço vigente já existe para algum (plan_id, interval, currency).';
|
||||
end $$;
|
||||
|
||||
-- 5) (Opcional) Integridade: impedir apagar plano em uso
|
||||
-- A FK subscriptions.plan_id -> plans.id deve estar com ON DELETE RESTRICT.
|
||||
-- Se precisar aplicar:
|
||||
-- alter table public.subscriptions drop constraint if exists subscriptions_plan_id_fkey;
|
||||
-- alter table public.subscriptions add constraint subscriptions_plan_id_fkey
|
||||
-- foreign key (plan_id) references public.plans(id) on delete restrict;
|
||||
|
||||
-- 6) Validação final (deve retornar 4 planos visíveis)
|
||||
select plan_key, plan_name, plan_target, monthly_cents, yearly_cents
|
||||
from v_public_pricing
|
||||
where is_visible = true
|
||||
order by plan_target, sort_order, plan_key;</code></pre>
|
||||
|
||||
<div class="warn">
|
||||
<strong>Nota de adaptação:</strong> o seeder acima assume certas colunas (ex.: <code>plans.plan_key</code>, <code>plans.plan_target</code>, <code>plans.is_active</code>, <code>plan_public.*</code>).
|
||||
Se o seu schema tiver nomes diferentes, ajuste no primeiro uso e depois mantenha como “padrão oficial”.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="section" id="8b-entitlements">
|
||||
<h2>8B. Entitlements — Schema real (plan_features)</h2>
|
||||
<p>
|
||||
O MVP usa <code>plan_features</code> como tabela de ligação entre plano e feature. O schema confirmado é:
|
||||
<code>(plan_id uuid NOT NULL, feature_id uuid NOT NULL, enabled boolean NOT NULL default true, limits jsonb NULL)</code>.
|
||||
</p>
|
||||
<div class="rule">
|
||||
<strong>Padrão recomendado para limits (jsonb):</strong> padronizar chaves por tipo de limite para evitar ambiguidade no front/back.
|
||||
Sugestão:
|
||||
<code>{"max": 30}</code> (limite absoluto),
|
||||
<code>{"per_month": 40}</code> (por período),
|
||||
<code>{"max_users": 1}</code> (limite de assentos),
|
||||
e manter <code>enabled</code> como flag binária.
|
||||
</div>
|
||||
<div class="warn">
|
||||
<strong>Pré-requisito:</strong> para seedar entitlements, é necessário listar/definir as features na tabela de features (ex.: <code>features</code>).
|
||||
Este documento mantém os limites do MVP como referência de produto; o seeder de <code>plan_features</code> deve mapear essas chaves para <code>feature_id</code> reais.
|
||||
</div>
|
||||
|
||||
<h3>Template (exemplo) — como gravar limites</h3>
|
||||
<pre><code>-- Exemplo: habilitar feature X com limite max=30 para clinic_free
|
||||
insert into plan_features (plan_id, feature_id, enabled, limits)
|
||||
values (
|
||||
(select id from plans where key='clinic_free'),
|
||||
'FEATURE_UUID_AQUI',
|
||||
true,
|
||||
'{"max": 30}'::jsonb
|
||||
);</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="section" id="8c-regras-negocio">
|
||||
<h2>8C. Regras de negócio confirmadas no banco</h2>
|
||||
<div class="ok">
|
||||
<strong>Regra confirmada:</strong> inserir subscription de <code>clinic_*</code> exige <code>tenant_id</code>.
|
||||
Em testes, uma tentativa de inserir assinatura de clínica sem tenant resultou em erro:
|
||||
<em>“Assinatura clinic exige tenant_id.”</em>
|
||||
</div>
|
||||
<div class="rule">
|
||||
<strong>Consequência:</strong> assinatura de clínica é “por tenant”; assinatura de terapeuta pode ser por <code>tenant_id</code> ou <code>user_id</code>,
|
||||
conforme sua arquitetura — mas o banco já impõe pelo menos o caso de clínica.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="section" id="9-onboarding">
|
||||
<h2>9. Onboarding & Upgrade (fluxo)</h2>
|
||||
|
||||
<h3>9.1 Onboarding (criação de tenant)</h3>
|
||||
<ul>
|
||||
<li>Ao criar um tenant <code>clinic</code> → atribuir automaticamente <code>clinic_free</code>.</li>
|
||||
<li>Ao criar um tenant <code>therapist</code> → atribuir automaticamente <code>therapist_free</code>.</li>
|
||||
<li>O plano deve ser a fonte de verdade para habilitar recursos (entitlements store).</li>
|
||||
</ul>
|
||||
|
||||
<h3>9.2 Upgrade</h3>
|
||||
<div class="rule">
|
||||
Upgrade é troca de plano na assinatura: <code>*_free → *_pro</code>.
|
||||
O sistema deve invalidar entitlements e atualizar cache (via <code>entitlements_invalidation</code> ou mecanismo equivalente).
|
||||
</div>
|
||||
|
||||
<h3>9.3 Downgrade/expiração</h3>
|
||||
<p>
|
||||
No MVP, a regra segura é: ao expirar, <strong>bloquear novas criações premium</strong>,
|
||||
mas <strong>não apagar dados</strong>. Apenas retira capacidade.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="section" id="10-runbook">
|
||||
<h2>10. Operação (runbook rápido)</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Incidente comum: Pricing mostra preços nulos</h3>
|
||||
<ol style="margin:10px 0 0 18px; color: var(--muted); line-height:1.65;">
|
||||
<li>Rodar <code>select * from v_public_pricing;</code></li>
|
||||
<li>Rodar <code>select * from plan_prices where plan_id = ... order by created_at desc;</code></li>
|
||||
<li>Confirmar existência de preço vigente: <code>is_active=true</code> e <code>active_to is null</code></li>
|
||||
<li>Se não existir, inserir preços PRO vigentes (month/year) e validar view novamente.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:12px;">
|
||||
<h3>Incidente comum: Plano aparece errado para um tenant</h3>
|
||||
<ol style="margin:10px 0 0 18px; color: var(--muted); line-height:1.65;">
|
||||
<li>Verificar <code>v_tenant_active_subscription</code> para o tenant em questão.</li>
|
||||
<li>Verificar se o plano tem <code>plan_target</code> correto.</li>
|
||||
<li>Verificar se o guard/menu não está inferindo plano do role (anti-padrão).</li>
|
||||
<li>Invalidar entitlements e reavaliar.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="11-qa">
|
||||
<h2>11. Checklist de QA</h2>
|
||||
<ul>
|
||||
<li><strong>Seeder</strong>: rodar duas vezes e confirmar que não duplica registros.</li>
|
||||
<li><strong>Pricing</strong>: <code>v_public_pricing</code> retorna 4 planos, com preços preenchidos para PRO.</li>
|
||||
<li><strong>Upgrade</strong>: trocar plano e confirmar mudança de entitlements no runtime.</li>
|
||||
<li><strong>FREE</strong>: criação de tenant atribui automaticamente plano free correto.</li>
|
||||
<li><strong>Target</strong>: clínica nunca recebe plano therapist (e vice-versa).</li>
|
||||
<li><strong>Vigência</strong>: inserir novo preço e confirmar que o antigo foi encerrado (active_to preenchido).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section" id="12-prompt">
|
||||
<h2>12. Prompt Mestre — Continuidade do Billing (Planos/Assinaturas)</h2>
|
||||
|
||||
<div class="rule">
|
||||
Sempre que iniciar um novo chat sobre Billing/Planos, copie e cole este prompt.
|
||||
Ele representa o estado oficial do domínio e da estrutura do banco para o MVP.
|
||||
</div>
|
||||
|
||||
<pre><code>
|
||||
Estou desenvolvendo um SaaS clínico multi-tenant usando Supabase (Postgres + RLS + Views)
|
||||
com planos e assinaturas.
|
||||
|
||||
══════════════════════════════════════
|
||||
📦 Domínio: Billing / Planos
|
||||
══════════════════════════════════════
|
||||
|
||||
Decisões do MVP:
|
||||
- Tudo começa grátis (clinic e therapist).
|
||||
- Paciente não tem plano (portal do paciente é feature do plano do therapist/clinic).
|
||||
- Plano (billing) NÃO é role (RBAC). Role dirige menus/rotas; plano dirige features/limites.
|
||||
- Planos por target: clinic e therapist.
|
||||
|
||||
Catálogo de planos (MVP):
|
||||
- clinic_free
|
||||
- clinic_pro
|
||||
- therapist_free
|
||||
- therapist_pro
|
||||
|
||||
Views fonte de verdade:
|
||||
- v_public_pricing (tela de preços)
|
||||
- v_plan_active_prices (infra)
|
||||
- v_tenant_active_subscription (gating por tenant)
|
||||
- v_subscription_health (debug)
|
||||
|
||||
Tabelas principais:
|
||||
- plans (colunas reais: key, target, ...)
|
||||
- plan_prices (tem vigência; preço vigente: is_active=true e active_to is null; a UI usa v_plan_active_prices)
|
||||
- plan_public + plan_public_bullets (marketing)
|
||||
- plan_features (entitlements)
|
||||
- subscriptions (+ events, intents)
|
||||
- entitlements_invalidation
|
||||
|
||||
Preços sugeridos (MVP):
|
||||
- clinic_pro: 14900/mês e 149000/ano (BRL)
|
||||
- therapist_pro: 4900/mês e 49000/ano (BRL)
|
||||
- free: grátis (pode manter sem preços)
|
||||
|
||||
Problema já observado:
|
||||
- v_public_pricing retornou null quando plan_prices tinha histórico mas não tinha preço vigente.
|
||||
|
||||
Estado atual (confirmado):
|
||||
- Apenas 4 planos existem (clinic_free/clinic_pro/therapist_free/therapist_pro)
|
||||
|
||||
Objetivo do próximo passo:
|
||||
- Seedar plan_features (entitlements) mapeando features -> feature_id e limits jsonb para nova instalação com os 4 planos + public metadata + preços PRO vigentes.
|
||||
</code></pre>
|
||||
|
||||
<div class="ok">
|
||||
Este prompt deve ser tratado como contexto estrutural completo do Billing no MVP.
|
||||
Qualquer solução proposta deve respeitar essa organização.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="13-tags">
|
||||
<h2>Tags</h2>
|
||||
<span class="pill">#Billing</span>
|
||||
<span class="pill">#Planos</span>
|
||||
<span class="pill">#Pricing</span>
|
||||
<span class="pill">#Seeder</span>
|
||||
<span class="pill">#Supabase</span>
|
||||
<span class="pill">#Postgres</span>
|
||||
<span class="pill">#MultiTenant</span>
|
||||
<span class="pill">#Entitlements</span>
|
||||
<span class="pill">#Subscriptions</span>
|
||||
<span class="pill">#v_public_pricing</span>
|
||||
<span class="pill">#MVP</span>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Agência PSI • Documentação interna • Billing (Planos/Assinaturas/Seeder)
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,612 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Relatório Técnico — Sessão de Correção: Subscription Health & Entitlements (2026-03-01) | Agência PSI</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg0:#f6f8fc;
|
||||
--bg1:#eef2f8;
|
||||
--panel:rgba(255,255,255,.78);
|
||||
--panel2:rgba(255,255,255,.92);
|
||||
--border:rgba(15,23,42,.10);
|
||||
--text:rgba(15,23,42,.92);
|
||||
--muted:rgba(15,23,42,.70);
|
||||
--muted2:rgba(15,23,42,.56);
|
||||
--accent:#2563eb;
|
||||
--accent2:#4f46e5;
|
||||
--warn:#b45309;
|
||||
--danger:#b91c1c;
|
||||
--ok:#047857;
|
||||
--shadow: 0 18px 60px rgba(2,6,23,.10);
|
||||
--radius: 16px;
|
||||
--radius2: 22px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{
|
||||
margin:0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
background: radial-gradient(1200px 600px at 10% 0%, rgba(79,70,229,.10), transparent 55%),
|
||||
radial-gradient(1100px 500px at 90% 10%, rgba(37,99,235,.10), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
color:var(--text);
|
||||
}
|
||||
a{color:inherit; text-decoration:none;}
|
||||
a:hover{text-decoration:underline;}
|
||||
.layout{
|
||||
display:grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 20px;
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 18px 42px;
|
||||
}
|
||||
header{
|
||||
grid-column: 1 / -1;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.92), rgba(255,255,255,.72));
|
||||
border-radius: var(--radius2);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.kicker{
|
||||
font-size:12px;
|
||||
letter-spacing:.08em;
|
||||
text-transform:uppercase;
|
||||
color:var(--muted2);
|
||||
margin:0 0 8px;
|
||||
}
|
||||
h1{
|
||||
margin:0 0 8px;
|
||||
font-size:30px;
|
||||
letter-spacing:-0.02em;
|
||||
}
|
||||
.subtitle{
|
||||
margin:0;
|
||||
color:var(--muted);
|
||||
max-width:980px;
|
||||
line-height:1.55;
|
||||
font-size:14px;
|
||||
}
|
||||
.meta-row{
|
||||
margin-top:12px;
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
}
|
||||
.pill{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
padding:8px 12px;
|
||||
border-radius:999px;
|
||||
border:1px solid var(--border);
|
||||
background:rgba(255,255,255,.72);
|
||||
color:var(--muted);
|
||||
font-size:12px;
|
||||
}
|
||||
.dot{
|
||||
width:8px;height:8px;border-radius:50%;
|
||||
background:var(--accent);
|
||||
box-shadow:0 0 0 4px rgba(37,99,235,.12);
|
||||
}
|
||||
aside{
|
||||
position:sticky;
|
||||
top:18px;
|
||||
align-self:start;
|
||||
border:1px solid var(--border);
|
||||
background:var(--panel2);
|
||||
border-radius:var(--radius);
|
||||
box-shadow:var(--shadow);
|
||||
overflow:hidden;
|
||||
}
|
||||
.toc-head{
|
||||
padding:14px;
|
||||
border-bottom:1px solid var(--border);
|
||||
background:rgba(15,23,42,.02);
|
||||
}
|
||||
.toc-title{ margin:0 0 6px; font-weight:700; font-size:14px; }
|
||||
.toc-sub{ margin:0; color:var(--muted); font-size:12px; line-height:1.45; }
|
||||
nav{ padding: 10px 6px 14px; }
|
||||
nav a{
|
||||
display:block;
|
||||
padding:10px 12px;
|
||||
margin:4px 6px;
|
||||
border-radius:12px;
|
||||
color:var(--muted);
|
||||
font-size:13px;
|
||||
border:1px solid transparent;
|
||||
}
|
||||
nav a:hover{
|
||||
background:rgba(37,99,235,.06);
|
||||
border-color:rgba(37,99,235,.12);
|
||||
color:var(--text);
|
||||
text-decoration:none;
|
||||
}
|
||||
.nav-sec{
|
||||
margin:10px 12px 6px;
|
||||
color:var(--muted2);
|
||||
font-size:11px;
|
||||
letter-spacing:.08em;
|
||||
text-transform:uppercase;
|
||||
}
|
||||
main{
|
||||
border:1px solid var(--border);
|
||||
background:var(--panel);
|
||||
border-radius:var(--radius2);
|
||||
box-shadow:var(--shadow);
|
||||
overflow:hidden;
|
||||
}
|
||||
.content{ padding: 18px 18px 22px; }
|
||||
.section{
|
||||
padding: 18px;
|
||||
border:1px solid var(--border);
|
||||
border-radius: var(--radius2);
|
||||
background: rgba(255,255,255,.80);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section h2{
|
||||
margin:0 0 10px;
|
||||
font-size:18px;
|
||||
letter-spacing:-0.01em;
|
||||
}
|
||||
.section h3{
|
||||
margin:14px 0 8px;
|
||||
font-size:14px;
|
||||
}
|
||||
.section p, .section li{
|
||||
color:var(--muted);
|
||||
line-height:1.65;
|
||||
font-size:13.5px;
|
||||
}
|
||||
.grid{
|
||||
display:grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 980px){
|
||||
.layout{ grid-template-columns: 1fr; }
|
||||
aside{ position:relative; top:auto; }
|
||||
.grid{ grid-template-columns: 1fr; }
|
||||
}
|
||||
.callout{
|
||||
border-radius: 16px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
border:1px solid var(--border);
|
||||
background: rgba(15,23,42,.02);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.callout strong{color:var(--text);}
|
||||
.callout.ok{ border-left: 4px solid var(--ok); background: rgba(4,120,87,.06); }
|
||||
.callout.warn{ border-left: 4px solid var(--warn); background: rgba(180,83,9,.08); }
|
||||
.callout.danger{ border-left: 4px solid var(--danger); background: rgba(185,28,28,.08); }
|
||||
.callout.info{ border-left: 4px solid var(--accent); background: rgba(37,99,235,.08); }
|
||||
pre{
|
||||
margin: 10px 0 0;
|
||||
padding: 14px;
|
||||
background: #0b1220;
|
||||
color:#e2e8f0;
|
||||
border-radius: 16px;
|
||||
overflow:auto;
|
||||
border: 1px solid rgba(226,232,240,.08);
|
||||
}
|
||||
code{ font-family: var(--mono); font-size:12.5px; }
|
||||
.kbd{
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255,255,255,.65);
|
||||
color: var(--text);
|
||||
}
|
||||
.hr{
|
||||
height:1px;
|
||||
background: var(--border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
footer{
|
||||
margin-top: 12px;
|
||||
padding: 14px 18px 18px;
|
||||
color: var(--muted2);
|
||||
font-size: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: rgba(255,255,255,.70);
|
||||
}
|
||||
.small{ font-size:12px; color:var(--muted2); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<header>
|
||||
<p class="kicker">Relatório Técnico • Billing Health • Agência PSI</p>
|
||||
<h1>Subscription Health & Entitlements — Sessão de Correção (2026-03-01)</h1>
|
||||
<p class="subtitle">Este documento registra, de forma minuciosa e operacional, a sessão de diagnóstico e correção dos problemas
|
||||
na página <strong>Saúde das Assinaturas</strong> (Subscription Health) e no pipeline de <strong>Entitlements</strong>.
|
||||
O objetivo é permitir que qualquer programador entenda o incidente, replique o diagnóstico e aplique correções
|
||||
com segurança, mesmo sem ter acompanhado a conversa original.</p>
|
||||
<div class="meta-row">
|
||||
<span class="pill"><span class="dot"></span><strong>Estado:</strong> resolvido e hardenizado</span>
|
||||
<span class="pill"><strong>Atualizado:</strong> 2026-03-01 11:46:44 UTC</span>
|
||||
<span class="pill"><strong>Stack:</strong> Supabase + Postgres + Vue/PrimeVue</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside>
|
||||
<div class="toc-head">
|
||||
<div class="toc-title">Sumário</div>
|
||||
<p class="toc-sub">Leitura rápida com passos reproduzíveis (SQL + patches + checklist).</p>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="nav-sec">Visão geral</div>
|
||||
<a href="#01">Resumo executivo</a>
|
||||
<a href="#02">Escopo e componentes</a>
|
||||
<a href="#03">Sintomas e evidências</a>
|
||||
<div class="nav-sec">Diagnóstico</div>
|
||||
<a href="#04">Causa raiz</a>
|
||||
<a href="#05">SQLs de diagnóstico</a>
|
||||
<div class="nav-sec">Correções</div>
|
||||
<a href="#06">Patches aplicados</a>
|
||||
<a href="#07">Hardening</a>
|
||||
<div class="nav-sec">Validação</div>
|
||||
<a href="#08">Checklist pós-correção</a>
|
||||
<div class="nav-sec">Contexto</div>
|
||||
<a href="#09">Notas de front-end</a>
|
||||
<a href="#10">Linha do tempo</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="content">
|
||||
|
||||
<section class="section" id="01">
|
||||
<h2>1. Resumo executivo</h2>
|
||||
|
||||
<p>
|
||||
<strong>Sintoma principal:</strong> a tela <em>Saúde das Assinaturas</em> exibia divergências e a coluna <strong>Owner</strong> aparecia vazia
|
||||
(linhas com <code>owner_id = NULL</code>). Além disso, os botões <strong>Fix</strong> e <strong>Fix All</strong> falhavam.
|
||||
</p>
|
||||
<div class="callout danger">
|
||||
<strong>Impacto:</strong> a ferramenta de diagnóstico do Billing ficou pouco confiável e os reparos automáticos não executavam, impedindo
|
||||
correções rápidas após mudanças de plano/feature.
|
||||
</div>
|
||||
<div class="callout ok">
|
||||
<strong>Resultado final:</strong> view de entitlements corrigida (filtra <code>subscriptions.status = 'active'</code> e exclui NULL),
|
||||
funções RPC alinhadas ao schema atual (<code>subscriptions.user_id</code>), dados inválidos removidos e constraints/índices adicionados
|
||||
para impedir regressões.
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="section" id="02">
|
||||
<h2>2. Escopo e componentes envolvidos</h2>
|
||||
|
||||
<div class="grid">
|
||||
<div class="callout">
|
||||
<strong>View de saúde</strong>
|
||||
<p><code>public.v_subscription_feature_mismatch</code> — compara o esperado (plan_features do plano ativo) com o atual (entitlements).</p>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>Entitlements agregados</strong>
|
||||
<p><code>public.owner_feature_entitlements</code> — <em>VIEW</em> agregada (sources + limits_list), derivada de <code>subscriptions</code> e <code>tenant_modules</code>.</p>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>Rotinas de reparo</strong>
|
||||
<p><code>public.rebuild_owner_entitlements(uuid)</code> e <code>public.fix_all_subscription_mismatches()</code>.</p>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>Tabelas de configuração</strong>
|
||||
<p><code>plans</code>, <code>features</code>, <code>plan_features</code>, <code>module_features</code>, <code>tenant_modules</code>, <code>subscriptions</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="section" id="03">
|
||||
<h2>3. Sintomas observados e evidências</h2>
|
||||
|
||||
<p>Foram observados os seguintes indícios no banco:</p>
|
||||
<ul>
|
||||
<li>View <code>v_subscription_feature_mismatch</code> retornando <code>owner_id = NULL</code> tanto em <em>missing</em> quanto em <em>unexpected</em>.</li>
|
||||
<li>Contagem estável em 4/4 divergências, mesmo após tentativa de reparo.</li>
|
||||
<li>Existência de uma <code>subscription</code> com <code>status='active'</code> e <code>user_id = NULL</code> (dado inválido).</li>
|
||||
<li>Falha de execução do FixAll com erro de coluna inexistente: <code>subscriptions.owner_id</code> (schema drift).</li>
|
||||
</ul>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Nota de leitura:</strong> ao ver <code>owner_id = NULL</code> em divergências, trate como anomalia de dados ou view/joins permissivos.
|
||||
Na prática, “owner nulo” não é um caso de negócio — é um caso de <em>integridade</em>.
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="section" id="04">
|
||||
<h2>4. Diagnóstico e causa raiz</h2>
|
||||
|
||||
<h3>4.1 Causa raiz #1 — Schema drift nas funções RPC</h3>
|
||||
<p>
|
||||
As funções de reparo estavam escritas para um schema anterior, usando <code>subscriptions.owner_id</code>. No schema atual, o owner do contexto
|
||||
“terapeuta” é <code>subscriptions.user_id</code>. Isso quebrou:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Fix owner</strong>: falha ao buscar o plano ativo do owner.</li>
|
||||
<li><strong>Fix all</strong>: falha ao iterar owners e chamar o rebuild.</li>
|
||||
</ul>
|
||||
|
||||
<div class="callout danger">
|
||||
<strong>Erro observado:</strong> <code>column "owner_id" does not exist</code> (hint citando <code>subscriptions.user_id</code>).
|
||||
</div>
|
||||
|
||||
<h3>4.2 Causa raiz #2 — View de entitlements agregados não filtrava status</h3>
|
||||
<p>
|
||||
A view <code>owner_feature_entitlements</code> agregava a fonte “plan” sem filtrar <code>subscriptions.status</code>, permitindo que uma subscription
|
||||
<em>inactive</em> com <code>user_id NULL</code> continuasse “vazando” entitlements com <code>owner_id NULL</code> para o sistema.
|
||||
</p>
|
||||
|
||||
<h3>4.3 Causa raiz #3 — Dado inválido</h3>
|
||||
<p>
|
||||
Foi identificado um registro em <code>subscriptions</code> com <code>user_id NULL</code>. Mesmo após torná-lo <em>inactive</em>, ele continuava contaminando
|
||||
a view (por ausência do filtro de status).
|
||||
</p>
|
||||
|
||||
</section>
|
||||
<section class="section" id="05">
|
||||
<h2>5. SQLs usados no diagnóstico (playbook)</h2>
|
||||
|
||||
<p>Use este bloco para reproduzir o diagnóstico com segurança.</p>
|
||||
|
||||
<h3>5.1 Ver divergências e amostras</h3>
|
||||
<pre><code>select mismatch_type, count(*) as qtd
|
||||
from public.v_subscription_feature_mismatch
|
||||
group by 1
|
||||
order by 2 desc;
|
||||
|
||||
select owner_id, feature_key, mismatch_type
|
||||
from public.v_subscription_feature_mismatch
|
||||
order by owner_id nulls first, feature_key
|
||||
limit 50;</code></pre>
|
||||
|
||||
<h3>5.2 Encontrar subscriptions inválidas (user_id nulo)</h3>
|
||||
<pre><code>select id, user_id, plan_id, status, created_at
|
||||
from public.subscriptions
|
||||
where user_id is null
|
||||
order by created_at desc;</code></pre>
|
||||
|
||||
<h3>5.3 Entender a origem dos entitlements agregados</h3>
|
||||
<pre><code>select pg_get_viewdef('public.owner_feature_entitlements'::regclass, true) as view_sql;</code></pre>
|
||||
|
||||
<h3>5.4 Verificar tenant_modules inválidos (owner_id nulo)</h3>
|
||||
<pre><code>select count(*) as qtd
|
||||
from public.tenant_modules
|
||||
where status = 'active' and owner_id is null;</code></pre>
|
||||
|
||||
</section>
|
||||
<section class="section" id="06">
|
||||
<h2>6. Correções aplicadas no banco (patches)</h2>
|
||||
|
||||
<h3>6.1 Patch: rebuild_owner_entitlements (owner = subscriptions.user_id)</h3>
|
||||
<p>
|
||||
Ajuste para buscar o plano ativo por <code>subscriptions.user_id</code> e reconstruir entitlements com base em <code>plan_features</code>.
|
||||
</p>
|
||||
<pre><code>create or replace function public.rebuild_owner_entitlements(p_owner_id uuid)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_plan_id uuid;
|
||||
begin
|
||||
select s.plan_id
|
||||
into v_plan_id
|
||||
from public.subscriptions s
|
||||
where s.user_id = p_owner_id
|
||||
and s.status = 'active'
|
||||
order by s.created_at desc
|
||||
limit 1;
|
||||
|
||||
delete from public.owner_feature_entitlements e
|
||||
where e.owner_id = p_owner_id;
|
||||
|
||||
if v_plan_id is null then
|
||||
return;
|
||||
end if;
|
||||
|
||||
insert into public.owner_feature_entitlements (owner_id, feature_key, sources, limits_list)
|
||||
select
|
||||
p_owner_id,
|
||||
f.key,
|
||||
array['plan'::text],
|
||||
'{}'::jsonb
|
||||
from public.plan_features pf
|
||||
join public.features f on f.id = pf.feature_id
|
||||
where pf.plan_id = v_plan_id;
|
||||
end;
|
||||
$$;</code></pre>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Importante:</strong> se <code>owner_feature_entitlements</code> for uma <em>VIEW</em> (como no ambiente desta sessão),
|
||||
o <code>DELETE/INSERT</code> acima deve ser direcionado à <em>tabela base</em> real de entitlements, se existir.
|
||||
Nesta sessão, a correção definitiva foi feita ajustando a view agregadora e limpando o dado inválido.
|
||||
</div>
|
||||
|
||||
<h3>6.2 Patch: fix_all_subscription_mismatches (itera subscriptions.user_id)</h3>
|
||||
<pre><code>create or replace function public.fix_all_subscription_mismatches()
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
r record;
|
||||
begin
|
||||
for r in
|
||||
select distinct s.user_id as owner_id
|
||||
from public.subscriptions s
|
||||
where s.status = 'active'
|
||||
and s.user_id is not null
|
||||
loop
|
||||
perform public.rebuild_owner_entitlements(r.owner_id);
|
||||
end loop;
|
||||
end;
|
||||
$$;</code></pre>
|
||||
|
||||
<h3>6.3 Patch: owner_feature_entitlements (filtra status e NULLs)</h3>
|
||||
<pre><code>create or replace view public.owner_feature_entitlements as
|
||||
with base as (
|
||||
select
|
||||
s.user_id as owner_id,
|
||||
f.key as feature_key,
|
||||
pf.limits,
|
||||
'plan'::text as source
|
||||
from public.subscriptions s
|
||||
join public.plan_features pf
|
||||
on pf.plan_id = s.plan_id
|
||||
and pf.enabled = true
|
||||
join public.features f
|
||||
on f.id = pf.feature_id
|
||||
where s.status = 'active'
|
||||
and s.user_id is not null
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
tm.owner_id,
|
||||
f.key as feature_key,
|
||||
mf.limits,
|
||||
'module'::text as source
|
||||
from public.tenant_modules tm
|
||||
join public.modules m
|
||||
on m.id = tm.module_id
|
||||
and m.is_active = true
|
||||
join public.module_features mf
|
||||
on mf.module_id = m.id
|
||||
and mf.enabled = true
|
||||
join public.features f
|
||||
on f.id = mf.feature_id
|
||||
where tm.status = 'active'
|
||||
and tm.owner_id is not null
|
||||
)
|
||||
select
|
||||
owner_id,
|
||||
feature_key,
|
||||
array_agg(distinct source) as sources,
|
||||
jsonb_agg(limits) filter (where limits is not null) as limits_list
|
||||
from base
|
||||
group by owner_id, feature_key;</code></pre>
|
||||
|
||||
<h3>6.4 Limpeza do dado inválido (subscription com user_id NULL)</h3>
|
||||
<pre><code>-- se for lixo de seed/teste (recomendado remover):
|
||||
delete from public.subscriptions
|
||||
where user_id is null;</code></pre>
|
||||
|
||||
</section>
|
||||
<section class="section" id="07">
|
||||
<h2>7. Hardening (constraints e índices recomendados)</h2>
|
||||
|
||||
<p>Após corrigir dados e views, aplique hardening para impedir regressões.</p>
|
||||
|
||||
<h3>7.1 subscriptions.user_id NOT NULL</h3>
|
||||
<pre><code>alter table public.subscriptions
|
||||
alter column user_id set not null;</code></pre>
|
||||
|
||||
<h3>7.2 Uma assinatura ativa por usuário</h3>
|
||||
<pre><code>create unique index if not exists subscriptions_one_active_per_user
|
||||
on public.subscriptions (user_id)
|
||||
where status = 'active';</code></pre>
|
||||
|
||||
<h3>7.3 Índice de performance para consultas por owner/status</h3>
|
||||
<pre><code>create index if not exists subscriptions_user_status_idx
|
||||
on public.subscriptions (user_id, status, created_at desc);</code></pre>
|
||||
|
||||
<h3>7.4 tenant_modules.owner_id NOT NULL (decisão tomada nesta sessão)</h3>
|
||||
<pre><code>alter table public.tenant_modules
|
||||
alter column owner_id set not null;</code></pre>
|
||||
|
||||
<h3>7.5 Uniqueness e performance em plan_features / module_features</h3>
|
||||
<pre><code>create unique index if not exists plan_features_plan_feature_ux
|
||||
on public.plan_features (plan_id, feature_id);
|
||||
|
||||
create index if not exists plan_features_plan_enabled_idx
|
||||
on public.plan_features (plan_id, enabled);
|
||||
|
||||
create unique index if not exists module_features_module_feature_ux
|
||||
on public.module_features (module_id, feature_id);</code></pre>
|
||||
|
||||
<div class="callout info">
|
||||
<strong>Regra prática:</strong> dados inválidos (NULL em owner) devem ser bloqueados na borda (constraints), não “corrigidos” no front.
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="section" id="08">
|
||||
<h2>8. Verificação pós-correção (checklist)</h2>
|
||||
|
||||
<h3>8.1 Saúde deve zerar</h3>
|
||||
<pre><code>select mismatch_type, count(*) as qtd
|
||||
from public.v_subscription_feature_mismatch
|
||||
group by 1
|
||||
order by 2 desc;</code></pre>
|
||||
|
||||
<h3>8.2 Não pode haver owner nulo em subscriptions / tenant_modules ativos</h3>
|
||||
<pre><code>select count(*) as subs_user_null
|
||||
from public.subscriptions
|
||||
where user_id is null;
|
||||
|
||||
select count(*) as tenant_modules_active_owner_null
|
||||
from public.tenant_modules
|
||||
where status='active' and owner_id is null;</code></pre>
|
||||
|
||||
<h3>8.3 Entitlements agregados não devem conter owner null</h3>
|
||||
<pre><code>select owner_id, feature_key
|
||||
from public.owner_feature_entitlements
|
||||
where owner_id is null
|
||||
limit 20;</code></pre>
|
||||
|
||||
<div class="callout ok">
|
||||
<strong>OK final:</strong> todas as queries acima retornam 0 linhas (ou contagens zero).
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="section" id="09">
|
||||
<h2>9. Notas de implementação no front-end (contexto)</h2>
|
||||
|
||||
<p>Durante a sessão, a UI foi ajustada para:</p>
|
||||
<ul>
|
||||
<li>Traduzir telas para PT-BR, melhorar títulos, descrições e mensagens.</li>
|
||||
<li>Padronizar inputs com <code>FloatLabel</code> + <code>IconField</code> + <code>InputIcon</code>.</li>
|
||||
<li>Adicionar confirmações e “alterações pendentes” em ações em massa (plan_features), evitando salvar por clique acidental.</li>
|
||||
<li>Garantir que ações de correção (Fix/FixAll) reflitam erros reais (RPC quebrada vs dados inválidos).</li>
|
||||
</ul>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Regra operacional:</strong> se a coluna Owner aparecer vazia, corrija no banco primeiro (dados/view),
|
||||
antes de mexer no front.
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="section" id="10">
|
||||
<h2>10. Linha do tempo da sessão (resumo)</h2>
|
||||
|
||||
<ul>
|
||||
<li><strong>Detecção:</strong> tela “Saúde das Assinaturas” exibindo Owner vazio e divergências.</li>
|
||||
<li><strong>Inspeção:</strong> <code>v_subscription_feature_mismatch</code> mostrava <code>owner_id NULL</code> em missing/unexpected.</li>
|
||||
<li><strong>Erro crítico:</strong> FixAll falhava com <code>subscriptions.owner_id</code> inexistente.</li>
|
||||
<li><strong>Correção #1:</strong> alinhar RPCs ao schema atual (<code>subscriptions.user_id</code>).</li>
|
||||
<li><strong>Correção #2:</strong> identificar que <code>owner_feature_entitlements</code> é VIEW e filtrar <code>status='active'</code>.</li>
|
||||
<li><strong>Correção #3:</strong> remover subscription inválida com <code>user_id NULL</code>.</li>
|
||||
<li><strong>Hardening:</strong> constraints e índices para prevenir regressões.</li>
|
||||
</ul>
|
||||
|
||||
<div class="callout info">
|
||||
<strong>Atualizado:</strong> 2026-03-01 11:46:44 UTC
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
<footer>
|
||||
<div><strong>Agência PSI — Relatório Técnico (Billing Health)</strong></div>
|
||||
<div class="small">Documento operacional inspirado no “Documento Mestre Billing v2.0”. Atualizado em 2026-03-01 11:46:44 UTC.</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
414
Nova-Dev-Doc/supervisor_fase1.sql
Normal file
414
Nova-Dev-Doc/supervisor_fase1.sql
Normal file
@@ -0,0 +1,414 @@
|
||||
-- ============================================================
|
||||
-- SUPERVISOR — Fase 1
|
||||
-- Aplicar no Supabase SQL Editor (em ordem)
|
||||
-- ============================================================
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 1. tenants.kind → adiciona 'supervisor'
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE public.tenants
|
||||
DROP CONSTRAINT IF EXISTS tenants_kind_check;
|
||||
|
||||
ALTER TABLE public.tenants
|
||||
ADD CONSTRAINT tenants_kind_check
|
||||
CHECK (kind = ANY (ARRAY[
|
||||
'therapist',
|
||||
'clinic_coworking',
|
||||
'clinic_reception',
|
||||
'clinic_full',
|
||||
'clinic',
|
||||
'saas',
|
||||
'supervisor' -- ← novo
|
||||
]));
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 2. plans.target → adiciona 'supervisor'
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE public.plans
|
||||
DROP CONSTRAINT IF EXISTS plans_target_check;
|
||||
|
||||
ALTER TABLE public.plans
|
||||
ADD CONSTRAINT plans_target_check
|
||||
CHECK (target = ANY (ARRAY[
|
||||
'patient',
|
||||
'therapist',
|
||||
'clinic',
|
||||
'supervisor' -- ← novo
|
||||
]));
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 3. plans.max_supervisees — limite de supervisionados
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE public.plans
|
||||
ADD COLUMN IF NOT EXISTS max_supervisees integer DEFAULT NULL;
|
||||
|
||||
COMMENT ON COLUMN public.plans.max_supervisees IS
|
||||
'Limite de terapeutas que podem ser supervisionados. Apenas para planos target=supervisor. NULL = sem limite.';
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 4. Planos supervisor_free e supervisor_pro
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.plans (key, name, description, target, is_active, max_supervisees)
|
||||
VALUES
|
||||
(
|
||||
'supervisor_free',
|
||||
'Supervisor Free',
|
||||
'Plano gratuito de supervisão. Até 3 terapeutas supervisionados.',
|
||||
'supervisor',
|
||||
true,
|
||||
3
|
||||
),
|
||||
(
|
||||
'supervisor_pro',
|
||||
'Supervisor PRO',
|
||||
'Plano profissional de supervisão. Até 20 terapeutas supervisionados.',
|
||||
'supervisor',
|
||||
true,
|
||||
20
|
||||
)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
target = EXCLUDED.target,
|
||||
is_active = EXCLUDED.is_active,
|
||||
max_supervisees = EXCLUDED.max_supervisees;
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 5. Features de supervisor
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.features (key, name, descricao)
|
||||
VALUES
|
||||
(
|
||||
'supervisor.access',
|
||||
'Acesso à Supervisão',
|
||||
'Acesso básico ao espaço de supervisão (sala, lista de supervisionados).'
|
||||
),
|
||||
(
|
||||
'supervisor.invite',
|
||||
'Convidar Supervisionados',
|
||||
'Permite convidar terapeutas para participar da sala de supervisão.'
|
||||
),
|
||||
(
|
||||
'supervisor.sessions',
|
||||
'Sessões de Supervisão',
|
||||
'Agendamento e registro de sessões de supervisão.'
|
||||
),
|
||||
(
|
||||
'supervisor.reports',
|
||||
'Relatórios de Supervisão',
|
||||
'Relatórios avançados de progresso e evolução dos supervisionados.'
|
||||
)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET
|
||||
name = EXCLUDED.name,
|
||||
descricao = EXCLUDED.descricao;
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 6. plan_features — vincula features aos planos supervisor
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
DO $$
|
||||
DECLARE
|
||||
v_free_id uuid;
|
||||
v_pro_id uuid;
|
||||
v_f_access uuid;
|
||||
v_f_invite uuid;
|
||||
v_f_sessions uuid;
|
||||
v_f_reports uuid;
|
||||
BEGIN
|
||||
SELECT id INTO v_free_id FROM public.plans WHERE key = 'supervisor_free';
|
||||
SELECT id INTO v_pro_id FROM public.plans WHERE key = 'supervisor_pro';
|
||||
|
||||
SELECT id INTO v_f_access FROM public.features WHERE key = 'supervisor.access';
|
||||
SELECT id INTO v_f_invite FROM public.features WHERE key = 'supervisor.invite';
|
||||
SELECT id INTO v_f_sessions FROM public.features WHERE key = 'supervisor.sessions';
|
||||
SELECT id INTO v_f_reports FROM public.features WHERE key = 'supervisor.reports';
|
||||
|
||||
-- supervisor_free: access + invite (limitado por max_supervisees=3)
|
||||
INSERT INTO public.plan_features (plan_id, feature_id)
|
||||
VALUES
|
||||
(v_free_id, v_f_access),
|
||||
(v_free_id, v_f_invite)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- supervisor_pro: tudo
|
||||
INSERT INTO public.plan_features (plan_id, feature_id)
|
||||
VALUES
|
||||
(v_pro_id, v_f_access),
|
||||
(v_pro_id, v_f_invite),
|
||||
(v_pro_id, v_f_sessions),
|
||||
(v_pro_id, v_f_reports)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 7. activate_subscription_from_intent — suporte a supervisor
|
||||
-- Supervisor = pessoal (user_id), sem tenant_id (igual therapist)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid)
|
||||
RETURNS public.subscriptions
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
declare
|
||||
v_intent record;
|
||||
v_sub public.subscriptions;
|
||||
v_days int;
|
||||
v_user_id uuid;
|
||||
v_plan_id uuid;
|
||||
v_target text;
|
||||
begin
|
||||
-- lê pela VIEW unificada
|
||||
select * into v_intent
|
||||
from public.subscription_intents
|
||||
where id = p_intent_id;
|
||||
|
||||
if not found then
|
||||
raise exception 'Intent não encontrado: %', p_intent_id;
|
||||
end if;
|
||||
|
||||
if v_intent.status <> 'paid' then
|
||||
raise exception 'Intent precisa estar paid para ativar assinatura';
|
||||
end if;
|
||||
|
||||
-- resolve target e plan_id via plans.key
|
||||
select p.id, p.target
|
||||
into v_plan_id, v_target
|
||||
from public.plans p
|
||||
where p.key = v_intent.plan_key
|
||||
limit 1;
|
||||
|
||||
if v_plan_id is null then
|
||||
raise exception 'Plano não encontrado em plans.key = %', v_intent.plan_key;
|
||||
end if;
|
||||
|
||||
v_target := lower(coalesce(v_target, ''));
|
||||
|
||||
-- ✅ supervisor adicionado
|
||||
if v_target not in ('clinic', 'therapist', 'supervisor') then
|
||||
raise exception 'Target inválido em plans.target: %', v_target;
|
||||
end if;
|
||||
|
||||
-- regra por target
|
||||
if v_target = 'clinic' then
|
||||
if v_intent.tenant_id is null then
|
||||
raise exception 'Intent sem tenant_id';
|
||||
end if;
|
||||
else
|
||||
-- therapist ou supervisor: vinculado ao user
|
||||
v_user_id := v_intent.user_id;
|
||||
if v_user_id is null then
|
||||
v_user_id := v_intent.created_by_user_id;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
if v_target in ('therapist', 'supervisor') and v_user_id is null then
|
||||
raise exception 'Não foi possível determinar user_id para assinatura %.', v_target;
|
||||
end if;
|
||||
|
||||
-- cancela assinatura ativa anterior
|
||||
if v_target = 'clinic' then
|
||||
update public.subscriptions
|
||||
set status = 'cancelled',
|
||||
cancelled_at = now()
|
||||
where tenant_id = v_intent.tenant_id
|
||||
and plan_id = v_plan_id
|
||||
and status = 'active';
|
||||
else
|
||||
-- therapist ou supervisor
|
||||
update public.subscriptions
|
||||
set status = 'cancelled',
|
||||
cancelled_at = now()
|
||||
where user_id = v_user_id
|
||||
and plan_id = v_plan_id
|
||||
and status = 'active'
|
||||
and tenant_id is null;
|
||||
end if;
|
||||
|
||||
-- duração do plano (30 dias para mensal)
|
||||
v_days := case
|
||||
when lower(coalesce(v_intent.interval, 'month')) = 'year' then 365
|
||||
else 30
|
||||
end;
|
||||
|
||||
-- cria nova assinatura
|
||||
insert into public.subscriptions (
|
||||
user_id,
|
||||
plan_id,
|
||||
status,
|
||||
started_at,
|
||||
expires_at,
|
||||
cancelled_at,
|
||||
activated_at,
|
||||
tenant_id,
|
||||
plan_key,
|
||||
interval,
|
||||
source,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
values (
|
||||
case when v_target = 'clinic' then null else v_user_id end,
|
||||
v_plan_id,
|
||||
'active',
|
||||
now(),
|
||||
now() + make_interval(days => v_days),
|
||||
null,
|
||||
now(),
|
||||
case when v_target = 'clinic' then v_intent.tenant_id else null end,
|
||||
v_intent.plan_key,
|
||||
v_intent.interval,
|
||||
'manual',
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
returning * into v_sub;
|
||||
|
||||
-- grava vínculo intent → subscription
|
||||
if v_target = 'clinic' then
|
||||
update public.subscription_intents_tenant
|
||||
set subscription_id = v_sub.id
|
||||
where id = p_intent_id;
|
||||
else
|
||||
update public.subscription_intents_personal
|
||||
set subscription_id = v_sub.id
|
||||
where id = p_intent_id;
|
||||
end if;
|
||||
|
||||
return v_sub;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 8. subscriptions_validate_scope — suporte a supervisor
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.subscriptions_validate_scope()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_target text;
|
||||
BEGIN
|
||||
SELECT lower(p.target) INTO v_target
|
||||
FROM public.plans p
|
||||
WHERE p.id = NEW.plan_id;
|
||||
|
||||
IF v_target IS NULL THEN
|
||||
RAISE EXCEPTION 'Plano inválido (target nulo).';
|
||||
END IF;
|
||||
|
||||
IF v_target = 'clinic' THEN
|
||||
IF NEW.tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura clinic exige tenant_id.';
|
||||
END IF;
|
||||
IF NEW.user_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura clinic não pode ter user_id (XOR).';
|
||||
END IF;
|
||||
|
||||
ELSIF v_target IN ('therapist', 'supervisor') THEN
|
||||
-- supervisor é pessoal como therapist
|
||||
IF NEW.tenant_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura % não deve ter tenant_id.', v_target;
|
||||
END IF;
|
||||
IF NEW.user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura % exige user_id.', v_target;
|
||||
END IF;
|
||||
|
||||
ELSIF v_target = 'patient' THEN
|
||||
IF NEW.tenant_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura patient não deve ter tenant_id.';
|
||||
END IF;
|
||||
IF NEW.user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura patient exige user_id.';
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Target de plano inválido: %', v_target;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 9. subscription_intents_view_insert — suporte a supervisor
|
||||
-- supervisor é roteado como therapist (tabela personal)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.subscription_intents_view_insert()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
declare
|
||||
v_target text;
|
||||
v_plan_id uuid;
|
||||
begin
|
||||
select p.id, p.target into v_plan_id, v_target
|
||||
from public.plans p
|
||||
where p.key = new.plan_key;
|
||||
|
||||
if v_plan_id is null then
|
||||
raise exception 'Plano inválido: plan_key=%', new.plan_key;
|
||||
end if;
|
||||
|
||||
if lower(v_target) = 'clinic' then
|
||||
if new.tenant_id is null then
|
||||
raise exception 'Intenção clinic exige tenant_id.';
|
||||
end if;
|
||||
|
||||
insert into public.subscription_intents_tenant (
|
||||
id, tenant_id, created_by_user_id, email,
|
||||
plan_id, plan_key, interval, amount_cents, currency,
|
||||
status, source, notes, created_at, paid_at
|
||||
) values (
|
||||
coalesce(new.id, gen_random_uuid()),
|
||||
new.tenant_id, new.created_by_user_id, new.email,
|
||||
v_plan_id, new.plan_key, coalesce(new.interval,'month'),
|
||||
new.amount_cents, coalesce(new.currency,'BRL'),
|
||||
coalesce(new.status,'pending'), coalesce(new.source,'manual'),
|
||||
new.notes, coalesce(new.created_at, now()), new.paid_at
|
||||
);
|
||||
|
||||
new.plan_target := 'clinic';
|
||||
return new;
|
||||
end if;
|
||||
|
||||
-- therapist ou supervisor → tabela personal
|
||||
if lower(v_target) in ('therapist', 'supervisor') then
|
||||
insert into public.subscription_intents_personal (
|
||||
id, user_id, created_by_user_id, email,
|
||||
plan_id, plan_key, interval, amount_cents, currency,
|
||||
status, source, notes, created_at, paid_at
|
||||
) values (
|
||||
coalesce(new.id, gen_random_uuid()),
|
||||
new.user_id, new.created_by_user_id, new.email,
|
||||
v_plan_id, new.plan_key, coalesce(new.interval,'month'),
|
||||
new.amount_cents, coalesce(new.currency,'BRL'),
|
||||
coalesce(new.status,'pending'), coalesce(new.source,'manual'),
|
||||
new.notes, coalesce(new.created_at, now()), new.paid_at
|
||||
);
|
||||
|
||||
new.plan_target := lower(v_target); -- 'therapist' ou 'supervisor'
|
||||
return new;
|
||||
end if;
|
||||
|
||||
raise exception 'Target de plano não suportado: %', v_target;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- FIM — verificação rápida
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
SELECT key, name, target, max_supervisees
|
||||
FROM public.plans
|
||||
WHERE target = 'supervisor'
|
||||
ORDER BY key;
|
||||
220
Novo-DB/fix_missing_subscriptions.sql
Normal file
220
Novo-DB/fix_missing_subscriptions.sql
Normal file
@@ -0,0 +1,220 @@
|
||||
-- =============================================================================
|
||||
-- FIX: Atribuir plano free a usuários/tenants sem assinatura ativa
|
||||
-- =============================================================================
|
||||
-- Execute no SQL Editor do Supabase (service_role)
|
||||
-- Idempotente: só insere onde não existe assinatura ativa.
|
||||
--
|
||||
-- Regras:
|
||||
-- • tenant kind = 'therapist' → therapist_free (por user_id do admin)
|
||||
-- • tenant kind IN (clinic_*) → clinic_free (por tenant_id)
|
||||
-- • profiles.account_type = 'patient' / portal_user → patient_free (por user_id)
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- DIAGNÓSTICO — mostra o estado atual antes de corrigir
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
RAISE NOTICE '=== DIAGNÓSTICO DE ASSINATURAS ===';
|
||||
RAISE NOTICE '';
|
||||
|
||||
-- Terapeutas sem plano
|
||||
RAISE NOTICE '--- Terapeutas SEM assinatura ativa ---';
|
||||
FOR r IN
|
||||
SELECT
|
||||
tm.user_id,
|
||||
p.full_name,
|
||||
t.id AS tenant_id,
|
||||
t.name AS tenant_name
|
||||
FROM public.tenant_members tm
|
||||
JOIN public.tenants t ON t.id = tm.tenant_id
|
||||
JOIN public.profiles p ON p.id = tm.user_id
|
||||
WHERE t.kind = 'therapist'
|
||||
AND tm.role = 'tenant_admin'
|
||||
AND tm.status = 'active'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = tm.user_id
|
||||
AND s.status = 'active'
|
||||
)
|
||||
LOOP
|
||||
RAISE NOTICE ' FALTANDO: % (%) — tenant %', r.full_name, r.user_id, r.tenant_id;
|
||||
END LOOP;
|
||||
|
||||
-- Clínicas sem plano
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '--- Clínicas SEM assinatura ativa ---';
|
||||
FOR r IN
|
||||
SELECT t.id, t.name, t.kind
|
||||
FROM public.tenants t
|
||||
WHERE t.kind IN ('clinic_coworking', 'clinic_reception', 'clinic_full', 'clinic')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.tenant_id = t.id
|
||||
AND s.status = 'active'
|
||||
)
|
||||
LOOP
|
||||
RAISE NOTICE ' FALTANDO: % (%) — kind %', r.name, r.id, r.kind;
|
||||
END LOOP;
|
||||
|
||||
-- Pacientes sem plano
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '--- Pacientes SEM assinatura ativa ---';
|
||||
FOR r IN
|
||||
SELECT p.id, p.full_name
|
||||
FROM public.profiles p
|
||||
WHERE p.account_type = 'patient'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = p.id
|
||||
AND s.status = 'active'
|
||||
)
|
||||
LOOP
|
||||
RAISE NOTICE ' FALTANDO: % (%)', r.full_name, r.id;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '=== FIM DO DIAGNÓSTICO — aplicando correções... ===';
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- CORREÇÃO 1: Terapeutas sem assinatura → therapist_free
|
||||
-- Escopo: user_id do tenant_admin do tenant kind='therapist'
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO public.subscriptions (
|
||||
user_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
tm.user_id,
|
||||
p.id,
|
||||
p.key,
|
||||
'active',
|
||||
'month',
|
||||
now(),
|
||||
now() + interval '30 days',
|
||||
'fix_seed',
|
||||
now(),
|
||||
now()
|
||||
FROM public.tenant_members tm
|
||||
JOIN public.tenants t ON t.id = tm.tenant_id
|
||||
JOIN public.plans p ON p.key = 'therapist_free'
|
||||
WHERE t.kind = 'therapist'
|
||||
AND tm.role = 'tenant_admin'
|
||||
AND tm.status = 'active'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = tm.user_id
|
||||
AND s.status = 'active'
|
||||
);
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- CORREÇÃO 2: Clínicas sem assinatura → clinic_free
|
||||
-- Escopo: tenant_id
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO public.subscriptions (
|
||||
tenant_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
t.id,
|
||||
p.id,
|
||||
p.key,
|
||||
'active',
|
||||
'month',
|
||||
now(),
|
||||
now() + interval '30 days',
|
||||
'fix_seed',
|
||||
now(),
|
||||
now()
|
||||
FROM public.tenants t
|
||||
JOIN public.plans p ON p.key = 'clinic_free'
|
||||
WHERE t.kind IN ('clinic_coworking', 'clinic_reception', 'clinic_full', 'clinic')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.tenant_id = t.id
|
||||
AND s.status = 'active'
|
||||
);
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- CORREÇÃO 3: Pacientes sem assinatura → patient_free
|
||||
-- Escopo: user_id
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO public.subscriptions (
|
||||
user_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
pr.id,
|
||||
p.id,
|
||||
p.key,
|
||||
'active',
|
||||
'month',
|
||||
now(),
|
||||
now() + interval '30 days',
|
||||
'fix_seed',
|
||||
now(),
|
||||
now()
|
||||
FROM public.profiles pr
|
||||
JOIN public.plans p ON p.key = 'patient_free'
|
||||
WHERE pr.account_type = 'patient'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = pr.id
|
||||
AND s.status = 'active'
|
||||
);
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- CONFIRMAÇÃO — mostra o que foi inserido (source = 'fix_seed')
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
total INT := 0;
|
||||
BEGIN
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '=== ASSINATURAS CRIADAS NESTA EXECUÇÃO ===';
|
||||
|
||||
FOR r IN
|
||||
SELECT
|
||||
s.plan_key,
|
||||
COALESCE(pr.full_name, t.name) AS nome,
|
||||
COALESCE(s.user_id::text, s.tenant_id::text) AS owner_id
|
||||
FROM public.subscriptions s
|
||||
LEFT JOIN public.profiles pr ON pr.id = s.user_id
|
||||
LEFT JOIN public.tenants t ON t.id = s.tenant_id
|
||||
WHERE s.source = 'fix_seed'
|
||||
AND s.started_at >= now() - interval '5 seconds'
|
||||
ORDER BY s.plan_key, nome
|
||||
LOOP
|
||||
RAISE NOTICE ' ✅ % → % (%)', r.plan_key, r.nome, r.owner_id;
|
||||
total := total + 1;
|
||||
END LOOP;
|
||||
|
||||
IF total = 0 THEN
|
||||
RAISE NOTICE ' (nenhuma nova assinatura criada — todos já tinham plano ativo)';
|
||||
ELSE
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE ' Total: % assinatura(s) criada(s).', total;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
50
Novo-DB/fix_subscriptions_validate_scope.sql
Normal file
50
Novo-DB/fix_subscriptions_validate_scope.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- Fix: subscriptions_validate_scope — adiciona suporte a target='patient'
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.subscriptions_validate_scope()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_target text;
|
||||
BEGIN
|
||||
SELECT lower(p.target) INTO v_target
|
||||
FROM public.plans p
|
||||
WHERE p.id = NEW.plan_id;
|
||||
|
||||
IF v_target IS NULL THEN
|
||||
RAISE EXCEPTION 'Plano inválido (target nulo).';
|
||||
END IF;
|
||||
|
||||
IF v_target = 'clinic' THEN
|
||||
IF NEW.tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura clinic exige tenant_id.';
|
||||
END IF;
|
||||
IF NEW.user_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura clinic não pode ter user_id (XOR).';
|
||||
END IF;
|
||||
|
||||
ELSIF v_target = 'therapist' THEN
|
||||
IF NEW.tenant_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura therapist não deve ter tenant_id.';
|
||||
END IF;
|
||||
IF NEW.user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura therapist exige user_id.';
|
||||
END IF;
|
||||
|
||||
ELSIF v_target = 'patient' THEN
|
||||
IF NEW.tenant_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura patient não deve ter tenant_id.';
|
||||
END IF;
|
||||
IF NEW.user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura patient exige user_id.';
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Target de plano inválido: %', v_target;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.subscriptions_validate_scope() OWNER TO supabase_admin;
|
||||
296
Novo-DB/migration_001.sql
Normal file
296
Novo-DB/migration_001.sql
Normal file
@@ -0,0 +1,296 @@
|
||||
-- =============================================================================
|
||||
-- SEED 001 — Usuários fictícios para teste
|
||||
-- =============================================================================
|
||||
-- Execute APÓS migration_001.sql
|
||||
--
|
||||
-- Usuários criados:
|
||||
-- paciente@agenciapsi.com.br senha: Teste@123 → patient
|
||||
-- terapeuta@agenciapsi.com.br senha: Teste@123 → therapist
|
||||
-- clinica1@agenciapsi.com.br senha: Teste@123 → clinic_coworking
|
||||
-- clinica2@agenciapsi.com.br senha: Teste@123 → clinic_reception
|
||||
-- clinica3@agenciapsi.com.br senha: Teste@123 → clinic_full
|
||||
-- saas@agenciapsi.com.br senha: Teste@123 → saas_admin
|
||||
-- =============================================================================
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- Limpeza de seeds anteriores
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE public.patient_groups DISABLE TRIGGER ALL;
|
||||
|
||||
DELETE FROM public.tenant_members
|
||||
WHERE user_id IN (
|
||||
SELECT id FROM auth.users
|
||||
WHERE email IN (
|
||||
'paciente@agenciapsi.com.br',
|
||||
'terapeuta@agenciapsi.com.br',
|
||||
'clinica1@agenciapsi.com.br',
|
||||
'clinica2@agenciapsi.com.br',
|
||||
'clinica3@agenciapsi.com.br',
|
||||
'saas@agenciapsi.com.br'
|
||||
)
|
||||
);
|
||||
|
||||
DELETE FROM public.tenants WHERE id IN (
|
||||
'bbbbbbbb-0002-0002-0002-000000000002',
|
||||
'bbbbbbbb-0003-0003-0003-000000000003',
|
||||
'bbbbbbbb-0004-0004-0004-000000000004',
|
||||
'bbbbbbbb-0005-0005-0005-000000000005'
|
||||
);
|
||||
|
||||
DELETE FROM auth.users WHERE email IN (
|
||||
'paciente@agenciapsi.com.br',
|
||||
'terapeuta@agenciapsi.com.br',
|
||||
'clinica1@agenciapsi.com.br',
|
||||
'clinica2@agenciapsi.com.br',
|
||||
'clinica3@agenciapsi.com.br',
|
||||
'saas@agenciapsi.com.br'
|
||||
);
|
||||
|
||||
ALTER TABLE public.patient_groups ENABLE TRIGGER ALL;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Usuários no auth.users
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO auth.users (
|
||||
id, email, encrypted_password, email_confirmed_at,
|
||||
created_at, updated_at, raw_user_meta_data, role, aud
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'aaaaaaaa-0001-0001-0001-000000000001',
|
||||
'paciente@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(),
|
||||
'{"name": "Ana Paciente"}'::jsonb,
|
||||
'authenticated', 'authenticated'
|
||||
),
|
||||
(
|
||||
'aaaaaaaa-0002-0002-0002-000000000002',
|
||||
'terapeuta@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(),
|
||||
'{"name": "Bruno Terapeuta"}'::jsonb,
|
||||
'authenticated', 'authenticated'
|
||||
),
|
||||
(
|
||||
'aaaaaaaa-0003-0003-0003-000000000003',
|
||||
'clinica1@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(),
|
||||
'{"name": "Clinica Espaco Psi"}'::jsonb,
|
||||
'authenticated', 'authenticated'
|
||||
),
|
||||
(
|
||||
'aaaaaaaa-0004-0004-0004-000000000004',
|
||||
'clinica2@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(),
|
||||
'{"name": "Clinica Mente Sa"}'::jsonb,
|
||||
'authenticated', 'authenticated'
|
||||
),
|
||||
(
|
||||
'aaaaaaaa-0005-0005-0005-000000000005',
|
||||
'clinica3@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(),
|
||||
'{"name": "Clinica Bem Estar"}'::jsonb,
|
||||
'authenticated', 'authenticated'
|
||||
),
|
||||
(
|
||||
'aaaaaaaa-0006-0006-0006-000000000006',
|
||||
'saas@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(),
|
||||
'{"name": "Admin Plataforma"}'::jsonb,
|
||||
'authenticated', 'authenticated'
|
||||
);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Profiles
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.profiles (id, role, account_type, full_name)
|
||||
VALUES
|
||||
('aaaaaaaa-0001-0001-0001-000000000001', 'portal_user', 'patient', 'Ana Paciente'),
|
||||
('aaaaaaaa-0002-0002-0002-000000000002', 'portal_user', 'therapist', 'Bruno Terapeuta'),
|
||||
('aaaaaaaa-0003-0003-0003-000000000003', 'portal_user', 'clinic', 'Clinica Espaco Psi'),
|
||||
('aaaaaaaa-0004-0004-0004-000000000004', 'portal_user', 'clinic', 'Clinica Mente Sa'),
|
||||
('aaaaaaaa-0005-0005-0005-000000000005', 'portal_user', 'clinic', 'Clinica Bem Estar'),
|
||||
('aaaaaaaa-0006-0006-0006-000000000006', 'saas_admin', 'free', 'Admin Plataforma')
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
role = EXCLUDED.role,
|
||||
account_type = EXCLUDED.account_type,
|
||||
full_name = EXCLUDED.full_name;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 3. SaaS Admin
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.saas_admins (user_id, created_at)
|
||||
VALUES ('aaaaaaaa-0006-0006-0006-000000000006', now())
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Tenant do terapeuta
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenants (id, name, kind, created_at)
|
||||
VALUES ('bbbbbbbb-0002-0002-0002-000000000002', 'Bruno Terapeuta', 'therapist', now())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES ('bbbbbbbb-0002-0002-0002-000000000002', 'aaaaaaaa-0002-0002-0002-000000000002', 'tenant_admin', 'active', now())
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
DO $$ BEGIN
|
||||
PERFORM public.seed_determined_commitments('bbbbbbbb-0002-0002-0002-000000000002');
|
||||
END; $$;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 5. Tenant Clinica 1 — Coworking
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenants (id, name, kind, created_at)
|
||||
VALUES ('bbbbbbbb-0003-0003-0003-000000000003', 'Clinica Espaco Psi', 'clinic_coworking', now())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES ('bbbbbbbb-0003-0003-0003-000000000003', 'aaaaaaaa-0003-0003-0003-000000000003', 'tenant_admin', 'active', now())
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
DO $$ BEGIN
|
||||
PERFORM public.seed_determined_commitments('bbbbbbbb-0003-0003-0003-000000000003');
|
||||
END; $$;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 6. Tenant Clinica 2 — Recepcao
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenants (id, name, kind, created_at)
|
||||
VALUES ('bbbbbbbb-0004-0004-0004-000000000004', 'Clinica Mente Sa', 'clinic_reception', now())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES ('bbbbbbbb-0004-0004-0004-000000000004', 'aaaaaaaa-0004-0004-0004-000000000004', 'tenant_admin', 'active', now())
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
DO $$ BEGIN
|
||||
PERFORM public.seed_determined_commitments('bbbbbbbb-0004-0004-0004-000000000004');
|
||||
END; $$;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 7. Tenant Clinica 3 — Full
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenants (id, name, kind, created_at)
|
||||
VALUES ('bbbbbbbb-0005-0005-0005-000000000005', 'Clinica Bem Estar', 'clinic_full', now())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES ('bbbbbbbb-0005-0005-0005-000000000005', 'aaaaaaaa-0005-0005-0005-000000000005', 'tenant_admin', 'active', now())
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
DO $$ BEGIN
|
||||
PERFORM public.seed_determined_commitments('bbbbbbbb-0005-0005-0005-000000000005');
|
||||
END; $$;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 8. Subscriptions ativas
|
||||
-- ============================================================
|
||||
|
||||
-- Paciente → patient_free
|
||||
INSERT INTO public.subscriptions (
|
||||
user_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end, source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
'aaaaaaaa-0001-0001-0001-000000000001',
|
||||
p.id, p.key, 'active', 'month',
|
||||
now(), now() + interval '30 days', 'seed', now(), now()
|
||||
FROM public.plans p WHERE p.key = 'patient_free';
|
||||
|
||||
-- Terapeuta → therapist_free
|
||||
INSERT INTO public.subscriptions (
|
||||
user_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end, source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
'aaaaaaaa-0002-0002-0002-000000000002',
|
||||
p.id, p.key, 'active', 'month',
|
||||
now(), now() + interval '30 days', 'seed', now(), now()
|
||||
FROM public.plans p WHERE p.key = 'therapist_free';
|
||||
|
||||
-- Clinica 1 → clinic_free
|
||||
INSERT INTO public.subscriptions (
|
||||
tenant_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end, source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
'bbbbbbbb-0003-0003-0003-000000000003',
|
||||
p.id, p.key, 'active', 'month',
|
||||
now(), now() + interval '30 days', 'seed', now(), now()
|
||||
FROM public.plans p WHERE p.key = 'clinic_free';
|
||||
|
||||
-- Clinica 2 → clinic_free
|
||||
INSERT INTO public.subscriptions (
|
||||
tenant_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end, source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
'bbbbbbbb-0004-0004-0004-000000000004',
|
||||
p.id, p.key, 'active', 'month',
|
||||
now(), now() + interval '30 days', 'seed', now(), now()
|
||||
FROM public.plans p WHERE p.key = 'clinic_free';
|
||||
|
||||
-- Clinica 3 → clinic_free
|
||||
INSERT INTO public.subscriptions (
|
||||
tenant_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end, source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
'bbbbbbbb-0005-0005-0005-000000000005',
|
||||
p.id, p.key, 'active', 'month',
|
||||
now(), now() + interval '30 days', 'seed', now(), now()
|
||||
FROM public.plans p WHERE p.key = 'clinic_free';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 9. Vincula terapeuta à Clinica 3 (exemplo de associacao)
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES (
|
||||
'bbbbbbbb-0005-0005-0005-000000000005',
|
||||
'aaaaaaaa-0002-0002-0002-000000000002',
|
||||
'therapist', 'active', now()
|
||||
)
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- Confirmacao
|
||||
-- ============================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '✅ Seed aplicado com sucesso.';
|
||||
RAISE NOTICE ' paciente@agenciapsi.com.br → patient';
|
||||
RAISE NOTICE ' terapeuta@agenciapsi.com.br → therapist';
|
||||
RAISE NOTICE ' clinica1@agenciapsi.com.br → clinic_coworking';
|
||||
RAISE NOTICE ' clinica2@agenciapsi.com.br → clinic_reception';
|
||||
RAISE NOTICE ' clinica3@agenciapsi.com.br → clinic_full';
|
||||
RAISE NOTICE ' saas@agenciapsi.com.br → saas_admin';
|
||||
RAISE NOTICE ' Senha: Teste@123';
|
||||
END;
|
||||
$$;
|
||||
13
Novo-DB/migration_002_layout_variant.sql
Normal file
13
Novo-DB/migration_002_layout_variant.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- =============================================================================
|
||||
-- Migration 002 — Adiciona layout_variant em user_settings
|
||||
-- =============================================================================
|
||||
-- Execute no SQL Editor do Supabase (ou via Docker psql).
|
||||
-- Tolerante: usa IF NOT EXISTS / DEFAULT para não quebrar dados existentes.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.user_settings
|
||||
ADD COLUMN IF NOT EXISTS layout_variant TEXT NOT NULL DEFAULT 'classic';
|
||||
|
||||
-- =============================================================================
|
||||
RAISE NOTICE '✅ Coluna layout_variant adicionada a user_settings.';
|
||||
-- =============================================================================
|
||||
334
Novo-DB/seed_001.sql
Normal file
334
Novo-DB/seed_001.sql
Normal file
@@ -0,0 +1,334 @@
|
||||
-- =============================================================================
|
||||
-- SEED — Usuários fictícios para teste
|
||||
-- =============================================================================
|
||||
-- IMPORTANTE: Execute APÓS a migration_001.sql
|
||||
-- IMPORTANTE: Requer extensão pgcrypto (já ativa no Supabase)
|
||||
--
|
||||
-- Cria os seguintes usuários de teste:
|
||||
--
|
||||
-- paciente@agenciapsi.com.br senha: Teste@123 → paciente
|
||||
-- terapeuta@agenciapsi.com.br senha: Teste@123 → terapeuta solo
|
||||
-- clinica1@agenciapsi.com.br senha: Teste@123 → clínica coworking
|
||||
-- clinica2@agenciapsi.com.br senha: Teste@123 → clínica com secretaria
|
||||
-- clinica3@agenciapsi.com.br senha: Teste@123 → clínica full
|
||||
-- saas@agenciapsi.com.br senha: Teste@123 → admin da plataforma
|
||||
--
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================
|
||||
-- Helper: cria usuário no auth.users + profile
|
||||
-- (Supabase não expõe auth.users diretamente, mas em SQL Editor
|
||||
-- com acesso de service_role podemos inserir diretamente)
|
||||
-- ============================================================
|
||||
|
||||
-- Limpa seeds anteriores se existirem
|
||||
DELETE FROM auth.users
|
||||
WHERE email IN (
|
||||
'paciente@agenciapsi.com.br',
|
||||
'terapeuta@agenciapsi.com.br',
|
||||
'clinica1@agenciapsi.com.br',
|
||||
'clinica2@agenciapsi.com.br',
|
||||
'clinica3@agenciapsi.com.br',
|
||||
'saas@agenciapsi.com.br'
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Cria usuários no auth.users
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO auth.users (
|
||||
instance_id,
|
||||
id,
|
||||
email,
|
||||
encrypted_password,
|
||||
email_confirmed_at,
|
||||
confirmed_at,
|
||||
created_at,
|
||||
updated_at,
|
||||
raw_user_meta_data,
|
||||
raw_app_meta_data,
|
||||
role,
|
||||
aud,
|
||||
is_sso_user,
|
||||
is_anonymous,
|
||||
confirmation_token,
|
||||
recovery_token,
|
||||
email_change_token_new,
|
||||
email_change_token_current,
|
||||
email_change
|
||||
)
|
||||
VALUES
|
||||
-- Paciente
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'aaaaaaaa-0001-0001-0001-000000000001',
|
||||
'paciente@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(), now(),
|
||||
'{"name": "Ana Paciente"}'::jsonb,
|
||||
'{"provider": "email", "providers": ["email"]}'::jsonb,
|
||||
'authenticated', 'authenticated', false, false, '', '', '', '', ''
|
||||
),
|
||||
-- Terapeuta
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'aaaaaaaa-0002-0002-0002-000000000002',
|
||||
'terapeuta@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(), now(),
|
||||
'{"name": "Bruno Terapeuta"}'::jsonb,
|
||||
'{"provider": "email", "providers": ["email"]}'::jsonb,
|
||||
'authenticated', 'authenticated', false, false, '', '', '', '', ''
|
||||
),
|
||||
-- Clínica 1 — Coworking
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'aaaaaaaa-0003-0003-0003-000000000003',
|
||||
'clinica1@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(), now(),
|
||||
'{"name": "Clínica Espaço Psi"}'::jsonb,
|
||||
'{"provider": "email", "providers": ["email"]}'::jsonb,
|
||||
'authenticated', 'authenticated', false, false, '', '', '', '', ''
|
||||
),
|
||||
-- Clínica 2 — Recepção
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'aaaaaaaa-0004-0004-0004-000000000004',
|
||||
'clinica2@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(), now(),
|
||||
'{"name": "Clínica Mente Sã"}'::jsonb,
|
||||
'{"provider": "email", "providers": ["email"]}'::jsonb,
|
||||
'authenticated', 'authenticated', false, false, '', '', '', '', ''
|
||||
),
|
||||
-- Clínica 3 — Full
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'aaaaaaaa-0005-0005-0005-000000000005',
|
||||
'clinica3@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(), now(),
|
||||
'{"name": "Clínica Bem Estar"}'::jsonb,
|
||||
'{"provider": "email", "providers": ["email"]}'::jsonb,
|
||||
'authenticated', 'authenticated', false, false, '', '', '', '', ''
|
||||
),
|
||||
-- SaaS Admin
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'aaaaaaaa-0006-0006-0006-000000000006',
|
||||
'saas@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(), now(),
|
||||
'{"name": "Admin Plataforma"}'::jsonb,
|
||||
'{"provider": "email", "providers": ["email"]}'::jsonb,
|
||||
'authenticated', 'authenticated', false, false, '', '', '', '', ''
|
||||
);
|
||||
|
||||
-- auth.identities (obrigatório para GoTrue reconhecer login email/senha)
|
||||
INSERT INTO auth.identities (id, user_id, provider_id, provider, identity_data, created_at, updated_at, last_sign_in_at)
|
||||
VALUES
|
||||
(gen_random_uuid(), 'aaaaaaaa-0001-0001-0001-000000000001', 'paciente@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0001-0001-0001-000000000001", "email": "paciente@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
|
||||
(gen_random_uuid(), 'aaaaaaaa-0002-0002-0002-000000000002', 'terapeuta@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0002-0002-0002-000000000002", "email": "terapeuta@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
|
||||
(gen_random_uuid(), 'aaaaaaaa-0003-0003-0003-000000000003', 'clinica1@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0003-0003-0003-000000000003", "email": "clinica1@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
|
||||
(gen_random_uuid(), 'aaaaaaaa-0004-0004-0004-000000000004', 'clinica2@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0004-0004-0004-000000000004", "email": "clinica2@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
|
||||
(gen_random_uuid(), 'aaaaaaaa-0005-0005-0005-000000000005', 'clinica3@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0005-0005-0005-000000000005", "email": "clinica3@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
|
||||
(gen_random_uuid(), 'aaaaaaaa-0006-0006-0006-000000000006', 'saas@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0006-0006-0006-000000000006", "email": "saas@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now())
|
||||
ON CONFLICT (provider, provider_id) DO NOTHING;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Profiles (o trigger handle_new_user não dispara em inserts
|
||||
-- diretos no auth.users via SQL, então criamos manualmente)
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.profiles (id, role, account_type, full_name)
|
||||
VALUES
|
||||
('aaaaaaaa-0001-0001-0001-000000000001', 'portal_user', 'patient', 'Ana Paciente'),
|
||||
('aaaaaaaa-0002-0002-0002-000000000002', 'tenant_member', 'therapist', 'Bruno Terapeuta'),
|
||||
('aaaaaaaa-0003-0003-0003-000000000003', 'tenant_member', 'clinic', 'Clínica Espaço Psi'),
|
||||
('aaaaaaaa-0004-0004-0004-000000000004', 'tenant_member', 'clinic', 'Clínica Mente Sã'),
|
||||
('aaaaaaaa-0005-0005-0005-000000000005', 'tenant_member', 'clinic', 'Clínica Bem Estar'),
|
||||
('aaaaaaaa-0006-0006-0006-000000000006', 'saas_admin', 'free', 'Admin Plataforma')
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
role = EXCLUDED.role,
|
||||
account_type = EXCLUDED.account_type,
|
||||
full_name = EXCLUDED.full_name;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 3. SaaS Admin na tabela saas_admins
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.saas_admins (user_id, created_at)
|
||||
VALUES ('aaaaaaaa-0006-0006-0006-000000000006', now())
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Tenant do terapeuta
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenants (id, name, kind, created_at)
|
||||
VALUES ('bbbbbbbb-0002-0002-0002-000000000002', 'Bruno Terapeuta', 'therapist', now())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES ('bbbbbbbb-0002-0002-0002-000000000002', 'aaaaaaaa-0002-0002-0002-000000000002', 'tenant_admin', 'active', now())
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0002-0002-0002-000000000002'); END $$;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 5. Tenant da Clínica 1 — Coworking
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenants (id, name, kind, created_at)
|
||||
VALUES ('bbbbbbbb-0003-0003-0003-000000000003', 'Clínica Espaço Psi', 'clinic_coworking', now())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES ('bbbbbbbb-0003-0003-0003-000000000003', 'aaaaaaaa-0003-0003-0003-000000000003', 'tenant_admin', 'active', now())
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0003-0003-0003-000000000003'); END $$;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 6. Tenant da Clínica 2 — Recepção
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenants (id, name, kind, created_at)
|
||||
VALUES ('bbbbbbbb-0004-0004-0004-000000000004', 'Clínica Mente Sã', 'clinic_reception', now())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES ('bbbbbbbb-0004-0004-0004-000000000004', 'aaaaaaaa-0004-0004-0004-000000000004', 'tenant_admin', 'active', now())
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0004-0004-0004-000000000004'); END $$;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 7. Tenant da Clínica 3 — Full
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenants (id, name, kind, created_at)
|
||||
VALUES ('bbbbbbbb-0005-0005-0005-000000000005', 'Clínica Bem Estar', 'clinic_full', now())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES ('bbbbbbbb-0005-0005-0005-000000000005', 'aaaaaaaa-0005-0005-0005-000000000005', 'tenant_admin', 'active', now())
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0005-0005-0005-000000000005'); END $$;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 8. Subscriptions ativas para cada conta
|
||||
-- ============================================================
|
||||
|
||||
-- Terapeuta → plano therapist_free
|
||||
INSERT INTO public.subscriptions (
|
||||
user_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
'aaaaaaaa-0002-0002-0002-000000000002',
|
||||
p.id, p.key, 'active', 'month',
|
||||
now(), now() + interval '30 days',
|
||||
'seed', now(), now()
|
||||
FROM public.plans p WHERE p.key = 'therapist_free';
|
||||
|
||||
-- Clínica 1 → plano clinic_free
|
||||
INSERT INTO public.subscriptions (
|
||||
tenant_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
'bbbbbbbb-0003-0003-0003-000000000003',
|
||||
p.id, p.key, 'active', 'month',
|
||||
now(), now() + interval '30 days',
|
||||
'seed', now(), now()
|
||||
FROM public.plans p WHERE p.key = 'clinic_free';
|
||||
|
||||
-- Clínica 2 → plano clinic_free
|
||||
INSERT INTO public.subscriptions (
|
||||
tenant_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
'bbbbbbbb-0004-0004-0004-000000000004',
|
||||
p.id, p.key, 'active', 'month',
|
||||
now(), now() + interval '30 days',
|
||||
'seed', now(), now()
|
||||
FROM public.plans p WHERE p.key = 'clinic_free';
|
||||
|
||||
-- Clínica 3 → plano clinic_free
|
||||
INSERT INTO public.subscriptions (
|
||||
tenant_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
'bbbbbbbb-0005-0005-0005-000000000005',
|
||||
p.id, p.key, 'active', 'month',
|
||||
now(), now() + interval '30 days',
|
||||
'seed', now(), now()
|
||||
FROM public.plans p WHERE p.key = 'clinic_free';
|
||||
|
||||
-- Paciente → plano patient_free
|
||||
INSERT INTO public.subscriptions (
|
||||
user_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
'aaaaaaaa-0001-0001-0001-000000000001',
|
||||
p.id, p.key, 'active', 'month',
|
||||
now(), now() + interval '30 days',
|
||||
'seed', now(), now()
|
||||
FROM public.plans p WHERE p.key = 'patient_free';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 9. Vincula terapeuta à Clínica 3 (full) como exemplo
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES (
|
||||
'bbbbbbbb-0005-0005-0005-000000000005',
|
||||
'aaaaaaaa-0002-0002-0002-000000000002',
|
||||
'therapist',
|
||||
'active',
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 10. Confirma
|
||||
-- ============================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '✅ Seed aplicado com sucesso.';
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE ' Usuários criados:';
|
||||
RAISE NOTICE ' paciente@agenciapsi.com.br → patient';
|
||||
RAISE NOTICE ' terapeuta@agenciapsi.com.br → therapist (tenant próprio + vinculado à Clínica 3)';
|
||||
RAISE NOTICE ' clinica1@agenciapsi.com.br → clinic_coworking';
|
||||
RAISE NOTICE ' clinica2@agenciapsi.com.br → clinic_reception';
|
||||
RAISE NOTICE ' clinica3@agenciapsi.com.br → clinic_full';
|
||||
RAISE NOTICE ' saas@agenciapsi.com.br → saas_admin';
|
||||
RAISE NOTICE ' Senha de todos: Teste@123';
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
199
Novo-DB/seed_002.sql
Normal file
199
Novo-DB/seed_002.sql
Normal file
@@ -0,0 +1,199 @@
|
||||
-- =============================================================================
|
||||
-- SEED 002 — Supervisor e Editor
|
||||
-- =============================================================================
|
||||
-- Execute APÓS seed_001.sql
|
||||
-- Requer: pgcrypto (já ativo no Supabase)
|
||||
--
|
||||
-- Cria os seguintes usuários de teste:
|
||||
--
|
||||
-- supervisor@agenciapsi.com.br senha: Teste@123 → supervisor da Clínica 3
|
||||
-- editor@agenciapsi.com.br senha: Teste@123 → editor de conteúdo (plataforma)
|
||||
--
|
||||
-- UUIDs reservados:
|
||||
-- Supervisor → aaaaaaaa-0007-0007-0007-000000000007
|
||||
-- Editor → aaaaaaaa-0008-0008-0008-000000000008
|
||||
--
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================
|
||||
-- 0. Migration: adiciona platform_roles em profiles (se não existir)
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN IF NOT EXISTS platform_roles text[] NOT NULL DEFAULT '{}';
|
||||
|
||||
COMMENT ON COLUMN public.profiles.platform_roles IS
|
||||
'Papéis globais de plataforma, independentes de tenant. Ex: editor de microlearning. Atribuído pelo saas_admin.';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Remove seeds anteriores (idempotente)
|
||||
-- ============================================================
|
||||
|
||||
DELETE FROM auth.users
|
||||
WHERE email IN (
|
||||
'supervisor@agenciapsi.com.br',
|
||||
'editor@agenciapsi.com.br'
|
||||
);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Cria usuários no auth.users
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO auth.users (
|
||||
instance_id,
|
||||
id,
|
||||
email,
|
||||
encrypted_password,
|
||||
email_confirmed_at,
|
||||
created_at,
|
||||
updated_at,
|
||||
raw_user_meta_data,
|
||||
raw_app_meta_data,
|
||||
role,
|
||||
aud,
|
||||
is_sso_user,
|
||||
is_anonymous,
|
||||
confirmation_token,
|
||||
recovery_token,
|
||||
email_change_token_new,
|
||||
email_change_token_current,
|
||||
email_change
|
||||
)
|
||||
VALUES
|
||||
-- Supervisor
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'aaaaaaaa-0007-0007-0007-000000000007',
|
||||
'supervisor@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(),
|
||||
'{"name": "Carlos Supervisor"}'::jsonb,
|
||||
'{"provider": "email", "providers": ["email"]}'::jsonb,
|
||||
'authenticated', 'authenticated', false, false, '', '', '', '', ''
|
||||
),
|
||||
-- Editor de Conteúdo
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'aaaaaaaa-0008-0008-0008-000000000008',
|
||||
'editor@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(),
|
||||
'{"name": "Diana Editora"}'::jsonb,
|
||||
'{"provider": "email", "providers": ["email"]}'::jsonb,
|
||||
'authenticated', 'authenticated', false, false, '', '', '', '', ''
|
||||
);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 3. auth.identities (obrigatório para GoTrue reconhecer login)
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO auth.identities (id, user_id, provider_id, provider, identity_data, created_at, updated_at, last_sign_in_at)
|
||||
VALUES
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'aaaaaaaa-0007-0007-0007-000000000007',
|
||||
'supervisor@agenciapsi.com.br',
|
||||
'email',
|
||||
'{"sub": "aaaaaaaa-0007-0007-0007-000000000007", "email": "supervisor@agenciapsi.com.br", "email_verified": true}'::jsonb,
|
||||
now(), now(), now()
|
||||
),
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'aaaaaaaa-0008-0008-0008-000000000008',
|
||||
'editor@agenciapsi.com.br',
|
||||
'email',
|
||||
'{"sub": "aaaaaaaa-0008-0008-0008-000000000008", "email": "editor@agenciapsi.com.br", "email_verified": true}'::jsonb,
|
||||
now(), now(), now()
|
||||
)
|
||||
ON CONFLICT (provider, provider_id) DO NOTHING;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Profiles
|
||||
-- Supervisor → tenant_member (papel no tenant via tenant_members.role)
|
||||
-- Editor → tenant_member + platform_roles = '{editor}'
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.profiles (id, role, account_type, full_name, platform_roles)
|
||||
VALUES
|
||||
(
|
||||
'aaaaaaaa-0007-0007-0007-000000000007',
|
||||
'tenant_member',
|
||||
'therapist',
|
||||
'Carlos Supervisor',
|
||||
'{}'
|
||||
),
|
||||
(
|
||||
'aaaaaaaa-0008-0008-0008-000000000008',
|
||||
'tenant_member',
|
||||
'therapist',
|
||||
'Diana Editora',
|
||||
'{editor}' -- permissão de plataforma: acesso à área do editor
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
role = EXCLUDED.role,
|
||||
account_type = EXCLUDED.account_type,
|
||||
full_name = EXCLUDED.full_name,
|
||||
platform_roles = EXCLUDED.platform_roles;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 5. Vincula Supervisor à Clínica 3 (Full) com role 'supervisor'
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES (
|
||||
'bbbbbbbb-0005-0005-0005-000000000005', -- Clínica Bem Estar (Full)
|
||||
'aaaaaaaa-0007-0007-0007-000000000007', -- Carlos Supervisor
|
||||
'supervisor',
|
||||
'active',
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET
|
||||
role = EXCLUDED.role,
|
||||
status = EXCLUDED.status;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 6. Vincula Editor à Clínica 3 como terapeuta
|
||||
-- (contexto de tenant para o editor poder usar /therapist também,
|
||||
-- se necessário. O papel de editor vem de platform_roles.)
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES (
|
||||
'bbbbbbbb-0005-0005-0005-000000000005', -- Clínica Bem Estar (Full)
|
||||
'aaaaaaaa-0008-0008-0008-000000000008', -- Diana Editora
|
||||
'therapist',
|
||||
'active',
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET
|
||||
role = EXCLUDED.role,
|
||||
status = EXCLUDED.status;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 7. Confirma
|
||||
-- ============================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '✅ Seed 002 aplicado com sucesso.';
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE ' Migration aplicada:';
|
||||
RAISE NOTICE ' → profiles.platform_roles text[] adicionada (se não existia)';
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE ' Usuários criados:';
|
||||
RAISE NOTICE ' supervisor@agenciapsi.com.br → supervisor da Clínica Bem Estar (Full)';
|
||||
RAISE NOTICE ' editor@agenciapsi.com.br → editor de conteúdo (platform_roles = {editor})';
|
||||
RAISE NOTICE ' Senha de todos: Teste@123';
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
283
Novo-DB/seed_003.sql
Normal file
283
Novo-DB/seed_003.sql
Normal file
@@ -0,0 +1,283 @@
|
||||
-- =============================================================================
|
||||
-- SEED 003 — Terapeuta 2, Terapeuta 3 e Secretária
|
||||
-- =============================================================================
|
||||
-- Execute APÓS seed_001.sql (e seed_002.sql se quiser todos os seeds)
|
||||
-- Requer: pgcrypto (já ativo no Supabase)
|
||||
--
|
||||
-- Cria os seguintes usuários de teste:
|
||||
--
|
||||
-- therapist2@agenciapsi.com.br senha: Teste@123 → terapeuta 2 (tenant próprio + Clínica 3)
|
||||
-- therapist3@agenciapsi.com.br senha: Teste@123 → terapeuta 3 (tenant próprio + Clínica 3)
|
||||
-- secretary@agenciapsi.com.br senha: Teste@123 → clinic_admin na Clínica 2 (Mente Sã)
|
||||
--
|
||||
-- UUIDs reservados:
|
||||
-- Terapeuta 2 → aaaaaaaa-0009-0009-0009-000000000009
|
||||
-- Terapeuta 3 → aaaaaaaa-0010-0010-0010-000000000010
|
||||
-- Secretária → aaaaaaaa-0011-0011-0011-000000000011
|
||||
-- Tenant Terapeuta 2 → bbbbbbbb-0009-0009-0009-000000000009
|
||||
-- Tenant Terapeuta 3 → bbbbbbbb-0010-0010-0010-000000000010
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Remove seeds anteriores (idempotente)
|
||||
-- ============================================================
|
||||
|
||||
DELETE FROM auth.users
|
||||
WHERE email IN (
|
||||
'therapist2@agenciapsi.com.br',
|
||||
'therapist3@agenciapsi.com.br',
|
||||
'secretary@agenciapsi.com.br'
|
||||
);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Cria usuários no auth.users
|
||||
-- ⚠️ confirmed_at é coluna gerada — NÃO incluir na lista
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO auth.users (
|
||||
instance_id,
|
||||
id,
|
||||
email,
|
||||
encrypted_password,
|
||||
email_confirmed_at,
|
||||
created_at,
|
||||
updated_at,
|
||||
raw_user_meta_data,
|
||||
raw_app_meta_data,
|
||||
role,
|
||||
aud,
|
||||
is_sso_user,
|
||||
is_anonymous,
|
||||
confirmation_token,
|
||||
recovery_token,
|
||||
email_change_token_new,
|
||||
email_change_token_current,
|
||||
email_change
|
||||
)
|
||||
VALUES
|
||||
-- Terapeuta 2
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'aaaaaaaa-0009-0009-0009-000000000009',
|
||||
'therapist2@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(),
|
||||
'{"name": "Eva Terapeuta"}'::jsonb,
|
||||
'{"provider": "email", "providers": ["email"]}'::jsonb,
|
||||
'authenticated', 'authenticated', false, false, '', '', '', '', ''
|
||||
),
|
||||
-- Terapeuta 3
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'aaaaaaaa-0010-0010-0010-000000000010',
|
||||
'therapist3@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(),
|
||||
'{"name": "Felipe Terapeuta"}'::jsonb,
|
||||
'{"provider": "email", "providers": ["email"]}'::jsonb,
|
||||
'authenticated', 'authenticated', false, false, '', '', '', '', ''
|
||||
),
|
||||
-- Secretária
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'aaaaaaaa-0011-0011-0011-000000000011',
|
||||
'secretary@agenciapsi.com.br',
|
||||
crypt('Teste@123', gen_salt('bf')),
|
||||
now(), now(), now(),
|
||||
'{"name": "Gabriela Secretária"}'::jsonb,
|
||||
'{"provider": "email", "providers": ["email"]}'::jsonb,
|
||||
'authenticated', 'authenticated', false, false, '', '', '', '', ''
|
||||
);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 3. auth.identities (obrigatório para GoTrue reconhecer login)
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO auth.identities (id, user_id, provider_id, provider, identity_data, created_at, updated_at, last_sign_in_at)
|
||||
VALUES
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'aaaaaaaa-0009-0009-0009-000000000009',
|
||||
'therapist2@agenciapsi.com.br',
|
||||
'email',
|
||||
'{"sub": "aaaaaaaa-0009-0009-0009-000000000009", "email": "therapist2@agenciapsi.com.br", "email_verified": true}'::jsonb,
|
||||
now(), now(), now()
|
||||
),
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'aaaaaaaa-0010-0010-0010-000000000010',
|
||||
'therapist3@agenciapsi.com.br',
|
||||
'email',
|
||||
'{"sub": "aaaaaaaa-0010-0010-0010-000000000010", "email": "therapist3@agenciapsi.com.br", "email_verified": true}'::jsonb,
|
||||
now(), now(), now()
|
||||
),
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'aaaaaaaa-0011-0011-0011-000000000011',
|
||||
'secretary@agenciapsi.com.br',
|
||||
'email',
|
||||
'{"sub": "aaaaaaaa-0011-0011-0011-000000000011", "email": "secretary@agenciapsi.com.br", "email_verified": true}'::jsonb,
|
||||
now(), now(), now()
|
||||
)
|
||||
ON CONFLICT (provider, provider_id) DO NOTHING;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Profiles
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.profiles (id, role, account_type, full_name)
|
||||
VALUES
|
||||
(
|
||||
'aaaaaaaa-0009-0009-0009-000000000009',
|
||||
'tenant_member',
|
||||
'therapist',
|
||||
'Eva Terapeuta'
|
||||
),
|
||||
(
|
||||
'aaaaaaaa-0010-0010-0010-000000000010',
|
||||
'tenant_member',
|
||||
'therapist',
|
||||
'Felipe Terapeuta'
|
||||
),
|
||||
(
|
||||
'aaaaaaaa-0011-0011-0011-000000000011',
|
||||
'tenant_member',
|
||||
'therapist',
|
||||
'Gabriela Secretária'
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
role = EXCLUDED.role,
|
||||
account_type = EXCLUDED.account_type,
|
||||
full_name = EXCLUDED.full_name;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 5. Tenants pessoais dos Terapeutas 2 e 3
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenants (id, name, kind, created_at)
|
||||
VALUES
|
||||
('bbbbbbbb-0009-0009-0009-000000000009', 'Eva Terapeuta', 'therapist', now()),
|
||||
('bbbbbbbb-0010-0010-0010-000000000010', 'Felipe Terapeuta', 'therapist', now())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Terapeuta 2 → tenant_admin do próprio tenant
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES (
|
||||
'bbbbbbbb-0009-0009-0009-000000000009',
|
||||
'aaaaaaaa-0009-0009-0009-000000000009',
|
||||
'tenant_admin', 'active', now()
|
||||
)
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
-- Terapeuta 3 → tenant_admin do próprio tenant
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES (
|
||||
'bbbbbbbb-0010-0010-0010-000000000010',
|
||||
'aaaaaaaa-0010-0010-0010-000000000010',
|
||||
'tenant_admin', 'active', now()
|
||||
)
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 6. Vincula Terapeutas 2 e 3 à Clínica 3 — Full
|
||||
-- (mesmo padrão de terapeuta@agenciapsi.com.br no seed_001)
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES
|
||||
(
|
||||
'bbbbbbbb-0005-0005-0005-000000000005', -- Clínica Bem Estar (Full)
|
||||
'aaaaaaaa-0009-0009-0009-000000000009', -- Eva Terapeuta
|
||||
'therapist', 'active', now()
|
||||
),
|
||||
(
|
||||
'bbbbbbbb-0005-0005-0005-000000000005', -- Clínica Bem Estar (Full)
|
||||
'aaaaaaaa-0010-0010-0010-000000000010', -- Felipe Terapeuta
|
||||
'therapist', 'active', now()
|
||||
)
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 7. Vincula Secretária à Clínica 2 (Recepção) como clinic_admin
|
||||
-- A secretária gerencia a recepção/agenda da clínica.
|
||||
-- Acessa a área /admin com o mesmo contexto de clinic_admin.
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES (
|
||||
'bbbbbbbb-0004-0004-0004-000000000004', -- Clínica Mente Sã (Recepção)
|
||||
'aaaaaaaa-0011-0011-0011-000000000011', -- Gabriela Secretária
|
||||
'clinic_admin', 'active', now()
|
||||
)
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 8. Subscriptions
|
||||
-- Terapeutas 2 e 3 → therapist_free (escopo: user_id)
|
||||
-- Secretária → sem assinatura própria (usa o plano da Clínica 2)
|
||||
-- ============================================================
|
||||
|
||||
-- Terapeuta 2 → therapist_free
|
||||
INSERT INTO public.subscriptions (
|
||||
user_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
'aaaaaaaa-0009-0009-0009-000000000009',
|
||||
p.id, p.key, 'active', 'month',
|
||||
now(), now() + interval '30 days',
|
||||
'seed', now(), now()
|
||||
FROM public.plans p WHERE p.key = 'therapist_free'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = 'aaaaaaaa-0009-0009-0009-000000000009' AND s.status = 'active'
|
||||
);
|
||||
|
||||
-- Terapeuta 3 → therapist_free
|
||||
INSERT INTO public.subscriptions (
|
||||
user_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
'aaaaaaaa-0010-0010-0010-000000000010',
|
||||
p.id, p.key, 'active', 'month',
|
||||
now(), now() + interval '30 days',
|
||||
'seed', now(), now()
|
||||
FROM public.plans p WHERE p.key = 'therapist_free'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = 'aaaaaaaa-0010-0010-0010-000000000010' AND s.status = 'active'
|
||||
);
|
||||
|
||||
-- Nota: a Secretária não tem assinatura própria.
|
||||
-- O acesso vem do plano da Clínica 2 (tenant_id = bbbbbbbb-0004-0004-0004-000000000004).
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 9. Confirma
|
||||
-- ============================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '✅ Seed 003 aplicado com sucesso.';
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE ' Usuários criados:';
|
||||
RAISE NOTICE ' therapist2@agenciapsi.com.br → tenant próprio (bbbbbbbb-0009) + Clínica 3 como therapist';
|
||||
RAISE NOTICE ' therapist3@agenciapsi.com.br → tenant próprio (bbbbbbbb-0010) + Clínica 3 como therapist';
|
||||
RAISE NOTICE ' secretary@agenciapsi.com.br → clinic_admin na Clínica 2 Mente Sã (bbbbbbbb-0004)';
|
||||
RAISE NOTICE ' Senha de todos: Teste@123';
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
1798
Pedra Angular/plataforma_saude_mental.html
Normal file
1798
Pedra Angular/plataforma_saude_mental.html
Normal file
File diff suppressed because it is too large
Load Diff
429
Pedra Angular/plataforma_saude_mental_estrategia.pdf
Normal file
429
Pedra Angular/plataforma_saude_mental_estrategia.pdf
Normal file
@@ -0,0 +1,429 @@
|
||||
%PDF-1.4
|
||||
%“Œ‹ž ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 6 0 R /F4 23 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-BoldOblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 30 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 31 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 32 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 33 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 34 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 35 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Contents 36 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Contents 37 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Contents 38 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Contents 39 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Contents 40 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Contents 41 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Contents 42 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Contents 43 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Contents 44 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Contents 45 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Contents 46 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 27 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Author (\(anonymous\)) /CreationDate (D:20260304134538+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260304134538+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Count 19 /Kids [ 4 0 R 5 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R
|
||||
15 0 R 16 0 R 17 0 R 18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 24 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1167
|
||||
>>
|
||||
stream
|
||||
Gatn%9lJcG&;KZQ'm&.^C+a/oIIqEJ!_'EL2JQ$u#dP)QLo&UeZ2=-:8:<OZ>Qh/4-^6S7+247jB`uNoVSC([i6Mi2\3`H4llJt2%G<V?Zc2Zm5`*<$#kV!u!^RtnJGmB[ig2eX@C/p?Je@(&"nFuoX#Zo-q_nm(PV8%l;\8^`6P-\qJ=g;k@]Zeb!eMdGdKnM9If"B'b#VUDI(ib"]/ABHmZ\o5goUP$<nG)Rp'n;dh*>r`o>@We7<$tO%f`[aNk;T3C02RHIGWa.K+H_2L-$RaJ(<*JG#MG\2Lh7@-%`DY.M0F2-S>@Bgq[503JsN;OD(0K`*fPU*PZ!WQ7SmR`9d8i0PL\,j?XF((iDb@YN>b&VmNH)Kl!GSP%e--%Q/('VVG61"7Z/q9g)cO^ST)8d/eR)EO.u(#:]R2%XBA%;Z7@X9oE`Za[Da\O8AT%Fj%#G#]#[`r0n"s@fY\c,;b]0]gChTp*tMP=<-llD/7:**T9RZFEBusU;6`\k#kIPSBKBSK`*YBL'cHg^IJXg;@od+;7`Tc:3\@;MO@^USq^P/O@l2.m$SO6pbC]>"XF_:^.4ftR]Br+abt"ANV0S>I<U-'L0LpET!&B/ju_/3(bXSrJ@i?iroKd`Mr'VMl,qCnF`JOPIc$FNNjBJ\H6^<Pgs-O/Zj8];(^&h9n?\!E''+gY=;UYhYWXi)/r-1X4[#)^mSgjGiIMBGXmk>4FZ.dYaPTaUANJ&J\D#E8+qO$;??tFjQ^_KI1SF%Vl`/W=a*a(<)/m[tg?7fhq%NAKZ/Gipr250Qi$ZX,)U@+^"@5Rp[#Tn?mlrR@mJ/TC_AfI_aZ$=^b;A9n`@-rId;Td0:pDOqF!;BSW"u:m$2o0(SYLh$cJ/B;aHJH#rq&esag8gH%Xk@]d1>ds-b/fuN-ZmHX3N$J^cH0%C>R\lDiTTofHi;FF<0[?#Nud`qjX(--HSJ:aNu5H5#shEc)[O>eI1q#;37&@@`KJ7cV\'b[r[R0a`n(PIo0T3WaK2+&6J\$"P1'rmMRcm)(0>1I.R"UPe1%6CrXMh'ZW':)7VTM4lQQn^TeZ$XV`U,MDT,V5mGYfZ[F0Ym4)^,edUG)([]IiGV2c5b;!lIn^9+>&]`bB4`hnJDG0d:H\\)aS(L$T(*oC<Dn>@4l%6nS-^SF[~>endstream
|
||||
endobj
|
||||
29 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1306
|
||||
>>
|
||||
stream
|
||||
Gb!$H95iQE&BF8='QZqUPfrg]kfjJem)F2'S:&`,[%T`#2j0[7qWW4"8:DgYp9^]),c4:n1S"9'r0(_Xn\s5^kn`4'"oNAXJ8n\mJOq%o0nJZ0k\3F%APP$(bYmCZ&FPu?T\PJ5I+U2?PS=1`;hQ*dJQj=oQIs'lqXXtS`>>t#9lR=r"\3ZI!5E*901loF\78pW+DhXI/R6LHj2LfUDE])YeMZ-CQU5XB9Wu2a`fj3&G_F'!Y+oS3mrpFT--f&&gAP#BG?\G(!NrK79^8HNCm1[rQCJo(%)k83q'+(Hr5]Xn8m<lt3%YTiphm=OqO#Vs0SC^9.AOI^@&Onupjd6kM8@^=@!;MgU=p3,&0AiJ;$m\jrsuiFQ9pH/N6iB=3T]P&oWnmd't#]QJ.CN4Z8rs9UJIjC>Y,16GDB_>('dIu>BdRl6jmF!h0F"5[=2ZM#BJ<YVg130UF`Yga\'#a@s2CI6CYC&2,5+JGamoTqk)?6TPXW#m_nDZm7L%8_6/KAYg'XSDignMVZp(YK:1V?3Sf;:N`bTuI`ULIh>VBq(li%Crf8!8g(%1nJaXO?NG)OE!._K;C*mCK!W2%3kWVrfV<ngDfnZTnZCTI#^,4D3XC&'eK!IRJ?VYCn`)#rajLlAZ=XrD'q`A,g'/(sZZB!XC?R@e7!mJgVkYL\qAY1tL\^5^#+&8[4Y`D=Jj(oe@Yn+]c`K:hknF$s'5rKF3gcDf[ET/o;:^n[<+@piW<4==u8GOhEg?@Lg^uM),=(WDd"V9'YB?pln@9PL^O<C<\T]g>c&<TLs(4j5@^J3X#k@*eZO?`VZW1orXU7oBaE);Y$:mh)79lOJ/;#+DI.'YUn,q:bo#6u7W#Nb;kE):OnQmCq!81Ia;4VqI&QIjeN_[f<Y,"sg;b^]hlN^LapI+i/>YBOR+WQ3OAP'XL_3045?O!`FIEM$6R,8mAj]["78P.uDd"ToPM"$RjlB]EKNa+#[A]0'';C="2VgVp:ul8'J/%(=1"M_6!.m_a$Jo=mX9h1.nhMZ^pL:[2I2aqk\f=^$R8-Y8DTRlE7oBJer3BZQr/ES_ucXF)'-'#+6;ZWs;/BQ"mu?Y@.!IIHYD%2fTa3?0cq,2G^0?i@HL`D'8+p9\i$.pkDor&JPB,`'?mMT,i>Nkf1fXsYmG:kVQ2Ctj$NoO.sI$NXJmU0<#'_h3fbK5e_*ROll5jZKSA<1/`;W3]4-D.n'p>H-4*fOui"i<W*ml'Cnb^+irJ]oG?G/k]QJ0m=*DN=IZc_&;LcErR(+Jh[SNVR`NqI`3I]TB@=(UON<:>X4/~>endstream
|
||||
endobj
|
||||
30 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2486
|
||||
>>
|
||||
stream
|
||||
Gau0D?#SK-&q/*0(bDmVDNXMTqkJ)?9j4np2qc.(IE;jAdgcaWF=>7:gg>QW:`l!oVFb`N@3h]qMOZ\J3+p5pH2.1&Io-gVB0lRsi0,80@RY_k,$J6uq;UVs8GJdK-U%i$o>3U4&`e=lm/![#bF'H03!r$1o2l-0S5><(0M3P5:pbOiKLPFICpuhu)=NZFACpm+rP=dZ&P4&)5%R#.g^8ssNit'1(T/?-YJ6UUff@lYhM,Hh_3acN(8huKU;]&p]GVJo&"[)kbVPBZ;7aO?R45^>eZGP:VR<_C%:ami$U_BhOOAJSYOk`CKpLh:09O:HQ@pOS,YG`Qh^Jd/Ll&SC,E.<=a([Yd!N:B1`<uu^#r%PLbea9X`6O"-^uGYDNFNCLriaI&[p]shnqG[7*I'm*O8`f$l+0m'2D>.1l.4)7(df4=5oeuC>.4l?mc`Scm'/Q:(5a@GZ[HoM[\;H([aFR%dJ.#\$uVk\-F*cu.]T,iCpp<5/o#3+i3@-M$hU^e$i9_aju2kK9DWm^I<n1lQZR@uq>#DX\S'!E9o1Xam7bXc46CoS)Jp&U&EX[(LO[ntLG2dD-+V+&4MsTZ2c$QJAGL'?4b"Z2gKRHuH0"ejju`AfqrEd4o+B@1R28$_[VIo.Bb`3I%PLg\=LH+"d*:38;IQS"WEMlN.U@7P2N8tL)f*/:pB#K)jWJ#3q]1a$9%h@9!R/'9[pTIG?a.Xk&9d1j[%=U.A3'E)o=-/7=-4t+dqDk*9Bik(qi6hT/!ku33X'!`lF!?D(tqp!-uf[U=r6a_e?UU/J!:G(9\mPr\R^b?U&O?G__S:483:mQiT!O?F&P`f@.m&*)UfD?'q=?ojeFH/482!&c]eRscC:$kM9,#g3R2s%Nf`oG(1-/#a<GHd,tDM!+X4=1$MLPFM24%,i<K(:7O7X6TNF>1C*9a&JK143h5i>3Q&?C5=kW":BkpI7)Vf0ZZNl[I@R&/$baF#9D>L30;Jd`RH0AdqXd70d.QSUh1+Qp9TeXEI,HQKGlYUJQE@,hXW>Kqh4],TO7S&\>S>U7C\Q8B$3Z\Z4n,dMI2%k#;,NMmD5mt+5<$/C!#NEZVUL;Y3)o]qR\U_Ka1W49k(_p8jY8>,Ac>sSLjW+<rK[b(R/>>*rV[3l(^YYrtY,R\1,nmt*6#F<gAWM'I:X1$173(ess5f.qb;(,k7@jd4T1;aJKj1:IHi!=CeOR@U]1:V3gh0oE4N0UH6mM7R$N4Y&.4dOIq%ad[7!W\/SKJB#%An_bF;YJ^hWp=`'[N>&E*Uq)Diobc'&Q.=Q[qLp"gTlm`t>W-ko*f:)OXV&?!9aV.:!4FmROV.4SA&rS[n?\P='IoK-r5`X8(c]*;PQ.Da.0Y#_MWIan;/BeH%P!PaOLaU1/48cJ8WLRjL6[,QBui>XY).f5m+%V+UrcTatGR3<@%\:JZYkDZAB:IPu/?B>i!#n((:`8'9pC2Hd@oaI/($3tSN(\kuKYc3c1,E,+Xd*+tInO&os*N(0p240HO?&:QOI>;s4]<["joWSkV2YN$@m0r`\2&kWi`s"l"Q896injiYC(@_2(HRJ/88E27GC@n?T(<8^$lVUGKu!;/Ze'd'!agtSHe_J3d4>au9h(%,UXrutRXVdW0r(0&R\9(rLN<D3!%L#4u6'pUTQTTVR>+fgh+OdUlL.nd$"/6lffOfao\dH1VXL8joCF5Zu#MQ3?*&"RV1c\R*Tb8"Y_LJt#WCJ#miXm<A.?jRr49!imr^CBkg<n[1#]1snB.g<dLN6GB:]EB<u$=WXM=pr#WI/\US&qQ_!lVAVK^CY_8bJgB_BK3]T2&=LW)W\ugP9GF&)\Ja4N+[URW:oQsN5O@*"?Z]%2q=M&)e_:EefF<$RCbrAm>NbmD,jrkJ_6?N8cuI<C(4Z)=+BZK%`r3Hh:(:MGURB_i`DGJX8>.)ajjqfngmBMcJ7gg+1>eHAbrr.]c4#>9K_'lC5na$5qU2Y?b9\.*m5iJ+RPVF]g['/.p^?0E!"A:G*3@s]_,KFqiZ#Y](u$hpi.tFMo:PEXN0?8hUG1uBnuH[bHIP`hYK7S!r9d'Ni#PYWH]1%Y.RtKqpli3[&=:Rh\V_"?n/1eJAtfj6YUs8qdkoZr6u6^#C;?R@0+Mg_l;M8%89WKD+_,;4ns9R**STO%_DV[pJlJdkfR$,2$KB\aJ*1K/oVX\FgEgqUnP1VYTnu\/i@%W:mgitm%S"paSHs_7VCDIP>tgccGWF;DR'O0fb/D/)H1Mg2[HbhU8O2]!tQr):`\t]L:u2_CtX;bec7kZ(j$:n46KE<&0R'N:^k,4B7Np\$s5[=n4ofPiWr5#Z(9!:?NcDiZbAf23S7*;EU03W!m0.Qmg`PU]R(3qFD;s:$'9@5hiUEOmGjfUE/a6*4aIc3&._4.n+>b=>X+k2>P@>f%W3XKb0bR'MA@,)e@2]0\p)O"c&V[KrLr?(Fi2mdB/!%e&D\m,a#,`0FS%eb:pUW~>endstream
|
||||
endobj
|
||||
31 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2081
|
||||
>>
|
||||
stream
|
||||
Gatm;gN)%,&:O:Sm%`<T;$%kbl'pBdR<G)]NKs4V0<b`(aRGqnioK]X(=\&&h8F.nR^"<'>TE)jHZg\a_FK?qX,?-(-2mUBP#SenPDhq(b:C//Sp`X516Qo,8]WI2\K[_f?D)I_<o`:8C11SRalGpie0LhFU#RE<'9:Z$T-5UHGWKZ7.,u=gOmG!AWn_#;M+DZP9?NJ7+i:OZo648ojl5t+qmQrZF\+NM>HJVmhHp2q>[2\IbE'%.IM[f9nB^'9Sr#Rhds:^ic"a1^h)BH/or[V"[?2ECC?S]tP^[?3[4bduo!Y(T-$#2[M'e?4E>g.uTAi3*EtTLWTjn[+PF>c4O=D\X]T@(bOj)mJbT6[^QuIY\Udlns)M8F;s/0YE>93$aUkhLe.T0lkqTq+\le]$l!rt<Qn!iPrp6=iAR5h*-\g(@'O@(;!a)G"T7A=o_LD.+U_sW_Jrd^%&mj2^m%&?R-$'_itK$:r`73:M.=CZ#"Ao&r+AEVS:c>HoZ7>$MAja>h0@V#qOW@G,4_t#&k&2?Ncj]N;Hl2O[=-6(ooPUN=WAN3"h\X'buREe#r!7as*f<,QDnf>9/dQ0k2D4?Rr,321u[,jZ^hU8F'BW1_"g39*+C0Qk%B\g%\"mtPF4]]!36C#g8g\k8"hHu13Vf?;M(.TJ:4Q?*fKdo','4/$Olau!\'L<Nn^%:nBIR)H;f)A')m==ZAmmOW7ER3,?!K8#2k=\8R`VE6WcHmu5<?cPud+AXh(US=%ja1>Z(EBG(L?%t26NuCPBVPi?nP7i'cF1-a2dnd0oM+WZMjph9P%r_`j1uNWfZdfoO([V034:&%)c1R$+RolfB_``<'?_alA$j@a38jZ[jKEdX(7B._+E$qRhDU('%kP0=gZjg`WCAeX!Jpq@9m\u/B<:OkDLp#t[^SOc2"H.5O#2-Rkl@H#CYm/]<p_=@^gdDM]%)WiL,+k-:/h#6i>(,@IdPfLq(@ad/W9#s$`(6A=<W$'CMc/bc$?T*JYgj3G$cO!G^^,YdKlDZ@DU0O&"#a+*r'K6HeNmI?quD6Euo$]E1lsW5@7]j:&XV@P29R&>[RGTq=a[IgK9GlKK2B@kVJh+m;.gapVnPDU?B<thnRi%R>TuL(Km&:peoPb5K6c>)IMHRP/G9DqBQYgWDicJg($S&K1O%X8jTXb]A\caB*AXEZo0IZh`c%MUf0\]X)=0CniTP8+IMK*;[tLf2bgV-*A'1P0'oN@)m2Q[^S&@0D;QqRg[Q%O@c661SF(o$VY9-Mq!L_a7%'3Q['MV0ZOSjMQq^eDnGoKTLL<Lk5#(I#b5-.nf3Q#;\"K3FP@g9)nZ`,&b#NXpZL^]a=1/k0r5Af4">,@GC(jD9L)O@#6`%C%Vde]WcVTJDiYPRN1hcDh+1qfFB&.sFR#/n4@9g%>NjpTDG$^)\_N/28YZ9fcGO',jHJEHk]:5JNa-HP[bBp"2,SqmDnr*Tkm!_[:[IsJH6dD0JBZb&<A!:2D7Wq(@>>@U)*q*.<6oH.6\r0sXc%+BS_.d<M4CS`tcrpp#dY6$7*V>14W6f!W]4LPfrcur>QpP.P3cN>N];F@p?s:(d^`g&aTFDW#V%"Kn2&\s*c&smB#-L-$IV2OQ(!fsP0:VB_kt*]01r'/Y=6[O`juclD4h$Li\hCP=dVWfE1$<$'YTK.SO[2&a$dM*Q/t'G1(8cq8#(u+h0?.CYDICXOPeAT%eU/W*$rtURV9Op`\hNAW`a!n6;SJeoK-1pn%Gpe@H\\TPI9F,/Kk\*i9M@<E,TGDPI^*7RH72'_FY\Bc=d1*;@l^?;LP8m#W^glr]lQ:KXa"TdmDWn4MnBL_0)?!hBj:!gp[B0bZ[91@8^!^L;.qe8Zcb[Ei,mUu'n1unF'!;:]m>]XU@cEXA+3.O$\[u0NT4\r<4`V%aREuEYhoX3NI!VT`;*gK^HNhK;@)Z`fQDiKCk@LmhqGOTCd&!Vm^SoMg!.>d`[3M+mD1J]]s/Y"*D_.4/s9>9ENU0""S6Ul&r;RPlQ<c#d/?cu&<*Y3]YdMHN:-%>1NT2^L[c[(E.\YQNUn+@a]p]BV;\\Ud+R:f!9jI~>endstream
|
||||
endobj
|
||||
32 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2052
|
||||
>>
|
||||
stream
|
||||
Gatm<>>s99'Ro4HSB#F^[$'P:^9JA5gO[d>]!Dp[#[9mNZ)uplFT;;gZ=CoU!jn05K\2T<cDP`tF$gMZ7s'70K'9BurGa/TGVichn<m)DF#oj6g`8)b71or:,3CZNLj=i(5h5Fu`65EI/F]L3+:4DO"X/.GPCD_*LSi&&!sljN]/p`K`D/e;_R0G-'l,?daMPkg"N?sCSV28SZd7gToAd1#X,CJ6InJ>52c=t3\2aFD5<f+,k20nPDCrr#@(1BK`%6cCBNtC]S$n#')b[)O;lSnK#%D:E9/iFYd>9Fk-IZO@<'J^KOs#]N8Lt2HLkkbbE$5FTn;T/ucaX7T?u6=>8r*a+K[,l;AiL]MNB^l<GCgXT,<Q_F!A5AkIrn]J87,O5N;&u.mKNR>f@lB:)dS^E!,t-Nf]b*pQcdAWr.G8r)eUH;*B8tL_XV*W67Lr331\c0qaAgJn7[&"o[_'=JsaM#R$AF?dqkbC;s-"Cc/T;'f/\/sI7BR]`7<!"qb$ub:;:,aalTFabH!-)jn*j,bk#fOR.1WUQl`Kpj2$7u"`%10n3#!@ojR7QK)@<2ELatWam",<r%LmCnc_fumaL4Ra2djAIpRB540OIqGpSZ*+!e@FT\1V+\k5t7l*aKtagJ*!AX[AGBH@k4[o/Kc>2@!;GjNMSEWR1F)aV'.'\#ZJ*s=(D\ZR(AO:#!O11d<egMs,$5bg%(43a0F9^*7pC^"s,$]38j$j0csV$Iu<__8O!$pnW]>?^.R5>[YDHWrf1/\V5QT[YUZ<!JJ^MaVUs&<Q6&ZCloo^StZMb,eR(C_prVR7uRQBo]kmBi7BgYc*&QFCjscErer7g=?b/W/P;k1EqbZ-Bg-'*h.&'GfO$\Hr+G-n7n\>J`-k"A9iit8*R;5_tdi(c\JUlEk2kAZYs\P(>c5cNaEUh5J)VnVV5Rg20lm?WXQ<Mma+gL^hKX47)0B'I4g[XKGQD87+0I*6\X_P2>HcVC[m7*&SK<$8/o=:<^pIdJp5gm/L%'h1Ku`J*n>1IHD9:)""$DeIRQKf/<WE&eXrnL/J4a*/jb-oXP\Q\gA-[:#TWfb6o<tu$(fg_(UFpX<6L:"G?]Gj3\-SGLuLdRC8@dlH(be(1,kWWM.O'.#c"$0iY(]Y0OOjV9Jn:uLK#L^WR].&R?^AR!9rq1J7VcK['I5C<GBV;$e@ZA[1>?iCm*b:jQL=K2gB!hRf@OO`r_NZ$Cda`J8l@''4pGYdi2QPmDUs7n-A6Iaq5he_GKueW\hj%>ZBt84g's8\i-ubR<m7DHlkg=/ljuIDY=:4lp]f#RlBJ#f.667freX&;Ise$\p8p(idJ>tLbgZ/J.h+(pPFS-oFKQR'O>5,\eG]GX`W;Gf1mI"dj_rDkbCAQ="f4d4;@5$@%Z/"&>dQh\Qp-H\tetnp<96=K&RLZGNm=(XWsrff%SBld8U$NK'04(a+R"IM,G"cC0[33LX2c+*9.8)Kb2D\([)"Rlil2MCR;T/j"C#Rmiqrl!i82Ppq!N=ICSPERa`)d("/R$9Q&AJ)E=_mqk.Ocn@?Q+g0ua_5Oo6od9ORMchZQF^>BH''`/M+/;qpN"do7O*WY=@%0$iJb?A`94*TupS8ms7(AQHj51qpKZ9s^hH)F;_,L#hgP9kE7/pZ2EhI\4n>)>0GrmlZc/0o"'\CKPa"4.G,NcWD(c*71YfBD.f$fjT)+--ol"pn;IL:^LknRmS`36huJ]^LY-EeTG&^'et<<B%qB:Tr)(dL.B][Zgs#djCHDdLcYn[i[dmrVUOGK;5lXQ(.Ic[BNZ)W9d*FG3CboVUA1o=M&3FlLGm-Me#cPc'4#h;sW#"Kh@\t%;.2-]i>alo7`7QV(WA`poc)GNF(<GF'iYMeXt@)d+PS>51:ql@a[[;l-#*V'ALM9U$c87bA&F\S+FoBSX+K%E`TVdKIp-gJ>)q>HUO@[Skg4?j/>'Mj%,BWIeK'u*;s):emnuWF<^OSe"V>nD)\#(Fk3Fi+1Q[0QAm=tjQJc<31Fe]m^#co>O:4Foj'Y>a!J#oV=MZZ0cg0&SUB=-~>endstream
|
||||
endobj
|
||||
33 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2097
|
||||
>>
|
||||
stream
|
||||
Gatm;gN)%,&:O:Slq;!>N.V`C_2I383(_qGS;WXrjTVM("YE:o]JscC^'>c,;+d'd`ZEGs'Fb,UkFCA7j:pR6LX2\=H9S7a??+tVk"F\gMeRW70BZtTc5;UeSoA.$Me"GrM;7!]Ap%/7iJ^F,@OXn1MMY:Y,Vg_,,fU0YbGT"*'Rs4*NY%HN'69Ct#N.3V@o3P*EfkFcKOeMHGstNc?-hZ>=)U=9RF1+h\t+84;W-QZbFA=oqH'-c7J!G;7QuIbgWM]hGoEUXgL%OdY>!b4HJ*J$mQO4]=13)?8ViU;\9_j*VCnr&-YK\h-!;FKj[kt3$,0>Cn-p_l%La5]LoXL7?iV\%;`p.d/-0@g"`oVeAL_6["*h=`WD^7qnc*<lp61H3,L3#,BFtP.>^^E/ft&t6m199gZ^mS_Z(Y-o6H(s%U>(fq#<>1'q_fFV8X;o8m=u@89u?.YKAJscS*(D=VNIm;AI4T$<'Q]%jNr\aCHaQeq:J(XqFRJ"?-ci)]U@SR&BBPBZcfh"FU==<]t2(Ag$N*.hiB22=L<u7UT[eka4VdRVFJ!mOH24_]H5b/-r+fKAU0"P-/BWu5>%gMo-hqZQk$kA1D]MB15HGio@0ZhR$9=\Rce&Bac(<N&b+5WL'h"a9kQP7Y<i#u.:#_em@(2]m6WI"M\UB^Qqk-_V)MACqK$#k]%$bKL32&5P1GVDkW^"i5dmhpoEMrVSCd&O[S'sYM/98*F?<#)'#UTC0)BnLHsr>L2AOcFD)L/8]$i2-1t,XWm,umVPK>r/4`oRgOMolW^fE]fgf"t=TLpc$>c.'2Ct2!LS-N^42-kt?LgSNQK;n1_H>!mUA)4X08dU2OP_`\s^/s+E[bkj3a0H8u1ron.J>;C-ag47Rc+mb'gWice&8YiWRQR\jpm5KDJWe.VHp_"Yn&c]iV#tuNLW+tdZ\DXo38E&JCOi[nK??!+Y#9de6`\4BE!\d57W/TM8$>YEGnmK`-QFUd50g.Enp8q$iVM3nSLoIRGGo7+?+mOL^Tb*u:QYoaW),>$@hMXA\m=er&ePrPatuhS#Ei8+,WUE1n1Xp2.+A/X$+/Z*N"8mDJ3K.$6WDc#e1,-L2`bZpo7ptQ?O0j!oKnab?K+W4[ao`Zl!m/IYOb1imV;r?TS<''2`?m)'^2"WI>)dS0hWK#`.6<)hi2A:G:EN?B'Yb)hmpnuds:0d0s/hEYcnH^6,ETrM#B3$m!5@1H\s#3bZH?&BL`d<CYLCb,Xoc^$ke7H]c()8;gRe2+_U8^Ws0]U!P?3+NS0TS'F"I,gu;:,6[tb*<2,P'n</"1^OMQAe*G]KZh0cZ?XD[n9S"qMUP;(jR%CHg)$<0Q)mVg,PObibShOF9VnXku;r9r0HDdq;+2YA9CqTBM=rG6f_`Oi5`!L&oY)[6"ZgDqP,AAF;TFYWHbS;b2/%[O'G3g'0$FD0;%>ee?'&j#VM?kZ.6e@]D>'N]``kX^PbeQ1BKYk1/g*P*^\JtDgZk!\!(#4a7#W:u3N)#.r_Rn3H%,_bHf)l&\bQ(!BXEHCRTTu\V7\:?9fEHD:bVs)+>Lj+2AVro+P=J32.bu_%,gt3H^"N'U\3dqg%BOKTens65&3\@;_**VBR.2H^"5E:kYC)#">BqMq"6R4qg2_WZ4Xf0Z5J4&c9Z+5Q;u#uO[<c[09U"LC%W`BLc,A.?%s1N61-*Vnlj_25:H`@n_edr7'">skqJ`>\+Ng&_o!gQ5/D;_$,R#HtL.,HC&4MJWD1;s_UF@%[d339V9a*/dG]CTJ.:P"@qLPN*A4e2D1o:^r41fc&ht"#DJJYa?*@NWcoL&1RGDG27jh*PASIZ(F_Au&-FT)'ljh>'n]#9b#J1;ICeuc#l$`;EP(RWJf:^:;q_L#2Gc.N?hP?A$,JQUsd<Mr@m6PYg<EPi?Mh#.27KTf`heSHc=]Z3`#F$C2%-Ya:>"!tc<EMRS^iO7X=n]p!V(9^2RBg'$;Dj"M7otoN>1?BE+*6EUmZu8/ULWAl>J"TK%VXaA$Vd&13s1g;cqotiQTA"XC)%KpFFV.;UqrS:"Q<q'.C9I'gVG!Fj?q!%_3bYrL."SFd.2@n%6>-<#QF7aE~>endstream
|
||||
endobj
|
||||
34 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1866
|
||||
>>
|
||||
stream
|
||||
Gatm;968iG&AJ$Clqu$K-lrL_oVNm/VO=5T*4[G(?kQA!+UOSJ2q)-k"\;7M/_P"g,?pn``BM"aHn,C8?S&j&-,Ah(r;M]Wj07A?Mb"!:O)V?f]/q6r1)2Sf/>Y[Q+C+^]ctq#X4Ie/mA0_:K,md@X$p5g[MBe?>Y^(AG9(@V`_$#PC>>QRd%H(MMqBBD^/$9_b>`bUe?,GUma45M/mdfYl$Jo+)f@>il*%sK]r3]jZr!h67@Jsq?j<HXd?Ds*e,YAQs^NE[dR]:FJd<:N;DM2P)RlS";4FoGog=Y1[RFT:#(/Zh^@f]!:;*ndj9^mX.R*[u5+sN8n7#kXt</`,J+SJn]R.1[1dJnm@&A]%L0MW60n\Y2'[qXP8&IA]$d(sEk\K%cT>6hV%9N*rn!9:DVRti_p<$Y^<$7>68a\nfP-qPMm_c&j1+jr`%K%VDOT,IKWFYoYIq%uRF;j,.&=]tQBRE^[g#[-c%EU%u4+jgZ9'3JOg\c-YuZ+drh9"BW$=-mj/f9Z_K*;:[M$/PK@_ETI?iG<Oba>(^AVAt6#H9^Q%KIBkH9i['X$M\pF5<;<tEN56mnF6$XS3.\k`H6l#=lKPFBMYEDe+!r"<_sUDdi6o')LU@-k/uDcSVGiUO?;2`h71,d>T4_!``Kak8q=P1KNl%PD"15dTV41olFGHIJ_Wr'd9rqlH`SsQ=P#11TJhf'`RK*[UVVJ!ONuuLpBRDA9r@r8*d2oY6H.*b1)EJpW/:j+?;1KF:XuI@#K(054=3XO5b6)4p^iHuQqq0:&SE5K?m63#WL`sd_i"8u`pT-t79H!.`T,?V16-"&il?h/EZlo1PWpjHr$W![lpnmZGeP-9'1@'q8I&?-CF8Zr98Slg'g?h%Yg=N,@2KC;`c6riF_D?ap=6UB_;&PohIC$4311Br7jLr0%a#Q*$EQ$dfh>2fcZ'A$l&!gCUO7-F%WO\kZ!`s_<4pjn%\NKL^TUW\Lr-S<4T/fs`YCs%Q*%JVB4FL9k9lfh@.E]8odT/EqfJ3jdFb<.'q"s9QdsYd\IL0soGq(W;`cR>@k1hr`\r'Ip-iW?CL*E=3Ot)rJ-7VW12WlrNQo+/"865M,@Zf0Ku8q+>%\W<QmL<_5&Xs_2p'+M([/cV[]O\;[]#neCX_(nO[o,faUU6A@oGk]ZUt-BkV7qd(f/-elc)`!2U%J4;T"SPD/DBqL[h\Qm2S'P"=M6_+Oa'Fg=?sE7>mlEA%q9r^Iu*2U#lQa^XZ><<!n)?2'0n3(UB[/P:u=M.8/$D?DYO>ZrM!Y9X'9m>`[dKU2O]&0eDdL*Wb^3rdR-mfoTkaEmZ`"b/8Md$"r7kh#htBS'D.L,"E?<)O#a<%]#6ZjLF]M:qh9O?Y$a:+#p"nb:3G+Q%MmtjrX$4?@]AQ^m"\9I)#0Y\-Z@DmMBeHkl=lYOAh8jrH>ScFSS&V77th[RFu-8@!%iae)3IH6b$a5,_=-(fa\@u[2%oL[&nfDAD"s[P!(T#YU/'I"MVR3]"PJrI4/UomoKS'93r00/5AZg)>oo,?B:Z0\5%8AhT^?Zg-&AO-`-t:_qph;6tgsaH,/?W==bV2XKM86]eDZi<O/PH)77o-V=-Pq?;VPj]m^7h4SdM5>f(sK$CdKF+.##ekkWAfRsE6Ih4K2u'fNtRA2sKVh!TKL!C"_H$DJ9km-ltEmL'"+MZ%i$8s^LCBZ:9^@7"bC*R]8@"DSVm#-Vlu:2ljJP^kFTIJRkVAMu`qR:fqKIKKdj*8Ki]R0>#o1Uf8l&Hkuq"2QuD1o6ecEM*4:4Q_JaLtif_Nj-@\4ha"F&U]/V@Ia+tj1HeS@;&3/Ri[(.qPSm("0cq.(Ps&GmVqtD!Ik@WSG`][]am"~>endstream
|
||||
endobj
|
||||
35 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2265
|
||||
>>
|
||||
stream
|
||||
Gau0DbAuCH'&Dk(HBY@c94eJ!SXlpYUn!TX72k?,["13(&TUHp+ushh?_>g*F!SpIk:3=KJ/f.!3dnT/JpdJ%pHL<frj/IDIMO1SXg]QB0;S7ESZbiW:H[To//I<'e.4<+T$6s627o%Z&#UW@U+cj$as'V.;+rb<JhR\>6dtbZ#nFobFup;!;O3DinK7gU`Loj<HZ:Ag6)b8TGJ^qee`Fd'qX!D>=7=GuVfTF5]*SOq`Oka!en5fVe+ujD^D[Sk$_BOGaAOZa_W:4]T5;4pFk'VUDnAJ]2aJ^4-F\(AAO:<O[5An/j[Ksm=^Dl_BcN5$`F7cHpe7n'UJLg@)+!K^,"eTG)/tiP#p+oUQZ-P!3HNP3nKMV^=55$rQUDE$XT'BRBlASWYTu?G='GGUl;o'29;^ur;ctLKqb1XhArIlB&fHB@NS;XnarDCK07Bp^9EQpXK(Mk[-T6+q(P7)@NlL6k3LGpsWL(Ui<aL4qAdb:r\YucJdV%C3$hVS;4F0fTDm.dU^ra2!([JQ60@)8s0+O6AZ5R0:q6LtgIDVR_:Ib.d1k[hS`*qEN011Og$KK?'dk&I,6.\o'+/dfOqu^.ik;*G:8-r(A,*B)e/eOXsE(ZGmiM4_28#m.HD/;iaZdk$;W+eg\pNtBRDVsq`ao>%;gSRmL!VS"C$e;5YD+[6UFL'Db[aVbABQDC-htGK"jhBs&lWViM+Ik4`dUuPthLS4C\_=N>5Ys;W%O[53Y<;YUYo^>.b,Llb.^oj$L-DNB055lY:hWY?.n&ee`>>r)%Ia#S]VN]YmEu2&iBXHo%A6sGqC^'q/SrmckpG!kQ+R32eHN=2Be0s9C(2,_.]o'HoI84">Nj@3)bUMSW\>BGA+B[37$+(SkFiV<OV_PJ`h?aW8:1[?OCI-kgi.c6h6#P%ME5laBSako82KoI[qK/QL>-e^VgR$E#VScuPh4!?fTQAYN`AW\4//B0qX5r7:;53-k)gK6'22lnZ:JRmVO+,f+Ms8iE9JS=2(j2=oA.5=HhCh1.Patad:=4Br,9c#:lA!>?p)u93c)`56/nX;[VF(T%L+%fN@KA[#L),j<-c[7/SV7LPH#H0KkWEp\o"Q!4M%aYHV,2oE6rr^JHK9`?CHDa,:Bp;m+/PGZ'?duDd(,Q_LeDt/Noen^U<sA!=0Lf0NX6J=!IdD<JMt,=4c/JWkhR"hBakAN;_"M6m@*W4K]C]4%jEJCi)iU9+tA?^4am@nb;4ukUco>IadFo$-i#A1nWjJ#j"%bl="_W)e+<NE;rV6qEfT1ij_2IY!0L:l$fg@*2\tX9\fAi-:`Vll6;pY@"3hA#ame#8rs1:FGV>M`!kSS-1gA^F?)VIH/W0I*]W/YRW'Z[L;!4..?crDA<b#(k[8SB!tcrsVF:k)8'K%(G;*h*T'kMVg^X<4bC%sn[ULFtF&aV8DsXg/59nEn%dJ\a5+7erqO)C:I&bmfPj@V0;&]ZWeqKY.pBY4;_/:nGNqFh"#jF*mZ$8f8A76b\Gl0<rE`.<nAfbYH!'A(=.PjZHNPu>=Ws_n%3@qa0#Cnj^gGm>Ke/[".(MX5TI1V=9Y+[=s=A5Qk>araAa$di>?TK*DVDA%]p`PkUQl^5YTp\+Ra$J$*iNrd&X*e8&oJNQ%fRR:c.<`6RCXmO(\-'ZFq[VUALS.[b]C[ct(7LM/:KD[7Ca@B6kN\E_"@;h2;7PK7\j6tTXl%UeAJW$g;C(38KsSlCn,kV^Xs?'A\<utaA_BH+:1;ct1Jf,CS\s&_Df/SS5N2u_cc)'^Fa<P^ln7j=.Hnl_qToP9M51R>-Yo.ZUM7XG>LTbIY#.qgYQ>`:WTk!f4B8s;oTm<[XbCRJpeH'pMrP#*)j)8#G&u#_,=L7j&[1P#r;&-jQpcZp6Z&:.%"a$N#+B%,0@JoQ'/4,"',rW69mXAg6?VE8)R3.<gmP/H>/-'U.>pCT`b^dDF#\D\B_:d0JSnM,XtG3KF7nt`q`4=NkidaU?L_!J&Z9(m];&&\T/QY?71Y^1Q9!E8$uc[%A)OM@p)9j/>&a-`ROk+/O8:*o>]8?2?`=u>XdX=s(o+rthl%=^+T-,#9HsqRj'd.FgCIoDD]8V.&H;eS(Ff%(R)A1-)0bmi%%6M90M>Ms-W')OmX\H_2[#Y@b_2!f#CJ9I@ksXOm*M-hpID_=l,c`8TM*QqIPuCg0:;V/?elt'lfi!(o+Yf5rdEE7j^OgSEe#%cjmf<)D/RDC</E*<bi?f:]gmB7rEB^YqN2HTjDPhQP%"~>endstream
|
||||
endobj
|
||||
36 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2121
|
||||
>>
|
||||
stream
|
||||
Gau0Da`?/p&A@B[qC&sdL&eaKVJ(ldP,1]Zq_AtT`/Ke>"O0AYjm2L<c:Ap,2QP&dW\b\*fQb*Bf*sM+n]0GbX<dXj6,/sF@[n2a@Y#?BidsoAHXHM0k-HmRjKZW>.4:1Sp'*D?]a>8t9R'tg"GLRRnMlJ`-_:U<!%SbchX83.rik*n_'uWNUgCF]!-d*#9,MqOb#:b9(S7t;-u&_Xib3=RG'.kO6mnmSHaFK=j5TY%_rBn>%5%RV`S6@qlbVoL7@JI8*5oJO:JB?+Wp1"`[Tt,iV$PaXB82_Ba)l-tQ-NBpBj$Dgo$T3a,!fNR"gK0.1WBUQ`l*?8L'To6KlOU/#"$:2N!COG9*@%s#j)>@m"N`;+,#Dh0+L?S=T"djdk4'3$o-'M5]`QQ_pufYOKLXp<!E?\TC0qUNGV9VbekR"/\8u5V61ec06n%_-c1dp89Wt%D,[q@BVW==8)*2N^2YA-'/,2==]tR\1)k)qPKTDK`dNc)]0u,bM+<n*_8XO:MlU&C\Y@jQ[u8_/B$O1j(JE_FWuXS]@gWjpJ&ISu2lugrD[XPm2u/JrIQ2Xp^<(Rj6'Q[eOs]s%d-[n-&^-=8+3+<aT3:*"p79P!2\LCC+S1A1e9<<MSOq<t4Of"XbJ*34]5(QMiodI7U"FqYVhlMBe'Fr=!]FFR<P!R^fOB1406uJ3!<5OcB76&+E92s<P=q'<(UB5Te!/:UF4E7=;.8%eU;#&+Ga"U!_LIEjFV"1_%jS1'\1]pl^Z-rtAC@W\ASl._gOlW_^'+R-MM*>e;luKd[pVrFWg;4qXSP88,#k%d1'>gLn#Xc7XaBtZ[rJ9LeIH]mXBm;)+-HVM=_Lo7jVB@1mnIf*nKfuffg2oN_h1GA13XK8p0F$U!.4$t<7@5#jO[_e=uEU]*57*1,KYd@<Zs)kRmu3qlJjg':k`P&<D"*QJCJlYZHgorejl]Na\[0)&aV6gY87Dacf1<E>lNY:WHZ-V*725NlTub^X1gqTXkG6cWp;uG^us%'ms&&3%jgAR5,kEj0l*"@MjldColJ&[1,n2W5;+"p7]c[T%da!]<=#KSe&EN9j19=9SEmUAXbKJ\!oRa0^m@+.$hXsVEj=@sD2YR*JjcV\14W8@WA0s6'&$`2UdoinQIol;A=4Ts#.aqDJkdHXL0c"QErY.sqIKD6!_AH",)oJ.o&U$=HWVt/&)cXPCIjM]f7^41d4U;jEmKB)jIU]L9nGViC&>,f-X!,O).[&cg/PANgefQi+f_qd,T4HuJi`*WB`F',Tp1GiRP+hTN1?'q;WV*AOl>O>WlS=)g_Ec:XC\7.Pmt,ag5:diZ3TJj26KqhPIlFL]%E\2Yem]V!W/d.A0Bl/W+OF"4'((TZI9O/CFf?P(IMhc$e!&?C$G@c%?#)@RXggNNHnK/TQFW6Qt[r0R#bO"QHq\77qqr_As*'GEQ37n(hSU3F</uY8ktA#/d496HmcNAD.s'uG]'/jWr1;Dc=skj$k(JVp#S@JmLG'1O[Q-PEg=<Mdj]#W_V&EKX%X7t6;V>fE`<lCS&./83^Er19\K5qgX0m?g=_nf*Y?n4Slp`9J`KoK(3tNTY@O\i:C8M2]pM0[?^#\orkD_mT=g*"a2(2Q=E_UE+</oO*_oN8*pdA5("!g=Mf;pA$@HY)5Sh`)a.BFa2q^EQQ;d=UB&)]%_o.O=Ehj9Pp68`2h+9[qUBb:(XtnabC[D>]B2eFc$p(B(+K[/"!lD+;\lK<lf_.HolT#JG&XoHrcZF=X[tg>:bq]$;dT,92hD!,E7jqB;Aj)\?gn,PT-Q?unSP`kQ4XA=I^=0Og,15';,S.fU[)U%I\a_:+?^a[t>C-VsUaXi1e5>2]Im=&*#r<0Ok/MR,*mp,.I[:5]I=(=r=VenI/c-:d^`h?*.et?4B>NARs*0]1^l]bjh4X5(e04$_Zn5H/rKd$L1'+A14?lNXX0S-?p3.S(/"=?u>rTj@p8n@dJpM+5;1MU;PdAaN<50cI5)rfqkbRN6XgJi6XDi9fiO1E'g^,t_c:JP+rkPeaNWr>0613NjeYWG4F2+c\a(O!\HEFD%R8;^Q3!"@!/d'\TYb]fMX]h_5?%0)&eNGh?_uA[43)=!THiF-1r3\r~>endstream
|
||||
endobj
|
||||
37 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1330
|
||||
>>
|
||||
stream
|
||||
Gatm:9p;>1'YO<E]VB\od3A*D2ojrMV.q@1b`J?Bd=BFh_2+Al43=\PPJC\LmUeIUXtjBMlL*9-T.,$[U$b!,!W8>$LH>j-6$<ALO8uS5fOq6\.mcCf7MH,&4?mCB=DE<c?*7pi3MAVC"5ekf%1.s1#ZsFk+:@TE!QXpb>SKEt[+g0u@Wt@71i.]WdK)uXOXOciKnT9?XcT)]rUG?+D=;q%I+Z='*hKeFhe.3)Yl?$(hS4^0:WVB'$X!\k?J\HkP/]dM?2dmdh+?+uK"YkUD\%:f9:7:i0G_Q$eONbu3g[Jtm7#GW8g5?J)?mA^iA&=cHI9[CMW"fYUL>,P@\?\b`qVl2[6uaE2_bR80aSkQs,i'l&r.X%=T!XSD%;$Q/"jiH5r4RUrRpX/JZN(LCP;bMT5<&F`hfJ[bebKV$bC,Y;J-5q,[\Ds#pn*!aV9ciQM*R548jn']"AlbJO^\+,`0lOq$Cu#QGu@!EN#q4o;-)NTG>qo$beUc&iZ\-%,4sn*7]jl71'&bQpI<5BkYI9(:oZAX@B?fE)c\Pa)=*#JJssJ$21Qt&!f]"j/5?aY]]6b:@eN?<0KsTF(lVCriC4X96#R$3ns$[crdBW"^O\E2Xo.cGV'A&H8gHW>FNC'-uj=qP^eh?_2nLp(]f?OASnVEMg-8-h9WPp;"'#ucl#s!`RWPFBiV/.;0"oj[:iNprR,Z0TiHe5LLBEfYVunZW6okG=fo)2ha\Y4Mn;&@1Fj$?H.<dX7SjF4Xa)/&QM1hq,7tE2Kddeg<pDK40pm!,]01qgi]Xk33Nor4el@tm"i`-#gRQ,q'R4@s1KRb<CLcq<<@=i\1XKQYjCg&%a5p0c&rbJm1AL\JN4qIEkrY;XH.hN*gPDYVV9M+Q_tq4?.Cr,;GQW/qk&i:%p4L.PnuQ,p`\XnH9W!oc-0Z*V$[PL5;UF'*@YA.jEU78/@=9No(N/U_F2Y!qFH=V3C>u(Z;/C!`/,9^A"\QFeAU1%*XU/SO/qnol)hjW+F!9eYIBPQZ+rARW^_8c:WA(ER(N).@Te1P,7rVbVIN[7f[\g*/X"$"JRA!k&O-Eio-gY2j''"3l=3eIq&@4ZGPtH1Zd:hr9TeJ8Vqb(Gu8W;V'H`%G(,!(jI&dRBOb7)a>C!cU[rlaCSg8?mF8,8%5PB5RR7*_1L6VLb<frulM.mER>0c.9QF#,0ZJok`):G$KH1M)*>D?WdXf1%^GLq9fB@[j2n)rPCRK6)JGgj!PCO.tS;6Zh[T@JYuGLK,-h,"L-'VF3U)K73_MWYr;_n&IpqnOk"?nJ\Y\\G8p\c%$Vgs)!M\mY1hFrWO5U8oF~>endstream
|
||||
endobj
|
||||
38 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2364
|
||||
>>
|
||||
stream
|
||||
Gau0D9lo&K'#"0DoOY&8W<>l`S%3eX-^uP4nN*,lM_?ajbEsR^)C1N8q=XV'Ua4*C@ni4d:2Ef0dBC/h^MZkGJ04$*QOV?W<qO^^P%7+a>EZ7IBlu`)X-<Do+TklCP(_&3W!GtJ"b/dTIF@-6>XfooUPP'W&Rh!$']pTsdhjSlh7bkbVYIuBD^)!I/JKD"%H(/F@nBR\(1A@8%jU%W=>b2HH#^8RHh[@$iu,NnV_;]7;&&$Lj8#V'fu60ho`qU*bT!el:N%<i\L=aCgY`"4/]U>iK;J0gE,)nC'"KosV<]hZk`'Ft9r6UmW-VlF'M4ak&W%]kZQR6E$l<ptP@,2c-l.A-Z=FFL67$2t#?$R.JsgM/3TJFRi]['&G[r5.,f'ZEs'<893f5.T;?RZs0F3m,lF5W5Ba=$/$NT\B%!LmT-DaQT-?2<jFLu&uSdp=m+2\D+.:a^qI5p)bPhu>TdXONXc%B^iqiN28aAYp9CjsU7L"1^$g)+$20Y#JBqD9T'm5*kaLGr;,0@%nH5(34uILjTq_8NkiG$o@eL,H`;Cf#puc?+fZ_o8\NF/Bn6dmE,@OlN=902Lu-B&R5!4cbbr?$[6A+:J;XJ%h&hfIL<khFe!LaFj98?alo]1[8^4W*AR+7?saiPg7i`\^n-'C+"$UX_DUMF1)uQp0O(q>N%[.N"u++3O@ea`3Gl7Sp&D0qOOXcZ%I23]=K(YDt%-mD/A#H(L_)O2*\djbjc1qR7IHPOMO,`:Fii+X'tXLe5Vi?m<k=!CO<:oZb7fID:MUT0Ke"1p4RplD+j3!HI0?uoCdW4]L:A_hk\qpNC=JQ]TUC<mW;[t`Wo&Fs65)ee#>2u.Ao^:nB2\>-l1@tEDZU,Na3R>TJNTVgDmn2e+\g%-KkM[CGrh<pU!r<kZ#gJ&[0=eP>d@sU[nsEnN7sn<(Bo`"VYLY+:ESE`6alZ]AW\LQQ*Ur/Ih!Beu87^TX$K3NhjcJdnJ3W9q(\0oLFcicZ?fiNMRh`kQSJRWUsEEF@Z@Z61)cILuT^9U6)I,&Af\D@MEgEa,u<>Jr!.12IGET'#VM']T^'d2V`oHf!B?6)p.%,WC2tH1(eAHoeI,:=Mh>[Vs-eu+/k+EdnVRH?bN=dO#LE,44%-05astc4]UF#5oo1'['eWN;J"Ek!$NihJ;#aM;6A\cK:9,gCCQIE#"R<=\0@;l\f+6'^p6Ek(q$S+pA!R.#pBGCDb09erH(Q5T!7JGJY75P00Sj7d=!ea^/ro_IJ/15?llK`JuPWjRW3==IugMBir:)b'#kdBq6q-K96rK,kkV$K->5Y)Xr\A"DF#7Z(Yh7(m(UqU^cLm:JaY6BBBhR=X6,gXcou\*;\!k9Vg1H['q"Q>>pmHPWi(Mu+&s.RYdRQk)h]WV/<>5Mhl)E(]R[j=GaRTD(!pD(?q5AHpWmj)Dio*sl<-p#m-m,B<4^]K*GkA>4qEIIC@JSc:(M);4f2l6LmEqoP53<*d#X'!&:.;ki*dYjD>dK3YOpK=6]dX(9j3T`D?'J.^r?O:RQPnZ%rn%S(Ei8lfH?TGEIsRAJ7(fKoI0:PQ4J!`,cC+?Z"$7>lXd)u9CsiS*OT2-rW(mH'%au[L+CTial[$\^pVC2CumF-4<%%oo)\<&5NgHBLBh&&_a1.05StO]iJ1H6VWmB*rk;uD*]&%k2QWhRdbZt'mi+1nVI>&RlGHnf[64M9E,=_Smk<R]l>#>cY,p7GYoYmb(oEZ3*=-&8I<.YiU\raE0-(aAS7ao):Bn2:1^623lZq@XiuhfHenU,Te$Z#2mk,-$SGj5>@['ioq.o+C[%V&&K*.u*Xm_4NIF[6.D[.CIH:`PE0b.)^qHc19DOQ1+"ENeB0p#E$PJ=)G;WH\F&-!,)*T\IRU\%+]Mf9Bb/GSH:VBFQh+4L;uLA&?sFc:9h7a2Hmki!aQA_K4_S=NYhl8LkhT]sb1f$.Ig]hj7:?8N@BQtlN;OEZ4"fs)QCkOe__SMCfB8V]HCd$AOf<EJEVkpAQj<d>>R>DX=>0NEeJZ%6h"N2YQ8YF.q5F'G]gWLY0u*cJS3d**O0@+ZG,IgeM%2L)DQ^1$M.bi.Wp%oG>0)a0,Wf[2;GIK3]"CqO,p::m]ADU:@#DGq'K`d(\:1\FTS36'Ck#e=p[+N:Q&j\LrY1.sO_s(jj7q-'I7%o<q-/3k4#\pMdX!dEB:M#6m&e^&.V"s/aiR@U],J2]MEhCDQlfllkO<3)/=I/r?gRbY1E!2/o(5X-AtCUZ]aYhF)9[U2f/`^fg?=lak<"f(/qCWDVBVl,lldp",X:;Zi]h(eW+<it1FHbc,gj29qMD_T+Tja&mn0#VND25eSXUqNV88GpT`lN*RS+7_!$n,~>endstream
|
||||
endobj
|
||||
39 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1849
|
||||
>>
|
||||
stream
|
||||
Gau0C9lncS&A@7.o[-L"<hjSQ$RAPJ,nL\BVd.6ifF1:hB-_%er;(h)[UaYN(:3=k;itmUAmW6$r?]P*8%ARf:%W$mJ%QF?!h(c66s`:Q$TaoPgmr],(O74G':Bs)!I6uV5EK0A1jD"oTbg4-89#\t5oM*bA6p*!&QU;`^-'6/pgCKk,+sljaLu,2!.GYe/90YqH&rEVK$\R#b\NYQ?(B29hgN)M4JfB*\iIG5_Z2W^k9&lb(GBJ$NTMn')mKQBD]e,0Q`!]l^\sQLqH>bIU%>_!ZF^QUZ-n?"gE9&OX_F*4<)'&t2*&k'j!78ao("%ur<1^d2e(^`hiSL?ShK([hN@o>KLkW"_H_]:A0c5S5P8Ju_8">A!\]OCgbA^\b-ncf'H.UU^B#AM6+jc*ZSePJ:>*Ve!:81*R@5k?1e`N]b%X,MF&q_ii-X-e3'elR&W1BHN]S"+ip5[$cOeOo@M3ME8<_9@&kK(%=_@c.LkJ(4I\)Z<Kd'4./Jp5-2<JrsRh-("oCVBA>"NdHj]_>JCoLBU\hLRW/(M$!!?A3!>^\9[4]FYd'hr[B%Yq;a1n-:,]_IEd+d-Jeo]U$k+@N"VUY8OQlFtZ"Zt/B,<lS$OW)YuHFphH$27-s?pekcR;B:9E3&]28WsZNC8n]^1kR(:tWeJNW=LD+$h,7gQ'hF(TNNHc)>*9W$N#=O/0^Kl#pCP=XgdQp!NgXlb29DC-RdG1FgiI"q"t'9eF-8\I.M.r#ZE+OhC,n'<aDN!?[]bdYP0drb#gC*U6P2,uT.c"qEPYMHmd8):9:(1/f@DKqgE'%)3!FC\@QE9;?#O#]=gaQ4$bbgp2MKQpc^PD)q00*$@q0otS7A(o5"j"$5Utu>oi:!U$hZgib6lAg!/PWDHo`4LN"."M6*4=.l'o]FPfr=lSE[Ai]`:#bI16sc7C[i)YqW2#fg+BdH@8&1NK_jZIEnIDVDo/o*2$n-\H'moYi+pc'mCI3aV\n-Ri_>WgtiLn(62e#W*=(:*fq)1/=qCEk8IXIIj*Pel4CQ"Za\@dK+P(g]r9:7[BI&TL-91V2#p7r8A&_@E=';UEok]&\lZ)V+qhgtQQ)o,;t`G_KsFqrXL7`b6`KPj(WsSZ)'0urR23`MQ*pJ3f^Gqd!%UqGgoS`M6T7S*V0U;,.0=o$n3q9K#-J'n6.P9.?e17!%ddrqd2P2f*Q$(s-W6GgM@E&ZTt>bQo5&9o;H]s2j3nM^6A3ZZ`91J,fX.BU7sI_F[6cO_V/MP'@0;DG%kr`b/q.2_P@;`1AR?;cRF?0p>01]'e%dI`Z+d'c/<H=?YaA-D_dD<pbUTXW!Rud:qV9SJiUg)!9H:C87#_Ok?T]>hi!(2=H:QZ+nJVT^fPOWdR^)JSkXISkOG8^YCPsN%UA)TQPQ#H$<G&jlNa8Ja>)/2m/=FI[pgb!GK7$qnJ!8e0fef(Akh:-JG*BP6)r5ZE3?N'r"ngKWK/D55op"M1@7=XXCN,;))4/-0(Pi'Ys2%USe^95UPjR(VOg]AVrXS%)5ktbpaq]<QE6lkV(=#7P>6ButoKmI9>3rOYWk`;Bc_h,WV`>8uN1eaU23j?5dERP-Fe0oIJhR<n1>=,m+_c<&aeMXY"ajFVk(*M$F4%Idd:EAf1M!LXmGU=C8\B^$,j2h?G':\nbGp&2$6BDC"/H\$/2k*'8DT4T(X>*UA?;f,)ZnZ`0_37UU#m*G0N&ql2)mMD1[\DC@bL3]C8uCII@(qnbimPUWU>iJn;e%SUUf9K8?#faRo/(<2eXJ,c[@Bdqg8:9aGu8Q6IcIhS3t+p=M:.u?ahian<tud?_+l^(Q&P^mnLbCY&@\F"#leK%DX4U)u~>endstream
|
||||
endobj
|
||||
40 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2285
|
||||
>>
|
||||
stream
|
||||
Gau0D968iI%)1n+i95=s<YY3hHIF_":(jIaG7QdLYS-?-,DhF%bt`ceK%)ljgc4Vj9G13qL_%9+&0qDeJ#N/^V#YuriPR#Me;!''W'_P,hS@\4m-0I*MhW8V63E."2@qt"0-K/BAb^71)0$\:^^XRYqlU0I5a^Rl3((LXr>@5F5<.DrYT_@36K0j0n1AL]\/t0,4/<G?N0k?G.Eq3uk0*Ru`OFS^I(Y&dq2-mG+)\:MQLgSiYl"OFpWZ%YK'DB"(m**gr:0>"<E,e=.hu;(A^8I6F6Q.`$<FV&_kkN,1)="<Gr!`86IuT-"sig\ht$9ONt8VjM:F\?3Q.Ypml&HV=&J8C#G1W83n*Dbg#2U]CFhI'*blAGFn%cXs%DFlME+?2,utK/[VWF%qQj!@ZVokP63(\k/Ci++2PeR+>pk:@>p$/pd(ppB/fZsGKJFI)i>Ate)>+)+?]dnHLS!>SiO]*s4JZ#B*p2-@Ws'hd)OkSXE41Vq$o,WdioAS66F5b@l?^EcG56CaaHX["eai=h80#gn?a^g1g5jGaLt*^Xi#1^*3GTcM[gB[-/3nQs>rQ1f_=9i)"CZh5"<OGkpO[@o5./8CEo?'ZQ^0_?2B<ZC9W,T_SImMo4;VST0O$fB@Z`'p"_;)K@NLFQEZ%FX&"Gl"CA+=OjYKCfncCd5M#=00`9J@2.YW\V4Q`lnKh,d?&t":gA-L"b>N]-m+)sWfp-PN8<LG8s090T8CI51h`"*3;4mGJF4QjKnM5)%)7**D>esV<A>XUoEgn5JlF28NG'l7s!Q:2rseu,5C1!=P+KU7(gns#?q8([?<_Erp8hVUPKq;$_%hdjhM@T(5Xdk\kk<M_1G>l,$!':u14&ts63HuQm+g_#'h("gM?E87;t?Q^@re6P4mL>cO#bM$uY\;jG,g\963jcI/a@E0:6KS0+GbIF6!Kal5mDC]K#'VKN\g,Hq*AWS?X.-gZJoEs(oe.5hlP:":d7>IUs`5Pig)jm9)bT$10MdZ)YX]gjjA]N\dO`3fB7D5!/phr"@V$mH(l[=j`.8iOMCJ8H+TL8#MF*D"GA0[tiS%S1Kp4o,jgVC=a;6CPJo1Wbc:B,ReY5ZclT'tEPEWa;OLkWUoDlR;cpdhuk)##pQ4L.Gc-$k)MG)[6D)K3;&r1]d,S3FUklmf[2Oi66.i'XPc[M1aZM@*"XbTsVQ.%Q%?na8eak%=Y?q7LQp($?Sk^(FMllWX</GtAD1^*]Or>bjpBUS'>W+#Ig'9!Q7U*d9YXfJ2J;Y"HBSk-BKOaYg_/:)ED[WMe_"Ve.$Be>Je>,9sQsdJ]ZMo`.SJOfmTfMN3f^'o^J09Vp2@kdbD1V]\6o.L!m]VV$L=C-YO13jQ'>@7MO*k%'s;;YBUgb]nl59<QRF;i5h:&!3Q]mV2SXO$lk;TR&7XWFF]='n?aCnotF<a4Ctj64RR3G<6f`9^I?Y+*^g,TN2'lOo"h#pbKZHj'm;o^F-:1,^]5(ghYDZk`+..,q7q6;&FtPkL4G78<'Or'6*fV6JWHKhK/%e[_+$,?J=!'[hEI:n\!AaM=Jbg=)Og2oGV22#(=C)I9u,6+f!GKIHuj!9#'No>3V(#Vht!d@W,6Vg8WbVI9j<UVr779-8eNa,@dt8?9GVp0fL*UZk[Vk570NG(&S'%60igu#Mdi78sr+><P5%d3/J0ClDr-<Z,N4FLK^X:J#@iQ<EJqYs+8!McA3Q%%,!lI4FandBFSa*hlb:]"4,f>1$oK\HDp9`\uUcj@#\JsK8G40'+=&Wk<PhuS]D\Jf>CXBLWY/N8$0QE.<8U&h8*6P]0VL1i]G%I/ainWg^a:O3*P/JH8uB"3gs7/%9iSZWJ^sC_NOtl-8-:)^&`!rr7?j])r7Z7oM-*A^kQa%;mai>4%#S'mC1-R]E+UT;EUZlXjl.7onW-Q>/P/FI305$'j^im)3^PL;LuYnCbY2!7Ra1]Ig2sfm9[K,D`,Ku>EPE0+V)Dj+"0Ma\L\rtMG#']:MhR^ksD3Jdbu&[2U0nc"<*jpMF`9.bJNk)W@d7=X3+lhF$,q5du!)%Q>_$I$8_&<e7O*1GSVh%-.G[i(#k(G#5:9I\[J6Jrh;CmSaSt0QguNU2Ic9?BN)p!r[>39RQkn945Ul.XlX?$nSJ5T1>U_LMI"E">T.gZCOY8D<$du^'tWTthmm6+[7(kp)qd7_9mlX,dqana:S3bH<KuX#Qp4TVOSsn#C3DVP`%3G3mY81K&p`;g%?e5OVb5I,N82rbbn3`_p8IU(D:CKo05Z?JIfY+S5um~>endstream
|
||||
endobj
|
||||
41 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2319
|
||||
>>
|
||||
stream
|
||||
Gb!;dbAu>q']&X:m\Vd8CU4/ZVft`YVs2inA#o%;)jl4fB)%HRde37uHN93AV-$@=;;CDLClnjVHZ1(i*f,r:&$u;"SET@)IQhD_3)/SS4NF5]"\kjaO%0\$=WB,9=QG@,PJ^o%JFqj"^rm<'Npd9?IdB-YoE4*8,J:h0$q9b0O0C#rQu,Q:MN!8A#s&05auV)&hukcq)+@diG(jX*a&a(Pl0/'*p#V"L'=o(;$,LV$3VdMQ\p-TkcSYU*>KH/=p[TMgS2g/Xc/luWdc@F,9:AhfQM$g<fcj-_]&PoKG@`L)B<"L#R13smYocAb+=@Z3cjfIbie$heES&JIT&VRjoK-^$<K)WV+Y^W`1P:rK48bf4gE#X#&*$FKqm\?TDj@KnKI9B$Nn]0J0)+,$hlDpgU/niR!<E!ilZ0Lp5,9C`^)'_/DB5h2VM"2&?c(b6F-p@_-6-feH.Mo/dO-;<s'idUI>!26;1R.go)3hlYj.D0`fL5A?%\0l:`hLUd,\eb4dt46`nl3LflB9fRG?_p7rg_`ge<1/rHN>Z_;rq_%>6BEjP4b,(aJkjF+-:;_)Zt2!$Un64267%,sY43&3E`'8%?\)B%QlF+GT2<2i_.([_K;"i3Ci<a9Cl3()e8;!B^s6`9L,Zr^\hFWT'o$T$fO39kI>'(U?U3P-^<N:61C9F\-XWWJ]1Zs(-=>M4@.PE_/ij;gJCGe`g"hk?(HZ"d4m/W>+go??D08k4g)1@**r?c"b<\/-e.622S>pP"@!C=ZJ==3n%@==6d&q[>%\&]Mc+t2-05`kmU@(HHE!a`QW5:/o)SuFZ+1YVo?<u\]dX-r7k7/Xs>U(<Zn!_>WQ5Q4X^C%7?o&T_[3#Pe(,`SajEn/4EBiY\rs\Pn.h.ij2YWc#CL.f(2^4$I`JXi\7nOq*ue[DD69?P#nKY-Y9YI]$>h)WU*6to4V@.+s"B\P]Y-WkDInQ1,.=2)mnQMBKMEM4!\C_!P.+Eu[WCuc&KIr0\M/YAA[77<HNMTa45HF5ZMnPG7GAt[XS.jlM<R''k3cHKKn!Nt(P<`[@;VoW?+=3L2ckN`=W#R]>1EtVOZ-F6ShB,&AMe=%?=b99KV:/tJ4P2.Nq%K.&X*!q8S_<b>%VtsFp?#1p7fGJ*\C4?Z=PP*c8&>c"_B0uf;_:KT2J8G1mb2p_&j2nY"$Ur?@^fm*5Hc+`6'b\=D'(H'M*es+FGpa<?t:>'&"pbh:2#&T<]9*lQ-PnPqJ!+1!H`JeoS,FlRoDmDjWF?DJe&D>eIfO@23$h0QcJl+cAabnDCpq=@$Pi!Th"\L?V&sZDl%&%+T#/2V(so1X^>D8QkFV7/qe&F7K6E9\*&f(D1_]eEO7k(L#*_M9]3=kcK!^=ANU5Ag[Ek[)i`I4sBJQ3SPKuI_a*OX(q8lPrtGRDW8rH6Zu9sX)+lQ7qf"$&07uQcF$5k>2GsXh.N9gnhea*VPGim@W=l*6l.KlW2,-AT3r+#+a];IXQc&-mZIL+^X#_bi*RSN15aI2,7q-ET"YeDNdsC$C`Gi4KCsV.DN,"06_:bAK\P#gG;k8d],H:R)iW^X*roj_#&r0)GT-q$2#t@!T6i):\?X-raoIoAh&cCC6@?J/NGZ0EO57Xj_3>`Og?sVE@0@hck9AP-N#5N25N/)b&SKo]%`d_"&_N\9e0;pH9SDE?(In>7#buB#=5$:(FVB4:8HlLE?VM\hL,>XnV3"A5f24>Js$@mJ#Acqp.ZkU)EG7I2B&o%:q:HoJW88RTNj!p-F;gduqpG0q4JB7E%sMqC_^;52C*8q!X_Vjm5GZ:<bNe'#P^6s*<T116X:_->oE)r4?>'FZNic?HQ,OrL)pn%T>/ESuh5,,]b\3dQ[POAK]jef)*h'ak-WEa@k2m6OPZA7=$\&Tm"1+e'MC(B6N4pK4&#oT3<G#"IJH/dlI*obKEs=MYL"'F<&5aSC]I\1g!0!-8ebK'm<8bR:C;;$+"-IoXHdKiMWGBt,B+Qt)/S.qU.T]O8[D_#?dWM\k':CMsp@jE`*(c+4iF.oDV4M*;q.o63an&CGeZ-3hjGYeRl$">KlF.o1QIeD[TI^GH+nMB%m4U>(81lBBHd*BR:':3Gpe%"E'&GUSWBLli&:`,uU*-HX)I4Ja14?U!li6I\q>H,ajM5[FK(4RZ1UemBpV5=94%.Ln,Ap]C!u(PhibIH_NS'6Ze;2=]:)*N9,pJ9EnS_<]/#goBREpi3J5#YsP2O+,LZ5r0_eWi[42'+C9%m5,GL;'b4LV?u@mb$J+::%S]c[ZogtWL76#MR>WXsP."]l4@cG4>-[7a#>!usC%.f~>endstream
|
||||
endobj
|
||||
42 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1722
|
||||
>>
|
||||
stream
|
||||
Gb!;c9lo&I&A@C2m*SB=Q2n[J;f*i@:7OA%g640D$-C8M"N(bJdpLu;O:k.*eg),-AidA3JkKP0/p?tP132p<9K5rP"u_hCrE/2HsY)j4!-2XZ).k$\u8D#oP`SG.iQm_\7*DlJ82QQXf/<Ja[ju,_<k.+=&!iSfdfR6IPSh!h80S:KnX*@cJVO_SH9@Z2mT03aol=7PXWW]>LM=Z^Z0>c0EQG+0Saf\Y`0'>L2?>\6cr-XPUX`c"GbC*&]Pa4MSRr`(gs@d<tgaST]]<>>=F\X[RU&"`1)79t-W_FJ6m[b%-niO\^?b8J&n87ns]nOW3;0KF>67M*!'%o,X/$;(s6tWF@Eq(IF<Y,VqP.F2/kXJW8SYH5Lk#+M`]Is0o)!>L3%#0urZIF-d.1eR+:_<OT1c2#na_e`picq]kbX*r$'/d>`pHfQ(r%pff^?Ae+E74GRG&ZJPPudg$T8rehAs$Ua&$8_dXG!+C&QGt\?^X`&Y:6oHPu;jk[%JKZd5(om28=nQFsh2YlN&at-B!_:>?FI928E<e<m'"e-%s6C]R_P2i=<@q'TTiWa=R3\;\W+=M;k+!o`.TE*X#fWZ6<+8-nXgb<EPV.Z)W"`4mU(pm<(Li]a&!&FAn`_`WPQ6SJTFur.mtL-DTL:MWD-)Wh^t'^91UK[BEW:.UY"_*d0"2.(\k:NaC?UF07kY+D8d2'&+HaeNm9l-YF-11:FA8`JoMMiqHTJAi[GKkMKu'e3%*3rU)UJQFSLimRJ5-hW>_uWXC1R*/VU<K*&>O_N#5AN?$,gnLDmeH`OtK-Tb=pD2BiS/s)oo_t-Z#^T\lIKP%`8:[;g$>J@Fc0'<Tlec*Q,ilfJ;cB.T$[5B[:WK`.j.%Dq2M*DpPEfKTk_m&kg+7QVIjGC+Qg)e=t,cDnLL</;-q[Q#+FMi=cq*U]<ia*d"^k^V$#TZm<2I+;Ro+[2BeC3Rm:,@/n%"kCjRZ>R-pk3!%pTR+m@F!rD\8iHY"&+5UF"`AkTV18dp]anKEp/.X)qc:h@.9d&,G"fo<;'7OE+C2`TS#lG87TD@Z`q[\a?9SVU4bch^>2W+km"NcB8%G%,JqOJ.$c@\YW82GnqaDZRl+WjZM!.GE&57O3[?Z5MN&(.R@N1PQJ8>uRllj^4S!A=*DlV6Wqqf2<e;UN(D.``3UB1gol'^"'hClt@'=aaJ;s(n=GHjK^![Pl#W=$+np2gQ$VDr%35J"DQ]0636.8$s@Yk4d.2RBXi'8'kkh*pfATc<1"%$JCAjs7Q;@KBpS.nD.7S7lo$i=T*nR3i28S=q-.flW0%p7\A_t%cX6*YBT7a=5[&ZR%XRSkndEf9<(FEG3-CX9(G:2h8j$eR0W]Z09[fb>h3M7Rdm<p]NO$H7UePJ6p]jiX<,FjghNRk$HqqUG>d$a(!;_n&(t*#MnafhigDp,e4b2;#$)6eBa;ob'5WBMK_F9+dO;/c.h[%Q`BDu8,F,:[&CmMT/F5]8WZ?^q:5$!^Vd8-BUS@%<]&`uqX`2DYUdC3@GrH!5o3+tkr4WD:W"`E8MRldKmT8Y>)kfllg[]tl0qu3"1>AMWVOCM,k&nZsFY@Hg[$J-cNZS0+>AG^rqY+5O$rg6`""n-?BA'.@oSf:qHKtcPf'Pl>UfFtC]>0T4[>@KiG7\-P8Y,MVdcLM&:S-_:M*0bb%'3SQd359X)'=60d-U/G.MiU.KuHV:>\NXLRIu!B~>endstream
|
||||
endobj
|
||||
43 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2151
|
||||
>>
|
||||
stream
|
||||
Gatm=>>s9;&:Vs/fZ1?4I!"oO0l;L+)-SGam<Qd2i[@*pD5Fr\VSE,3HoE<9W.u7p]X%7[m\`i26%Rr)/UrO1^q0dJF%1^9=DWNAQ48`[3*f4,9e8nMi)6eX:O'[j@Y&e*l#r6Ja$/UN@ri,Ti)n3E>[Y:bD:ccK&iOYU3%4c6n'V@AhfoDX[s!:?BoU)O/N7&<"eTe8*+9Gso]fo"R%IYjJ""91;n/5K3G8-Vl1FRVIdmUX5@F#i*luR`nF/R0Xh_9ViU@q2=Muu:24S9(.e4m0kZ3BUQAg+Y?7-]"Q:\#CeFR5TNpF:sXMD5,io4;U+(u<$dKP9X(86cOeA8ps;<m*FY)+G/dqs_?j7b+&SA>ltU!3'I#9OBRD_fS'gu]NR74QZC0W06nRV>Si0%335!1]\(Fak<(9"Dt^4+IN@Q`QjU"0qdEE0sEQb,IUY;nAfCl+Z?F$PKH7ct2l44NFpY(TA.h:\1[NPsC`qK8gHFNg6$(+p`FCIJN^0r-E7JEXAAP?,:R(OXN/d2p4,^0`0h]^-t(B529S=IjY[8bGfRM@HqiqfB7:ZA$Fe)o)6AE7KbXTa!YbSHqFI4f^SD!_s&qgRP1POS1jQZHn"o%eBIk$VO%jk3ml\]_ueN$SG>W)=NaAb[fYIR6n4jc+]kq%:rffV%s")>6,H6I5L!+R9s:tMB!=pn:=UGKXBW,pdM&dI<,P,/^Bt9ORE?@qMb'a:/eM)i[.ubK.b.@+"Yu;$@$$c-if?Q&^d%mT(r9e7o3&oe((]397'5@_,:.PMi%A_*4KDM]*FLt:@\$mB+)"X,LH)t2%W@[$dqbO8rK1s&De.)E.n6C2Yj\X/a:pj#IUqfSk9Eko\5T2lGHBU4RM>P4nSK0;^;tNR[@=!raTsgMTKCAuiu(=g;;Gf@MX0JdBd^e>O-sG.b]o+gf0\.7]QSa)YCb'm_#DD474t#k^V[=0@Mu\SXd/'neY6h"&'^.TF-NPB(O=8qW>4p<VR>U9C'f)_*gg`h[JE+MH02BlFNirLV.2Z)dnc?YQ2tj\9Q*a*-uL(YHQWdDOg1_dF+WG6EO$,.6:Rnp-s)PfWal=-=:<nLorNk)NDPu`40b!s$ki(L",i'"Q2>RUD^@/a;!/`hNJ)UajL7V:GEL,B%0$4>R"<rbMnDRieeV5b,FKEP?ATrGT!;J&,C(Y5!d_P'16#;"%H_`!S0N%5CU\/6W=h1],T"gYWo=HK?=[M`.Q3RZUW_*pVFO,R#R5@f@/JI_*X<QTlUKj9N8&Eu:h9(_8rL(,SZ,5G.;d7'-I20G5Fgr'X5i--?[Xc3VcP1_7?jj:$>^))PM<IZY08h?;l@8:FN;[H:nQV)U/33$k"sE[g`kShB[R3A<FSs>MCfQ,P(a_DP^6j1P`;WKe9/f0>*Dpj`?!>?YarYR'E_S#@V52D;-Oa>4qHA-CR+:/U/iaA8<)f_FsQ-+K<n,3=>_<rdrNWF@9#*5:R<k9-mQ5/^?`j+7l.bJ]nF?LZ!l$J[L;F(eAq0F*?!<NGkuW18oa(0ch7[M#beEgQRAK5W4:G"CTABW'S-JoVO[u-RUf.N2!tsK=%Uml!)H!cAUC#bUWdHK=m'Op_EKQ2WlgN-.,6N#h8)#*;q$-/'mV^pm?#nrHr%VRRs@*[PBQo=JCii*kGi]2Vkjd+Lf_)rUb\/b)%Y;(Q^$+#ZZd\]-V;WS'PEbkVTg'F@$-78HIUnXQ/tONSsh18Sq;+7SO&R"]GTke:2HsWngpubJc5*>Ddf4DPih*"9&2/XbfnVV*`@n?f<M;DE`EkJTjFQ>0cbU7\s'1!Xa_BuU+N:`iPp3Z7_6comZFt.o:`-BHIk#-YC_gopC[?%443akfq9"L&%C=t@km@6D>6t=H"HsNh[C\;Ue8(DnsVoX*g&d1C(b7/ZQ:C_./:"-Dj@_1035qYb=B'%^\TY2[n?G>Gr4(;U8<(c-cQcJfBG(AYq)23TuK/s()\N5Usq3k\@OK-/0:A?I[.CG'Ri_,caPo'a1gpQ4==?!"lT6)@bG_P?L5YeD3en)X4[-#[P<l[CXVP$lZ-AUF"u<+@C%\VN;$#Cfp_-O6p$UCg$>&"ZS`>=@)?McEd6JnXif#iHbeQX)]aCmgEeS6\U!VEga6%PNO`m_p+PM<9`>:3i[kr~>endstream
|
||||
endobj
|
||||
44 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2180
|
||||
>>
|
||||
stream
|
||||
GauHL968iG&AJ$Cm&b"M7L4P&j/=e0`gO>[CpbWr+;0jbOG![uEi6[d$]6UjZ>@(?N"V&q!nbK[\)/f/_8?e#r['Y@LDdc)7"I+\/V3c:LY5b=R%=18,LiK.5_7;'j"UYQ@eTuWeQIH],Kbl43<p-(_Enh7M[9%Z+(m!(rcX$ciWj*T.MtQK6VN)4!WG\q>=:`.R)Ka``_!MS/;M?lfBI,Jl2(*g$6LqZDE?sNiPX3b+1(4`?X`Xeb^O=dE[Q*\HQG[&%fL?jqi<)'F&c0D0$;>ZC<cugl,:B-Yn2@C`DWV68@6T1\k"B(.,[AM^^n*Y^I*CASPQo7);HS\'qo<3JZb_(`!(_`,s!jGS_;^='%*6-J9r[hTb."Wm/K`3EmB)NJ.cd92"R*e55eV&cpadI@F,!:T<?E5(gT[XaFkX&BAF-#\:K9&U8R.XL_@jEZtiF3QA1CqOf!%&Ui'VVg2hok,#Nm;9(KkZ)enu<$(J8\H-SD#7!aG5K\O\^hllJKZ#Me9r/<o2_Du8gibleurMK?%nHMo9Gec=Hc8lo#B]C(Af^XL$TA)?gi!1unU,^"U#'Z.%&&%+U5/Mi2pHP0G4:]1DD-EM-hW%"=k(rPtTM)^0JE`(TX:fp<cAY9jk.IoSNPn#-G'9`>IY5mY_&d[1`kt0tciso=^097U64a/._l_\69Jk\Ln*m^'3d3ZiQuKlH9P5eJDKVB>Blh!5H\*[2KnN;+jVtQ>-DH=E<pO>2QH>5kkMD6oWFAOJ`WHE-n.-tfgjH+>1H>lBa'."Peh#lKB0,Fe@msdWMH2?.eXJu2nJQAeI]F=WLkm;U/aeED$#/JtXjS_*l'4Z:$QH\C%G76F_ubl^*RZR#4<GVo5;n7C>ANHTR`X(YW-+r:g<*1b,d4OHT<I?[AWRE;@Zm3[AMTOs'$gOk*Zesa^Xoo1LY&.c`^t8X\I+bikOnL&B?5tNl/VXThPuPfV$-s\>7IA8;ONsd,aPme%22ZJEN?L$+19=AeB/8pR.Gj+2(k)l::)H\7c:V[\@.g.i0Pd>^OSB^e2^&NPD^=uK![c&Ub6FN*OXV6K48&&lWcb&(Dpd5eJ^A?-,G$2Xg)QDHK`tLKgjp#c44(iKdo_%M9Oe-B:Tk?o>N.dH\jpifcb9AaPFsAY%6#YoY6X`!o!jK^C%S(4>O1uL8Q*-VpT]B<sJ#_`r!6F<-EoP>s$u=Y7\/NnWM'^3ohJ1dFBO&r4p)uqJjjpPJablZ6V-A!9&ZtE>a<,Q<F0I.E>kF:lB[1^;#;sbjM=NO$:q;j@Y$R5&>ZZK\hi/kfV)O)a61E>,hX.<?X7mQNQ5H%Z/a%b2@i;qDGLPa3r,b.J#C-5"PXu.kjJu'g+q)kNbgPBHp-hSd"bn<GPOnV4bZZg7"c$GccW"5dE(DMiU+[$sOp3gf;4F%nRI^D7,W..Z:%/dH-ZU'5>^9(Mg9q_T3s6nRCU]0tFoO?l^ih,17+gH(e7cdE[]1aprubMBi)k$lDtn1L8d#U;!JqL+:XG$^lC_n!ASrK-QT?J+DK2oY$:bh"a]A]0E<l0!NW1`GY.tA*QB1E`_[`]jQ/-$&'Aj!cfQ(4eLLmaONo9pOAP;lhC<Y!h\1K3Fo.!A$X>Z]BuKJ.jX"FLXWi]?/^E5gfkE:p6Y#rPrUnR2>^fA:!LKBRIa*N,R.l!CHFF$FX>4tTbtg7N=o$]lLdk^i10q)*u);1WKMV(USaDVDpDJ#4;b`d[?C\=AC==SfE,YJk94&?3^H*E/f$q1<#7(*WWI3H@)n(J]>6AB#"ZC,mAOp!$=uqg/$F&(9r=^<gu=&#'8OJF!LF6_%d5TiKb(9#B&O:RO6l</=-!a%l`SV^X3(R+4jL)J$V%:(hP!M.'5?RC#@K>mFFN44kGQ_g8R/tUaW]Hj<S<X4Z#qkI%7N.5VRi8;G$#TkqWk$!_rP$Ko0L44@3(JCQ)jFjK+0&AUu#.#"0TFNB%>^J7b<2\>co[PT6LgFh,W^ZR^>g>\:-[G2lX.'4,tihYYis-k4&iYs8.b.Oj>KQ,4\)s`'E=a:MrCh&K`(j2dQ7KHH-KN9=4]S=srf_IeH5E7oQ#4aLs#AMo6`YYJWgR2s`sqg(E;J'=h1M,EWr=+r]p+E7g!M+%pR['u]W#4e2?(o3a]rhA4O7g^aTL:>b'Xann@Os42SUlp(=N\'G~>endstream
|
||||
endobj
|
||||
45 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1927
|
||||
>>
|
||||
stream
|
||||
Gatm<9lo&I&A@sBm*X(f/AZ8uVDrV9*P4p[V7l'oOcYNh,T)&R1AUTk!3%V!:6gdX@nS"3i4XR9G#r;:j+k1[78<E#:4HM'@KF_T`$-k/q$g']B:mI3k-I0YjKZo>o74Ir[hhT=cPep9Z-\E(0Vg[h]c`:J$8O8nOpEQ@X^7Q,;uC%24!L?ASre<@GQ=nP&PJX=8KQrOZ68ep%eH.7hq^ugPH3Bmm=a=il$foD_Z4UmVgm#?2o!c<,L'ST)d*@uD]e,0Q`!],\G[@HoX/?n78qZLhCZU3X_"2>&HjOgWb*PMd1haFHY7VLS7G2^?CjQaTAVTG)n(T[^C*LJGs7/8GROdC`E8!&&fXIH1YDtR`1M@\A+5-?YeWol(iLu)ItT_<0FAOLcQ>CWILm%Y4r?_ol%Epg!.^^.e38kOUgY1[;,/'P40`gm%_e9X$Ra#_a$Q(4_K!WUU4(iZkrfYno3`2i4OQ4\Z37.,e3D6b]i0?"Z<;!pDnuTPl#WXO:%.7hPPHD5m-V2RQH>WL,4nh3L,5:Hjk]S.o%GG9li>c6_*sdTn%`N,)/K44gS",s;>Co_!6TM(8*+NDP2+6d4Mur3mU+`k;;L",LIurL4Gt2)hiXPY<StlT8jSY:]eg?.Oh.@`J"B4Im,JA%Jm!Csm"9m+Ke,p8DfEX!*6`!8+MqBkMFp;fLLt%h:+X2\49eFm*Q/'dr.prZ50":(!uMlmAHYXX)!2!A_En8"9]rmjd&I^`2mQT8)E.'C"QD,J,hRaYU@5UcAT%q4J,sSrBM+t4%G/*\H?;BBOf5830sUge<bb?\@'/^F)fJ*lGNZ\mksT%3hujEo2l>Up]uC.5Z?[/GFbBX-.V41[Z.nt3.OnDl\A8XCLqp:VA0Tk;q-:l&QW@d6X!I_%b_jm9A82;ibO[h(LT"5n_;)&("'EZKFuF`-d)nVT=gM(4QWA!;(/lfS^/JX"2D:>R[eM'3[^)rX8T6)NfJ+c6`33CIC2$LH"[,/GJ=_"^1i[&[gU-'XY)gp\Nr>/U#5abEESf0A2]RC'"Xau,Y"#a?!IWcd5P5bmeoG7[WJt#ig`@kh6/9%eMjeH8V?St:Y7<spO''1/mFJkumUW-X7*S:ClA(R-Cl^Gk0=QFd<7p:e_.Q?L50de<(7e.*44+M6@H;/\H`%Sm/1!&N*.]G/:8_7]"+'<c2FS4Z:lfcGaBW,#W9eJfKNc5TJf^^*5@R[QR%a?`[\?)]Pda7"O2&km.^?EdFL$':.sf[TU1q-eUEUGUZq>i5"Y"L@hVcj7$@D-:3]>dU:<-F.f1-.fZBGTMC<.@#VqQ9'lh=1S`\@![f]U[jgZOY_[_6W!@sNmqcTV"2\)7GR_r81QM:etVo)S$@.%r.1nNoRK99`)S&os(.Mc*eV_96IkEjmi0rcR7'>;[RHceW[9q;FS[KO/($S8:7?TBfcn?4.lub0:>GL[m(F1u.Zh-u];WJ%lcciP>)DaW=!-I+\i"ZI"u.=c+A#'H2aBW]<]$(r.MLh@'c.W0Mcm1p>KO_AX!W#;2[.rqjJm1ML'\o84>nWRTtI5o(`Nf^_(A"aV#Pa+t=<0/H=OM<Jb%Zm[V7,ueA@1><T&L^[S*ejB"3W>]*r*o:U23;Imm?M%^pY1$"!Elj50H;G'nK0"`_Eb[d^JlhLnKL=^?@sI2Z9H;V=E)t#/a,g;1@(s"r+m2<AAgDO.qB.kBhT_?UkU4/I[[Y,c:FH@-5]):T$aabTU,tFCe[4=C5seC(<'bmr7U&=?fdme-9([[OZ3euh!H/*b3IM</s(.M2VU5VNJk12ugG%GN0\]r<XuZ0RR9GkV-:EB67&(Zt\Xc"GJ;8],%hnp2?\?pY'j+LM:IhWufr8-!20tDNJl'e/:"=m5Q]D#T1Y+^`??5iFK^DL'P5BAC5nM\BZ.aS(&ErIFI;uB2~>endstream
|
||||
endobj
|
||||
46 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2037
|
||||
>>
|
||||
stream
|
||||
Gatm;9lJcG&A@7.b[l?AO`EmJPc2:+D-F;;Rr%EX!fL;sR<D\'Ua+Q1d9jYL[/?1ID2JdS`3_i?BBHmQ1ZXeZLSqn09D!$oJ%Xj\"c[05-'L1"T_%]qm0VejY6n$`lR]4B0i>mpeBLcO?Ih5O9'/.58hj2CJqRaH/0oC@RQWZ^;>agG3^Spg-WI/a%_u",pnA*r<2/BJ7F%'MED*32(SgNajlQBK,C4?)+%Fk-0!KSIrbaj0pmoEsB`)Bp'I1-FHsUMfQ*NM1s!#snf(rsNo7#A\j`Ra$gsptYKdc22n2!#2^36A63/^e:cp^^2.V+q^pai].gRMgOAE8nb>a9%p##`EcM&2&:9-gjr-$/RjA1C%;KCj-!8u8bJrdV:\J"=bF&80\9Bor_0=oI.]Edh^a+5d.YF3AM>`YR"8'M;E5okk:@Q0=;arN/.JF2i:(O53CJngYZ0E7/)9de:XH$uqM(1L5N7Fo,[?*LrQqJh?gGZKRfL8=sqAki:t>f6NZh7\3ji^3auo=78ZZ?66ZII7L)Fg"m<8T<[S(ajpQk#*_c2Npto?)Gk<C()><0deM8;LO8E$`M%^O<B?=cp[`9q6>dtNi$hQ1`^i*gR4`QBj>3?u\"q+?d%?Go7:VWq(:r)De?1Cr[KG`9n2Ie!;aY\@"1C3SlV-BVC:WW6ePL1e6%Gr2=9k^s8S<A$=GQo'3-UV/d\3P/3,Gr94Pj4_@HRFC"`IXAB$jaDD9iKq1bm77_6OQ1R=rkmA>obBCYQ.eK7^ihYDOeVm;PF\-.5?NPe.C6-biCBgj*(l8Rl8_9<&r?fE7rSR>O8s^(`VWgO*s@C9?M7*%RE7cpFfZe]C3H2tYE0ngD0q^i,6A'or\%+]+nr+E=5hcK/(<@!$VT;gd8S6'_rJ97MIpm%ehE1(<^>f;^+J)tN&oX/SdS>GWK)&]TlLcjbNX"@!EIdX2LRdKf8R@/ta!.#1sXe^EeE&b,R=AO3,)9aL.)G&rP03P9B#'l^S/H-XlaXmKjZ%5`._pc;PMloH>.[-Xp0=ga%'C8d1`F=r):^:/1MEifB!VE<r&Q?=5#&%XX\HUN45hbQJ:D@$@=V9T"$?gSk_h(&j:6q_$4Qf5Oc8sd.!J6i4p*ds+WMEqXZbKKf&kg`]V3Su'2H?H.r+Q1IeaJjudNP)5hRX\Y.kRrXlr@]H19U6<27,0Do2(iu-Fe(r/0(OfUr7hmg-W&u/,%20"ciZI%($Z+]3HtPmVr<XS/RU:_cd^/^8]ON0rPHRW%V;0a1K6W!oiEQ8/2q-k&iK)MaqdAs4qA0#/LC1e6$>FteHZT'.%O]_./&4ns4ep"o1-\l6Kg7\SWq"S[Tg>2g\P##J.XO%*%EWo>??$BZ)7-<cO[\n2RWoKBEFqZe((mJGTO+a7GcgQrYVqNE%S3)>@[M$#keeE7On(EfXr7kpea<!,Q8Np^B3/Up&SLho$QC2aJYk"$]O<f+Z93RF_:CN#Eqh7Jid"R%sNesG>G@tpcleqYj2R/C0Xtm!%f7$&jA^2ZhFr#B%lX_d(+Te8je\;7g\'l/FI4+_3:^`#EKG1/)#3QR-X&-lKl^2pn8S&/iM(^Ve4IL0q&9\kMd#";Z+q(`U9g,S'H9_@]ebs_DafND@DVO)^5?'&rRm1"R_fR-4Y2OjkoJn4,_!Z=Bi7.8r8jOqS\Vs.lL+PG6Y`/^U4SHR"I_t@"bH#`8hbl5CF):,(I7gCSCR6VtD4uFrXQ[<>W>PgBVgQU"(BRSZKtYogpVMRua&?ghpj^_hE+LkF&$]!&IPX0Ri#9#Jd!=`?e\^qooQ;O1O.%/o;S_E+U,%1qkk^dQ3]]-:\S37MIj5Gu`]_HjJNQ`39:B`jKou(#<qFr8h.FO;PNJ2nn1%XP.O$rQ3Uc!&Cg19HI!DOpN^_FD3j+anJXYG?=f$^olK3S`n&llEk__6AJB6BfJXp;o]))Mm\1HW10IrcCgPIkg0E0Fm\8lo/\D@E9u_5PUrY^Aig[dL^7?.8+MX]6Esg3G;qeJ!a!GOktZs;=R"m2@bckd~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 47
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000123 00000 n
|
||||
0000000230 00000 n
|
||||
0000000342 00000 n
|
||||
0000000547 00000 n
|
||||
0000000752 00000 n
|
||||
0000000871 00000 n
|
||||
0000001076 00000 n
|
||||
0000001281 00000 n
|
||||
0000001486 00000 n
|
||||
0000001692 00000 n
|
||||
0000001898 00000 n
|
||||
0000002104 00000 n
|
||||
0000002310 00000 n
|
||||
0000002516 00000 n
|
||||
0000002722 00000 n
|
||||
0000002928 00000 n
|
||||
0000003134 00000 n
|
||||
0000003340 00000 n
|
||||
0000003546 00000 n
|
||||
0000003752 00000 n
|
||||
0000003958 00000 n
|
||||
0000004164 00000 n
|
||||
0000004280 00000 n
|
||||
0000004486 00000 n
|
||||
0000004556 00000 n
|
||||
0000004837 00000 n
|
||||
0000005023 00000 n
|
||||
0000006282 00000 n
|
||||
0000007680 00000 n
|
||||
0000010258 00000 n
|
||||
0000012431 00000 n
|
||||
0000014575 00000 n
|
||||
0000016764 00000 n
|
||||
0000018722 00000 n
|
||||
0000021079 00000 n
|
||||
0000023292 00000 n
|
||||
0000024714 00000 n
|
||||
0000027170 00000 n
|
||||
0000029111 00000 n
|
||||
0000031488 00000 n
|
||||
0000033899 00000 n
|
||||
0000035713 00000 n
|
||||
0000037956 00000 n
|
||||
0000040228 00000 n
|
||||
0000042247 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<e9856c676444c494d18feb53bf5d2892><e9856c676444c494d18feb53bf5d2892>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 26 0 R
|
||||
/Root 25 0 R
|
||||
/Size 47
|
||||
>>
|
||||
startxref
|
||||
44376
|
||||
%%EOF
|
||||
566
USER_ARCHETYPES.html
Normal file
566
USER_ARCHETYPES.html
Normal file
@@ -0,0 +1,566 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AgenciaPsi — Arquétipos de Usuário</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
background: #0f1117;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
padding: 2rem 1rem 4rem;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
background: linear-gradient(135deg, #818cf8, #34d399);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.page-header p {
|
||||
margin-top: .5rem;
|
||||
color: #64748b;
|
||||
font-size: .95rem;
|
||||
}
|
||||
|
||||
/* ── Grid ── */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: 1.5rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.card {
|
||||
background: #1e2330;
|
||||
border: 1px solid #2d3548;
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
transition: border-color .2s, transform .2s;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: #4f6ef7;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ── Card header ── */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
}
|
||||
.card-icon {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: .75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-title { font-size: 1.05rem; font-weight: 700; line-height: 1.2; }
|
||||
.card-subtitle { font-size: .75rem; color: #64748b; margin-top: 2px; font-family: monospace; }
|
||||
|
||||
/* ── Tree ── */
|
||||
.tree {
|
||||
background: #0f1117;
|
||||
border-radius: .75rem;
|
||||
padding: 1rem 1.1rem;
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: .78rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.tree-root { color: #a5b4fc; font-weight: 600; }
|
||||
.tree-branch { color: #475569; }
|
||||
.tree-leaf { color: #cbd5e1; }
|
||||
.tree-comment { color: #475569; font-style: italic; }
|
||||
.tree-key { color: #f472b6; }
|
||||
.tree-val { color: #34d399; }
|
||||
.tree-warn { color: #fb923c; }
|
||||
.tree-new { color: #facc15; }
|
||||
|
||||
/* ── Badges ── */
|
||||
.badges { display: flex; flex-wrap: wrap; gap: .4rem; }
|
||||
.badge {
|
||||
font-size: .68rem;
|
||||
font-weight: 600;
|
||||
padding: .2rem .6rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid transparent;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.badge-purple { background: #312e81; border-color: #4f46e5; color: #a5b4fc; }
|
||||
.badge-green { background: #064e3b; border-color: #059669; color: #6ee7b7; }
|
||||
.badge-blue { background: #1e3a5f; border-color: #2563eb; color: #93c5fd; }
|
||||
.badge-orange { background: #431407; border-color: #ea580c; color: #fdba74; }
|
||||
.badge-yellow { background: #422006; border-color: #ca8a04; color: #fde047; }
|
||||
.badge-pink { background: #500724; border-color: #db2777; color: #f9a8d4; }
|
||||
.badge-gray { background: #1e293b; border-color: #475569; color: #94a3b8; }
|
||||
.badge-red { background: #450a0a; border-color: #dc2626; color: #fca5a5; }
|
||||
|
||||
/* ── Notes ── */
|
||||
.note {
|
||||
font-size: .75rem;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
border-left: 2px solid #2d3548;
|
||||
padding-left: .75rem;
|
||||
}
|
||||
.note strong { color: #94a3b8; }
|
||||
|
||||
/* ── Phase tag ── */
|
||||
.phase {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .3rem;
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
padding: .15rem .55rem;
|
||||
border-radius: 9999px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.phase-1 { background: #064e3b; color: #6ee7b7; border: 1px solid #059669; }
|
||||
.phase-2 { background: #422006; color: #fde047; border: 1px solid #ca8a04; }
|
||||
|
||||
/* ── Section label ── */
|
||||
.section-label {
|
||||
grid-column: 1 / -1;
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
padding: .25rem 0;
|
||||
border-bottom: 1px solid #2d3548;
|
||||
margin-bottom: -.25rem;
|
||||
}
|
||||
|
||||
/* ── Legend ── */
|
||||
.legend {
|
||||
max-width: 1400px;
|
||||
margin: 2.5rem auto 0;
|
||||
background: #1e2330;
|
||||
border: 1px solid #2d3548;
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.legend h3 { font-size: .8rem; font-weight: 700; color: #64748b; letter-spacing: .08em; text-transform: uppercase; margin-bottom: .75rem; }
|
||||
.legend-grid { display: flex; flex-wrap: wrap; gap: 1rem 2rem; }
|
||||
.legend-item { display: flex; align-items: center; gap: .5rem; font-size: .78rem; color: #94a3b8; }
|
||||
.legend-dot { width: .65rem; height: .65rem; border-radius: 50%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>AgenciaPsi — Arquétipos de Usuário</h1>
|
||||
<p>Como cada tipo de usuário está estruturado no banco de dados e no sistema de permissões.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
|
||||
<!-- ════════════════════════════════════════ PLATAFORMA ══ -->
|
||||
<div class="section-label">🏛️ Plataforma</div>
|
||||
|
||||
<!-- SaaS Admin -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon" style="background:#312e81">🛡️</div>
|
||||
<div>
|
||||
<div class="card-title">SaaS Admin</div>
|
||||
<div class="card-subtitle">profiles.role = 'saas_admin'</div>
|
||||
</div>
|
||||
<span class="phase phase-1">Fase 1</span>
|
||||
</div>
|
||||
|
||||
<div class="tree">
|
||||
<div class="tree-root">Usuário (saas@agenciapsi.com.br)</div>
|
||||
<div> <span class="tree-branch">└──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'saas_admin'</span></div>
|
||||
<div> <span class="tree-comment">// sem memberships de tenant</span></div>
|
||||
<div> <span class="tree-comment">// acessa /saas/*</span></div>
|
||||
</div>
|
||||
|
||||
<div class="badges">
|
||||
<span class="badge badge-purple">role: saas_admin</span>
|
||||
<span class="badge badge-gray">sem tenant</span>
|
||||
</div>
|
||||
|
||||
<p class="note">Acesso total à plataforma. Gerencia planos, features, assinaturas e usuários. Nunca entra em contexto de tenant.</p>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon" style="background:#1e3a5f">✍️</div>
|
||||
<div>
|
||||
<div class="card-title">Editor de Conteúdo</div>
|
||||
<div class="card-subtitle">profiles.platform_roles[] = 'editor'</div>
|
||||
</div>
|
||||
<span class="phase phase-1">Fase 1</span>
|
||||
</div>
|
||||
|
||||
<div class="tree">
|
||||
<div class="tree-root">Usuário (editor@agenciapsi.com.br)</div>
|
||||
<div> <span class="tree-branch">└──</span> <span class="tree-key">profiles.platform_roles</span> <span class="tree-val">['editor']</span></div>
|
||||
<div> <span class="tree-comment">// sem memberships de tenant</span></div>
|
||||
<div> <span class="tree-comment">// acessa /editor/*</span></div>
|
||||
</div>
|
||||
|
||||
<div class="badges">
|
||||
<span class="badge badge-blue">platform_roles: editor</span>
|
||||
<span class="badge badge-gray">sem tenant</span>
|
||||
</div>
|
||||
|
||||
<p class="note">Papel de plataforma (não de tenant). Gerencia conteúdo público, landing pages, textos. Verificado via <code>platform_roles[]</code>.</p>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════ CLÍNICA ══ -->
|
||||
<div class="section-label">🏥 Clínica</div>
|
||||
|
||||
<!-- Clinic Admin -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon" style="background:#064e3b">🏥</div>
|
||||
<div>
|
||||
<div class="card-title">Admin da Clínica</div>
|
||||
<div class="card-subtitle">tenant.kind = 'clinic'</div>
|
||||
</div>
|
||||
<span class="phase phase-1">Fase 1</span>
|
||||
</div>
|
||||
|
||||
<div class="tree">
|
||||
<div class="tree-root">Usuário (admin@clinicax.com.br)</div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'tenant_member'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> Membership: <span class="tree-val">Clínica X</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'clinic'</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'clinic_admin'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-val">clinic_free | clinic_pro</span></div>
|
||||
<div> <span class="tree-comment">// acessa /admin/*</span></div>
|
||||
</div>
|
||||
|
||||
<div class="badges">
|
||||
<span class="badge badge-green">role: clinic_admin</span>
|
||||
<span class="badge badge-blue">tenant: clinic</span>
|
||||
<span class="badge badge-gray">clinic_free</span>
|
||||
<span class="badge badge-purple">clinic_pro</span>
|
||||
</div>
|
||||
|
||||
<p class="note">Dono ou gestor de uma clínica. Gerencia profissionais, pacientes, agenda e módulos da clínica.</p>
|
||||
</div>
|
||||
|
||||
<!-- Terapeuta da Clínica -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon" style="background:#1e3a5f">🧑⚕️</div>
|
||||
<div>
|
||||
<div class="card-title">Terapeuta da Clínica</div>
|
||||
<div class="card-subtitle">tenant.kind = 'clinic' / role = 'therapist'</div>
|
||||
</div>
|
||||
<span class="phase phase-1">Fase 1</span>
|
||||
</div>
|
||||
|
||||
<div class="tree">
|
||||
<div class="tree-root">Usuário (terapeuta@clinicax.com.br)</div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'tenant_member'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> Membership: <span class="tree-val">Clínica X</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'clinic'</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> <span class="tree-key">entitlements</span> <span class="tree-val">via plano da clínica</span></div>
|
||||
<div> <span class="tree-comment">// acessa /therapist/*</span></div>
|
||||
</div>
|
||||
|
||||
<div class="badges">
|
||||
<span class="badge badge-green">role: therapist</span>
|
||||
<span class="badge badge-blue">tenant: clinic</span>
|
||||
<span class="badge badge-gray">entitlements da clínica</span>
|
||||
</div>
|
||||
|
||||
<p class="note">Terapeuta vinculado a uma clínica. Seus entitlements vêm do plano do tenant (clínica), não de assinatura pessoal.</p>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════ TERAPEUTA INDEPENDENTE ══ -->
|
||||
<div class="section-label">🧑💼 Terapeuta Independente</div>
|
||||
|
||||
<!-- Terapeuta Solo -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon" style="background:#064e3b">🧑💼</div>
|
||||
<div>
|
||||
<div class="card-title">Terapeuta Solo</div>
|
||||
<div class="card-subtitle">tenant.kind = 'saas'</div>
|
||||
</div>
|
||||
<span class="phase phase-1">Fase 1</span>
|
||||
</div>
|
||||
|
||||
<div class="tree">
|
||||
<div class="tree-root">Usuário (terapeuta@gmail.com)</div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'tenant_member'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> Membership: <span class="tree-val">Tenant Pessoal</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'saas'</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-val">therapist_free | therapist_pro</span></div>
|
||||
<div> <span class="tree-comment">// entitlements via v_user_entitlements</span></div>
|
||||
<div> <span class="tree-comment">// acessa /therapist/*</span></div>
|
||||
</div>
|
||||
|
||||
<div class="badges">
|
||||
<span class="badge badge-green">role: therapist</span>
|
||||
<span class="badge badge-gray">tenant: saas (pessoal)</span>
|
||||
<span class="badge badge-gray">therapist_free</span>
|
||||
<span class="badge badge-purple">therapist_pro</span>
|
||||
</div>
|
||||
|
||||
<p class="note">Terapeuta autônomo sem clínica. Assina diretamente. Entitlements vêm de <code>v_user_entitlements</code> (assinatura pessoal).</p>
|
||||
</div>
|
||||
|
||||
<!-- Terapeuta Solo + Clínica -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon" style="background:#1e3a5f">🔀</div>
|
||||
<div>
|
||||
<div class="card-title">Terapeuta Solo + Clínica</div>
|
||||
<div class="card-subtitle">2 memberships / contexto switcher</div>
|
||||
</div>
|
||||
<span class="phase phase-1">Fase 1</span>
|
||||
</div>
|
||||
|
||||
<div class="tree">
|
||||
<div class="tree-root">Usuário (terapeuta@gmail.com)</div>
|
||||
<div> <span class="tree-branch">├──</span> Membership A: <span class="tree-val">Tenant Pessoal</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'saas'</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-val">therapist_pro</span></div>
|
||||
<div> <span class="tree-branch">└──</span> Membership B: <span class="tree-val">Clínica X</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'clinic'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
|
||||
<div> <span class="tree-comment">// switcher de contexto no topbar</span></div>
|
||||
</div>
|
||||
|
||||
<div class="badges">
|
||||
<span class="badge badge-green">role: therapist</span>
|
||||
<span class="badge badge-blue">2 tenants</span>
|
||||
<span class="badge badge-purple">therapist_pro</span>
|
||||
<span class="badge badge-gray">switcher de contexto</span>
|
||||
</div>
|
||||
|
||||
<p class="note">Atua em dois contextos. No tenant pessoal usa PRO. Na clínica usa os entitlements da clínica. Precisa de switcher de tenant no topbar.</p>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════ SUPERVISOR ══ -->
|
||||
<div class="section-label">🎓 Supervisor (Fase 1 — novo)</div>
|
||||
|
||||
<!-- Supervisor Solo -->
|
||||
<div class="card" style="border-color: #ca8a04">
|
||||
<div class="card-header">
|
||||
<div class="card-icon" style="background:#422006">🎓</div>
|
||||
<div>
|
||||
<div class="card-title">Supervisor Solo</div>
|
||||
<div class="card-subtitle">tenant.kind = 'supervisor'</div>
|
||||
</div>
|
||||
<span class="phase phase-1">Fase 1</span>
|
||||
</div>
|
||||
|
||||
<div class="tree">
|
||||
<div class="tree-root">Usuário (supervisor@gmail.com)</div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'tenant_member'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> Membership: <span class="tree-new">Tenant Supervisão (novo)</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-new">'supervisor'</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-new">'supervisor'</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">plano</span> <span class="tree-new">supervisor_free | supervisor_pro</span></div>
|
||||
<div> <span class="tree-branch">└──</span> <span class="tree-key">max_supervisees</span> <span class="tree-new">3 | 20</span></div>
|
||||
<div> <span class="tree-comment">// acessa /supervisor/*</span></div>
|
||||
</div>
|
||||
|
||||
<div class="badges">
|
||||
<span class="badge badge-yellow">role: supervisor</span>
|
||||
<span class="badge badge-yellow">tenant: supervisor</span>
|
||||
<span class="badge badge-yellow">supervisor_free</span>
|
||||
<span class="badge badge-orange">supervisor_pro</span>
|
||||
</div>
|
||||
|
||||
<p class="note"><strong>Novo.</strong> Supervisor independente. Tem sua própria sala de supervisão. O plano define o limite de terapeutas supervisionados (<code>plans.max_supervisees</code>).</p>
|
||||
</div>
|
||||
|
||||
<!-- Terapeuta + Supervisor -->
|
||||
<div class="card" style="border-color: #ca8a04">
|
||||
<div class="card-header">
|
||||
<div class="card-icon" style="background:#422006">🔀🎓</div>
|
||||
<div>
|
||||
<div class="card-title">Terapeuta + Supervisor</div>
|
||||
<div class="card-subtitle">2 tenants / 2 papéis</div>
|
||||
</div>
|
||||
<span class="phase phase-1">Fase 1</span>
|
||||
</div>
|
||||
|
||||
<div class="tree">
|
||||
<div class="tree-root">Usuário (terapeuta@gmail.com)</div>
|
||||
<div> <span class="tree-branch">├──</span> Membership A: <span class="tree-val">Tenant Pessoal</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'saas'</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-val">therapist_pro</span></div>
|
||||
<div> <span class="tree-branch">└──</span> Membership B: <span class="tree-new">Tenant Supervisão (novo)</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-new">'supervisor'</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-new">'supervisor'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-new">supervisor_pro</span></div>
|
||||
<div> <span class="tree-comment">// switcher: "Meu consultório" / "Minha supervisão"</span></div>
|
||||
</div>
|
||||
|
||||
<div class="badges">
|
||||
<span class="badge badge-green">therapist</span>
|
||||
<span class="badge badge-yellow">supervisor</span>
|
||||
<span class="badge badge-blue">2 tenants</span>
|
||||
<span class="badge badge-purple">therapist_pro</span>
|
||||
<span class="badge badge-orange">supervisor_pro</span>
|
||||
</div>
|
||||
|
||||
<p class="note"><strong>O caso mais comum.</strong> Atua como terapeuta no tenant pessoal e como supervisor no tenant de supervisão. Switcher de contexto no topbar.</p>
|
||||
</div>
|
||||
|
||||
<!-- Terapeuta (clínica) + Supervisor -->
|
||||
<div class="card" style="border-color: #ca8a04">
|
||||
<div class="card-header">
|
||||
<div class="card-icon" style="background:#422006">🏥🎓</div>
|
||||
<div>
|
||||
<div class="card-title">Terapeuta (Clínica) + Supervisor</div>
|
||||
<div class="card-subtitle">3 tenants possíveis</div>
|
||||
</div>
|
||||
<span class="phase phase-1">Fase 1</span>
|
||||
</div>
|
||||
|
||||
<div class="tree">
|
||||
<div class="tree-root">Usuário (terapeuta@clinicax.com.br)</div>
|
||||
<div> <span class="tree-branch">├──</span> Membership A: <span class="tree-val">Clínica X</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'clinic'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> Membership B: <span class="tree-new">Tenant Supervisão (novo)</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-new">'supervisor'</span></div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-new">'supervisor'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-new">supervisor_free | supervisor_pro</span></div>
|
||||
<div> <span class="tree-comment">// supervisão é INDEPENDENTE da clínica</span></div>
|
||||
<div> <span class="tree-comment">// colegas da clínica podem ser supervisionados</span></div>
|
||||
<div> <span class="tree-comment">// via convite no tenant de supervisão</span></div>
|
||||
</div>
|
||||
|
||||
<div class="badges">
|
||||
<span class="badge badge-green">therapist (clínica)</span>
|
||||
<span class="badge badge-yellow">supervisor (independente)</span>
|
||||
<span class="badge badge-orange">supervisor_pro</span>
|
||||
</div>
|
||||
|
||||
<p class="note">Trabalha na clínica como terapeuta <strong>e</strong> supervisiona outros terapeutas (inclusive colegas da clínica) de forma independente. A clínica não interfere na supervisão.</p>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════ FASE 2 ══ -->
|
||||
<div class="section-label">🚀 Fase 2 — Marketplace de Supervisão</div>
|
||||
|
||||
<!-- Clínica com Supervisor Associado -->
|
||||
<div class="card" style="border-color: #475569; opacity: .8">
|
||||
<div class="card-header">
|
||||
<div class="card-icon" style="background:#1e293b">🏥🤝🎓</div>
|
||||
<div>
|
||||
<div class="card-title">Clínica com Supervisor Contratado</div>
|
||||
<div class="card-subtitle">repasse financeiro AgenciaPsi</div>
|
||||
</div>
|
||||
<span class="phase phase-2">Fase 2</span>
|
||||
</div>
|
||||
|
||||
<div class="tree">
|
||||
<div class="tree-root">Clínica X</div>
|
||||
<div> <span class="tree-branch">├──</span> Ativa módulo <span class="tree-key">supervisao</span> (feature)</div>
|
||||
<div> <span class="tree-branch">├──</span> Associa supervisor externo via convite</div>
|
||||
<div> <span class="tree-branch">├──</span> Sessões registradas na plataforma</div>
|
||||
<div> <span class="tree-branch">└──</span> Pagamento via AgenciaPsi</div>
|
||||
<div> </div>
|
||||
<div class="tree-root">Fluxo financeiro</div>
|
||||
<div> <span class="tree-branch">└──</span> Clínica paga <span class="tree-warn">R$ 200/sessão</span></div>
|
||||
<div> <span class="tree-branch">├──</span> Supervisor recebe <span class="tree-val">R$ 180</span></div>
|
||||
<div> <span class="tree-branch">└──</span> AgenciaPsi retém <span class="tree-warn">R$ 20 (10%)</span></div>
|
||||
</div>
|
||||
|
||||
<div class="badges">
|
||||
<span class="badge badge-pink">split de pagamento</span>
|
||||
<span class="badge badge-gray">Stripe Connect / Iugu</span>
|
||||
<span class="badge badge-red">Fase 2</span>
|
||||
</div>
|
||||
|
||||
<p class="note"><strong>Futuro.</strong> A clínica contrata supervisão via plataforma. AgenciaPsi faz o split automático. Requer gateway com marketplace split (Stripe Connect, Iugu, Pagar.me).</p>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════ PACIENTE ══ -->
|
||||
<div class="section-label">👤 Paciente / Portal</div>
|
||||
|
||||
<!-- Paciente -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon" style="background:#500724">👤</div>
|
||||
<div>
|
||||
<div class="card-title">Paciente</div>
|
||||
<div class="card-subtitle">profiles.role = 'portal_user'</div>
|
||||
</div>
|
||||
<span class="phase phase-1">Fase 1</span>
|
||||
</div>
|
||||
|
||||
<div class="tree">
|
||||
<div class="tree-root">Usuário (paciente@gmail.com)</div>
|
||||
<div> <span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'portal_user'</span></div>
|
||||
<div> <span class="tree-branch">└──</span> <span class="tree-comment">// sem memberships de tenant</span></div>
|
||||
<div> <span class="tree-comment">// acessa /portal/*</span></div>
|
||||
<div> <span class="tree-comment">// identidade global, não tenant</span></div>
|
||||
</div>
|
||||
|
||||
<div class="badges">
|
||||
<span class="badge badge-pink">role: portal_user</span>
|
||||
<span class="badge badge-gray">sem tenant</span>
|
||||
</div>
|
||||
|
||||
<p class="note">Acessa apenas o portal do paciente. Vê suas sessões, agenda e documentos. Nunca entra na área de tenant-app.</p>
|
||||
</div>
|
||||
|
||||
</div><!-- /grid -->
|
||||
|
||||
<!-- ── LEGEND ── -->
|
||||
<div class="legend">
|
||||
<h3>Legenda</h3>
|
||||
<div class="legend-grid">
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#facc15"></div> Novo — Fase 1 (supervisor)</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#6ee7b7"></div> Existente — Fase 1</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#fb923c"></div> Planejado — Fase 2</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#a5b4fc"></div> Plataforma (sem tenant)</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#94a3b8"></div> profiles.role → identidade global</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#f9a8d4"></div> memberships.role → contexto de tenant</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── RESUMO TÉCNICO ── -->
|
||||
<div class="legend" style="margin-top: 1rem">
|
||||
<h3>Resumo Técnico — Como o Guard decide o menu</h3>
|
||||
<div style="font-family: monospace; font-size: .8rem; line-height: 2; color: #94a3b8;">
|
||||
<div><span style="color:#a5b4fc">profiles.role</span> = identidade global (saas_admin | tenant_member | portal_user)</div>
|
||||
<div><span style="color:#6ee7b7">memberships.role</span> = papel dentro do tenant (clinic_admin | therapist | supervisor | editor)</div>
|
||||
<div><span style="color:#f9a8d4">tenant.kind</span> = tipo do tenant (clinic | saas | supervisor) → define qual menu e contexto</div>
|
||||
<div><span style="color:#fde047">plans.target</span> = para quem é o plano (clinic | therapist | supervisor)</div>
|
||||
<div><span style="color:#fdba74">plans.max_supervisees</span> = limite de supervisionados (novo — Fase 1)</div>
|
||||
<div style="margin-top:.5rem; color: #475569">
|
||||
Entitlements: v_tenant_entitlements (plano do tenant) UNION v_user_entitlements (assinatura pessoal)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
9444
schema.sql
Normal file
9444
schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
132
src/App.vue
132
src/App.vue
@@ -1,38 +1,128 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const entStore = useEntitlementsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
// 1) carrega sessão + tenant ativo (do seu fluxo atual)
|
||||
await tenantStore.loadSessionAndTenant()
|
||||
function isTenantArea (path = '') {
|
||||
return path.startsWith('/admin') || path.startsWith('/therapist')
|
||||
}
|
||||
|
||||
// 2) carrega permissões do tenant ativo (se existir)
|
||||
if (tenantStore.activeTenantId) {
|
||||
await entStore.loadForTenant(tenantStore.activeTenantId)
|
||||
function isPortalArea (path = '') {
|
||||
return path.startsWith('/portal')
|
||||
}
|
||||
|
||||
function isSaasArea (path = '') {
|
||||
return path.startsWith('/saas')
|
||||
}
|
||||
|
||||
async function debugSnapshot (label = 'snapshot') {
|
||||
console.group(`🧭 [APP DEBUG] ${label}`)
|
||||
try {
|
||||
// 0) rota + meta
|
||||
console.log('route.fullPath:', route.fullPath)
|
||||
console.log('route.path:', route.path)
|
||||
console.log('route.name:', route.name)
|
||||
console.log('route.meta:', route.meta)
|
||||
|
||||
// 1) storage
|
||||
console.groupCollapsed('📦 Storage')
|
||||
console.log('localStorage.tenant_id:', localStorage.getItem('tenant_id'))
|
||||
console.log('localStorage.currentTenantId:', localStorage.getItem('currentTenantId'))
|
||||
console.log('localStorage.tenant:', localStorage.getItem('tenant'))
|
||||
console.log('sessionStorage.redirect_after_login:', sessionStorage.getItem('redirect_after_login'))
|
||||
console.log('sessionStorage.intended_area:', sessionStorage.getItem('intended_area'))
|
||||
console.groupEnd()
|
||||
|
||||
// 2) sessão auth (fonte real)
|
||||
const { data: authData, error: authErr } = await supabase.auth.getUser()
|
||||
if (authErr) console.warn('[auth.getUser] error:', authErr)
|
||||
const user = authData?.user || null
|
||||
console.log('auth.user:', user ? { id: user.id, email: user.email } : null)
|
||||
|
||||
// 3) profiles.role (identidade global)
|
||||
let profileRole = null
|
||||
if (user?.id) {
|
||||
const { data: profile, error: pErr } = await supabase
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (pErr) console.warn('[profiles] error:', pErr)
|
||||
profileRole = profile?.role || null
|
||||
}
|
||||
console.log('profiles.role (global):', profileRole)
|
||||
|
||||
// 4) memberships via RPC (fonte de verdade do tenantStore)
|
||||
let rpcTenants = null
|
||||
if (user?.id) {
|
||||
const { data: rpcData, error: rpcErr } = await supabase.rpc('my_tenants')
|
||||
if (rpcErr) console.warn('[rpc my_tenants] error:', rpcErr)
|
||||
rpcTenants = rpcData ?? null
|
||||
}
|
||||
console.log('rpc.my_tenants():', rpcTenants)
|
||||
|
||||
// 5) stores (sempre logar)
|
||||
console.groupCollapsed('🏪 Stores (before optional loads)')
|
||||
console.log('tenantStore.activeTenantId:', tenantStore.activeTenantId)
|
||||
console.log('tenantStore.activeRole:', tenantStore.activeRole)
|
||||
console.log('tenantStore.memberships:', tenantStore.memberships)
|
||||
console.log('entStore.loaded:', entStore.loaded)
|
||||
console.log('entStore.tenantId:', entStore.activeTenantId || entStore.tenantId)
|
||||
console.groupEnd()
|
||||
|
||||
// 6) IMPORTANTÍSSIMO: não carregar tenant fora da área tenant
|
||||
const path = route.path || ''
|
||||
|
||||
if (isTenantArea(path)) {
|
||||
console.log('✅ Tenant area detected → will loadSessionAndTenant + entitlements')
|
||||
await tenantStore.loadSessionAndTenant()
|
||||
|
||||
if (tenantStore.activeTenantId) {
|
||||
await entStore.loadForTenant(tenantStore.activeTenantId)
|
||||
}
|
||||
|
||||
console.groupCollapsed('🏪 Stores (after tenant loads)')
|
||||
console.log('tenantStore.activeTenantId:', tenantStore.activeTenantId)
|
||||
console.log('tenantStore.activeRole:', tenantStore.activeRole)
|
||||
console.log('tenantStore.memberships:', tenantStore.memberships)
|
||||
console.log("entStore.can('online_scheduling.manage'):", entStore.can?.('online_scheduling.manage'))
|
||||
console.groupEnd()
|
||||
} else if (isPortalArea(path)) {
|
||||
console.log('🟣 Portal area detected → SKIP tenantStore.loadSessionAndTenant()')
|
||||
} else if (isSaasArea(path)) {
|
||||
console.log('🟠 SaaS area detected → SKIP tenantStore.loadSessionAndTenant()')
|
||||
} else {
|
||||
console.log('⚪ Other/public area detected → SKIP tenantStore.loadSessionAndTenant()')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[APP DEBUG] snapshot error:', e)
|
||||
} finally {
|
||||
console.groupEnd()
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
onMounted(async () => {
|
||||
// 🔥 PRIMEIRO LOG — TENANT ID BRUTO (mantive sua ideia)
|
||||
console.log('[SEU_TENANT_ID]', localStorage.getItem('tenant_id'))
|
||||
|
||||
// 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()
|
||||
// snapshot inicial
|
||||
await debugSnapshot('mounted')
|
||||
})
|
||||
|
||||
// snapshot a cada navegação (isso é o que vai te salvar)
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
async (to, from) => {
|
||||
await debugSnapshot(`route change: ${from} -> ${to}`)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
9
src/app/bootstrapUserSettings.js
vendored
9
src/app/bootstrapUserSettings.js
vendored
@@ -63,7 +63,7 @@ export async function bootstrapUserSettings({
|
||||
primaryColors = [], // passe a lista do seu Perfil (ou uma versão reduzida)
|
||||
surfaces = [] // idem
|
||||
} = {}) {
|
||||
const { layoutConfig, isDarkTheme, toggleDarkMode, changeMenuMode } = useLayout()
|
||||
const { layoutConfig, isDarkTheme, toggleDarkMode, changeMenuMode, setVariant } = useLayout()
|
||||
|
||||
const { data: uRes, error: uErr } = await supabase.auth.getUser()
|
||||
if (uErr) return
|
||||
@@ -72,12 +72,17 @@ export async function bootstrapUserSettings({
|
||||
|
||||
const { data: settings, error } = await supabase
|
||||
.from('user_settings')
|
||||
.select('theme_mode, preset, primary_color, surface_color, menu_mode')
|
||||
.select('theme_mode, preset, primary_color, surface_color, menu_mode, layout_variant')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (error || !settings) return
|
||||
|
||||
// layout variant (rail / classic)
|
||||
if (settings.layout_variant === 'rail' || settings.layout_variant === 'classic') {
|
||||
setVariant(settings.layout_variant)
|
||||
}
|
||||
|
||||
// menu mode
|
||||
if (settings.menu_mode && settings.menu_mode !== layoutConfig.menuMode) {
|
||||
layoutConfig.menuMode = settings.menu_mode
|
||||
|
||||
@@ -8,10 +8,25 @@
|
||||
@hide="onHide"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xl font-semibold">{{ title }}</div>
|
||||
<div class="text-sm text-surface-500">
|
||||
Crie um paciente rapidamente (nome, e-mail e telefone obrigatórios).
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xl font-semibold">{{ title }}</div>
|
||||
<div class="text-sm text-surface-500">
|
||||
Crie um paciente rapidamente (nome, e-mail e telefone obrigatórios).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOPBAR ACTION -->
|
||||
<Button
|
||||
v-if="canSee('testMODE')"
|
||||
label="Gerar usuário"
|
||||
icon="pi pi-user-plus"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:disabled="saving"
|
||||
@click="generateUser"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,55 +36,67 @@
|
||||
{{ errorMsg }}
|
||||
</Message>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium" for="cr-nome">Nome *</label>
|
||||
<InputText
|
||||
id="cr-nome"
|
||||
v-model.trim="form.nome_completo"
|
||||
:disabled="saving"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
<small v-if="touched && !form.nome_completo" class="text-red-500">
|
||||
Informe o nome.
|
||||
</small>
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<!-- Nome -->
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-user" />
|
||||
<InputText
|
||||
id="cr-nome"
|
||||
v-model.trim="form.nome_completo"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="cr-nome">Nome completo *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium" for="cr-email">E-mail *</label>
|
||||
<InputText
|
||||
id="cr-email"
|
||||
v-model.trim="form.email_principal"
|
||||
:disabled="saving"
|
||||
inputmode="email"
|
||||
autocomplete="off"
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
<small v-if="touched && !form.email_principal" class="text-red-500">
|
||||
Informe o e-mail.
|
||||
</small>
|
||||
<small v-if="touched && form.email_principal && !isValidEmail(form.email_principal)" class="text-red-500">
|
||||
E-mail inválido.
|
||||
</small>
|
||||
<!-- E-mail -->
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-envelope" />
|
||||
<InputText
|
||||
id="cr-email"
|
||||
v-model.trim="form.email_principal"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving"
|
||||
inputmode="email"
|
||||
autocomplete="off"
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="cr-email">E-mail *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium" for="cr-telefone">Telefone *</label>
|
||||
<InputMask
|
||||
id="cr-telefone"
|
||||
v-model="form.telefone"
|
||||
:disabled="saving"
|
||||
mask="(99) 99999-9999"
|
||||
placeholder="(16) 99999-9999"
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
<small v-if="touched && !form.telefone" class="text-red-500">
|
||||
Informe o telefone.
|
||||
</small>
|
||||
<small v-else-if="touched && form.telefone && !isValidPhone(form.telefone)" class="text-red-500">
|
||||
Telefone inválido.
|
||||
</small>
|
||||
<!-- Telefone -->
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-phone" />
|
||||
<InputMask
|
||||
id="cr-telefone"
|
||||
v-model="form.telefone"
|
||||
mask="(99) 99999-9999"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving"
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="cr-telefone">Telefone *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-surface-500">
|
||||
Dica: “Gerar usuário” preenche automaticamente com dados fictícios.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,12 +122,49 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoleGuard } from '@/composables/useRoleGuard'
|
||||
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import InputMask from 'primevue/inputmask'
|
||||
import Message from 'primevue/message'
|
||||
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
const { canSee } = useRoleGuard()
|
||||
|
||||
/**
|
||||
* Lista “curada” de pensadores influentes na psicanálise e seu entorno.
|
||||
* Usada para geração rápida de dados fictícios.
|
||||
*/
|
||||
const PSICANALISE_PENSADORES = Object.freeze([
|
||||
{ nome: 'Sigmund Freud' },
|
||||
{ nome: 'Jacques Lacan' },
|
||||
{ nome: 'Melanie Klein' },
|
||||
{ nome: 'Donald Winnicott' },
|
||||
{ nome: 'Wilfred Bion' },
|
||||
{ nome: 'Sándor Ferenczi' },
|
||||
{ nome: 'Anna Freud' },
|
||||
{ nome: 'Karl Abraham' },
|
||||
{ nome: 'Otto Rank' },
|
||||
{ nome: 'Karen Horney' },
|
||||
{ nome: 'Erich Fromm' },
|
||||
{ nome: 'Michael Balint' },
|
||||
{ nome: 'Ronald Fairbairn' },
|
||||
{ nome: 'John Bowlby' },
|
||||
{ nome: 'André Green' },
|
||||
{ nome: 'Jean Laplanche' },
|
||||
{ nome: 'Christopher Bollas' },
|
||||
{ nome: 'Thomas Ogden' },
|
||||
{ nome: 'Jessica Benjamin' },
|
||||
{ nome: 'Joyce McDougall' },
|
||||
{ nome: 'Peter Fonagy' },
|
||||
{ nome: 'Carl Gustav Jung' },
|
||||
{ nome: 'Alfred Adler' }
|
||||
])
|
||||
|
||||
// domínio seguro para dados fictícios
|
||||
const AUTO_EMAIL_DOMAIN = 'example.com'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
@@ -114,7 +178,10 @@ const props = defineProps({
|
||||
emailField: { type: String, default: 'email_principal' },
|
||||
phoneField: { type: String, default: 'telefone' },
|
||||
|
||||
// ✅ NÃO coloque status aqui por padrão (evita violar patients_status_check)
|
||||
// multi-tenant (defaults do seu schema)
|
||||
tenantField: { type: String, default: 'tenant_id' },
|
||||
responsibleMemberField: { type: String, default: 'responsible_member_id' },
|
||||
|
||||
extraPayload: { type: Object, default: () => ({}) },
|
||||
|
||||
closeOnCreated: { type: Boolean, default: true },
|
||||
@@ -184,6 +251,78 @@ async function getOwnerId () {
|
||||
return user.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Pega tenant_id + member_id do usuário logado.
|
||||
*/
|
||||
async function resolveTenantContextOrFail () {
|
||||
const { data: authData, error: authError } = await supabase.auth.getUser()
|
||||
if (authError) throw authError
|
||||
const uid = authData?.user?.id
|
||||
if (!uid) throw new Error('Sessão inválida.')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('id, tenant_id')
|
||||
.eq('user_id', uid)
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
if (!data?.tenant_id || !data?.id) throw new Error('Responsible member not found')
|
||||
|
||||
return { tenantId: data.tenant_id, memberId: data.id }
|
||||
}
|
||||
|
||||
/* ----------------------------
|
||||
* Gerador (nome/email/telefone)
|
||||
* ---------------------------- */
|
||||
function randInt (min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
function pick (arr) {
|
||||
return arr[randInt(0, arr.length - 1)]
|
||||
}
|
||||
function slugify (s) {
|
||||
return String(s || '')
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '.')
|
||||
.replace(/(^\.)|(\.$)/g, '')
|
||||
}
|
||||
function randomPhoneBRMasked () {
|
||||
const ddd = randInt(11, 99)
|
||||
const a = randInt(10000, 99999)
|
||||
const b = randInt(1000, 9999)
|
||||
return `(${ddd}) ${a}-${b}`
|
||||
}
|
||||
|
||||
function generateUser () {
|
||||
if (saving.value) return
|
||||
|
||||
const p = pick(PSICANALISE_PENSADORES)
|
||||
const nome = p?.nome || 'Paciente'
|
||||
|
||||
const base = slugify(nome) || 'paciente'
|
||||
const suffix = randInt(10, 999)
|
||||
const email = `${base}.${suffix}@${AUTO_EMAIL_DOMAIN}`
|
||||
|
||||
form.nome_completo = nome
|
||||
form.email_principal = email
|
||||
form.telefone = randomPhoneBRMasked()
|
||||
|
||||
touched.value = true
|
||||
errorMsg.value = ''
|
||||
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Gerar usuário',
|
||||
detail: 'Dados fictícios preenchidos.',
|
||||
life: 2200
|
||||
})
|
||||
}
|
||||
|
||||
async function submit () {
|
||||
touched.value = true
|
||||
errorMsg.value = ''
|
||||
@@ -201,16 +340,21 @@ async function submit () {
|
||||
saving.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
const { tenantId, memberId } = await resolveTenantContextOrFail()
|
||||
|
||||
// extraPayload antes; tenant/responsible forçados depois (não podem ser sobrescritos sem querer)
|
||||
const payload = {
|
||||
...props.extraPayload,
|
||||
|
||||
[props.ownerField]: ownerId,
|
||||
[props.tenantField]: tenantId,
|
||||
[props.responsibleMemberField]: memberId,
|
||||
|
||||
[props.nameField]: nome,
|
||||
[props.emailField]: email.toLowerCase(),
|
||||
[props.phoneField]: normalizePhoneDigits(tel),
|
||||
...props.extraPayload
|
||||
[props.phoneField]: normalizePhoneDigits(tel)
|
||||
}
|
||||
|
||||
// remove undefined
|
||||
Object.keys(payload).forEach((k) => {
|
||||
if (payload[k] === undefined) delete payload[k]
|
||||
})
|
||||
@@ -248,4 +392,4 @@ async function submit () {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
@@ -3,7 +3,6 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import TabView from 'primevue/tabview'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import Tag from 'primevue/tag'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<!-- src/components/agenda/AgendaSlotsPorDiaCard.vue -->
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import TabView from 'primevue/tabview'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import InputSwitch from 'primevue/inputswitch'
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import { fetchSlotsRegras, upsertSlotRegra } from '@/services/agendaConfigService'
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<!-- 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 DatePicker from 'primevue/datepicker'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
const toast = useToast()
|
||||
@@ -32,6 +26,15 @@ function minToHHMM(min) {
|
||||
function newId() {
|
||||
return Math.random().toString(16).slice(2) + Date.now().toString(16)
|
||||
}
|
||||
function hhmmToDate(hhmm) {
|
||||
if (!isValidHHMM(hhmm)) return null
|
||||
const [h, m] = String(hhmm).split(':').map(Number)
|
||||
const d = new Date(); d.setHours(h, m, 0, 0); return d
|
||||
}
|
||||
function dateToHHMM(date) {
|
||||
if (!date || !(date instanceof Date)) return null
|
||||
return String(date.getHours()).padStart(2, '0') + ':' + String(date.getMinutes()).padStart(2, '0')
|
||||
}
|
||||
|
||||
const internal = ref([])
|
||||
|
||||
@@ -151,14 +154,22 @@ const presets = [
|
||||
]
|
||||
|
||||
const dlg = ref(false)
|
||||
const form = ref({ label: 'Pausa', inicio: '12:00', fim: '13:00' })
|
||||
const form = ref({ label: 'Pausa', inicio: null, fim: null })
|
||||
|
||||
const formInicioHHMM = computed(() => dateToHHMM(form.value.inicio))
|
||||
const formFimHHMM = computed(() => dateToHHMM(form.value.fim))
|
||||
const formValid = computed(() =>
|
||||
isValidHHMM(formInicioHHMM.value) &&
|
||||
isValidHHMM(formFimHHMM.value) &&
|
||||
formFimHHMM.value > formInicioHHMM.value
|
||||
)
|
||||
|
||||
function openCustom() {
|
||||
form.value = { label: 'Pausa', inicio: '12:00', fim: '13:00' }
|
||||
form.value = { label: 'Pausa', inicio: hhmmToDate('12:00'), fim: hhmmToDate('13:00') }
|
||||
dlg.value = true
|
||||
}
|
||||
function saveCustom() {
|
||||
addPauseSmart(form.value)
|
||||
addPauseSmart({ label: form.value.label, inicio: formInicioHHMM.value, fim: formFimHHMM.value })
|
||||
dlg.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -200,46 +211,44 @@ function saveCustom() {
|
||||
</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>
|
||||
<Dialog v-model:visible="dlg" modal :draggable="false" header="Adicionar pausa" :style="{ width: '420px' }">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)] mb-1 block">Nome</label>
|
||||
<InputText v-model="form.label" class="w-full" placeholder="Ex.: Almoço" />
|
||||
</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 class="flex gap-3">
|
||||
<div class="flex-1 flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Início</label>
|
||||
<DatePicker v-model="form.inicio" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
|
||||
<template #inputicon="slotProps">
|
||||
<i class="pi pi-clock" @click="slotProps.clickCallback" />
|
||||
</template>
|
||||
</DatePicker>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Fim</label>
|
||||
<DatePicker v-model="form.fim" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
|
||||
<template #inputicon="slotProps">
|
||||
<i class="pi pi-clock" @click="slotProps.clickCallback" />
|
||||
</template>
|
||||
</DatePicker>
|
||||
</div>
|
||||
</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">
|
||||
<div v-if="formInicioHHMM && formFimHHMM && formFimHHMM <= formInicioHHMM" class="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 class="text-[var(--text-color-secondary)] text-xs">
|
||||
Se houver conflito com outra pausa, o sistema adiciona 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"
|
||||
/>
|
||||
<Button label="Adicionar" icon="pi pi-check" :disabled="!formValid" @click="saveCustom" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
81
src/composables/usePlatformPermissions.js
Normal file
81
src/composables/usePlatformPermissions.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// src/composables/usePlatformPermissions.js
|
||||
//
|
||||
// Permissões de PLATAFORMA (globais, não vinculadas a tenant).
|
||||
// Distinto do RBAC de tenant (useRoleGuard).
|
||||
//
|
||||
// O campo `platform_roles text[]` na tabela `profiles` do Supabase
|
||||
// armazena papéis globais da plataforma. Ex.: ['editor'].
|
||||
//
|
||||
// Quem pode atribuir: somente o saas_admin.
|
||||
// Quem pode ter: qualquer usuário autenticado (exceto paciente).
|
||||
//
|
||||
// PAPÉIS DE PLATAFORMA DISPONÍVEIS:
|
||||
// 'editor' — pode criar e gerenciar cursos/módulos da plataforma de microlearning.
|
||||
//
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { sessionUser } from '@/app/session'
|
||||
|
||||
// cache em módulo (evita queries repetidas por navegação)
|
||||
let _cachedUid = null
|
||||
let _cachedRoles = null
|
||||
|
||||
export function usePlatformPermissions () {
|
||||
const platformRoles = ref(_cachedRoles ?? [])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function load (force = false) {
|
||||
const uid = sessionUser.value?.id
|
||||
if (!uid) {
|
||||
platformRoles.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// cache por uid (invalida se usuário mudou)
|
||||
if (!force && _cachedUid === uid && _cachedRoles !== null) {
|
||||
platformRoles.value = _cachedRoles
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const { data, err } = await supabase
|
||||
.from('profiles')
|
||||
.select('platform_roles')
|
||||
.eq('id', uid)
|
||||
.single()
|
||||
|
||||
const roles = !err && Array.isArray(data?.platform_roles) ? data.platform_roles : []
|
||||
_cachedUid = uid
|
||||
_cachedRoles = roles
|
||||
platformRoles.value = roles
|
||||
} catch (e) {
|
||||
console.warn('[usePlatformPermissions] load falhou:', e)
|
||||
error.value = e
|
||||
platformRoles.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function invalidate () {
|
||||
_cachedUid = null
|
||||
_cachedRoles = null
|
||||
platformRoles.value = []
|
||||
}
|
||||
|
||||
const isEditor = computed(() => platformRoles.value.includes('editor'))
|
||||
|
||||
return {
|
||||
platformRoles,
|
||||
loading,
|
||||
error,
|
||||
isEditor,
|
||||
load,
|
||||
invalidate
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useTenantStore } from '@/stores/tenantStore'
|
||||
/**
|
||||
* ---------------------------------------------------------
|
||||
* useRoleGuard() — RBAC puro (somente PAPEL do tenant)
|
||||
* + testMODE (modo operacional de teste)
|
||||
* ---------------------------------------------------------
|
||||
*
|
||||
* Objetivo:
|
||||
@@ -11,82 +12,133 @@ import { useTenantStore } from '@/stores/tenantStore'
|
||||
* Aqui NÃO entra plano, módulos ou features pagas.
|
||||
*
|
||||
* Fonte da verdade do papel (tenant role):
|
||||
* - public.tenant_members.role → 'tenant_admin' | 'therapist' | 'patient'
|
||||
* - no frontend: tenantStore.membership.role (ou fallback tenantStore.activeRole)
|
||||
* - public.tenant_members.role
|
||||
* → 'saas' | 'tenant_admin' | 'therapist' | 'patient'
|
||||
*
|
||||
* O que este composable resolve:
|
||||
* - "Esse papel pode ver/usar este elemento?"
|
||||
* Ex:
|
||||
* - paciente não vê botão Configurações
|
||||
* - therapist e tenant_admin veem
|
||||
* ---------------------------------------------------------
|
||||
* testMODE — INTERRUPTOR OPERACIONAL
|
||||
* ---------------------------------------------------------
|
||||
*
|
||||
* O que ele NÃO resolve (de propósito):
|
||||
* - liberar feature por plano (Free/Pro)
|
||||
* - limitar módulos / recursos contratados
|
||||
* testMODE NÃO é regra de negócio.
|
||||
* Ele serve para:
|
||||
* - testar módulos antes de liberar no plano
|
||||
* - visualizar telas ainda não contratadas
|
||||
* - validar UI sem alterar regras de plano
|
||||
*
|
||||
* Para controle por plano, use o entStore:
|
||||
* - entStore.can('feature_key')
|
||||
* COMPORTAMENTO:
|
||||
*
|
||||
* Padrão recomendado (RBAC + Plano):
|
||||
* Quando algo depende do PLANO e do PAPEL, combine no template:
|
||||
* - TEST_MODE_ROLES = []
|
||||
* → testMODE OFF (ninguém vê)
|
||||
*
|
||||
* v-if="entStore.can('online_scheduling.manage') && canSee('settings.view')"
|
||||
* - TEST_MODE_ROLES = [ROLES.ADMIN]
|
||||
* → Apenas tenant_admin vê elementos marcados como testMODE
|
||||
*
|
||||
* - TEST_MODE_ROLES = [ROLES.ADMIN, ROLES.THERAPIST]
|
||||
* → Admin e Therapist veem
|
||||
*
|
||||
* - TEST_MODE_ROLES = [ROLES.SAAS, ROLES.ADMIN, ROLES.THERAPIST, ROLES.PATIENT]
|
||||
* → testMODE ON geral
|
||||
*
|
||||
* ---------------------------------------------------------
|
||||
* COMO IMPORTAR NO COMPONENTE
|
||||
* ---------------------------------------------------------
|
||||
*
|
||||
* 1) Importação padrão:
|
||||
*
|
||||
* import { useRoleGuard } from '@/composables/useRoleGuard'
|
||||
*
|
||||
* const { canSee, canSeeOrTest } = useRoleGuard()
|
||||
*
|
||||
*
|
||||
* 2) Se for usar apenas testMODE:
|
||||
*
|
||||
* import { useRoleGuard } from '@/composables/useRoleGuard'
|
||||
*
|
||||
* const { canSee } = useRoleGuard()
|
||||
*
|
||||
*
|
||||
* ---------------------------------------------------------
|
||||
* EXEMPLOS DE USO NO TEMPLATE
|
||||
* ---------------------------------------------------------
|
||||
*
|
||||
* 1) Elemento puramente experimental:
|
||||
*
|
||||
* <Button
|
||||
* v-if="canSee('testMODE')"
|
||||
* label="Botão em teste"
|
||||
* />
|
||||
*
|
||||
*
|
||||
* 2) Liberar visualização mesmo sem plano:
|
||||
*
|
||||
* <Button
|
||||
* v-if="entStore.can('online_scheduling.manage') || canSee('testMODE')"
|
||||
* label="Agendamento Online"
|
||||
* @click="..."
|
||||
* />
|
||||
*
|
||||
* Interpretação:
|
||||
* - Gate A (Plano): o tenant tem a feature liberada?
|
||||
* - Gate B (Papel): o usuário, pelo papel, pode ver/usar isso?
|
||||
* - Gate A → plano liberado
|
||||
* - Gate B → testMODE ativo
|
||||
*
|
||||
* Nota de segurança:
|
||||
* Isso controla UI/rotas (experiência). Segurança real deve existir no backend (RLS).
|
||||
*
|
||||
* 3) Usando helper:
|
||||
*
|
||||
* <Button
|
||||
* v-if="canSeeOrTest('settings.view')"
|
||||
* ...
|
||||
* />
|
||||
*
|
||||
* Isso significa:
|
||||
* - Usuário pode ver normalmente pela regra RBAC
|
||||
* OU
|
||||
* - testMODE está ativo
|
||||
*
|
||||
*
|
||||
* ---------------------------------------------------------
|
||||
* IMPORTANTE:
|
||||
* testMODE controla apenas UI.
|
||||
* Segurança real deve existir no backend (RLS).
|
||||
* ---------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function useRoleGuard () {
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// Roles confirmados no seu banco (tenant_members.role)
|
||||
const ROLES = Object.freeze({
|
||||
ADMIN: 'tenant_admin',
|
||||
SAAS: 'saas',
|
||||
ADMIN: 'clinic_admin',
|
||||
SUPERVISOR: 'supervisor',
|
||||
THERAPIST: 'therapist',
|
||||
PATIENT: 'patient'
|
||||
})
|
||||
|
||||
// Papel atual no tenant ativo
|
||||
const role = computed(() => tenantStore.membership?.role ?? tenantStore.activeRole ?? null)
|
||||
|
||||
// Opcional: útil se você quiser segurar render até carregar
|
||||
const isReady = computed(() => !!role.value)
|
||||
|
||||
// Helpers semânticos
|
||||
const isSaas = computed(() => role.value === ROLES.SAAS)
|
||||
const isTenantAdmin = computed(() => role.value === ROLES.ADMIN)
|
||||
const isSupervisor = computed(() => role.value === ROLES.SUPERVISOR)
|
||||
const isTherapist = computed(() => role.value === ROLES.THERAPIST)
|
||||
const isPatient = computed(() => role.value === ROLES.PATIENT)
|
||||
const isStaff = computed(() => [ROLES.ADMIN, ROLES.THERAPIST].includes(role.value))
|
||||
const isStaff = computed(() => [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST].includes(role.value))
|
||||
|
||||
const TEST_MODE_ROLES = Object.freeze([
|
||||
ROLES.ADMIN,
|
||||
ROLES.SUPERVISOR,
|
||||
ROLES.THERAPIST,
|
||||
ROLES.SAAS,
|
||||
ROLES.PATIENT
|
||||
])
|
||||
|
||||
// Matriz RBAC (somente por papel)
|
||||
// Dica: mantenha chaves no padrão "modulo.acao"
|
||||
const rbac = Object.freeze({
|
||||
// Botões/telas de configuração do tenant
|
||||
'settings.view': [ROLES.ADMIN, ROLES.THERAPIST],
|
||||
'testMODE': TEST_MODE_ROLES,
|
||||
|
||||
// Perfil/conta (normalmente todos)
|
||||
'profile.view': [ROLES.ADMIN, ROLES.THERAPIST, ROLES.PATIENT],
|
||||
|
||||
// Segurança (normalmente todos; ajuste se quiser restringir)
|
||||
'security.view': [ROLES.ADMIN, ROLES.THERAPIST, ROLES.PATIENT]
|
||||
|
||||
// Exemplos futuros:
|
||||
// 'agenda.view': [ROLES.ADMIN, ROLES.THERAPIST, ROLES.PATIENT],
|
||||
// 'agenda.manage': [ROLES.ADMIN, ROLES.THERAPIST],
|
||||
'settings.view': [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST],
|
||||
'profile.view': [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST, ROLES.PATIENT],
|
||||
'security.view': [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST, ROLES.PATIENT]
|
||||
})
|
||||
|
||||
/**
|
||||
* canSee(key)
|
||||
* Retorna true se o PAPEL atual estiver autorizado para a chave RBAC.
|
||||
*
|
||||
* Política segura:
|
||||
* - se não carregou role → false
|
||||
* - se não existe mapeamento pra key → false
|
||||
*/
|
||||
function canSee (key) {
|
||||
const r = role.value
|
||||
if (!r) return false
|
||||
@@ -97,19 +149,23 @@ export function useRoleGuard () {
|
||||
return allowed.includes(r)
|
||||
}
|
||||
|
||||
function canSeeOrTest (key) {
|
||||
return canSee(key) || canSee('testMODE')
|
||||
}
|
||||
|
||||
return {
|
||||
// estado
|
||||
role,
|
||||
isReady,
|
||||
|
||||
// constantes & helpers
|
||||
ROLES,
|
||||
isSaas,
|
||||
isTenantAdmin,
|
||||
isSupervisor,
|
||||
isTherapist,
|
||||
isPatient,
|
||||
isStaff,
|
||||
|
||||
// API
|
||||
canSee
|
||||
canSee,
|
||||
canSeeOrTest
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export function useUserSettingsPersistence() {
|
||||
primary_color: patch.primary_color ?? layoutConfig.primary ?? 'noir',
|
||||
surface_color: patch.surface_color ?? layoutConfig.surface ?? 'slate',
|
||||
menu_mode: patch.menu_mode ?? layoutConfig.menuMode ?? 'static',
|
||||
layout_variant: patch.layout_variant ?? layoutConfig.variant ?? 'classic',
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
@@ -83,6 +84,7 @@ export function useUserSettingsPersistence() {
|
||||
primary_color: pendingPatch.value.primary_color ?? layoutConfig.primary,
|
||||
surface_color: pendingPatch.value.surface_color ?? layoutConfig.surface,
|
||||
menu_mode: pendingPatch.value.menu_mode ?? layoutConfig.menuMode,
|
||||
layout_variant: pendingPatch.value.layout_variant ?? layoutConfig.variant ?? 'classic',
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,15 @@
|
||||
<!-- src/features/agenda/components/AgendaClinicMosaic.vue -->
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br'
|
||||
|
||||
const props = defineProps({
|
||||
view: { type: String, default: 'day' }, // 'day' | 'week'
|
||||
view: { type: String, default: 'day' }, // 'day' | 'week' | 'month'
|
||||
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
|
||||
timezone: { type: String, default: 'America/Sao_Paulo' },
|
||||
|
||||
@@ -22,33 +25,63 @@ const props = defineProps({
|
||||
|
||||
loading: { type: Boolean, default: false },
|
||||
|
||||
// controla quantas colunas "visíveis" por vez (resto vai por scroll horizontal)
|
||||
minColWidth: { type: Number, default: 360 }
|
||||
// largura mínima de cada coluna (terapeutas)
|
||||
minColWidth: { type: Number, default: 360 },
|
||||
|
||||
// ✅ coluna da clínica
|
||||
showClinicColumn: { type: Boolean, default: true },
|
||||
clinicId: { type: String, default: '' },
|
||||
clinicTitle: { type: String, default: 'Clínica' },
|
||||
clinicSubtitle: { type: String, default: 'Agenda da clínica' },
|
||||
|
||||
// subtitle terapeutas
|
||||
staffSubtitle: { type: String, default: 'Visão diária operacional' }
|
||||
})
|
||||
|
||||
// ✅ 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 emit = defineEmits([
|
||||
'rangeChange',
|
||||
'slotSelect',
|
||||
'eventClick',
|
||||
'eventDrop',
|
||||
'eventResize',
|
||||
// ✅ debug
|
||||
'debugColumn'
|
||||
])
|
||||
|
||||
const calendarRefs = ref([])
|
||||
|
||||
function setCalendarRef (el, idx) {
|
||||
if (!el) return
|
||||
calendarRefs.value[idx] = el
|
||||
}
|
||||
|
||||
const initialView = computed(() => (props.view === 'week' ? 'timeGridWeek' : 'timeGridDay'))
|
||||
const initialView = computed(() => {
|
||||
if (props.view === 'week') return 'timeGridWeek'
|
||||
if (props.view === 'month') return 'dayGridMonth'
|
||||
return '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))
|
||||
|
||||
// ✅ coluna fixa (clínica)
|
||||
const clinicColumn = computed(() => {
|
||||
if (!props.showClinicColumn) return null
|
||||
const id = String(props.clinicId || '').trim()
|
||||
if (!id) return null
|
||||
return { id, title: props.clinicTitle || 'Clínica', __kind: 'clinic' }
|
||||
})
|
||||
|
||||
const staffColumns = computed(() => {
|
||||
const base = Array.isArray(props.staff) ? props.staff : []
|
||||
return base
|
||||
.filter(s => s?.id)
|
||||
.map(s => ({ ...s, __kind: 'staff' }))
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -59,18 +92,24 @@ function forEachApi (fn) {
|
||||
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))
|
||||
function gotoDate (date) {
|
||||
if (!date) return
|
||||
const dt = (date instanceof Date) ? new Date(date) : new Date(date)
|
||||
dt.setHours(12, 0, 0, 0) // anti “voltar dia”
|
||||
forEachApi(api => api.gotoDate(dt))
|
||||
}
|
||||
|
||||
defineExpose({ goToday, prev, next, setView })
|
||||
function setView (v) {
|
||||
const target = v === 'week' ? 'timeGridWeek' : (v === 'month' ? 'dayGridMonth' : 'timeGridDay')
|
||||
forEachApi(api => api.changeView(target))
|
||||
}
|
||||
function setMode () {}
|
||||
|
||||
defineExpose({ goToday, prev, next, gotoDate, setView, setMode })
|
||||
|
||||
// Eventos por profissional (owner)
|
||||
function eventsFor (ownerId) {
|
||||
const list = props.events || []
|
||||
return list.filter(e => e?.extendedProps?.owner_id === ownerId)
|
||||
return list.filter(e => String(e?.extendedProps?.owner_id || '') === String(ownerId || ''))
|
||||
}
|
||||
|
||||
// ---- range sync ----
|
||||
@@ -82,35 +121,93 @@ function onDatesSet (arg) {
|
||||
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
|
||||
viewType: arg.view.type,
|
||||
currentDate: arg.view?.currentStart || arg.start
|
||||
})
|
||||
|
||||
// mantém todos os calendários na mesma data
|
||||
if (suppressSync) return
|
||||
suppressSync = true
|
||||
|
||||
const masterDate = arg.start
|
||||
const masterDate = arg.view?.currentStart || arg.start
|
||||
forEachApi((api) => {
|
||||
const cur = api.view?.currentStart
|
||||
if (!cur) return
|
||||
if (!cur || !masterDate) 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)
|
||||
})
|
||||
|
||||
// ---------- helpers UI ----------
|
||||
function colSubtitle (p) {
|
||||
return p?.__kind === 'clinic' ? props.clinicSubtitle : props.staffSubtitle
|
||||
}
|
||||
|
||||
// ✅ debug emitter (cabeçalho clicável)
|
||||
function emitDebug (col) {
|
||||
emit('debugColumn', {
|
||||
staffCol: col,
|
||||
staffUserId: col?.id || null,
|
||||
staffTitle: col?.title || null,
|
||||
kind: col?.__kind || null,
|
||||
at: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
function buildFcOptions (ownerId) {
|
||||
const base = {
|
||||
plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin],
|
||||
locale: ptBrLocale,
|
||||
timeZone: props.timezone,
|
||||
|
||||
headerToolbar: false,
|
||||
initialView: initialView.value,
|
||||
|
||||
nowIndicator: true,
|
||||
editable: true,
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
|
||||
slotDuration: props.slotDuration,
|
||||
slotMinTime: computedSlotMinTime.value,
|
||||
slotMaxTime: computedSlotMaxTime.value,
|
||||
|
||||
height: 'auto',
|
||||
expandRows: true,
|
||||
allDaySlot: false,
|
||||
|
||||
events: eventsFor(ownerId),
|
||||
datesSet: onDatesSet,
|
||||
|
||||
eventClick: (info) => emit('eventClick', info),
|
||||
eventDrop: (info) => emit('eventDrop', info),
|
||||
eventResize: (info) => emit('eventResize', info)
|
||||
}
|
||||
|
||||
base.select = (selection) => {
|
||||
emit('slotSelect', {
|
||||
ownerId,
|
||||
start: selection.start,
|
||||
end: selection.end,
|
||||
startStr: selection.startStr,
|
||||
endStr: selection.endStr,
|
||||
jsEvent: selection.jsEvent || null,
|
||||
viewType: selection.view?.type || initialView.value
|
||||
})
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -119,74 +216,105 @@ watch(() => props.view, async () => {
|
||||
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 class="mosaic-shell">
|
||||
<!-- Coluna fixa: Clínica -->
|
||||
<div v-if="clinicColumn" class="mosaic-fixed">
|
||||
<div class="mosaic-col">
|
||||
<div class="mosaic-col-head cursor-pointer" @click="emitDebug(clinicColumn)" title="Debug desta coluna">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold truncate">{{ clinicColumn.title }}</div>
|
||||
<div class="text-xs opacity-70 truncate">{{ colSubtitle(clinicColumn) }}</div>
|
||||
</div>
|
||||
<div class="text-xs opacity-70 whitespace-nowrap">
|
||||
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs opacity-70 whitespace-nowrap">
|
||||
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
|
||||
|
||||
<div class="p-2">
|
||||
<FullCalendar
|
||||
:ref="(el) => setCalendarRef(el, 0)"
|
||||
:options="buildFcOptions(clinicColumn.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<FullCalendar
|
||||
:ref="(el) => setCalendarRef(el, idx)"
|
||||
:options="{
|
||||
plugins: [timeGridPlugin, interactionPlugin],
|
||||
initialView: initialView,
|
||||
timeZone: timezone,
|
||||
<!-- Área rolável: Terapeutas -->
|
||||
<div class="mosaic-scroll">
|
||||
<div
|
||||
class="mosaic-grid"
|
||||
:style="{ gridAutoColumns: `minmax(${minColWidth}px, 1fr)` }"
|
||||
>
|
||||
<div
|
||||
v-for="(p, sIdx) in staffColumns"
|
||||
:key="p.id"
|
||||
class="mosaic-col"
|
||||
>
|
||||
<div class="mosaic-col-head cursor-pointer" @click="emitDebug(p)" title="Debug desta coluna">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold truncate">{{ p.title }}</div>
|
||||
<div class="text-xs opacity-70 truncate">{{ colSubtitle(p) }}</div>
|
||||
</div>
|
||||
<div class="text-xs opacity-70 whitespace-nowrap">
|
||||
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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 class="p-2">
|
||||
<FullCalendar
|
||||
:ref="(el) => setCalendarRef(el, (clinicColumn ? (sIdx + 1) : sIdx))"
|
||||
:options="buildFcOptions(p.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mosaic-shell{
|
||||
display:flex;
|
||||
gap:12px;
|
||||
padding: 8px;
|
||||
}
|
||||
@media (min-width: 768px){
|
||||
.mosaic-shell{ padding: 12px; }
|
||||
}
|
||||
|
||||
.mosaic-fixed{
|
||||
flex: 0 0 auto;
|
||||
width: 420px;
|
||||
min-width: 320px;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.mosaic-scroll{
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mosaic-grid{
|
||||
display:grid;
|
||||
grid-auto-flow: column;
|
||||
gap:12px;
|
||||
}
|
||||
|
||||
.mosaic-col{
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: color-mix(in_srgb, var(--surface-card), transparent 12%);
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.mosaic-col-head{
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,5 @@
|
||||
<!-- src/features/agenda/components/AgendaRightPanel.vue -->
|
||||
<script setup>
|
||||
import Card from 'primevue/card'
|
||||
import Divider from 'primevue/divider'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: 'Painel' },
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<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' },
|
||||
|
||||
523
src/features/agenda/components/DeterminedCommitmentDialog.vue
Normal file
523
src/features/agenda/components/DeterminedCommitmentDialog.vue
Normal file
@@ -0,0 +1,523 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!saving"
|
||||
:dismissableMask="!saving"
|
||||
class="dc-dialog w-[96vw] max-w-2xl"
|
||||
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' } }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex w-full items-center justify-between gap-3 px-1">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<!-- Dot de cor -->
|
||||
<span
|
||||
class="dc-header-dot shrink-0"
|
||||
:style="{ backgroundColor: previewBgColor }"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">
|
||||
{{ form.name || (mode === 'create' ? 'Novo compromisso' : 'Editar compromisso') }}
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
{{ mode === 'create' ? 'Novo tipo de compromisso' : 'Editando tipo de compromisso' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
v-if="mode === 'edit' && canDelete"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
:disabled="saving"
|
||||
v-tooltip.top="'Excluir'"
|
||||
@click="emitDelete"
|
||||
/>
|
||||
<Button
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:disabled="saving"
|
||||
@click="close"
|
||||
/>
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
class="rounded-full"
|
||||
:loading="saving"
|
||||
:disabled="!canSubmit"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Banner de preview -->
|
||||
<div
|
||||
class="dc-banner"
|
||||
:style="{ backgroundColor: previewBgColor }"
|
||||
>
|
||||
<span
|
||||
class="dc-banner__pill"
|
||||
:style="{ color: form.text_color || '#ffffff' }"
|
||||
>
|
||||
{{ form.name || 'Nome do compromisso' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Corpo -->
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
|
||||
<!-- Nome + Ativo -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-tag" />
|
||||
<InputText
|
||||
id="cr-nome"
|
||||
v-model="form.name"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving || isEditLocked"
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="cr-nome">Nome *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 pt-1">
|
||||
<span class="text-sm font-medium">Ativo</span>
|
||||
<InputSwitch v-model="form.active" :disabled="saving || isActiveLocked" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção Cor -->
|
||||
<div class="dc-section">
|
||||
<div class="dc-section__label">Cor</div>
|
||||
|
||||
<!-- Paleta predefinida -->
|
||||
<div class="dc-palette">
|
||||
<button
|
||||
v-for="p in presetColors"
|
||||
:key="p.bg"
|
||||
class="dc-swatch"
|
||||
:class="{ 'dc-swatch--active': form.bg_color === p.bg }"
|
||||
:style="{ backgroundColor: `#${p.bg}` }"
|
||||
:title="p.name"
|
||||
:disabled="saving || isEditLocked"
|
||||
@click="applyPreset(p)"
|
||||
>
|
||||
<i v-if="form.bg_color === p.bg" class="pi pi-check dc-swatch__check" />
|
||||
</button>
|
||||
|
||||
<!-- Custom ColorPicker -->
|
||||
<div class="dc-swatch dc-swatch--custom" title="Cor personalizada">
|
||||
<ColorPicker
|
||||
v-model="form.bg_color"
|
||||
format="hex"
|
||||
:disabled="saving || isEditLocked"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Texto -->
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<span class="text-xs font-medium opacity-60 uppercase tracking-wide">Texto</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="dc-text-opt"
|
||||
:class="{ 'dc-text-opt--active': form.text_color === '#ffffff' }"
|
||||
:disabled="saving || isEditLocked"
|
||||
@click="form.text_color = '#ffffff'"
|
||||
>
|
||||
<span class="dc-text-opt__dot" style="background:#ffffff; border: 1px solid #ccc;" />
|
||||
Branco
|
||||
</button>
|
||||
<button
|
||||
class="dc-text-opt"
|
||||
:class="{ 'dc-text-opt--active': form.text_color === '#000000' }"
|
||||
:disabled="saving || isEditLocked"
|
||||
@click="form.text_color = '#000000'"
|
||||
>
|
||||
<span class="dc-text-opt__dot" style="background:#000000;" />
|
||||
Preto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Descrição -->
|
||||
<FloatLabel variant="on">
|
||||
<Textarea
|
||||
id="cr-descricao"
|
||||
v-model="form.description"
|
||||
autoResize
|
||||
rows="2"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving || isEditLocked"
|
||||
/>
|
||||
<label for="cr-descricao">Descrição</label>
|
||||
</FloatLabel>
|
||||
|
||||
<!-- Campos adicionais -->
|
||||
<div class="dc-section">
|
||||
<div class="flex items-center justify-between gap-2 mb-3">
|
||||
<div class="dc-section__label mb-0">Campos adicionais</div>
|
||||
<Button
|
||||
label="Adicionar campo"
|
||||
icon="pi pi-plus"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
:disabled="saving || isFieldsLocked"
|
||||
@click="addField"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!form.fields.length" class="py-3 text-center text-sm opacity-50">
|
||||
Nenhum campo adicional configurado.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="(f, idx) in form.fields"
|
||||
:key="f.key"
|
||||
class="grid grid-cols-1 gap-2 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-0)] p-3 md:grid-cols-12"
|
||||
>
|
||||
<div class="md:col-span-6">
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
:id="`cr-field-label-${idx}`"
|
||||
v-model="f.label"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving || isFieldsLocked"
|
||||
@keydown.enter.prevent="submit"
|
||||
@blur="syncKey(f)"
|
||||
/>
|
||||
<label :for="`cr-field-label-${idx}`">Nome do campo</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-4">
|
||||
<FloatLabel variant="on">
|
||||
<Dropdown
|
||||
:id="`cr-field-type-${idx}`"
|
||||
v-model="f.type"
|
||||
:options="fieldTypeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving || isFieldsLocked"
|
||||
/>
|
||||
<label :for="`cr-field-type-${idx}`">Tipo</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 flex items-center justify-end">
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
:disabled="saving || isFieldsLocked"
|
||||
@click="removeField(idx)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-12 text-xs opacity-40 font-mono">
|
||||
key: {{ f.key }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputSwitch from 'primevue/inputswitch'
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
mode: { type: String, default: 'create' }, // 'create' | 'edit'
|
||||
saving: { type: Boolean, default: false },
|
||||
commitment: { type: Object, default: null } // quando edit
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save', 'delete'])
|
||||
|
||||
const fieldTypeOptions = [
|
||||
{ label: 'Texto', value: 'text' },
|
||||
{ label: 'Texto longo', value: 'textarea' }
|
||||
]
|
||||
|
||||
const presetColors = [
|
||||
{ bg: '6366f1', text: '#ffffff', name: 'Índigo' },
|
||||
{ bg: '8b5cf6', text: '#ffffff', name: 'Violeta' },
|
||||
{ bg: 'ec4899', text: '#ffffff', name: 'Rosa' },
|
||||
{ bg: 'ef4444', text: '#ffffff', name: 'Vermelho' },
|
||||
{ bg: 'f97316', text: '#ffffff', name: 'Laranja' },
|
||||
{ bg: 'eab308', text: '#000000', name: 'Amarelo' },
|
||||
{ bg: '22c55e', text: '#ffffff', name: 'Verde' },
|
||||
{ bg: '14b8a6', text: '#ffffff', name: 'Teal' },
|
||||
{ bg: '3b82f6', text: '#ffffff', name: 'Azul' },
|
||||
{ bg: '06b6d4', text: '#ffffff', name: 'Ciano' },
|
||||
{ bg: '64748b', text: '#ffffff', name: 'Ardósia' },
|
||||
{ bg: '292524', text: '#ffffff', name: 'Escuro' },
|
||||
]
|
||||
|
||||
function applyPreset (p) {
|
||||
if (props.saving) return
|
||||
form.bg_color = p.bg
|
||||
form.text_color = p.text
|
||||
}
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
native: false,
|
||||
locked: false,
|
||||
active: true,
|
||||
bg_color: '6366f1',
|
||||
text_color: '#ffffff',
|
||||
fields: []
|
||||
})
|
||||
|
||||
const previewBgColor = computed(() => {
|
||||
if (!form.bg_color) return '#6366f1'
|
||||
return form.bg_color.startsWith('#') ? form.bg_color : `#${form.bg_color}`
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
hydrate()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.commitment,
|
||||
() => {
|
||||
if (!props.modelValue) return
|
||||
hydrate()
|
||||
}
|
||||
)
|
||||
|
||||
function hydrate () {
|
||||
const c = props.commitment
|
||||
if (props.mode === 'edit' && c) {
|
||||
form.id = c.id
|
||||
form.name = c.name || ''
|
||||
form.description = c.description || ''
|
||||
form.native = !!c.native
|
||||
form.locked = !!c.locked
|
||||
form.active = !!c.active
|
||||
form.bg_color = c.bg_color || '6366f1'
|
||||
form.text_color = c.text_color || '#ffffff'
|
||||
form.fields = Array.isArray(c.fields) ? JSON.parse(JSON.stringify(c.fields)) : []
|
||||
} else {
|
||||
form.id = null
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.native = false
|
||||
form.locked = false
|
||||
form.active = true
|
||||
form.bg_color = '6366f1'
|
||||
form.text_color = '#ffffff'
|
||||
form.fields = []
|
||||
}
|
||||
}
|
||||
|
||||
const isActiveLocked = computed(() => !!form.locked) // nativo+locked → sempre ativo, nunca pode desativar
|
||||
const isEditLocked = computed(() => false) // edição sempre permitida
|
||||
const isFieldsLocked = computed(() => false) // campos sempre editáveis
|
||||
const canDelete = computed(() => !form.native)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (props.saving) return false
|
||||
if (!String(form.name || '').trim()) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function close () {
|
||||
if (props.saving) return
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function submit () {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
const payload = {
|
||||
id: form.id,
|
||||
name: String(form.name || '').trim(),
|
||||
description: String(form.description || '').trim(),
|
||||
active: form.locked ? true : !!form.active,
|
||||
bg_color: form.bg_color || null,
|
||||
text_color: form.text_color || null,
|
||||
fields: (form.fields || []).map(f => ({
|
||||
key: f.key,
|
||||
label: String(f.label || '').trim() || 'Campo',
|
||||
type: f.type === 'textarea' ? 'textarea' : 'text',
|
||||
required: !!f.required
|
||||
}))
|
||||
}
|
||||
|
||||
emit('save', payload)
|
||||
}
|
||||
|
||||
function emitDelete () {
|
||||
if (props.saving) return
|
||||
emit('delete', { id: form.id })
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function addField () {
|
||||
const base = `campo-${form.fields.length + 1}`
|
||||
form.fields.push({
|
||||
key: makeKey(base),
|
||||
label: 'Observação',
|
||||
type: 'textarea',
|
||||
required: false
|
||||
})
|
||||
}
|
||||
|
||||
function removeField (idx) {
|
||||
form.fields.splice(idx, 1)
|
||||
}
|
||||
|
||||
function syncKey (field) {
|
||||
// se o user renomear, a key acompanha (sem quebrar: simples por enquanto)
|
||||
const next = makeKey(field.label)
|
||||
field.key = next
|
||||
}
|
||||
|
||||
function makeKey (label) {
|
||||
|
||||
const k = String(label || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/(^_|_$)/g, '') || `field_${Math.random().toString(16).slice(2, 8)}`
|
||||
return k
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Header ─────────────────────────────── */
|
||||
.dc-header-dot {
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
box-shadow: 0 0 0 3px rgba(0,0,0,0.08);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* ── Banner de preview ───────────────────── */
|
||||
.dc-banner {
|
||||
height: 72px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background-color 0.25s ease;
|
||||
}
|
||||
.dc-banner__pill {
|
||||
font-size: 1rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
padding: 0.35rem 1.1rem;
|
||||
background: rgba(0,0,0,0.15);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(4px);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────── */
|
||||
.dc-section {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--surface-card);
|
||||
padding: 1rem;
|
||||
}
|
||||
.dc-section__label {
|
||||
font-size: 0.7rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
opacity: 0.45; margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Paleta ──────────────────────────────── */
|
||||
.dc-palette {
|
||||
display: flex; flex-wrap: wrap; gap: 0.45rem;
|
||||
}
|
||||
.dc-swatch {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
display: grid; place-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
|
||||
position: relative;
|
||||
}
|
||||
.dc-swatch:hover:not(:disabled) {
|
||||
transform: scale(1.18);
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
|
||||
}
|
||||
.dc-swatch--active {
|
||||
border-color: var(--surface-0, #fff);
|
||||
box-shadow: 0 0 0 2px var(--text-color);
|
||||
}
|
||||
.dc-swatch__check {
|
||||
font-size: 0.6rem; color: #fff; font-weight: 900;
|
||||
}
|
||||
.dc-swatch--custom {
|
||||
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
|
||||
overflow: hidden;
|
||||
}
|
||||
.dc-swatch--custom :deep(.p-colorpicker-preview) {
|
||||
width: 100%; height: 100%;
|
||||
border: none; border-radius: 50%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ── Texto toggle ────────────────────────── */
|
||||
.dc-text-opt {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--surface-border);
|
||||
font-size: 0.8rem; font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
background: transparent;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.dc-text-opt:hover:not(:disabled) { background: var(--surface-hover); }
|
||||
.dc-text-opt--active {
|
||||
background: var(--surface-section, var(--surface-100));
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
.dc-text-opt__dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; display: inline-block;
|
||||
}
|
||||
</style>
|
||||
@@ -2,13 +2,6 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: 'Agenda' },
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
const props = defineProps({
|
||||
stats: { type: Object, default: () => ({}) }
|
||||
|
||||
75
src/features/agenda/composables/useAgendaClinicEvents.js
Normal file
75
src/features/agenda/composables/useAgendaClinicEvents.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// src/features/agenda/composables/useAgendaClinicEvents.js
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
listClinicEvents,
|
||||
createClinicAgendaEvento,
|
||||
updateClinicAgendaEvento,
|
||||
deleteClinicAgendaEvento
|
||||
} from '@/features/agenda/services/agendaClinicRepository'
|
||||
|
||||
export function useAgendaClinicEvents () {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rows = ref([])
|
||||
|
||||
async function loadClinicRange ({ tenantId, ownerIds, startISO, endISO }) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
rows.value = await listClinicEvents({ tenantId, ownerIds, startISO, endISO })
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar eventos.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createClinic (payload, { tenantId } = {}) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
return await createClinicAgendaEvento(payload, { tenantId })
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao criar evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateClinic (id, patch, { tenantId } = {}) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
return await updateClinicAgendaEvento(id, patch, { tenantId })
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao atualizar evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeClinic (id, { tenantId } = {}) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
return await deleteClinicAgendaEvento(id, { tenantId })
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao remover evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
rows,
|
||||
loadClinicRange,
|
||||
createClinic,
|
||||
updateClinic,
|
||||
removeClinic
|
||||
}
|
||||
}
|
||||
45
src/features/agenda/composables/useDeterminedCommitments.js
Normal file
45
src/features/agenda/composables/useDeterminedCommitments.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
export function useDeterminedCommitments (tenantIdRef) {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rows = ref([])
|
||||
|
||||
const tenantId = computed(() => {
|
||||
const v = tenantIdRef?.value ?? tenantIdRef
|
||||
return v ? String(v) : ''
|
||||
})
|
||||
|
||||
async function load () {
|
||||
try {
|
||||
if (!tenantId.value) {
|
||||
rows.value = []
|
||||
error.value = ''
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('determined_commitments')
|
||||
.select('id,tenant_id,created_by,is_native,native_key,is_locked,active,name,description,bg_color,text_color,created_at,determined_commitment_fields(id,key,label,field_type,required,sort_order)')
|
||||
.eq('tenant_id', tenantId.value) // ✅ SOMENTE tenant corrente
|
||||
.eq('active', true)
|
||||
.order('is_native', { ascending: false })
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (err) throw err
|
||||
rows.value = data || []
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar compromissos determinísticos.'
|
||||
rows.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, rows, load }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
753
src/features/agenda/pages/CompromissosDeterminados.vue
Normal file
753
src/features/agenda/pages/CompromissosDeterminados.vue
Normal file
@@ -0,0 +1,753 @@
|
||||
<!-- src/features/agenda/pages/CompromissosDeterminados.vue -->
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<!-- Sentinel para detecção de sticky -->
|
||||
<div ref="headerSentinelRef" class="cmpr-sentinel" />
|
||||
|
||||
<!-- Hero Header sticky -->
|
||||
<div ref="headerEl" class="cmpr-hero mx-3 md:mx-5 mb-4" :class="{ 'cmpr-hero--stuck': headerStuck }">
|
||||
<!-- Blobs decorativos -->
|
||||
<div class="cmpr-hero__blobs" aria-hidden="true">
|
||||
<div class="cmpr-hero__blob cmpr-hero__blob--1" />
|
||||
<div class="cmpr-hero__blob cmpr-hero__blob--2" />
|
||||
</div>
|
||||
|
||||
<!-- Linha 1: brand + controles -->
|
||||
<div class="cmpr-hero__row1">
|
||||
<div class="cmpr-hero__brand">
|
||||
<div class="cmpr-hero__icon">
|
||||
<i class="pi pi-list text-lg" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="cmpr-hero__title">Compromissos</div>
|
||||
<div class="cmpr-hero__sub">Configure tipos de compromissos e campos adicionais</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controles desktop (≥1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<Button label="Novo" icon="pi pi-plus" class="rounded-full" :disabled="loading" @click="openCreate()" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="fetchAll()" />
|
||||
</div>
|
||||
|
||||
<!-- Menu mobile (<1200px) -->
|
||||
<div class="flex xl:hidden items-center shrink-0">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divisor -->
|
||||
<Divider class="cmpr-hero__divider my-2" />
|
||||
|
||||
<!-- Linha 2: filtros + busca (oculta no mobile) -->
|
||||
<div class="cmpr-hero__row2">
|
||||
<SelectButton v-model="typeFilter" :options="typeOptions" optionLabel="label" optionValue="value" :disabled="loading" />
|
||||
<InputGroup class="w-72">
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filters.global.value" placeholder="Buscar compromisso" :disabled="loading" />
|
||||
<Button
|
||||
v-if="filters.global.value"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
title="Limpar busca"
|
||||
@click="clearSearch"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog de busca (mobile) -->
|
||||
<Dialog v-model:visible="searchDlgOpen" modal :draggable="false" header="Buscar compromisso" class="w-[94vw] max-w-sm">
|
||||
<div class="pt-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filters.global.value" placeholder="Nome ou descrição..." autofocus />
|
||||
<Button
|
||||
v-if="filters.global.value"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
title="Limpar"
|
||||
@click="filters.global.value = null"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchDlgOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Cards -->
|
||||
<div class="mb-4 px-3 md:px-5 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Card
|
||||
v-for="c in cardsCommitments"
|
||||
:key="c.id"
|
||||
class="rounded-3xl border border-[var(--surface-border)] shadow-sm"
|
||||
>
|
||||
<template #content>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
v-if="c.bg_color"
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
||||
:style="{ backgroundColor: `#${c.bg_color}`, color: c.text_color || '#ffffff' }"
|
||||
>{{ c.name }}</span>
|
||||
<span v-else class="truncate text-base font-semibold">{{ c.name }}</span>
|
||||
<Tag v-if="c.is_native" value="Nativo" severity="info" />
|
||||
</div>
|
||||
<div class="mt-1 line-clamp-2 text-sm opacity-70">
|
||||
{{ c.description || '—' }}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-sm">
|
||||
<span class="opacity-70">Tempo total:</span>
|
||||
<span class="ml-2 font-semibold">{{ formatMinutes(getTotalMinutes(c.id)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs opacity-70">Ativo</span>
|
||||
<InputSwitch
|
||||
v-model="c.active"
|
||||
:disabled="isActiveLocked(c) || saving"
|
||||
@change="onToggleActive(c)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
:disabled="isEditLocked(c) || saving"
|
||||
@click="openEdit(c)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
:disabled="isDeleteLocked(c) || saving"
|
||||
@click="confirmDelete(c)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<div class="mx-3 md:mx-5 mb-5 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<div class="text-base font-semibold">Lista de compromissos</div>
|
||||
<div class="text-sm opacity-60">
|
||||
{{ visibleCommitments.length }} itens
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="visibleCommitments"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
:paginator="true"
|
||||
:rows="10"
|
||||
responsiveLayout="scroll"
|
||||
class="p-datatable-sm"
|
||||
:filters="filters"
|
||||
filterDisplay="menu"
|
||||
:globalFilterFields="['name','description']"
|
||||
>
|
||||
|
||||
<Column field="name" header="Nome" sortable filter filterPlaceholder="Filtrar nome" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">{{ data.name }}</span>
|
||||
<Tag v-if="data.is_native" value="Nativo" severity="info" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="description" header="Descrição" sortable filter filterPlaceholder="Filtrar descrição" style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<span class="opacity-80">{{ data.description || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Tempo total" sortable style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatMinutes(getTotalMinutes(data.id)) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="active" header="Ativo" style="width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<InputSwitch
|
||||
v-model="data.active"
|
||||
:disabled="isActiveLocked(data) || saving"
|
||||
@change="onToggleActive(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ação" style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
:disabled="isEditLocked(data) || saving"
|
||||
@click="openEdit(data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
:disabled="isDeleteLocked(data) || saving"
|
||||
@click="confirmDelete(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum compromisso determinístico encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">
|
||||
Tente limpar filtros ou mudar o termo de busca.
|
||||
</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearSearch" />
|
||||
<Button icon="pi pi-plus" label="Cadastrar compromisso" @click="openCreate()" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<DeterminedCommitmentDialog
|
||||
v-model="dlgOpen"
|
||||
:mode="dlgMode"
|
||||
:saving="saving"
|
||||
:commitment="editing"
|
||||
@save="onSave"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import InputSwitch from 'primevue/inputswitch'
|
||||
import Menu from 'primevue/menu'
|
||||
|
||||
import DeterminedCommitmentDialog from '@/features/agenda/components/DeterminedCommitmentDialog.vue'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
// ── Mobile menu ───────────────────────────────────────────
|
||||
const mobileMenuRef = ref(null)
|
||||
const searchDlgOpen = ref(false)
|
||||
|
||||
const mobileMenuItems = computed(() => [
|
||||
{
|
||||
label: 'Novo compromisso',
|
||||
icon: 'pi pi-plus',
|
||||
command: () => openCreate()
|
||||
},
|
||||
{
|
||||
label: 'Buscar',
|
||||
icon: 'pi pi-search',
|
||||
command: () => { searchDlgOpen.value = true }
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Recarregar',
|
||||
icon: 'pi pi-refresh',
|
||||
command: () => fetchAll()
|
||||
}
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
|
||||
await tenantStore.loadSessionAndTenant()
|
||||
await fetchAll()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const filters = reactive({
|
||||
global: { value: null, matchMode: 'contains' },
|
||||
name: { value: null, matchMode: 'contains' },
|
||||
description: { value: null, matchMode: 'contains' }
|
||||
})
|
||||
|
||||
/**
|
||||
* Filtro por tipo (Todos / Nativos / Meus)
|
||||
* - aplica na tabela (via computed) e nos cards
|
||||
*/
|
||||
const typeFilter = ref('all')
|
||||
const typeOptions = [
|
||||
{ label: 'Todos', value: 'all' },
|
||||
{ label: 'Nativos', value: 'native' },
|
||||
{ label: 'Meus', value: 'custom' }
|
||||
]
|
||||
|
||||
/**
|
||||
* Modelo de compromisso (tipo determinístico):
|
||||
* - is_native: template do sistema
|
||||
* - is_locked: trava comportamento (ex: Sessão)
|
||||
* - fields: campos adicionais (dinâmicos)
|
||||
*/
|
||||
const commitments = ref([])
|
||||
|
||||
// Totais reais (minutos) agregados de commitment_time_logs
|
||||
const totalsByCommitmentId = ref({})
|
||||
|
||||
/**
|
||||
* Lista base para tabela:
|
||||
* - aplica filtro por tipo (Todos / Nativos / Meus)
|
||||
* - (global search do DataTable continua via :filters)
|
||||
*/
|
||||
const visibleCommitments = computed(() => {
|
||||
let list = commitments.value
|
||||
|
||||
if (typeFilter.value === 'native') list = list.filter(c => !!c.is_native)
|
||||
if (typeFilter.value === 'custom') list = list.filter(c => !c.is_native)
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
/**
|
||||
* Lista para cards:
|
||||
* - aplica o mesmo filtro de tipo
|
||||
* - + aplica busca global (para cards acompanharem a barra de busca)
|
||||
*/
|
||||
const cardsCommitments = computed(() => {
|
||||
let list = visibleCommitments.value
|
||||
|
||||
const q = String(filters.global?.value ?? '').trim().toLowerCase()
|
||||
if (q) {
|
||||
list = list.filter(c =>
|
||||
String(c.name || '').toLowerCase().includes(q) ||
|
||||
String(c.description || '').toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
function clearSearch () {
|
||||
filters.global.value = null
|
||||
}
|
||||
|
||||
const dlgOpen = ref(false)
|
||||
const dlgMode = ref('create') // 'create' | 'edit'
|
||||
const editing = ref(null)
|
||||
|
||||
function getTenantId () {
|
||||
// ✅ sem fallback (evita vazamento clinic↔therapist)
|
||||
return tenantStore.activeTenantId || null
|
||||
}
|
||||
|
||||
|
||||
async function fetchAll () {
|
||||
const tenantId = getTenantId()
|
||||
if (!tenantId) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Tenant inválido.', life: 3000 })
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 1) commitments
|
||||
const { data: cData, error: cErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('is_native', { ascending: false })
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (cErr) throw cErr
|
||||
|
||||
const ids = (cData || []).map(x => x.id)
|
||||
|
||||
// 2) fields
|
||||
let fieldsByCommitmentId = {}
|
||||
if (ids.length > 0) {
|
||||
const { data: fData, error: fErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.select('id, tenant_id, commitment_id, key, label, field_type, required, sort_order')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('commitment_id', ids)
|
||||
.order('sort_order', { ascending: true })
|
||||
|
||||
if (fErr) throw fErr
|
||||
|
||||
fieldsByCommitmentId = (fData || []).reduce((acc, row) => {
|
||||
const k = row.commitment_id
|
||||
if (!acc[k]) acc[k] = []
|
||||
acc[k].push({
|
||||
id: row.id,
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
type: row.field_type,
|
||||
required: !!row.required,
|
||||
sort_order: row.sort_order
|
||||
})
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
// 3) totals (logs)
|
||||
const { data: lData, error: lErr } = await supabase
|
||||
.from('commitment_time_logs')
|
||||
.select('commitment_id, minutes')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
if (lErr) throw lErr
|
||||
|
||||
const totals = {}
|
||||
for (const row of (lData || [])) {
|
||||
const cid = row.commitment_id
|
||||
const m = Number(row.minutes ?? 0) || 0
|
||||
totals[cid] = (totals[cid] || 0) + m
|
||||
}
|
||||
|
||||
totalsByCommitmentId.value = totals
|
||||
|
||||
// 4) merge
|
||||
commitments.value = (cData || []).map(c => ({
|
||||
...c,
|
||||
fields: fieldsByCommitmentId[c.id] || []
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar compromissos.', life: 4500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getTotalMinutes (commitmentId) {
|
||||
return Number(totalsByCommitmentId.value?.[commitmentId] ?? 0)
|
||||
}
|
||||
|
||||
function formatMinutes (minutes) {
|
||||
const m = Math.max(0, Number(minutes) || 0)
|
||||
const h = Math.floor(m / 60)
|
||||
const mm = m % 60
|
||||
if (h <= 0) return `${mm}m`
|
||||
return `${h}h ${String(mm).padStart(2, '0')}m`
|
||||
}
|
||||
|
||||
function isActiveLocked (c) {
|
||||
return !!c.is_locked
|
||||
}
|
||||
|
||||
function isDeleteLocked (c) {
|
||||
return !!c.is_native
|
||||
}
|
||||
|
||||
function isEditLocked (_c) {
|
||||
return false // edição sempre permitida; só o "ativo" fica travado
|
||||
}
|
||||
|
||||
function openCreate () {
|
||||
dlgMode.value = 'create'
|
||||
editing.value = null
|
||||
dlgOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit (c) {
|
||||
dlgMode.value = 'edit'
|
||||
editing.value = JSON.parse(JSON.stringify(c))
|
||||
dlgOpen.value = true
|
||||
}
|
||||
|
||||
async function onToggleActive (c) {
|
||||
if (isActiveLocked(c)) return
|
||||
const tenantId = getTenantId()
|
||||
if (!tenantId) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('determined_commitments')
|
||||
.update({ active: !!c.active })
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('id', c.id)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Atualizado',
|
||||
detail: `“${c.name}” agora está ${c.active ? 'ativo' : 'inativo'}.`,
|
||||
life: 2500
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
c.active = !c.active
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao atualizar.', life: 4500 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave (payload) {
|
||||
const tenantId = getTenantId()
|
||||
if (!tenantId) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// pega usuário atual (se quiser auditoria futura)
|
||||
await supabase.auth.getUser()
|
||||
|
||||
if (dlgMode.value === 'create') {
|
||||
const insertRow = {
|
||||
tenant_id: tenantId,
|
||||
is_native: false,
|
||||
native_key: null,
|
||||
is_locked: false,
|
||||
active: !!payload.active,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
bg_color: payload.bg_color || null,
|
||||
text_color: payload.text_color || null
|
||||
}
|
||||
|
||||
const { data: newC, error: cErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
.insert(insertRow)
|
||||
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
|
||||
.single()
|
||||
|
||||
if (cErr) throw cErr
|
||||
|
||||
const fields = Array.isArray(payload.fields) ? payload.fields : []
|
||||
if (fields.length > 0) {
|
||||
const rows = fields.map((f, idx) => ({
|
||||
tenant_id: tenantId,
|
||||
commitment_id: newC.id,
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
field_type: f.type,
|
||||
required: !!f.required,
|
||||
sort_order: Number(f.sort_order ?? ((idx + 1) * 10))
|
||||
}))
|
||||
|
||||
const { error: fErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.insert(rows)
|
||||
|
||||
if (fErr) throw fErr
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado com sucesso.', life: 2500 })
|
||||
dlgOpen.value = false
|
||||
await fetchAll()
|
||||
} else {
|
||||
const updateRow = {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
active: !!payload.active,
|
||||
bg_color: payload.bg_color || null,
|
||||
text_color: payload.text_color || null
|
||||
}
|
||||
|
||||
const { error: upErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
.update(updateRow)
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('id', payload.id)
|
||||
|
||||
if (upErr) throw upErr
|
||||
|
||||
const fields = Array.isArray(payload.fields) ? payload.fields : []
|
||||
|
||||
const { error: delErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.delete()
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('commitment_id', payload.id)
|
||||
|
||||
if (delErr) throw delErr
|
||||
|
||||
if (fields.length > 0) {
|
||||
const rows = fields.map((f, idx) => ({
|
||||
tenant_id: tenantId,
|
||||
commitment_id: payload.id,
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
field_type: f.type,
|
||||
required: !!f.required,
|
||||
sort_order: Number(f.sort_order ?? ((idx + 1) * 10))
|
||||
}))
|
||||
|
||||
const { error: insErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.insert(rows)
|
||||
|
||||
if (insErr) throw insErr
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Alterações salvas.', life: 2500 })
|
||||
dlgOpen.value = false
|
||||
await fetchAll()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
const msg = e?.message || ''
|
||||
const detail = (e?.code === '23505' || /duplicate key value/i.test(msg))
|
||||
? 'Já existe um compromisso com esse nome neste tenant. Escolha outro nome.'
|
||||
: (msg || 'Falha ao salvar compromisso.')
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail, life: 4500 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete (c) {
|
||||
if (isDeleteLocked(c)) return
|
||||
const ok = window.confirm(`Excluir “${c.name}”? Essa ação não pode ser desfeita.`)
|
||||
if (!ok) return
|
||||
onDelete(c)
|
||||
}
|
||||
|
||||
async function onDelete (c) {
|
||||
if (isDeleteLocked(c)) return
|
||||
const tenantId = getTenantId()
|
||||
if (!tenantId) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const { error: fErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.delete()
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('commitment_id', c.id)
|
||||
if (fErr) throw fErr
|
||||
|
||||
const { error: lErr } = await supabase
|
||||
.from('commitment_time_logs')
|
||||
.delete()
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('commitment_id', c.id)
|
||||
if (lErr) throw lErr
|
||||
|
||||
const { data: delRows, error: dErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
.delete()
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('id', c.id)
|
||||
.eq('is_native', false)
|
||||
.select('id')
|
||||
if (dErr) throw dErr
|
||||
|
||||
if (!delRows || delRows.length === 0) {
|
||||
throw new Error('DELETE bloqueado por RLS (0 linhas). Confirme policy dc_delete_custom_for_active_member.')
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 })
|
||||
dlgOpen.value = false
|
||||
await fetchAll()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir compromisso.', life: 4500 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Hero Header ─────────────────────────────────── */
|
||||
.cmpr-sentinel { height: 1px; }
|
||||
|
||||
.cmpr-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.cmpr-hero--stuck {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
/* Blobs */
|
||||
.cmpr-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.cmpr-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.cmpr-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
|
||||
.cmpr-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
|
||||
|
||||
/* Linha 1 */
|
||||
.cmpr-hero__row1 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.cmpr-hero__brand {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.cmpr-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.cmpr-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.cmpr-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
|
||||
/* Linha 2 (oculta no mobile) */
|
||||
.cmpr-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.cmpr-hero__divider,
|
||||
.cmpr-hero__row2 { display: none; }
|
||||
}
|
||||
</style>
|
||||
131
src/features/agenda/services/agendaClinicRepository.js
Normal file
131
src/features/agenda/services/agendaClinicRepository.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// src/features/agenda/services/agendaClinicRepository.js
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
function assertValidTenantId (tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de carregar a agenda.')
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidIsoRange (startISO, endISO) {
|
||||
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).')
|
||||
}
|
||||
|
||||
function sanitizeOwnerIds (ownerIds) {
|
||||
return (ownerIds || [])
|
||||
.filter(id => typeof id === 'string' && id && id !== 'null' && id !== 'undefined')
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista eventos para mosaico da clínica (admin/secretaria) dentro de um tenant específico.
|
||||
* IMPORTANTE: SEM tenant_id aqui vira vazamento multi-tenant.
|
||||
*/
|
||||
export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO } = {}) {
|
||||
assertValidTenantId(tenantId)
|
||||
if (!ownerIds?.length) return []
|
||||
assertValidIsoRange(startISO, endISO)
|
||||
|
||||
const safeOwnerIds = sanitizeOwnerIds(ownerIds)
|
||||
if (!safeOwnerIds.length) return []
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('owner_id', safeOwnerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
.lt('inicio_em', endISO)
|
||||
.order('inicio_em', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista profissionais/membros para montar colunas no mosaico.
|
||||
* Usando view "v_tenant_staff" (como você já tem).
|
||||
*/
|
||||
export async function listTenantStaff (tenantId) {
|
||||
assertValidTenantId(tenantId)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('v_tenant_staff')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Criação para a área da clínica (admin/secretária):
|
||||
* - exige tenantId explícito
|
||||
* - permite definir owner_id (terapeuta dono do compromisso)
|
||||
*
|
||||
* Segurança real deve ser garantida por RLS:
|
||||
* - clinic_admin/tenant_admin pode criar para qualquer owner dentro do tenant
|
||||
* - therapist não deve conseguir passar daqui (guard + RLS)
|
||||
*/
|
||||
export async function createClinicAgendaEvento (payload, { tenantId } = {}) {
|
||||
assertValidTenantId(tenantId)
|
||||
if (!payload) throw new Error('Payload vazio.')
|
||||
|
||||
const ownerId = payload.owner_id
|
||||
if (!ownerId || ownerId === 'null' || ownerId === 'undefined') {
|
||||
throw new Error('owner_id é obrigatório para criação pela clínica.')
|
||||
}
|
||||
|
||||
const insertPayload = {
|
||||
...payload,
|
||||
tenant_id: tenantId
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.insert(insertPayload)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualização segura para clínica:
|
||||
* - filtra por id + tenant_id (evita update cruzado)
|
||||
* - permite editar owner_id (caso você mova evento para outro profissional)
|
||||
*/
|
||||
export async function updateClinicAgendaEvento (id, patch, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.')
|
||||
if (!patch) throw new Error('Patch vazio.')
|
||||
assertValidTenantId(tenantId)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.update(patch)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete seguro para clínica:
|
||||
* - filtra por id + tenant_id
|
||||
*/
|
||||
export async function deleteClinicAgendaEvento (id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.')
|
||||
assertValidTenantId(tenantId)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
if (error) throw error
|
||||
return true
|
||||
}
|
||||
@@ -1,20 +1,52 @@
|
||||
// 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
|
||||
return (rows || []).map((r) => {
|
||||
// 🔥 regra importante:
|
||||
// prioridade: owner_id
|
||||
// fallback: terapeuta_id
|
||||
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
|
||||
|
||||
const commitment = r.determined_commitments
|
||||
const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
|
||||
const txtColor = commitment?.text_color || undefined
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
|
||||
...(txtColor && { textColor: txtColor }),
|
||||
extendedProps: {
|
||||
// 🔥 ESSENCIAL PARA O MOSAICO
|
||||
owner_id: ownerId,
|
||||
|
||||
tipo: r.tipo ?? null,
|
||||
status: r.status ?? null,
|
||||
|
||||
paciente_id: r.paciente_id ?? null,
|
||||
paciente_nome: r.patients?.nome_completo ?? null,
|
||||
paciente_avatar: r.patients?.avatar_url ?? null,
|
||||
terapeuta_id: r.terapeuta_id ?? null,
|
||||
|
||||
observacoes: r.observacoes ?? null,
|
||||
|
||||
// ✅ usados na clínica p/ mascarar/privacidade
|
||||
visibility_scope: r.visibility_scope ?? null,
|
||||
masked: !!r.masked,
|
||||
|
||||
// ✅ compromisso determinístico
|
||||
determined_commitment_id: r.determined_commitment_id ?? null,
|
||||
commitment_bg_color: bgColor ?? null,
|
||||
commitment_text_color: txtColor ?? null,
|
||||
|
||||
// ✅ campos customizados
|
||||
titulo_custom: r.titulo_custom ?? null,
|
||||
extra_fields: r.extra_fields ?? null
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
export function buildNextSessions (rows, now = new Date()) {
|
||||
@@ -98,21 +130,52 @@ export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd)
|
||||
}
|
||||
|
||||
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
|
||||
return (rows || []).map((r) => {
|
||||
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
|
||||
|
||||
const commitment = r.determined_commitments
|
||||
const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
|
||||
const txtColor = commitment?.text_color || undefined
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
|
||||
// 🔥 resourceId também precisa ser confiável
|
||||
resourceId: ownerId,
|
||||
|
||||
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
|
||||
...(txtColor && { textColor: txtColor }),
|
||||
|
||||
extendedProps: {
|
||||
owner_id: ownerId,
|
||||
|
||||
tipo: r.tipo ?? null,
|
||||
status: r.status ?? null,
|
||||
|
||||
paciente_id: r.paciente_id ?? null,
|
||||
terapeuta_id: r.terapeuta_id ?? null,
|
||||
observacoes: r.observacoes ?? null,
|
||||
|
||||
visibility_scope: r.visibility_scope ?? null,
|
||||
masked: !!r.masked,
|
||||
|
||||
determined_commitment_id: r.determined_commitment_id ?? null,
|
||||
commitment_bg_color: bgColor ?? null,
|
||||
commitment_text_color: txtColor ?? null
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
// -------------------- helpers --------------------
|
||||
|
||||
function normalizeId (v) {
|
||||
if (v === null || v === undefined) return null
|
||||
const s = String(v).trim()
|
||||
return s ? s : null
|
||||
}
|
||||
|
||||
function normalizeWeekday (value) {
|
||||
|
||||
@@ -28,7 +28,9 @@ export async function getMyAgendaSettings () {
|
||||
.from('agenda_configuracoes')
|
||||
.select('*')
|
||||
.eq('owner_id', uid)
|
||||
.single()
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
@@ -49,7 +51,7 @@ export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantId
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.select('*, patients(id, nome_completo, avatar_url), determined_commitments!determined_commitment_id(id, bg_color, text_color)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('owner_id', uid)
|
||||
.gte('inicio_em', startISO)
|
||||
@@ -57,7 +59,27 @@ export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantId
|
||||
.order('inicio_em', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
const rows = data || []
|
||||
|
||||
// Eventos antigos têm paciente_id mas patient_id=null (sem FK) → join retorna null.
|
||||
// Fazemos um segundo fetch para esses casos e mesclamos.
|
||||
const orphanIds = [...new Set(
|
||||
rows.filter(r => r.paciente_id && !r.patients).map(r => r.paciente_id)
|
||||
)]
|
||||
if (orphanIds.length) {
|
||||
const { data: pts } = await supabase
|
||||
.from('patients')
|
||||
.select('id, nome_completo, avatar_url')
|
||||
.in('id', orphanIds)
|
||||
if (pts?.length) {
|
||||
const map = Object.fromEntries(pts.map(p => [p.id, p]))
|
||||
for (const r of rows) {
|
||||
if (r.paciente_id && !r.patients) r.patients = map[r.paciente_id] || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +99,7 @@ export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO }
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.select('*, determined_commitments!determined_commitment_id(id, bg_color, text_color)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('owner_id', safeOwnerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
|
||||
35
src/features/clinic/components/ModuleRow.vue
Normal file
35
src/features/clinic/components/ModuleRow.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ToggleButton from 'primevue/togglebutton'
|
||||
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
desc: { type: String, default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
enabled: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
defineEmits(['toggle'])
|
||||
</script>
|
||||
@@ -1,139 +1,112 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- HEADER CONCEITUAL -->
|
||||
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative p-5">
|
||||
<!-- blobs sutis -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-80">
|
||||
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-emerald-400/10 blur-3xl" />
|
||||
<div class="absolute top-10 -left-24 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
|
||||
<div class="absolute bottom-0 right-24 h-44 w-44 rounded-full bg-fuchsia-400/10 blur-3xl" />
|
||||
</div>
|
||||
<Toast />
|
||||
|
||||
<div class="relative flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<!-- título -->
|
||||
<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-users text-lg" />
|
||||
</div>
|
||||
<!-- Sentinel para detecção de sticky -->
|
||||
<div ref="headerSentinelRef" class="pat-sentinel" />
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-xl font-semibold leading-none">Pacientes</div>
|
||||
<Tag :value="`${kpis.total}`" severity="secondary" />
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">
|
||||
Lista de pacientes cadastrados. Filtre por status, tags e grupos.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hero Header sticky -->
|
||||
<div ref="headerEl" class="pat-hero mx-3 md:mx-5 mb-4" :class="{ 'pat-hero--stuck': headerStuck }">
|
||||
<!-- Blobs decorativos -->
|
||||
<div class="pat-hero__blobs" aria-hidden="true">
|
||||
<div class="pat-hero__blob pat-hero__blob--1" />
|
||||
<div class="pat-hero__blob pat-hero__blob--2" />
|
||||
<div class="pat-hero__blob pat-hero__blob--3" />
|
||||
</div>
|
||||
|
||||
<!-- KPIs como filtros -->
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="filters.status !== 'Todos'"
|
||||
severity="secondary"
|
||||
@click="setStatus('Todos')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-users" />
|
||||
Total: <b>{{ kpis.total }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
<!-- Linha 1: brand + controles -->
|
||||
<div class="pat-hero__row1">
|
||||
<div class="pat-hero__brand">
|
||||
<div class="pat-hero__icon">
|
||||
<i class="pi pi-users text-lg" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="pat-hero__title">Pacientes</div>
|
||||
<Tag :value="`${kpis.total}`" severity="secondary" />
|
||||
</div>
|
||||
<div class="pat-hero__sub">Lista de pacientes cadastrados. Filtre por status, tags e grupos.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="filters.status !== 'Ativo'"
|
||||
:severity="filters.status === 'Ativo' ? 'success' : 'secondary'"
|
||||
@click="setStatus('Ativo')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-user-plus" />
|
||||
Ativos: <b>{{ kpis.active }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
<!-- Controles desktop (≥1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchAll" />
|
||||
<SplitButton label="Cadastrar" icon="pi pi-user-plus" :model="createMenu" class="rounded-full" @click="goCreateFull" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="filters.status !== 'Inativo'"
|
||||
:severity="filters.status === 'Inativo' ? 'danger' : 'secondary'"
|
||||
@click="setStatus('Inativo')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-user-minus" />
|
||||
Inativos: <b>{{ kpis.inactive }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
<!-- Menu mobile (<1200px) -->
|
||||
<div class="flex xl:hidden items-center shrink-0">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => patMobileMenuRef.toggle(e)" />
|
||||
<Menu ref="patMobileMenuRef" :model="patMobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-2 text-xs text-color-secondary">
|
||||
<i class="pi pi-calendar" />
|
||||
Último atendimento: <b class="text-[var(--text-color)]">{{ prettyDate(kpis.latestLastAttended) }}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Divisor -->
|
||||
<Divider class="pat-hero__divider my-2" />
|
||||
|
||||
<!-- ações -->
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:items-center">
|
||||
<span class="p-input-icon-left w-full sm:w-[360px]">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
v-model="filters.search"
|
||||
class="w-full"
|
||||
placeholder="Buscar por nome, e-mail ou telefone…"
|
||||
@input="onFilterChangedDebounced"
|
||||
/>
|
||||
</IconField>
|
||||
</FloatLabel>
|
||||
</span>
|
||||
<!-- Linha 2: KPI filtros (oculta no mobile) -->
|
||||
<div class="pat-hero__row2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
class="!rounded-full"
|
||||
:outlined="filters.status !== 'Todos'"
|
||||
severity="secondary"
|
||||
@click="setStatus('Todos')"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<i class="pi pi-users text-xs" />
|
||||
Total: <b>{{ kpis.total }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
label="Atualizar"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="loading"
|
||||
@click="fetchAll"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
class="!rounded-full"
|
||||
:outlined="filters.status !== 'Ativo'"
|
||||
:severity="filters.status === 'Ativo' ? 'success' : 'secondary'"
|
||||
@click="setStatus('Ativo')"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<i class="pi pi-user-plus text-xs" />
|
||||
Ativos: <b>{{ kpis.active }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<SplitButton
|
||||
label="Cadastrar"
|
||||
icon="pi pi-user-plus"
|
||||
:model="createMenu"
|
||||
@click="goCreateFull"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
class="!rounded-full"
|
||||
:outlined="filters.status !== 'Inativo'"
|
||||
:severity="filters.status === 'Inativo' ? 'danger' : 'secondary'"
|
||||
@click="setStatus('Inativo')"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<i class="pi pi-user-minus text-xs" />
|
||||
Inativos: <b>{{ kpis.inactive }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<!-- chips de filtros ativos (micro-UX) -->
|
||||
<div v-if="hasActiveFilters" class="relative mt-4 flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-color-secondary">Filtros:</span>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1.5 text-xs text-color-secondary">
|
||||
<i class="pi pi-calendar" />
|
||||
Último atend.: <b class="text-[var(--text-color)]">{{ prettyDate(kpis.latestLastAttended) }}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tag v-if="filters.status && filters.status !== 'Todos'" :value="`Status: ${filters.status}`" severity="secondary" />
|
||||
|
||||
<Tag v-if="filters.groupId" value="Grupo selecionado" severity="secondary" />
|
||||
<Tag v-if="filters.tagId" value="Tag selecionada" severity="secondary" />
|
||||
<Tag v-if="filters.createdFrom" value="Data inicial" severity="secondary" />
|
||||
<Tag v-if="filters.createdTo" value="Data final" severity="secondary" />
|
||||
|
||||
<Button
|
||||
label="Limpar"
|
||||
icon="pi pi-filter-slash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
class="!rounded-full"
|
||||
@click="clearAllFilters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Chips de filtros ativos (fora do hero) -->
|
||||
<div v-if="hasActiveFilters" class="mx-3 md:mx-5 mb-3 flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-color-secondary">Filtros:</span>
|
||||
<Tag v-if="filters.status && filters.status !== 'Todos'" :value="`Status: ${filters.status}`" severity="secondary" />
|
||||
<Tag v-if="filters.groupId" value="Grupo selecionado" severity="secondary" />
|
||||
<Tag v-if="filters.tagId" value="Tag selecionada" severity="secondary" />
|
||||
<Tag v-if="filters.createdFrom" value="Data inicial" severity="secondary" />
|
||||
<Tag v-if="filters.createdTo" value="Data final" severity="secondary" />
|
||||
<Button label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined size="small" class="!rounded-full" @click="clearAllFilters" />
|
||||
</div>
|
||||
|
||||
|
||||
<!-- KPI Cards
|
||||
@@ -209,7 +182,7 @@
|
||||
</div> -->
|
||||
|
||||
<!-- TABS (placeholder para evoluir depois) -->
|
||||
<Tabs value="pacientes" class="mt-3">
|
||||
<Tabs value="pacientes" class="px-3 md:px-5 mb-5">
|
||||
<TabList>
|
||||
<Tab value="pacientes"><i class="pi pi-users mr-2" />Pacientes</Tab>
|
||||
<Tab value="espera"><i class="pi pi-hourglass mr-2" />Lista de espera</Tab>
|
||||
@@ -403,163 +376,170 @@
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<DataTable
|
||||
:value="filteredRows"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="15"
|
||||
:rowsPerPageOptions="[10, 15, 25, 50]"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
scrollable
|
||||
scrollHeight="flex"
|
||||
sortMode="single"
|
||||
:sortField="sort.field"
|
||||
:sortOrder="sort.order"
|
||||
@sort="onSort"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">
|
||||
Tente limpar filtros ou mudar o termo de busca.
|
||||
</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
|
||||
<Button icon="pi pi-user-plus" label="Cadastrar paciente" @click="goCreateFull" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<Column
|
||||
:key="'col-paciente'"
|
||||
field="nome_completo"
|
||||
header="Paciente"
|
||||
v-if="isColVisible('paciente')"
|
||||
sortable
|
||||
>
|
||||
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar
|
||||
v-if="data.avatar_url"
|
||||
:image="data.avatar_url"
|
||||
shape="square"
|
||||
size="large"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:label="initials(data.nome_completo)"
|
||||
shape="square"
|
||||
size="large"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ data.nome_completo }}</div>
|
||||
<small class="text-color-secondary">ID: {{ shortId(data.id) }}</small>
|
||||
<!-- Table – desktop (md+) -->
|
||||
<div class="hidden md:block">
|
||||
<DataTable
|
||||
:value="filteredRows"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="15"
|
||||
:rowsPerPageOptions="[10, 15, 25, 50]"
|
||||
stripedRows
|
||||
scrollable
|
||||
scrollHeight="flex"
|
||||
sortMode="single"
|
||||
:sortField="sort.field"
|
||||
:sortOrder="sort.order"
|
||||
@sort="onSort"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">Tente limpar filtros ou mudar o termo de busca.</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
|
||||
<Button icon="pi pi-user-plus" label="Cadastrar paciente" @click="goCreateFull" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:value="data.status"
|
||||
:severity="data.status === 'Ativo' ? 'success' : 'danger'"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column :key="'col-paciente'" field="nome_completo" header="Paciente" v-if="isColVisible('paciente')" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="square" size="large" />
|
||||
<Avatar v-else :label="initials(data.nome_completo)" shape="square" size="large" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ data.nome_completo }}</div>
|
||||
<small class="text-color-secondary">ID: {{ shortId(data.id) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Telefone" style="width: 16rem;" v-if="isColVisible('telefone')" :key="'col-telefone'">
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm leading-tight">
|
||||
<div class="font-medium">
|
||||
{{ fmtPhoneBR(data.telefone) }}
|
||||
</div>
|
||||
<div class="text-xs text-color-secondary">
|
||||
{{ data.email_principal || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="data.status === 'Ativo' ? 'success' : 'danger'" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="email_principal" header="Email" style="width: 18rem;" v-if="isColVisible('email')" :key="'col-email'">
|
||||
<template #body="{ data }">
|
||||
<span class="text-color-secondary">{{ data.email_principal || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Telefone" style="width: 16rem;" v-if="isColVisible('telefone')" :key="'col-telefone'">
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm leading-tight">
|
||||
<div class="font-medium">{{ fmtPhoneBR(data.telefone) }}</div>
|
||||
<div class="text-xs text-color-secondary">{{ data.email_principal || '—' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="last_attended_at" header="Último Atendimento" v-if="isColVisible('last_attended_at')" :key="'col-last_attended_at'" sortable style="width: 12rem;">
|
||||
<template #body="{ data }">{{ data.last_attended_at || '—' }}</template>
|
||||
</Column>
|
||||
<Column field="email_principal" header="Email" style="width: 18rem;" v-if="isColVisible('email')" :key="'col-email'">
|
||||
<template #body="{ data }">
|
||||
<span class="text-color-secondary">{{ data.email_principal || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="created_at" header="Cadastrado em" v-if="isColVisible('created_at')" :key="'col-created_at'" sortable style="width: 12rem;">
|
||||
<template #body="{ data }">{{ data.created_at || '—' }}</template>
|
||||
</Column>
|
||||
<Column field="last_attended_at" header="Último Atendimento" v-if="isColVisible('last_attended_at')" :key="'col-last_attended_at'" sortable style="width: 12rem;">
|
||||
<template #body="{ data }">{{ data.last_attended_at || '—' }}</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Grupos" style="min-width: 14rem;" v-if="isColVisible('grupos')" :key="'col-grupos'">
|
||||
<template #body="{ data }">
|
||||
<div v-if="!(data.groups || []).length" class="text-color-secondary">—</div>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<Tag
|
||||
v-for="g in data.groups"
|
||||
:key="g.id"
|
||||
:value="g.name"
|
||||
:style="chipStyle(g.color)"
|
||||
/>
|
||||
<Column field="created_at" header="Cadastrado em" v-if="isColVisible('created_at')" :key="'col-created_at'" sortable style="width: 12rem;">
|
||||
<template #body="{ data }">{{ data.created_at || '—' }}</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Grupos" style="min-width: 14rem;" v-if="isColVisible('grupos')" :key="'col-grupos'">
|
||||
<template #body="{ data }">
|
||||
<div v-if="!(data.groups || []).length" class="text-color-secondary">—</div>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<Tag v-for="g in data.groups" :key="g.id" :value="g.name" :style="chipStyle(g.color)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Tags" style="min-width: 14rem;" v-if="isColVisible('tags')" :key="'col-tags'">
|
||||
<template #body="{ data }">
|
||||
<div v-if="!(data.tags || []).length" class="text-color-secondary">—</div>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<Tag v-for="t in data.tags" :key="t.id" :value="t.name" :style="chipStyle(t.color)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column :key="'col-acoes'" header="Ações" style="width: 16rem;" frozen alignFrozen="right">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Cards – mobile (<md) -->
|
||||
<div class="md:hidden">
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredRows.length === 0" class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">Tente limpar filtros ou mudar o termo de busca.</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
|
||||
<Button icon="pi pi-user-plus" label="Cadastrar" @click="goCreateFull" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3 pb-4">
|
||||
<div
|
||||
v-for="pat in filteredRows"
|
||||
:key="pat.id"
|
||||
class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
|
||||
>
|
||||
<!-- Topo: avatar + nome + status -->
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar v-if="pat.avatar_url" :image="pat.avatar_url" shape="square" size="large" />
|
||||
<Avatar v-else :label="initials(pat.nome_completo)" shape="square" size="large" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold truncate">{{ pat.nome_completo }}</div>
|
||||
<div class="text-xs text-color-secondary">{{ fmtPhoneBR(pat.telefone) }} · {{ pat.email_principal || '—' }}</div>
|
||||
</div>
|
||||
<Tag :value="pat.status" :severity="pat.status === 'Ativo' ? 'success' : 'danger'" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Tags" style="min-width: 14rem;" v-if="isColVisible('tags')" :key="'col-tags'">
|
||||
<template #body="{ data }">
|
||||
<div v-if="!(data.tags || []).length" class="text-color-secondary">—</div>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<Tag
|
||||
v-for="t in data.tags"
|
||||
:key="t.id"
|
||||
:value="t.name"
|
||||
:style="chipStyle(t.color)"
|
||||
/>
|
||||
<!-- Grupos + Tags -->
|
||||
<div v-if="(pat.groups || []).length || (pat.tags || []).length" class="mt-3 flex flex-wrap gap-1.5">
|
||||
<Tag v-for="g in pat.groups" :key="g.id" :value="g.name" :style="chipStyle(g.color)" />
|
||||
<Tag v-for="t in pat.tags" :key="t.id" :value="t.name" :style="chipStyle(t.color)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column
|
||||
:key="'col-acoes'"
|
||||
header="Ações"
|
||||
style="width: 16rem;"
|
||||
frozen
|
||||
alignFrozen="right"
|
||||
>
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
|
||||
</DataTable>
|
||||
<!-- Ações -->
|
||||
<div class="mt-3 flex gap-2 justify-end">
|
||||
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(pat)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="goEdit(pat)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" @click="confirmDeleteOne(pat)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between text-xs text-color-secondary">
|
||||
<div>
|
||||
Exibindo <b class="text-[var(--text-color)]">{{ filteredRows.length }}</b> de
|
||||
<b class="text-[var(--text-color)]">{{ patients.length }}</b> pacientes.
|
||||
<span v-if="hasActiveFilters"> (filtrado)</span>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
Dica: clique em “Ativos/Inativos” no topo para filtrar rápido.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
Exibindo <b class="text-[var(--text-color)]">{{ filteredRows.length }}</b> de
|
||||
<b class="text-[var(--text-color)]">{{ patients.length }}</b> pacientes.
|
||||
<span v-if="hasActiveFilters"> (filtrado)</span>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
Dica: clique em “Ativos/Inativos" no topo para filtrar rápido.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</TabPanel>
|
||||
|
||||
@@ -604,18 +584,19 @@
|
||||
@close="closeProntuario"
|
||||
/>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import Popover from 'primevue/popover'
|
||||
import Menu from 'primevue/menu'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
|
||||
|
||||
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
|
||||
@@ -642,6 +623,22 @@ function getAreaBase() {
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
// ── Mobile menu ───────────────────────────────────────────
|
||||
const patMobileMenuRef = ref(null)
|
||||
|
||||
const patMobileMenuItems = [
|
||||
{ label: 'Cadastro Rápido', icon: 'pi pi-bolt', command: () => openQuickCreate() },
|
||||
{ label: 'Cadastro Completo', icon: 'pi pi-file-edit', command: () => goCreateFull() },
|
||||
{ separator: true },
|
||||
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchAll() }
|
||||
]
|
||||
|
||||
const uid = ref(null)
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -681,7 +678,7 @@ const lockedKeys = computed(() =>
|
||||
columnCatalogAll.filter(c => c.locked).map(c => c.key)
|
||||
)
|
||||
|
||||
// SEM mutar selectedColumns: apenas “projeta” as visíveis
|
||||
// SEM mutar selectedColumns: apenas “projeta" as visíveis
|
||||
const visibleKeys = computed(() => {
|
||||
const set = new Set(selectedColumns.value || [])
|
||||
lockedKeys.value.forEach(k => set.add(k))
|
||||
@@ -751,10 +748,18 @@ const createMenu = [
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
await loadUser()
|
||||
await fetchAll()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
|
||||
function fmtPhoneBR(v) {
|
||||
const d = String(v ?? '').replace(/\D/g, '')
|
||||
if (!d) return '—'
|
||||
@@ -815,37 +820,62 @@ function onQuickCreated(row) {
|
||||
// -----------------------------
|
||||
// Navigation (shared feature)
|
||||
// -----------------------------
|
||||
function goGroups() {
|
||||
router.push(`${getAreaBase()}/patients/grupos`)
|
||||
function getAreaKey () {
|
||||
const seg = String(route.path || '').split('/')[1]
|
||||
return seg === 'therapist' ? 'therapist' : 'admin'
|
||||
}
|
||||
|
||||
function goCreateFull() {
|
||||
router.push(`${getAreaBase()}/patients/cadastro`)
|
||||
function getPatientsRoutes () {
|
||||
const area = getAreaKey()
|
||||
|
||||
if (area === 'therapist') {
|
||||
return {
|
||||
groupsPath: '/therapist/patients/grupos',
|
||||
createPath: '/therapist/patients/cadastro',
|
||||
editPath: (id) => `/therapist/patients/cadastro/${id}`,
|
||||
|
||||
// se existir no seu router
|
||||
createName: 'therapist-patients-cadastro',
|
||||
editName: 'therapist-patients-cadastro-edit',
|
||||
groupsName: 'therapist-patients-grupos'
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ admin usa "pacientes" (PT-BR)
|
||||
return {
|
||||
groupsPath: '/admin/pacientes/grupos',
|
||||
createPath: '/admin/pacientes/cadastro',
|
||||
editPath: (id) => `/admin/pacientes/cadastro/${id}`,
|
||||
|
||||
// se existir no seu router (pelo que você mostrou antes, existe)
|
||||
createName: 'admin-pacientes-cadastro',
|
||||
editName: 'admin-pacientes-cadastro-edit',
|
||||
groupsName: 'admin-pacientes-grupos'
|
||||
}
|
||||
}
|
||||
|
||||
function goEdit(row) {
|
||||
function safePush (toObj, fallbackPath) {
|
||||
try {
|
||||
const r = router.resolve(toObj)
|
||||
if (r?.matched?.length) return router.push(toObj)
|
||||
} catch (_) {}
|
||||
return router.push(fallbackPath)
|
||||
}
|
||||
|
||||
function goGroups () {
|
||||
const r = getPatientsRoutes()
|
||||
return safePush({ name: r.groupsName }, r.groupsPath)
|
||||
}
|
||||
|
||||
function goCreateFull () {
|
||||
const r = getPatientsRoutes()
|
||||
return safePush({ name: r.createName }, r.createPath)
|
||||
}
|
||||
|
||||
function goEdit (row) {
|
||||
if (!row?.id) return
|
||||
router.push(`${getAreaBase()}/patients/cadastro/${row.id}`)
|
||||
}
|
||||
|
||||
function setStatus(v) {
|
||||
filters.status = v
|
||||
onFilterChanged()
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
filters.status = 'Todos'
|
||||
filters.search = ''
|
||||
filters.groupId = null
|
||||
filters.tagId = null
|
||||
filters.createdFrom = null
|
||||
filters.createdTo = null
|
||||
onFilterChanged()
|
||||
}
|
||||
|
||||
function onSort(e) {
|
||||
sort.field = e.sortField
|
||||
sort.order = e.sortOrder
|
||||
const r = getPatientsRoutes()
|
||||
return safePush({ name: r.editName, params: { id: row.id } }, r.editPath(row.id))
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
@@ -1188,7 +1218,7 @@ function confirmDeleteOne(row) {
|
||||
const nome = row?.nome_completo || 'este paciente'
|
||||
confirm.require({
|
||||
header: 'Excluir paciente',
|
||||
message: `Tem certeza que deseja excluir “${nome}”?`,
|
||||
message: `Tem certeza que deseja excluir “${nome}"?`,
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Excluir',
|
||||
rejectLabel: 'Cancelar',
|
||||
@@ -1237,17 +1267,65 @@ function updateKpis() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kpi-card :deep(.p-card-body) {
|
||||
padding: 1rem;
|
||||
/* ── Hero Header ─────────────────────────────────── */
|
||||
.pat-sentinel { height: 1px; }
|
||||
|
||||
.pat-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.pat-hero--stuck {
|
||||
margin-left: 0; margin-right: 0;
|
||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
/* Blobs */
|
||||
.pat-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.pat-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.pat-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
|
||||
.pat-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
|
||||
.pat-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 30%; background: rgba(236,72,153,0.07); }
|
||||
|
||||
/* Linha 1 */
|
||||
.pat-hero__row1 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.pat-hero__brand {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.pat-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.pat-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.pat-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
/* Linha 2 (oculta no mobile) */
|
||||
.pat-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; flex-wrap: wrap; align-items: center;
|
||||
justify-content: space-between; gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.pat-hero__divider,
|
||||
.pat-hero__row2 { display: none; }
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
/* KPI card */
|
||||
.kpi-card :deep(.p-card-body) { padding: 1rem; }
|
||||
|
||||
/* Fade */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoleGuard } from '@/composables/useRoleGuard'
|
||||
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
const { canSee } = useRoleGuard()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -16,8 +18,17 @@ const confirm = useConfirm()
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
/**
|
||||
* ✅ NOTAS IMPORTANTES DO AJUSTE
|
||||
* - Corrige o 404 (admin usa /pacientes..., therapist usa /patients...).
|
||||
* - Depois de criar (insert), faz upload do avatar usando o ID recém-criado.
|
||||
* - Se bucket não for público, troca para signed URL automaticamente (fallback).
|
||||
*/
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Tenant helpers
|
||||
// ------------------------------------------------------
|
||||
async function getCurrentTenantId () {
|
||||
// ajuste para o nome real no seu store
|
||||
return tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
|
||||
}
|
||||
|
||||
@@ -105,6 +116,48 @@ onBeforeUnmount(() => {
|
||||
const patientId = computed(() => String(route.params?.id || '').trim() || null)
|
||||
const isEdit = computed(() => !!patientId.value)
|
||||
|
||||
// ------------------------------------------------------
|
||||
// ✅ FIX 404: base por área + rotas reais (admin: /pacientes | therapist: /patients)
|
||||
// ------------------------------------------------------
|
||||
function getAreaKey () {
|
||||
const seg = String(route.path || '').split('/').filter(Boolean)[0] || 'admin'
|
||||
return seg === 'therapist' ? 'therapist' : 'admin'
|
||||
}
|
||||
|
||||
function getPatientsRoutes () {
|
||||
const area = getAreaKey()
|
||||
|
||||
if (area === 'therapist') {
|
||||
return {
|
||||
listName: 'therapist-patients',
|
||||
editName: 'therapist-patients-edit',
|
||||
listPath: '/therapist/patients',
|
||||
editPath: (id) => `/therapist/patients/cadastro/${id}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
listName: 'admin-pacientes',
|
||||
editName: 'admin-pacientes-cadastro-edit',
|
||||
listPath: '/admin/pacientes',
|
||||
editPath: (id) => `/admin/pacientes/cadastro/${id}`
|
||||
}
|
||||
}
|
||||
|
||||
async function safePush (toNameObj, fallbackPath) {
|
||||
try {
|
||||
const r = router.resolve(toNameObj)
|
||||
if (r?.matched?.length) return router.push(toNameObj)
|
||||
} catch (_) {}
|
||||
return router.push(fallbackPath)
|
||||
}
|
||||
|
||||
function goBack () {
|
||||
const { listName, listPath } = getPatientsRoutes()
|
||||
if (window.history.length > 1) router.back()
|
||||
else safePush({ name: listName }, listPath)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Avatar state (TEM que existir no setup)
|
||||
// ------------------------------------------------------
|
||||
@@ -112,7 +165,7 @@ const avatarFile = ref(null)
|
||||
const avatarPreviewUrl = ref('')
|
||||
const avatarUploading = ref(false)
|
||||
|
||||
const AVATAR_BUCKET = 'avatars' // confirme o nome do bucket no Supabase
|
||||
const AVATAR_BUCKET = 'avatars'
|
||||
|
||||
function isImageFile (file) {
|
||||
return !!file && typeof file.type === 'string' && file.type.startsWith('image/')
|
||||
@@ -149,6 +202,24 @@ function onAvatarPicked (ev) {
|
||||
toast.add({ severity: 'info', summary: 'Avatar', detail: 'Preview carregado. Clique em “Salvar” para enviar.', life: 2500 })
|
||||
}
|
||||
|
||||
// ✅ Gera URL pública OU signed URL (se o bucket for privado)
|
||||
async function getReadableAvatarUrl (path) {
|
||||
// tenta público primeiro
|
||||
try {
|
||||
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
|
||||
const publicUrl = pub?.publicUrl || null
|
||||
if (publicUrl) return publicUrl
|
||||
} catch (_) {}
|
||||
|
||||
// fallback: signed (bucket privado)
|
||||
const { data, error } = await supabase.storage
|
||||
.from(AVATAR_BUCKET)
|
||||
.createSignedUrl(path, 60 * 60 * 24 * 7) // 7 dias
|
||||
if (error) throw error
|
||||
if (!data?.signedUrl) throw new Error('Não consegui gerar signed URL do avatar.')
|
||||
return data.signedUrl
|
||||
}
|
||||
|
||||
async function uploadAvatarToStorage ({ ownerId, patientId, file }) {
|
||||
if (!ownerId) throw new Error('ownerId ausente.')
|
||||
if (!patientId) throw new Error('patientId ausente.')
|
||||
@@ -171,15 +242,12 @@ async function uploadAvatarToStorage ({ ownerId, patientId, file }) {
|
||||
|
||||
if (upErr) throw upErr
|
||||
|
||||
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
|
||||
const publicUrl = pub?.publicUrl || null
|
||||
if (!publicUrl) throw new Error('Não consegui gerar URL pública do avatar.')
|
||||
|
||||
return { publicUrl, path }
|
||||
const readableUrl = await getReadableAvatarUrl(path)
|
||||
return { publicUrl: readableUrl, path }
|
||||
}
|
||||
|
||||
async function maybeUploadAvatar (ownerId, id) {
|
||||
if (!avatarFile.value) return
|
||||
if (!avatarFile.value) return null
|
||||
|
||||
avatarUploading.value = true
|
||||
try {
|
||||
@@ -189,45 +257,40 @@ async function maybeUploadAvatar (ownerId, id) {
|
||||
file: avatarFile.value
|
||||
})
|
||||
|
||||
// 1) atualiza UI IMEDIATAMENTE (não deixa “sumir”)
|
||||
// UI
|
||||
form.value.avatar_url = publicUrl
|
||||
|
||||
// 2) grava no banco
|
||||
await updatePatient(id, { avatar_url: publicUrl })
|
||||
|
||||
// 3) limpa o arquivo selecionado
|
||||
avatarFile.value = null
|
||||
|
||||
// 4) se o preview era blob, pode revogar
|
||||
// MAS NÃO zere o avatarPreviewUrl se o template depende dele
|
||||
// => aqui vamos só revogar e então setar para a própria URL pública.
|
||||
revokePreview()
|
||||
avatarPreviewUrl.value = publicUrl
|
||||
|
||||
// DB
|
||||
await updatePatient(id, { avatar_url: publicUrl })
|
||||
|
||||
return publicUrl
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Avatar',
|
||||
detail: e?.message || 'Falha ao enviar avatar.',
|
||||
life: 4000
|
||||
life: 4500
|
||||
})
|
||||
return null
|
||||
} finally {
|
||||
avatarUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Form state (PT-BR)
|
||||
// Form state
|
||||
// ------------------------------------------------------
|
||||
function resetForm () {
|
||||
return {
|
||||
// Sessão 1 — pessoais
|
||||
nome_completo: '',
|
||||
telefone: '',
|
||||
email_principal: '',
|
||||
email_alternativo: '',
|
||||
telefone_alternativo: '',
|
||||
data_nascimento: '', // ✅ SEMPRE DD-MM-AAAA (hífen)
|
||||
data_nascimento: '',
|
||||
genero: '',
|
||||
estado_civil: '',
|
||||
cpf: '',
|
||||
@@ -237,7 +300,6 @@ function resetForm () {
|
||||
onde_nos_conheceu: '',
|
||||
encaminhado_por: '',
|
||||
|
||||
// Sessão 2 — endereço
|
||||
cep: '',
|
||||
pais: 'Brasil',
|
||||
cidade: '',
|
||||
@@ -247,24 +309,19 @@ function resetForm () {
|
||||
bairro: '',
|
||||
complemento: '',
|
||||
|
||||
// Sessão 3 — adicionais
|
||||
escolaridade: '',
|
||||
profissao: '',
|
||||
nome_parente: '',
|
||||
grau_parentesco: '',
|
||||
telefone_parente: '',
|
||||
|
||||
// Sessão 4 — responsável
|
||||
nome_responsavel: '',
|
||||
cpf_responsavel: '',
|
||||
telefone_responsavel: '',
|
||||
observacao_responsavel: '',
|
||||
cobranca_no_responsavel: false,
|
||||
|
||||
// Sessão 5 — internos
|
||||
notas_internas: '',
|
||||
|
||||
// Avatar
|
||||
avatar_url: ''
|
||||
}
|
||||
}
|
||||
@@ -331,7 +388,6 @@ function toISODateFromDDMMYYYY (s) {
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
// banco (YYYY-MM-DD ou ISO) -> form (DD-MM-YYYY)
|
||||
function isoToDDMMYYYY (value) {
|
||||
if (!value) return ''
|
||||
const s = String(value).trim()
|
||||
@@ -407,7 +463,6 @@ function mapDbToForm (p) {
|
||||
cobranca_no_responsavel: !!p.cobranca_no_responsavel,
|
||||
|
||||
notas_internas: p.notas_internas ?? '',
|
||||
|
||||
avatar_url: p.avatar_url ?? ''
|
||||
}
|
||||
}
|
||||
@@ -430,8 +485,6 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
|
||||
'owner_id',
|
||||
'tenant_id',
|
||||
'responsible_member_id',
|
||||
|
||||
// Sessão 1
|
||||
'nome_completo',
|
||||
'telefone',
|
||||
'email_principal',
|
||||
@@ -446,8 +499,6 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
|
||||
'observacoes',
|
||||
'onde_nos_conheceu',
|
||||
'encaminhado_por',
|
||||
|
||||
// Sessão 2
|
||||
'pais',
|
||||
'cep',
|
||||
'cidade',
|
||||
@@ -456,25 +507,17 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
|
||||
'numero',
|
||||
'bairro',
|
||||
'complemento',
|
||||
|
||||
// Sessão 3
|
||||
'escolaridade',
|
||||
'profissao',
|
||||
'nome_parente',
|
||||
'grau_parentesco',
|
||||
'telefone_parente',
|
||||
|
||||
// Sessão 4
|
||||
'nome_responsavel',
|
||||
'cpf_responsavel',
|
||||
'telefone_responsavel',
|
||||
'observacao_responsavel',
|
||||
'cobranca_no_responsavel',
|
||||
|
||||
// Sessão 5
|
||||
'notas_internas',
|
||||
|
||||
// Avatar
|
||||
'avatar_url'
|
||||
])
|
||||
|
||||
@@ -523,11 +566,10 @@ function sanitizePayload (raw, ownerId) {
|
||||
cobranca_no_responsavel: !!raw.cobranca_no_responsavel,
|
||||
|
||||
notas_internas: raw.notas_internas || null,
|
||||
|
||||
avatar_url: raw.avatar_url || null
|
||||
}
|
||||
|
||||
// strings vazias -> null
|
||||
// strings vazias -> null e trim
|
||||
Object.keys(payload).forEach(k => {
|
||||
if (payload[k] === '') payload[k] = null
|
||||
if (typeof payload[k] === 'string') {
|
||||
@@ -536,23 +578,19 @@ function sanitizePayload (raw, ownerId) {
|
||||
}
|
||||
})
|
||||
|
||||
// docs: só dígitos
|
||||
payload.cpf = payload.cpf ? digitsOnly(payload.cpf) : null
|
||||
payload.rg = payload.rg ? digitsOnly(payload.rg) : null
|
||||
payload.cpf_responsavel = payload.cpf_responsavel ? digitsOnly(payload.cpf_responsavel) : null
|
||||
|
||||
// fones: só dígitos
|
||||
payload.telefone = payload.telefone ? digitsOnly(payload.telefone) : null
|
||||
payload.telefone_alternativo = payload.telefone_alternativo ? digitsOnly(payload.telefone_alternativo) : null
|
||||
payload.telefone_parente = payload.telefone_parente ? digitsOnly(payload.telefone_parente) : null
|
||||
payload.telefone_responsavel = payload.telefone_responsavel ? digitsOnly(payload.telefone_responsavel) : null
|
||||
|
||||
// ✅ FIX CRÍTICO: DD-MM-YYYY -> YYYY-MM-DD
|
||||
payload.data_nascimento = payload.data_nascimento
|
||||
? (toISODateFromDDMMYYYY(payload.data_nascimento) || null)
|
||||
: null
|
||||
|
||||
// filtra
|
||||
const filtrado = {}
|
||||
Object.keys(payload).forEach(k => {
|
||||
if (PACIENTES_COLUNAS_PERMITIDAS.has(k)) filtrado[k] = payload[k]
|
||||
@@ -565,11 +603,7 @@ function sanitizePayload (raw, ownerId) {
|
||||
// Supabase: lists / get / relations
|
||||
// ------------------------------------------------------
|
||||
async function listGroups () {
|
||||
const probe = await supabase
|
||||
.from('patient_groups')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
|
||||
const probe = await supabase.from('patient_groups').select('*').limit(1)
|
||||
if (probe.error) throw probe.error
|
||||
|
||||
const row = probe.data?.[0] || {}
|
||||
@@ -582,13 +616,8 @@ async function listGroups () {
|
||||
.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
|
||||
}))
|
||||
return (data || []).map(g => ({ ...g, name: g.nome, color: g.cor }))
|
||||
}
|
||||
|
||||
if (hasEN) {
|
||||
@@ -597,93 +626,42 @@ async function listGroups () {
|
||||
.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
|
||||
}))
|
||||
return (data || []).map(g => ({ ...g, nome: g.name, cor: g.color }))
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('patient_groups')
|
||||
.select('*')
|
||||
.order('id', { ascending: true })
|
||||
|
||||
const { data, error } = await supabase.from('patient_groups').select('*').order('id', { ascending: true })
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
|
||||
async function listTags () {
|
||||
// 1) Pega 1 registro sem order, só pra descobrir o schema real (sem 400)
|
||||
const probe = await supabase
|
||||
.from('patient_tags')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
|
||||
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 })
|
||||
|
||||
const { data, error } = await supabase.from('patient_tags').select('id,name,color').order('name', { ascending: true })
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
if (hasPT) {
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.select('id,nome,cor')
|
||||
.order('nome', { ascending: true })
|
||||
|
||||
const { data, error } = await supabase.from('patient_tags').select('id,nome,cor').order('nome', { ascending: true })
|
||||
if (error) throw error
|
||||
|
||||
return (data || []).map(t => ({
|
||||
...t,
|
||||
name: t.nome,
|
||||
color: t.cor
|
||||
}))
|
||||
return (data || []).map(t => ({ ...t, name: t.nome, color: t.cor }))
|
||||
}
|
||||
|
||||
// 3) fallback final: tabela vazia ou schema incomum
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.select('*')
|
||||
.order('id', { ascending: true })
|
||||
|
||||
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
|
||||
}))
|
||||
return (data || []).map(t => ({ ...t, name: t.name ?? t.nome ?? '', color: t.color ?? t.cor ?? null }))
|
||||
}
|
||||
|
||||
|
||||
async function getPatientById (id) {
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
const { data, error } = await supabase.from('patients').select('*').eq('id', id).single()
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
@@ -708,11 +686,7 @@ async function getPatientRelations (id) {
|
||||
}
|
||||
|
||||
async function createPatient (payload) {
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
.insert(payload)
|
||||
.select('id')
|
||||
.single()
|
||||
const { data, error } = await supabase.from('patients').insert(payload).select('id').single()
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
@@ -720,17 +694,14 @@ async function createPatient (payload) {
|
||||
async function updatePatient (id, payload) {
|
||||
const { error } = await supabase
|
||||
.from('patients')
|
||||
.update({
|
||||
...payload,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.update({ ...payload, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Relations
|
||||
// Relations state
|
||||
// ------------------------------------------------------
|
||||
const groups = ref([])
|
||||
const tags = ref([])
|
||||
@@ -738,17 +709,11 @@ const grupoIdSelecionado = ref(null)
|
||||
const tagIdsSelecionadas = ref([])
|
||||
|
||||
async function replacePatientGroups (patient_id, groupId) {
|
||||
const { error: delErr } = await supabase
|
||||
.from('patient_group_patient')
|
||||
.delete()
|
||||
.eq('patient_id', patient_id)
|
||||
const { error: delErr } = await supabase.from('patient_group_patient').delete().eq('patient_id', patient_id)
|
||||
if (delErr) throw delErr
|
||||
|
||||
if (!groupId) return
|
||||
|
||||
const { error: insErr } = await supabase
|
||||
.from('patient_group_patient')
|
||||
.insert({ patient_id, patient_group_id: groupId })
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
const { error: insErr } = await supabase.from('patient_group_patient').insert({ patient_id, patient_group_id: groupId, tenant_id: tenantId })
|
||||
if (insErr) throw insErr
|
||||
}
|
||||
|
||||
@@ -765,15 +730,9 @@ async function replacePatientTags (patient_id, tagIds) {
|
||||
const clean = Array.from(new Set([...(tagIds || [])].filter(Boolean)))
|
||||
if (!clean.length) return
|
||||
|
||||
const rows = clean.map(tag_id => ({
|
||||
owner_id: ownerId,
|
||||
patient_id,
|
||||
tag_id
|
||||
}))
|
||||
|
||||
const { error: insErr } = await supabase
|
||||
.from('patient_patient_tag')
|
||||
.insert(rows)
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
const rows = clean.map(tag_id => ({ owner_id: ownerId, patient_id, tag_id, tenant_id: tenantId }))
|
||||
const { error: insErr } = await supabase.from('patient_patient_tag').insert(rows)
|
||||
if (insErr) throw insErr
|
||||
}
|
||||
|
||||
@@ -808,19 +767,6 @@ 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 () {
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.push(`${getAreaBase()}/patients`)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Fetch (load everything)
|
||||
// ------------------------------------------------------
|
||||
@@ -829,34 +775,32 @@ async function fetchAll () {
|
||||
try {
|
||||
const [gRes, tRes] = await Promise.allSettled([listGroups(), listTags()])
|
||||
|
||||
if (gRes.status === 'fulfilled') {
|
||||
groups.value = gRes.value || []
|
||||
} else {
|
||||
if (gRes.status === 'fulfilled') groups.value = gRes.value || []
|
||||
else {
|
||||
groups.value = []
|
||||
console.warn('[listGroups error]', gRes.reason)
|
||||
toast.add({ severity: 'warn', summary: 'Grupos', detail: gRes.reason?.message || 'Falha ao carregar grupos', life: 3500 })
|
||||
}
|
||||
|
||||
if (tRes.status === 'fulfilled') {
|
||||
tags.value = tRes.value || []
|
||||
} else {
|
||||
if (tRes.status === 'fulfilled') tags.value = tRes.value || []
|
||||
else {
|
||||
tags.value = []
|
||||
console.warn('[listTags error]', tRes.reason)
|
||||
toast.add({ severity: 'warn', summary: 'Tags', detail: tRes.reason?.message || 'Falha ao carregar tags', life: 3500 })
|
||||
}
|
||||
|
||||
console.log('[groups]', groups.value.length, groups.value[0])
|
||||
console.log('[tags]', tags.value.length, tags.value[0])
|
||||
|
||||
if (isEdit.value) {
|
||||
const p = await getPatientById(patientId.value)
|
||||
form.value = mapDbToForm(p)
|
||||
|
||||
// se já tinha avatar no banco, garante preview
|
||||
avatarPreviewUrl.value = form.value.avatar_url || ''
|
||||
|
||||
const rel = await getPatientRelations(patientId.value)
|
||||
grupoIdSelecionado.value = rel.groupIds?.[0] || null
|
||||
tagIdsSelecionadas.value = rel.tagIds || []
|
||||
} else {
|
||||
form.value = resetForm()
|
||||
//form.value = resetForm()
|
||||
grupoIdSelecionado.value = null
|
||||
tagIdsSelecionadas.value = []
|
||||
avatarFile.value = null
|
||||
@@ -872,19 +816,33 @@ async function fetchAll () {
|
||||
watch(() => route.params?.id, fetchAll, { immediate: true })
|
||||
onMounted(fetchAll)
|
||||
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Tenant resolve (robusto)
|
||||
// ------------------------------------------------------
|
||||
async function resolveTenantContextOrFail () {
|
||||
const { data: authData, error: authError } = await supabase.auth.getUser()
|
||||
if (authError) throw authError
|
||||
const uid = authData?.user?.id
|
||||
if (!uid) throw new Error('Sessão inválida.')
|
||||
|
||||
// 1) tenta pelo store
|
||||
const storeTid = await getCurrentTenantId()
|
||||
if (storeTid) {
|
||||
try {
|
||||
const mid = await getCurrentMemberId(storeTid)
|
||||
return { tenantId: storeTid, memberId: mid }
|
||||
} catch (_) {
|
||||
// cai pro fallback (último membership active)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) fallback
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('id, tenant_id')
|
||||
.eq('user_id', uid)
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: false }) // se existir
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
@@ -904,20 +862,69 @@ async function onSubmit () {
|
||||
const ownerId = await getOwnerId()
|
||||
const { tenantId, memberId } = await resolveTenantContextOrFail()
|
||||
|
||||
// depois do sanitize
|
||||
const payload = sanitizePayload(form.value, ownerId)
|
||||
|
||||
// multi-tenant obrigatório
|
||||
payload.tenant_id = tenantId
|
||||
payload.responsible_member_id = memberId
|
||||
|
||||
// ✅ validações mínimas (NÃO DEIXA CHEGAR NO BANCO)
|
||||
const nome = String(form.value?.nome_completo || '').trim()
|
||||
if (!nome) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Nome obrigatório',
|
||||
detail: 'Preencha “Nome completo” para salvar o paciente.',
|
||||
life: 3500
|
||||
})
|
||||
|
||||
// abre o painel certo (você já tem navItems: "Informações pessoais" é o 0)
|
||||
await openPanel(0)
|
||||
return
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// EDIT
|
||||
// ---------------------------
|
||||
if (isEdit.value) {
|
||||
await updatePatient(patientId.value, payload)
|
||||
|
||||
// ✅ Se houver avatar selecionado, sobe e grava avatar_url
|
||||
await maybeUploadAvatar(ownerId, patientId.value)
|
||||
|
||||
await replacePatientGroups(patientId.value, grupoIdSelecionado.value)
|
||||
await replacePatientTags(patientId.value, tagIdsSelecionadas.value)
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 })
|
||||
} else {
|
||||
const created = await createPatient(payload)
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
|
||||
// opcional: router.push(`${getAreaBase()}/patients/${created.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// CREATE
|
||||
// ---------------------------
|
||||
const created = await createPatient(payload)
|
||||
|
||||
// ✅ upload do avatar usando ID recém-criado
|
||||
await maybeUploadAvatar(ownerId, created.id)
|
||||
|
||||
await replacePatientGroups(created.id, grupoIdSelecionado.value)
|
||||
await replacePatientTags(created.id, tagIdsSelecionadas.value)
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
|
||||
|
||||
// ✅ NÃO navega para /cadastro/:id (fica em /admin/pacientes/cadastro)
|
||||
// Em vez disso, reseta o formulário para novo cadastro:
|
||||
form.value = resetForm()
|
||||
grupoIdSelecionado.value = null
|
||||
tagIdsSelecionadas.value = []
|
||||
|
||||
avatarFile.value = null
|
||||
revokePreview()
|
||||
avatarPreviewUrl.value = ''
|
||||
|
||||
// volta pro primeiro painel (UX boa)
|
||||
await openPanel(0)
|
||||
|
||||
return
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar paciente.', life: 4000 })
|
||||
@@ -925,8 +932,6 @@ async function onSubmit () {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Delete
|
||||
// ------------------------------------------------------
|
||||
@@ -968,7 +973,7 @@ async function doDelete () {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Fake fill (opcional)
|
||||
// Fake fill (opcional) — mantive como você tinha
|
||||
// ------------------------------------------------------
|
||||
function randInt (min, max) { return Math.floor(Math.random() * (max - min + 1)) + min }
|
||||
function pick (arr) { return arr[randInt(0, arr.length - 1)] }
|
||||
@@ -1036,7 +1041,6 @@ function fillRandomPatient () {
|
||||
|
||||
form.value = {
|
||||
...resetForm(),
|
||||
|
||||
nome_completo: nomeCompleto,
|
||||
telefone: randomPhoneBR(),
|
||||
email_principal: randomEmailFromName(nomeCompleto),
|
||||
@@ -1076,16 +1080,10 @@ function fillRandomPatient () {
|
||||
cobranca_no_responsavel: true,
|
||||
|
||||
notas_internas: 'Paciente apresenta discurso organizado. Acompanhar evolução clínica.',
|
||||
|
||||
avatar_url: ''
|
||||
}
|
||||
|
||||
// Grupo
|
||||
if (Array.isArray(groups.value) && groups.value.length) {
|
||||
grupoIdSelecionado.value = pick(groups.value).id
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (Array.isArray(groups.value) && groups.value.length) grupoIdSelecionado.value = pick(groups.value).id
|
||||
if (Array.isArray(tags.value) && tags.value.length) {
|
||||
const shuffled = [...tags.value].sort(() => Math.random() - 0.5)
|
||||
tagIdsSelecionadas.value = shuffled.slice(0, randInt(1, Math.min(3, tags.value.length))).map(t => t.id)
|
||||
@@ -1118,137 +1116,88 @@ const maritalStatusOptions = [
|
||||
const createGroupDialog = ref(false)
|
||||
const createGroupSaving = ref(false)
|
||||
const createGroupError = ref('')
|
||||
const newGroup = ref({ name: '', color: '#6366F1' }) // indigo default
|
||||
const newGroup = ref({ name: '', color: '#6366F1' })
|
||||
|
||||
const createTagDialog = ref(false)
|
||||
const createTagSaving = ref(false)
|
||||
const createTagError = ref('')
|
||||
const newTag = ref({ name: '', color: '#22C55E' }) // green default
|
||||
const newTag = ref({ name: '', color: '#22C55E' })
|
||||
|
||||
function openGroupDlg(mode = 'create') {
|
||||
// por enquanto só create
|
||||
function openGroupDlg () {
|
||||
createGroupError.value = ''
|
||||
newGroup.value = { name: '', color: '#6366F1' }
|
||||
createGroupDialog.value = true
|
||||
}
|
||||
|
||||
function openTagDlg(mode = 'create') {
|
||||
// por enquanto só create
|
||||
function openTagDlg () {
|
||||
createTagError.value = ''
|
||||
newTag.value = { name: '', color: '#22C55E' }
|
||||
createTagDialog.value = true
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Persist: Grupo
|
||||
// ------------------------------------------------------
|
||||
async function createGroupPersist() {
|
||||
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
|
||||
}
|
||||
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)
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
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
|
||||
}
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('patient_groups')
|
||||
.insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, descricao: null, cor: color, is_system: false, is_active: true })
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
createdId = data?.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.'
|
||||
const msg = e?.message || ''
|
||||
if (e?.code === '23505' || /duplicate key value/i.test(msg)) {
|
||||
createGroupError.value = 'Já existe um grupo com esse nome.'
|
||||
} else {
|
||||
createGroupError.value = msg || 'Falha ao criar grupo.'
|
||||
}
|
||||
} finally {
|
||||
createGroupSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Persist: Tag
|
||||
// ------------------------------------------------------
|
||||
async function createTagPersist() {
|
||||
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
|
||||
}
|
||||
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)
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
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
|
||||
}
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, cor: color })
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
createdId = data?.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])
|
||||
@@ -1258,7 +1207,12 @@ async function createTagPersist() {
|
||||
toast.add({ severity: 'success', summary: 'Tag', detail: 'Tag criada.', life: 2500 })
|
||||
createTagDialog.value = false
|
||||
} catch (e) {
|
||||
createTagError.value = e?.message || 'Falha ao criar tag.'
|
||||
const msg = e?.message || ''
|
||||
if (e?.code === '23505' || /duplicate key value/i.test(msg)) {
|
||||
createTagError.value = 'Já existe uma tag com esse nome.'
|
||||
} else {
|
||||
createTagError.value = msg || 'Falha ao criar tag.'
|
||||
}
|
||||
} finally {
|
||||
createTagSaving.value = false
|
||||
}
|
||||
@@ -1285,11 +1239,12 @@ async function createTagPersist() {
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-if="canSee('testMODE')"
|
||||
label="Preencher tudo"
|
||||
icon="pi pi-bolt"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="fillRandomPatient"
|
||||
@click="fillRandomPatient"
|
||||
/>
|
||||
<Button
|
||||
label="Voltar"
|
||||
@@ -1931,6 +1886,7 @@ async function createTagPersist() {
|
||||
<Dialog
|
||||
v-model:visible="createGroupDialog"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Criar grupo"
|
||||
:style="{ width: '26rem' }"
|
||||
:closable="!createGroupSaving"
|
||||
@@ -1965,6 +1921,7 @@ async function createTagPersist() {
|
||||
<Dialog
|
||||
v-model:visible="createTagDialog"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Criar tag"
|
||||
:style="{ width: '26rem' }"
|
||||
:closable="!createTagSaving"
|
||||
|
||||
@@ -1,249 +1,240 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- Top header -->
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl bg-slate-900 text-slate-50 grid place-items-center shadow-sm">
|
||||
<i class="pi pi-link text-lg"></i>
|
||||
</div>
|
||||
<Toast />
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="text-2xl font-semibold text-slate-900 leading-tight">
|
||||
Cadastro Externo
|
||||
</div>
|
||||
<div class="text-slate-600 mt-1">
|
||||
Gere um link para o paciente preencher o pré-cadastro com calma e segurança.
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="extlink-sentinel" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div ref="headerEl" class="extlink-hero mx-3 md:mx-5 mb-4" :class="{ 'extlink-hero--stuck': headerStuck }">
|
||||
<div class="extlink-hero__blobs" aria-hidden="true">
|
||||
<div class="extlink-hero__blob extlink-hero__blob--1" />
|
||||
<div class="extlink-hero__blob extlink-hero__blob--2" />
|
||||
</div>
|
||||
|
||||
<!-- Row 1 -->
|
||||
<div class="extlink-hero__row1">
|
||||
<div class="extlink-hero__brand">
|
||||
<div class="extlink-hero__icon"><i class="pi pi-link text-lg" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="extlink-hero__title">Link de Cadastro</div>
|
||||
<div class="extlink-hero__sub">Compartilhe com o paciente para preencher o pré-cadastro com calma e segurança</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 justify-start md:justify-end">
|
||||
<!-- Desktop (≥1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
class="inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border transition-colors"
|
||||
:class="inviteToken
|
||||
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
|
||||
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'"
|
||||
/>
|
||||
{{ inviteToken ? 'Link ativo' : 'Gerando…' }}
|
||||
</span>
|
||||
<Button
|
||||
label="Gerar novo link"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:loading="rotating"
|
||||
@click="rotateLink"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile (<1200px) -->
|
||||
<div class="flex xl:hidden items-center shrink-0">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main grid -->
|
||||
<div class="mt-5 grid grid-cols-1 lg:grid-cols-12 gap-4">
|
||||
<!-- Left: Link card -->
|
||||
<div class="lg:col-span-7">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<!-- Card head -->
|
||||
<div class="p-5 border-b border-slate-200">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<!-- Divider -->
|
||||
<Divider class="extlink-hero__divider my-2" />
|
||||
|
||||
<!-- Row 2: link rápido (oculto no mobile) -->
|
||||
<div class="extlink-hero__row2">
|
||||
<div v-if="!inviteToken" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-spin pi-spinner text-xs" /> Gerando link…
|
||||
</div>
|
||||
<InputGroup v-else class="max-w-2xl">
|
||||
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
|
||||
<InputText readonly :value="publicUrl" class="font-mono text-xs" />
|
||||
<Button icon="pi pi-copy" severity="secondary" title="Copiar link" @click="copyLink" />
|
||||
<Button icon="pi pi-external-link" severity="secondary" title="Abrir no navegador" @click="openLink" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 mb-5">
|
||||
|
||||
<!-- Esquerda: ações do link -->
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-4">
|
||||
|
||||
<!-- Card principal: link -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<div class="p-5 border-b border-[var(--surface-border)] flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="font-semibold text-[var(--text-color)]">Seu link público</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Envie ao paciente por WhatsApp, e-mail ou mensagem direta</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border"
|
||||
:class="inviteToken
|
||||
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
|
||||
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
|
||||
{{ inviteToken ? 'Ativo' : 'Gerando…' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Skeleton -->
|
||||
<div v-if="!inviteToken" class="space-y-3">
|
||||
<div class="h-10 rounded-xl bg-[var(--surface-ground)] animate-pulse" />
|
||||
<div class="h-10 rounded-xl bg-[var(--surface-ground)] animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Link com ações -->
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
|
||||
<InputText readonly :value="publicUrl" class="font-mono text-xs" />
|
||||
<Button icon="pi pi-copy" severity="secondary" title="Copiar" @click="copyLink" />
|
||||
<Button icon="pi pi-external-link" severity="secondary" title="Abrir" @click="openLink" />
|
||||
</InputGroup>
|
||||
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
Token: <span class="font-mono select-all">{{ inviteToken }}</span>
|
||||
</div>
|
||||
|
||||
<!-- CTAs rápidas -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button class="extlink-cta-btn" @click="copyLink">
|
||||
<div class="extlink-cta-btn__icon bg-[color-mix(in_srgb,var(--p-primary-500,#6366f1)_12%,transparent)] text-[var(--p-primary-500,#6366f1)]">
|
||||
<i class="pi pi-copy" />
|
||||
</div>
|
||||
<div class="text-left min-w-0">
|
||||
<div class="font-semibold text-sm text-[var(--text-color)]">Copiar link</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Cole no WhatsApp ou e-mail</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="extlink-cta-btn" @click="copyInviteMessage">
|
||||
<div class="extlink-cta-btn__icon bg-emerald-500/10 text-emerald-600">
|
||||
<i class="pi pi-comment" />
|
||||
</div>
|
||||
<div class="text-left min-w-0">
|
||||
<div class="font-semibold text-sm text-[var(--text-color)]">Copiar mensagem pronta</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Texto formatado com o link incluso</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Aviso -->
|
||||
<Message severity="warn" :closable="false">
|
||||
<b>Dica:</b> ao gerar um novo link, o anterior é revogado. Use isso quando quiser invalidar um link já compartilhado.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensagem pronta -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-1">
|
||||
<i class="pi pi-comment text-sm text-[var(--text-color-secondary)]" />
|
||||
Mensagem pronta para envio
|
||||
</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mb-3">Copie e cole ao enviar o link ao paciente:</div>
|
||||
<div class="rounded-xl bg-[var(--surface-ground)] border border-[var(--surface-border)] p-4 text-sm text-[var(--text-color)] leading-relaxed">
|
||||
Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
|
||||
<span class="block mt-2 font-mono text-xs break-all text-[var(--text-color-secondary)]">{{ publicUrl || '…aguardando link…' }}</span>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
label="Copiar mensagem"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:disabled="!publicUrl"
|
||||
@click="copyInviteMessage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Direita: instruções -->
|
||||
<div class="lg:w-80 shrink-0 flex flex-col gap-4">
|
||||
|
||||
<!-- Como funciona -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<div class="p-5 border-b border-[var(--surface-border)]">
|
||||
<div class="font-semibold text-[var(--text-color)]">Como funciona</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Simples e sem fricção para o paciente</div>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<ol class="space-y-4">
|
||||
<li class="flex gap-3">
|
||||
<div class="extlink-step shrink-0">1</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-lg font-semibold text-slate-900">Seu link</div>
|
||||
<div class="text-slate-600 text-sm mt-1">
|
||||
Envie este link ao paciente. Ele abre a página de cadastro externo.
|
||||
</div>
|
||||
<div class="font-semibold text-sm text-[var(--text-color)]">Você envia o link</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Por WhatsApp, e-mail ou mensagem direta.</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-2 text-xs px-2.5 py-1 rounded-full border"
|
||||
:class="inviteToken ? 'border-emerald-200 text-emerald-700 bg-emerald-50' : 'border-slate-200 text-slate-600 bg-slate-50'"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="inviteToken ? 'bg-emerald-500' : 'bg-slate-400'"
|
||||
></span>
|
||||
{{ inviteToken ? 'Ativo' : 'Gerando...' }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<div class="extlink-step shrink-0">2</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-sm text-[var(--text-color)]">O paciente preenche</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Campos opcionais podem ficar em branco. Menos fricção, mais adesão.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<div class="p-5">
|
||||
<!-- Skeleton while loading -->
|
||||
<div v-if="!inviteToken" class="space-y-3">
|
||||
<div class="h-10 rounded-xl bg-slate-100 animate-pulse"></div>
|
||||
<div class="h-10 rounded-xl bg-slate-100 animate-pulse"></div>
|
||||
<Message severity="info" :closable="false">
|
||||
Gerando seu link...
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Link display + quick actions -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-slate-700">Link público</label>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
<div class="flex-1 min-w-0">
|
||||
<InputText
|
||||
readonly
|
||||
:value="publicUrl"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="mt-1 text-xs text-slate-500 break-words">
|
||||
Token: <span class="font-mono">{{ inviteToken }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 sm:flex-col sm:w-[140px]">
|
||||
<Button
|
||||
class="w-full"
|
||||
icon="pi pi-copy"
|
||||
label="Copiar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="copyLink"
|
||||
/>
|
||||
<Button
|
||||
class="w-full"
|
||||
icon="pi pi-external-link"
|
||||
label="Abrir"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="openLink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<div class="extlink-step shrink-0">3</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-sm text-[var(--text-color)]">Você recebe e converte</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">O cadastro aparece em "Cadastros recebidos". Revise e converta em paciente quando quiser.</div>
|
||||
</div>
|
||||
|
||||
<!-- Big CTA -->
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">Envio rápido</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Copie e mande por WhatsApp / e-mail. O paciente preenche e você recebe o cadastro no sistema.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
label="Copiar link agora"
|
||||
class="md:shrink-0"
|
||||
@click="copyLink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Safety note -->
|
||||
<Message severity="warn" :closable="false">
|
||||
<b>Dica:</b> ao gerar um novo link, o anterior deve deixar de funcionar. Use isso quando você quiser “revogar” um link que já foi compartilhado.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Concept / Instructions -->
|
||||
<div class="lg:col-span-5">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div class="p-5 border-b border-slate-200">
|
||||
<div class="text-lg font-semibold text-slate-900">Como funciona</div>
|
||||
<div class="text-slate-600 text-sm mt-1">
|
||||
Um fluxo simples, mas com cuidado clínico: menos fricção, mais adesão.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<ol class="space-y-4">
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">1</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">Você envia o link</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Pode ser WhatsApp, e-mail ou mensagem direta. O link abre a página de cadastro externo.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">2</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">O paciente preenche</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Campos opcionais podem ser deixados em branco. A ideia é reduzir ansiedade e acelerar o início.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">3</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">Você recebe no admin</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Os dados entram como “cadastro recebido”. Você revisa, completa e transforma em paciente quando quiser.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="font-semibold text-slate-900 flex items-center gap-2">
|
||||
<i class="pi pi-shield text-slate-700"></i>
|
||||
Boas práticas
|
||||
</div>
|
||||
<ul class="mt-2 space-y-2 text-sm text-slate-700">
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Gere um novo link se você suspeitar que ele foi repassado indevidamente.</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Envie junto uma mensagem curta: “preencha com calma; campos opcionais podem ficar em branco”.</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Evite divulgar em público; é um link pensado para compartilhamento individual.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-slate-500">
|
||||
Se você quiser, eu deixo este card ainda mais “noir” (contraste, microtextos, ícones, sombras) sem perder legibilidade.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Small helper card -->
|
||||
<div class="mt-4 rounded-2xl border border-slate-200 bg-white shadow-sm p-5">
|
||||
<div class="font-semibold text-slate-900">Mensagem pronta (copiar/colar)</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Se quiser, use este texto ao enviar o link:
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-xl bg-slate-50 border border-slate-200 p-3 text-sm text-slate-800">
|
||||
Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
|
||||
<span class="block mt-2 font-mono break-words">{{ publicUrl || '…' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
label="Copiar mensagem"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:disabled="!publicUrl"
|
||||
@click="copyInviteMessage"
|
||||
/>
|
||||
</div>
|
||||
<!-- Boas práticas -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-3">
|
||||
<i class="pi pi-shield text-sm text-[var(--text-color-secondary)]" />
|
||||
Boas práticas
|
||||
</div>
|
||||
<ul class="space-y-2.5">
|
||||
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
|
||||
<span>Gere um novo link se suspeitar que ele foi repassado indevidamente.</span>
|
||||
</li>
|
||||
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
|
||||
<span>Informe o paciente que campos opcionais podem ficar em branco.</span>
|
||||
</li>
|
||||
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
|
||||
<span>Evite divulgar em público; é um link para compartilhamento individual.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast is global in layout usually; if not, add <Toast /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
import Card from 'primevue/card'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import Message from 'primevue/message'
|
||||
import Menu from 'primevue/menu'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
@@ -252,12 +243,25 @@ const toast = useToast()
|
||||
const inviteToken = ref('')
|
||||
const rotating = ref(false)
|
||||
|
||||
/**
|
||||
* Se o cadastro externo estiver em outro domínio, fixe aqui:
|
||||
* ex.: const PUBLIC_BASE_URL = 'https://seusite.com'
|
||||
* se vazio, usa window.location.origin
|
||||
*/
|
||||
const PUBLIC_BASE_URL = '' // opcional
|
||||
// ── Hero sticky ────────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
// ── Mobile menu ────────────────────────────────────────────
|
||||
const mobileMenuRef = ref(null)
|
||||
|
||||
const mobileMenuItems = computed(() => [
|
||||
{ label: 'Copiar link', icon: 'pi pi-copy', command: () => copyLink(), disabled: !inviteToken.value },
|
||||
{ label: 'Copiar mensagem', icon: 'pi pi-comment', command: () => copyInviteMessage(), disabled: !inviteToken.value },
|
||||
{ label: 'Abrir no navegador', icon: 'pi pi-external-link', command: () => openLink(), disabled: !inviteToken.value },
|
||||
{ separator: true },
|
||||
{ label: 'Gerar novo link', icon: 'pi pi-refresh', command: () => rotateLink() }
|
||||
])
|
||||
|
||||
// ── URL base ────────────────────────────────────────────────
|
||||
const PUBLIC_BASE_URL = ''
|
||||
|
||||
const origin = computed(() => {
|
||||
if (PUBLIC_BASE_URL) return PUBLIC_BASE_URL
|
||||
@@ -269,12 +273,13 @@ const publicUrl = computed(() => {
|
||||
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
|
||||
})
|
||||
|
||||
function newToken () {
|
||||
// ── Token helpers ───────────────────────────────────────────
|
||||
function newToken() {
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
|
||||
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||
}
|
||||
|
||||
async function requireUserId () {
|
||||
async function requireUserId() {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
const uid = data?.user?.id
|
||||
@@ -282,7 +287,7 @@ async function requireUserId () {
|
||||
return uid
|
||||
}
|
||||
|
||||
async function loadOrCreateInvite () {
|
||||
async function loadOrCreateInvite() {
|
||||
const uid = await requireUserId()
|
||||
|
||||
const { data, error } = await supabase
|
||||
@@ -310,16 +315,14 @@ async function loadOrCreateInvite () {
|
||||
inviteToken.value = t
|
||||
}
|
||||
|
||||
async function rotateLink () {
|
||||
async function rotateLink() {
|
||||
rotating.value = true
|
||||
try {
|
||||
const uid = await requireUserId()
|
||||
const t = newToken()
|
||||
|
||||
// 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() })
|
||||
@@ -334,7 +337,7 @@ async function rotateLink () {
|
||||
}
|
||||
|
||||
inviteToken.value = t
|
||||
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 })
|
||||
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado. O anterior foi revogado.', life: 2500 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 })
|
||||
} finally {
|
||||
@@ -342,40 +345,138 @@ async function rotateLink () {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink () {
|
||||
async function copyLink() {
|
||||
try {
|
||||
if (!publicUrl.value) return
|
||||
await navigator.clipboard.writeText(publicUrl.value)
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado.', life: 1500 })
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado para a área de transferência.', life: 1500 })
|
||||
} catch {
|
||||
// fallback clássico
|
||||
window.prompt('Copie o link:', publicUrl.value)
|
||||
}
|
||||
}
|
||||
|
||||
function openLink () {
|
||||
function openLink() {
|
||||
if (!publicUrl.value) return
|
||||
window.open(publicUrl.value, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
async function copyInviteMessage () {
|
||||
async function copyInviteMessage() {
|
||||
try {
|
||||
if (!publicUrl.value) return
|
||||
const msg =
|
||||
`Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
|
||||
${publicUrl.value}`
|
||||
const msg = `Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:\n${publicUrl.value}`
|
||||
await navigator.clipboard.writeText(msg)
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada.', life: 1500 })
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada para a área de transferência.', life: 1500 })
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
|
||||
try {
|
||||
await loadOrCreateInvite()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 })
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Sentinel ─────────────────────────────────────── */
|
||||
.extlink-sentinel { height: 1px; }
|
||||
|
||||
/* ── Hero ─────────────────────────────────────────── */
|
||||
.extlink-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.extlink-hero--stuck {
|
||||
margin-left: 0; margin-right: 0;
|
||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
/* Blobs decorativos */
|
||||
.extlink-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.extlink-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.extlink-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
|
||||
.extlink-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
|
||||
|
||||
/* Linha 1 */
|
||||
.extlink-hero__row1 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.extlink-hero__brand {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.extlink-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.extlink-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.extlink-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
/* Linha 2 */
|
||||
.extlink-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.extlink-hero__divider,
|
||||
.extlink-hero__row2 { display: none; }
|
||||
}
|
||||
|
||||
/* ── CTA button ───────────────────────────────────── */
|
||||
.extlink-cta-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
|
||||
text-align: left;
|
||||
}
|
||||
.extlink-cta-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.extlink-cta-btn:active { transform: translateY(0); }
|
||||
.extlink-cta-btn__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.25rem; height: 2.25rem;
|
||||
border-radius: 0.75rem; flex-shrink: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ── Step numbers ─────────────────────────────────── */
|
||||
.extlink-step {
|
||||
display: grid; place-items: center;
|
||||
width: 2rem; height: 2rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.8rem; font-weight: 700;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
<!-- src/views/pages/patients/PatientIntakeRequestsPage.vue -->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Tag from 'primevue/tag'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ConfirmDialog from 'primevue/confirmdialog'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Avatar from 'primevue/avatar'
|
||||
import Menu from 'primevue/menu'
|
||||
|
||||
import { brToISO, isoToBR } from '@/utils/dateBR'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
const converting = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -227,7 +224,7 @@ function fmtDate (iso) {
|
||||
return d.toLocaleString('pt-BR')
|
||||
}
|
||||
|
||||
// converte nascimento para ISO date (YYYY-MM-DD) usando teu utils
|
||||
// converte nascimento para ISO date (YYYY-MM-DD)
|
||||
function normalizeBirthToISO (v) {
|
||||
if (!v) return null
|
||||
const s = String(v).trim()
|
||||
@@ -248,6 +245,34 @@ function normalizeBirthToISO (v) {
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Tenant + Responsible Member (para satisfazer trigger)
|
||||
// -----------------------------
|
||||
async function getTenantIdForConversion (item) {
|
||||
// intake NÃO tem tenant_id hoje, então usamos o contexto
|
||||
const fromStore =
|
||||
tenantStore?.activeTenantId ||
|
||||
tenantStore?.currentTenantId ||
|
||||
tenantStore?.tenantId ||
|
||||
tenantStore?.tenant?.id
|
||||
|
||||
return fromStore || null
|
||||
}
|
||||
|
||||
async function getResponsibleMemberId (tenantId, userId) {
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'active')
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
if (!data?.id) throw new Error('Responsible member not found')
|
||||
return data.id
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Seções do modal
|
||||
// -----------------------------
|
||||
@@ -420,19 +445,19 @@ async function markRejected () {
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Converter
|
||||
// Converter (com tenant_id + responsible_member_id)
|
||||
// -----------------------------
|
||||
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') {
|
||||
// só bloqueia cadastros já convertidos
|
||||
if (item.status === 'converted') {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Atenção',
|
||||
detail: 'Só é possível converter cadastros com status "Novo".',
|
||||
detail: 'Este cadastro já foi convertido em paciente.',
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
@@ -447,19 +472,27 @@ async function convertToPatient () {
|
||||
const ownerId = userData?.user?.id
|
||||
if (!ownerId) throw new Error('Sessão inválida.')
|
||||
|
||||
const tenantId = await getTenantIdForConversion(item)
|
||||
if (!tenantId) throw new Error('tenant_id is required')
|
||||
|
||||
const responsibleMemberId = await getResponsibleMemberId(tenantId, ownerId)
|
||||
|
||||
const cleanStr = (v) => {
|
||||
const s = String(v ?? '').trim()
|
||||
return s ? s : null
|
||||
}
|
||||
|
||||
const digitsOnly = (v) => {
|
||||
const d = String(v ?? '').replace(/\D/g, '')
|
||||
return d ? d : null
|
||||
}
|
||||
|
||||
// tenta reaproveitar avatar do intake (se vier url/path)
|
||||
// tenta reaproveitar avatar do intake
|
||||
const intakeAvatar = cleanStr(item.avatar_url) || cleanStr(item.foto_url) || cleanStr(item.photo_url) || null
|
||||
|
||||
const patientPayload = {
|
||||
tenant_id: tenantId,
|
||||
responsible_member_id: responsibleMemberId,
|
||||
owner_id: ownerId,
|
||||
|
||||
// identificação/contato
|
||||
@@ -471,7 +504,7 @@ async function convertToPatient () {
|
||||
telefone_alternativo: digitsOnly(fTelAlt(item)),
|
||||
|
||||
// pessoais
|
||||
data_nascimento: normalizeBirthToISO(fNasc(item)), // ✅ agora é sempre ISO date
|
||||
data_nascimento: normalizeBirthToISO(fNasc(item)),
|
||||
naturalidade: cleanStr(fNaturalidade(item)),
|
||||
genero: cleanStr(fGenero(item)),
|
||||
estado_civil: cleanStr(fEstadoCivil(item)),
|
||||
@@ -520,6 +553,7 @@ async function convertToPatient () {
|
||||
const patientId = created?.id
|
||||
if (!patientId) throw new Error('Falha ao obter ID do paciente criado.')
|
||||
|
||||
// ✅ intake é externo: não prenda por owner_id aqui
|
||||
const { error: upErr } = await supabase
|
||||
.from('patient_intake_requests')
|
||||
.update({
|
||||
@@ -528,7 +562,6 @@ async function convertToPatient () {
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', item.id)
|
||||
.eq('owner_id', ownerId)
|
||||
|
||||
if (upErr) throw upErr
|
||||
|
||||
@@ -537,6 +570,7 @@ async function convertToPatient () {
|
||||
dlg.value.open = false
|
||||
await fetchIntakes()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Falha ao converter',
|
||||
@@ -557,135 +591,125 @@ const totals = computed(() => {
|
||||
return { total, nNew, nConv, nRej }
|
||||
})
|
||||
|
||||
onMounted(fetchIntakes)
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
const recMobileMenuRef = ref(null)
|
||||
const recSearchDlgOpen = ref(false)
|
||||
|
||||
const recMobileMenuItems = computed(() => [
|
||||
{ label: 'Buscar', icon: 'pi pi-search', command: () => { recSearchDlgOpen.value = true } },
|
||||
{ separator: true },
|
||||
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchIntakes() }
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
fetchIntakes()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<ConfirmDialog />
|
||||
<Toast />
|
||||
<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>
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="rec-sentinel" />
|
||||
|
||||
<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>
|
||||
<!-- Hero sticky -->
|
||||
<div ref="headerEl" class="rec-hero mx-3 md:mx-5 mb-4" :class="{ 'rec-hero--stuck': headerStuck }">
|
||||
<div class="rec-hero__blobs" aria-hidden="true">
|
||||
<div class="rec-hero__blob rec-hero__blob--1" />
|
||||
<div class="rec-hero__blob rec-hero__blob--2" />
|
||||
</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 -->
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'new'"
|
||||
:severity="statusFilter === 'new' ? 'info' : 'secondary'"
|
||||
@click="toggleStatusFilter('new')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-sparkles" />
|
||||
Novos: <b>{{ totals.nNew }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'converted'"
|
||||
:severity="statusFilter === 'converted' ? 'success' : 'secondary'"
|
||||
@click="toggleStatusFilter('converted')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-check" />
|
||||
Convertidos: <b>{{ totals.nConv }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'rejected'"
|
||||
:severity="statusFilter === 'rejected' ? 'danger' : 'secondary'"
|
||||
@click="toggleStatusFilter('rejected')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-times" />
|
||||
Rejeitados: <b>{{ totals.nRej }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="statusFilter"
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
severity="secondary"
|
||||
outlined
|
||||
icon="pi pi-filter-slash"
|
||||
label="Limpar filtro"
|
||||
@click="statusFilter = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:items-center">
|
||||
<span class="p-input-icon-left w-full sm:w-[360px]">
|
||||
<InputText
|
||||
v-model="q"
|
||||
class="w-full"
|
||||
placeholder="Buscar por nome, e-mail ou telefone…"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
label="Atualizar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="loading"
|
||||
@click="fetchIntakes"
|
||||
/>
|
||||
</div>
|
||||
<!-- Linha 1 -->
|
||||
<div class="rec-hero__row1">
|
||||
<div class="rec-hero__brand">
|
||||
<div class="rec-hero__icon"><i class="pi pi-inbox text-lg" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="rec-hero__title">Cadastros recebidos</div>
|
||||
<Tag :value="`${totals.total}`" severity="secondary" />
|
||||
</div>
|
||||
<div class="rec-hero__sub">Pré-cadastros externos para avaliar e converter em pacientes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop (≥1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchIntakes" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile (<1200px) -->
|
||||
<div class="flex xl:hidden items-center shrink-0">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => recMobileMenuRef.toggle(e)" />
|
||||
<Menu ref="recMobileMenuRef" :model="recMobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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" />
|
||||
<!-- Divisor -->
|
||||
<Divider class="rec-hero__divider my-2" />
|
||||
|
||||
<!-- Linha 2: filtros de status + busca -->
|
||||
<div class="rec-hero__row2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'new'" :severity="statusFilter === 'new' ? 'info' : 'secondary'" @click="toggleStatusFilter('new')">
|
||||
<span class="flex items-center gap-1.5"><i class="pi pi-sparkles text-xs" /> Novos: <b>{{ totals.nNew }}</b></span>
|
||||
</Button>
|
||||
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'converted'" :severity="statusFilter === 'converted' ? 'success' : 'secondary'" @click="toggleStatusFilter('converted')">
|
||||
<span class="flex items-center gap-1.5"><i class="pi pi-check text-xs" /> Convertidos: <b>{{ totals.nConv }}</b></span>
|
||||
</Button>
|
||||
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'rejected'" :severity="statusFilter === 'rejected' ? 'danger' : 'secondary'" @click="toggleStatusFilter('rejected')">
|
||||
<span class="flex items-center gap-1.5"><i class="pi pi-times text-xs" /> Rejeitados: <b>{{ totals.nRej }}</b></span>
|
||||
</Button>
|
||||
<Button v-if="statusFilter" type="button" size="small" class="!rounded-full" severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar" @click="statusFilter = ''" />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
v-else
|
||||
:value="filteredRows"
|
||||
dataKey="id"
|
||||
paginator
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
responsiveLayout="scroll"
|
||||
stripedRows
|
||||
class="!border-0"
|
||||
>
|
||||
<InputGroup class="w-72 shrink-0">
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="q" placeholder="Nome, e-mail ou telefone…" :disabled="loading" />
|
||||
<Button v-if="q" icon="pi pi-trash" severity="danger" title="Limpar" @click="q = ''" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog busca mobile -->
|
||||
<Dialog v-model:visible="recSearchDlgOpen" modal :draggable="false" header="Buscar cadastro" class="w-[94vw] max-w-sm">
|
||||
<div class="pt-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="q" placeholder="Nome, e-mail ou telefone…" autofocus />
|
||||
<Button v-if="q" icon="pi pi-trash" severity="danger" @click="q = ''" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="recSearchDlgOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- TABLE – desktop (md+) -->
|
||||
<div class="hidden md:block mx-3 md:mx-5 mb-5 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<DataTable
|
||||
:value="filteredRows"
|
||||
:loading="loading"
|
||||
dataKey="id"
|
||||
paginator
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
stripedRows
|
||||
class="!border-0"
|
||||
>
|
||||
<Column header="Status" style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
|
||||
@@ -734,13 +758,67 @@ onMounted(fetchIntakes)
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-color-secondary py-6 text-center">
|
||||
Nenhum cadastro encontrado.
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-inbox text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum cadastro encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">
|
||||
{{ q || statusFilter ? 'Tente limpar os filtros ou mudar o termo de busca.' : 'Ainda não há cadastros recebidos.' }}
|
||||
</div>
|
||||
<div v-if="q || statusFilter" class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="q = ''; statusFilter = ''" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- TABLE – mobile cards (<md) -->
|
||||
<div class="md:hidden mx-3 mb-5">
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredRows.length === 0" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-inbox text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum cadastro encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">
|
||||
{{ q || statusFilter ? 'Tente limpar os filtros ou mudar o termo de busca.' : 'Ainda não há cadastros recebidos.' }}
|
||||
</div>
|
||||
<div v-if="q || statusFilter" class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="q = ''; statusFilter = ''" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="row in filteredRows"
|
||||
:key="row.id"
|
||||
class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar v-if="avatarUrl(row)" :image="avatarUrl(row)" shape="circle" />
|
||||
<Avatar v-else icon="pi pi-user" shape="circle" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold truncate">{{ fNome(row) || '—' }}</div>
|
||||
<div class="text-sm text-color-secondary truncate">{{ fEmail(row) || '—' }}</div>
|
||||
</div>
|
||||
<Tag :value="statusLabel(row.status)" :severity="statusSeverity(row.status)" />
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<div class="text-sm text-color-secondary flex flex-col gap-0.5">
|
||||
<span>{{ fmtPhoneBR(fTel(row)) }}</span>
|
||||
<span>{{ fmtDate(row.created_at) }}</span>
|
||||
</div>
|
||||
<Button icon="pi pi-eye" label="Ver" severity="secondary" outlined size="small" @click="openDetails(row)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODAL -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
@@ -748,6 +826,7 @@ onMounted(fetchIntakes)
|
||||
:header="null"
|
||||
:style="{ width: 'min(940px, 96vw)' }"
|
||||
:contentStyle="{ padding: 0 }"
|
||||
:draggable="false"
|
||||
@hide="closeDlg"
|
||||
>
|
||||
<div v-if="dlg.item" class="relative">
|
||||
@@ -878,5 +957,49 @@ onMounted(fetchIntakes)
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rec-sentinel { height: 1px; }
|
||||
|
||||
.rec-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.rec-hero--stuck {
|
||||
margin-left: 0; margin-right: 0;
|
||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.rec-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.rec-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.rec-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
|
||||
.rec-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
|
||||
|
||||
.rec-hero__row1 { position: relative; z-index: 1; display: flex; align-items: center; gap: 1rem; }
|
||||
.rec-hero__brand { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; }
|
||||
.rec-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.rec-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.rec-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
.rec-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; flex-wrap: wrap; align-items: center;
|
||||
justify-content: space-between; gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.rec-hero__divider,
|
||||
.rec-hero__row2 { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +1,79 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- TOOLBAR (padrão Sakai CRUD) -->
|
||||
<Toolbar class="mb-4">
|
||||
<template #start>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-xl font-semibold leading-none">Grupos de Pacientes</div>
|
||||
<small class="text-color-secondary mt-1">
|
||||
Organize seus pacientes por grupos. Alguns grupos são padrões do sistema e não podem ser alterados.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
<Toast />
|
||||
|
||||
<template #end>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
label="Excluir selecionados"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
:disabled="!selectedGroups || !selectedGroups.length"
|
||||
@click="confirmDeleteSelected"
|
||||
/>
|
||||
<Button label="Adicionar" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
</template>
|
||||
</Toolbar>
|
||||
<!-- Sentinel para detecção de sticky -->
|
||||
<div ref="headerSentinelRef" class="grp-sentinel" />
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- Hero Header sticky -->
|
||||
<div ref="headerEl" class="grp-hero mx-3 md:mx-5 mb-4" :class="{ 'grp-hero--stuck': headerStuck }">
|
||||
<div class="grp-hero__blobs" aria-hidden="true">
|
||||
<div class="grp-hero__blob grp-hero__blob--1" />
|
||||
<div class="grp-hero__blob grp-hero__blob--2" />
|
||||
</div>
|
||||
|
||||
<!-- Linha 1 -->
|
||||
<div class="grp-hero__row1">
|
||||
<div class="grp-hero__brand">
|
||||
<div class="grp-hero__icon"><i class="pi pi-sitemap text-lg" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="grp-hero__title">Grupos</div>
|
||||
<Tag :value="`${groups.length}`" severity="secondary" />
|
||||
</div>
|
||||
<div class="grp-hero__sub">Organize seus pacientes por grupos temáticos ou clínicos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop (≥1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
v-if="selectedGroups?.length"
|
||||
label="Excluir selecionados"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
@click="confirmDeleteSelected"
|
||||
/>
|
||||
<Button label="Novo" icon="pi pi-plus" class="rounded-full" @click="openCreate" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="fetchAll" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile (<1200px) -->
|
||||
<div class="flex xl:hidden items-center shrink-0">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => grpMobileMenuRef.toggle(e)" />
|
||||
<Menu ref="grpMobileMenuRef" :model="grpMobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divisor -->
|
||||
<Divider class="grp-hero__divider my-2" />
|
||||
|
||||
<!-- Linha 2: busca (oculta no mobile) -->
|
||||
<div class="grp-hero__row2">
|
||||
<InputGroup class="w-72">
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filters.global.value" placeholder="Buscar grupo..." :disabled="loading" />
|
||||
<Button v-if="filters.global.value" icon="pi pi-trash" severity="danger" title="Limpar" @click="filters.global.value = null" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog de busca (mobile) -->
|
||||
<Dialog v-model:visible="grpSearchDlgOpen" modal :draggable="false" header="Buscar grupo" class="w-[94vw] max-w-sm">
|
||||
<div class="pt-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filters.global.value" placeholder="Nome do grupo..." autofocus />
|
||||
<Button v-if="filters.global.value" icon="pi pi-trash" severity="danger" @click="filters.global.value = null" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="grpSearchDlgOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 mb-5">
|
||||
<!-- LEFT: TABLE -->
|
||||
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
|
||||
<Card class="h-full">
|
||||
@@ -48,16 +95,9 @@
|
||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} grupos"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-wrap gap-2 items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">Lista de Grupos</span>
|
||||
<Tag :value="`${groups.length} grupos`" severity="secondary" />
|
||||
</div>
|
||||
|
||||
<IconField>
|
||||
<InputIcon><i class="pi pi-search" /></InputIcon>
|
||||
<InputText v-model="filters.global.value" placeholder="Buscar grupos..." class="w-64" />
|
||||
</IconField>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">Lista de Grupos</span>
|
||||
<Tag :value="`${groups.length} grupos`" severity="secondary" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -73,7 +113,18 @@
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="nome" header="Nome" sortable style="min-width: 16rem" />
|
||||
<Column field="nome" header="Nome" sortable style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="data.cor"
|
||||
class="inline-block w-3 h-3 rounded-full flex-shrink-0"
|
||||
:style="colorStyle(data.cor)"
|
||||
/>
|
||||
<span>{{ data.nome }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Origem" sortable sortField="is_system" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
@@ -116,14 +167,24 @@
|
||||
outlined
|
||||
rounded
|
||||
disabled
|
||||
v-tooltip.top="'Grupo padrão do sistema (inalterável)'"
|
||||
title="Grupo padrão do sistema (inalterável)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
Nenhum grupo encontrado.
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum grupo encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">Tente limpar o filtro ou crie um novo grupo.</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtro" @click="filters.global.value = null" />
|
||||
<Button icon="pi pi-plus" label="Criar grupo" @click="openCreate" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -192,31 +253,118 @@
|
||||
|
||||
<!-- DIALOG CREATE / EDIT -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
:header="dlg.mode === 'create' ? 'Criar Grupo' : 'Editar Grupo'"
|
||||
modal
|
||||
:style="{ width: '520px', maxWidth: '92vw' }"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="block mb-2">Nome do Grupo</label>
|
||||
<InputText v-model="dlg.nome" class="w-full" :disabled="dlg.saving" />
|
||||
<small class="text-color-secondary">
|
||||
Grupos “Padrão” são do sistema e não podem ser editados.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
v-model:visible="dlg.open"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!dlg.saving"
|
||||
:dismissableMask="!dlg.saving"
|
||||
class="grp-dialog w-[96vw] max-w-lg"
|
||||
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' } }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex w-full items-center justify-between gap-3 px-1">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="grp-dlg-dot shrink-0" :style="{ backgroundColor: dlgPreviewColor }" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">
|
||||
{{ dlg.nome || (dlg.mode === 'create' ? 'Novo grupo' : 'Editar grupo') }}
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
{{ dlg.mode === 'create' ? 'Criar tipo de grupo' : 'Editar tipo de grupo' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text :disabled="dlg.saving" @click="dlg.open = false" />
|
||||
<Button
|
||||
:label="dlg.mode === 'create' ? 'Criar' : 'Salvar'"
|
||||
:loading="dlg.saving"
|
||||
@click="saveDialog"
|
||||
:disabled="!String(dlg.nome || '').trim()"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:disabled="dlg.saving"
|
||||
@click="dlg.open = false"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
class="rounded-full"
|
||||
:loading="dlg.saving"
|
||||
:disabled="!String(dlg.nome || '').trim()"
|
||||
@click="saveDialog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Banner de preview -->
|
||||
<div class="grp-dlg-banner" :style="{ backgroundColor: dlgPreviewColor }">
|
||||
<span class="grp-dlg-banner__pill">{{ dlg.nome || 'Nome do grupo' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Corpo -->
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
|
||||
<!-- Nome -->
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<i class="pi pi-sitemap" />
|
||||
</InputIcon>
|
||||
|
||||
<InputText
|
||||
id="grp-nome"
|
||||
v-model="dlg.nome"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="dlg.saving"
|
||||
@keydown.enter.prevent="saveDialog"
|
||||
/>
|
||||
</IconField>
|
||||
|
||||
<label for="grp-nome">Nome do grupo *</label>
|
||||
</FloatLabel>
|
||||
|
||||
<!-- Cor -->
|
||||
<div class="grp-dlg-section">
|
||||
<div class="grp-dlg-section__label">Cor</div>
|
||||
|
||||
<div class="grp-dlg-palette">
|
||||
|
||||
<button
|
||||
v-for="p in dlgPresetColors"
|
||||
:key="p.bg"
|
||||
class="grp-dlg-swatch"
|
||||
:class="{ 'grp-dlg-swatch--active': dlg.cor === p.bg }"
|
||||
:style="{ backgroundColor: `#${p.bg}` }"
|
||||
:title="p.name"
|
||||
:disabled="dlg.saving"
|
||||
@click="dlg.cor = p.bg"
|
||||
>
|
||||
<i v-if="dlg.cor === p.bg" class="pi pi-check grp-dlg-swatch__check" />
|
||||
</button>
|
||||
|
||||
<!-- Custom ColorPicker -->
|
||||
<div class="grp-dlg-swatch grp-dlg-swatch--custom" title="Cor personalizada">
|
||||
<ColorPicker v-model="dlg.cor" format="hex" :disabled="dlg.saving" />
|
||||
</div>
|
||||
|
||||
<!-- Limpar cor -->
|
||||
<button
|
||||
v-if="dlg.cor"
|
||||
class="grp-dlg-swatch grp-dlg-swatch--clear"
|
||||
title="Sem cor"
|
||||
:disabled="dlg.saving"
|
||||
@click="dlg.cor = ''"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- ✅ DIALOG PACIENTES (com botão Abrir) -->
|
||||
<Dialog
|
||||
@@ -253,8 +401,12 @@
|
||||
</Message>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="patientsDialog.items.length === 0" class="text-color-secondary">
|
||||
Nenhum paciente associado a este grupo.
|
||||
<div v-if="patientsDialog.items.length === 0" class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-users text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente neste grupo</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">Associe pacientes a este grupo na página de pacientes.</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
@@ -299,7 +451,16 @@
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-color-secondary py-5">Nenhum resultado para "{{ patientsDialog.search }}".</div>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum resultado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">Nenhum paciente corresponde a "{{ patientsDialog.search }}".</div>
|
||||
<div class="mt-4">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar busca" @click="patientsDialog.search = ''" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
@@ -311,17 +472,17 @@
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Menu from 'primevue/menu'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
import {
|
||||
@@ -335,6 +496,24 @@ const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
// ── Mobile menu ───────────────────────────────────────────
|
||||
const grpMobileMenuRef = ref(null)
|
||||
const grpSearchDlgOpen = ref(false)
|
||||
|
||||
const grpMobileMenuItems = computed(() => [
|
||||
{ label: 'Adicionar grupo', icon: 'pi pi-plus', command: () => openCreate() },
|
||||
{ label: 'Buscar', icon: 'pi pi-search', command: () => { grpSearchDlgOpen.value = true } },
|
||||
{ separator: true },
|
||||
...(selectedGroups.value?.length ? [{ label: 'Excluir selecionados', icon: 'pi pi-trash', command: () => confirmDeleteSelected() }, { separator: true }] : []),
|
||||
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => fetchAll() }
|
||||
])
|
||||
|
||||
const dt = ref(null)
|
||||
const loading = ref(false)
|
||||
const groups = ref([])
|
||||
@@ -350,9 +529,30 @@ const dlg = reactive({
|
||||
mode: 'create', // 'create' | 'edit'
|
||||
id: '',
|
||||
nome: '',
|
||||
cor: '',
|
||||
saving: false
|
||||
})
|
||||
|
||||
const dlgPresetColors = [
|
||||
{ bg: '6366f1', name: 'Índigo' },
|
||||
{ bg: '8b5cf6', name: 'Violeta' },
|
||||
{ bg: 'ec4899', name: 'Rosa' },
|
||||
{ bg: 'ef4444', name: 'Vermelho' },
|
||||
{ bg: 'f97316', name: 'Laranja' },
|
||||
{ bg: 'eab308', name: 'Amarelo' },
|
||||
{ bg: '22c55e', name: 'Verde' },
|
||||
{ bg: '14b8a6', name: 'Teal' },
|
||||
{ bg: '3b82f6', name: 'Azul' },
|
||||
{ bg: '06b6d4', name: 'Ciano' },
|
||||
{ bg: '64748b', name: 'Ardósia' },
|
||||
{ bg: '292524', name: 'Escuro' },
|
||||
]
|
||||
|
||||
const dlgPreviewColor = computed(() => {
|
||||
if (!dlg.cor) return '#64748b'
|
||||
return dlg.cor.startsWith('#') ? dlg.cor : `#${dlg.cor}`
|
||||
})
|
||||
|
||||
const patientsDialog = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
@@ -428,6 +628,12 @@ function patientsLabel (n) {
|
||||
return n === 1 ? '1 paciente' : `${n} pacientes`
|
||||
}
|
||||
|
||||
function colorStyle (cor) {
|
||||
if (!cor) return {}
|
||||
const hex = String(cor).startsWith('#') ? cor : '#' + cor
|
||||
return { background: hex }
|
||||
}
|
||||
|
||||
function humanizeError (err) {
|
||||
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.'
|
||||
const code = err?.code
|
||||
@@ -482,6 +688,7 @@ function openCreate () {
|
||||
dlg.mode = 'create'
|
||||
dlg.id = ''
|
||||
dlg.nome = ''
|
||||
dlg.cor = ''
|
||||
}
|
||||
|
||||
function openEdit (row) {
|
||||
@@ -489,6 +696,7 @@ function openEdit (row) {
|
||||
dlg.mode = 'edit'
|
||||
dlg.id = row.id
|
||||
dlg.nome = row.nome
|
||||
dlg.cor = row.cor || ''
|
||||
}
|
||||
|
||||
async function saveDialog () {
|
||||
@@ -502,13 +710,16 @@ async function saveDialog () {
|
||||
return
|
||||
}
|
||||
|
||||
const corRaw = String(dlg.cor || '').trim()
|
||||
const cor = corRaw ? (corRaw.startsWith('#') ? corRaw : `#${corRaw}`) : null
|
||||
|
||||
dlg.saving = true
|
||||
try {
|
||||
if (dlg.mode === 'create') {
|
||||
await createGroup(nome)
|
||||
await createGroup(nome, cor)
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 })
|
||||
} else {
|
||||
await updateGroup(dlg.id, nome)
|
||||
await updateGroup(dlg.id, nome, cor)
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo atualizado.', life: 2500 })
|
||||
}
|
||||
dlg.open = false
|
||||
@@ -653,12 +864,125 @@ function abrirPaciente (patient) {
|
||||
router.push(`/features/patients/cadastro/${patient.id}`)
|
||||
}
|
||||
|
||||
onMounted(fetchAll)
|
||||
onMounted(() => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
fetchAll()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active { transition: opacity .14s ease; }
|
||||
.fade-enter-from,
|
||||
.fade-leave-to { opacity: 0; }
|
||||
/* ── Hero ────────────────────────────────────────── */
|
||||
.grp-sentinel { height: 1px; }
|
||||
|
||||
.grp-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.grp-hero--stuck {
|
||||
margin-left: 0; margin-right: 0;
|
||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.grp-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.grp-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.grp-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(16,185,129,0.10); }
|
||||
.grp-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
|
||||
|
||||
.grp-hero__row1 { position: relative; z-index: 1; display: flex; align-items: center; gap: 1rem; }
|
||||
.grp-hero__brand { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; }
|
||||
.grp-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.grp-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.grp-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
.grp-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.grp-hero__divider,
|
||||
.grp-hero__row2 { display: none; }
|
||||
}
|
||||
|
||||
/* ── Dialog ──────────────────────────────────────── */
|
||||
.grp-dlg-dot {
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
box-shadow: 0 0 0 3px rgba(0,0,0,0.08);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.grp-dlg-banner {
|
||||
height: 72px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background-color 0.25s ease;
|
||||
}
|
||||
.grp-dlg-banner__pill {
|
||||
font-size: 1rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
padding: 0.35rem 1.1rem;
|
||||
background: rgba(0,0,0,0.15);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(4px);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.grp-dlg-section {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--surface-card);
|
||||
padding: 1rem;
|
||||
}
|
||||
.grp-dlg-section__label {
|
||||
font-size: 0.7rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
opacity: 0.45; margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.grp-dlg-palette { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||
|
||||
.grp-dlg-swatch {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
display: grid; place-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.grp-dlg-swatch:hover:not(:disabled) { transform: scale(1.18); box-shadow: 0 3px 10px rgba(0,0,0,0.2); }
|
||||
.grp-dlg-swatch--active {
|
||||
border-color: var(--surface-0, #fff);
|
||||
box-shadow: 0 0 0 2px var(--text-color);
|
||||
}
|
||||
.grp-dlg-swatch__check { font-size: 0.6rem; color: #fff; font-weight: 900; }
|
||||
.grp-dlg-swatch--custom {
|
||||
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
|
||||
overflow: hidden;
|
||||
}
|
||||
.grp-dlg-swatch--custom :deep(.p-colorpicker-preview) {
|
||||
width: 100%; height: 100%; border: none; border-radius: 50%; opacity: 0;
|
||||
}
|
||||
.grp-dlg-swatch--clear {
|
||||
background: var(--surface-border);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Fade */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity .14s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import Toast from 'primevue/toast'
|
||||
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Chip from 'primevue/chip'
|
||||
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
|
||||
import Accordion from 'primevue/accordion'
|
||||
import AccordionPanel from 'primevue/accordionpanel'
|
||||
import AccordionHeader from 'primevue/accordionheader'
|
||||
|
||||
@@ -1,32 +1,88 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- TOOLBAR -->
|
||||
<Toolbar class="mb-4">
|
||||
<template #start>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-xl font-semibold leading-none">Tags de Pacientes</div>
|
||||
<small class="text-color-secondary mt-1">
|
||||
Classifique pacientes por temas (ex.: Burnout, Ansiedade, Triagem). Clique em “Pacientes” para ver a lista.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
<Toast />
|
||||
|
||||
<template #end>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
label="Excluir selecionados"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
:disabled="!etiquetasSelecionadas?.length"
|
||||
@click="confirmarExclusaoSelecionadas"
|
||||
/>
|
||||
<Button label="Adicionar" icon="pi pi-plus" @click="abrirCriar" />
|
||||
</div>
|
||||
</template>
|
||||
</Toolbar>
|
||||
<!-- Sentinel para detecção de sticky -->
|
||||
<div ref="headerSentinelRef" class="tags-sentinel" />
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- Hero Header sticky -->
|
||||
<div ref="headerEl" class="tags-hero mx-3 md:mx-5 mb-4" :class="{ 'tags-hero--stuck': headerStuck }">
|
||||
<!-- Blobs decorativos -->
|
||||
<div class="tags-hero__blobs" aria-hidden="true">
|
||||
<div class="tags-hero__blob tags-hero__blob--1" />
|
||||
<div class="tags-hero__blob tags-hero__blob--2" />
|
||||
</div>
|
||||
|
||||
<!-- Linha 1: brand + controles -->
|
||||
<div class="tags-hero__row1">
|
||||
<div class="tags-hero__brand">
|
||||
<div class="tags-hero__icon">
|
||||
<i class="pi pi-tags text-lg" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="tags-hero__title">Tags</div>
|
||||
<Tag :value="`${etiquetas.length}`" severity="secondary" />
|
||||
</div>
|
||||
<div class="tags-hero__sub">Classifique pacientes por temas — ex.: Burnout, Ansiedade, Triagem</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controles desktop (≥1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
v-if="etiquetasSelecionadas?.length"
|
||||
label="Excluir selecionados"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
@click="confirmarExclusaoSelecionadas"
|
||||
/>
|
||||
<Button label="Nova" icon="pi pi-plus" class="rounded-full" @click="abrirCriar" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="carregando" title="Recarregar" @click="buscarEtiquetas" />
|
||||
</div>
|
||||
|
||||
<!-- Menu mobile (<1200px) -->
|
||||
<div class="flex xl:hidden items-center shrink-0">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divisor -->
|
||||
<Divider class="tags-hero__divider my-2" />
|
||||
|
||||
<!-- Linha 2: busca (oculta no mobile) -->
|
||||
<div class="tags-hero__row2">
|
||||
<InputGroup class="w-72">
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filtros.global.value" placeholder="Buscar tag..." :disabled="carregando" />
|
||||
<Button
|
||||
v-if="filtros.global.value"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
title="Limpar busca"
|
||||
@click="filtros.global.value = null"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog de busca (mobile) -->
|
||||
<Dialog v-model:visible="searchDlgOpen" modal :draggable="false" header="Buscar tag" class="w-[94vw] max-w-sm">
|
||||
<div class="pt-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filtros.global.value" placeholder="Nome da tag..." autofocus />
|
||||
<Button v-if="filtros.global.value" icon="pi pi-trash" severity="danger" title="Limpar" @click="filtros.global.value = null" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchDlgOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 mb-5">
|
||||
<!-- LEFT: tabela -->
|
||||
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
|
||||
<Card class="h-full">
|
||||
@@ -46,34 +102,6 @@
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} tags"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">Lista de Tags</span>
|
||||
<Tag :value="`${etiquetas.length} tags`" severity="secondary" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 w-full md:w-auto">
|
||||
<IconField class="w-full md:w-80">
|
||||
<InputIcon><i class="pi pi-search" /></InputIcon>
|
||||
<InputText
|
||||
v-model="filtros.global.value"
|
||||
placeholder="Buscar tag..."
|
||||
class="w-full"
|
||||
/>
|
||||
</IconField>
|
||||
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
v-tooltip.top="'Atualizar'"
|
||||
@click="buscarEtiquetas"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Seleção (bloqueia tags padrão) -->
|
||||
<Column :exportable="false" headerStyle="width: 3rem">
|
||||
<template #body="{ data }">
|
||||
@@ -124,7 +152,7 @@
|
||||
outlined
|
||||
size="small"
|
||||
:disabled="data.is_padrao"
|
||||
v-tooltip.top="data.is_padrao ? 'Tags padrão não podem ser editadas' : 'Editar'"
|
||||
:title="data.is_padrao ? 'Tags padrão não podem ser editadas' : 'Editar'"
|
||||
@click="abrirEditar(data)"
|
||||
/>
|
||||
<Button
|
||||
@@ -133,7 +161,7 @@
|
||||
outlined
|
||||
size="small"
|
||||
:disabled="data.is_padrao"
|
||||
v-tooltip.top="data.is_padrao ? 'Tags padrão não podem ser excluídas' : 'Excluir'"
|
||||
:title="data.is_padrao ? 'Tags padrão não podem ser excluídas' : 'Excluir'"
|
||||
@click="confirmarExclusaoUma(data)"
|
||||
/>
|
||||
</div>
|
||||
@@ -141,8 +169,20 @@
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-color-secondary py-5">Nenhuma tag encontrada.</div>
|
||||
</template>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhuma tag encontrada</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">
|
||||
Tente limpar filtros ou mudar o termo de busca.
|
||||
</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="filtros.global.value = null" />
|
||||
<Button icon="pi pi-user-plus" label="Cadastrar tag" @click="abrirCriar" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</Card>
|
||||
@@ -155,12 +195,12 @@
|
||||
<template #subtitle>As tags aparecem aqui quando houver pacientes associados.</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="cards.length === 0" class="min-h-[150px] flex flex-col items-center justify-center text-center gap-2">
|
||||
<i class="pi pi-tags text-3xl"></i>
|
||||
<div class="font-medium">Sem dados ainda</div>
|
||||
<small class="text-color-secondary">
|
||||
Quando você associar pacientes às tags, elas aparecem aqui.
|
||||
</small>
|
||||
<div v-if="cards.length === 0" class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-tags text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhuma tag em uso</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">As tags mais usadas aparecem aqui quando houver pacientes associados.</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
@@ -212,64 +252,102 @@
|
||||
<!-- DIALOG CREATE / EDIT -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
:header="dlg.mode === 'create' ? 'Criar Tag' : 'Editar Tag'"
|
||||
modal
|
||||
:style="{ width: '520px', maxWidth: '92vw' }"
|
||||
:draggable="false"
|
||||
:closable="!dlg.saving"
|
||||
:dismissableMask="!dlg.saving"
|
||||
class="tag-dialog w-[96vw] max-w-lg"
|
||||
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' } }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="block mb-2">Nome da Tag</label>
|
||||
<InputText v-model="dlg.nome" class="w-full" :disabled="dlg.saving" />
|
||||
<small class="text-color-secondary">Ex.: Burnout, Ansiedade, Triagem.</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2">Cor (opcional)</label>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<ColorPicker v-model="dlg.cor" format="hex" :disabled="dlg.saving" />
|
||||
|
||||
<InputText
|
||||
v-model="dlg.cor"
|
||||
class="w-44"
|
||||
placeholder="#22c55e"
|
||||
:disabled="dlg.saving"
|
||||
/>
|
||||
|
||||
<template #header>
|
||||
<div class="flex w-full items-center justify-between gap-3 px-1">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
class="inline-block rounded-lg"
|
||||
:style="{
|
||||
width: '34px',
|
||||
height: '34px',
|
||||
border: '1px solid var(--surface-border)',
|
||||
background: corPreview(dlg.cor)
|
||||
}"
|
||||
class="tag-dlg-dot shrink-0"
|
||||
:style="{ backgroundColor: tagDlgPreviewColor }"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">
|
||||
{{ dlg.nome || (dlg.mode === 'create' ? 'Nova tag' : 'Editar tag') }}
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
{{ dlg.mode === 'create' ? 'Criar nova tag' : 'Editando tag' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="dlg.saving" @click="fecharDlg" />
|
||||
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="dlg.saving" :disabled="!String(dlg.nome || '').trim()" @click="salvarDlg" />
|
||||
</div>
|
||||
|
||||
<small class="text-color-secondary">
|
||||
Pode usar HEX (#rrggbb). Se vazio, usamos uma cor neutra.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Banner -->
|
||||
<div class="tag-dlg-banner" :style="{ backgroundColor: tagDlgPreviewColor }">
|
||||
<span class="tag-dlg-banner__pill">{{ dlg.nome || 'Nome da tag' }}</span>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" icon="pi pi-times" text @click="fecharDlg" :disabled="dlg.saving" />
|
||||
<Button
|
||||
:label="dlg.mode === 'create' ? 'Criar' : 'Salvar'"
|
||||
icon="pi pi-check"
|
||||
@click="salvarDlg"
|
||||
:loading="dlg.saving"
|
||||
:disabled="!String(dlg.nome || '').trim()"
|
||||
/>
|
||||
</template>
|
||||
<!-- Corpo -->
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<!-- Nome -->
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-tag" />
|
||||
<InputText
|
||||
id="tag-nome"
|
||||
v-model="dlg.nome"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="dlg.saving"
|
||||
@keydown.enter.prevent="salvarDlg"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="tag-nome">Nome da tag *</label>
|
||||
</FloatLabel>
|
||||
|
||||
<!-- Cor -->
|
||||
<div class="tag-dlg-section">
|
||||
<div class="tag-dlg-section__label">Cor</div>
|
||||
<div class="tag-dlg-palette">
|
||||
<button
|
||||
v-for="p in tagPresetColors"
|
||||
:key="p.bg"
|
||||
class="tag-dlg-swatch"
|
||||
:class="{ 'tag-dlg-swatch--active': dlg.cor === p.bg }"
|
||||
:style="{ backgroundColor: `#${p.bg}` }"
|
||||
:title="p.name"
|
||||
:disabled="dlg.saving"
|
||||
@click="dlg.cor = p.bg"
|
||||
>
|
||||
<i v-if="dlg.cor === p.bg" class="pi pi-check tag-dlg-swatch__check" />
|
||||
</button>
|
||||
|
||||
<!-- Custom ColorPicker -->
|
||||
<div class="tag-dlg-swatch tag-dlg-swatch--custom" title="Cor personalizada">
|
||||
<ColorPicker v-model="dlg.cor" format="hex" :disabled="dlg.saving" />
|
||||
</div>
|
||||
|
||||
<!-- Limpar -->
|
||||
<button
|
||||
v-if="dlg.cor"
|
||||
class="tag-dlg-swatch tag-dlg-swatch--clear"
|
||||
title="Sem cor"
|
||||
:disabled="dlg.saving"
|
||||
@click="dlg.cor = ''"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- MODAL: pacientes da tag -->
|
||||
<Dialog
|
||||
v-model:visible="modalPacientes.open"
|
||||
:header="modalPacientes.tag ? `Pacientes — ${modalPacientes.tag.nome}` : 'Pacientes'"
|
||||
:header="modalPacientesHeader"
|
||||
modal
|
||||
:draggable="false"
|
||||
:style="{ width: '900px', maxWidth: '96vw' }"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -330,7 +408,16 @@
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-color-secondary py-5">Nenhum paciente encontrado.</div>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">Nenhum resultado para "{{ modalPacientes.search }}".</div>
|
||||
<div class="mt-4">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar busca" @click="modalPacientes.search = ''" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
@@ -340,17 +427,17 @@
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Menu from 'primevue/menu'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
@@ -358,6 +445,27 @@ const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
// ── Mobile menu ───────────────────────────────────────────
|
||||
const mobileMenuRef = ref(null)
|
||||
const searchDlgOpen = ref(false)
|
||||
|
||||
const mobileMenuItems = computed(() => [
|
||||
{ label: 'Adicionar', icon: 'pi pi-plus', command: () => abrirCriar() },
|
||||
{ label: 'Buscar', icon: 'pi pi-search', command: () => { searchDlgOpen.value = true } },
|
||||
...(etiquetasSelecionadas.value?.length ? [
|
||||
{ separator: true },
|
||||
{ label: 'Excluir selecionados', icon: 'pi pi-trash', command: () => confirmarExclusaoSelecionadas() }
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => buscarEtiquetas() }
|
||||
])
|
||||
|
||||
const dt = ref(null)
|
||||
const carregando = ref(false)
|
||||
|
||||
@@ -393,6 +501,30 @@ const cards = computed(() =>
|
||||
.sort((a, b) => Number(b.pacientes_count ?? 0) - Number(a.pacientes_count ?? 0))
|
||||
)
|
||||
|
||||
const tagPresetColors = [
|
||||
{ bg: '6366f1', name: 'Índigo' },
|
||||
{ bg: '8b5cf6', name: 'Violeta' },
|
||||
{ bg: 'ec4899', name: 'Rosa' },
|
||||
{ bg: 'ef4444', name: 'Vermelho' },
|
||||
{ bg: 'f97316', name: 'Laranja' },
|
||||
{ bg: 'eab308', name: 'Amarelo' },
|
||||
{ bg: '22c55e', name: 'Verde' },
|
||||
{ bg: '14b8a6', name: 'Teal' },
|
||||
{ bg: '3b82f6', name: 'Azul' },
|
||||
{ bg: '06b6d4', name: 'Ciano' },
|
||||
{ bg: '64748b', name: 'Ardósia' },
|
||||
{ bg: '292524', name: 'Escuro' },
|
||||
]
|
||||
|
||||
const tagDlgPreviewColor = computed(() => {
|
||||
if (!dlg.cor) return '#64748b'
|
||||
const s = String(dlg.cor).trim()
|
||||
return s.startsWith('#') ? s : `#${s}`
|
||||
})
|
||||
const modalPacientesHeader = computed(() =>
|
||||
modalPacientes.tag ? `Pacientes — ${modalPacientes.tag.nome}` : 'Pacientes'
|
||||
)
|
||||
|
||||
const modalPacientesFiltrado = computed(() => {
|
||||
const s = String(modalPacientes.search || '').trim().toLowerCase()
|
||||
if (!s) return modalPacientes.items || []
|
||||
@@ -405,9 +537,17 @@ const modalPacientesFiltrado = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
buscarEtiquetas()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
|
||||
async function getOwnerId() {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
@@ -416,6 +556,20 @@ async function getOwnerId() {
|
||||
return user.id
|
||||
}
|
||||
|
||||
async function getActiveTenantId(uid) {
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('tenant_id')
|
||||
.eq('user_id', uid)
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
if (error) throw error
|
||||
if (!data?.tenant_id) throw new Error('Tenant não encontrado.')
|
||||
return data.tenant_id
|
||||
}
|
||||
|
||||
function normalizarEtiquetaRow(r) {
|
||||
// Compatível com banco antigo (name/color/is_native/patient_count)
|
||||
// e com banco pt-BR (nome/cor/is_padrao/patients_count)
|
||||
@@ -441,7 +595,7 @@ function isUniqueViolation(e) {
|
||||
}
|
||||
|
||||
function friendlyDupMessage(nome) {
|
||||
return `Já existe uma tag chamada “${nome}”. Tente outro nome.`
|
||||
return `Já existe uma tag chamada “${nome}". Tente outro nome.`
|
||||
}
|
||||
|
||||
function corPreview(raw) {
|
||||
@@ -560,22 +714,14 @@ async function salvarDlg() {
|
||||
const cor = hex ? `#${hex}` : null
|
||||
|
||||
if (dlg.mode === 'create') {
|
||||
// tenta pt-BR
|
||||
let res = await supabase.from('patient_tags').insert({
|
||||
const tenantId = await getActiveTenantId(ownerId)
|
||||
const res = await supabase.from('patient_tags').insert({
|
||||
owner_id: ownerId,
|
||||
tenant_id: tenantId,
|
||||
nome,
|
||||
cor
|
||||
})
|
||||
|
||||
// se colunas pt-BR não existem ainda, cai pra legado
|
||||
if (res.error && /column .*nome/i.test(String(res.error.message || ''))) {
|
||||
res = await supabase.from('patient_tags').insert({
|
||||
owner_id: ownerId,
|
||||
name: nome,
|
||||
color: cor
|
||||
})
|
||||
}
|
||||
|
||||
if (res.error) throw res.error
|
||||
toast.add({ severity: 'success', summary: 'Tag criada', detail: nome, life: 2500 })
|
||||
} else {
|
||||
@@ -640,7 +786,7 @@ async function salvarDlg() {
|
||||
-------------------------------- */
|
||||
function confirmarExclusaoUma(row) {
|
||||
confirm.require({
|
||||
message: `Excluir a tag “${row.nome}”? (Isso remove também os vínculos com pacientes)`,
|
||||
message: `Excluir a tag “${row.nome}"? (Isso remove também os vínculos com pacientes)`,
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Excluir',
|
||||
@@ -807,7 +953,114 @@ function abrirPaciente (patient) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Mantido apenas porque Transition name="fade" precisa das classes */
|
||||
/* ── Hero Header ─────────────────────────────────── */
|
||||
.tags-sentinel { height: 1px; }
|
||||
|
||||
.tags-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.tags-hero--stuck {
|
||||
margin-left: 0; margin-right: 0;
|
||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
/* Blobs */
|
||||
.tags-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.tags-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.tags-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(236,72,153,0.09); }
|
||||
.tags-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.08); }
|
||||
|
||||
/* Linha 1 */
|
||||
.tags-hero__row1 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.tags-hero__brand {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.tags-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.tags-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.tags-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
/* Linha 2 (oculta no mobile) */
|
||||
.tags-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.tags-hero__divider,
|
||||
.tags-hero__row2 { display: none; }
|
||||
}
|
||||
|
||||
/* ── Dialog de tag ───────────────────────────────── */
|
||||
.tag-dlg-dot {
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
box-shadow: 0 0 0 3px rgba(0,0,0,0.08);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.tag-dlg-banner {
|
||||
height: 72px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background-color 0.25s ease;
|
||||
}
|
||||
.tag-dlg-banner__pill {
|
||||
font-size: 1rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
padding: 0.35rem 1.1rem;
|
||||
background: rgba(0,0,0,0.15);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(4px);
|
||||
color: #fff;
|
||||
}
|
||||
.tag-dlg-section {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--surface-card);
|
||||
padding: 1rem;
|
||||
}
|
||||
.tag-dlg-section__label {
|
||||
font-size: 0.7rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
opacity: 0.45; margin-bottom: 0.75rem;
|
||||
}
|
||||
.tag-dlg-palette { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||
.tag-dlg-swatch {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
display: grid; place-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.tag-dlg-swatch:hover:not(:disabled) { transform: scale(1.18); box-shadow: 0 3px 10px rgba(0,0,0,0.2); }
|
||||
.tag-dlg-swatch--active {
|
||||
border-color: var(--surface-0, #fff);
|
||||
box-shadow: 0 0 0 2px var(--text-color);
|
||||
}
|
||||
.tag-dlg-swatch__check { font-size: 0.6rem; color: #fff; font-weight: 900; }
|
||||
.tag-dlg-swatch--custom {
|
||||
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tag-dlg-swatch--custom :deep(.p-colorpicker-preview) {
|
||||
width: 100%; height: 100%; border: none; border-radius: 50%; opacity: 0;
|
||||
}
|
||||
.tag-dlg-swatch--clear { background: var(--surface-border); color: var(--text-color-secondary); }
|
||||
|
||||
/* Fade (Transition nos cards) */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from,
|
||||
|
||||
BIN
src/images/layout-design2.png
Normal file
BIN
src/images/layout-design2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<AppShellLayout />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppShellLayout from './AppShellLayout.vue'
|
||||
</script>
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue'
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
|
||||
import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options'
|
||||
|
||||
const { layoutConfig, isDarkTheme, changeMenuMode } = useLayout()
|
||||
|
||||
// ✅ vem do AppTopbar (mesma instância)
|
||||
const queuePatch = inject('queueUserSettingsPatch', null)
|
||||
console.log('[AppConfigurator] queuePatch injected?', !!queuePatch)
|
||||
|
||||
// menu mode options
|
||||
const menuModeOptions = [
|
||||
@@ -35,14 +32,14 @@ const menuModeModel = computed({
|
||||
if (!val || val === layoutConfig.menuMode) return
|
||||
layoutConfig.menuMode = val
|
||||
|
||||
// composable pode aceitar nada (no teu caso, costuma ser isso)
|
||||
try { changeMenuMode() } catch {}
|
||||
// ✅ changeMenuMode espera event.value (seu composable usa event.value)
|
||||
try { changeMenuMode({ value: val }) } catch {}
|
||||
|
||||
queuePatch?.({ menu_mode: val })
|
||||
}
|
||||
})
|
||||
|
||||
function updateColors(type, item) {
|
||||
function updateColors (type, item) {
|
||||
if (type === 'primary') {
|
||||
layoutConfig.primary = item.name
|
||||
applyThemeEngine(layoutConfig)
|
||||
@@ -116,4 +113,4 @@ function updateColors(type, item) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,34 +1,161 @@
|
||||
<script setup>
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { computed } from 'vue';
|
||||
import AppFooter from './AppFooter.vue';
|
||||
import AppSidebar from './AppSidebar.vue';
|
||||
import AppTopbar from './AppTopbar.vue';
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
import { computed, onMounted, onBeforeUnmount, provide } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const { layoutConfig, layoutState, hideMobileMenu } = useLayout();
|
||||
import AppFooter from './AppFooter.vue'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import AppTopbar from './AppTopbar.vue'
|
||||
import AppRail from './AppRail.vue'
|
||||
import AppRailPanel from './AppRailPanel.vue'
|
||||
import AppRailTopbar from './AppRailTopbar.vue'
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
||||
|
||||
const route = useRoute()
|
||||
const { layoutConfig, layoutState, hideMobileMenu, isDesktop } = useLayout()
|
||||
|
||||
// ✅ área do layout definida por rota (shell único)
|
||||
const layoutArea = computed(() => route.meta?.area || null)
|
||||
provide('layoutArea', layoutArea)
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const entitlementsStore = useEntitlementsStore()
|
||||
const tf = useTenantFeaturesStore()
|
||||
|
||||
const containerClass = computed(() => {
|
||||
return {
|
||||
'layout-overlay': layoutConfig.menuMode === 'overlay',
|
||||
'layout-static': layoutConfig.menuMode === 'static',
|
||||
'layout-overlay-active': layoutState.overlayMenuActive,
|
||||
'layout-mobile-active': layoutState.mobileMenuActive,
|
||||
'layout-static-inactive': layoutState.staticMenuInactive
|
||||
};
|
||||
});
|
||||
return {
|
||||
'layout-overlay': layoutConfig.menuMode === 'overlay',
|
||||
'layout-static': layoutConfig.menuMode === 'static',
|
||||
'layout-overlay-active': layoutState.overlayMenuActive,
|
||||
'layout-mobile-active': layoutState.mobileMenuActive,
|
||||
'layout-static-inactive': layoutState.staticMenuInactive
|
||||
}
|
||||
})
|
||||
|
||||
function getTenantId () {
|
||||
return (
|
||||
tenantStore.activeTenantId ||
|
||||
tenantStore.tenantId ||
|
||||
tenantStore.currentTenantId ||
|
||||
tenantStore.tenant?.id ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
async function revalidateAfterSessionRefresh () {
|
||||
try {
|
||||
if (!tenantStore.loaded && !tenantStore.loading) {
|
||||
await tenantStore.loadSessionAndTenant()
|
||||
}
|
||||
|
||||
const tid = getTenantId()
|
||||
if (!tid) return
|
||||
|
||||
await Promise.allSettled([
|
||||
entitlementsStore.loadForTenant?.(tid, { force: true }),
|
||||
tf.fetchForTenant?.(tid, { force: true })
|
||||
])
|
||||
} catch (e) {
|
||||
console.warn('[AppLayout] revalidateAfterSessionRefresh failed:', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
function onSessionRefreshed () {
|
||||
// ✅ Só revalidar tenantStore/entitlements em áreas TENANT.
|
||||
// Em /portal e /account isso causa vazamento de contexto e troca de menu.
|
||||
const p = String(route.path || '')
|
||||
const isTenantArea =
|
||||
p.startsWith('/admin') ||
|
||||
p.startsWith('/therapist') ||
|
||||
p.startsWith('/supervisor') ||
|
||||
p.startsWith('/saas')
|
||||
|
||||
if (!isTenantArea) return
|
||||
revalidateAfterSessionRefresh()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('app:session-refreshed', onSessionRefreshed)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('app:session-refreshed', onSessionRefreshed)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-wrapper" :class="containerClass">
|
||||
<AppTopbar />
|
||||
<AppSidebar />
|
||||
<div class="layout-main-container">
|
||||
<div class="layout-main">
|
||||
<router-view />
|
||||
</div>
|
||||
<AppFooter />
|
||||
<!-- ══ Layout 2: Rail + Painel + Main (full-width) ══════════ -->
|
||||
<template v-if="layoutConfig.variant === 'rail' && isDesktop()">
|
||||
<div class="l2-root">
|
||||
<AppRail />
|
||||
<div class="l2-body">
|
||||
<AppRailTopbar />
|
||||
<div class="l2-content">
|
||||
<AppRailPanel />
|
||||
<div class="l2-main">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout-mask animate-fadein" @click="hideMobileMenu" />
|
||||
</div>
|
||||
</div>
|
||||
<Toast />
|
||||
</template>
|
||||
|
||||
<!-- ══ Layout 1: Clássico ═══════════════════════════════════ -->
|
||||
<template v-else>
|
||||
<div class="layout-wrapper" :class="containerClass">
|
||||
<AppTopbar />
|
||||
<AppSidebar />
|
||||
<div class="layout-main-container">
|
||||
<div class="layout-main">
|
||||
<router-view />
|
||||
</div>
|
||||
<AppFooter />
|
||||
</div>
|
||||
<div class="layout-mask animate-fadein" @click="hideMobileMenu" />
|
||||
</div>
|
||||
<Toast />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ─── Layout 2 ───────────────────────────────────────────── */
|
||||
.l2-root {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
|
||||
/* Coluna direita do rail: topbar + conteúdo */
|
||||
.l2-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Linha: painel lateral + main */
|
||||
.l2-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Área de conteúdo principal */
|
||||
.l2-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
/* Headers sticky no Rail colam no topo do scroll container (já abaixo da topbar) */
|
||||
--layout-sticky-top: 0px;
|
||||
}
|
||||
</style>
|
||||
@@ -7,114 +7,84 @@ import AppMenuItem from './AppMenuItem.vue'
|
||||
import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
|
||||
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
import { sessionRole, sessionIsSaasAdmin } from '@/app/session'
|
||||
import { getMenuByRole } from '@/navigation'
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { layoutState } = useLayout()
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const entitlementsStore = useEntitlementsStore()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || null)
|
||||
// ======================================================
|
||||
// ✅ Blindagem anti-“menu some”
|
||||
// - se o menuStore.model piscar como [], mantém o último menu válido
|
||||
// - evita sumiço ao entrar em /admin/clinic/features (reset momentâneo)
|
||||
// ======================================================
|
||||
|
||||
/**
|
||||
* ✅ Role canônico pro MENU:
|
||||
* - PRIORIDADE 1: contexto de rota (evita menu errado quando role do tenant atrasa/falha)
|
||||
* Ex.: /therapist/* => força menu therapist; /admin* => força menu admin
|
||||
* - PRIORIDADE 2: se há tenant ativo: usa role do tenant
|
||||
* - PRIORIDADE 3: fallback pro sessionRole (ex.: telas fora de tenant)
|
||||
*
|
||||
* Motivo: o bug que você descreveu (terapeuta vendo admin.menu) geralmente é:
|
||||
* - tenant role ainda não carregou OU tenantId está null
|
||||
* - sessionRole vem como 'admin'
|
||||
* Então, rota > tenant > session elimina o menu “trocar sozinho”.
|
||||
*/
|
||||
const navRole = computed(() => {
|
||||
const p = String(route.path || '')
|
||||
// raw (pode piscar vazio)
|
||||
const rawModel = computed(() => menuStore.model || [])
|
||||
|
||||
// ✅ blindagem por contexto
|
||||
if (p.startsWith('/therapist')) return 'therapist'
|
||||
if (p.startsWith('/admin') || p.startsWith('/clinic')) return 'clinic_admin'
|
||||
if (p.startsWith('/patient')) return 'patient'
|
||||
// último menu válido
|
||||
const lastGoodModel = ref([])
|
||||
|
||||
// ✅ dentro de tenant: confia no role do tenant
|
||||
if (tenantId.value) return tenantStore.activeRole || null
|
||||
// debounce curto para aceitar "vazio real" (ex.: logout) sem travar UI
|
||||
let acceptEmptyT = null
|
||||
|
||||
// ✅ fora de tenant: fallback pro sessionRole
|
||||
return sessionRole.value || null
|
||||
})
|
||||
|
||||
const model = computed(() => {
|
||||
// ✅ role efetivo do menu já vem “canônico” do navRole
|
||||
const effectiveRole = navRole.value
|
||||
|
||||
const base = getMenuByRole(effectiveRole, { isSaasAdmin: sessionIsSaasAdmin.value }) || []
|
||||
|
||||
const normalize = (s) => String(s || '').toLowerCase()
|
||||
const priorityOrder = (group) => {
|
||||
const label = normalize(group?.label)
|
||||
if (label.includes('saas')) return 0
|
||||
if (label.includes('pacientes')) return 1
|
||||
return 99
|
||||
function setLastGoodIfValid (m) {
|
||||
if (Array.isArray(m) && m.length) {
|
||||
lastGoodModel.value = m
|
||||
}
|
||||
}
|
||||
|
||||
return [...base].sort((a, b) => priorityOrder(a) - priorityOrder(b))
|
||||
})
|
||||
|
||||
// quando troca tenant -> recarrega entitlements
|
||||
watch(
|
||||
tenantId,
|
||||
async (id) => {
|
||||
entitlementsStore.invalidate()
|
||||
if (id) await entitlementsStore.loadForTenant(id, { force: true })
|
||||
rawModel,
|
||||
(m) => {
|
||||
// se veio com itens, atualiza na hora
|
||||
if (Array.isArray(m) && m.length) {
|
||||
if (acceptEmptyT) clearTimeout(acceptEmptyT)
|
||||
setLastGoodIfValid(m)
|
||||
return
|
||||
}
|
||||
|
||||
// se veio vazio, NÃO derruba o menu imediatamente.
|
||||
// Só aceita vazio se continuar vazio por um tempinho.
|
||||
if (acceptEmptyT) clearTimeout(acceptEmptyT)
|
||||
acceptEmptyT = setTimeout(() => {
|
||||
// se ainda estiver vazio, e você quiser realmente limpar, faça aqui.
|
||||
// Por padrão, manteremos o último menu válido para evitar UX quebrada.
|
||||
// Se quiser limpar em logout, o logout deve limpar lastGoodModel explicitamente.
|
||||
}, 250)
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true, deep: false }
|
||||
)
|
||||
|
||||
// ✅ quando troca role efetivo do menu (via rota/tenant/session) -> recarrega entitlements do tenant atual
|
||||
watch(
|
||||
() => navRole.value,
|
||||
async () => {
|
||||
if (!tenantId.value) return
|
||||
entitlementsStore.invalidate()
|
||||
await entitlementsStore.loadForTenant(tenantId.value, { force: true })
|
||||
}
|
||||
)
|
||||
// model final exibido (com fallback)
|
||||
const model = computed(() => {
|
||||
const m = rawModel.value
|
||||
if (Array.isArray(m) && m.length) return m
|
||||
if (Array.isArray(lastGoodModel.value) && lastGoodModel.value.length) return lastGoodModel.value
|
||||
return []
|
||||
})
|
||||
|
||||
// ✅ rota -> activePath (NÃO fecha menu em nenhum cenário)
|
||||
// ✅ rota -> activePath (NÃO fecha menu)
|
||||
watch(
|
||||
() => route.path,
|
||||
(p) => { layoutState.activePath = p },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// ==============================
|
||||
// 🔎 Busca no menu (flatten + resultados)
|
||||
// ==============================
|
||||
// ======================================================
|
||||
// 🔎 Busca no menu (mantive igual)
|
||||
// ======================================================
|
||||
const query = ref('')
|
||||
const showResults = ref(false)
|
||||
const activeIndex = ref(-1)
|
||||
|
||||
// ✅ garante Ctrl/Cmd+K mesmo sem recentes
|
||||
const forcedOpen = ref(false)
|
||||
|
||||
// ref do InputText (pra Ctrl/Cmd + K)
|
||||
const searchEl = ref(null)
|
||||
|
||||
// wrapper pra click-outside
|
||||
const searchWrapEl = ref(null)
|
||||
|
||||
// Recentes
|
||||
const RECENT_KEY = 'menu_search_recent'
|
||||
const recent = ref([])
|
||||
|
||||
@@ -136,15 +106,11 @@ loadRecent()
|
||||
|
||||
watch(query, (v) => {
|
||||
const hasText = !!v?.trim()
|
||||
|
||||
// digitou: abre e sai do modo "forced"
|
||||
if (hasText) {
|
||||
forcedOpen.value = false
|
||||
showResults.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// vazio: mantém aberto apenas se foi "forçado" (Ctrl/Cmd+K ou foco)
|
||||
showResults.value = forcedOpen.value
|
||||
})
|
||||
|
||||
@@ -163,10 +129,17 @@ function norm (s) {
|
||||
.trim()
|
||||
}
|
||||
|
||||
function isVisibleItem (it) {
|
||||
const v = it?.visible
|
||||
if (typeof v === 'function') return !!v()
|
||||
if (v === undefined || v === null) return true
|
||||
return v !== false
|
||||
}
|
||||
|
||||
function flattenMenu (items, trail = []) {
|
||||
const out = []
|
||||
for (const it of (items || [])) {
|
||||
if (it?.visible === false) continue
|
||||
if (!isVisibleItem(it)) continue
|
||||
|
||||
const nextTrail = [...trail, it?.label].filter(Boolean)
|
||||
|
||||
@@ -176,7 +149,10 @@ function flattenMenu (items, trail = []) {
|
||||
to: it.to,
|
||||
icon: it.icon,
|
||||
trail: nextTrail,
|
||||
proBadge: !!it.proBadge,
|
||||
|
||||
// ✅ usa cálculo dinâmico vindo do navigation (quando existir)
|
||||
proBadge: !!(it.__showProBadge ?? it.proBadge),
|
||||
|
||||
feature: it.feature || null
|
||||
})
|
||||
}
|
||||
@@ -210,7 +186,6 @@ watch(results, (list) => {
|
||||
activeIndex.value = list.length ? 0 : -1
|
||||
})
|
||||
|
||||
// ===== highlight =====
|
||||
function escapeHtml (s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
@@ -235,7 +210,6 @@ function highlight (text, q) {
|
||||
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`
|
||||
}
|
||||
|
||||
// ===== teclado =====
|
||||
function onSearchKeydown (e) {
|
||||
if (e.key === 'Escape') {
|
||||
showResults.value = false
|
||||
@@ -273,7 +247,6 @@ function isTypingTarget (el) {
|
||||
return tag === 'input' || tag === 'textarea' || el.isContentEditable
|
||||
}
|
||||
|
||||
// ===== Ctrl/Cmd + K =====
|
||||
function focusSearch () {
|
||||
forcedOpen.value = true
|
||||
showResults.value = true
|
||||
@@ -304,25 +277,18 @@ function onGlobalKeydown (e) {
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Recentes: aplicar query + abrir + focar (sem depender de watch timing)
|
||||
function applyRecent (q) {
|
||||
query.value = q
|
||||
forcedOpen.value = true
|
||||
showResults.value = true
|
||||
activeIndex.value = 0
|
||||
|
||||
nextTick(() => {
|
||||
// garante foco e teclado funcionando
|
||||
focusSearch()
|
||||
})
|
||||
nextTick(() => focusSearch())
|
||||
}
|
||||
|
||||
// click outside para fechar painel
|
||||
function onDocMouseDown (e) {
|
||||
if (!showResults.value) return
|
||||
const root = searchWrapEl.value
|
||||
if (!root) return
|
||||
|
||||
if (!root.contains(e.target)) {
|
||||
showResults.value = false
|
||||
forcedOpen.value = false
|
||||
@@ -334,6 +300,7 @@ onMounted(() => {
|
||||
document.addEventListener('mousedown', onDocMouseDown)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (acceptEmptyT) clearTimeout(acceptEmptyT)
|
||||
window.removeEventListener('keydown', onGlobalKeydown, true)
|
||||
document.removeEventListener('mousedown', onDocMouseDown)
|
||||
})
|
||||
@@ -356,7 +323,6 @@ const quickDialog = ref(false)
|
||||
function onQuickCreate () { quickDialog.value = true }
|
||||
function onQuickCreated () { quickDialog.value = false }
|
||||
|
||||
// controle de “recentes”: mostrar ao focar (mesmo sem recentes, para exibir dicas)
|
||||
function onSearchFocus () {
|
||||
if (!query.value?.trim()) {
|
||||
forcedOpen.value = true
|
||||
@@ -370,12 +336,29 @@ function onSearchFocus () {
|
||||
<!-- 🔎 TOPO FIXO -->
|
||||
<div ref="searchWrapEl" class="pb-2 border-b border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style="position:absolute; left:-9999px; top:-9999px; width:1px; height:1px; overflow:hidden;"
|
||||
>
|
||||
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
|
||||
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
|
||||
</div>
|
||||
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
ref="searchEl"
|
||||
id="menu_search"
|
||||
name="menu_search"
|
||||
type="search"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
v-model="query"
|
||||
class="w-full pr-10"
|
||||
variant="filled"
|
||||
@@ -383,10 +366,9 @@ function onSearchFocus () {
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="menu_search">Buscar no menu</label>
|
||||
<label for="menu_search">Encontrar menu...</label>
|
||||
</FloatLabel>
|
||||
|
||||
<!-- ✅ botão limpar busca -->
|
||||
<button
|
||||
v-if="query.trim()"
|
||||
type="button"
|
||||
@@ -398,7 +380,6 @@ function onSearchFocus () {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Recentes (quando query vazio) -->
|
||||
<div
|
||||
v-if="showResults && !query.trim() && recent.length"
|
||||
class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
|
||||
@@ -427,14 +408,13 @@ function onSearchFocus () {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Resultados -->
|
||||
<div
|
||||
v-else-if="showResults && results.length"
|
||||
class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
|
||||
>
|
||||
<button
|
||||
v-for="(r, i) in results"
|
||||
:key="r.to"
|
||||
:key="String(r.to)"
|
||||
type="button"
|
||||
@mousedown.prevent="goTo(r)"
|
||||
:class="[
|
||||
@@ -449,7 +429,7 @@ function onSearchFocus () {
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="r.proBadge || r.feature"
|
||||
v-if="r.proBadge"
|
||||
class="text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"
|
||||
>
|
||||
PRO
|
||||
@@ -457,48 +437,19 @@ function onSearchFocus () {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="showResults && query && !results.length"
|
||||
class="mt-2 px-3 py-2 text-sm opacity-70"
|
||||
>
|
||||
<div v-else-if="showResults && query && !results.length" class="mt-2 px-3 py-2 text-sm opacity-70">
|
||||
Nenhum item encontrado.
|
||||
</div>
|
||||
|
||||
<!-- ✅ instruções embaixo quando houver recentes/resultados/uso -->
|
||||
<div
|
||||
v-if="showResults && (recent.length || results.length || query.trim())"
|
||||
class="mt-2 px-3 text-xs opacity-70 flex flex-wrap gap-x-3 gap-y-1"
|
||||
>
|
||||
<span><b>Ctrl+K</b>/<b>Cmd+K</b> focar</span>
|
||||
<span><b>↑↓</b> navegar</span>
|
||||
<span><b>Enter</b> abrir</span>
|
||||
<span><b>Esc</b> fechar</span>
|
||||
</div>
|
||||
|
||||
<!-- fallback quando não tem nada -->
|
||||
<div
|
||||
v-else-if="showResults && !query.trim() && !recent.length"
|
||||
class="mt-2 px-3 py-2 text-xs opacity-60"
|
||||
>
|
||||
Dica: pressione <b>Ctrl + K</b> (ou <b>Cmd + K</b>) para buscar.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ SOMENTE O MENU ROLA -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<ul class="layout-menu pb-20">
|
||||
<template v-for="(item, i) in model" :key="i">
|
||||
<AppMenuItem
|
||||
:item="item"
|
||||
:index="i"
|
||||
:root="true"
|
||||
@quick-create="onQuickCreate"
|
||||
/>
|
||||
<AppMenuItem :item="item" :index="i" :root="true" @quick-create="onQuickCreate" />
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- rodapé fixo -->
|
||||
<AppMenuFooterPanel />
|
||||
|
||||
<ComponentCadastroRapido
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Popover from 'primevue/popover'
|
||||
|
||||
import { sessionUser, sessionRole } from '@/app/session'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
@@ -13,7 +15,7 @@ const pop = ref(null)
|
||||
// ------------------------------------------------------
|
||||
// RBAC (Tenant): fonte da verdade para permissões por papel
|
||||
// ------------------------------------------------------
|
||||
const { role, canSee, isPatient } = useRoleGuard()
|
||||
const { role, canSee } = useRoleGuard()
|
||||
|
||||
// ------------------------------------------------------
|
||||
// UI labels (nome/iniciais)
|
||||
@@ -33,22 +35,21 @@ const label = computed(() => {
|
||||
|
||||
/**
|
||||
* sublabel:
|
||||
* Aqui eu recomendo exibir o papel do TENANT (role do useRoleGuard),
|
||||
* porque é ele que realmente governa a UI dentro da clínica.
|
||||
*
|
||||
* Se você preferir manter sessionRole como rótulo "global", ok,
|
||||
* mas isso pode confundir quando o usuário estiver em contextos diferentes.
|
||||
* Prefere exibir o papel do TENANT (role do useRoleGuard),
|
||||
* porque governa a UI dentro da clínica.
|
||||
*/
|
||||
const sublabel = computed(() => {
|
||||
const r = role.value || sessionRole.value
|
||||
if (!r) return 'Sessão'
|
||||
|
||||
// tenant roles (confirmados no banco): tenant_admin | therapist | patient
|
||||
if (r === 'tenant_admin') return 'Administrador'
|
||||
// tenant roles
|
||||
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return 'Administrador'
|
||||
if (r === 'therapist') return 'Terapeuta'
|
||||
if (r === 'patient') return 'Paciente'
|
||||
|
||||
// fallback (caso venha algo diferente)
|
||||
// portal/global roles
|
||||
if (r === 'portal_user') return 'Portal'
|
||||
if (r === 'patient') return 'Portal' // legado (caso ainda exista em algum lugar)
|
||||
|
||||
return r
|
||||
})
|
||||
|
||||
@@ -60,69 +61,52 @@ function toggle (e) {
|
||||
}
|
||||
|
||||
function close () {
|
||||
try {
|
||||
pop.value?.hide()
|
||||
} catch {}
|
||||
try { pop.value?.hide() } catch {}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Navegação segura (NAME com fallback)
|
||||
// Navegação segura (resolve antes; fallback se não existir)
|
||||
// ------------------------------------------------------
|
||||
async function safePush (target, fallback) {
|
||||
try {
|
||||
await router.push(target)
|
||||
} catch (e) {
|
||||
// fallback quando o "name" não existe no router
|
||||
if (fallback) {
|
||||
try {
|
||||
await router.push(fallback)
|
||||
} catch {
|
||||
await router.push('/')
|
||||
}
|
||||
} else {
|
||||
await router.push('/')
|
||||
}
|
||||
const r = router.resolve(target)
|
||||
if (r?.matched?.length) return await router.push(target)
|
||||
} catch {}
|
||||
|
||||
if (fallback) {
|
||||
try { return await router.push(fallback) } catch {}
|
||||
}
|
||||
|
||||
return router.push('/')
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Actions
|
||||
// ------------------------------------------------------
|
||||
function goMyProfile () {
|
||||
close()
|
||||
|
||||
// Navegação segura para Account → Profile
|
||||
safePush(
|
||||
{ name: 'account-profile' },
|
||||
'/account/profile'
|
||||
)
|
||||
safePush({ name: 'account-profile' }, '/account/profile')
|
||||
}
|
||||
|
||||
function goSettings () {
|
||||
close()
|
||||
|
||||
// ✅ Decide por RBAC (tenant role), não por sessionRole
|
||||
// ✅ Configurações é RBAC (quem pode ver, vê)
|
||||
if (canSee('settings.view')) {
|
||||
router.push({ name: 'ConfiguracoesAgenda' })
|
||||
return
|
||||
return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings') // fallback genérico
|
||||
}
|
||||
|
||||
// Se não pode ver configurações, manda paciente pro portal.
|
||||
// (Se amanhã você criar outro papel, esta regra continua segura.)
|
||||
if (isPatient.value) {
|
||||
router.push('/patient/portal')
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/')
|
||||
// ✅ quem não pode (ex.: paciente), manda pro portal correto
|
||||
return safePush({ name: 'portal-sessoes' }, '/portal')
|
||||
}
|
||||
|
||||
function goSecurity () {
|
||||
close()
|
||||
|
||||
// ✅ 1) tenta por NAME (recomendado)
|
||||
// ✅ 2) fallback: caminhos mais prováveis do teu projeto
|
||||
// Ajuste/defina a rota no router como name: 'AdminSecurity' para ficar perfeito
|
||||
safePush(
|
||||
{ name: 'AdminSecurity' },
|
||||
'/admin/settings/security'
|
||||
// ✅ Segurança é "Account": todos podem acessar
|
||||
return safePush(
|
||||
{ name: 'account-security' },
|
||||
'/account/security'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,9 +131,10 @@ async function signOut () {
|
||||
>
|
||||
<!-- avatar -->
|
||||
<img
|
||||
v-if="sessionUser.value?.user_metadata?.avatar_url"
|
||||
:src="sessionUser.value.user_metadata.avatar_url"
|
||||
v-if="sessionUser?.user_metadata?.avatar_url"
|
||||
:src="sessionUser.user_metadata.avatar_url"
|
||||
class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]"
|
||||
alt="avatar"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!-- src/layout/AppMenuItem.vue -->
|
||||
<script setup>
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
import { computed, ref, nextTick } from 'vue'
|
||||
import { computed, ref, nextTick, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Popover from 'primevue/popover'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
@@ -31,54 +31,85 @@ const fullPath = computed(() =>
|
||||
)
|
||||
|
||||
// ==============================
|
||||
// Active logic: mantém submenu aberto se algum descendente estiver ativo
|
||||
// Visible: boolean OU function() -> boolean
|
||||
// ==============================
|
||||
const isVisible = computed(() => {
|
||||
const v = props.item?.visible
|
||||
if (typeof v === 'function') return !!v()
|
||||
if (v === undefined || v === null) return true
|
||||
return v !== false
|
||||
})
|
||||
|
||||
// ==============================
|
||||
// Helpers de rota: aceita string OU objeto
|
||||
// ==============================
|
||||
function toPath (to) {
|
||||
if (!to) return ''
|
||||
if (typeof to === 'string') return to
|
||||
try { return router.resolve(to).path || '' } catch { return '' }
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// Active logic
|
||||
// ==============================
|
||||
function isSameRoute (current, target) {
|
||||
if (!current || !target) return false
|
||||
return current === target || current.startsWith(target + '/')
|
||||
const cur = typeof current === 'string' ? current : toPath(current)
|
||||
const tar = typeof target === 'string' ? target : toPath(target)
|
||||
if (!cur || !tar) return false
|
||||
return cur === tar || cur.startsWith(tar + '/')
|
||||
}
|
||||
|
||||
function hasActiveDescendant (node, currentPath) {
|
||||
const children = node?.items || []
|
||||
for (const child of children) {
|
||||
if (child?.to && isSameRoute(currentPath, child.to)) return true
|
||||
const childTo = toPath(child?.to)
|
||||
if (childTo && isSameRoute(currentPath, childTo)) return true
|
||||
if (child?.items?.length && hasActiveDescendant(child, currentPath)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const isActive = computed(() => {
|
||||
const current = layoutState.activePath || ''
|
||||
const current = typeof layoutState.activePath === 'string'
|
||||
? layoutState.activePath
|
||||
: toPath(layoutState.activePath)
|
||||
|
||||
const item = props.item
|
||||
|
||||
// grupo com submenu: active se qualquer descendente estiver ativo
|
||||
if (item?.items?.length) {
|
||||
if (hasActiveDescendant(item, current)) return true
|
||||
|
||||
// fallback pelo "path" (útil para rotas internas tipo /saas/plans/123)
|
||||
return item.path ? current.startsWith(fullPath.value || '') : false
|
||||
}
|
||||
|
||||
// folha: active se rota igual ao to
|
||||
return item?.to ? isSameRoute(current, item.to) : false
|
||||
const leafTo = toPath(item?.to)
|
||||
return leafTo ? isSameRoute(current, leafTo) : false
|
||||
})
|
||||
|
||||
// ==============================
|
||||
// Feature lock + label
|
||||
// ✅ PRO badge (agora 100% por entitlementsStore)
|
||||
// ==============================
|
||||
const ownerId = computed(() => tenantStore.activeTenantId || null)
|
||||
|
||||
const isLocked = computed(() => {
|
||||
const showProBadge = computed(() => {
|
||||
const feature = props.item?.feature
|
||||
return !!(props.item?.proBadge && feature && ownerId.value && !entitlementsStore.has(feature))
|
||||
if (!props.item?.proBadge || !feature) return false
|
||||
|
||||
// se tiver a feature, NÃO é PRO (não mostra badge)
|
||||
// se NÃO tiver, mostra PRO
|
||||
try {
|
||||
return !entitlementsStore.has(feature)
|
||||
} catch {
|
||||
// se der erro, não mostra (evita “PRO fantasma”)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// 🔒 locked quando precisa mostrar PRO (ou seja, não tem feature)
|
||||
const isLocked = computed(() => !!(props.item?.proBadge && showProBadge.value))
|
||||
|
||||
const itemDisabled = computed(() => !!props.item?.disabled)
|
||||
const isBlocked = computed(() => itemDisabled.value || isLocked.value)
|
||||
|
||||
const labelText = computed(() => {
|
||||
const base = props.item?.label || ''
|
||||
return props.item?.proBadge && isLocked.value ? `${base} (PRO)` : base
|
||||
return props.item?.label || ''
|
||||
})
|
||||
|
||||
const itemClick = async (event, item) => {
|
||||
@@ -96,17 +127,14 @@ const itemClick = async (event, item) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 🚫 disabled -> bloqueia
|
||||
if (itemDisabled.value) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
// commands
|
||||
if (item?.command) item.command({ originalEvent: event, item })
|
||||
|
||||
// ✅ submenu: expande/colapsa e não navega
|
||||
if (item?.items?.length) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -114,24 +142,22 @@ const itemClick = async (event, item) => {
|
||||
if (isActive.value) {
|
||||
layoutState.activePath = props.parentPath || ''
|
||||
} else {
|
||||
layoutState.activePath = fullPath.value
|
||||
layoutState.activePath = fullPath.value || ''
|
||||
layoutState.menuHoverActive = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ leaf: marca ativo e NÃO fecha menu
|
||||
if (item?.to) layoutState.activePath = item.to
|
||||
if (item?.to) layoutState.activePath = toPath(item.to)
|
||||
}
|
||||
|
||||
const onMouseEnter = () => {
|
||||
if (isDesktop() && props.root && props.item?.items && layoutState.menuHoverActive) {
|
||||
layoutState.activePath = fullPath.value
|
||||
layoutState.activePath = fullPath.value || ''
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- POPUP + ---------- */
|
||||
|
||||
function togglePopover (event) {
|
||||
if (isBlocked.value) return
|
||||
pop.value?.toggle(event)
|
||||
@@ -143,10 +169,7 @@ function closePopover () {
|
||||
|
||||
function abrirCadastroRapido () {
|
||||
closePopover()
|
||||
emit('quick-create', {
|
||||
entity: props.item?.quickCreateEntity || 'patient',
|
||||
mode: 'rapido'
|
||||
})
|
||||
emit('quick-create', { entity: props.item?.quickCreateEntity || 'patient', mode: 'rapido' })
|
||||
}
|
||||
|
||||
async function irCadastroCompleto () {
|
||||
@@ -157,17 +180,17 @@ async function irCadastroCompleto () {
|
||||
layoutState.menuHoverActive = false
|
||||
|
||||
await nextTick()
|
||||
router.push('/admin/patients/cadastro')
|
||||
router.push({ name: 'admin-pacientes-cadastro' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
|
||||
<div v-if="root && item.visible !== false" class="layout-menuitem-root-text">
|
||||
<li v-show="isVisible" :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
|
||||
<div v-if="root" class="layout-menuitem-root-text">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
|
||||
<div v-if="!root && item.visible !== false" class="flex align-items-center justify-content-between w-full">
|
||||
<div v-if="!root" class="flex align-items-center justify-content-between w-full">
|
||||
<component
|
||||
:is="item.to && !item.items ? 'router-link' : 'a'"
|
||||
v-bind="item.to && !item.items ? { to: item.to } : { href: item.url }"
|
||||
@@ -183,8 +206,14 @@ async function irCadastroCompleto () {
|
||||
|
||||
<span class="layout-menuitem-text">
|
||||
{{ labelText }}
|
||||
<!-- (debug) pode remover depois -->
|
||||
<small style="opacity:.6">[locked={{ isLocked }}]</small>
|
||||
</span>
|
||||
|
||||
<!-- ✅ Badge PRO some quando tem entitlements -->
|
||||
<span
|
||||
v-if="item.proBadge && showProBadge"
|
||||
class="ml-2 text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"
|
||||
>
|
||||
PRO
|
||||
</span>
|
||||
|
||||
<i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" />
|
||||
@@ -209,7 +238,7 @@ async function irCadastroCompleto () {
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<Transition v-if="item.items && item.visible !== false" name="layout-submenu">
|
||||
<Transition v-if="item.items" name="layout-submenu">
|
||||
<ul v-show="root ? true : isActive" class="layout-submenu">
|
||||
<app-menu-item
|
||||
v-for="child in item.items"
|
||||
@@ -222,4 +251,4 @@ async function irCadastroCompleto () {
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
329
src/layout/AppRail.vue
Normal file
329
src/layout/AppRail.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<!-- src/layout/AppRail.vue — Mini icon rail (Layout 2) -->
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Popover from 'primevue/popover'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
import { useLayout } from './composables/layout'
|
||||
import { sessionUser } from '@/app/session'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const menuStore = useMenuStore()
|
||||
const { layoutConfig, layoutState, isDesktop } = useLayout()
|
||||
const router = useRouter()
|
||||
|
||||
// ── Seções do rail (derivadas do model) ─────────────────────
|
||||
const railSections = computed(() => {
|
||||
const model = menuStore.model || []
|
||||
return model
|
||||
.filter(s => s.label && Array.isArray(s.items) && s.items.length)
|
||||
.map(s => ({
|
||||
key: s.label,
|
||||
label: s.label,
|
||||
icon: s.icon || s.items.find(i => i.icon)?.icon || 'pi pi-fw pi-circle',
|
||||
items: s.items
|
||||
}))
|
||||
})
|
||||
|
||||
// ── Avatar / iniciais ────────────────────────────────────────
|
||||
const avatarUrl = computed(() => sessionUser.value?.user_metadata?.avatar_url || null)
|
||||
const initials = computed(() => {
|
||||
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || ''
|
||||
const parts = String(name).trim().split(/\s+/).filter(Boolean)
|
||||
const a = parts[0]?.[0] || 'U'
|
||||
const b = parts.length > 1 ? parts[parts.length - 1][0] : ''
|
||||
return (a + b).toUpperCase()
|
||||
})
|
||||
|
||||
const userName = computed(() => sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || 'Conta')
|
||||
|
||||
// ── Seleção de seção ─────────────────────────────────────────
|
||||
function selectSection (section) {
|
||||
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) {
|
||||
layoutState.railPanelOpen = false
|
||||
} else {
|
||||
layoutState.railSectionKey = section.key
|
||||
layoutState.railPanelOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
function isActiveSectionOrChild (section) {
|
||||
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) return true
|
||||
// verifica se algum filho está ativo
|
||||
const active = String(layoutState.activePath || '')
|
||||
return section.items.some(i => {
|
||||
const p = typeof i.to === 'string' ? i.to : ''
|
||||
return p && active.startsWith(p)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Popover do usuário (rodapé) ───────────────────────────────
|
||||
const userPop = ref(null)
|
||||
function toggleUserPop (e) { userPop.value?.toggle(e) }
|
||||
|
||||
function goTo (path) {
|
||||
try { userPop.value?.hide() } catch {}
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
async function signOut () {
|
||||
try { userPop.value?.hide() } catch {}
|
||||
try { await supabase.auth.signOut() } catch {}
|
||||
router.push('/auth/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="rail">
|
||||
|
||||
<!-- ── Brand ──────────────────────────────────────────── -->
|
||||
<div class="rail__brand">
|
||||
<span class="rail__psi">Ψ</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Nav icons ──────────────────────────────────────── -->
|
||||
<nav class="rail__nav" role="navigation" aria-label="Menu principal">
|
||||
<button
|
||||
v-for="section in railSections"
|
||||
:key="section.key"
|
||||
class="rail__btn"
|
||||
:class="{ 'rail__btn--active': isActiveSectionOrChild(section) }"
|
||||
v-tooltip.right="{ value: section.label, showDelay: 400 }"
|
||||
:aria-label="section.label"
|
||||
@click="selectSection(section)"
|
||||
>
|
||||
<i :class="section.icon" />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- ── Rodapé ─────────────────────────────────────────── -->
|
||||
<div class="rail__foot">
|
||||
<!-- Configurações de layout -->
|
||||
<button
|
||||
class="rail__btn rail__btn--sm"
|
||||
v-tooltip.right="{ value: 'Meu Perfil', showDelay: 400 }"
|
||||
aria-label="Meu Perfil"
|
||||
@click="goTo('/account/profile')"
|
||||
>
|
||||
<i class="pi pi-fw pi-cog" />
|
||||
</button>
|
||||
|
||||
<!-- Avatar / user -->
|
||||
<button
|
||||
class="rail__av-btn"
|
||||
v-tooltip.right="{ value: userName, showDelay: 400 }"
|
||||
:aria-label="userName"
|
||||
@click="toggleUserPop"
|
||||
>
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="rail__av-img" :alt="userName" />
|
||||
<span v-else class="rail__av-init">{{ initials }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Popover usuário ────────────────────────────────── -->
|
||||
<Popover ref="userPop" appendTo="body">
|
||||
<div class="rail-pop">
|
||||
<div class="rail-pop__user">
|
||||
<div class="rail-pop__av">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="rail-pop__av-img" />
|
||||
<span v-else class="rail-pop__av-init">{{ initials }}</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="rail-pop__name">{{ userName }}</div>
|
||||
<div class="rail-pop__email">{{ sessionUser?.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rail-pop__divider" />
|
||||
|
||||
<Button label="Meu Perfil" icon="pi pi-user" text class="w-full justify-start" @click="goTo('/account/profile')" />
|
||||
<Button label="Segurança" icon="pi pi-shield" text class="w-full justify-start" @click="goTo('/account/security')" />
|
||||
|
||||
<div class="rail-pop__divider" />
|
||||
|
||||
<Button label="Sair" icon="pi pi-sign-out" severity="danger" text class="w-full justify-start" @click="signOut" />
|
||||
</div>
|
||||
</Popover>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ─── Rail container ─────────────────────────────────────── */
|
||||
.rail {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border-right: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
z-index: 50;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ─── Brand ──────────────────────────────────────────────── */
|
||||
.rail__brand {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rail__psi {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary-color);
|
||||
text-shadow: 0 0 20px color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ─── Nav ────────────────────────────────────────────────── */
|
||||
.rail__nav {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.rail__nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* ─── Buttons ────────────────────────────────────────────── */
|
||||
.rail__btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.15s, color 0.15s, transform 0.12s;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rail__btn:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.rail__btn--active {
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.rail__btn--active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 22px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: var(--primary-color);
|
||||
}
|
||||
.rail__btn--sm {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ─── Footer ─────────────────────────────────────────────── */
|
||||
.rail__foot {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 0 12px;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
/* ─── Avatar button ──────────────────────────────────────── */
|
||||
.rail__av-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: transform 0.12s, box-shadow 0.15s;
|
||||
background: var(--surface-ground);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rail__av-btn:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 0 0 2px var(--primary-color);
|
||||
}
|
||||
.rail__av-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.rail__av-init {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* ─── Popover ────────────────────────────────────────────── */
|
||||
.rail-pop {
|
||||
min-width: 210px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.rail-pop__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
.rail-pop__av {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface-ground);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
.rail-pop__av-img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.rail-pop__av-init { font-size: 0.78rem; font-weight: 700; color: var(--text-color); }
|
||||
.rail-pop__name {
|
||||
font-size: 0.83rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-pop__email {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-color-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-pop__divider {
|
||||
height: 1px;
|
||||
background: var(--surface-border);
|
||||
margin: 2px 0;
|
||||
}
|
||||
</style>
|
||||
246
src/layout/AppRailPanel.vue
Normal file
246
src/layout/AppRailPanel.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<!-- src/layout/AppRailPanel.vue — Painel expansível do Layout 2 -->
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
import { useLayout } from './composables/layout'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
|
||||
const menuStore = useMenuStore()
|
||||
const { layoutState } = useLayout()
|
||||
const entitlements = useEntitlementsStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// ── Seção ativa ──────────────────────────────────────────────
|
||||
const currentSection = computed(() => {
|
||||
const model = menuStore.model || []
|
||||
return model.find(s => s.label === layoutState.railSectionKey) || null
|
||||
})
|
||||
|
||||
// ── Items da seção (com suporte a children) ──────────────────
|
||||
const sectionItems = computed(() => currentSection.value?.items || [])
|
||||
|
||||
function isLocked (item) {
|
||||
if (!item.proBadge || !item.feature) return false
|
||||
try { return !entitlements.has(item.feature) } catch { return false }
|
||||
}
|
||||
|
||||
function isActive (item) {
|
||||
const active = String(layoutState.activePath || route.path || '')
|
||||
if (!item.to) return false
|
||||
const p = typeof item.to === 'string' ? item.to : ''
|
||||
return active === p || active.startsWith(p + '/')
|
||||
}
|
||||
|
||||
function navigate (item) {
|
||||
if (isLocked(item)) {
|
||||
router.push({ name: 'upgrade', query: { feature: item.feature || '' } })
|
||||
return
|
||||
}
|
||||
if (item.to) {
|
||||
layoutState.activePath = typeof item.to === 'string' ? item.to : router.resolve(item.to).path
|
||||
router.push(item.to)
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel () {
|
||||
layoutState.railPanelOpen = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="panel-slide">
|
||||
<aside
|
||||
v-if="layoutState.railPanelOpen && currentSection"
|
||||
class="rp"
|
||||
aria-label="Menu lateral"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="rp__head">
|
||||
<span class="rp__title">{{ currentSection.label }}</span>
|
||||
<button class="rp__close" aria-label="Fechar painel" @click="closePanel">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
<nav class="rp__nav">
|
||||
<template v-for="item in sectionItems" :key="item.to || item.label">
|
||||
<!-- Item com filhos (sub-seção) -->
|
||||
<div v-if="item.items?.length" class="rp__group">
|
||||
<div class="rp__group-label">{{ item.label }}</div>
|
||||
<button
|
||||
v-for="child in item.items"
|
||||
:key="child.to || child.label"
|
||||
class="rp__item"
|
||||
:class="{
|
||||
'rp__item--active': isActive(child),
|
||||
'rp__item--locked': isLocked(child)
|
||||
}"
|
||||
@click="navigate(child)"
|
||||
>
|
||||
<i v-if="child.icon" :class="child.icon" class="rp__item-icon" />
|
||||
<span class="rp__item-label">{{ child.label }}</span>
|
||||
<span v-if="isLocked(child)" class="rp__pro">PRO</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Item folha -->
|
||||
<button
|
||||
v-else
|
||||
class="rp__item"
|
||||
:class="{
|
||||
'rp__item--active': isActive(item),
|
||||
'rp__item--locked': isLocked(item)
|
||||
}"
|
||||
@click="navigate(item)"
|
||||
>
|
||||
<i v-if="item.icon" :class="item.icon" class="rp__item-icon" />
|
||||
<span class="rp__item-label">{{ item.label }}</span>
|
||||
<span v-if="isLocked(item)" class="rp__pro">PRO</span>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</aside>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ─── Panel ──────────────────────────────────────────────── */
|
||||
.rp {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── Header ─────────────────────────────────────────────── */
|
||||
.rp__head {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
.rp__title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.rp__close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.75rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.rp__close:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* ─── Nav list ───────────────────────────────────────────── */
|
||||
.rp__nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--surface-border) transparent;
|
||||
}
|
||||
|
||||
/* ─── Group ──────────────────────────────────────────────── */
|
||||
.rp__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.rp__group:first-child { margin-top: 0; }
|
||||
.rp__group-label {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.55;
|
||||
padding: 2px 10px 6px;
|
||||
}
|
||||
|
||||
/* ─── Item ───────────────────────────────────────────────── */
|
||||
.rp__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 9px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.83rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.13s, color 0.13s;
|
||||
}
|
||||
.rp__item:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.rp__item--active {
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.rp__item--locked {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.rp__item-icon {
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.rp__item-label { flex: 1; }
|
||||
.rp__pro {
|
||||
font-size: 0.58rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--surface-border);
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ─── Slide transition ───────────────────────────────────── */
|
||||
.panel-slide-enter-active,
|
||||
.panel-slide-leave-active {
|
||||
transition: width 0.22s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.18s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-slide-enter-from,
|
||||
.panel-slide-leave-to {
|
||||
width: 0 !important;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
159
src/layout/AppRailTopbar.vue
Normal file
159
src/layout/AppRailTopbar.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<!-- src/layout/AppRailTopbar.vue — Topbar leve para Layout 2 (Rail) -->
|
||||
<script setup>
|
||||
import { computed, ref, nextTick } from 'vue'
|
||||
|
||||
import AppConfigurator from './AppConfigurator.vue'
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
const { toggleDarkMode, isDarkTheme } = useLayout()
|
||||
const { queuePatch } = useUserSettingsPersistence()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
const tenantName = computed(() => {
|
||||
const t =
|
||||
tenantStore.activeTenant ||
|
||||
tenantStore.tenant ||
|
||||
tenantStore.currentTenant ||
|
||||
null
|
||||
return (
|
||||
t?.name ||
|
||||
t?.nome ||
|
||||
t?.display_name ||
|
||||
t?.fantasy_name ||
|
||||
t?.razao_social ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
function isDarkNow () {
|
||||
return document.documentElement.classList.contains('app-dark')
|
||||
}
|
||||
|
||||
async function waitForDarkFlip (before, timeoutMs = 900) {
|
||||
const start = performance.now()
|
||||
while (performance.now() - start < timeoutMs) {
|
||||
await nextTick()
|
||||
await new Promise((r) => requestAnimationFrame(r))
|
||||
if (isDarkNow() !== before) return isDarkNow()
|
||||
}
|
||||
return isDarkNow()
|
||||
}
|
||||
|
||||
async function toggleDarkAndPersist () {
|
||||
try {
|
||||
const before = isDarkNow()
|
||||
toggleDarkMode()
|
||||
const after = await waitForDarkFlip(before)
|
||||
await queuePatch({ theme_mode: after ? 'dark' : 'light' }, { flushNow: true })
|
||||
} catch (e) {
|
||||
console.error('[RailTopbar][theme] falhou:', e?.message || e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="rail-topbar">
|
||||
<!-- Tenant pill -->
|
||||
<div class="rail-topbar__left">
|
||||
<span v-if="tenantName" class="rail-topbar__tenant" :title="tenantName">
|
||||
{{ tenantName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="rail-topbar__actions">
|
||||
<!-- Dark mode -->
|
||||
<button
|
||||
type="button"
|
||||
class="rail-topbar__btn"
|
||||
:title="isDarkTheme ? 'Modo claro' : 'Modo escuro'"
|
||||
@click="toggleDarkAndPersist"
|
||||
>
|
||||
<i :class="['pi', isDarkTheme ? 'pi-sun' : 'pi-moon']" />
|
||||
</button>
|
||||
|
||||
<!-- Tema / paleta -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="rail-topbar__btn rail-topbar__btn--highlight"
|
||||
title="Configurar tema"
|
||||
v-styleclass="{
|
||||
selector: '@next',
|
||||
enterFromClass: 'hidden',
|
||||
enterActiveClass: 'p-anchored-overlay-enter-active',
|
||||
leaveToClass: 'hidden',
|
||||
leaveActiveClass: 'p-anchored-overlay-leave-active',
|
||||
hideOnOutsideClick: true
|
||||
}"
|
||||
>
|
||||
<i class="pi pi-palette" />
|
||||
</button>
|
||||
<AppConfigurator />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rail-topbar {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.25rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.rail-topbar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rail-topbar__tenant {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 320px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
|
||||
.rail-topbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.rail-topbar__btn {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color-secondary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.rail-topbar__btn:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.rail-topbar__btn--highlight {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<div class="layout-wrapper">
|
||||
<AppTopbar @toggleMenu="toggleSidebar" />
|
||||
<AppSidebar :model="menu" :visible="sidebarVisible" @hide="sidebarVisible=false" />
|
||||
<div class="layout-main-container">
|
||||
<div class="layout-main">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useSessionStore } from '@/app/store/sessionStore'
|
||||
import { getMenuByRole } from '@/navigation'
|
||||
import AppTopbar from '@/components/layout/AppTopbar.vue'
|
||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||
|
||||
const sidebarVisible = ref(true)
|
||||
function toggleSidebar(){ sidebarVisible.value = !sidebarVisible.value }
|
||||
|
||||
const session = useSessionStore()
|
||||
const menu = computed(() => getMenuByRole(session.role))
|
||||
</script>
|
||||
@@ -1,66 +1,73 @@
|
||||
<script setup>
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import AppMenu from './AppMenu.vue';
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppMenu from './AppMenu.vue'
|
||||
|
||||
const { layoutState, isDesktop, hasOpenOverlay } = useLayout();
|
||||
const route = useRoute();
|
||||
const sidebarRef = ref(null);
|
||||
let outsideClickListener = null;
|
||||
const { layoutState, isDesktop, hasOpenOverlay, closeMenuOnNavigate } = useLayout()
|
||||
const route = useRoute()
|
||||
const sidebarRef = ref(null)
|
||||
let outsideClickListener = null
|
||||
|
||||
// ✅ rota mudou:
|
||||
// - atualiza activePath sempre (desktop e mobile)
|
||||
// - fecha menu SOMENTE no mobile (evita “sumir” no desktop / inconsistências)
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
if (isDesktop()) layoutState.activePath = null;
|
||||
else layoutState.activePath = newPath;
|
||||
|
||||
layoutState.overlayMenuActive = false;
|
||||
layoutState.mobileMenuActive = false;
|
||||
layoutState.menuHoverActive = false;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
layoutState.activePath = newPath
|
||||
closeMenuOnNavigate?.()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// mantém o outside click só quando overlay está aberto e estamos em desktop
|
||||
watch(hasOpenOverlay, (newVal) => {
|
||||
if (isDesktop()) {
|
||||
if (newVal) bindOutsideClickListener();
|
||||
else unbindOutsideClickListener();
|
||||
}
|
||||
});
|
||||
if (isDesktop()) {
|
||||
if (newVal) bindOutsideClickListener()
|
||||
else unbindOutsideClickListener()
|
||||
}
|
||||
})
|
||||
|
||||
const bindOutsideClickListener = () => {
|
||||
if (!outsideClickListener) {
|
||||
outsideClickListener = (event) => {
|
||||
if (isOutsideClicked(event)) {
|
||||
layoutState.overlayMenuActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', outsideClickListener);
|
||||
if (!outsideClickListener) {
|
||||
outsideClickListener = (event) => {
|
||||
if (isOutsideClicked(event)) {
|
||||
layoutState.overlayMenuActive = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', outsideClickListener)
|
||||
}
|
||||
}
|
||||
|
||||
const unbindOutsideClickListener = () => {
|
||||
if (outsideClickListener) {
|
||||
document.removeEventListener('click', outsideClickListener);
|
||||
outsideClickListener = null;
|
||||
}
|
||||
};
|
||||
if (outsideClickListener) {
|
||||
document.removeEventListener('click', outsideClickListener)
|
||||
outsideClickListener = null
|
||||
}
|
||||
}
|
||||
|
||||
const isOutsideClicked = (event) => {
|
||||
const topbarButtonEl = document.querySelector('.layout-menu-button');
|
||||
const topbarButtonEl = document.querySelector('.layout-menu-button')
|
||||
const el = sidebarRef.value
|
||||
if (!el) return true
|
||||
|
||||
return !(sidebarRef.value.isSameNode(event.target) || sidebarRef.value.contains(event.target) || topbarButtonEl?.isSameNode(event.target) || topbarButtonEl?.contains(event.target));
|
||||
};
|
||||
return !(
|
||||
el.isSameNode(event.target) ||
|
||||
el.contains(event.target) ||
|
||||
topbarButtonEl?.isSameNode(event.target) ||
|
||||
topbarButtonEl?.contains(event.target)
|
||||
)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unbindOutsideClickListener();
|
||||
});
|
||||
unbindOutsideClickListener()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="sidebarRef" class="layout-sidebar">
|
||||
<AppMenu />
|
||||
</div>
|
||||
</template>
|
||||
<div ref="sidebarRef" class="layout-sidebar">
|
||||
<AppMenu />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,66 +1,132 @@
|
||||
<!-- src/layout/AppTopbar.vue -->
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, provide, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
import AppConfigurator from './AppConfigurator.vue'
|
||||
|
||||
import Button from 'primevue/button'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
|
||||
import { useRoleGuard } from '@/composables/useRoleGuard'
|
||||
const { canSee } = useRoleGuard()
|
||||
|
||||
// ✅ engine central
|
||||
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
|
||||
import { applyThemeEngine } from '@/theme/theme.options'
|
||||
|
||||
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
||||
|
||||
const toast = useToast()
|
||||
const entitlementsStore = useEntitlementsStore()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
const { toggleMenu, toggleDarkMode, isDarkTheme, layoutConfig, changeMenuMode } = useLayout()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const planBtn = ref(null)
|
||||
|
||||
/* ----------------------------
|
||||
Persistência (1 instância)
|
||||
Persistência
|
||||
----------------------------- */
|
||||
const { init: initUserSettings, queuePatch } = useUserSettingsPersistence()
|
||||
|
||||
provide('queueUserSettingsPatch', queuePatch)
|
||||
|
||||
/* ----------------------------
|
||||
Contexto (UID/Email/Tenant)
|
||||
----------------------------- */
|
||||
const sessionUid = ref(null)
|
||||
const sessionEmail = ref(null)
|
||||
|
||||
async function loadSessionIdentity () {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
sessionUid.value = data?.user?.id || null
|
||||
sessionEmail.value = data?.user?.email || null
|
||||
} catch (e) {
|
||||
sessionUid.value = null
|
||||
sessionEmail.value = null
|
||||
console.warn('[Topbar][identity] falhou:', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || null)
|
||||
|
||||
// ✅ tenta achar “nome/email” da clínica do jeito mais tolerante possível
|
||||
const tenantName = computed(() => {
|
||||
const t =
|
||||
tenantStore.activeTenant ||
|
||||
tenantStore.tenant ||
|
||||
tenantStore.currentTenant ||
|
||||
null
|
||||
|
||||
return (
|
||||
t?.name ||
|
||||
t?.nome ||
|
||||
t?.display_name ||
|
||||
t?.fantasy_name ||
|
||||
t?.razao_social ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const tenantEmail = computed(() => {
|
||||
const t =
|
||||
tenantStore.activeTenant ||
|
||||
tenantStore.tenant ||
|
||||
tenantStore.currentTenant ||
|
||||
null
|
||||
|
||||
return (
|
||||
t?.email ||
|
||||
t?.clinic_email ||
|
||||
t?.contact_email ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const ctxItems = computed(() => {
|
||||
const items = []
|
||||
|
||||
if (tenantName.value) items.push({ k: 'Clínica', v: tenantName.value })
|
||||
if (tenantEmail.value) items.push({ k: 'Email', v: tenantEmail.value })
|
||||
|
||||
// ids (sempre úteis pra debug)
|
||||
if (tenantId.value) items.push({ k: 'Tenant', v: tenantId.value })
|
||||
if (sessionUid.value) items.push({ k: 'UID', v: sessionUid.value })
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
/* ----------------------------
|
||||
Fonte da verdade: DOM
|
||||
----------------------------- */
|
||||
function isDarkNow() {
|
||||
function isDarkNow () {
|
||||
return document.documentElement.classList.contains('app-dark')
|
||||
}
|
||||
|
||||
function setDarkMode(shouldBeDark) {
|
||||
function setDarkMode (shouldBeDark) {
|
||||
const now = isDarkNow()
|
||||
if (shouldBeDark !== now) toggleDarkMode()
|
||||
}
|
||||
|
||||
async function waitForDarkFlip(before, timeoutMs = 900) {
|
||||
async function waitForDarkFlip (before, timeoutMs = 900) {
|
||||
const start = performance.now()
|
||||
|
||||
while (performance.now() - start < timeoutMs) {
|
||||
await nextTick()
|
||||
await new Promise((r) => requestAnimationFrame(r))
|
||||
|
||||
const now = isDarkNow()
|
||||
if (now !== before) return now
|
||||
}
|
||||
return isDarkNow()
|
||||
}
|
||||
|
||||
/* ----------------------------
|
||||
Bootstrap: carrega e aplica
|
||||
----------------------------- */
|
||||
async function loadAndApplyUserSettings() {
|
||||
async function loadAndApplyUserSettings () {
|
||||
try {
|
||||
const { data: u, error: uErr } = await supabase.auth.getUser()
|
||||
if (uErr) throw uErr
|
||||
@@ -74,27 +140,27 @@ async function loadAndApplyUserSettings() {
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
if (!settings) {
|
||||
console.log('[Topbar][bootstrap] sem user_settings ainda')
|
||||
return
|
||||
}
|
||||
if (!settings) return
|
||||
|
||||
console.log('[Topbar][bootstrap] settings=', settings)
|
||||
|
||||
// dark/light
|
||||
// 1) dark/light (DOM é a fonte da verdade)
|
||||
if (settings.theme_mode) setDarkMode(settings.theme_mode === 'dark')
|
||||
|
||||
// layoutConfig
|
||||
// 2) layoutConfig
|
||||
if (settings.preset) layoutConfig.preset = settings.preset
|
||||
if (settings.primary_color) layoutConfig.primary = settings.primary_color
|
||||
if (settings.surface_color) layoutConfig.surface = settings.surface_color
|
||||
if (settings.menu_mode) layoutConfig.menuMode = settings.menu_mode
|
||||
|
||||
// aplica tema via engine única
|
||||
// 3) aplica engine UMA vez
|
||||
applyThemeEngine(layoutConfig)
|
||||
|
||||
// aplica menu mode
|
||||
try { changeMenuMode() } catch (e) {
|
||||
// ✅ IMPORTANTE:
|
||||
// changeMenuMode NÃO é só "setar menuMode".
|
||||
// Ele reseta estados do sidebar/overlay/mobile e previne drift.
|
||||
try {
|
||||
changeMenuMode(layoutConfig.menuMode)
|
||||
} catch (e) {
|
||||
try { changeMenuMode({ value: layoutConfig.menuMode }) } catch {}
|
||||
console.warn('[Topbar][bootstrap] changeMenuMode falhou:', e?.message || e)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -102,45 +168,88 @@ async function loadAndApplyUserSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------
|
||||
Atalho topbar: Dark/Light
|
||||
----------------------------- */
|
||||
async function toggleDarkAndPersistSilently() {
|
||||
async function toggleDarkAndPersistSilently () {
|
||||
try {
|
||||
const before = isDarkNow()
|
||||
console.log('[Topbar][theme] click. before=', before ? 'dark' : 'light')
|
||||
|
||||
toggleDarkMode()
|
||||
|
||||
const after = await waitForDarkFlip(before)
|
||||
const theme_mode = after ? 'dark' : 'light'
|
||||
|
||||
console.log('[Topbar][theme] after=', theme_mode, 'isDarkTheme=', !!isDarkTheme)
|
||||
|
||||
await queuePatch({ theme_mode }, { flushNow: true })
|
||||
|
||||
console.log('[Topbar][theme] saved theme_mode=', theme_mode)
|
||||
} catch (e) {
|
||||
console.error('[Topbar][theme] falhou:', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------
|
||||
Plano (teu código intacto)
|
||||
Plano (DEV) — popup menu
|
||||
----------------------------- */
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || null)
|
||||
const trocandoPlano = ref(false)
|
||||
|
||||
async function getPlanIdByKey(planKey) {
|
||||
const { data, error } = await supabase.from('plans').select('id, key').eq('key', planKey).single()
|
||||
const enablePlanToggle = computed(() => {
|
||||
const flag = String(import.meta.env?.VITE_ENABLE_PLAN_TOGGLE || '').toLowerCase()
|
||||
return Boolean(import.meta.env?.DEV) || flag === 'true'
|
||||
})
|
||||
|
||||
const showPlanDevMenu = computed(() => {
|
||||
return canSee('settings.view') && enablePlanToggle.value
|
||||
})
|
||||
|
||||
const planMenu = ref()
|
||||
const planMenuLoading = ref(false)
|
||||
const planMenuTarget = ref(null) // 'therapist' | 'clinic' | null
|
||||
const planMenuSub = ref(null) // subscription ativa (obj)
|
||||
const planMenuPlans = ref([]) // plans ativos do target
|
||||
|
||||
async function getMyUserId () {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
return data.id
|
||||
const uid = data?.user?.id
|
||||
if (!uid) throw new Error('Sessão inválida (sem user).')
|
||||
return uid
|
||||
}
|
||||
|
||||
async function getActiveSubscriptionByTenant(tid) {
|
||||
// therapist subscription: user_id — sem filtro de tenant_id (pode estar preenchido)
|
||||
async function getActiveTherapistSubscription () {
|
||||
const uid = await getMyUserId()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, tenant_id, plan_id, status, created_at, updated_at')
|
||||
.select('id, tenant_id, user_id, plan_id, status, updated_at')
|
||||
.eq('user_id', uid)
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(10)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const list = data || []
|
||||
if (!list.length) return null
|
||||
|
||||
const priority = (st) => {
|
||||
const s = String(st || '').toLowerCase()
|
||||
if (s === 'active') return 1
|
||||
if (s === 'trialing') return 2
|
||||
if (s === 'past_due') return 3
|
||||
if (s === 'unpaid') return 4
|
||||
if (s === 'incomplete') return 5
|
||||
if (s === 'canceled' || s === 'cancelled') return 9
|
||||
return 8
|
||||
}
|
||||
|
||||
return list.slice().sort((a, b) => {
|
||||
const pa = priority(a?.status)
|
||||
const pb = priority(b?.status)
|
||||
if (pa !== pb) return pa - pb
|
||||
return new Date(b?.updated_at || 0) - new Date(a?.updated_at || 0)
|
||||
})[0]
|
||||
}
|
||||
|
||||
async function getActiveClinicSubscription () {
|
||||
const tid = tenantId.value
|
||||
if (!tid) return null
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, tenant_id, user_id, plan_id, status, updated_at')
|
||||
.eq('tenant_id', tid)
|
||||
.eq('status', 'active')
|
||||
.order('updated_at', { ascending: false })
|
||||
@@ -151,69 +260,273 @@ async function getActiveSubscriptionByTenant(tid) {
|
||||
return data || null
|
||||
}
|
||||
|
||||
async function getPlanKeyById(planId) {
|
||||
const { data, error } = await supabase.from('plans').select('key').eq('id', planId).single()
|
||||
async function listActivePlansByTarget (target) {
|
||||
const { data, error } = await supabase
|
||||
.from('plans')
|
||||
.select('id, key, target, is_active')
|
||||
.eq('target', target)
|
||||
.eq('is_active', true)
|
||||
.order('key', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data.key
|
||||
return data || []
|
||||
}
|
||||
|
||||
async function alternarPlano() {
|
||||
async function refreshEntitlementsAfterToggle (target) {
|
||||
// ✅ aqui NÃO dá pra usar invalidate geral, porque precisamos dos dois caches
|
||||
// mas durante toggle, é mais seguro forçar recarga do escopo que foi alterado.
|
||||
|
||||
if (target === 'clinic') {
|
||||
const tid = tenantId.value
|
||||
if (!tid) return
|
||||
await entitlementsStore.loadForTenant(tid, { force: true })
|
||||
return
|
||||
}
|
||||
|
||||
// therapist
|
||||
const uid = await getMyUserId()
|
||||
await entitlementsStore.loadForUser(uid, { force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ Resolve a subscription ativa levando em conta a área da rota atual.
|
||||
*
|
||||
* Áreas de clínica (/admin, /supervisor) → contexto tenant_id (clinic)
|
||||
* Demais áreas (/therapist, /editor, /portal, etc.) → contexto user_id (pessoal)
|
||||
*
|
||||
* Isso evita que um editor que também é membro de uma clínica veja o plano
|
||||
* da clínica no botão DEV em vez do seu próprio plano.
|
||||
*/
|
||||
async function resolveActiveSubscriptionContext () {
|
||||
const path = route.path || ''
|
||||
const isClinicContext =
|
||||
path.startsWith('/admin') ||
|
||||
path.startsWith('/supervisor')
|
||||
|
||||
if (isClinicContext && tenantId.value) {
|
||||
const clinicSub = await getActiveClinicSubscription()
|
||||
if (clinicSub) return { sub: clinicSub, target: 'clinic' }
|
||||
}
|
||||
|
||||
const therapistSub = await getActiveTherapistSubscription()
|
||||
if (therapistSub) return { sub: therapistSub, target: 'therapist' }
|
||||
|
||||
// último fallback: clinic (caso não-clínica sem sub pessoal)
|
||||
if (tenantId.value) {
|
||||
const clinicSub = await getActiveClinicSubscription()
|
||||
return { sub: clinicSub || null, target: clinicSub ? 'clinic' : null }
|
||||
}
|
||||
|
||||
return { sub: null, target: null }
|
||||
}
|
||||
|
||||
function normalizeKey (k) {
|
||||
return String(k || '').trim()
|
||||
}
|
||||
|
||||
// free primeiro, depois o resto por key
|
||||
function sortPlansSmart (plans) {
|
||||
const arr = [...(plans || [])]
|
||||
arr.sort((a, b) => {
|
||||
const ak = normalizeKey(a?.key).toLowerCase()
|
||||
const bk = normalizeKey(b?.key).toLowerCase()
|
||||
|
||||
const aIsFree = ak.endsWith('_free') || ak === 'free'
|
||||
const bIsFree = bk.endsWith('_free') || bk === 'free'
|
||||
if (aIsFree && !bIsFree) return -1
|
||||
if (!aIsFree && bIsFree) return 1
|
||||
|
||||
return ak.localeCompare(bk)
|
||||
})
|
||||
return arr
|
||||
}
|
||||
|
||||
async function loadPlanMenuData () {
|
||||
planMenuLoading.value = true
|
||||
try {
|
||||
const { sub, target } = await resolveActiveSubscriptionContext()
|
||||
planMenuSub.value = sub
|
||||
planMenuTarget.value = target
|
||||
|
||||
if (!sub?.id || !target) {
|
||||
planMenuPlans.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const plans = await listActivePlansByTarget(target)
|
||||
planMenuPlans.value = sortPlansSmart(plans)
|
||||
} finally {
|
||||
planMenuLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const planMenuModel = computed(() => {
|
||||
const sub = planMenuSub.value
|
||||
const target = planMenuTarget.value
|
||||
const plans = planMenuPlans.value || []
|
||||
|
||||
if (!sub?.id || !target) {
|
||||
return [
|
||||
{ label: 'Sem assinatura ativa', icon: 'pi pi-exclamation-triangle', disabled: true },
|
||||
{ label: 'Não encontrei subscription ativa nem para therapist (user_id) nem para clinic (tenant_id).', disabled: true }
|
||||
]
|
||||
}
|
||||
|
||||
const currentPlanId = String(sub.plan_id || '')
|
||||
|
||||
const header = {
|
||||
label: `Planos (${target})`,
|
||||
icon: target === 'therapist' ? 'pi pi-user' : 'pi pi-building',
|
||||
disabled: true
|
||||
}
|
||||
|
||||
const subInfo = {
|
||||
label: `Sub: ${String(sub.id).slice(0, 8)}… • Atual: ${String(currentPlanId).slice(0, 8)}…`,
|
||||
icon: 'pi pi-info-circle',
|
||||
disabled: true
|
||||
}
|
||||
|
||||
const items = []
|
||||
let insertedSeparator = false
|
||||
|
||||
plans.forEach((p) => {
|
||||
const isCurrent = String(p.id) === currentPlanId
|
||||
const keyLower = String(p.key || '').toLowerCase()
|
||||
const isFree = keyLower.endsWith('_free') || keyLower === 'free'
|
||||
|
||||
items.push({
|
||||
label: isCurrent ? `${p.key} (atual)` : p.key,
|
||||
icon: isCurrent ? 'pi pi-check' : (isFree ? 'pi pi-star' : 'pi pi-circle'),
|
||||
disabled: isCurrent || planMenuLoading.value || trocandoPlano.value,
|
||||
command: async () => {
|
||||
await changePlanTo(p.id, p.key, target)
|
||||
}
|
||||
})
|
||||
|
||||
if (!insertedSeparator && isFree) {
|
||||
items.push({ separator: true })
|
||||
insertedSeparator = true
|
||||
}
|
||||
})
|
||||
|
||||
if (items.length && items[items.length - 1]?.separator) items.pop()
|
||||
|
||||
if (!plans.length) {
|
||||
return [header, subInfo, { separator: true }, { label: 'Nenhum plano ativo encontrado', icon: 'pi pi-info-circle', disabled: true }]
|
||||
}
|
||||
|
||||
return [header, subInfo, { separator: true }, ...items]
|
||||
})
|
||||
|
||||
async function openPlanMenu (event) {
|
||||
if (!showPlanDevMenu.value) return
|
||||
|
||||
try {
|
||||
await loadPlanMenuData()
|
||||
} catch (err) {
|
||||
console.error('[PLANO][DEV menu] erro:', err?.message || err)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro ao carregar planos',
|
||||
detail: err?.message || 'Falha desconhecida.',
|
||||
life: 5200
|
||||
})
|
||||
}
|
||||
|
||||
const anchorEl = planBtn.value?.$el || event?.currentTarget || event?.target
|
||||
if (!anchorEl) {
|
||||
planMenu.value?.toggle?.(event)
|
||||
return
|
||||
}
|
||||
|
||||
planMenu.value?.show?.({ currentTarget: anchorEl })
|
||||
}
|
||||
|
||||
async function changePlanTo (newPlanId, newPlanKey, target) {
|
||||
if (trocandoPlano.value) return
|
||||
trocandoPlano.value = true
|
||||
|
||||
try {
|
||||
const tid = tenantId.value
|
||||
if (!tid) {
|
||||
toast.add({ severity: 'warn', summary: 'Sem tenant ativo', detail: 'Selecione/entre em uma clínica (tenant) antes de trocar o plano.', life: 4500 })
|
||||
return
|
||||
}
|
||||
|
||||
const sub = await getActiveSubscriptionByTenant(tid)
|
||||
if (!sub?.id) {
|
||||
toast.add({ severity: 'warn', summary: 'Sem assinatura ativa', detail: 'Esse tenant ainda não tem subscription ativa. Ative via intenção/pagamento manual.', life: 5000 })
|
||||
return
|
||||
}
|
||||
|
||||
const atualKey = await getPlanKeyById(sub.plan_id)
|
||||
const novoKey = atualKey === 'pro' ? 'free' : 'pro'
|
||||
const novoPlanId = await getPlanIdByKey(novoKey)
|
||||
const sub = planMenuSub.value
|
||||
if (!sub?.id) throw new Error('Subscription inválida.')
|
||||
|
||||
const { error: rpcError } = await supabase.rpc('change_subscription_plan', {
|
||||
p_subscription_id: sub.id,
|
||||
p_new_plan_id: novoPlanId
|
||||
p_new_plan_id: newPlanId
|
||||
})
|
||||
if (rpcError) throw rpcError
|
||||
|
||||
entitlementsStore.clear?.()
|
||||
await entitlementsStore.fetch(tid, { force: true })
|
||||
planMenuSub.value = { ...sub, plan_id: newPlanId }
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Plano alternado', detail: `${String(atualKey).toUpperCase()} → ${String(novoKey).toUpperCase()}`, life: 3000 })
|
||||
// ✅ recarrega o escopo certo (tenant ou user)
|
||||
await refreshEntitlementsAfterToggle(target)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Plano alterado (DEV)',
|
||||
detail: `${String(newPlanKey).toUpperCase()} aplicado (${target})`,
|
||||
life: 3200
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[PLANO] Erro ao alternar:', err?.message || err)
|
||||
toast.add({ severity: 'error', summary: 'Erro ao alternar plano', detail: err?.message || 'Falha desconhecida.', life: 5000 })
|
||||
console.error('[PLANO] Erro ao trocar:', err?.message || err)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro ao trocar plano',
|
||||
detail: err?.message || 'Falha desconhecida.',
|
||||
life: 6000
|
||||
})
|
||||
} finally {
|
||||
trocandoPlano.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
/* ----------------------------
|
||||
Logout
|
||||
----------------------------- */
|
||||
async function logout () {
|
||||
const tenant = useTenantStore()
|
||||
const ent = useEntitlementsStore()
|
||||
const tf = useTenantFeaturesStore()
|
||||
|
||||
try {
|
||||
await supabase.auth.signOut()
|
||||
} finally {
|
||||
// limpa possíveis intenções guardadas
|
||||
sessionStorage.removeItem('redirect_after_login')
|
||||
sessionStorage.removeItem('intended_area')
|
||||
tenant.reset()
|
||||
ent.invalidate()
|
||||
tf.invalidate()
|
||||
|
||||
// ✅ 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.
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
|
||||
router.replace('/auth/login')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ Bootstrap entitlements (resolve “menu não alterna” sem depender do guard)
|
||||
* - se tem tenant ativo => carrega tenant entitlements
|
||||
* - senão => carrega user entitlements
|
||||
*/
|
||||
async function bootstrapEntitlements () {
|
||||
try {
|
||||
const uid = sessionUid.value || (await getMyUserId())
|
||||
const tid = tenantId.value
|
||||
|
||||
if (tid) {
|
||||
await entitlementsStore.loadForTenant(tid, { force: false, maxAgeMs: 60_000 })
|
||||
} else if (uid) {
|
||||
await entitlementsStore.loadForUser(uid, { force: false, maxAgeMs: 60_000 })
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Topbar][entitlements bootstrap] falhou:', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await initUserSettings()
|
||||
await loadAndApplyUserSettings()
|
||||
await loadSessionIdentity()
|
||||
await bootstrapEntitlements()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -228,10 +541,24 @@ onMounted(async () => {
|
||||
|
||||
<router-link to="/" class="layout-topbar-logo">
|
||||
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- ... SVG gigante ... -->
|
||||
<!-- ... SVG ... -->
|
||||
</svg>
|
||||
<span>SAKAI</span>
|
||||
</router-link>
|
||||
|
||||
<div v-if="ctxItems.length" class="topbar-ctx">
|
||||
<div class="topbar-ctx-row">
|
||||
<span
|
||||
v-for="(it, idx) in ctxItems"
|
||||
:key="`${it.k}-${idx}`"
|
||||
class="topbar-ctx-pill"
|
||||
:title="`${it.k}: ${it.v}`"
|
||||
>
|
||||
<b class="topbar-ctx-k">{{ it.k }}:</b>
|
||||
<span class="topbar-ctx-v">{{ it.v }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout-topbar-actions">
|
||||
@@ -277,13 +604,23 @@ onMounted(async () => {
|
||||
<div class="layout-topbar-menu hidden lg:block">
|
||||
<div class="layout-topbar-menu-content">
|
||||
<Button
|
||||
label="Plano"
|
||||
icon="pi pi-sync"
|
||||
v-if="showPlanDevMenu"
|
||||
ref="planBtn"
|
||||
label="Plano (DEV)"
|
||||
icon="pi pi-sliders-h"
|
||||
severity="contrast"
|
||||
outlined
|
||||
:loading="trocandoPlano"
|
||||
:disabled="trocandoPlano"
|
||||
@click="alternarPlano"
|
||||
:loading="planMenuLoading || trocandoPlano"
|
||||
:disabled="planMenuLoading || trocandoPlano"
|
||||
@click="openPlanMenu"
|
||||
/>
|
||||
|
||||
<Menu
|
||||
ref="planMenu"
|
||||
:model="planMenuModel"
|
||||
popup
|
||||
appendTo="body"
|
||||
:baseZIndex="3000"
|
||||
/>
|
||||
|
||||
<button type="button" class="layout-topbar-action">
|
||||
@@ -309,3 +646,46 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar-ctx {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 0.75rem;
|
||||
max-width: min(62vw, 980px);
|
||||
}
|
||||
|
||||
.topbar-ctx-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topbar-ctx-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.topbar-ctx-k {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-ctx-v {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.95;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 240px;
|
||||
}
|
||||
</style>
|
||||
172
src/layout/ConfiguracoesPage - Copia.vue
Normal file
172
src/layout/ConfiguracoesPage - Copia.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<!-- src/layout/ConfiguracoesPage.vue -->
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const secoes = [
|
||||
{
|
||||
key: 'agenda',
|
||||
label: 'Agenda',
|
||||
desc: 'Horários semanais, exceções, duração e intervalo padrão.',
|
||||
icon: 'pi pi-calendar',
|
||||
to: '/configuracoes/agenda',
|
||||
tags: ['Horários', 'Exceções', 'Duração']
|
||||
},
|
||||
|
||||
// Ative quando criar as rotas/páginas
|
||||
// {
|
||||
// key: 'clinica',
|
||||
// label: 'Clínica',
|
||||
// desc: 'Padrões clínicos, status e preferências de atendimento.',
|
||||
// icon: 'pi pi-heart',
|
||||
// to: '/configuracoes/clinica',
|
||||
// tags: ['Status', 'Modelos', 'Preferências']
|
||||
// },
|
||||
// {
|
||||
// key: 'intake',
|
||||
// label: 'Cadastros & Intake',
|
||||
// desc: 'Link externo, campos do formulário e mensagens padrão.',
|
||||
// icon: 'pi pi-file-edit',
|
||||
// to: '/configuracoes/intake',
|
||||
// tags: ['Formulário', 'Campos', 'Textos']
|
||||
// },
|
||||
// {
|
||||
// key: 'conta',
|
||||
// label: 'Conta',
|
||||
// desc: 'Perfil, segurança e preferências da conta.',
|
||||
// icon: 'pi pi-user',
|
||||
// to: '/configuracoes/conta',
|
||||
// tags: ['Perfil', 'Segurança', 'Preferências']
|
||||
// }
|
||||
]
|
||||
|
||||
const activeTo = computed(() => {
|
||||
const p = route.path || ''
|
||||
const hit = secoes.find(s => p.startsWith(s.to))
|
||||
return hit?.to || '/configuracoes/agenda'
|
||||
})
|
||||
|
||||
function ir(to) {
|
||||
if (!to) return
|
||||
if (route.path !== to) router.push(to)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- HEADER CONCEITUAL -->
|
||||
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative p-5">
|
||||
<!-- blobs sutis -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-80">
|
||||
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-emerald-400/10 blur-3xl" />
|
||||
<div class="absolute top-10 -left-24 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
|
||||
<div class="absolute bottom-0 right-24 h-44 w-44 rounded-full bg-fuchsia-400/10 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-900 text-2xl font-semibold leading-none">Configurações</div>
|
||||
<div class="text-600 mt-2 max-w-2xl">
|
||||
Defina como sua clínica funciona: agenda, cadastros e preferências. Tudo no mesmo lugar — sem espalhar opções pelo sistema.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label="Voltar"
|
||||
icon="pi pi-arrow-left"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="hidden md:inline-flex"
|
||||
@click="router.back()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<!-- SIDEBAR (seções) -->
|
||||
<div class="col-span-12 lg:col-span-4 xl:col-span-3">
|
||||
<Card class="h-full">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-cog" />
|
||||
<span>Seções</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
v-for="s in secoes"
|
||||
:key="s.key"
|
||||
type="button"
|
||||
class="w-full text-left p-3 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] hover:bg-[var(--surface-hover)] transition flex items-start justify-between gap-3"
|
||||
:class="activeTo === s.to ? 'ring-1 ring-primary/40 border-primary/40' : ''"
|
||||
@click="ir(s.to)"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<div class="mt-1">
|
||||
<i :class="[s.icon, 'text-lg']" style="opacity:.85" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-900 font-medium leading-none">{{ s.label }}</div>
|
||||
<div class="text-600 text-sm mt-2 leading-snug">{{ s.desc }}</div>
|
||||
|
||||
<div v-if="s.tags?.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="t in s.tags"
|
||||
:key="t"
|
||||
class="text-xs px-2 py-1 rounded-full border border-[var(--surface-border)] text-600"
|
||||
>
|
||||
{{ t }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<i class="pi pi-angle-right mt-1" style="opacity:.55" />
|
||||
</button>
|
||||
|
||||
<Divider class="my-2" />
|
||||
|
||||
<Button
|
||||
label="Voltar"
|
||||
icon="pi pi-arrow-left"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="w-full md:hidden"
|
||||
@click="router.back()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Card pequeno “atalhos” opcional -->
|
||||
<div class="mt-4 hidden lg:block">
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="text-900 font-medium">Dica</div>
|
||||
<div class="text-600 text-sm mt-2 leading-relaxed">
|
||||
Comece pela <b>Agenda</b>. É ela que dá “tempo” ao prontuário: sessão marcada → sessão realizada → evolução.
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTEÚDO (seção selecionada) -->
|
||||
<div class="col-span-12 lg:col-span-8 xl:col-span-9">
|
||||
<!-- Aqui entra /configuracoes/agenda etc -->
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,15 +1,17 @@
|
||||
<!-- src/layout/ConfiguracoesPage.vue -->
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// ── Hero sticky ────────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
const secoes = [
|
||||
{
|
||||
key: 'agenda',
|
||||
@@ -57,41 +59,51 @@ function ir(to) {
|
||||
if (!to) return
|
||||
if (route.path !== to) router.push(to)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- HEADER CONCEITUAL -->
|
||||
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative p-5">
|
||||
<!-- blobs sutis -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-80">
|
||||
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-emerald-400/10 blur-3xl" />
|
||||
<div class="absolute top-10 -left-24 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
|
||||
<div class="absolute bottom-0 right-24 h-44 w-44 rounded-full bg-fuchsia-400/10 blur-3xl" />
|
||||
</div>
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="cfg-sentinel" />
|
||||
|
||||
<div class="relative">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-900 text-2xl font-semibold leading-none">Configurações</div>
|
||||
<div class="text-600 mt-2 max-w-2xl">
|
||||
Defina como sua clínica funciona: agenda, cadastros e preferências. Tudo no mesmo lugar — sem espalhar opções pelo sistema.
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hero sticky -->
|
||||
<div ref="headerEl" class="cfg-hero mb-4" :class="{ 'cfg-hero--stuck': headerStuck }">
|
||||
<div class="cfg-hero__blobs" aria-hidden="true">
|
||||
<div class="cfg-hero__blob cfg-hero__blob--1" />
|
||||
<div class="cfg-hero__blob cfg-hero__blob--2" />
|
||||
<div class="cfg-hero__blob cfg-hero__blob--3" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label="Voltar"
|
||||
icon="pi pi-arrow-left"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="hidden md:inline-flex"
|
||||
@click="router.back()"
|
||||
/>
|
||||
</div>
|
||||
<div class="cfg-hero__row1">
|
||||
<div class="cfg-hero__brand">
|
||||
<div class="cfg-hero__icon"><i class="pi pi-cog text-lg" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-hero__title">Configurações</div>
|
||||
<div class="cfg-hero__sub">Defina como sua agenda e clínica funcionam</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" @click="router.back()" />
|
||||
</div>
|
||||
|
||||
<div class="flex xl:hidden items-center shrink-0">
|
||||
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="router.back()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-0">
|
||||
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<!-- SIDEBAR (seções) -->
|
||||
@@ -151,18 +163,6 @@ function ir(to) {
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Card pequeno “atalhos” opcional -->
|
||||
<div class="mt-4 hidden lg:block">
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="text-900 font-medium">Dica</div>
|
||||
<div class="text-600 text-sm mt-2 leading-relaxed">
|
||||
Comece pela <b>Agenda</b>. É ela que dá “tempo” ao prontuário: sessão marcada → sessão realizada → evolução.
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTEÚDO (seção selecionada) -->
|
||||
@@ -173,3 +173,45 @@ function ir(to) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cfg-sentinel { height: 1px; }
|
||||
|
||||
.cfg-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.cfg-hero--stuck {
|
||||
margin-left: 0; margin-right: 0;
|
||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.cfg-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.cfg-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.cfg-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
|
||||
.cfg-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
|
||||
.cfg-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 8rem; background: rgba(217,70,239,0.07); }
|
||||
|
||||
.cfg-hero__row1 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.cfg-hero__brand {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.cfg-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.cfg-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.cfg-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<AppShellLayout />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppShellLayout from './AppShellLayout.vue'
|
||||
</script>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<AppShellLayout />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppShellLayout from './AppShellLayout.vue'
|
||||
</script>
|
||||
4
src/layout/areas/AdminLayout.vue
Normal file
4
src/layout/areas/AdminLayout.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<template><AppLayout area="admin" /></template>
|
||||
<script setup>
|
||||
import AppLayout from '../AppLayout.vue'
|
||||
</script>
|
||||
4
src/layout/areas/PortalLayout.vue
Normal file
4
src/layout/areas/PortalLayout.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<template><AppLayout area="portal" /></template>
|
||||
<script setup>
|
||||
import AppLayout from '../AppLayout.vue'
|
||||
</script>
|
||||
4
src/layout/areas/TherapistLayout.vue
Normal file
4
src/layout/areas/TherapistLayout.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<template><AppLayout area="therapist" /></template>
|
||||
<script setup>
|
||||
import AppLayout from '../AppLayout.vue'
|
||||
</script>
|
||||
@@ -1,38 +1,76 @@
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
// ── resolve variant salvo no localStorage ───────────────────
|
||||
function _loadVariant () {
|
||||
try {
|
||||
const v = localStorage.getItem('layout_variant')
|
||||
if (v === 'rail' || v === 'classic') return v
|
||||
} catch {}
|
||||
return 'classic'
|
||||
}
|
||||
|
||||
const layoutConfig = reactive({
|
||||
preset: 'Aura',
|
||||
primary: 'emerald',
|
||||
surface: null,
|
||||
darkTheme: false,
|
||||
menuMode: 'static'
|
||||
menuMode: 'static',
|
||||
variant: _loadVariant() // 'classic' | 'rail'
|
||||
})
|
||||
|
||||
const layoutState = reactive({
|
||||
staticMenuInactive: false,
|
||||
overlayMenuActive: false,
|
||||
mobileMenuActive: false, // ✅ ADICIONA (estava faltando)
|
||||
mobileMenuActive: false,
|
||||
profileSidebarVisible: false,
|
||||
configSidebarVisible: false,
|
||||
sidebarExpanded: false,
|
||||
menuHoverActive: false,
|
||||
anchored: false,
|
||||
activeMenuItem: null,
|
||||
activePath: null
|
||||
activePath: null,
|
||||
// ── Layout 2 (rail) ─────────────────────────────────────
|
||||
railSectionKey: null, // qual seção está ativa no rail
|
||||
railPanelOpen: false // painel lateral expandido
|
||||
})
|
||||
|
||||
/**
|
||||
* ✅ Fonte da verdade do dark:
|
||||
* - DOM class: .app-dark (usado pelo PrimeUI/PrimeVue)
|
||||
* - layoutConfig.darkTheme: refletir o DOM (pra UI reagir)
|
||||
*
|
||||
* Motivo: você aplica tema cedo (main.js / user_settings) e depois
|
||||
* usa o composable em páginas/Topbar/Configurator. Se não sincronizar,
|
||||
* isDarkTheme pode ficar “mentindo”.
|
||||
*/
|
||||
let _syncedDarkFromDomOnce = false
|
||||
function syncDarkFromDomOnce () {
|
||||
if (_syncedDarkFromDomOnce) return
|
||||
_syncedDarkFromDomOnce = true
|
||||
try {
|
||||
layoutConfig.darkTheme = document.documentElement.classList.contains('app-dark')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function useLayout () {
|
||||
// ✅ garante coerência sempre que alguém usar useLayout()
|
||||
syncDarkFromDomOnce()
|
||||
|
||||
const executeDarkModeToggle = () => {
|
||||
layoutConfig.darkTheme = !layoutConfig.darkTheme
|
||||
|
||||
// ✅ garante consistência (não depende do estado anterior do DOM)
|
||||
document.documentElement.classList.toggle('app-dark', layoutConfig.darkTheme)
|
||||
}
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
if (!document.startViewTransition) {
|
||||
executeDarkModeToggle()
|
||||
return
|
||||
}
|
||||
|
||||
document.startViewTransition(() => executeDarkModeToggle(event))
|
||||
}
|
||||
|
||||
const executeDarkModeToggle = () => {
|
||||
layoutConfig.darkTheme = !layoutConfig.darkTheme
|
||||
document.documentElement.classList.toggle('app-dark')
|
||||
// ✅ não usa "event" (undefined) e mantém transição suave quando suportado
|
||||
document.startViewTransition(() => executeDarkModeToggle())
|
||||
}
|
||||
|
||||
const isDesktop = () => window.innerWidth > 991
|
||||
@@ -57,6 +95,8 @@ export function useLayout () {
|
||||
|
||||
const hideMobileMenu = () => {
|
||||
layoutState.mobileMenuActive = false
|
||||
layoutState.overlayMenuActive = false
|
||||
layoutState.menuHoverActive = false
|
||||
}
|
||||
|
||||
// ✅ use isso ao navegar: mantém menu aberto no desktop, fecha só no mobile
|
||||
@@ -68,15 +108,41 @@ export function useLayout () {
|
||||
}
|
||||
}
|
||||
|
||||
const changeMenuMode = (event) => {
|
||||
layoutConfig.menuMode = event.value
|
||||
/**
|
||||
* ✅ aceita:
|
||||
* - changeMenuMode({ value: 'static' })
|
||||
* - changeMenuMode('static')
|
||||
*
|
||||
* Motivo: você chama isso de lugares diferentes (Topbar, Configurator, Profile).
|
||||
*/
|
||||
const changeMenuMode = (eventOrValue) => {
|
||||
const nextMode = typeof eventOrValue === 'string'
|
||||
? eventOrValue
|
||||
: eventOrValue?.value
|
||||
|
||||
// ✅ não deixa setar undefined / vazio
|
||||
if (!nextMode) return
|
||||
|
||||
layoutConfig.menuMode = nextMode
|
||||
|
||||
// ✅ reset consistente (evita drift quando alterna overlay/static)
|
||||
layoutState.staticMenuInactive = false
|
||||
layoutState.overlayMenuActive = false
|
||||
layoutState.mobileMenuActive = false
|
||||
layoutState.sidebarExpanded = false
|
||||
layoutState.menuHoverActive = false
|
||||
layoutState.anchored = false
|
||||
}
|
||||
|
||||
const setVariant = (v) => {
|
||||
if (v !== 'classic' && v !== 'rail') return
|
||||
layoutConfig.variant = v
|
||||
try { localStorage.setItem('layout_variant', v) } catch {}
|
||||
// reset rail state ao trocar
|
||||
layoutState.railSectionKey = null
|
||||
layoutState.railPanelOpen = false
|
||||
}
|
||||
|
||||
const isDarkTheme = computed(() => layoutConfig.darkTheme)
|
||||
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive)
|
||||
|
||||
@@ -88,9 +154,10 @@ export function useLayout () {
|
||||
toggleConfigSidebar,
|
||||
toggleMenu,
|
||||
hideMobileMenu,
|
||||
closeMenuOnNavigate, // ✅ exporta
|
||||
closeMenuOnNavigate,
|
||||
changeMenuMode,
|
||||
setVariant,
|
||||
isDesktop,
|
||||
hasOpenOverlay
|
||||
}
|
||||
}
|
||||
}
|
||||
272
src/layout/concepcoes/ex-header-conceitual.vue
Normal file
272
src/layout/concepcoes/ex-header-conceitual.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<!-- src/layout/concepcoes/ex-header-conceitual.vue -->
|
||||
<!-- ===========================================================
|
||||
TEMPLATE DE REFERÊNCIA — Hero Header Sticky
|
||||
Padrão utilizado em: AgendaTerapeutaPage, ProfilePage
|
||||
===========================================================
|
||||
|
||||
ESTRUTURA GERAL
|
||||
───────────────
|
||||
1. Sentinel (div 1px) + IntersectionObserver → detecta quando o header
|
||||
"cola" no topo da viewport e ativa a classe --stuck.
|
||||
2. Hero div com position:sticky, top: var(--layout-sticky-top, 56px).
|
||||
Layout 1 (classic): 56px (topbar fixed). Layout 2 (rail): 0px (topbar no fluxo).
|
||||
3. Dois estados:
|
||||
- Expandido : blobs decorativos visíveis, subtítulo, filtros e busca
|
||||
- Colado : comprimido (max-height), apenas brand + ações essenciais
|
||||
4. Responsividade:
|
||||
- ≥1200px : todos os controles inline (ag-hero__desktop-controls)
|
||||
- <1200px : botão "Ações" abre Menu popup (ag-hero__mobile-controls)
|
||||
O menu mobile DEVE incluir "Buscar" abrindo um Dialog com input + resultados.
|
||||
|
||||
SCRIPT (refs + onMounted)
|
||||
─────────────────────────
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerEl = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
const headerMenuRef = ref(null)
|
||||
|
||||
const headerMenuItems = computed(() => [
|
||||
{ label: 'Ação principal', icon: 'pi pi-plus', command: () => acaoPrincipal() },
|
||||
{ label: 'Buscar', icon: 'pi pi-search', command: () => { searchModalOpen.value = true } },
|
||||
{ separator: true },
|
||||
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => refetch() },
|
||||
{ label: 'Configurações', icon: 'pi pi-cog', command: () => goSettings() },
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
if (headerSentinelRef.value) {
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px` }
|
||||
)
|
||||
io.observe(headerSentinelRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
CSS (scoped)
|
||||
────────────
|
||||
Ver seção <style> ao final deste arquivo.
|
||||
|
||||
BUSCA MOBILE
|
||||
────────────
|
||||
O Dialog de busca deve ter o InputText DENTRO do dialog (não só resultados),
|
||||
com autofocus, compartilhando o mesmo v-model="search" do header desktop.
|
||||
Estados: sem texto → instrução | buscando → loading | sem resultado | lista.
|
||||
=========================================================== -->
|
||||
|
||||
<!-- ── SENTINEL ─────────────────────────────────────────── -->
|
||||
<div ref="headerSentinelRef" class="pg-sentinel" />
|
||||
|
||||
<!-- ── HERO HEADER ──────────────────────────────────────── -->
|
||||
<div ref="headerEl" class="pg-hero mb-4" :class="{ 'pg-hero--stuck': headerStuck }">
|
||||
|
||||
<!-- Blobs decorativos (some automaticamente quando colado via overflow:hidden) -->
|
||||
<div class="pg-hero__blobs" aria-hidden="true">
|
||||
<div class="pg-hero__blob pg-hero__blob--1" />
|
||||
<div class="pg-hero__blob pg-hero__blob--2" />
|
||||
<div class="pg-hero__blob pg-hero__blob--3" />
|
||||
</div>
|
||||
|
||||
<!-- ── Linha 1: brand + controles ── -->
|
||||
<div class="pg-hero__row1">
|
||||
|
||||
<!-- Brand: ícone + título + subtítulo (some quando colado) -->
|
||||
<div class="pg-hero__brand">
|
||||
<div class="pg-hero__icon">
|
||||
<i class="pi pi-ICON_AQUI text-lg" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="pg-hero__title">Título da Página</div>
|
||||
<div v-if="!headerStuck" class="pg-hero__sub">Subtítulo ou data/contexto atual</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controles desktop (≥1200px) -->
|
||||
<div class="pg-hero__desktop-controls">
|
||||
|
||||
<!-- Grupo de busca (oculto quando colado) -->
|
||||
<div v-if="!headerStuck" class="w-[260px]">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="search" class="w-full" autocomplete="off" @keyup.enter="searchModalOpen = true" />
|
||||
</IconField>
|
||||
<label>Buscar…</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Ações secundárias (ocultas quando colado, ex: filtros contextuais) -->
|
||||
<div v-if="!headerStuck" class="flex items-center gap-2">
|
||||
<!-- SplitButton, Dropdown, etc. -->
|
||||
</div>
|
||||
|
||||
<!-- Ações primárias (sempre visíveis) -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" title="Ação principal" @click="acaoPrincipal" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recarregar" @click="refetch" />
|
||||
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="goSettings" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botão mobile (<1200px) -->
|
||||
<div class="pg-hero__mobile-controls">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full"
|
||||
@click="(e) => headerMenuRef.toggle(e)" />
|
||||
<Menu ref="headerMenuRef" :model="headerMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Linha 2: filtros/KPIs — oculta quando colado ── -->
|
||||
<div v-if="!headerStuck" class="pg-hero__row2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- SelectButtons, Tags de filtro, KPIs clicáveis, etc. -->
|
||||
<Button class="!rounded-full" outlined severity="secondary">
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-list" /> Total: <b>{{ total }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Chips de filtros ativos + limpar -->
|
||||
<div v-if="hasActiveFilters" class="flex items-center gap-2">
|
||||
<Tag value="Filtro ativo" severity="secondary" />
|
||||
<Button label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined size="small" class="!rounded-full" @click="clearFilters" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ── DIALOG DE BUSCA (mobile + desktop) ───────────────── -->
|
||||
<!--
|
||||
REGRA: o InputText de busca FICA DENTRO do dialog.
|
||||
Isso garante boa UX no mobile (teclado não cobre resultados).
|
||||
O v-model="search" é o mesmo do header desktop — resultados sincronizados.
|
||||
-->
|
||||
<Dialog
|
||||
v-model:visible="searchModalOpen"
|
||||
modal
|
||||
header="Buscar"
|
||||
:style="{ width: '96vw', maxWidth: '720px' }"
|
||||
:breakpoints="{ '960px': '92vw', '640px': '96vw' }"
|
||||
:draggable="false"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="search" class="w-full" autocomplete="off" autofocus />
|
||||
</IconField>
|
||||
<label>Nome, e-mail, título…</label>
|
||||
</FloatLabel>
|
||||
|
||||
<Divider class="my-0" />
|
||||
|
||||
<div v-if="!searchTrim" class="text-color-secondary text-sm py-2">
|
||||
Digite para buscar.
|
||||
</div>
|
||||
<div v-else-if="searchLoading" class="text-color-secondary text-sm">Buscando…</div>
|
||||
<div v-else-if="!searchResults.length" class="text-color-secondary text-sm">
|
||||
Nenhum resultado para "<b>{{ searchTrim }}</b>".
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2 max-h-[60vh] overflow-auto pr-1">
|
||||
<div class="text-xs text-color-secondary mb-1">{{ searchResults.length }} resultado(s)</div>
|
||||
<button
|
||||
v-for="r in searchResults" :key="r.id"
|
||||
class="text-left rounded-2xl border border-[var(--surface-border)] p-3 transition hover:shadow-sm"
|
||||
@click="gotoResultFromModal(r)"
|
||||
>
|
||||
<div class="font-medium truncate">{{ r.titulo || r.nome }}</div>
|
||||
<div class="mt-1 text-xs opacity-70 truncate">{{ r.subtitulo }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" icon="pi pi-times" text @click="searchModalOpen = false" />
|
||||
<Button v-if="searchTrim" label="Limpar" icon="pi pi-eraser" severity="secondary" outlined class="rounded-full" @click="search = ''; searchModalOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
CSS DE REFERÊNCIA (copiar para <style scoped> da página)
|
||||
Prefixo: "pg-" → substitua pelo prefixo da página (ag-, prof-, etc.)
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<style scoped>
|
||||
/* Sentinel */
|
||||
.pg-sentinel { height: 1px; }
|
||||
|
||||
/* Hero base */
|
||||
.pg-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px); /* 56px Layout1 / 0px Layout2 (Rail) */
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
/* Estado colado */
|
||||
.pg-hero--stuck {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
max-height: 64px;
|
||||
padding-top: 0.625rem;
|
||||
padding-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
/* Blobs decorativos */
|
||||
.pg-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.pg-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.pg-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.12); }
|
||||
.pg-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(52,211,153,0.09); }
|
||||
.pg-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 22%; background: rgba(217,70,239,0.08); }
|
||||
|
||||
/* Linha 1 */
|
||||
.pg-hero__row1 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.pg-hero__brand {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
flex-shrink: 0; min-width: 0;
|
||||
}
|
||||
.pg-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.pg-hero__title {
|
||||
font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-color); white-space: nowrap;
|
||||
}
|
||||
.pg-hero__sub {
|
||||
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.pg-hero__desktop-controls {
|
||||
flex: 1; display: flex; align-items: center;
|
||||
justify-content: flex-end; gap: 0.75rem; flex-wrap: wrap;
|
||||
}
|
||||
.pg-hero__mobile-controls { display: none; }
|
||||
|
||||
/* Linha 2 */
|
||||
.pg-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; flex-wrap: wrap; align-items: center;
|
||||
justify-content: space-between; gap: 0.75rem;
|
||||
margin-top: 0.875rem; padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
/* Mobile < 1200px */
|
||||
@media (max-width: 1199px) {
|
||||
.pg-hero__desktop-controls { display: none; }
|
||||
.pg-hero__mobile-controls { display: flex; margin-left: auto; }
|
||||
.pg-hero__row2 { display: none; }
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
101
src/main.js
101
src/main.js
@@ -9,12 +9,44 @@ import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
|
||||
// ── Componentes PrimeVue globais (≥ 10 usos no projeto) ──────────────────────
|
||||
import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Tag from 'primevue/tag'
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import Toast from 'primevue/toast'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import Divider from 'primevue/divider'
|
||||
import Card from 'primevue/card'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import ConfirmDialog from 'primevue/confirmdialog'
|
||||
import Menu from 'primevue/menu'
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import '@/assets/tailwind.css'
|
||||
import '@/assets/styles.scss'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
async function applyUserThemeEarly() {
|
||||
// ✅ pt-BR (PrimeVue locale global)
|
||||
const ptBR = {
|
||||
firstDayOfWeek: 1,
|
||||
dayNames: ['domingo','segunda-feira','terça-feira','quarta-feira','quinta-feira','sexta-feira','sábado'],
|
||||
dayNamesShort: ['dom','seg','ter','qua','qui','sex','sáb'],
|
||||
dayNamesMin: ['D','S','T','Q','Q','S','S'],
|
||||
monthNames: ['janeiro','fevereiro','março','abril','maio','junho','julho','agosto','setembro','outubro','novembro','dezembro'],
|
||||
monthNamesShort: ['jan','fev','mar','abr','mai','jun','jul','ago','set','out','nov','dez'],
|
||||
today: 'Hoje',
|
||||
clear: 'Limpar',
|
||||
weekHeader: 'Sm',
|
||||
dateFormat: 'dd/mm/yy'
|
||||
}
|
||||
|
||||
async function applyUserThemeEarly () {
|
||||
try {
|
||||
const { data } = await supabase.auth.getUser()
|
||||
const user = data?.user
|
||||
@@ -34,7 +66,6 @@ async function applyUserThemeEarly() {
|
||||
const root = document.documentElement
|
||||
root.classList.toggle('app-dark', isDark)
|
||||
|
||||
// opcional: marca em storage pra teu layout composable ler depois
|
||||
localStorage.setItem('ui_theme_mode', settings.theme_mode)
|
||||
} catch {}
|
||||
}
|
||||
@@ -49,39 +80,69 @@ window.__fromVisibilityRefresh = false
|
||||
window.__appBootstrapped = false
|
||||
// ========================================
|
||||
|
||||
// 🛟 ao voltar da aba: refresh leve (sem concorrência + com flag global)
|
||||
let refreshing = false
|
||||
let refreshTimer = null
|
||||
|
||||
// 🛟 ao voltar da aba: refresh leve, sem martelar e sem rodar antes do app subir
|
||||
let lastVisibilityRefreshAt = 0
|
||||
|
||||
document.addEventListener('visibilitychange', async () => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
|
||||
// só depois do app montar (evita refresh no meio do bootstrap)
|
||||
if (!window.__appBootstrapped) return
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// evita martelar: no máximo 1 refresh a cada 10s
|
||||
// no máximo 1 refresh a cada 10s
|
||||
if (now - lastVisibilityRefreshAt < 10_000) return
|
||||
|
||||
// se já tem refresh em andamento, não entra
|
||||
if (window.__sessionRefreshing) return
|
||||
|
||||
// (opcional) se não houver user, não precisa refresh
|
||||
try {
|
||||
const { data } = await supabase.auth.getUser()
|
||||
if (!data?.user) return
|
||||
} catch {
|
||||
// se falhar getUser, deixa tentar refreshSession mesmo assim
|
||||
}
|
||||
|
||||
lastVisibilityRefreshAt = now
|
||||
console.log('[VISIBILITY] Aba voltou -> refreshSession()')
|
||||
|
||||
try {
|
||||
window.__sessionRefreshing = true
|
||||
window.__fromVisibilityRefresh = true
|
||||
|
||||
await refreshSession()
|
||||
|
||||
// 🔔 avisa o app inteiro SOMENTE em áreas TENANT.
|
||||
// Portal (/portal) e área global (/account) NÃO devem rehidratar tenantStore/menu.
|
||||
try {
|
||||
const path = router.currentRoute.value?.path || ''
|
||||
const isTenantArea =
|
||||
path.startsWith('/admin') ||
|
||||
path.startsWith('/therapist') ||
|
||||
path.startsWith('/saas')
|
||||
|
||||
if (isTenantArea) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('app:session-refreshed', { detail: { source: 'visibility' } })
|
||||
)
|
||||
} else {
|
||||
console.log('[VISIBILITY] refresh ok (skip event) - area não-tenant:', path)
|
||||
}
|
||||
} catch {
|
||||
// se algo der errado, não dispare evento global por segurança
|
||||
}
|
||||
} finally {
|
||||
window.__fromVisibilityRefresh = false
|
||||
window.__sessionRefreshing = false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
async function bootstrap () {
|
||||
await initSession({ initial: true })
|
||||
listenAuthChanges()
|
||||
|
||||
|
||||
await applyUserThemeEarly()
|
||||
|
||||
const app = createApp(App)
|
||||
@@ -94,19 +155,39 @@ async function bootstrap () {
|
||||
// ✅ garante router pronto antes de montar
|
||||
await router.isReady()
|
||||
|
||||
// ✅ PrimeVue global config (tema + locale pt-BR)
|
||||
app.use(PrimeVue, {
|
||||
locale: ptBR, // 🔥 isso traduz Calendar/DatePicker
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: { darkModeSelector: '.app-dark' }
|
||||
}
|
||||
})
|
||||
|
||||
app.use(ToastService)
|
||||
app.use(ConfirmationService)
|
||||
|
||||
// Registro global de componentes PrimeVue frequentes
|
||||
app.component('Button', Button)
|
||||
app.component('InputText', InputText)
|
||||
app.component('Tag', Tag)
|
||||
app.component('FloatLabel', FloatLabel)
|
||||
app.component('Toast', Toast)
|
||||
app.component('IconField', IconField)
|
||||
app.component('InputIcon', InputIcon)
|
||||
app.component('Divider', Divider)
|
||||
app.component('Card', Card)
|
||||
app.component('SelectButton', SelectButton)
|
||||
app.component('Dialog', Dialog)
|
||||
app.component('DataTable', DataTable)
|
||||
app.component('Column', Column)
|
||||
app.component('ConfirmDialog', ConfirmDialog)
|
||||
app.component('Menu', Menu)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// ✅ marca boot completo
|
||||
window.__appBootstrapped = true
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
bootstrap()
|
||||
@@ -4,42 +4,175 @@
|
||||
// 📦 Importação dos menus base por área
|
||||
// ======================================================
|
||||
|
||||
import adminMenu from './menus/admin.menu'
|
||||
import adminMenu from './menus/clinic.menu'
|
||||
import therapistMenu from './menus/therapist.menu'
|
||||
import supervisorMenu from './menus/supervisor.menu'
|
||||
import editorMenu from './menus/editor.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'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
|
||||
// ======================================================
|
||||
// 🎭 Mapeamento de role → menu base
|
||||
// ======================================================
|
||||
|
||||
const MENUS = {
|
||||
// ✅ role real do tenant
|
||||
clinic_admin: adminMenu,
|
||||
tenant_admin: adminMenu, // alias
|
||||
therapist: therapistMenu,
|
||||
supervisor: supervisorMenu,
|
||||
editor: editorMenu,
|
||||
patient: portalMenu,
|
||||
|
||||
// ✅ compatibilidade profiles.role
|
||||
admin: adminMenu,
|
||||
|
||||
// ✅ legado
|
||||
tenant_admin: adminMenu
|
||||
portal_user: portalMenu, // alias (globalRole do paciente)
|
||||
saas_admin: saasMenu
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// 🧠 Função utilitária
|
||||
// Permite que o menu seja:
|
||||
// - Array direto
|
||||
// - ou função (ctx) => Array
|
||||
// 🧠 Helpers
|
||||
// ======================================================
|
||||
|
||||
function resolveMenu (builder, ctx) {
|
||||
if (!builder) return []
|
||||
return typeof builder === 'function' ? builder(ctx) : builder
|
||||
try {
|
||||
return typeof builder === 'function' ? builder(ctx) : builder
|
||||
} catch (e) {
|
||||
// se um builder estourar, não derruba o app: cai no fallback
|
||||
console.warn('[NAV] menu builder error:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// core menu anti-“sumir”
|
||||
function coreMenu () {
|
||||
return [
|
||||
{
|
||||
label: 'Geral',
|
||||
items: [
|
||||
{ label: 'Início', icon: 'pi pi-home', to: '/' },
|
||||
{ label: 'Assinatura', icon: 'pi pi-credit-card', to: '/billing' },
|
||||
{ label: 'Perfil', icon: 'pi pi-user', to: '/profile' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function safeHasFeature (fn, key) {
|
||||
try { return !!fn?.(key) } catch { return false }
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// 🧩 Decorator do menu
|
||||
// - NÃO remove itens
|
||||
// - Apenas calcula badge PRO dinâmico (com base em entitlements)
|
||||
// ======================================================
|
||||
|
||||
function decorateMenu (menu, hasFeature) {
|
||||
const arr = Array.isArray(menu) ? menu : []
|
||||
return arr.map((group) => {
|
||||
if (group?.items && Array.isArray(group.items)) {
|
||||
return { ...group, items: decorateItems(group.items, hasFeature) }
|
||||
}
|
||||
return group
|
||||
})
|
||||
}
|
||||
|
||||
function decorateItems (items, hasFeature) {
|
||||
return (items || []).map((it) => {
|
||||
if (it?.items && Array.isArray(it.items)) {
|
||||
return { ...it, items: decorateItems(it.items, hasFeature) }
|
||||
}
|
||||
|
||||
const featureKey = it?.feature ? String(it.feature) : null
|
||||
const showPro = !!it?.proBadge && !!featureKey && !safeHasFeature(hasFeature, featureKey)
|
||||
|
||||
return { ...it, __showProBadge: showPro }
|
||||
})
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// ✅ Normalização de role (evita menu vazio)
|
||||
// ======================================================
|
||||
|
||||
function normalizeRole (role) {
|
||||
const r = String(role || '').trim()
|
||||
if (!r) return null
|
||||
|
||||
// aliases comuns (blindagem)
|
||||
if (r === 'tenant_admin') return 'clinic_admin'
|
||||
if (r === 'admin') return 'clinic_admin'
|
||||
if (r === 'clinic') return 'clinic_admin'
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// ✅ hasFeature robusto
|
||||
// - Prioriza sessionCtx (evita flicker)
|
||||
// - Senão tenta deduzir do store com vários formatos comuns
|
||||
// ======================================================
|
||||
|
||||
function buildHasFeature (sessionCtx, entitlementsStore) {
|
||||
// 1) se já veio pronto, usa
|
||||
if (typeof sessionCtx?.hasFeature === 'function') return sessionCtx.hasFeature
|
||||
|
||||
// 2) se veio entitlements como objeto { key: true }
|
||||
if (sessionCtx?.entitlements && typeof sessionCtx.entitlements === 'object') {
|
||||
const bag = sessionCtx.entitlements
|
||||
return (k) => !!bag[String(k || '').trim()]
|
||||
}
|
||||
|
||||
// 3) se veio allowedFeatures como array ou Set
|
||||
if (sessionCtx?.allowedFeatures) {
|
||||
const af = sessionCtx.allowedFeatures
|
||||
if (Array.isArray(af)) {
|
||||
const s = new Set(af.map((x) => String(x).trim()).filter(Boolean))
|
||||
return (k) => s.has(String(k || '').trim())
|
||||
}
|
||||
if (af instanceof Set) {
|
||||
return (k) => af.has(String(k || '').trim())
|
||||
}
|
||||
}
|
||||
|
||||
// 4) fallback no store
|
||||
return (featureKey) => {
|
||||
const k = String(featureKey || '').trim()
|
||||
if (!k) return false
|
||||
|
||||
try {
|
||||
if (typeof entitlementsStore?.hasFeature === 'function') return !!entitlementsStore.hasFeature(k)
|
||||
if (typeof entitlementsStore?.has === 'function') return !!entitlementsStore.has(k)
|
||||
|
||||
// formatos comuns:
|
||||
// - entitlements: { key: true }
|
||||
// - entitlements: Set([...])
|
||||
// - entitlements: Array([...])
|
||||
// - byOwner: { [ownerId]: { key: true } }
|
||||
const bag =
|
||||
entitlementsStore?.entitlements ||
|
||||
entitlementsStore?.features ||
|
||||
entitlementsStore?.data ||
|
||||
entitlementsStore?.state
|
||||
|
||||
if (bag instanceof Set) return bag.has(k)
|
||||
if (Array.isArray(bag)) return bag.includes(k)
|
||||
if (bag && typeof bag === 'object') {
|
||||
// tenta direto
|
||||
if (Object.prototype.hasOwnProperty.call(bag, k)) return !!bag[k]
|
||||
|
||||
// tenta byOwner (caso exista)
|
||||
const ownerId = sessionCtx?.ownerId || sessionCtx?.activeTenantId || sessionCtx?.tenantId
|
||||
if (ownerId && bag[ownerId] && typeof bag[ownerId] === 'object') {
|
||||
return !!bag[ownerId][k]
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
@@ -47,50 +180,68 @@ function resolveMenu (builder, ctx) {
|
||||
// ======================================================
|
||||
|
||||
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
|
||||
|
||||
// 🔹 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()
|
||||
const entitlementsStore = useEntitlementsStore()
|
||||
|
||||
// 🔹 SaaS overlay aparece somente para SaaS master
|
||||
const isSaas = sessionCtx?.isSaasAdmin === true
|
||||
|
||||
// ctx que será passado pros menu builders
|
||||
const tenantFeatureEnabled =
|
||||
typeof sessionCtx?.tenantFeatureEnabled === 'function'
|
||||
? sessionCtx.tenantFeatureEnabled
|
||||
: (key) => {
|
||||
try { return !!tenantFeaturesStore?.isEnabled?.(key) } catch { return false }
|
||||
}
|
||||
|
||||
const tenantLoading =
|
||||
typeof sessionCtx?.tenantLoading === 'function'
|
||||
? sessionCtx.tenantLoading
|
||||
: () => false
|
||||
|
||||
const tenantFeaturesLoading =
|
||||
typeof sessionCtx?.tenantFeaturesLoading === 'function'
|
||||
? sessionCtx.tenantFeaturesLoading
|
||||
: () => false
|
||||
|
||||
const hasFeature = buildHasFeature(sessionCtx, entitlementsStore)
|
||||
|
||||
const ctx = {
|
||||
...sessionCtx,
|
||||
mismatchCount,
|
||||
tenantFeaturesStore,
|
||||
tenantFeatureEnabled: (key) => {
|
||||
try { return !!tenantFeaturesStore?.isEnabled?.(key) } catch { return false }
|
||||
}
|
||||
tenantFeatureEnabled,
|
||||
tenantLoading,
|
||||
tenantFeaturesLoading,
|
||||
entitlementsStore,
|
||||
hasFeature
|
||||
}
|
||||
|
||||
// 🔹 Menu base da role
|
||||
const base = resolveMenu(MENUS[role], ctx)
|
||||
// ✅ role normalizado
|
||||
const r = normalizeRole(role)
|
||||
|
||||
// 🔹 Resolve menu SaaS (array ou função)
|
||||
const saas = typeof saasMenu === 'function'
|
||||
const baseRaw = resolveMenu(MENUS[r], ctx)
|
||||
const base = decorateMenu(baseRaw, hasFeature)
|
||||
|
||||
const saasRaw = typeof saasMenu === 'function'
|
||||
? saasMenu(ctx, { mismatchCount })
|
||||
: saasMenu
|
||||
const saas = decorateMenu(saasRaw, hasFeature)
|
||||
|
||||
// ======================================================
|
||||
// 🚀 Menu final
|
||||
// - base sempre
|
||||
// - overlay SaaS só para SaaS master
|
||||
// - Demo Sakai só para SaaS master em DEV
|
||||
// ======================================================
|
||||
// 🔒 SaaS master: somente área SaaS
|
||||
if (isSaas) {
|
||||
const out = [
|
||||
...(saas.length ? saas : coreMenu()),
|
||||
...(import.meta.env.DEV ? [{ separator: true }, ...sakaiDemoMenu] : [])
|
||||
]
|
||||
return out
|
||||
}
|
||||
|
||||
return [
|
||||
...base,
|
||||
// ✅ fallback: nunca retorna vazio
|
||||
if (!base || !base.length) {
|
||||
return coreMenu()
|
||||
}
|
||||
|
||||
...(isSaas && saas.length ? [{ separator: true }, ...saas] : []),
|
||||
|
||||
...(isSaas && import.meta.env.DEV
|
||||
? [{ separator: true }, ...sakaiDemoMenu]
|
||||
: [])
|
||||
]
|
||||
return [...base]
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// 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 do Terapeuta',
|
||||
icon: 'pi pi-fw pi-sitemap',
|
||||
to: '/therapist/agenda',
|
||||
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: 'Profissionais',
|
||||
icon: 'pi pi-fw pi-id-card',
|
||||
to: '/admin/clinic/professionals'
|
||||
},
|
||||
{
|
||||
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'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// =====================================================
|
||||
// 🔒 SISTEMA
|
||||
// =====================================================
|
||||
menu.push({
|
||||
label: 'Sistema',
|
||||
items: [
|
||||
{
|
||||
label: 'Segurança',
|
||||
icon: 'pi pi-fw pi-shield',
|
||||
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
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return menu
|
||||
}
|
||||
82
src/navigation/menus/clinic.menu.js
Normal file
82
src/navigation/menus/clinic.menu.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// src/navigation/menus/clinic.menu.js
|
||||
export default function adminMenu (ctx = {}) {
|
||||
const menu = [
|
||||
{
|
||||
label: 'Clínica',
|
||||
items: [
|
||||
// ✅ usar name real da rota (evita /admin cair em redirect estranho)
|
||||
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: { name: 'admin.dashboard' } },
|
||||
|
||||
{
|
||||
label: 'Agenda da Clínica',
|
||||
icon: 'pi pi-fw pi-calendar',
|
||||
to: { name: 'admin-agenda-clinica' },
|
||||
feature: 'agenda.view'
|
||||
},
|
||||
|
||||
// ✅ Compromissos determinísticos (tipos)
|
||||
{
|
||||
label: 'Compromissos',
|
||||
icon: 'pi pi-fw pi-clock',
|
||||
to: { name: 'admin-agenda-compromissos' },
|
||||
feature: 'agenda.view'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// ✅ SEM IF: sempre existe, só fica visível quando a feature estiver ligada
|
||||
{
|
||||
label: 'Pacientes',
|
||||
visible: () => {
|
||||
// 1) enquanto tenant/features estão carregando, NÃO some (evita piscar ao trocar de aba)
|
||||
if (ctx?.tenantLoading?.()) return true
|
||||
if (ctx?.tenantFeaturesLoading?.()) return true
|
||||
|
||||
// 2) quando estabilizou, aí sim decide pela feature
|
||||
return !!ctx?.tenantFeatureEnabled?.('patients')
|
||||
},
|
||||
items: [
|
||||
// ✅ usar name real das rotas (você já tem todas no routes.clinic.js)
|
||||
{ label: 'Lista de Pacientes', icon: 'pi pi-fw pi-users', to: { name: 'admin-pacientes' } },
|
||||
{ label: 'Grupos', icon: 'pi pi-fw pi-sitemap', to: { name: 'admin-pacientes-grupos' } },
|
||||
{ label: 'Tags', icon: 'pi pi-fw pi-tags', to: { name: 'admin-pacientes-tags' } },
|
||||
{ label: 'Link Externo', icon: 'pi pi-fw pi-link', to: { name: 'admin-pacientes-link-externo' } },
|
||||
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' } }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Gestão',
|
||||
items: [
|
||||
{ label: 'Profissionais', icon: 'pi pi-fw pi-id-card', to: { name: 'admin-clinic-professionals' } },
|
||||
{
|
||||
label: 'Tipos de Clínicas',
|
||||
icon: 'pi pi-fw pi-sliders-h',
|
||||
to: { name: 'admin-clinic-features' },
|
||||
visible: () => {
|
||||
if (ctx?.tenantLoading?.()) return true // ← true durante loading (evita piscar)
|
||||
const role = ctx?.role?.()
|
||||
return role === 'clinic_admin' // tenant_admin normaliza para clinic_admin
|
||||
}
|
||||
},
|
||||
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: { name: 'admin-meu-plano' } }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Sistema',
|
||||
items: [
|
||||
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: { name: 'admin-settings-security' } },
|
||||
{
|
||||
label: 'Agendamento Online (PRO)',
|
||||
icon: 'pi pi-fw pi-calendar-plus',
|
||||
to: { name: 'admin-online-scheduling' },
|
||||
feature: 'online_scheduling.manage',
|
||||
proBadge: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
return menu
|
||||
}
|
||||
32
src/navigation/menus/editor.menu.js
Normal file
32
src/navigation/menus/editor.menu.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// src/navigation/menus/editor.menu.js
|
||||
//
|
||||
// Menu da área de Editor de Conteúdo (plataforma de microlearning).
|
||||
// O Editor é um papel de PLATAFORMA (não de tenant).
|
||||
// Indicado pelo saas_admin via platform_roles[] na tabela profiles.
|
||||
//
|
||||
|
||||
export default [
|
||||
{
|
||||
label: 'Editor',
|
||||
items: [
|
||||
// ======================================================
|
||||
// 📊 DASHBOARD
|
||||
// ======================================================
|
||||
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/editor' },
|
||||
|
||||
// ======================================================
|
||||
// 📚 CONTEÚDO
|
||||
// ======================================================
|
||||
{ label: 'Cursos', icon: 'pi pi-fw pi-book', to: '/editor/cursos' },
|
||||
{ label: 'Módulos', icon: 'pi pi-fw pi-th-large', to: '/editor/modulos' },
|
||||
{ label: 'Publicados', icon: 'pi pi-fw pi-check-circle', to: '/editor/publicados' },
|
||||
|
||||
// ======================================================
|
||||
// 👤 CONTA
|
||||
// ======================================================
|
||||
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: '/editor/meu-plano' },
|
||||
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' },
|
||||
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -6,10 +6,11 @@ export default [
|
||||
// ✅ Básico (sempre)
|
||||
// ======================
|
||||
{ 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' },
|
||||
{ label: 'Minhas sessões', icon: 'pi pi-fw pi-user', to: '/portal/sessoes' },
|
||||
// ✅ Conta é global, não do portal
|
||||
{ label: 'My Account', icon: 'pi pi-fw pi-user', to: '/account/profile' }
|
||||
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: '/portal/meu-plano' },
|
||||
{ label: 'Minha Conta', icon: 'pi pi-fw pi-user', to: '/account/profile' },
|
||||
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
|
||||
|
||||
// =====================================================
|
||||
// 🔒 PRO (exemplos futuros no portal do paciente)
|
||||
|
||||
@@ -5,7 +5,6 @@ export default function saasMenu (sessionCtx, opts = {}) {
|
||||
|
||||
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' }
|
||||
: {}
|
||||
@@ -14,49 +13,40 @@ export default function saasMenu (sessionCtx, opts = {}) {
|
||||
{
|
||||
label: 'SaaS',
|
||||
icon: 'pi pi-building',
|
||||
path: '/saas', // ✅ necessário p/ expandir e controlar activePath
|
||||
path: '/saas',
|
||||
items: [
|
||||
{ label: 'Dashboard', icon: 'pi pi-chart-bar', to: '/saas' },
|
||||
|
||||
{
|
||||
label: 'Planos',
|
||||
icon: 'pi pi-star',
|
||||
path: '/saas/plans', // ✅ absoluto (mais confiável p/ active/expand)
|
||||
path: '/saas/plans',
|
||||
items: [
|
||||
{ label: 'Listagem de Planos', icon: 'pi pi-list', to: '/saas/plans' },
|
||||
|
||||
// ✅ vitrine pública (pricing page)
|
||||
{ label: 'Vitrine Pública', icon: 'pi pi-megaphone', to: '/saas/plans-public' },
|
||||
|
||||
{ label: 'Recursos', icon: 'pi pi-bolt', to: '/saas/features' },
|
||||
{ label: 'Controle de Recursos', icon: 'pi pi-th-large', to: '/saas/plan-features' }
|
||||
{ label: 'Planos e Preços', icon: 'pi pi-list', to: '/saas/plans' },
|
||||
{ label: 'Vitrine Pública', icon: 'pi pi-megaphone', to: '/saas/plans-public' },
|
||||
{ label: 'Recursos', icon: 'pi pi-bolt', to: '/saas/features' },
|
||||
{ label: 'Controle de Recursos', icon: 'pi pi-th-large', to: '/saas/plan-features' },
|
||||
{ label: 'Limites por Plano', icon: 'pi pi-sliders-h', to: '/saas/plan-limits' }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Assinaturas',
|
||||
icon: 'pi pi-credit-card',
|
||||
path: '/saas/subscriptions', // ✅ absoluto
|
||||
path: '/saas/subscriptions',
|
||||
items: [
|
||||
{ label: 'Listagem de Assinaturas', icon: 'pi pi-list', to: '/saas/subscriptions' },
|
||||
{ label: 'Histórico', icon: 'pi pi-history', to: '/saas/subscription-events' },
|
||||
{ label: 'Listagem de Assinaturas', icon: 'pi pi-list', to: '/saas/subscriptions' },
|
||||
{ label: 'Intenções', icon: 'pi pi-inbox', to: '/saas/subscription-intents' },
|
||||
{ label: 'Histórico', icon: 'pi pi-history', to: '/saas/subscription-events' },
|
||||
{
|
||||
label: 'Saúde das Assinaturas',
|
||||
icon: 'pi pi-shield',
|
||||
to: '/saas/subscription-health',
|
||||
...(mismatchBadge
|
||||
? mismatchBadge
|
||||
: {})
|
||||
...mismatchBadge
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
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,8 +1,4 @@
|
||||
export default [
|
||||
{
|
||||
label: 'Home',
|
||||
items: [{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' }]
|
||||
},
|
||||
{
|
||||
label: 'UI Components',
|
||||
path: '/uikit',
|
||||
@@ -29,7 +25,6 @@ export default [
|
||||
icon: 'pi pi-fw pi-prime',
|
||||
path: '/blocks',
|
||||
items: [
|
||||
{ label: 'Free Blocks', icon: 'pi pi-fw pi-eye', to: '/utilities' },
|
||||
{ label: 'All Blocks', icon: 'pi pi-fw pi-globe', url: 'https://blocks.primevue.org/', target: '_blank' }
|
||||
]
|
||||
},
|
||||
@@ -49,68 +44,15 @@ export default [
|
||||
{ label: 'Access Denied', icon: 'pi pi-fw pi-lock', to: '/auth/access' }
|
||||
]
|
||||
},
|
||||
{ label: 'Crud', icon: 'pi pi-fw pi-pencil', to: '/pages/crud' },
|
||||
{ label: 'Not Found', icon: 'pi pi-fw pi-exclamation-circle', to: '/pages/notfound' },
|
||||
{ label: 'Empty', icon: 'pi pi-fw pi-circle-off', to: '/pages/empty' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Hierarchy',
|
||||
icon: 'pi pi-fw pi-align-left',
|
||||
path: '/hierarchy',
|
||||
items: [
|
||||
{
|
||||
label: 'Submenu 1',
|
||||
icon: 'pi pi-fw pi-align-left',
|
||||
path: '/submenu_1',
|
||||
items: [
|
||||
{
|
||||
label: 'Submenu 1.1',
|
||||
icon: 'pi pi-fw pi-align-left',
|
||||
path: '/submenu_1_1',
|
||||
items: [
|
||||
{ label: 'Submenu 1.1.1', icon: 'pi pi-fw pi-align-left' },
|
||||
{ label: 'Submenu 1.1.2', icon: 'pi pi-fw pi-align-left' },
|
||||
{ label: 'Submenu 1.1.3', icon: 'pi pi-fw pi-align-left' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Submenu 1.2',
|
||||
icon: 'pi pi-fw pi-align-left',
|
||||
path: '/submenu_1_2',
|
||||
items: [{ label: 'Submenu 1.2.1', icon: 'pi pi-fw pi-align-left' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Submenu 2',
|
||||
icon: 'pi pi-fw pi-align-left',
|
||||
path: '/submenu_2',
|
||||
items: [
|
||||
{
|
||||
label: 'Submenu 2.1',
|
||||
icon: 'pi pi-fw pi-align-left',
|
||||
path: '/submenu_2_1',
|
||||
items: [
|
||||
{ label: 'Submenu 2.1.1', icon: 'pi pi-fw pi-align-left' },
|
||||
{ label: 'Submenu 2.1.2', icon: 'pi pi-fw pi-align-left' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Submenu 2.2',
|
||||
icon: 'pi pi-fw pi-align-left',
|
||||
path: '/submenu_2_2',
|
||||
items: [{ label: 'Submenu 2.2.1', icon: 'pi pi-fw pi-align-left' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Get Started',
|
||||
path: '/start',
|
||||
items: [
|
||||
{ label: 'Documentation', icon: 'pi pi-fw pi-book', to: '/pages' },
|
||||
{ label: 'Documentation', icon: 'pi pi-fw pi-book', url: 'https://sakai.primevue.org/documentation', target: '_blank' },
|
||||
{ label: 'View Source', icon: 'pi pi-fw pi-github', url: 'https://github.com/primefaces/sakai-vue', target: '_blank' }
|
||||
]
|
||||
}
|
||||
|
||||
30
src/navigation/menus/supervisor.menu.js
Normal file
30
src/navigation/menus/supervisor.menu.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// src/navigation/menus/supervisor.menu.js
|
||||
|
||||
export default [
|
||||
{
|
||||
label: 'Supervisão',
|
||||
items: [
|
||||
// ======================================================
|
||||
// 📊 DASHBOARD
|
||||
// ======================================================
|
||||
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/supervisor' },
|
||||
|
||||
// ======================================================
|
||||
// 🎓 SALA DE SUPERVISÃO
|
||||
// ======================================================
|
||||
{
|
||||
label: 'Sala de Supervisão',
|
||||
icon: 'pi pi-fw pi-users',
|
||||
to: '/supervisor/sala',
|
||||
feature: 'supervisor.access'
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 💳 PLANO / CONTA
|
||||
// ======================================================
|
||||
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: '/supervisor/meu-plano' },
|
||||
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' },
|
||||
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
export default [
|
||||
{
|
||||
label: 'Therapist',
|
||||
label: 'Terapeuta',
|
||||
items: [
|
||||
// ======================================================
|
||||
// 📊 DASHBOARD
|
||||
@@ -12,7 +12,10 @@ export default [
|
||||
// ======================================================
|
||||
// 📅 AGENDA
|
||||
// ======================================================
|
||||
{ label: 'Agenda', icon: 'pi pi-fw pi-calendar', to: '/therapist/agenda' },
|
||||
{ label: 'Agenda', icon: 'pi pi-fw pi-calendar', to: '/therapist/agenda', feature: 'agenda.view', proBadge: true },
|
||||
|
||||
// ✅ NOVO: Compromissos determinísticos (tipos)
|
||||
{ label: 'Compromissos', icon: 'pi pi-fw pi-clock', to: '/therapist/agenda/compromissos', feature: 'agenda.view', proBadge: true },
|
||||
|
||||
// ======================================================
|
||||
// 👥 PATIENTS
|
||||
@@ -34,14 +37,16 @@ export default [
|
||||
label: 'Online Scheduling',
|
||||
icon: 'pi pi-fw pi-globe',
|
||||
to: '/therapist/online-scheduling',
|
||||
feature: 'online_scheduling.manage',
|
||||
feature: 'online_scheduling',
|
||||
proBadge: true
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 👤 ACCOUNT
|
||||
// ======================================================
|
||||
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' }
|
||||
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: '/therapist/meu-plano' },
|
||||
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' },
|
||||
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -9,10 +9,13 @@ import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
||||
import { buildUpgradeUrl } from '@/utils/upgradeContext'
|
||||
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
import { getMenuByRole } from '@/navigation'
|
||||
|
||||
import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session'
|
||||
|
||||
// ✅ separa RBAC (papel) vs Plano (upgrade)
|
||||
import { denyByRole, denyByPlan } from '@/router/accessRedirects'
|
||||
import { denyByRole, denyByPlan } from '@/router/accessRedirects' // (denyByPlan pode ficar, mesmo que não use aqui)
|
||||
|
||||
// cache simples (evita bater no banco em toda navegação)
|
||||
let sessionUidCache = null
|
||||
@@ -38,12 +41,45 @@ 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 || ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ Normaliza roles (aliases) para RBAC.
|
||||
*
|
||||
* tenant_admin / admin + kind = 'therapist' → 'therapist'
|
||||
* tenant_admin / admin + kind = clinic_* → 'clinic_admin'
|
||||
* tenant_admin / admin + kind desconhecido → 'clinic_admin' (legado)
|
||||
* qualquer outro role → pass-through
|
||||
*/
|
||||
function normalizeRole (role, kind) {
|
||||
const r = String(role || '').trim()
|
||||
if (!r) return ''
|
||||
|
||||
const isAdmin = (r === 'tenant_admin' || r === 'admin')
|
||||
|
||||
if (isAdmin) {
|
||||
const k = String(kind || '').trim()
|
||||
if (k === 'therapist' || k === 'saas') return 'therapist'
|
||||
if (k === 'supervisor') return 'supervisor'
|
||||
return 'clinic_admin'
|
||||
}
|
||||
|
||||
if (r === 'clinic_admin') return 'clinic_admin'
|
||||
|
||||
// demais
|
||||
return r
|
||||
}
|
||||
|
||||
function roleToPath (role) {
|
||||
// ✅ clínica: aceita nomes canônicos e legado
|
||||
if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin'
|
||||
|
||||
if (role === 'therapist') return '/therapist'
|
||||
|
||||
// ✅ supervisor (papel de tenant)
|
||||
if (role === 'supervisor') return '/supervisor'
|
||||
|
||||
// ⚠️ legado (se ainda existir em algum lugar)
|
||||
if (role === 'patient') return '/portal'
|
||||
if (role === 'portal_user') return '/portal'
|
||||
|
||||
// ✅ saas master
|
||||
if (role === 'saas_admin') return '/saas'
|
||||
@@ -70,19 +106,22 @@ async function waitSessionIfRefreshing () {
|
||||
|
||||
async function isSaasAdmin (uid) {
|
||||
if (!uid) return false
|
||||
|
||||
if (saasAdminCacheUid === uid && typeof saasAdminCacheIsAdmin === 'boolean') {
|
||||
return saasAdminCacheIsAdmin
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('saas_admins')
|
||||
.select('user_id')
|
||||
.eq('user_id', uid)
|
||||
.maybeSingle()
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', uid)
|
||||
.single()
|
||||
|
||||
const ok = !error && data?.role === 'saas_admin'
|
||||
|
||||
const ok = !error && !!data
|
||||
saasAdminCacheUid = uid
|
||||
saasAdminCacheIsAdmin = ok
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -115,10 +154,121 @@ async function loadEntitlementsSafe (ent, tenantId, force) {
|
||||
}
|
||||
}
|
||||
|
||||
// util: roles guard (plural)
|
||||
/**
|
||||
* wrapper: tenant features store pode não aceitar force:false (ou pode falhar silenciosamente)
|
||||
* -> tenta sem forçar e, se der ruim, tenta force:true.
|
||||
*/
|
||||
async function fetchTenantFeaturesSafe (tf, tenantId) {
|
||||
if (!tf?.fetchForTenant) return
|
||||
try {
|
||||
await tf.fetchForTenant(tenantId, { force: false })
|
||||
} catch (e) {
|
||||
console.warn('[guards] tf.fetchForTenant(force:false) falhou, tentando force:true', e)
|
||||
await tf.fetchForTenant(tenantId, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// util: roles guard (plural) com aliases
|
||||
function matchesRoles (roles, activeRole) {
|
||||
if (!Array.isArray(roles) || !roles.length) return true
|
||||
return roles.includes(activeRole)
|
||||
|
||||
const ar = normalizeRole(activeRole)
|
||||
const wanted = roles.map(normalizeRole)
|
||||
|
||||
return wanted.includes(ar)
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// ✅ MENU: monta 1x por contexto (sem flicker)
|
||||
// - O AppMenu lê menuStore.model e não recalcula.
|
||||
// ======================================================
|
||||
|
||||
async function ensureMenuBuilt ({ uid, tenantId, tenantRole, globalRole }) {
|
||||
try {
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
const isSaas = (globalRole === 'saas_admin')
|
||||
const roleForMenu = isSaas ? 'saas_admin' : normalizeRole(tenantRole)
|
||||
|
||||
// ✅ FIX: inclui o role normalizado E o tenantId no key de forma explícita
|
||||
// O bug era: em alguns fluxos tenantRole chegava vazio/antigo antes de
|
||||
// setActiveTenant() ser chamado, fazendo o key bater com o menu errado.
|
||||
const safeRole = roleForMenu || 'unknown'
|
||||
const safeTenant = tenantId || 'no-tenant'
|
||||
const safeGlobal = globalRole || 'no-global'
|
||||
const key = `${uid}:${safeTenant}:${safeRole}:${safeGlobal}`
|
||||
|
||||
// ✅ FIX PRINCIPAL: só considera cache válido se role E tenant baterem.
|
||||
// Antes, o check era feito antes de garantir que tenant.activeRole
|
||||
// já tinha sido resolvido corretamente nessa navegação.
|
||||
if (menuStore.ready && menuStore.key === key && Array.isArray(menuStore.model) && menuStore.model.length > 0) {
|
||||
// sanity check extra: verifica se o modelo tem itens do role correto
|
||||
// (evita falso positivo quando key colide por acidente)
|
||||
const firstLabel = menuStore.model?.[0]?.label || ''
|
||||
const isClinicMenu = firstLabel === 'Clínica'
|
||||
const isTherapistMenu = firstLabel === 'Terapeuta'
|
||||
const isSupervisorMenu = firstLabel === 'Supervisão'
|
||||
const isEditorMenu = firstLabel === 'Editor'
|
||||
const isPortalMenu = firstLabel === 'Paciente'
|
||||
const isSaasMenuCached = firstLabel === 'SaaS'
|
||||
|
||||
const expectClinic = safeRole === 'clinic_admin'
|
||||
const expectTherapist = safeRole === 'therapist'
|
||||
const expectSupervisor = safeRole === 'supervisor'
|
||||
const expectEditor = safeRole === 'editor'
|
||||
const expectPortal = safeRole === 'patient'
|
||||
const expectSaas = safeRole === 'saas_admin'
|
||||
|
||||
const menuMatchesRole =
|
||||
(expectClinic && isClinicMenu) ||
|
||||
(expectTherapist && isTherapistMenu) ||
|
||||
(expectSupervisor && isSupervisorMenu) ||
|
||||
(expectEditor && isEditorMenu) ||
|
||||
(expectPortal && isPortalMenu) ||
|
||||
(expectSaas && isSaasMenuCached) ||
|
||||
// roles desconhecidos: aceita o cache (coreMenu)
|
||||
(!expectClinic && !expectTherapist && !expectSupervisor && !expectEditor && !expectPortal && !expectSaas)
|
||||
|
||||
if (menuMatchesRole) {
|
||||
return // cache válido e menu correto
|
||||
}
|
||||
|
||||
// cache com key igual mas menu errado: força rebuild
|
||||
console.warn('[ensureMenuBuilt] key match mas menu incompatível com role, forçando rebuild:', {
|
||||
key, safeRole, firstLabel
|
||||
})
|
||||
menuStore.reset()
|
||||
}
|
||||
|
||||
// garante tenant_features pronto ANTES de construir
|
||||
if (!isSaas && tenantId) {
|
||||
const tfm = useTenantFeaturesStore()
|
||||
const hasAny = tfm?.features && typeof tfm.features === 'object' && Object.keys(tfm.features).length > 0
|
||||
const loadedFor = tfm?.loadedForTenantId || null
|
||||
if (!hasAny || (loadedFor && loadedFor !== tenantId)) {
|
||||
await fetchTenantFeaturesSafe(tfm, tenantId)
|
||||
} else if (!loadedFor) {
|
||||
await fetchTenantFeaturesSafe(tfm, tenantId)
|
||||
}
|
||||
}
|
||||
|
||||
const tfm2 = useTenantFeaturesStore()
|
||||
const ctx = {
|
||||
isSaasAdmin: isSaas,
|
||||
tenantLoading: () => false,
|
||||
tenantFeaturesLoading: () => false,
|
||||
tenantFeatureEnabled: (featureKey) => {
|
||||
if (!tenantId) return false
|
||||
try { return !!tfm2.isEnabled(featureKey, tenantId) } catch { return false }
|
||||
},
|
||||
role: () => normalizeRole(tenantRole)
|
||||
}
|
||||
|
||||
const model = getMenuByRole(roleForMenu, ctx) || []
|
||||
menuStore.setMenu(key, model)
|
||||
} catch (e) {
|
||||
console.warn('[guards] ensureMenuBuilt failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export function applyGuards (router) {
|
||||
@@ -165,20 +315,96 @@ export function applyGuards (router) {
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
const isTenantArea =
|
||||
to.path.startsWith('/admin') ||
|
||||
to.path.startsWith('/therapist') ||
|
||||
to.path.startsWith('/supervisor')
|
||||
|
||||
// ======================================
|
||||
// ✅ IDENTIDADE GLOBAL (1x por navegação)
|
||||
// - se falhar, NÃO nega por engano: volta pro login (seguro)
|
||||
// ======================================
|
||||
const { data: prof, error: profErr } = await supabase
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', uid)
|
||||
.single()
|
||||
|
||||
const globalRole = !profErr ? prof?.role : null
|
||||
console.timeLog(tlabel, 'profiles.role =', globalRole)
|
||||
|
||||
if (!globalRole) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
// ======================================
|
||||
// ✅ TRAVA GLOBAL: portal_user não entra em tenant-app
|
||||
// ======================================
|
||||
if (isTenantArea && globalRole === 'portal_user') {
|
||||
// limpa lixo de tenant herdado
|
||||
try {
|
||||
localStorage.removeItem('tenant_id')
|
||||
localStorage.removeItem('tenant')
|
||||
localStorage.removeItem('currentTenantId')
|
||||
} catch (_) {}
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/portal' }
|
||||
}
|
||||
|
||||
// ======================================
|
||||
// ✅ Portal (identidade global) via meta.profileRole
|
||||
// ======================================
|
||||
if (to.meta?.profileRole) {
|
||||
if (globalRole !== to.meta.profileRole) {
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/pages/access' }
|
||||
}
|
||||
|
||||
// monta menu do portal (patient) antes de liberar
|
||||
await ensureMenuBuilt({
|
||||
uid,
|
||||
tenantId: null,
|
||||
tenantRole: globalRole, // ex.: 'portal_user'
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
}
|
||||
|
||||
// ======================================
|
||||
// ✅ ÁREA GLOBAL (não-tenant)
|
||||
// - /account/* é perfil/config do usuário
|
||||
// - NÃO pode carregar tenantStore nem trocar contexto de tenant
|
||||
// ======================================
|
||||
const isAccountArea = (to.path === '/account' || to.path.startsWith('/account/'))
|
||||
if (isAccountArea) {
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
}
|
||||
|
||||
// (opcional, mas recomendado)
|
||||
// se não é tenant_member, evita carregar tenant/entitlements sem necessidade
|
||||
if (globalRole && globalRole !== 'tenant_member') {
|
||||
try {
|
||||
localStorage.removeItem('tenant_id')
|
||||
localStorage.removeItem('tenant')
|
||||
localStorage.removeItem('currentTenantId')
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ✅ Pending invite (Modelo B): se o usuário logou e tem token pendente,
|
||||
// redireciona para /accept-invite antes de qualquer load pesado.
|
||||
// ✅ Pending invite (Modelo B)
|
||||
// ==========================================
|
||||
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) &&
|
||||
@@ -199,6 +425,11 @@ export function applyGuards (router) {
|
||||
|
||||
const tf0 = useTenantFeaturesStore()
|
||||
if (typeof tf0.invalidate === 'function') tf0.invalidate()
|
||||
|
||||
try {
|
||||
const menuStore = useMenuStore()
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ================================
|
||||
@@ -206,13 +437,103 @@ export function applyGuards (router) {
|
||||
// ================================
|
||||
if (to.meta?.saasAdmin) {
|
||||
console.timeLog(tlabel, 'isSaasAdmin')
|
||||
const ok = await isSaasAdmin(uid)
|
||||
// usa identidade global primeiro (evita cache fantasma)
|
||||
const ok = (globalRole === 'saas_admin') ? true : await isSaasAdmin(uid)
|
||||
if (!ok) { console.timeEnd(tlabel); return { path: '/pages/access' } }
|
||||
|
||||
// ✅ monta menu SaaS 1x (AppMenu lê do menuStore)
|
||||
await ensureMenuBuilt({
|
||||
uid,
|
||||
tenantId: null,
|
||||
tenantRole: 'saas_admin',
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
}
|
||||
|
||||
// ================================
|
||||
// ✅ ÁREA DO EDITOR (papel de plataforma)
|
||||
// Verificado por platform_roles[] em profiles, não por tenant.
|
||||
// ⚠️ Requer migration: ALTER TABLE profiles ADD COLUMN platform_roles text[] DEFAULT '{}'
|
||||
// ================================
|
||||
if (to.meta?.editorArea) {
|
||||
let platformRoles = []
|
||||
try {
|
||||
const { data: pRoles } = await supabase
|
||||
.from('profiles')
|
||||
.select('platform_roles')
|
||||
.eq('id', uid)
|
||||
.single()
|
||||
platformRoles = Array.isArray(pRoles?.platform_roles) ? pRoles.platform_roles : []
|
||||
} catch {
|
||||
// coluna ainda não existe: acesso negado por padrão
|
||||
}
|
||||
|
||||
if (!platformRoles.includes('editor')) {
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/pages/access' }
|
||||
}
|
||||
|
||||
await ensureMenuBuilt({
|
||||
uid,
|
||||
tenantId: null,
|
||||
tenantRole: 'editor',
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
}
|
||||
|
||||
// ================================
|
||||
// 🚫 SaaS master: bloqueia tenant-app por padrão
|
||||
// ✅ Mas libera rotas de DEMO em DEV (Sakai)
|
||||
// ================================
|
||||
console.timeLog(tlabel, 'saas.lockdown?')
|
||||
|
||||
// 🔥 PATCH: aqui NÃO consulta isSaasAdmin — usa só identidade global
|
||||
const isSaas = (globalRole === 'saas_admin')
|
||||
|
||||
if (isSaas) {
|
||||
const isSaasArea = to.path === '/saas' || to.path.startsWith('/saas/')
|
||||
|
||||
// Rotas do Sakai Demo (no seu caso ficam em /demo/*)
|
||||
const isDemoArea = import.meta.env.DEV && (
|
||||
to.path === '/demo' ||
|
||||
to.path.startsWith('/demo/')
|
||||
)
|
||||
|
||||
// Se for demo em DEV, libera
|
||||
if (isDemoArea) {
|
||||
// ✅ ainda assim monta menu SaaS (pra layout não piscar)
|
||||
await ensureMenuBuilt({
|
||||
uid,
|
||||
tenantId: null,
|
||||
tenantRole: 'saas_admin',
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
}
|
||||
|
||||
// Fora de /saas (e não-demo), não pode
|
||||
if (!isSaasArea) {
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/saas' }
|
||||
}
|
||||
|
||||
// ✅ estamos no /saas: monta menu SaaS
|
||||
await ensureMenuBuilt({
|
||||
uid,
|
||||
tenantId: null,
|
||||
tenantRole: 'saas_admin',
|
||||
globalRole
|
||||
})
|
||||
}
|
||||
|
||||
// ================================
|
||||
// ✅ Abaixo daqui é tudo tenant-app
|
||||
// ================================
|
||||
@@ -232,21 +553,33 @@ export function applyGuards (router) {
|
||||
}
|
||||
|
||||
// se não tem tenant ativo:
|
||||
// - se não tem memberships active -> manda pro access (sem clínica)
|
||||
// - se tem memberships active mas activeTenantId está null -> seta e segue
|
||||
if (!tenant.activeTenantId) {
|
||||
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
|
||||
|
||||
// 1) tenta casar role da rota (ex.: therapist) com membership
|
||||
const wantedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : []
|
||||
const preferred = wantedRoles.length
|
||||
? mem.find(m => m && m.status === 'active' && m.tenant_id && wantedRoles.includes(m.role))
|
||||
const wantedNorm = wantedRoles.map(normalizeRole)
|
||||
|
||||
const preferred = wantedNorm.length
|
||||
? mem.find(m =>
|
||||
m &&
|
||||
m.status === 'active' &&
|
||||
m.tenant_id &&
|
||||
wantedNorm.includes(normalizeRole(m.role, m.kind))
|
||||
)
|
||||
: null
|
||||
|
||||
// 2) fallback: primeiro active
|
||||
const firstActive = preferred || mem.find(m => m && m.status === 'active' && m.tenant_id)
|
||||
|
||||
if (!firstActive) {
|
||||
// 🔥 race/sem vínculo: em área tenant, trate como login necessário (não AccessDenied)
|
||||
if (isTenantArea) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/pages/access' }
|
||||
@@ -260,17 +593,90 @@ export function applyGuards (router) {
|
||||
}
|
||||
}
|
||||
|
||||
const tenantId = tenant.activeTenantId
|
||||
// 🔥 FIX: se ainda assim não resolveu tenant/role e estamos em tenant area, não negue “por engano”
|
||||
if (isTenantArea && (!tenant.activeTenantId || !tenant.activeRole)) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
let tenantId = tenant.activeTenantId
|
||||
if (!tenantId) {
|
||||
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/pages/access' }
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// ✅ tenantScope baseado em tenants.kind (fonte da verdade)
|
||||
// =====================================================
|
||||
const scope = to.meta?.tenantScope // 'personal' | 'clinic'
|
||||
|
||||
if (scope) {
|
||||
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
|
||||
|
||||
// seleciona membership ativa cujo kind corresponde ao escopo
|
||||
const desired = mem.find(m =>
|
||||
m &&
|
||||
m.status === 'active' &&
|
||||
m.tenant_id &&
|
||||
(
|
||||
(scope === 'personal' && m.kind === 'saas') ||
|
||||
(scope === 'clinic' && m.kind === 'clinic') ||
|
||||
(scope === 'supervisor' && m.kind === 'supervisor')
|
||||
)
|
||||
)
|
||||
|
||||
const desiredTenantId = desired?.tenant_id || null
|
||||
|
||||
if (desiredTenantId && tenant.activeTenantId !== desiredTenantId) {
|
||||
console.timeLog(tlabel, `tenantScope.switch(${scope})`)
|
||||
|
||||
// ✅ guarda o tenant antigo para invalidar APENAS ele
|
||||
const oldTenantId = tenant.activeTenantId
|
||||
|
||||
if (typeof tenant.setActiveTenant === 'function') {
|
||||
tenant.setActiveTenant(desiredTenantId)
|
||||
} else {
|
||||
tenant.activeTenantId = desiredTenantId
|
||||
}
|
||||
|
||||
localStorage.setItem('tenant_id', desiredTenantId)
|
||||
tenantId = desiredTenantId
|
||||
|
||||
try {
|
||||
const entX = useEntitlementsStore()
|
||||
if (typeof entX.invalidate === 'function') entX.invalidate()
|
||||
} catch {}
|
||||
|
||||
// ✅ NÃO invalidar o cache inteiro — invalida somente o tenant anterior
|
||||
try {
|
||||
const tfX = useTenantFeaturesStore()
|
||||
if (typeof tfX.invalidate === 'function' && oldTenantId) tfX.invalidate(oldTenantId)
|
||||
} catch {}
|
||||
|
||||
// ✅ troca tenant => menu precisa recompôr (contexto mudou)
|
||||
try {
|
||||
const menuStore = useMenuStore()
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset()
|
||||
} catch {}
|
||||
} else if (!desiredTenantId) {
|
||||
console.warn('[guards] tenantScope sem match:', scope, {
|
||||
memberships: mem.map(x => ({
|
||||
tenant_id: x?.tenant_id,
|
||||
role: x?.role,
|
||||
kind: x?.kind,
|
||||
status: x?.status
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
// ✅ invalida só o tenant que estava carregado antes
|
||||
tfSwitch.invalidate(tfSwitch.loadedForTenantId)
|
||||
}
|
||||
|
||||
// entitlements (✅ carrega só quando precisa)
|
||||
@@ -280,6 +686,17 @@ export function applyGuards (router) {
|
||||
await loadEntitlementsSafe(ent, tenantId, true)
|
||||
}
|
||||
|
||||
// ✅ user entitlements: terapeuta pode ter assinatura pessoal (therapist_pro)
|
||||
// que gera features em v_user_entitlements, não em v_tenant_entitlements.
|
||||
// user entitlements: therapist e supervisor têm assinatura pessoal (v_user_entitlements)
|
||||
const activeRoleNormForEnt = normalizeRole(tenant.activeRole)
|
||||
if (['therapist', 'supervisor'].includes(activeRoleNormForEnt) && uid && ent.loadedForUser !== uid) {
|
||||
console.timeLog(tlabel, 'ent.loadForUser')
|
||||
try { await ent.loadForUser(uid) } catch (e) {
|
||||
console.warn('[guards] ent.loadForUser failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// ================================
|
||||
// ✅ tenant_features (módulos ativáveis por clínica)
|
||||
// meta.tenantFeature = 'patients' | ...
|
||||
@@ -288,10 +705,14 @@ export function applyGuards (router) {
|
||||
if (requiredTenantFeature) {
|
||||
const tf = useTenantFeaturesStore()
|
||||
console.timeLog(tlabel, 'tenantFeatures.fetchForTenant')
|
||||
await tf.fetchForTenant(tenantId, { force: false })
|
||||
await fetchTenantFeaturesSafe(tf, tenantId)
|
||||
|
||||
if (!tf.isEnabled(requiredTenantFeature)) {
|
||||
// evita loop
|
||||
// ✅ IMPORTANTÍSSIMO: passa tenantId
|
||||
const enabled = typeof tf.isEnabled === 'function'
|
||||
? tf.isEnabled(requiredTenantFeature, tenantId)
|
||||
: false
|
||||
|
||||
if (!enabled) {
|
||||
if (to.path === '/admin/clinic/features') { console.timeEnd(tlabel); return true }
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
@@ -304,65 +725,73 @@ export function applyGuards (router) {
|
||||
|
||||
// ------------------------------------------------
|
||||
// ✅ RBAC (roles) — BLOQUEIA se não for compatível
|
||||
//
|
||||
// Importante:
|
||||
// - Isso é "papel": se falhar, NÃO é caso de upgrade.
|
||||
// - Só depois disso checamos feature/plano.
|
||||
// ------------------------------------------------
|
||||
const allowedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : null
|
||||
if (allowedRoles && allowedRoles.length && !allowedRoles.includes(tenant.activeRole)) {
|
||||
const allowedRolesRaw = Array.isArray(to.meta?.roles) ? to.meta.roles : null
|
||||
const allowedRoles = allowedRolesRaw && allowedRolesRaw.length
|
||||
? allowedRolesRaw.map(normalizeRole)
|
||||
: null
|
||||
|
||||
const activeRoleNorm = normalizeRole(tenant.activeRole)
|
||||
|
||||
if (allowedRoles && allowedRoles.length && !allowedRoles.includes(activeRoleNorm)) {
|
||||
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
|
||||
|
||||
const compatible = mem.find(m =>
|
||||
m && m.status === 'active' && m.tenant_id === tenantId && allowedRoles.includes(m.role)
|
||||
m &&
|
||||
m.status === 'active' &&
|
||||
m.tenant_id === tenantId &&
|
||||
allowedRoles.includes(normalizeRole(m.role, m.kind))
|
||||
)
|
||||
|
||||
if (compatible) {
|
||||
// muda role ativo para o compatível (mesmo tenant)
|
||||
tenant.activeRole = compatible.role
|
||||
tenant.activeRole = normalizeRole(compatible.role, compatible.kind)
|
||||
} else {
|
||||
// 🔥 aqui era o "furo": antes ajustava se achasse, mas se não achasse, deixava passar.
|
||||
console.timeEnd(tlabel)
|
||||
return denyByRole({ to, currentRole: tenant.activeRole })
|
||||
}
|
||||
}
|
||||
|
||||
// role guard (singular) - mantém compatibilidade
|
||||
const requiredRole = to.meta?.role
|
||||
if (requiredRole && tenant.activeRole !== requiredRole) {
|
||||
// RBAC singular também é "papel" → cai fora (não é upgrade)
|
||||
// role guard (singular)
|
||||
const requiredRoleRaw = to.meta?.role
|
||||
const requiredRole = requiredRoleRaw ? normalizeRole(requiredRoleRaw) : null
|
||||
|
||||
if (requiredRole && normalizeRole(tenant.activeRole) !== requiredRole) {
|
||||
console.timeEnd(tlabel)
|
||||
return denyByRole({ to, currentRole: tenant.activeRole })
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
// ✅ feature guard (entitlements/plano → upgrade)
|
||||
//
|
||||
// Aqui sim é caso de upgrade:
|
||||
// - o usuário "poderia" usar, mas o plano do tenant não liberou.
|
||||
// ------------------------------------------------
|
||||
const requiredFeature = to.meta?.feature
|
||||
if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
|
||||
// evita loop
|
||||
if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
|
||||
|
||||
// Mantém compatibilidade com seu fluxo existente (buildUpgradeUrl)
|
||||
const url = buildUpgradeUrl({
|
||||
missingKeys: [requiredFeature],
|
||||
redirectTo: to.fullPath
|
||||
})
|
||||
missingKeys: [requiredFeature],
|
||||
redirectTo: to.fullPath,
|
||||
role: normalizeRole(tenant.activeRole) // ✅ passa o role para a UpgradePage detectar
|
||||
})
|
||||
|
||||
// Se quiser padronizar no futuro, você pode trocar por:
|
||||
// return denyByPlan({ to, missingFeature: requiredFeature, redirectTo: to.fullPath })
|
||||
console.timeEnd(tlabel)
|
||||
return url
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// ✅ MENU: monta 1x por contexto APÓS estabilizar tenant+role
|
||||
// ======================================================
|
||||
await ensureMenuBuilt({
|
||||
uid,
|
||||
tenantId,
|
||||
tenantRole: tenant.activeRole,
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('[guards] erro no beforeEach:', e)
|
||||
|
||||
// fallback seguro
|
||||
if (to.path.startsWith('/auth')) return true
|
||||
if (to.meta?.public) return true
|
||||
if (to.path === '/pages/access') return true
|
||||
@@ -372,19 +801,87 @@ export function applyGuards (router) {
|
||||
}
|
||||
})
|
||||
|
||||
// auth listener (reset caches)
|
||||
// auth listener (reset caches) — ✅ agora com filtro de evento
|
||||
if (!window.__supabaseAuthListenerBound) {
|
||||
window.__supabaseAuthListenerBound = true
|
||||
|
||||
supabase.auth.onAuthStateChange(() => {
|
||||
sessionUidCache = null
|
||||
saasAdminCacheUid = null
|
||||
saasAdminCacheIsAdmin = null
|
||||
supabase.auth.onAuthStateChange((event, sess) => {
|
||||
// ⚠️ NÃO derrubar caches em token refresh / eventos redundantes.
|
||||
const uid = sess?.user?.id || null
|
||||
|
||||
try {
|
||||
const tf = useTenantFeaturesStore()
|
||||
if (typeof tf.invalidate === 'function') tf.invalidate()
|
||||
} catch {}
|
||||
// ✅ SIGNED_OUT: aqui sim zera tudo
|
||||
if (event === 'SIGNED_OUT') {
|
||||
sessionUidCache = null
|
||||
saasAdminCacheUid = null
|
||||
saasAdminCacheIsAdmin = null
|
||||
|
||||
// ✅ FIX: limpa o localStorage de tenant na saída
|
||||
// Sem isso, o próximo login restaura o tenant do usuário anterior.
|
||||
try {
|
||||
localStorage.removeItem('tenant_id')
|
||||
localStorage.removeItem('tenant')
|
||||
localStorage.removeItem('currentTenantId')
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
const tf = useTenantFeaturesStore()
|
||||
if (typeof tf.invalidate === 'function') tf.invalidate()
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const ent = useEntitlementsStore()
|
||||
if (typeof ent.invalidate === 'function') ent.invalidate()
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const tenant = useTenantStore()
|
||||
if (typeof tenant.reset === 'function') tenant.reset()
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const menuStore = useMenuStore()
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset()
|
||||
} catch {}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ TOKEN_REFRESHED: NÃO invalida nada (é o caso clássico de trocar de aba)
|
||||
if (event === 'TOKEN_REFRESHED') return
|
||||
|
||||
// ✅ SIGNED_IN / USER_UPDATED:
|
||||
// só invalida se o usuário mudou de verdade
|
||||
if (event === 'SIGNED_IN' || event === 'USER_UPDATED') {
|
||||
if (uid && sessionUidCache && sessionUidCache === uid) {
|
||||
// mesmo usuário -> não derruba caches
|
||||
return
|
||||
}
|
||||
|
||||
// user mudou (ou cache vazio) -> invalida dependências
|
||||
sessionUidCache = uid || null
|
||||
saasAdminCacheUid = null
|
||||
saasAdminCacheIsAdmin = null
|
||||
|
||||
try {
|
||||
const tf = useTenantFeaturesStore()
|
||||
if (typeof tf.invalidate === 'function') tf.invalidate()
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const ent = useEntitlementsStore()
|
||||
if (typeof ent.invalidate === 'function') ent.invalidate()
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const menuStore = useMenuStore()
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset()
|
||||
} catch {}
|
||||
|
||||
// tenantStore carrega de novo no fluxo do guard quando precisar
|
||||
return
|
||||
}
|
||||
|
||||
// default: não faz nada
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,17 @@ import { createRouter, createWebHistory, isNavigationFailure, NavigationFailureT
|
||||
|
||||
import configuracoesRoutes from './routes.configs';
|
||||
import meRoutes from './routes.account';
|
||||
import adminRoutes from './routes.admin';
|
||||
import adminRoutes from './routes.clinic';
|
||||
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 portalRoutes from './routes.portal';
|
||||
import publicRoutes from './routes.public';
|
||||
import saasRoutes from './routes.saas';
|
||||
import therapistRoutes from './routes.therapist';
|
||||
import supervisorRoutes from './routes.supervisor';
|
||||
import editorRoutes from './routes.editor';
|
||||
import featuresRoutes from './routes.features'
|
||||
|
||||
import { applyGuards } from './guards';
|
||||
@@ -24,7 +26,9 @@ const routes = [
|
||||
...(Array.isArray(meRoutes) ? meRoutes : [meRoutes]),
|
||||
...(Array.isArray(adminRoutes) ? adminRoutes : [adminRoutes]),
|
||||
...(Array.isArray(therapistRoutes) ? therapistRoutes : [therapistRoutes]),
|
||||
...(Array.isArray(patientRoutes) ? patientRoutes : [patientRoutes]),
|
||||
...(Array.isArray(supervisorRoutes) ? supervisorRoutes : [supervisorRoutes]),
|
||||
...(Array.isArray(editorRoutes) ? editorRoutes : [editorRoutes]),
|
||||
...(Array.isArray(portalRoutes) ? portalRoutes : [portalRoutes]),
|
||||
...(Array.isArray(demoRoutes) ? demoRoutes : [demoRoutes]),
|
||||
...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]),
|
||||
...(Array.isArray(featuresRoutes) ? featuresRoutes : [featuresRoutes]),
|
||||
|
||||
@@ -3,7 +3,7 @@ import AppLayout from '@/layout/AppLayout.vue'
|
||||
export default {
|
||||
path: '/account',
|
||||
component: AppLayout,
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true, area: 'account' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
// src/router/routes.admin.js
|
||||
// src/router/routes.clinic.js
|
||||
import AppLayout from '@/layout/AppLayout.vue'
|
||||
|
||||
export default {
|
||||
path: '/admin',
|
||||
component: AppLayout,
|
||||
|
||||
meta: {
|
||||
meta: {
|
||||
// 🔐 Tudo aqui dentro exige login
|
||||
requiresAuth: true,
|
||||
|
||||
area: 'admin',
|
||||
requiresAuth: true,
|
||||
|
||||
// 👤 Perfil de acesso (tenant-level)
|
||||
// tenantStore normaliza tenant_admin -> clinic_admin, mas mantemos compatibilidade
|
||||
roles: ['clinic_admin', 'tenant_admin']
|
||||
roles: ['clinic_admin']
|
||||
},
|
||||
|
||||
children: [
|
||||
// ======================================================
|
||||
// 📊 DASHBOARD
|
||||
// ======================================================
|
||||
{
|
||||
path: '',
|
||||
name: 'admin-dashboard',
|
||||
component: () => import('@/views/pages/admin/AdminDashboard.vue')
|
||||
},
|
||||
{ path: '', name: 'admin.dashboard', component: () => import('@/views/pages/clinic/ClinicDashboard.vue') },
|
||||
|
||||
// ======================================================
|
||||
// 🧩 CLÍNICA — MÓDULOS (tenant_features)
|
||||
@@ -30,53 +26,54 @@ export default {
|
||||
{
|
||||
path: 'clinic/features',
|
||||
name: 'admin-clinic-features',
|
||||
component: () => import('@/views/pages/admin/clinic/ClinicFeaturesPage.vue'),
|
||||
component: () => import('@/views/pages/clinic/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'),
|
||||
path: 'clinic/professionals',
|
||||
name: 'admin-clinic-professionals',
|
||||
component: () => import('@/views/pages/clinic/clinic/ClinicProfessionalsPage.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.view'
|
||||
requiresAuth: true,
|
||||
roles: ['clinic_admin', 'tenant_admin']
|
||||
}
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 💳 MEU PLANO
|
||||
// ======================================================
|
||||
{
|
||||
path: 'meu-plano',
|
||||
name: 'admin-meu-plano',
|
||||
component: () => import('@/views/pages/billing/ClinicMeuPlanoPage.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 📅 AGENDA DA CLÍNICA
|
||||
// ======================================================
|
||||
|
||||
{
|
||||
path: 'agenda/clinica',
|
||||
name: 'admin-agenda-clinica',
|
||||
component: () => import('@/features/agenda/pages/AgendaClinicaPage.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.view',
|
||||
roles: ['clinic_admin', 'tenant_admin']
|
||||
}
|
||||
},
|
||||
|
||||
// ➕ Adicionar novo compromisso
|
||||
{
|
||||
path: 'agenda/adicionar',
|
||||
name: 'admin-agenda-adicionar',
|
||||
component: () => import('@/views/pages/admin/agenda/NewAppointmentPage.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.manage'
|
||||
}
|
||||
},
|
||||
|
||||
// ✅ NOVO: Compromissos determinísticos (tipos)
|
||||
{
|
||||
path: 'agenda/clinica',
|
||||
name: 'admin-agenda-clinica',
|
||||
component: () => import('@/features/agenda/pages/AgendaClinicaPage.vue'),
|
||||
path: 'agenda/compromissos',
|
||||
name: 'admin-agenda-compromissos',
|
||||
component: () => import('@/features/agenda/pages/CompromissosDeterminados.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.view',
|
||||
roles: ['clinic_admin', 'tenant_admin']
|
||||
// ✅ sem tenantScope: a área /admin já está no tenant da clínica pelo fluxo normal
|
||||
}
|
||||
},
|
||||
|
||||
@@ -171,7 +168,7 @@ export default {
|
||||
{
|
||||
path: 'online-scheduling',
|
||||
name: 'admin-online-scheduling',
|
||||
component: () => import('@/views/pages/admin/OnlineSchedulingAdminPage.vue'),
|
||||
component: () => import('@/views/pages/clinic/OnlineSchedulingAdminPage.vue'),
|
||||
meta: {
|
||||
feature: 'online_scheduling.manage'
|
||||
}
|
||||
@@ -22,11 +22,6 @@ const configuracoesRoutes = {
|
||||
name: 'ConfiguracoesAgenda',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesAgendaPage.vue')
|
||||
}
|
||||
|
||||
// Futuro:
|
||||
// { path: 'clinica', name: 'ConfiguracoesClinica', component: () => import('@/layout/configuracoes/ConfiguracoesClinicaPage.vue') },
|
||||
// { path: 'intake', name: 'ConfiguracoesIntake', component: () => import('@/layout/configuracoes/ConfiguracoesIntakePage.vue') },
|
||||
// { path: 'conta', name: 'ConfiguracoesConta', component: () => import('@/layout/configuracoes/ConfiguracoesContaPage.vue') },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,7 +4,12 @@ export default {
|
||||
// ✅ não use '/' aqui (conflita com HomeCards)
|
||||
path: '/demo',
|
||||
component: AppLayout,
|
||||
meta: { requiresAuth: true, role: 'tenant_admin' },
|
||||
|
||||
// ✅ DEMO pertence ao backoffice SaaS (somente DEV)
|
||||
// - assim o guard trata como área SaaS e não cai no tenant-app
|
||||
// - remove dependência de role tenant_admin / tenant ativo
|
||||
meta: { requiresAuth: true, saasAdmin: true },
|
||||
|
||||
children: [
|
||||
{ path: 'uikit/formlayout', name: 'uikit-formlayout', component: () => import('@/views/uikit/FormLayout.vue') },
|
||||
{ path: 'uikit/input', name: 'uikit-input', component: () => import('@/views/uikit/InputDoc.vue') },
|
||||
@@ -26,4 +31,4 @@ export default {
|
||||
{ path: 'pages/empty', name: 'pages-empty', component: () => import('@/views/pages/Empty.vue') },
|
||||
{ path: 'pages/crud', name: 'pages-crud', component: () => import('@/views/pages/Crud.vue') }
|
||||
]
|
||||
}
|
||||
}
|
||||
64
src/router/routes.editor.js
Normal file
64
src/router/routes.editor.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// src/router/routes.editor.js
|
||||
//
|
||||
// Área de Editor de Conteúdo — papel de PLATAFORMA.
|
||||
// Acesso controlado por `platform_roles` no guard (não por tenant role).
|
||||
// meta.editorArea: true sinaliza ao guard que use a verificação de plataforma.
|
||||
//
|
||||
import AppLayout from '@/layout/AppLayout.vue'
|
||||
|
||||
export default {
|
||||
path: '/editor',
|
||||
component: AppLayout,
|
||||
|
||||
meta: { area: 'editor', requiresAuth: true, editorArea: true },
|
||||
|
||||
children: [
|
||||
// ======================================================
|
||||
// 📊 DASHBOARD
|
||||
// ======================================================
|
||||
{
|
||||
path: '',
|
||||
name: 'editor.dashboard',
|
||||
component: () => import('@/views/pages/editor/EditorDashboard.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 📚 CURSOS
|
||||
// ======================================================
|
||||
{
|
||||
path: 'cursos',
|
||||
name: 'editor-cursos',
|
||||
// placeholder — módulo de microlearning a implementar
|
||||
component: () => import('@/views/pages/editor/EditorDashboard.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 📦 MÓDULOS
|
||||
// ======================================================
|
||||
{
|
||||
path: 'modulos',
|
||||
name: 'editor-modulos',
|
||||
// placeholder
|
||||
component: () => import('@/views/pages/editor/EditorDashboard.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// ✅ PUBLICADOS
|
||||
// ======================================================
|
||||
{
|
||||
path: 'publicados',
|
||||
name: 'editor-publicados',
|
||||
// placeholder
|
||||
component: () => import('@/views/pages/editor/EditorDashboard.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 💳 MEU PLANO (assinatura pessoal do editor)
|
||||
// ======================================================
|
||||
{
|
||||
path: 'meu-plano',
|
||||
name: 'editor-meu-plano',
|
||||
component: () => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,24 +4,22 @@ import AppLayout from '@/layout/AppLayout.vue'
|
||||
export default {
|
||||
path: '/portal',
|
||||
component: AppLayout,
|
||||
meta: { requiresAuth: true, roles: ['patient'] },
|
||||
meta: { area: 'portal', requiresAuth: true, profileRole: 'portal_user' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'portal-dashboard',
|
||||
component: () => import('@/views/pages/portal/PortalDashboard.vue')
|
||||
{ path: '', name: 'portal.dashboard', component: () => import('@/views/pages/portal/PortalDashboard.vue') },
|
||||
{
|
||||
path: 'sessoes',
|
||||
name: 'portal-sessoes',
|
||||
component: () => import('@/views/pages/portal/MinhasSessoes.vue')
|
||||
},
|
||||
|
||||
// ✅ Appointments (era agenda)
|
||||
// ======================================================
|
||||
// 💳 MEU PLANO (assinatura pessoal do paciente)
|
||||
// ======================================================
|
||||
{
|
||||
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')
|
||||
path: 'meu-plano',
|
||||
name: 'portal-meu-plano',
|
||||
component: () => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -30,6 +30,11 @@ export default {
|
||||
name: 'saas-plan-features',
|
||||
component: () => import('@/views/pages/saas/SaasPlanFeaturesMatrixPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'plan-limits',
|
||||
name: 'saas-plan-limits',
|
||||
component: () => import('@/views/pages/saas/SaasPlanLimitsPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'subscriptions',
|
||||
name: 'saas-subscriptions',
|
||||
@@ -45,7 +50,7 @@ export default {
|
||||
name: 'saas-subscription-health',
|
||||
component: () => import('@/views/pages/saas/SaasSubscriptionHealthPage.vue')
|
||||
},
|
||||
{
|
||||
{
|
||||
path: 'subscription-intents',
|
||||
name: 'saas.subscriptionIntents',
|
||||
component: () => import('@/views/pages/saas/SubscriptionIntentsPage.vue'),
|
||||
@@ -57,4 +62,4 @@ export default {
|
||||
component: () => import('@/views/pages/saas/SaasPlaceholder.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
46
src/router/routes.supervisor.js
Normal file
46
src/router/routes.supervisor.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// src/router/routes.supervisor.js
|
||||
import AppLayout from '@/layout/AppLayout.vue'
|
||||
|
||||
export default {
|
||||
path: '/supervisor',
|
||||
component: AppLayout,
|
||||
|
||||
// tenantScope: 'supervisor' → o guard troca automaticamente para o tenant
|
||||
// com kind='supervisor' quando o usuário navega para esta área.
|
||||
meta: {
|
||||
area: 'supervisor',
|
||||
requiresAuth: true,
|
||||
roles: ['supervisor'],
|
||||
tenantScope: 'supervisor'
|
||||
},
|
||||
|
||||
children: [
|
||||
// ======================================================
|
||||
// 📊 DASHBOARD
|
||||
// ======================================================
|
||||
{
|
||||
path: '',
|
||||
name: 'supervisor.dashboard',
|
||||
component: () => import('@/views/pages/supervisor/SupervisorDashboard.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🎓 SALA DE SUPERVISÃO
|
||||
// ======================================================
|
||||
{
|
||||
path: 'sala',
|
||||
name: 'supervisor.sala',
|
||||
component: () => import('@/views/pages/supervisor/SupervisaoSalaPage.vue'),
|
||||
meta: { feature: 'supervisor.access' }
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 💳 MEU PLANO
|
||||
// ======================================================
|
||||
{
|
||||
path: 'meu-plano',
|
||||
name: 'supervisor.meu-plano',
|
||||
component: () => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,24 +5,13 @@ export default {
|
||||
path: '/therapist',
|
||||
component: AppLayout,
|
||||
|
||||
meta: {
|
||||
// 🔐 Tudo aqui dentro exige login
|
||||
requiresAuth: true,
|
||||
|
||||
// 👤 Perfil de acesso (tenant-level)
|
||||
roles: ['therapist']
|
||||
},
|
||||
meta: { area: 'therapist', requiresAuth: true, roles: ['therapist'] },
|
||||
|
||||
children: [
|
||||
// ======================================================
|
||||
// 📊 DASHBOARD
|
||||
// ======================================================
|
||||
{
|
||||
path: '',
|
||||
name: 'therapist-dashboard',
|
||||
component: () => import('@/views/pages/therapist/TherapistDashboard.vue')
|
||||
// herda requiresAuth + roles do pai
|
||||
},
|
||||
{ path: '', name: 'therapist.dashboard', component: () => import('@/views/pages/therapist/TherapistDashboard.vue') },
|
||||
|
||||
// ======================================================
|
||||
// 📅 AGENDA
|
||||
@@ -30,81 +19,81 @@ export default {
|
||||
{
|
||||
path: 'agenda',
|
||||
name: 'therapist-agenda',
|
||||
//component: () => import('@/views/pages/therapist/agenda/MyAppointmentsPage.vue'),
|
||||
component: () => import('@/features/agenda/pages/AgendaTerapeutaPage.vue'),
|
||||
component: () => import('@/features/agenda/pages/AgendaTerapeutaPage.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.view'
|
||||
}
|
||||
},
|
||||
|
||||
// ✅ Compromissos determinísticos
|
||||
{
|
||||
path: 'agenda/adicionar',
|
||||
name: 'therapist-agenda-adicionar',
|
||||
component: () => import('@/views/pages/therapist/agenda/NewAppointmentPage.vue'),
|
||||
path: 'agenda/compromissos',
|
||||
name: 'therapist-agenda-compromissos',
|
||||
component: () => import('@/features/agenda/pages/CompromissosDeterminados.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.manage'
|
||||
feature: 'agenda.view',
|
||||
roles: ['therapist']
|
||||
// ✅ sem tenantScope
|
||||
}
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// ======================================================
|
||||
// 💳 MEU PLANO
|
||||
// ======================================================
|
||||
{
|
||||
path: 'meu-plano',
|
||||
name: 'therapist-meu-plano',
|
||||
component: () => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'upgrade',
|
||||
name: 'therapist-upgrade',
|
||||
component: () => import('@/views/pages/billing/TherapistUpgradePage.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 👥 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')
|
||||
component: () => import('@/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🔒 PRO — Online Scheduling (gestão interna)
|
||||
// 🔒 PRO — Online Scheduling
|
||||
// ======================================================
|
||||
// feature gate via meta.feature:
|
||||
// - bloqueia rota (guard)
|
||||
// - menu pode desabilitar/ocultar (entitlementsStore.has)
|
||||
{
|
||||
path: 'online-scheduling',
|
||||
name: 'therapist-online-scheduling',
|
||||
@@ -115,23 +104,12 @@ export default {
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🔐 SECURITY (temporário dentro da área)
|
||||
// 🔐 SECURITY
|
||||
// ======================================================
|
||||
// ⚠️ 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',
|
||||
// component: () => import('@/views/pages/therapist/OnlineSchedulingPublicPage.vue'),
|
||||
// meta: { feature: 'online_scheduling.public' }
|
||||
// }
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user