This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions

View File

@@ -1,2 +1,4 @@
VITE_SUPABASE_URL=http://127.0.0.1:54321 VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH VITE_SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH
VITE_QA_MODE=true
VITE_QA_PASS=123Mudar@

View File

@@ -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 #### `profiles`
- Migrate sass from @import to @use - ✅ 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 #### `plans`
- Add border to overlay menu - Adicionado `patient` como valor válido em `target`
- Animation for mobile mask - ✅ Inserido plano `patient_free` (gratuito, target=patient)
- Fixed chart colors
## 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. ### O que precisa ser aplicado no banco
- Update assets style files
- Remove code highlight
**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 ## Futuro — registrado mas não implementado
- Upgrade to vite 4.2.1
### 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*

View File

@@ -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 &gt; 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:&lt;uuid&gt;</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 (
'&lt;TENANT_UUID&gt;',
(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>

View File

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

View File

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

View File

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

View 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 &lt;&gt; '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
(&#x27;clinic_free&#x27;, &#x27;CLINIC FREE&#x27;, &#x27;Plano gratuito para clínicas iniciarem.&#x27;, true, 0, &#x27;BRL&#x27;, &#x27;month&#x27;, &#x27;clinic&#x27;),
(&#x27;clinic_pro&#x27;, &#x27;CLINIC PRO&#x27;, &#x27;Plano completo para clínicas.&#x27;, true, 14900, &#x27;BRL&#x27;, &#x27;month&#x27;, &#x27;clinic&#x27;),
(&#x27;therapist_free&#x27;, &#x27;THERAPIST FREE&#x27;, &#x27;Plano gratuito para terapeutas.&#x27;, true, 0, &#x27;BRL&#x27;, &#x27;month&#x27;, &#x27;therapist&#x27;),
(&#x27;therapist_pro&#x27;, &#x27;THERAPIST PRO&#x27;, &#x27;Plano completo para terapeutas.&#x27;, true, 4900, &#x27;BRL&#x27;, &#x27;month&#x27;, &#x27;therapist&#x27;)
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 (&#x27;clinic_free&#x27;,&#x27;clinic_pro&#x27;,&#x27;therapist_free&#x27;,&#x27;therapist_pro&#x27;)
)
insert into plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
select
id,
case key
when &#x27;clinic_free&#x27; then &#x27;Clínica — Free&#x27;
when &#x27;clinic_pro&#x27; then &#x27;Clínica — PRO&#x27;
when &#x27;therapist_free&#x27; then &#x27;Terapeuta — Free&#x27;
when &#x27;therapist_pro&#x27; then &#x27;Terapeuta — PRO&#x27;
end,
case key
when &#x27;clinic_free&#x27; then &#x27;Para clínicas pequenas começarem sem cartão.&#x27;
when &#x27;clinic_pro&#x27; then &#x27;Para clínicas que querem recursos completos.&#x27;
when &#x27;therapist_free&#x27; then &#x27;Para começar e organizar sua prática.&#x27;
when &#x27;therapist_pro&#x27; then &#x27;Para expandir com automações e escala.&#x27;
end,
case key
when &#x27;clinic_free&#x27; then &#x27;Grátis&#x27;
when &#x27;therapist_free&#x27; then &#x27;Grátis&#x27;
else null
end,
case key
when &#x27;clinic_pro&#x27; then true
when &#x27;therapist_pro&#x27; then true
else false
end,
true,
case key
when &#x27;clinic_free&#x27; then 10
when &#x27;clinic_pro&#x27; then 20
when &#x27;therapist_free&#x27; then 10
when &#x27;therapist_pro&#x27; 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 (&#x27;clinic_free&#x27;,&#x27;clinic_pro&#x27;,&#x27;therapist_free&#x27;,&#x27;therapist_pro&#x27;));
insert into plan_public_bullets (plan_id, text, highlight, sort_order)
values
((select id from plans where key=&#x27;clinic_free&#x27;), &#x27;1 terapeuta incluído&#x27;, false, 10),
((select id from plans where key=&#x27;clinic_free&#x27;), &#x27;Até 30 pacientes&#x27;, false, 20),
((select id from plans where key=&#x27;clinic_free&#x27;), &#x27;Até 100 sessões/mês&#x27;, false, 30),
((select id from plans where key=&#x27;clinic_pro&#x27;), &#x27;Terapeutas ilimitados&#x27;, true, 10),
((select id from plans where key=&#x27;clinic_pro&#x27;), &#x27;Pacientes ilimitados&#x27;, true, 20),
((select id from plans where key=&#x27;clinic_pro&#x27;), &#x27;Relatórios e lembretes&#x27;, false, 30),
((select id from plans where key=&#x27;therapist_free&#x27;), &#x27;Até 10 pacientes&#x27;, false, 10),
((select id from plans where key=&#x27;therapist_free&#x27;), &#x27;Até 40 sessões/mês&#x27;, false, 20),
((select id from plans where key=&#x27;therapist_free&#x27;), &#x27;Portal do paciente&#x27;, false, 30),
((select id from plans where key=&#x27;therapist_pro&#x27;), &#x27;Pacientes ilimitados&#x27;, true, 10),
((select id from plans where key=&#x27;therapist_pro&#x27;), &#x27;Sessões ilimitadas&#x27;, true, 20),
((select id from plans where key=&#x27;therapist_pro&#x27;), &#x27;Relatórios e lembretes&#x27;, 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=&#x27;clinic_pro&#x27;;
select id into v_therapist_pro from plans where key=&#x27;therapist_pro&#x27;;
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, &#x27;BRL&#x27;, &#x27;month&#x27;, 14900, true, now(), null, &#x27;manual&#x27;, null, null),
(v_clinic_pro, &#x27;BRL&#x27;, &#x27;year&#x27;, 149000, true, now(), null, &#x27;manual&#x27;, null, null),
(v_therapist_pro, &#x27;BRL&#x27;, &#x27;month&#x27;, 4900, true, now(), null, &#x27;manual&#x27;, null, null),
(v_therapist_pro, &#x27;BRL&#x27;, &#x27;year&#x27;, 49000, true, now(), null, &#x27;manual&#x27;, null, null);
exception
when unique_violation then
raise notice &#x27;Preço vigente já existe para algum (plan_id, interval, currency).&#x27;;
end $$;
-- 5) (Opcional) Integridade: impedir apagar plano em uso
-- A FK subscriptions.plan_id -&gt; 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>

View File

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

View 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;

View 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;

View 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
View 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;
$$;

View 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
View 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
View 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
View 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;

File diff suppressed because it is too large Load Diff

View 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&lt9K5rP"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
View 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>&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'saas_admin'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// sem memberships de tenant</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<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>&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">profiles.platform_roles</span> <span class="tree-val">['editor']</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// sem memberships de tenant</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<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>&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'tenant_member'</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Membership: <span class="tree-val">Clínica X</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'clinic'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'clinic_admin'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-val">clinic_free | clinic_pro</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<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>&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'tenant_member'</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Membership: <span class="tree-val">Clínica X</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'clinic'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">entitlements</span> <span class="tree-val">via plano da clínica</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<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>&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'tenant_member'</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Membership: <span class="tree-val">Tenant Pessoal</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'saas'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-val">therapist_free | therapist_pro</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// entitlements via v_user_entitlements</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<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>&nbsp;&nbsp;<span class="tree-branch">├──</span> Membership A: <span class="tree-val">Tenant Pessoal</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'saas'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-val">therapist_pro</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Membership B: <span class="tree-val">Clínica X</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'clinic'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<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>&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'tenant_member'</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Membership: <span class="tree-new">Tenant Supervisão (novo)</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-new">'supervisor'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-new">'supervisor'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">plano</span> <span class="tree-new">supervisor_free | supervisor_pro</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">max_supervisees</span> <span class="tree-new">3 | 20</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<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>&nbsp;&nbsp;<span class="tree-branch">├──</span> Membership A: <span class="tree-val">Tenant Pessoal</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'saas'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-val">therapist_pro</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Membership B: <span class="tree-new">Tenant Supervisão (novo)</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-new">'supervisor'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-new">'supervisor'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-new">supervisor_pro</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<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>&nbsp;&nbsp;<span class="tree-branch">├──</span> Membership A: <span class="tree-val">Clínica X</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'clinic'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Membership B: <span class="tree-new">Tenant Supervisão (novo)</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-new">'supervisor'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-new">'supervisor'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-new">supervisor_free | supervisor_pro</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// supervisão é INDEPENDENTE da clínica</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// colegas da clínica podem ser supervisionados</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<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>&nbsp;&nbsp;<span class="tree-branch">├──</span> Ativa módulo <span class="tree-key">supervisao</span> (feature)</div>
<div>&nbsp;&nbsp;<span class="tree-branch">├──</span> Associa supervisor externo via convite</div>
<div>&nbsp;&nbsp;<span class="tree-branch">├──</span> Sessões registradas na plataforma</div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Pagamento via AgenciaPsi</div>
<div>&nbsp;</div>
<div class="tree-root">Fluxo financeiro</div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Clínica paga <span class="tree-warn">R$ 200/sessão</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> Supervisor recebe <span class="tree-val">R$ 180</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<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>&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'portal_user'</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-comment">// sem memberships de tenant</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// acessa /portal/*</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<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

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,128 @@
<script setup> <script setup>
import { onMounted } from 'vue' import { onMounted, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore' import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore' import { useEntitlementsStore } from '@/stores/entitlementsStore'
const route = useRoute() const route = useRoute()
const tenantStore = useTenantStore() const tenantStore = useTenantStore()
const entStore = useEntitlementsStore() const entStore = useEntitlementsStore()
onMounted(async () => { function isTenantArea (path = '') {
// 1) carrega sessão + tenant ativo (do seu fluxo atual) return path.startsWith('/admin') || path.startsWith('/therapist')
await tenantStore.loadSessionAndTenant() }
// 2) carrega permissões do tenant ativo (se existir) function isPortalArea (path = '') {
if (tenantStore.activeTenantId) { return path.startsWith('/portal')
await entStore.loadForTenant(tenantStore.activeTenantId) }
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 onMounted(async () => {
console.groupCollapsed('[Debug] Tenant localStorage') // 🔥 PRIMEIRO LOG — TENANT ID BRUTO (mantive sua ideia)
console.log('tenant_id:', localStorage.getItem('tenant_id')) console.log('[SEU_TENANT_ID]', localStorage.getItem('tenant_id'))
console.log('currentTenantId:', localStorage.getItem('currentTenantId'))
console.log('tenant:', localStorage.getItem('tenant'))
console.groupEnd()
// 4) debug: stores // snapshot inicial
console.groupCollapsed('[Debug] Tenant stores') await debugSnapshot('mounted')
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 a cada navegação (isso é o que vai te salvar)
watch(
() => route.fullPath,
async (to, from) => {
await debugSnapshot(`route change: ${from} -> ${to}`)
}
)
</script> </script>
<template> <template>

View File

@@ -63,7 +63,7 @@ export async function bootstrapUserSettings({
primaryColors = [], // passe a lista do seu Perfil (ou uma versão reduzida) primaryColors = [], // passe a lista do seu Perfil (ou uma versão reduzida)
surfaces = [] // idem surfaces = [] // idem
} = {}) { } = {}) {
const { layoutConfig, isDarkTheme, toggleDarkMode, changeMenuMode } = useLayout() const { layoutConfig, isDarkTheme, toggleDarkMode, changeMenuMode, setVariant } = useLayout()
const { data: uRes, error: uErr } = await supabase.auth.getUser() const { data: uRes, error: uErr } = await supabase.auth.getUser()
if (uErr) return if (uErr) return
@@ -72,12 +72,17 @@ export async function bootstrapUserSettings({
const { data: settings, error } = await supabase const { data: settings, error } = await supabase
.from('user_settings') .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) .eq('user_id', user.id)
.maybeSingle() .maybeSingle()
if (error || !settings) return if (error || !settings) return
// layout variant (rail / classic)
if (settings.layout_variant === 'rail' || settings.layout_variant === 'classic') {
setVariant(settings.layout_variant)
}
// menu mode // menu mode
if (settings.menu_mode && settings.menu_mode !== layoutConfig.menuMode) { if (settings.menu_mode && settings.menu_mode !== layoutConfig.menuMode) {
layoutConfig.menuMode = settings.menu_mode layoutConfig.menuMode = settings.menu_mode

View File

@@ -8,10 +8,25 @@
@hide="onHide" @hide="onHide"
> >
<template #header> <template #header>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-2">
<div class="text-xl font-semibold">{{ title }}</div> <div class="flex items-start justify-between gap-3">
<div class="text-sm text-surface-500"> <div class="min-w-0">
Crie um paciente rapidamente (nome, e-mail e telefone obrigatórios). <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>
</div> </div>
</template> </template>
@@ -21,55 +36,67 @@
{{ errorMsg }} {{ errorMsg }}
</Message> </Message>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2 mt-2">
<label class="text-sm font-medium" for="cr-nome">Nome *</label> <!-- Nome -->
<InputText <FloatLabel variant="on">
id="cr-nome" <IconField>
v-model.trim="form.nome_completo" <InputIcon class="pi pi-user" />
:disabled="saving" <InputText
autocomplete="off" id="cr-nome"
autofocus v-model.trim="form.nome_completo"
@keydown.enter.prevent="submit" class="w-full"
/> variant="filled"
<small v-if="touched && !form.nome_completo" class="text-red-500"> :disabled="saving"
Informe o nome. autocomplete="off"
</small> autofocus
@keydown.enter.prevent="submit"
/>
</IconField>
<label for="cr-nome">Nome completo *</label>
</FloatLabel>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-sm font-medium" for="cr-email">E-mail *</label> <!-- E-mail -->
<InputText <FloatLabel variant="on">
id="cr-email" <IconField>
v-model.trim="form.email_principal" <InputIcon class="pi pi-envelope" />
:disabled="saving" <InputText
inputmode="email" id="cr-email"
autocomplete="off" v-model.trim="form.email_principal"
@keydown.enter.prevent="submit" class="w-full"
/> variant="filled"
<small v-if="touched && !form.email_principal" class="text-red-500"> :disabled="saving"
Informe o e-mail. inputmode="email"
</small> autocomplete="off"
<small v-if="touched && form.email_principal && !isValidEmail(form.email_principal)" class="text-red-500"> @keydown.enter.prevent="submit"
E-mail inválido. />
</small> </IconField>
<label for="cr-email">E-mail *</label>
</FloatLabel>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-sm font-medium" for="cr-telefone">Telefone *</label> <!-- Telefone -->
<InputMask <FloatLabel variant="on">
id="cr-telefone" <IconField>
v-model="form.telefone" <InputIcon class="pi pi-phone" />
:disabled="saving" <InputMask
mask="(99) 99999-9999" id="cr-telefone"
placeholder="(16) 99999-9999" v-model="form.telefone"
@keydown.enter.prevent="submit" mask="(99) 99999-9999"
/> class="w-full"
<small v-if="touched && !form.telefone" class="text-red-500"> variant="filled"
Informe o telefone. :disabled="saving"
</small> @keydown.enter.prevent="submit"
<small v-else-if="touched && form.telefone && !isValidPhone(form.telefone)" class="text-red-500"> />
Telefone inválido. </IconField>
</small> <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>
</div> </div>
@@ -95,12 +122,49 @@
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { useRoleGuard } from '@/composables/useRoleGuard'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import InputMask from 'primevue/inputmask' import InputMask from 'primevue/inputmask'
import Message from 'primevue/message' import Message from 'primevue/message'
import { supabase } from '@/lib/supabase/client' 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({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
@@ -114,7 +178,10 @@ const props = defineProps({
emailField: { type: String, default: 'email_principal' }, emailField: { type: String, default: 'email_principal' },
phoneField: { type: String, default: 'telefone' }, 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: () => ({}) }, extraPayload: { type: Object, default: () => ({}) },
closeOnCreated: { type: Boolean, default: true }, closeOnCreated: { type: Boolean, default: true },
@@ -184,6 +251,78 @@ async function getOwnerId () {
return user.id 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 () { async function submit () {
touched.value = true touched.value = true
errorMsg.value = '' errorMsg.value = ''
@@ -201,16 +340,21 @@ async function submit () {
saving.value = true saving.value = true
try { try {
const ownerId = await getOwnerId() 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 = { const payload = {
...props.extraPayload,
[props.ownerField]: ownerId, [props.ownerField]: ownerId,
[props.tenantField]: tenantId,
[props.responsibleMemberField]: memberId,
[props.nameField]: nome, [props.nameField]: nome,
[props.emailField]: email.toLowerCase(), [props.emailField]: email.toLowerCase(),
[props.phoneField]: normalizePhoneDigits(tel), [props.phoneField]: normalizePhoneDigits(tel)
...props.extraPayload
} }
// remove undefined
Object.keys(payload).forEach((k) => { Object.keys(payload).forEach((k) => {
if (payload[k] === undefined) delete payload[k] if (payload[k] === undefined) delete payload[k]
}) })
@@ -248,4 +392,4 @@ async function submit () {
saving.value = false saving.value = false
} }
} }
</script> </script>

View File

@@ -3,7 +3,6 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import TabView from 'primevue/tabview' import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel' import TabPanel from 'primevue/tabpanel'
import Tag from 'primevue/tag'
import ProgressSpinner from 'primevue/progressspinner' import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'

View File

@@ -1,14 +1,11 @@
<!-- src/components/agenda/AgendaSlotsPorDiaCard.vue --> <!-- src/components/agenda/AgendaSlotsPorDiaCard.vue -->
<script setup> <script setup>
import { computed, ref, watch, onMounted } from 'vue' import { computed, ref, watch, onMounted } from 'vue'
import Card from 'primevue/card'
import Button from 'primevue/button'
import TabView from 'primevue/tabview' import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel' import TabPanel from 'primevue/tabpanel'
import Dropdown from 'primevue/dropdown' import Dropdown from 'primevue/dropdown'
import InputNumber from 'primevue/inputnumber' import InputNumber from 'primevue/inputnumber'
import InputSwitch from 'primevue/inputswitch' import InputSwitch from 'primevue/inputswitch'
import FloatLabel from 'primevue/floatlabel'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { fetchSlotsRegras, upsertSlotRegra } from '@/services/agendaConfigService' import { fetchSlotsRegras, upsertSlotRegra } from '@/services/agendaConfigService'

View File

@@ -1,13 +1,7 @@
<!-- src/components/agenda/PausasChipsEditor.vue --> <!-- src/components/agenda/PausasChipsEditor.vue -->
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import DatePicker from 'primevue/datepicker'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import FloatLabel from 'primevue/floatlabel'
import InputText from 'primevue/inputtext'
import Tag from 'primevue/tag'
import Divider from 'primevue/divider'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
const toast = useToast() const toast = useToast()
@@ -32,6 +26,15 @@ function minToHHMM(min) {
function newId() { function newId() {
return Math.random().toString(16).slice(2) + Date.now().toString(16) 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([]) const internal = ref([])
@@ -151,14 +154,22 @@ const presets = [
] ]
const dlg = ref(false) 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() { 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 dlg.value = true
} }
function saveCustom() { function saveCustom() {
addPauseSmart(form.value) addPauseSmart({ label: form.value.label, inicio: formInicioHHMM.value, fim: formFimHHMM.value })
dlg.value = false dlg.value = false
} }
</script> </script>
@@ -200,46 +211,44 @@ function saveCustom() {
</div> </div>
<!-- custom dialog --> <!-- custom dialog -->
<Dialog v-model:visible="dlg" modal header="Adicionar pausa" :style="{ width: '520px' }"> <Dialog v-model:visible="dlg" modal :draggable="false" header="Adicionar pausa" :style="{ width: '420px' }">
<div class="grid grid-cols-12 gap-3"> <div class="flex flex-col gap-4">
<div class="col-span-12"> <div>
<FloatLabel variant="on"> <label class="text-xs text-[var(--text-color-secondary)] mb-1 block">Nome</label>
<InputText v-model="form.label" class="w-full" inputId="plabel" placeholder="Ex.: Almoço" /> <InputText v-model="form.label" class="w-full" placeholder="Ex.: Almoço" />
<label for="plabel">Nome</label>
</FloatLabel>
</div> </div>
<div class="col-span-12 md:col-span-6"> <div class="flex gap-3">
<FloatLabel variant="on"> <div class="flex-1 flex flex-col gap-1">
<InputText v-model="form.inicio" class="w-full" inputId="pinicio" placeholder="12:00" /> <label class="text-xs text-[var(--text-color-secondary)]">Início</label>
<label for="pinicio">Início (HH:MM)</label> <DatePicker v-model="form.inicio" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
</FloatLabel> <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>
<div class="col-span-12 md:col-span-6"> <div v-if="formInicioHHMM && formFimHHMM && formFimHHMM <= formInicioHHMM" class="text-sm text-red-500">
<FloatLabel variant="on">
<InputText v-model="form.fim" class="w-full" inputId="pfim" placeholder="13:00" />
<label for="pfim">Fim (HH:MM)</label>
</FloatLabel>
</div>
<div v-if="isValidHHMM(form.inicio) && isValidHHMM(form.fim) && form.fim <= form.inicio" class="col-span-12 text-sm text-red-500">
O fim precisa ser maior que o início. O fim precisa ser maior que o início.
</div> </div>
<div class="col-span-12 text-600 text-xs"> <div class="text-[var(--text-color-secondary)] text-xs">
Se houver conflito com outra pausa, o sistema adiciona automaticamente apenas o trecho que não sobrepõe. Se houver conflito com outra pausa, o sistema adiciona apenas o trecho que não sobrepõe.
</div> </div>
</div> </div>
<template #footer> <template #footer>
<Button label="Cancelar" icon="pi pi-times" severity="secondary" outlined @click="dlg = false" /> <Button label="Cancelar" icon="pi pi-times" severity="secondary" outlined @click="dlg = false" />
<Button <Button label="Adicionar" icon="pi pi-check" :disabled="!formValid" @click="saveCustom" />
label="Adicionar"
icon="pi pi-check"
:disabled="!isValidHHMM(form.inicio) || !isValidHHMM(form.fim) || form.fim <= form.inicio"
@click="saveCustom"
/>
</template> </template>
</Dialog> </Dialog>
</div> </div>

View 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
}
}

View File

@@ -4,6 +4,7 @@ import { useTenantStore } from '@/stores/tenantStore'
/** /**
* --------------------------------------------------------- * ---------------------------------------------------------
* useRoleGuard() — RBAC puro (somente PAPEL do tenant) * useRoleGuard() — RBAC puro (somente PAPEL do tenant)
* + testMODE (modo operacional de teste)
* --------------------------------------------------------- * ---------------------------------------------------------
* *
* Objetivo: * Objetivo:
@@ -11,82 +12,133 @@ import { useTenantStore } from '@/stores/tenantStore'
* Aqui NÃO entra plano, módulos ou features pagas. * Aqui NÃO entra plano, módulos ou features pagas.
* *
* Fonte da verdade do papel (tenant role): * Fonte da verdade do papel (tenant role):
* - public.tenant_members.role → 'tenant_admin' | 'therapist' | 'patient' * - public.tenant_members.role
* - no frontend: tenantStore.membership.role (ou fallback tenantStore.activeRole) * → 'saas' | 'tenant_admin' | 'therapist' | 'patient'
* *
* O que este composable resolve: * ---------------------------------------------------------
* - "Esse papel pode ver/usar este elemento?" * testMODE — INTERRUPTOR OPERACIONAL
* Ex: * ---------------------------------------------------------
* - paciente não vê botão Configurações
* - therapist e tenant_admin veem
* *
* O que ele NÃO resolve (de propósito): * testMODE NÃO é regra de negócio.
* - liberar feature por plano (Free/Pro) * Ele serve para:
* - limitar módulos / recursos contratados * - 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: * COMPORTAMENTO:
* - entStore.can('feature_key')
* *
* Padrão recomendado (RBAC + Plano): * - TEST_MODE_ROLES = []
* Quando algo depende do PLANO e do PAPEL, combine no template: * → 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: * Interpretação:
* - Gate A (Plano): o tenant tem a feature liberada? * - Gate A → plano liberado
* - Gate B (Papel): o usuário, pelo papel, pode ver/usar isso? * - 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 () { export function useRoleGuard () {
const tenantStore = useTenantStore() const tenantStore = useTenantStore()
// Roles confirmados no seu banco (tenant_members.role)
const ROLES = Object.freeze({ const ROLES = Object.freeze({
ADMIN: 'tenant_admin', SAAS: 'saas',
ADMIN: 'clinic_admin',
SUPERVISOR: 'supervisor',
THERAPIST: 'therapist', THERAPIST: 'therapist',
PATIENT: 'patient' PATIENT: 'patient'
}) })
// Papel atual no tenant ativo
const role = computed(() => tenantStore.membership?.role ?? tenantStore.activeRole ?? null) const role = computed(() => tenantStore.membership?.role ?? tenantStore.activeRole ?? null)
// Opcional: útil se você quiser segurar render até carregar
const isReady = computed(() => !!role.value) const isReady = computed(() => !!role.value)
// Helpers semânticos const isSaas = computed(() => role.value === ROLES.SAAS)
const isTenantAdmin = computed(() => role.value === ROLES.ADMIN) const isTenantAdmin = computed(() => role.value === ROLES.ADMIN)
const isSupervisor = computed(() => role.value === ROLES.SUPERVISOR)
const isTherapist = computed(() => role.value === ROLES.THERAPIST) const isTherapist = computed(() => role.value === ROLES.THERAPIST)
const isPatient = computed(() => role.value === ROLES.PATIENT) 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({ const rbac = Object.freeze({
// Botões/telas de configuração do tenant 'testMODE': TEST_MODE_ROLES,
'settings.view': [ROLES.ADMIN, ROLES.THERAPIST],
// Perfil/conta (normalmente todos) 'settings.view': [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST],
'profile.view': [ROLES.ADMIN, ROLES.THERAPIST, ROLES.PATIENT], 'profile.view': [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST, ROLES.PATIENT],
'security.view': [ROLES.ADMIN, ROLES.SUPERVISOR, 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],
}) })
/**
* 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) { function canSee (key) {
const r = role.value const r = role.value
if (!r) return false if (!r) return false
@@ -97,19 +149,23 @@ export function useRoleGuard () {
return allowed.includes(r) return allowed.includes(r)
} }
function canSeeOrTest (key) {
return canSee(key) || canSee('testMODE')
}
return { return {
// estado
role, role,
isReady, isReady,
// constantes & helpers
ROLES, ROLES,
isSaas,
isTenantAdmin, isTenantAdmin,
isSupervisor,
isTherapist, isTherapist,
isPatient, isPatient,
isStaff, isStaff,
// API canSee,
canSee canSeeOrTest
} }
} }

View File

@@ -44,6 +44,7 @@ export function useUserSettingsPersistence() {
primary_color: patch.primary_color ?? layoutConfig.primary ?? 'noir', primary_color: patch.primary_color ?? layoutConfig.primary ?? 'noir',
surface_color: patch.surface_color ?? layoutConfig.surface ?? 'slate', surface_color: patch.surface_color ?? layoutConfig.surface ?? 'slate',
menu_mode: patch.menu_mode ?? layoutConfig.menuMode ?? 'static', menu_mode: patch.menu_mode ?? layoutConfig.menuMode ?? 'static',
layout_variant: patch.layout_variant ?? layoutConfig.variant ?? 'classic',
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
} }
@@ -83,6 +84,7 @@ export function useUserSettingsPersistence() {
primary_color: pendingPatch.value.primary_color ?? layoutConfig.primary, primary_color: pendingPatch.value.primary_color ?? layoutConfig.primary,
surface_color: pendingPatch.value.surface_color ?? layoutConfig.surface, surface_color: pendingPatch.value.surface_color ?? layoutConfig.surface,
menu_mode: pendingPatch.value.menu_mode ?? layoutConfig.menuMode, menu_mode: pendingPatch.value.menu_mode ?? layoutConfig.menuMode,
layout_variant: pendingPatch.value.layout_variant ?? layoutConfig.variant ?? 'classic',
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,15 @@
<!-- src/features/agenda/components/AgendaClinicMosaic.vue -->
<script setup> <script setup>
import { computed, ref, watch, nextTick } from 'vue' import { computed, ref, watch, nextTick } from 'vue'
import FullCalendar from '@fullcalendar/vue3' import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid' import timeGridPlugin from '@fullcalendar/timegrid'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction' import interactionPlugin from '@fullcalendar/interaction'
import ptBrLocale from '@fullcalendar/core/locales/pt-br'
const props = defineProps({ 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' mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
timezone: { type: String, default: 'America/Sao_Paulo' }, timezone: { type: String, default: 'America/Sao_Paulo' },
@@ -22,33 +25,63 @@ const props = defineProps({
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
// controla quantas colunas "visíveis" por vez (resto vai por scroll horizontal) // largura mínima de cada coluna (terapeutas)
minColWidth: { type: Number, default: 360 } 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) const emit = defineEmits([
// ✅ slotSelect = seleção de intervalo em uma coluna específica (criar evento) 'rangeChange',
// ✅ eventClick/Drop/Resize = ações em evento 'slotSelect',
const emit = defineEmits(['rangeChange', 'slotSelect', 'eventClick', 'eventDrop', 'eventResize']) 'eventClick',
'eventDrop',
'eventResize',
// ✅ debug
'debugColumn'
])
const calendarRefs = ref([]) const calendarRefs = ref([])
function setCalendarRef (el, idx) { function setCalendarRef (el, idx) {
if (!el) return if (!el) return
calendarRefs.value[idx] = el 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)) 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)) 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) { function apiAt (idx) {
const fc = calendarRefs.value[idx] const fc = calendarRefs.value[idx]
return fc?.getApi?.() return fc?.getApi?.()
} }
function forEachApi (fn) { function forEachApi (fn) {
for (let i = 0; i < calendarRefs.value.length; i++) { for (let i = 0; i < calendarRefs.value.length; i++) {
const api = apiAt(i) const api = apiAt(i)
@@ -59,18 +92,24 @@ function forEachApi (fn) {
function goToday () { forEachApi(api => api.today()) } function goToday () { forEachApi(api => api.today()) }
function prev () { forEachApi(api => api.prev()) } function prev () { forEachApi(api => api.prev()) }
function next () { forEachApi(api => api.next()) } function next () { forEachApi(api => api.next()) }
function gotoDate (date) {
function setView (v) { if (!date) return
const target = v === 'week' ? 'timeGridWeek' : 'timeGridDay' const dt = (date instanceof Date) ? new Date(date) : new Date(date)
forEachApi(api => api.changeView(target)) 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) { function eventsFor (ownerId) {
const list = props.events || [] 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 ---- // ---- range sync ----
@@ -82,35 +121,93 @@ function onDatesSet (arg) {
if (key === lastRangeKey) return if (key === lastRangeKey) return
lastRangeKey = key lastRangeKey = key
// dispara carregamento no pai
emit('rangeChange', { emit('rangeChange', {
start: arg.start, start: arg.start,
end: arg.end, end: arg.end,
startStr: arg.startStr, startStr: arg.startStr,
endStr: arg.endStr, 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 if (suppressSync) return
suppressSync = true suppressSync = true
const masterDate = arg.start const masterDate = arg.view?.currentStart || arg.start
forEachApi((api) => { forEachApi((api) => {
const cur = api.view?.currentStart const cur = api.view?.currentStart
if (!cur) return if (!cur || !masterDate) return
if (cur.getTime() !== masterDate.getTime()) api.gotoDate(masterDate) if (cur.getTime() !== masterDate.getTime()) api.gotoDate(masterDate)
}) })
// libera no próximo tick (evita loops)
Promise.resolve().then(() => { suppressSync = false }) Promise.resolve().then(() => { suppressSync = false })
} }
// Se trocar view, garante que todos estão no mesmo
watch(() => props.view, async () => { watch(() => props.view, async () => {
await nextTick() await nextTick()
setView(props.view) 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> </script>
<template> <template>
@@ -119,74 +216,105 @@ watch(() => props.view, async () => {
Carregando agenda da clínica Carregando agenda da clínica
</div> </div>
<!-- Mosaic --> <div class="mosaic-shell">
<div <!-- Coluna fixa: Clínica -->
class="p-2 md:p-3 overflow-x-auto" <div v-if="clinicColumn" class="mosaic-fixed">
:style="{ display: 'grid', gridAutoFlow: 'column', gridAutoColumns: `minmax(${minColWidth}px, 1fr)`, gap: '12px' }" <div class="mosaic-col">
> <div class="mosaic-col-head cursor-pointer" @click="emitDebug(clinicColumn)" title="Debug desta coluna">
<div <div class="min-w-0">
v-for="(p, idx) in staff" <div class="font-semibold truncate">{{ clinicColumn.title }}</div>
:key="p.id" <div class="text-xs opacity-70 truncate">{{ colSubtitle(clinicColumn) }}</div>
class="rounded-[1.25rem] border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)] overflow-hidden" </div>
> <div class="text-xs opacity-70 whitespace-nowrap">
<!-- Header da coluna --> {{ mode === 'full_24h' ? '24h' : 'Horário' }}
<div class="p-3 border-b border-[var(--surface-border)] flex items-center justify-between gap-2"> </div>
<div class="min-w-0">
<div class="font-semibold truncate">{{ p.title }}</div>
<div class="text-xs opacity-70 truncate">Visão diária operacional</div>
</div> </div>
<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>
</div>
<div class="p-2"> <!-- Área rolável: Terapeutas -->
<FullCalendar <div class="mosaic-scroll">
:ref="(el) => setCalendarRef(el, idx)" <div
:options="{ class="mosaic-grid"
plugins: [timeGridPlugin, interactionPlugin], :style="{ gridAutoColumns: `minmax(${minColWidth}px, 1fr)` }"
initialView: initialView, >
timeZone: timezone, <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, <div class="p-2">
nowIndicator: true, <FullCalendar
:ref="(el) => setCalendarRef(el, (clinicColumn ? (sIdx + 1) : sIdx))"
editable: true, :options="buildFcOptions(p.id)"
/>
// ✅ seleção para criar evento (por coluna) </div>
selectable: true, </div>
selectMirror: true,
select: (selection) => {
emit('slotSelect', {
ownerId: p.id,
start: selection.start,
end: selection.end,
startStr: selection.startStr,
endStr: selection.endStr,
jsEvent: selection.jsEvent || null,
viewType: selection.view?.type || initialView
})
},
slotDuration: slotDuration,
slotMinTime: computedSlotMinTime,
slotMaxTime: computedSlotMaxTime,
height: 'auto',
expandRows: true,
allDaySlot: false,
events: eventsFor(p.id),
datesSet: onDatesSet,
eventClick: (info) => emit('eventClick', info),
eventDrop: (info) => emit('eventDrop', info),
eventResize: (info) => emit('eventResize', info)
}"
/>
</div> </div>
</div> </div>
</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

View File

@@ -1,8 +1,5 @@
<!-- src/features/agenda/components/AgendaRightPanel.vue --> <!-- src/features/agenda/components/AgendaRightPanel.vue -->
<script setup> <script setup>
import Card from 'primevue/card'
import Divider from 'primevue/divider'
import Button from 'primevue/button'
const props = defineProps({ const props = defineProps({
title: { type: String, default: 'Painel' }, title: { type: String, default: 'Painel' },

View File

@@ -1,13 +1,7 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import Button from 'primevue/button'
import SelectButton from 'primevue/selectbutton'
import ToggleButton from 'primevue/togglebutton' 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({ const props = defineProps({
title: { type: String, default: 'Agenda' }, title: { type: String, default: 'Agenda' },

View 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>

View File

@@ -2,13 +2,6 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' 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({ const props = defineProps({
title: { type: String, default: 'Agenda' }, title: { type: String, default: 'Agenda' },

View File

@@ -2,10 +2,6 @@
<script setup> <script setup>
import { computed } from 'vue' 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({ const props = defineProps({
stats: { type: Object, default: () => ({}) } stats: { type: Object, default: () => ({}) }

View 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
}
}

View 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

View 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>

View 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
}

View File

@@ -1,20 +1,52 @@
// src/features/agenda/services/agendaMappers.js // src/features/agenda/services/agendaMappers.js
export function mapAgendaEventosToCalendarEvents (rows) { export function mapAgendaEventosToCalendarEvents (rows) {
return (rows || []).map((r) => ({ return (rows || []).map((r) => {
id: r.id, // 🔥 regra importante:
title: r.titulo || tituloFallback(r.tipo), // prioridade: owner_id
start: r.inicio_em, // fallback: terapeuta_id
end: r.fim_em, const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
extendedProps: {
tipo: r.tipo, const commitment = r.determined_commitments
status: r.status, const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
paciente_id: r.paciente_id, const txtColor = commitment?.text_color || undefined
terapeuta_id: r.terapeuta_id,
observacoes: r.observacoes, return {
owner_id: r.owner_id 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()) { export function buildNextSessions (rows, now = new Date()) {
@@ -98,21 +130,52 @@ export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd)
} }
export function mapAgendaEventosToClinicResourceEvents (rows) { export function mapAgendaEventosToClinicResourceEvents (rows) {
return (rows || []).map((r) => ({ return (rows || []).map((r) => {
id: r.id, const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
title: r.titulo || tituloFallback(r.tipo),
start: r.inicio_em, const commitment = r.determined_commitments
end: r.fim_em, const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
resourceId: r.owner_id, // 🔥 coluna = dono da agenda (profissional) const txtColor = commitment?.text_color || undefined
extendedProps: {
tipo: r.tipo, return {
status: r.status, id: r.id,
paciente_id: r.paciente_id, title: r.titulo || tituloFallback(r.tipo),
terapeuta_id: r.terapeuta_id, start: r.inicio_em,
observacoes: r.observacoes, end: r.fim_em,
owner_id: r.owner_id
// 🔥 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) { function normalizeWeekday (value) {

View File

@@ -28,7 +28,9 @@ export async function getMyAgendaSettings () {
.from('agenda_configuracoes') .from('agenda_configuracoes')
.select('*') .select('*')
.eq('owner_id', uid) .eq('owner_id', uid)
.single() .order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (error) throw error if (error) throw error
return data return data
@@ -49,7 +51,7 @@ export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantId
const { data, error } = await supabase const { data, error } = await supabase
.from('agenda_eventos') .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('tenant_id', tenantId)
.eq('owner_id', uid) .eq('owner_id', uid)
.gte('inicio_em', startISO) .gte('inicio_em', startISO)
@@ -57,7 +59,27 @@ export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantId
.order('inicio_em', { ascending: true }) .order('inicio_em', { ascending: true })
if (error) throw error 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 const { data, error } = await supabase
.from('agenda_eventos') .from('agenda_eventos')
.select('*') .select('*, determined_commitments!determined_commitment_id(id, bg_color, text_color)')
.eq('tenant_id', tenantId) .eq('tenant_id', tenantId)
.in('owner_id', safeOwnerIds) .in('owner_id', safeOwnerIds)
.gte('inicio_em', startISO) .gte('inicio_em', startISO)

View 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>

View File

@@ -1,139 +1,112 @@
<template> <template>
<div class="p-4"> <Toast />
<!-- 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 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> <!-- Sentinel para detecção de sticky -->
<!-- título --> <div ref="headerSentinelRef" class="pat-sentinel" />
<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>
<div class="min-w-0"> <!-- Hero Header sticky -->
<div class="flex items-center gap-2"> <div ref="headerEl" class="pat-hero mx-3 md:mx-5 mb-4" :class="{ 'pat-hero--stuck': headerStuck }">
<div class="text-xl font-semibold leading-none">Pacientes</div> <!-- Blobs decorativos -->
<Tag :value="`${kpis.total}`" severity="secondary" /> <div class="pat-hero__blobs" aria-hidden="true">
</div> <div class="pat-hero__blob pat-hero__blob--1" />
<div class="mt-1 text-sm text-color-secondary"> <div class="pat-hero__blob pat-hero__blob--2" />
Lista de pacientes cadastrados. Filtre por status, tags e grupos. <div class="pat-hero__blob pat-hero__blob--3" />
</div> </div>
</div>
</div>
<!-- KPIs como filtros --> <!-- Linha 1: brand + controles -->
<div class="mt-4 flex flex-wrap gap-2"> <div class="pat-hero__row1">
<Button <div class="pat-hero__brand">
type="button" <div class="pat-hero__icon">
class="!rounded-full" <i class="pi pi-users text-lg" />
:outlined="filters.status !== 'Todos'" </div>
severity="secondary" <div class="min-w-0">
@click="setStatus('Todos')" <div class="flex items-center gap-2">
> <div class="pat-hero__title">Pacientes</div>
<span class="flex items-center gap-2"> <Tag :value="`${kpis.total}`" severity="secondary" />
<i class="pi pi-users" /> </div>
Total: <b>{{ kpis.total }}</b> <div class="pat-hero__sub">Lista de pacientes cadastrados. Filtre por status, tags e grupos.</div>
</span> </div>
</Button> </div>
<Button <!-- Controles desktop (1200px) -->
type="button" <div class="hidden xl:flex items-center gap-2 shrink-0">
class="!rounded-full" <Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchAll" />
:outlined="filters.status !== 'Ativo'" <SplitButton label="Cadastrar" icon="pi pi-user-plus" :model="createMenu" class="rounded-full" @click="goCreateFull" />
:severity="filters.status === 'Ativo' ? 'success' : 'secondary'" </div>
@click="setStatus('Ativo')"
>
<span class="flex items-center gap-2">
<i class="pi pi-user-plus" />
Ativos: <b>{{ kpis.active }}</b>
</span>
</Button>
<Button <!-- Menu mobile (<1200px) -->
type="button" <div class="flex xl:hidden items-center shrink-0">
class="!rounded-full" <Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => patMobileMenuRef.toggle(e)" />
:outlined="filters.status !== 'Inativo'" <Menu ref="patMobileMenuRef" :model="patMobileMenuItems" :popup="true" />
:severity="filters.status === 'Inativo' ? 'danger' : 'secondary'" </div>
@click="setStatus('Inativo')" </div>
>
<span class="flex items-center gap-2">
<i class="pi pi-user-minus" />
Inativos: <b>{{ kpis.inactive }}</b>
</span>
</Button>
<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"> <!-- Divisor -->
<i class="pi pi-calendar" /> <Divider class="pat-hero__divider my-2" />
Último atendimento: <b class="text-[var(--text-color)]">{{ prettyDate(kpis.latestLastAttended) }}</b>
</span>
</div>
</div>
<!-- ações --> <!-- Linha 2: KPI filtros (oculta no mobile) -->
<div class="flex flex-col sm:flex-row gap-2 sm:items-center"> <div class="pat-hero__row2">
<span class="p-input-icon-left w-full sm:w-[360px]"> <div class="flex flex-wrap items-center gap-2">
<FloatLabel variant="on"> <Button
<IconField> type="button"
<InputIcon class="pi pi-search" /> size="small"
<InputText class="!rounded-full"
v-model="filters.search" :outlined="filters.status !== 'Todos'"
class="w-full" severity="secondary"
placeholder="Buscar por nome, e-mail ou telefone…" @click="setStatus('Todos')"
@input="onFilterChangedDebounced" >
/> <span class="flex items-center gap-1.5">
</IconField> <i class="pi pi-users text-xs" />
</FloatLabel> Total: <b>{{ kpis.total }}</b>
</span> </span>
</Button>
<Button <Button
label="Atualizar" type="button"
icon="pi pi-refresh" size="small"
severity="secondary" class="!rounded-full"
outlined :outlined="filters.status !== 'Ativo'"
:loading="loading" :severity="filters.status === 'Ativo' ? 'success' : 'secondary'"
@click="fetchAll" @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 <Button
label="Cadastrar" type="button"
icon="pi pi-user-plus" size="small"
:model="createMenu" class="!rounded-full"
@click="goCreateFull" :outlined="filters.status !== 'Inativo'"
/> :severity="filters.status === 'Inativo' ? 'danger' : 'secondary'"
</div> @click="setStatus('Inativo')"
</div> >
<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) --> <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">
<div v-if="hasActiveFilters" class="relative mt-4 flex flex-wrap items-center gap-2"> <i class="pi pi-calendar" />
<span class="text-xs text-color-secondary">Filtros:</span> Ú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" /> <!-- 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">
<Tag v-if="filters.groupId" value="Grupo selecionado" severity="secondary" /> <span class="text-xs text-color-secondary">Filtros:</span>
<Tag v-if="filters.tagId" value="Tag selecionada" severity="secondary" /> <Tag v-if="filters.status && filters.status !== 'Todos'" :value="`Status: ${filters.status}`" severity="secondary" />
<Tag v-if="filters.createdFrom" value="Data inicial" severity="secondary" /> <Tag v-if="filters.groupId" value="Grupo selecionado" severity="secondary" />
<Tag v-if="filters.createdTo" value="Data final" severity="secondary" /> <Tag v-if="filters.tagId" value="Tag selecionada" severity="secondary" />
<Tag v-if="filters.createdFrom" value="Data inicial" severity="secondary" />
<Button <Tag v-if="filters.createdTo" value="Data final" severity="secondary" />
label="Limpar" <Button label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined size="small" class="!rounded-full" @click="clearAllFilters" />
icon="pi pi-filter-slash" </div>
severity="danger"
outlined
size="small"
class="!rounded-full"
@click="clearAllFilters"
/>
</div>
</div>
</div>
<!-- KPI Cards <!-- KPI Cards
@@ -209,7 +182,7 @@
</div> --> </div> -->
<!-- TABS (placeholder para evoluir depois) --> <!-- TABS (placeholder para evoluir depois) -->
<Tabs value="pacientes" class="mt-3"> <Tabs value="pacientes" class="px-3 md:px-5 mb-5">
<TabList> <TabList>
<Tab value="pacientes"><i class="pi pi-users mr-2" />Pacientes</Tab> <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> <Tab value="espera"><i class="pi pi-hourglass mr-2" />Lista de espera</Tab>
@@ -403,163 +376,170 @@
</Transition> </Transition>
</div> </div>
<!-- Table --> <!-- Table desktop (md+) -->
<DataTable <div class="hidden md:block">
:value="filteredRows" <DataTable
dataKey="id" :value="filteredRows"
:loading="loading" dataKey="id"
paginator :loading="loading"
:rows="15" paginator
:rowsPerPageOptions="[10, 15, 25, 50]" :rows="15"
stripedRows :rowsPerPageOptions="[10, 15, 25, 50]"
responsiveLayout="scroll" stripedRows
scrollable scrollable
scrollHeight="flex" scrollHeight="flex"
sortMode="single" sortMode="single"
:sortField="sort.field" :sortField="sort.field"
:sortOrder="sort.order" :sortOrder="sort.order"
@sort="onSort" @sort="onSort"
> >
<template #empty> <template #empty>
<div class="py-10 text-center"> <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)]"> <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" /> <i class="pi pi-search text-xl" />
</div> </div>
<div class="font-semibold">Nenhum paciente encontrado</div> <div class="font-semibold">Nenhum paciente encontrado</div>
<div class="mt-1 text-sm text-color-secondary"> <div class="mt-1 text-sm text-color-secondary">Tente limpar filtros ou mudar o termo de busca.</div>
Tente limpar filtros ou mudar o termo de busca. <div class="mt-4 flex justify-center gap-2">
</div> <Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
<div class="mt-4 flex justify-center gap-2"> <Button icon="pi pi-user-plus" label="Cadastrar paciente" @click="goCreateFull" />
<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>
</div> </div>
</div> </div>
</template> </template>
</Column>
<Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;"> <Column :key="'col-paciente'" field="nome_completo" header="Paciente" v-if="isColVisible('paciente')" sortable>
<template #body="{ data }"> <template #body="{ data }">
<Tag <div class="flex items-center gap-3">
:value="data.status" <Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="square" size="large" />
:severity="data.status === 'Ativo' ? 'success' : 'danger'" <Avatar v-else :label="initials(data.nome_completo)" shape="square" size="large" />
/> <div class="min-w-0">
</template> <div class="font-medium truncate">{{ data.nome_completo }}</div>
</Column> <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'"> <Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;">
<template #body="{ data }"> <template #body="{ data }">
<div class="text-sm leading-tight"> <Tag :value="data.status" :severity="data.status === 'Ativo' ? 'success' : 'danger'" />
<div class="font-medium"> </template>
{{ fmtPhoneBR(data.telefone) }} </Column>
</div>
<div class="text-xs text-color-secondary">
{{ data.email_principal || '—' }}
</div>
</div>
</template>
</Column>
<Column field="email_principal" header="Email" style="width: 18rem;" v-if="isColVisible('email')" :key="'col-email'"> <Column header="Telefone" style="width: 16rem;" v-if="isColVisible('telefone')" :key="'col-telefone'">
<template #body="{ data }"> <template #body="{ data }">
<span class="text-color-secondary">{{ data.email_principal || '—' }}</span> <div class="text-sm leading-tight">
</template> <div class="font-medium">{{ fmtPhoneBR(data.telefone) }}</div>
</Column> <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;"> <Column field="email_principal" header="Email" style="width: 18rem;" v-if="isColVisible('email')" :key="'col-email'">
<template #body="{ data }">{{ data.last_attended_at || '—' }}</template> <template #body="{ data }">
</Column> <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;"> <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.created_at || '—' }}</template> <template #body="{ data }">{{ data.last_attended_at || '—' }}</template>
</Column> </Column>
<Column header="Grupos" style="min-width: 14rem;" v-if="isColVisible('grupos')" :key="'col-grupos'"> <Column field="created_at" header="Cadastrado em" v-if="isColVisible('created_at')" :key="'col-created_at'" sortable style="width: 12rem;">
<template #body="{ data }"> <template #body="{ data }">{{ data.created_at || '—' }}</template>
<div v-if="!(data.groups || []).length" class="text-color-secondary"></div> </Column>
<div v-else class="flex flex-wrap gap-2">
<Tag <Column header="Grupos" style="min-width: 14rem;" v-if="isColVisible('grupos')" :key="'col-grupos'">
v-for="g in data.groups" <template #body="{ data }">
:key="g.id" <div v-if="!(data.groups || []).length" class="text-color-secondary"></div>
:value="g.name" <div v-else class="flex flex-wrap gap-2">
:style="chipStyle(g.color)" <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> </div>
</template>
</Column>
<Column header="Tags" style="min-width: 14rem;" v-if="isColVisible('tags')" :key="'col-tags'"> <!-- Grupos + Tags -->
<template #body="{ data }"> <div v-if="(pat.groups || []).length || (pat.tags || []).length" class="mt-3 flex flex-wrap gap-1.5">
<div v-if="!(data.tags || []).length" class="text-color-secondary"></div> <Tag v-for="g in pat.groups" :key="g.id" :value="g.name" :style="chipStyle(g.color)" />
<div v-else class="flex flex-wrap gap-2"> <Tag v-for="t in pat.tags" :key="t.id" :value="t.name" :style="chipStyle(t.color)" />
<Tag
v-for="t in data.tags"
:key="t.id"
:value="t.name"
:style="chipStyle(t.color)"
/>
</div> </div>
</template>
</Column>
<Column <!-- Ações -->
:key="'col-acoes'" <div class="mt-3 flex gap-2 justify-end">
header="Ações" <Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(pat)" />
style="width: 16rem;" <Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="goEdit(pat)" />
frozen <Button icon="pi pi-trash" severity="danger" outlined size="small" @click="confirmDeleteOne(pat)" />
alignFrozen="right" </div>
> </div>
<template #body="{ data }"> </div>
<div class="flex gap-2 justify-end"> </div>
<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 class="mt-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between text-xs text-color-secondary"> <div class="mt-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between text-xs text-color-secondary">
<div> <div>
Exibindo <b class="text-[var(--text-color)]">{{ filteredRows.length }}</b> de Exibindo <b class="text-[var(--text-color)]">{{ filteredRows.length }}</b> de
<b class="text-[var(--text-color)]">{{ patients.length }}</b> pacientes. <b class="text-[var(--text-color)]">{{ patients.length }}</b> pacientes.
<span v-if="hasActiveFilters"> (filtrado)</span> <span v-if="hasActiveFilters"> (filtrado)</span>
</div> </div>
<div class="hidden md:block"> <div class="hidden md:block">
Dica: clique em Ativos/Inativos no topo para filtrar rápido. Dica: clique em Ativos/Inativos" no topo para filtrar rápido.
</div> </div>
</div> </div>
</TabPanel> </TabPanel>
@@ -604,18 +584,19 @@
@close="closeProntuario" @close="closeProntuario"
/> />
<ConfirmDialog /> <ConfirmDialog />
</div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm' import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
import MultiSelect from 'primevue/multiselect' import MultiSelect from 'primevue/multiselect'
import Popover from 'primevue/popover' import Popover from 'primevue/popover'
import Menu from 'primevue/menu'
import ProgressSpinner from 'primevue/progressspinner'
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue' import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
@@ -642,6 +623,22 @@ function getAreaBase() {
const toast = useToast() const toast = useToast()
const confirm = useConfirm() 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 uid = ref(null)
const loading = ref(false) const loading = ref(false)
@@ -681,7 +678,7 @@ const lockedKeys = computed(() =>
columnCatalogAll.filter(c => c.locked).map(c => c.key) 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 visibleKeys = computed(() => {
const set = new Set(selectedColumns.value || []) const set = new Set(selectedColumns.value || [])
lockedKeys.value.forEach(k => set.add(k)) lockedKeys.value.forEach(k => set.add(k))
@@ -751,10 +748,18 @@ const createMenu = [
] ]
onMounted(async () => { 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 loadUser()
await fetchAll() await fetchAll()
}) })
onBeforeUnmount(() => { _observer?.disconnect() })
function fmtPhoneBR(v) { function fmtPhoneBR(v) {
const d = String(v ?? '').replace(/\D/g, '') const d = String(v ?? '').replace(/\D/g, '')
if (!d) return '—' if (!d) return '—'
@@ -815,37 +820,62 @@ function onQuickCreated(row) {
// ----------------------------- // -----------------------------
// Navigation (shared feature) // Navigation (shared feature)
// ----------------------------- // -----------------------------
function goGroups() { function getAreaKey () {
router.push(`${getAreaBase()}/patients/grupos`) const seg = String(route.path || '').split('/')[1]
return seg === 'therapist' ? 'therapist' : 'admin'
} }
function goCreateFull() { function getPatientsRoutes () {
router.push(`${getAreaBase()}/patients/cadastro`) 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 if (!row?.id) return
router.push(`${getAreaBase()}/patients/cadastro/${row.id}`) const r = getPatientsRoutes()
} return safePush({ name: r.editName, params: { id: row.id } }, r.editPath(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
} }
// ----------------------------- // -----------------------------
@@ -1188,7 +1218,7 @@ function confirmDeleteOne(row) {
const nome = row?.nome_completo || 'este paciente' const nome = row?.nome_completo || 'este paciente'
confirm.require({ confirm.require({
header: 'Excluir paciente', header: 'Excluir paciente',
message: `Tem certeza que deseja excluir “${nome}?`, message: `Tem certeza que deseja excluir “${nome}"?`,
icon: 'pi pi-exclamation-triangle', icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir', acceptLabel: 'Excluir',
rejectLabel: 'Cancelar', rejectLabel: 'Cancelar',
@@ -1237,17 +1267,65 @@ function updateKpis() {
</script> </script>
<style scoped> <style scoped>
.kpi-card :deep(.p-card-body) { /* ── Hero Header ─────────────────────────────────── */
padding: 1rem; .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, /* Blobs */
.fade-leave-active { .pat-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
transition: opacity 0.15s ease; .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, /* KPI card */
.fade-leave-to { .kpi-card :deep(.p-card-body) { padding: 1rem; }
opacity: 0;
} /* Fade */
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style> </style>

View File

@@ -1,14 +1,16 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useRoleGuard } from '@/composables/useRoleGuard'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm' import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore' import { useTenantStore } from '@/stores/tenantStore'
const { canSee } = useRoleGuard()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
@@ -16,8 +18,17 @@ const confirm = useConfirm()
const tenantStore = useTenantStore() 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 () { async function getCurrentTenantId () {
// ajuste para o nome real no seu store
return tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id return tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
} }
@@ -105,6 +116,48 @@ onBeforeUnmount(() => {
const patientId = computed(() => String(route.params?.id || '').trim() || null) const patientId = computed(() => String(route.params?.id || '').trim() || null)
const isEdit = computed(() => !!patientId.value) 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) // Avatar state (TEM que existir no setup)
// ------------------------------------------------------ // ------------------------------------------------------
@@ -112,7 +165,7 @@ const avatarFile = ref(null)
const avatarPreviewUrl = ref('') const avatarPreviewUrl = ref('')
const avatarUploading = ref(false) const avatarUploading = ref(false)
const AVATAR_BUCKET = 'avatars' // confirme o nome do bucket no Supabase const AVATAR_BUCKET = 'avatars'
function isImageFile (file) { function isImageFile (file) {
return !!file && typeof file.type === 'string' && file.type.startsWith('image/') 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 }) 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 }) { async function uploadAvatarToStorage ({ ownerId, patientId, file }) {
if (!ownerId) throw new Error('ownerId ausente.') if (!ownerId) throw new Error('ownerId ausente.')
if (!patientId) throw new Error('patientId ausente.') if (!patientId) throw new Error('patientId ausente.')
@@ -171,15 +242,12 @@ async function uploadAvatarToStorage ({ ownerId, patientId, file }) {
if (upErr) throw upErr if (upErr) throw upErr
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path) const readableUrl = await getReadableAvatarUrl(path)
const publicUrl = pub?.publicUrl || null return { publicUrl: readableUrl, path }
if (!publicUrl) throw new Error('Não consegui gerar URL pública do avatar.')
return { publicUrl, path }
} }
async function maybeUploadAvatar (ownerId, id) { async function maybeUploadAvatar (ownerId, id) {
if (!avatarFile.value) return if (!avatarFile.value) return null
avatarUploading.value = true avatarUploading.value = true
try { try {
@@ -189,45 +257,40 @@ async function maybeUploadAvatar (ownerId, id) {
file: avatarFile.value file: avatarFile.value
}) })
// 1) atualiza UI IMEDIATAMENTE (não deixa “sumir”) // UI
form.value.avatar_url = publicUrl form.value.avatar_url = publicUrl
// 2) grava no banco
await updatePatient(id, { avatar_url: publicUrl })
// 3) limpa o arquivo selecionado
avatarFile.value = null 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() revokePreview()
avatarPreviewUrl.value = publicUrl avatarPreviewUrl.value = publicUrl
// DB
await updatePatient(id, { avatar_url: publicUrl })
return publicUrl
} catch (e) { } catch (e) {
toast.add({ toast.add({
severity: 'warn', severity: 'warn',
summary: 'Avatar', summary: 'Avatar',
detail: e?.message || 'Falha ao enviar avatar.', detail: e?.message || 'Falha ao enviar avatar.',
life: 4000 life: 4500
}) })
return null
} finally { } finally {
avatarUploading.value = false avatarUploading.value = false
} }
} }
// ------------------------------------------------------ // ------------------------------------------------------
// Form state (PT-BR) // Form state
// ------------------------------------------------------ // ------------------------------------------------------
function resetForm () { function resetForm () {
return { return {
// Sessão 1 — pessoais
nome_completo: '', nome_completo: '',
telefone: '', telefone: '',
email_principal: '', email_principal: '',
email_alternativo: '', email_alternativo: '',
telefone_alternativo: '', telefone_alternativo: '',
data_nascimento: '', // ✅ SEMPRE DD-MM-AAAA (hífen) data_nascimento: '',
genero: '', genero: '',
estado_civil: '', estado_civil: '',
cpf: '', cpf: '',
@@ -237,7 +300,6 @@ function resetForm () {
onde_nos_conheceu: '', onde_nos_conheceu: '',
encaminhado_por: '', encaminhado_por: '',
// Sessão 2 — endereço
cep: '', cep: '',
pais: 'Brasil', pais: 'Brasil',
cidade: '', cidade: '',
@@ -247,24 +309,19 @@ function resetForm () {
bairro: '', bairro: '',
complemento: '', complemento: '',
// Sessão 3 — adicionais
escolaridade: '', escolaridade: '',
profissao: '', profissao: '',
nome_parente: '', nome_parente: '',
grau_parentesco: '', grau_parentesco: '',
telefone_parente: '', telefone_parente: '',
// Sessão 4 — responsável
nome_responsavel: '', nome_responsavel: '',
cpf_responsavel: '', cpf_responsavel: '',
telefone_responsavel: '', telefone_responsavel: '',
observacao_responsavel: '', observacao_responsavel: '',
cobranca_no_responsavel: false, cobranca_no_responsavel: false,
// Sessão 5 — internos
notas_internas: '', notas_internas: '',
// Avatar
avatar_url: '' avatar_url: ''
} }
} }
@@ -331,7 +388,6 @@ function toISODateFromDDMMYYYY (s) {
return `${yyyy}-${mm}-${dd}` return `${yyyy}-${mm}-${dd}`
} }
// banco (YYYY-MM-DD ou ISO) -> form (DD-MM-YYYY)
function isoToDDMMYYYY (value) { function isoToDDMMYYYY (value) {
if (!value) return '' if (!value) return ''
const s = String(value).trim() const s = String(value).trim()
@@ -407,7 +463,6 @@ function mapDbToForm (p) {
cobranca_no_responsavel: !!p.cobranca_no_responsavel, cobranca_no_responsavel: !!p.cobranca_no_responsavel,
notas_internas: p.notas_internas ?? '', notas_internas: p.notas_internas ?? '',
avatar_url: p.avatar_url ?? '' avatar_url: p.avatar_url ?? ''
} }
} }
@@ -430,8 +485,6 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
'owner_id', 'owner_id',
'tenant_id', 'tenant_id',
'responsible_member_id', 'responsible_member_id',
// Sessão 1
'nome_completo', 'nome_completo',
'telefone', 'telefone',
'email_principal', 'email_principal',
@@ -446,8 +499,6 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
'observacoes', 'observacoes',
'onde_nos_conheceu', 'onde_nos_conheceu',
'encaminhado_por', 'encaminhado_por',
// Sessão 2
'pais', 'pais',
'cep', 'cep',
'cidade', 'cidade',
@@ -456,25 +507,17 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
'numero', 'numero',
'bairro', 'bairro',
'complemento', 'complemento',
// Sessão 3
'escolaridade', 'escolaridade',
'profissao', 'profissao',
'nome_parente', 'nome_parente',
'grau_parentesco', 'grau_parentesco',
'telefone_parente', 'telefone_parente',
// Sessão 4
'nome_responsavel', 'nome_responsavel',
'cpf_responsavel', 'cpf_responsavel',
'telefone_responsavel', 'telefone_responsavel',
'observacao_responsavel', 'observacao_responsavel',
'cobranca_no_responsavel', 'cobranca_no_responsavel',
// Sessão 5
'notas_internas', 'notas_internas',
// Avatar
'avatar_url' 'avatar_url'
]) ])
@@ -523,11 +566,10 @@ function sanitizePayload (raw, ownerId) {
cobranca_no_responsavel: !!raw.cobranca_no_responsavel, cobranca_no_responsavel: !!raw.cobranca_no_responsavel,
notas_internas: raw.notas_internas || null, notas_internas: raw.notas_internas || null,
avatar_url: raw.avatar_url || null avatar_url: raw.avatar_url || null
} }
// strings vazias -> null // strings vazias -> null e trim
Object.keys(payload).forEach(k => { Object.keys(payload).forEach(k => {
if (payload[k] === '') payload[k] = null if (payload[k] === '') payload[k] = null
if (typeof payload[k] === 'string') { 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.cpf = payload.cpf ? digitsOnly(payload.cpf) : null
payload.rg = payload.rg ? digitsOnly(payload.rg) : null payload.rg = payload.rg ? digitsOnly(payload.rg) : null
payload.cpf_responsavel = payload.cpf_responsavel ? digitsOnly(payload.cpf_responsavel) : 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 = payload.telefone ? digitsOnly(payload.telefone) : null
payload.telefone_alternativo = payload.telefone_alternativo ? digitsOnly(payload.telefone_alternativo) : 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_parente = payload.telefone_parente ? digitsOnly(payload.telefone_parente) : null
payload.telefone_responsavel = payload.telefone_responsavel ? digitsOnly(payload.telefone_responsavel) : 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 payload.data_nascimento = payload.data_nascimento
? (toISODateFromDDMMYYYY(payload.data_nascimento) || null) ? (toISODateFromDDMMYYYY(payload.data_nascimento) || null)
: null : null
// filtra
const filtrado = {} const filtrado = {}
Object.keys(payload).forEach(k => { Object.keys(payload).forEach(k => {
if (PACIENTES_COLUNAS_PERMITIDAS.has(k)) filtrado[k] = payload[k] if (PACIENTES_COLUNAS_PERMITIDAS.has(k)) filtrado[k] = payload[k]
@@ -565,11 +603,7 @@ function sanitizePayload (raw, ownerId) {
// Supabase: lists / get / relations // Supabase: lists / get / relations
// ------------------------------------------------------ // ------------------------------------------------------
async function listGroups () { async function listGroups () {
const probe = await supabase const probe = await supabase.from('patient_groups').select('*').limit(1)
.from('patient_groups')
.select('*')
.limit(1)
if (probe.error) throw probe.error if (probe.error) throw probe.error
const row = probe.data?.[0] || {} const row = probe.data?.[0] || {}
@@ -582,13 +616,8 @@ async function listGroups () {
.select('id,nome,descricao,cor,is_system,is_active') .select('id,nome,descricao,cor,is_system,is_active')
.eq('is_active', true) .eq('is_active', true)
.order('nome', { ascending: true }) .order('nome', { ascending: true })
if (error) throw error if (error) throw error
return (data || []).map(g => ({ return (data || []).map(g => ({ ...g, name: g.nome, color: g.cor }))
...g,
name: g.nome,
color: g.cor
}))
} }
if (hasEN) { if (hasEN) {
@@ -597,93 +626,42 @@ async function listGroups () {
.select('id,name,description,color,is_system,is_active') .select('id,name,description,color,is_system,is_active')
.eq('is_active', true) .eq('is_active', true)
.order('name', { ascending: true }) .order('name', { ascending: true })
if (error) throw error if (error) throw error
return (data || []).map(g => ({ return (data || []).map(g => ({ ...g, nome: g.name, cor: g.color }))
...g,
nome: g.name,
cor: g.color
}))
} }
const { data, error } = await supabase const { data, error } = await supabase.from('patient_groups').select('*').order('id', { ascending: true })
.from('patient_groups')
.select('*')
.order('id', { ascending: true })
if (error) throw error if (error) throw error
return data || [] return data || []
} }
async function listTags () { 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 if (probe.error) throw probe.error
const row = probe.data?.[0] || {} const row = probe.data?.[0] || {}
const hasEN = ('name' in row) || ('color' in row) const hasEN = ('name' in row) || ('color' in row)
const hasPT = ('nome' in row) || ('cor' 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) { if (hasEN) {
const { data, error } = await supabase const { data, error } = await supabase.from('patient_tags').select('id,name,color').order('name', { ascending: true })
.from('patient_tags')
.select('id,name,color')
.order('name', { ascending: true })
if (error) throw error if (error) throw error
return data || [] return data || []
} }
if (hasPT) { if (hasPT) {
const { data, error } = await supabase const { data, error } = await supabase.from('patient_tags').select('id,nome,cor').order('nome', { ascending: true })
.from('patient_tags')
.select('id,nome,cor')
.order('nome', { ascending: true })
if (error) throw error 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 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) { async function getPatientById (id) {
const { data, error } = await supabase const { data, error } = await supabase.from('patients').select('*').eq('id', id).single()
.from('patients')
.select('*')
.eq('id', id)
.single()
if (error) throw error if (error) throw error
return data return data
} }
@@ -708,11 +686,7 @@ async function getPatientRelations (id) {
} }
async function createPatient (payload) { async function createPatient (payload) {
const { data, error } = await supabase const { data, error } = await supabase.from('patients').insert(payload).select('id').single()
.from('patients')
.insert(payload)
.select('id')
.single()
if (error) throw error if (error) throw error
return data return data
} }
@@ -720,17 +694,14 @@ async function createPatient (payload) {
async function updatePatient (id, payload) { async function updatePatient (id, payload) {
const { error } = await supabase const { error } = await supabase
.from('patients') .from('patients')
.update({ .update({ ...payload, updated_at: new Date().toISOString() })
...payload,
updated_at: new Date().toISOString()
})
.eq('id', id) .eq('id', id)
if (error) throw error if (error) throw error
} }
// ------------------------------------------------------ // ------------------------------------------------------
// Relations // Relations state
// ------------------------------------------------------ // ------------------------------------------------------
const groups = ref([]) const groups = ref([])
const tags = ref([]) const tags = ref([])
@@ -738,17 +709,11 @@ const grupoIdSelecionado = ref(null)
const tagIdsSelecionadas = ref([]) const tagIdsSelecionadas = ref([])
async function replacePatientGroups (patient_id, groupId) { async function replacePatientGroups (patient_id, groupId) {
const { error: delErr } = await supabase const { error: delErr } = await supabase.from('patient_group_patient').delete().eq('patient_id', patient_id)
.from('patient_group_patient')
.delete()
.eq('patient_id', patient_id)
if (delErr) throw delErr if (delErr) throw delErr
if (!groupId) return if (!groupId) return
const { tenantId } = await resolveTenantContextOrFail()
const { error: insErr } = await supabase const { error: insErr } = await supabase.from('patient_group_patient').insert({ patient_id, patient_group_id: groupId, tenant_id: tenantId })
.from('patient_group_patient')
.insert({ patient_id, patient_group_id: groupId })
if (insErr) throw insErr if (insErr) throw insErr
} }
@@ -765,15 +730,9 @@ async function replacePatientTags (patient_id, tagIds) {
const clean = Array.from(new Set([...(tagIds || [])].filter(Boolean))) const clean = Array.from(new Set([...(tagIds || [])].filter(Boolean)))
if (!clean.length) return if (!clean.length) return
const rows = clean.map(tag_id => ({ const { tenantId } = await resolveTenantContextOrFail()
owner_id: ownerId, const rows = clean.map(tag_id => ({ owner_id: ownerId, patient_id, tag_id, tenant_id: tenantId }))
patient_id, const { error: insErr } = await supabase.from('patient_patient_tag').insert(rows)
tag_id
}))
const { error: insErr } = await supabase
.from('patient_patient_tag')
.insert(rows)
if (insErr) throw insErr if (insErr) throw insErr
} }
@@ -808,19 +767,6 @@ const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const deleting = 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) // Fetch (load everything)
// ------------------------------------------------------ // ------------------------------------------------------
@@ -829,34 +775,32 @@ async function fetchAll () {
try { try {
const [gRes, tRes] = await Promise.allSettled([listGroups(), listTags()]) const [gRes, tRes] = await Promise.allSettled([listGroups(), listTags()])
if (gRes.status === 'fulfilled') { if (gRes.status === 'fulfilled') groups.value = gRes.value || []
groups.value = gRes.value || [] else {
} else {
groups.value = [] groups.value = []
console.warn('[listGroups error]', gRes.reason) console.warn('[listGroups error]', gRes.reason)
toast.add({ severity: 'warn', summary: 'Grupos', detail: gRes.reason?.message || 'Falha ao carregar grupos', life: 3500 }) toast.add({ severity: 'warn', summary: 'Grupos', detail: gRes.reason?.message || 'Falha ao carregar grupos', life: 3500 })
} }
if (tRes.status === 'fulfilled') { if (tRes.status === 'fulfilled') tags.value = tRes.value || []
tags.value = tRes.value || [] else {
} else {
tags.value = [] tags.value = []
console.warn('[listTags error]', tRes.reason) console.warn('[listTags error]', tRes.reason)
toast.add({ severity: 'warn', summary: 'Tags', detail: tRes.reason?.message || 'Falha ao carregar tags', life: 3500 }) 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) { if (isEdit.value) {
const p = await getPatientById(patientId.value) const p = await getPatientById(patientId.value)
form.value = mapDbToForm(p) form.value = mapDbToForm(p)
// se já tinha avatar no banco, garante preview
avatarPreviewUrl.value = form.value.avatar_url || ''
const rel = await getPatientRelations(patientId.value) const rel = await getPatientRelations(patientId.value)
grupoIdSelecionado.value = rel.groupIds?.[0] || null grupoIdSelecionado.value = rel.groupIds?.[0] || null
tagIdsSelecionadas.value = rel.tagIds || [] tagIdsSelecionadas.value = rel.tagIds || []
} else { } else {
form.value = resetForm() //form.value = resetForm()
grupoIdSelecionado.value = null grupoIdSelecionado.value = null
tagIdsSelecionadas.value = [] tagIdsSelecionadas.value = []
avatarFile.value = null avatarFile.value = null
@@ -872,19 +816,33 @@ async function fetchAll () {
watch(() => route.params?.id, fetchAll, { immediate: true }) watch(() => route.params?.id, fetchAll, { immediate: true })
onMounted(fetchAll) onMounted(fetchAll)
// ------------------------------------------------------
// Tenant resolve (robusto)
// ------------------------------------------------------
async function resolveTenantContextOrFail () { async function resolveTenantContextOrFail () {
const { data: authData, error: authError } = await supabase.auth.getUser() const { data: authData, error: authError } = await supabase.auth.getUser()
if (authError) throw authError if (authError) throw authError
const uid = authData?.user?.id const uid = authData?.user?.id
if (!uid) throw new Error('Sessão inválida.') 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 const { data, error } = await supabase
.from('tenant_members') .from('tenant_members')
.select('id, tenant_id') .select('id, tenant_id')
.eq('user_id', uid) .eq('user_id', uid)
.eq('status', 'active') .eq('status', 'active')
.order('created_at', { ascending: false }) // se existir .order('created_at', { ascending: false })
.limit(1) .limit(1)
.single() .single()
@@ -904,20 +862,69 @@ async function onSubmit () {
const ownerId = await getOwnerId() const ownerId = await getOwnerId()
const { tenantId, memberId } = await resolveTenantContextOrFail() const { tenantId, memberId } = await resolveTenantContextOrFail()
// depois do sanitize
const payload = sanitizePayload(form.value, ownerId) const payload = sanitizePayload(form.value, ownerId)
// multi-tenant obrigatório
payload.tenant_id = tenantId payload.tenant_id = tenantId
payload.responsible_member_id = memberId 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) { if (isEdit.value) {
await updatePatient(patientId.value, payload) 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 }) toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 })
} else { return
const created = await createPatient(payload)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
// opcional: router.push(`${getAreaBase()}/patients/${created.id}`)
} }
// ---------------------------
// 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) { } catch (e) {
console.error(e) console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar paciente.', life: 4000 }) 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 saving.value = false
} }
} }
// ------------------------------------------------------ // ------------------------------------------------------
// Delete // 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 randInt (min, max) { return Math.floor(Math.random() * (max - min + 1)) + min }
function pick (arr) { return arr[randInt(0, arr.length - 1)] } function pick (arr) { return arr[randInt(0, arr.length - 1)] }
@@ -1036,7 +1041,6 @@ function fillRandomPatient () {
form.value = { form.value = {
...resetForm(), ...resetForm(),
nome_completo: nomeCompleto, nome_completo: nomeCompleto,
telefone: randomPhoneBR(), telefone: randomPhoneBR(),
email_principal: randomEmailFromName(nomeCompleto), email_principal: randomEmailFromName(nomeCompleto),
@@ -1076,16 +1080,10 @@ function fillRandomPatient () {
cobranca_no_responsavel: true, cobranca_no_responsavel: true,
notas_internas: 'Paciente apresenta discurso organizado. Acompanhar evolução clínica.', notas_internas: 'Paciente apresenta discurso organizado. Acompanhar evolução clínica.',
avatar_url: '' avatar_url: ''
} }
// Grupo if (Array.isArray(groups.value) && groups.value.length) grupoIdSelecionado.value = pick(groups.value).id
if (Array.isArray(groups.value) && groups.value.length) {
grupoIdSelecionado.value = pick(groups.value).id
}
// Tags
if (Array.isArray(tags.value) && tags.value.length) { if (Array.isArray(tags.value) && tags.value.length) {
const shuffled = [...tags.value].sort(() => Math.random() - 0.5) 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) 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 createGroupDialog = ref(false)
const createGroupSaving = ref(false) const createGroupSaving = ref(false)
const createGroupError = ref('') const createGroupError = ref('')
const newGroup = ref({ name: '', color: '#6366F1' }) // indigo default const newGroup = ref({ name: '', color: '#6366F1' })
const createTagDialog = ref(false) const createTagDialog = ref(false)
const createTagSaving = ref(false) const createTagSaving = ref(false)
const createTagError = ref('') const createTagError = ref('')
const newTag = ref({ name: '', color: '#22C55E' }) // green default const newTag = ref({ name: '', color: '#22C55E' })
function openGroupDlg(mode = 'create') { function openGroupDlg () {
// por enquanto só create
createGroupError.value = '' createGroupError.value = ''
newGroup.value = { name: '', color: '#6366F1' } newGroup.value = { name: '', color: '#6366F1' }
createGroupDialog.value = true createGroupDialog.value = true
} }
function openTagDlg(mode = 'create') { function openTagDlg () {
// por enquanto só create
createTagError.value = '' createTagError.value = ''
newTag.value = { name: '', color: '#22C55E' } newTag.value = { name: '', color: '#22C55E' }
createTagDialog.value = true createTagDialog.value = true
} }
// ------------------------------------------------------ async function createGroupPersist () {
// Persist: Grupo
// ------------------------------------------------------
async function createGroupPersist() {
if (createGroupSaving.value) return if (createGroupSaving.value) return
createGroupError.value = '' createGroupError.value = ''
const name = String(newGroup.value?.name || '').trim() const name = String(newGroup.value?.name || '').trim()
const color = String(newGroup.value?.color || '').trim() || '#6366F1' 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 createGroupSaving.value = true
try { try {
const ownerId = await getOwnerId() const ownerId = await getOwnerId()
const { tenantId } = await resolveTenantContextOrFail()
// Tenta schema PT-BR primeiro (pelo teu listGroups)
let createdId = null 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 const { data, error } = await supabase
else { .from('patient_groups')
// fallback (caso seu schema seja EN) .insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, descricao: null, cor: color, is_system: false, is_active: true })
const { data: d2, error: e2 } = await supabase .select('id')
.from('patient_groups') .single()
.insert({
owner_id: ownerId, if (error) throw error
name, createdId = data?.id || null
description: null,
color,
is_system: false,
is_active: true
})
.select('id')
.single()
if (e2) throw e2
createdId = d2?.id || null
}
}
// Recarrega lista e seleciona o novo
groups.value = await listGroups() groups.value = await listGroups()
if (createdId) grupoIdSelecionado.value = createdId if (createdId) grupoIdSelecionado.value = createdId
toast.add({ severity: 'success', summary: 'Grupo', detail: 'Grupo criado.', life: 2500 }) toast.add({ severity: 'success', summary: 'Grupo', detail: 'Grupo criado.', life: 2500 })
createGroupDialog.value = false createGroupDialog.value = false
} catch (e) { } 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 { } finally {
createGroupSaving.value = false createGroupSaving.value = false
} }
} }
// ------------------------------------------------------ async function createTagPersist () {
// Persist: Tag
// ------------------------------------------------------
async function createTagPersist() {
if (createTagSaving.value) return if (createTagSaving.value) return
createTagError.value = '' createTagError.value = ''
const name = String(newTag.value?.name || '').trim() const name = String(newTag.value?.name || '').trim()
const color = String(newTag.value?.color || '').trim() || '#22C55E' 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 createTagSaving.value = true
try { try {
const ownerId = await getOwnerId() const ownerId = await getOwnerId()
const { tenantId } = await resolveTenantContextOrFail()
// Tenta schema EN primeiro (pelo teu listTags)
let createdId = null 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 const { data, error } = await supabase
else { .from('patient_tags')
// fallback PT-BR .insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, cor: color })
const { data: d2, error: e2 } = await supabase .select('id')
.from('patient_tags') .single()
.insert({ owner_id: ownerId, nome: name, cor: color })
.select('id') if (error) throw error
.single() createdId = data?.id || null
if (e2) throw e2
createdId = d2?.id || null
}
}
// Recarrega lista e já marca a nova na seleção
tags.value = await listTags() tags.value = await listTags()
if (createdId) { if (createdId) {
const set = new Set([...(tagIdsSelecionadas.value || []), 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 }) toast.add({ severity: 'success', summary: 'Tag', detail: 'Tag criada.', life: 2500 })
createTagDialog.value = false createTagDialog.value = false
} catch (e) { } 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 { } finally {
createTagSaving.value = false createTagSaving.value = false
} }
@@ -1285,11 +1239,12 @@ async function createTagPersist() {
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Button <Button
v-if="canSee('testMODE')"
label="Preencher tudo" label="Preencher tudo"
icon="pi pi-bolt" icon="pi pi-bolt"
severity="secondary" severity="secondary"
outlined outlined
@click="fillRandomPatient" @click="fillRandomPatient"
/> />
<Button <Button
label="Voltar" label="Voltar"
@@ -1931,6 +1886,7 @@ async function createTagPersist() {
<Dialog <Dialog
v-model:visible="createGroupDialog" v-model:visible="createGroupDialog"
modal modal
:draggable="false"
header="Criar grupo" header="Criar grupo"
:style="{ width: '26rem' }" :style="{ width: '26rem' }"
:closable="!createGroupSaving" :closable="!createGroupSaving"
@@ -1965,6 +1921,7 @@ async function createTagPersist() {
<Dialog <Dialog
v-model:visible="createTagDialog" v-model:visible="createTagDialog"
modal modal
:draggable="false"
header="Criar tag" header="Criar tag"
:style="{ width: '26rem' }" :style="{ width: '26rem' }"
:closable="!createTagSaving" :closable="!createTagSaving"

View File

@@ -1,249 +1,240 @@
<template> <template>
<div class="p-4"> <Toast />
<!-- Top header -->
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-2xl bg-slate-900 text-slate-50 grid place-items-center shadow-sm">
<i class="pi pi-link text-lg"></i>
</div>
<div class="min-w-0"> <!-- Sentinel -->
<div class="text-2xl font-semibold text-slate-900 leading-tight"> <div ref="headerSentinelRef" class="extlink-sentinel" />
Cadastro Externo
</div> <!-- Hero sticky -->
<div class="text-slate-600 mt-1"> <div ref="headerEl" class="extlink-hero mx-3 md:mx-5 mb-4" :class="{ 'extlink-hero--stuck': headerStuck }">
Gere um link para o paciente preencher o pré-cadastro com calma e segurança. <div class="extlink-hero__blobs" aria-hidden="true">
</div> <div class="extlink-hero__blob extlink-hero__blob--1" />
</div> <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> </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 <Button
label="Gerar novo link" label="Gerar novo link"
icon="pi pi-refresh" icon="pi pi-refresh"
severity="secondary" severity="secondary"
outlined outlined
class="rounded-full"
:loading="rotating" :loading="rotating"
@click="rotateLink" @click="rotateLink"
/> />
</div> </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> </div>
<!-- Main grid --> <!-- Divider -->
<div class="mt-5 grid grid-cols-1 lg:grid-cols-12 gap-4"> <Divider class="extlink-hero__divider my-2" />
<!-- Left: Link card -->
<div class="lg:col-span-7"> <!-- Row 2: link rápido (oculto no mobile) -->
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden"> <div class="extlink-hero__row2">
<!-- Card head --> <div v-if="!inviteToken" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
<div class="p-5 border-b border-slate-200"> <i class="pi pi-spin pi-spinner text-xs" /> Gerando link
<div class="flex items-start justify-between gap-3"> </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 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="min-w-0">
<div class="text-lg font-semibold text-slate-900">Seu link</div> <div class="font-semibold text-sm text-[var(--text-color)]">Você envia o link</div>
<div class="text-slate-600 text-sm mt-1"> <div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Por WhatsApp, e-mail ou mensagem direta.</div>
Envie este link ao paciente. Ele abre a página de cadastro externo.
</div>
</div> </div>
</li>
<div class="hidden md:flex items-center gap-2"> <li class="flex gap-3">
<span <div class="extlink-step shrink-0">2</div>
class="inline-flex items-center gap-2 text-xs px-2.5 py-1 rounded-full border" <div class="min-w-0">
:class="inviteToken ? 'border-emerald-200 text-emerald-700 bg-emerald-50' : 'border-slate-200 text-slate-600 bg-slate-50'" <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>
<span
class="h-2 w-2 rounded-full"
:class="inviteToken ? 'bg-emerald-500' : 'bg-slate-400'"
></span>
{{ inviteToken ? 'Ativo' : 'Gerando...' }}
</span>
</div> </div>
</div> </li>
</div> <li class="flex gap-3">
<div class="extlink-step shrink-0">3</div>
<!-- Card content --> <div class="min-w-0">
<div class="p-5"> <div class="font-semibold text-sm text-[var(--text-color)]">Você recebe e converte</div>
<!-- Skeleton while loading --> <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 v-if="!inviteToken" class="space-y-3">
<div class="h-10 rounded-xl bg-slate-100 animate-pulse"></div>
<div class="h-10 rounded-xl bg-slate-100 animate-pulse"></div>
<Message severity="info" :closable="false">
Gerando seu link...
</Message>
</div>
<div v-else class="space-y-4">
<!-- Link display + quick actions -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-slate-700">Link público</label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<div class="flex-1 min-w-0">
<InputText
readonly
:value="publicUrl"
class="w-full"
/>
<div class="mt-1 text-xs text-slate-500 break-words">
Token: <span class="font-mono">{{ inviteToken }}</span>
</div>
</div>
<div class="flex gap-2 sm:flex-col sm:w-[140px]">
<Button
class="w-full"
icon="pi pi-copy"
label="Copiar"
severity="secondary"
outlined
@click="copyLink"
/>
<Button
class="w-full"
icon="pi pi-external-link"
label="Abrir"
severity="secondary"
outlined
@click="openLink"
/>
</div>
</div>
</div> </div>
</li>
<!-- Big CTA --> </ol>
<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 foi compartilhado.
</Message>
</div>
</div>
</div> </div>
</div> </div>
<!-- Right: Concept / Instructions --> <!-- Boas práticas -->
<div class="lg:col-span-5"> <div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden"> <div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-3">
<div class="p-5 border-b border-slate-200"> <i class="pi pi-shield text-sm text-[var(--text-color-secondary)]" />
<div class="text-lg font-semibold text-slate-900">Como funciona</div> Boas práticas
<div class="text-slate-600 text-sm mt-1">
Um fluxo simples, mas com cuidado clínico: menos fricção, mais adesão.
</div>
</div>
<div class="p-5">
<ol class="space-y-4">
<li class="flex gap-3">
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">1</div>
<div class="min-w-0">
<div class="font-semibold text-slate-900">Você envia o link</div>
<div class="text-sm text-slate-600 mt-1">
Pode ser WhatsApp, e-mail ou mensagem direta. O link abre a página de cadastro externo.
</div>
</div>
</li>
<li class="flex gap-3">
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">2</div>
<div class="min-w-0">
<div class="font-semibold text-slate-900">O paciente preenche</div>
<div class="text-sm text-slate-600 mt-1">
Campos opcionais podem ser deixados em branco. A ideia é reduzir ansiedade e acelerar o início.
</div>
</div>
</li>
<li class="flex gap-3">
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">3</div>
<div class="min-w-0">
<div class="font-semibold text-slate-900">Você recebe no admin</div>
<div class="text-sm text-slate-600 mt-1">
Os dados entram como cadastro recebido. Você revisa, completa e transforma em paciente quando quiser.
</div>
</div>
</li>
</ol>
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div class="font-semibold text-slate-900 flex items-center gap-2">
<i class="pi pi-shield text-slate-700"></i>
Boas práticas
</div>
<ul class="mt-2 space-y-2 text-sm text-slate-700">
<li class="flex gap-2">
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
<span>Gere um novo link se você suspeitar que ele foi repassado indevidamente.</span>
</li>
<li class="flex gap-2">
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
<span>Envie junto uma mensagem curta: preencha com calma; campos opcionais podem ficar em branco.</span>
</li>
<li class="flex gap-2">
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
<span>Evite divulgar em público; é um link pensado para compartilhamento individual.</span>
</li>
</ul>
</div>
<div class="mt-4 text-xs text-slate-500">
Se você quiser, eu deixo este card ainda mais noir (contraste, microtextos, ícones, sombras) sem perder legibilidade.
</div>
</div>
</div>
<!-- Small helper card -->
<div class="mt-4 rounded-2xl border border-slate-200 bg-white shadow-sm p-5">
<div class="font-semibold text-slate-900">Mensagem pronta (copiar/colar)</div>
<div class="text-sm text-slate-600 mt-1">
Se quiser, use este texto ao enviar o link:
</div>
<div class="mt-3 rounded-xl bg-slate-50 border border-slate-200 p-3 text-sm text-slate-800">
Olá! Segue o link para seu pré-cadastro. Preencha com calma campos opcionais podem ficar em branco:
<span class="block mt-2 font-mono break-words">{{ publicUrl || '…' }}</span>
</div>
<div class="mt-3 flex gap-2">
<Button
icon="pi pi-copy"
label="Copiar mensagem"
severity="secondary"
outlined
:disabled="!publicUrl"
@click="copyInviteMessage"
/>
</div>
</div> </div>
<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>
</div> </div>
<!-- Toast is global in layout usually; if not, add <Toast /> -->
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import Button from 'primevue/button'
import Card from 'primevue/card'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message' import Message from 'primevue/message'
import Menu from 'primevue/menu'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
@@ -252,12 +243,25 @@ const toast = useToast()
const inviteToken = ref('') const inviteToken = ref('')
const rotating = ref(false) const rotating = ref(false)
/** // ── Hero sticky ────────────────────────────────────────────
* Se o cadastro externo estiver em outro domínio, fixe aqui: const headerEl = ref(null)
* ex.: const PUBLIC_BASE_URL = 'https://seusite.com' const headerSentinelRef = ref(null)
* se vazio, usa window.location.origin const headerStuck = ref(false)
*/ let _observer = null
const PUBLIC_BASE_URL = '' // opcional
// ── 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(() => { const origin = computed(() => {
if (PUBLIC_BASE_URL) return PUBLIC_BASE_URL if (PUBLIC_BASE_URL) return PUBLIC_BASE_URL
@@ -269,12 +273,13 @@ const publicUrl = computed(() => {
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}` return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
}) })
function newToken () { // ── Token helpers ───────────────────────────────────────────
function newToken() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID() if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36) 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() const { data, error } = await supabase.auth.getUser()
if (error) throw error if (error) throw error
const uid = data?.user?.id const uid = data?.user?.id
@@ -282,7 +287,7 @@ async function requireUserId () {
return uid return uid
} }
async function loadOrCreateInvite () { async function loadOrCreateInvite() {
const uid = await requireUserId() const uid = await requireUserId()
const { data, error } = await supabase const { data, error } = await supabase
@@ -310,16 +315,14 @@ async function loadOrCreateInvite () {
inviteToken.value = t inviteToken.value = t
} }
async function rotateLink () { async function rotateLink() {
rotating.value = true rotating.value = true
try { try {
const uid = await requireUserId() const uid = await requireUserId()
const t = newToken() const t = newToken()
// tenta RPC primeiro
const rpc = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t }) const rpc = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
if (rpc.error) { if (rpc.error) {
// fallback: desativa todos os ativos e cria um novo
const { error: e1 } = await supabase const { error: e1 } = await supabase
.from('patient_invites') .from('patient_invites')
.update({ active: false, updated_at: new Date().toISOString() }) .update({ active: false, updated_at: new Date().toISOString() })
@@ -334,7 +337,7 @@ async function rotateLink () {
} }
inviteToken.value = t 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) { } catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 }) toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 })
} finally { } finally {
@@ -342,40 +345,138 @@ async function rotateLink () {
} }
} }
async function copyLink () { async function copyLink() {
try { try {
if (!publicUrl.value) return if (!publicUrl.value) return
await navigator.clipboard.writeText(publicUrl.value) 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 { } catch {
// fallback clássico
window.prompt('Copie o link:', publicUrl.value) window.prompt('Copie o link:', publicUrl.value)
} }
} }
function openLink () { function openLink() {
if (!publicUrl.value) return if (!publicUrl.value) return
window.open(publicUrl.value, '_blank', 'noopener') window.open(publicUrl.value, '_blank', 'noopener')
} }
async function copyInviteMessage () { async function copyInviteMessage() {
try { try {
if (!publicUrl.value) return if (!publicUrl.value) return
const msg = const msg = `Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:\n${publicUrl.value}`
`Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
${publicUrl.value}`
await navigator.clipboard.writeText(msg) 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 { } catch {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 }) toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
} }
} }
onMounted(async () => { 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 { try {
await loadOrCreateInvite() await loadOrCreateInvite()
} catch (err) { } catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 }) toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 })
} }
}) })
onBeforeUnmount(() => { _observer?.disconnect() })
</script> </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>

View File

@@ -1,24 +1,21 @@
<!-- src/views/pages/patients/PatientIntakeRequestsPage.vue -->
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm' 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 ProgressSpinner from 'primevue/progressspinner'
import Textarea from 'primevue/textarea' import Textarea from 'primevue/textarea'
import Avatar from 'primevue/avatar' import Avatar from 'primevue/avatar'
import Menu from 'primevue/menu'
import { brToISO, isoToBR } from '@/utils/dateBR' import { brToISO, isoToBR } from '@/utils/dateBR'
const toast = useToast() const toast = useToast()
const confirm = useConfirm() const confirm = useConfirm()
const tenantStore = useTenantStore()
const converting = ref(false) const converting = ref(false)
const loading = ref(false) const loading = ref(false)
@@ -227,7 +224,7 @@ function fmtDate (iso) {
return d.toLocaleString('pt-BR') 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) { function normalizeBirthToISO (v) {
if (!v) return null if (!v) return null
const s = String(v).trim() const s = String(v).trim()
@@ -248,6 +245,34 @@ function normalizeBirthToISO (v) {
return `${yyyy}-${mm}-${dd}` 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 // Seções do modal
// ----------------------------- // -----------------------------
@@ -420,19 +445,19 @@ async function markRejected () {
} }
// ----------------------------- // -----------------------------
// Converter // Converter (com tenant_id + responsible_member_id)
// ----------------------------- // -----------------------------
async function convertToPatient () { async function convertToPatient () {
const item = dlg.value?.item const item = dlg.value?.item
if (!item?.id) return if (!item?.id) return
if (converting.value) return if (converting.value) return
// regra de negócio: só converte "new" // só bloqueia cadastros já convertidos
if (item.status !== 'new') { if (item.status === 'converted') {
toast.add({ toast.add({
severity: 'warn', severity: 'warn',
summary: 'Atenção', summary: 'Atenção',
detail: 'Só é possível converter cadastros com status "Novo".', detail: 'Este cadastro já foi convertido em paciente.',
life: 3000 life: 3000
}) })
return return
@@ -447,19 +472,27 @@ async function convertToPatient () {
const ownerId = userData?.user?.id const ownerId = userData?.user?.id
if (!ownerId) throw new Error('Sessão inválida.') 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 cleanStr = (v) => {
const s = String(v ?? '').trim() const s = String(v ?? '').trim()
return s ? s : null return s ? s : null
} }
const digitsOnly = (v) => { const digitsOnly = (v) => {
const d = String(v ?? '').replace(/\D/g, '') const d = String(v ?? '').replace(/\D/g, '')
return d ? d : null 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 intakeAvatar = cleanStr(item.avatar_url) || cleanStr(item.foto_url) || cleanStr(item.photo_url) || null
const patientPayload = { const patientPayload = {
tenant_id: tenantId,
responsible_member_id: responsibleMemberId,
owner_id: ownerId, owner_id: ownerId,
// identificação/contato // identificação/contato
@@ -471,7 +504,7 @@ async function convertToPatient () {
telefone_alternativo: digitsOnly(fTelAlt(item)), telefone_alternativo: digitsOnly(fTelAlt(item)),
// pessoais // pessoais
data_nascimento: normalizeBirthToISO(fNasc(item)), // ✅ agora é sempre ISO date data_nascimento: normalizeBirthToISO(fNasc(item)),
naturalidade: cleanStr(fNaturalidade(item)), naturalidade: cleanStr(fNaturalidade(item)),
genero: cleanStr(fGenero(item)), genero: cleanStr(fGenero(item)),
estado_civil: cleanStr(fEstadoCivil(item)), estado_civil: cleanStr(fEstadoCivil(item)),
@@ -520,6 +553,7 @@ async function convertToPatient () {
const patientId = created?.id const patientId = created?.id
if (!patientId) throw new Error('Falha ao obter ID do paciente criado.') 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 const { error: upErr } = await supabase
.from('patient_intake_requests') .from('patient_intake_requests')
.update({ .update({
@@ -528,7 +562,6 @@ async function convertToPatient () {
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
}) })
.eq('id', item.id) .eq('id', item.id)
.eq('owner_id', ownerId)
if (upErr) throw upErr if (upErr) throw upErr
@@ -537,6 +570,7 @@ async function convertToPatient () {
dlg.value.open = false dlg.value.open = false
await fetchIntakes() await fetchIntakes()
} catch (err) { } catch (err) {
console.error(err)
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: 'Falha ao converter', summary: 'Falha ao converter',
@@ -557,135 +591,125 @@ const totals = computed(() => {
return { total, nNew, nConv, nRej } 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> </script>
<template> <template>
<div class="p-4"> <Toast />
<ConfirmDialog /> <ConfirmDialog />
<!-- HEADER --> <!-- Sentinel -->
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]"> <div ref="headerSentinelRef" class="rec-sentinel" />
<div class="relative px-5 py-5">
<!-- faixa de cor -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-10 -right-12 h-40 w-40 rounded-full bg-emerald-400/20 blur-3xl" />
<div class="absolute top-10 -left-16 h-44 w-44 rounded-full bg-indigo-400/20 blur-3xl" />
</div>
<div class="relative flex flex-col gap-3 md:flex-row md:items-end md:justify-between"> <!-- Hero sticky -->
<div class="min-w-0"> <div ref="headerEl" class="rec-hero mx-3 md:mx-5 mb-4" :class="{ 'rec-hero--stuck': headerStuck }">
<div class="flex items-center gap-3"> <div class="rec-hero__blobs" aria-hidden="true">
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]"> <div class="rec-hero__blob rec-hero__blob--1" />
<i class="pi pi-inbox text-lg"></i> <div class="rec-hero__blob rec-hero__blob--2" />
</div> </div>
<div class="min-w-0"> <!-- Linha 1 -->
<div class="flex items-center gap-2"> <div class="rec-hero__row1">
<div class="text-xl font-semibold leading-none">Cadastros recebidos</div> <div class="rec-hero__brand">
<Tag :value="`${totals.total}`" severity="secondary" /> <div class="rec-hero__icon"><i class="pi pi-inbox text-lg" /></div>
</div> <div class="min-w-0">
<div class="text-color-secondary mt-1"> <div class="flex items-center gap-2">
Solicitações de pré-cadastro (cadastro externo) para avaliar e converter. <div class="rec-hero__title">Cadastros recebidos</div>
</div> <Tag :value="`${totals.total}`" severity="secondary" />
</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>
</div> </div>
<div class="rec-hero__sub">Pré-cadastros externos para avaliar e converter em pacientes</div>
</div> </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> </div>
<!-- TABLE --> <!-- Divisor -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden"> <Divider class="rec-hero__divider my-2" />
<div v-if="loading" class="flex items-center justify-center py-10">
<ProgressSpinner style="width: 38px; height: 38px" /> <!-- 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> </div>
<DataTable <InputGroup class="w-72 shrink-0">
v-else <InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
:value="filteredRows" <InputText v-model="q" placeholder="Nome, e-mail ou telefone…" :disabled="loading" />
dataKey="id" <Button v-if="q" icon="pi pi-trash" severity="danger" title="Limpar" @click="q = ''" />
paginator </InputGroup>
:rows="10" </div>
:rowsPerPageOptions="[10, 20, 50]" </div>
responsiveLayout="scroll"
stripedRows <!-- Dialog busca mobile -->
class="!border-0" <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"> <Column header="Status" style="width: 10rem">
<template #body="{ data }"> <template #body="{ data }">
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" /> <Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
@@ -734,13 +758,67 @@ onMounted(fetchIntakes)
</Column> </Column>
<template #empty> <template #empty>
<div class="text-color-secondary py-6 text-center"> <div class="py-10 text-center">
Nenhum cadastro encontrado. <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>
</template> </template>
</DataTable> </DataTable>
</div> </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 --> <!-- MODAL -->
<Dialog <Dialog
v-model:visible="dlg.open" v-model:visible="dlg.open"
@@ -748,6 +826,7 @@ onMounted(fetchIntakes)
:header="null" :header="null"
:style="{ width: 'min(940px, 96vw)' }" :style="{ width: 'min(940px, 96vw)' }"
:contentStyle="{ padding: 0 }" :contentStyle="{ padding: 0 }"
:draggable="false"
@hide="closeDlg" @hide="closeDlg"
> >
<div v-if="dlg.item" class="relative"> <div v-if="dlg.item" class="relative">
@@ -878,5 +957,49 @@ onMounted(fetchIntakes)
</div> </div>
</div> </div>
</Dialog> </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>

View File

@@ -1,32 +1,79 @@
<template> <template>
<div class="p-4"> <Toast />
<!-- TOOLBAR (padrão Sakai CRUD) -->
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Grupos de Pacientes</div>
<small class="text-color-secondary mt-1">
Organize seus pacientes por grupos. Alguns grupos são padrões do sistema e não podem ser alterados.
</small>
</div>
</template>
<template #end> <!-- Sentinel para detecção de sticky -->
<div class="flex items-center gap-2"> <div ref="headerSentinelRef" class="grp-sentinel" />
<Button
label="Excluir selecionados"
icon="pi pi-trash"
severity="danger"
outlined
:disabled="!selectedGroups || !selectedGroups.length"
@click="confirmDeleteSelected"
/>
<Button label="Adicionar" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
</Toolbar>
<div class="flex flex-col lg:flex-row gap-4"> <!-- 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 --> <!-- LEFT: TABLE -->
<div class="w-full lg:basis-[70%] lg:max-w-[70%]"> <div class="w-full lg:basis-[70%] lg:max-w-[70%]">
<Card class="h-full"> <Card class="h-full">
@@ -48,16 +95,9 @@
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} grupos" currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} grupos"
> >
<template #header> <template #header>
<div class="flex flex-wrap gap-2 items-center justify-between"> <div class="flex items-center gap-2">
<div class="flex items-center gap-2"> <span class="font-medium">Lista de Grupos</span>
<span class="font-medium">Lista de Grupos</span> <Tag :value="`${groups.length} grupos`" severity="secondary" />
<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> </div>
</template> </template>
@@ -73,7 +113,18 @@
</template> </template>
</Column> </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"> <Column header="Origem" sortable sortField="is_system" style="min-width: 12rem">
<template #body="{ data }"> <template #body="{ data }">
@@ -116,14 +167,24 @@
outlined outlined
rounded rounded
disabled disabled
v-tooltip.top="'Grupo padrão do sistema (inalterável)'" title="Grupo padrão do sistema (inalterável)"
/> />
</div> </div>
</template> </template>
</Column> </Column>
<template #empty> <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> </template>
</DataTable> </DataTable>
</template> </template>
@@ -192,31 +253,118 @@
<!-- DIALOG CREATE / EDIT --> <!-- DIALOG CREATE / EDIT -->
<Dialog <Dialog
v-model:visible="dlg.open" v-model:visible="dlg.open"
:header="dlg.mode === 'create' ? 'Criar Grupo' : 'Editar Grupo'" modal
modal :draggable="false"
:style="{ width: '520px', maxWidth: '92vw' }" :closable="!dlg.saving"
> :dismissableMask="!dlg.saving"
<div class="flex flex-col gap-3"> class="grp-dialog w-[96vw] max-w-lg"
<div> :pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' } }"
<label class="block mb-2">Nome do Grupo</label> >
<InputText v-model="dlg.nome" class="w-full" :disabled="dlg.saving" /> <template #header>
<small class="text-color-secondary"> <div class="flex w-full items-center justify-between gap-3 px-1">
Grupos Padrão são do sistema e não podem ser editados. <div class="flex items-center gap-3 min-w-0">
</small> <span class="grp-dlg-dot shrink-0" :style="{ backgroundColor: dlgPreviewColor }" />
</div> <div class="min-w-0">
</div> <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> <div class="flex items-center gap-2 shrink-0">
<Button label="Cancelar" text :disabled="dlg.saving" @click="dlg.open = false" /> <Button
<Button label="Cancelar"
:label="dlg.mode === 'create' ? 'Criar' : 'Salvar'" severity="secondary"
:loading="dlg.saving" outlined
@click="saveDialog" class="rounded-full"
:disabled="!String(dlg.nome || '').trim()" :disabled="dlg.saving"
/> @click="dlg.open = false"
</template> />
</Dialog>
<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 PACIENTES (com botão Abrir) -->
<Dialog <Dialog
@@ -253,8 +401,12 @@
</Message> </Message>
<div v-else> <div v-else>
<div v-if="patientsDialog.items.length === 0" class="text-color-secondary"> <div v-if="patientsDialog.items.length === 0" class="py-10 text-center">
Nenhum paciente associado a este grupo. <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>
<div v-else> <div v-else>
@@ -299,7 +451,16 @@
</Column> </Column>
<template #empty> <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> </template>
</DataTable> </DataTable>
</div> </div>
@@ -311,17 +472,17 @@
</template> </template>
</Dialog> </Dialog>
<ConfirmDialog /> <ConfirmDialog />
</div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm' import { useConfirm } from 'primevue/useconfirm'
import Checkbox from 'primevue/checkbox' import Checkbox from 'primevue/checkbox'
import Menu from 'primevue/menu'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
import { import {
@@ -335,6 +496,24 @@ const router = useRouter()
const toast = useToast() const toast = useToast()
const confirm = useConfirm() 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 dt = ref(null)
const loading = ref(false) const loading = ref(false)
const groups = ref([]) const groups = ref([])
@@ -350,9 +529,30 @@ const dlg = reactive({
mode: 'create', // 'create' | 'edit' mode: 'create', // 'create' | 'edit'
id: '', id: '',
nome: '', nome: '',
cor: '',
saving: false 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({ const patientsDialog = reactive({
open: false, open: false,
loading: false, loading: false,
@@ -428,6 +628,12 @@ function patientsLabel (n) {
return n === 1 ? '1 paciente' : `${n} pacientes` 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) { function humanizeError (err) {
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.' const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.'
const code = err?.code const code = err?.code
@@ -482,6 +688,7 @@ function openCreate () {
dlg.mode = 'create' dlg.mode = 'create'
dlg.id = '' dlg.id = ''
dlg.nome = '' dlg.nome = ''
dlg.cor = ''
} }
function openEdit (row) { function openEdit (row) {
@@ -489,6 +696,7 @@ function openEdit (row) {
dlg.mode = 'edit' dlg.mode = 'edit'
dlg.id = row.id dlg.id = row.id
dlg.nome = row.nome dlg.nome = row.nome
dlg.cor = row.cor || ''
} }
async function saveDialog () { async function saveDialog () {
@@ -502,13 +710,16 @@ async function saveDialog () {
return return
} }
const corRaw = String(dlg.cor || '').trim()
const cor = corRaw ? (corRaw.startsWith('#') ? corRaw : `#${corRaw}`) : null
dlg.saving = true dlg.saving = true
try { try {
if (dlg.mode === 'create') { if (dlg.mode === 'create') {
await createGroup(nome) await createGroup(nome, cor)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 }) toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 })
} else { } else {
await updateGroup(dlg.id, nome) await updateGroup(dlg.id, nome, cor)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo atualizado.', life: 2500 }) toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo atualizado.', life: 2500 })
} }
dlg.open = false dlg.open = false
@@ -653,12 +864,125 @@ function abrirPaciente (patient) {
router.push(`/features/patients/cadastro/${patient.id}`) 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> </script>
<style scoped> <style scoped>
.fade-enter-active, /* ── Hero ────────────────────────────────────────── */
.fade-leave-active { transition: opacity .14s ease; } .grp-sentinel { height: 1px; }
.fade-enter-from,
.fade-leave-to { opacity: 0; } .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> </style>

View File

@@ -1,19 +1,9 @@
<script setup> <script setup>
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue' 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 Textarea from 'primevue/textarea'
import Chip from 'primevue/chip' 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 Accordion from 'primevue/accordion'
import AccordionPanel from 'primevue/accordionpanel' import AccordionPanel from 'primevue/accordionpanel'
import AccordionHeader from 'primevue/accordionheader' import AccordionHeader from 'primevue/accordionheader'

View File

@@ -1,32 +1,88 @@
<template> <template>
<div class="p-4"> <Toast />
<!-- TOOLBAR -->
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Tags de Pacientes</div>
<small class="text-color-secondary mt-1">
Classifique pacientes por temas (ex.: Burnout, Ansiedade, Triagem). Clique em Pacientes para ver a lista.
</small>
</div>
</template>
<template #end> <!-- Sentinel para detecção de sticky -->
<div class="flex items-center gap-2"> <div ref="headerSentinelRef" class="tags-sentinel" />
<Button
label="Excluir selecionados"
icon="pi pi-trash"
severity="danger"
outlined
:disabled="!etiquetasSelecionadas?.length"
@click="confirmarExclusaoSelecionadas"
/>
<Button label="Adicionar" icon="pi pi-plus" @click="abrirCriar" />
</div>
</template>
</Toolbar>
<div class="flex flex-col lg:flex-row gap-4"> <!-- 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 --> <!-- LEFT: tabela -->
<div class="w-full lg:basis-[70%] lg:max-w-[70%]"> <div class="w-full lg:basis-[70%] lg:max-w-[70%]">
<Card class="h-full"> <Card class="h-full">
@@ -46,34 +102,6 @@
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown" paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} tags" 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) --> <!-- Seleção (bloqueia tags padrão) -->
<Column :exportable="false" headerStyle="width: 3rem"> <Column :exportable="false" headerStyle="width: 3rem">
<template #body="{ data }"> <template #body="{ data }">
@@ -124,7 +152,7 @@
outlined outlined
size="small" size="small"
:disabled="data.is_padrao" :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)" @click="abrirEditar(data)"
/> />
<Button <Button
@@ -133,7 +161,7 @@
outlined outlined
size="small" size="small"
:disabled="data.is_padrao" :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)" @click="confirmarExclusaoUma(data)"
/> />
</div> </div>
@@ -141,8 +169,20 @@
</Column> </Column>
<template #empty> <template #empty>
<div class="text-color-secondary py-5">Nenhuma tag encontrada.</div> <div class="py-10 text-center">
</template> <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> </DataTable>
</template> </template>
</Card> </Card>
@@ -155,12 +195,12 @@
<template #subtitle>As tags aparecem aqui quando houver pacientes associados.</template> <template #subtitle>As tags aparecem aqui quando houver pacientes associados.</template>
<template #content> <template #content>
<div v-if="cards.length === 0" class="min-h-[150px] flex flex-col items-center justify-center text-center gap-2"> <div v-if="cards.length === 0" class="py-10 text-center">
<i class="pi pi-tags text-3xl"></i> <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)]">
<div class="font-medium">Sem dados ainda</div> <i class="pi pi-tags text-xl" />
<small class="text-color-secondary"> </div>
Quando você associar pacientes às tags, elas aparecem aqui. <div class="font-semibold">Nenhuma tag em uso</div>
</small> <div class="mt-1 text-sm text-color-secondary">As tags mais usadas aparecem aqui quando houver pacientes associados.</div>
</div> </div>
<div v-else class="flex flex-col gap-3"> <div v-else class="flex flex-col gap-3">
@@ -212,64 +252,102 @@
<!-- DIALOG CREATE / EDIT --> <!-- DIALOG CREATE / EDIT -->
<Dialog <Dialog
v-model:visible="dlg.open" v-model:visible="dlg.open"
:header="dlg.mode === 'create' ? 'Criar Tag' : 'Editar Tag'"
modal 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"> <template #header>
<div> <div class="flex w-full items-center justify-between gap-3 px-1">
<label class="block mb-2">Nome da Tag</label> <div class="flex items-center gap-3 min-w-0">
<InputText v-model="dlg.nome" class="w-full" :disabled="dlg.saving" />
<small class="text-color-secondary">Ex.: Burnout, Ansiedade, Triagem.</small>
</div>
<div>
<label class="block mb-2">Cor (opcional)</label>
<div class="flex flex-wrap items-center gap-3">
<ColorPicker v-model="dlg.cor" format="hex" :disabled="dlg.saving" />
<InputText
v-model="dlg.cor"
class="w-44"
placeholder="#22c55e"
:disabled="dlg.saving"
/>
<span <span
class="inline-block rounded-lg" class="tag-dlg-dot shrink-0"
:style="{ :style="{ backgroundColor: tagDlgPreviewColor }"
width: '34px',
height: '34px',
border: '1px solid var(--surface-border)',
background: corPreview(dlg.cor)
}"
/> />
<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> </div>
<small class="text-color-secondary">
Pode usar HEX (#rrggbb). Se vazio, usamos uma cor neutra.
</small>
</div> </div>
</template>
<!-- Banner -->
<div class="tag-dlg-banner" :style="{ backgroundColor: tagDlgPreviewColor }">
<span class="tag-dlg-banner__pill">{{ dlg.nome || 'Nome da tag' }}</span>
</div> </div>
<template #footer> <!-- Corpo -->
<Button label="Cancelar" icon="pi pi-times" text @click="fecharDlg" :disabled="dlg.saving" /> <div class="flex flex-col gap-4 p-4">
<Button <!-- Nome -->
:label="dlg.mode === 'create' ? 'Criar' : 'Salvar'" <FloatLabel variant="on">
icon="pi pi-check" <IconField>
@click="salvarDlg" <InputIcon class="pi pi-tag" />
:loading="dlg.saving" <InputText
:disabled="!String(dlg.nome || '').trim()" id="tag-nome"
/> v-model="dlg.nome"
</template> 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> </Dialog>
<!-- MODAL: pacientes da tag --> <!-- MODAL: pacientes da tag -->
<Dialog <Dialog
v-model:visible="modalPacientes.open" v-model:visible="modalPacientes.open"
:header="modalPacientes.tag ? `Pacientes — ${modalPacientes.tag.nome}` : 'Pacientes'" :header="modalPacientesHeader"
modal modal
:draggable="false"
:style="{ width: '900px', maxWidth: '96vw' }" :style="{ width: '900px', maxWidth: '96vw' }"
> >
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
@@ -330,7 +408,16 @@
</Column> </Column>
<template #empty> <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> </template>
</DataTable> </DataTable>
</div> </div>
@@ -340,17 +427,17 @@
</template> </template>
</Dialog> </Dialog>
<ConfirmDialog /> <ConfirmDialog />
</div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm' import { useConfirm } from 'primevue/useconfirm'
import ColorPicker from 'primevue/colorpicker' import ColorPicker from 'primevue/colorpicker'
import Checkbox from 'primevue/checkbox' import Checkbox from 'primevue/checkbox'
import Menu from 'primevue/menu'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
@@ -358,6 +445,27 @@ const router = useRouter()
const toast = useToast() const toast = useToast()
const confirm = useConfirm() 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 dt = ref(null)
const carregando = ref(false) const carregando = ref(false)
@@ -393,6 +501,30 @@ const cards = computed(() =>
.sort((a, b) => Number(b.pacientes_count ?? 0) - Number(a.pacientes_count ?? 0)) .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 modalPacientesFiltrado = computed(() => {
const s = String(modalPacientes.search || '').trim().toLowerCase() const s = String(modalPacientes.search || '').trim().toLowerCase()
if (!s) return modalPacientes.items || [] if (!s) return modalPacientes.items || []
@@ -405,9 +537,17 @@ const modalPacientesFiltrado = computed(() => {
}) })
onMounted(() => { 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() buscarEtiquetas()
}) })
onBeforeUnmount(() => { _observer?.disconnect() })
async function getOwnerId() { async function getOwnerId() {
const { data, error } = await supabase.auth.getUser() const { data, error } = await supabase.auth.getUser()
if (error) throw error if (error) throw error
@@ -416,6 +556,20 @@ async function getOwnerId() {
return user.id 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) { function normalizarEtiquetaRow(r) {
// Compatível com banco antigo (name/color/is_native/patient_count) // Compatível com banco antigo (name/color/is_native/patient_count)
// e com banco pt-BR (nome/cor/is_padrao/patients_count) // e com banco pt-BR (nome/cor/is_padrao/patients_count)
@@ -441,7 +595,7 @@ function isUniqueViolation(e) {
} }
function friendlyDupMessage(nome) { 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) { function corPreview(raw) {
@@ -560,22 +714,14 @@ async function salvarDlg() {
const cor = hex ? `#${hex}` : null const cor = hex ? `#${hex}` : null
if (dlg.mode === 'create') { if (dlg.mode === 'create') {
// tenta pt-BR const tenantId = await getActiveTenantId(ownerId)
let res = await supabase.from('patient_tags').insert({ const res = await supabase.from('patient_tags').insert({
owner_id: ownerId, owner_id: ownerId,
tenant_id: tenantId,
nome, nome,
cor 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 if (res.error) throw res.error
toast.add({ severity: 'success', summary: 'Tag criada', detail: nome, life: 2500 }) toast.add({ severity: 'success', summary: 'Tag criada', detail: nome, life: 2500 })
} else { } else {
@@ -640,7 +786,7 @@ async function salvarDlg() {
-------------------------------- */ -------------------------------- */
function confirmarExclusaoUma(row) { function confirmarExclusaoUma(row) {
confirm.require({ 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', header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle', icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir', acceptLabel: 'Excluir',
@@ -807,7 +953,114 @@ function abrirPaciente (patient) {
</script> </script>
<style scoped> <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-enter-active,
.fade-leave-active { transition: opacity 0.15s ease; } .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-enter-from,

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

View File

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

View File

@@ -1,15 +1,12 @@
<script setup> <script setup>
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { useLayout } from '@/layout/composables/layout' import { useLayout } from '@/layout/composables/layout'
import SelectButton from 'primevue/selectbutton'
import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options' import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options'
const { layoutConfig, isDarkTheme, changeMenuMode } = useLayout() const { layoutConfig, isDarkTheme, changeMenuMode } = useLayout()
// ✅ vem do AppTopbar (mesma instância) // ✅ vem do AppTopbar (mesma instância)
const queuePatch = inject('queueUserSettingsPatch', null) const queuePatch = inject('queueUserSettingsPatch', null)
console.log('[AppConfigurator] queuePatch injected?', !!queuePatch)
// menu mode options // menu mode options
const menuModeOptions = [ const menuModeOptions = [
@@ -35,14 +32,14 @@ const menuModeModel = computed({
if (!val || val === layoutConfig.menuMode) return if (!val || val === layoutConfig.menuMode) return
layoutConfig.menuMode = val layoutConfig.menuMode = val
// composable pode aceitar nada (no teu caso, costuma ser isso) // ✅ changeMenuMode espera event.value (seu composable usa event.value)
try { changeMenuMode() } catch {} try { changeMenuMode({ value: val }) } catch {}
queuePatch?.({ menu_mode: val }) queuePatch?.({ menu_mode: val })
} }
}) })
function updateColors(type, item) { function updateColors (type, item) {
if (type === 'primary') { if (type === 'primary') {
layoutConfig.primary = item.name layoutConfig.primary = item.name
applyThemeEngine(layoutConfig) applyThemeEngine(layoutConfig)
@@ -116,4 +113,4 @@ function updateColors(type, item) {
</div> </div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,34 +1,161 @@
<script setup> <script setup>
import { useLayout } from '@/layout/composables/layout'; import { useLayout } from '@/layout/composables/layout'
import { computed } from 'vue'; import { computed, onMounted, onBeforeUnmount, provide } from 'vue'
import AppFooter from './AppFooter.vue'; import { useRoute } from 'vue-router'
import AppSidebar from './AppSidebar.vue';
import AppTopbar from './AppTopbar.vue';
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(() => { const containerClass = computed(() => {
return { return {
'layout-overlay': layoutConfig.menuMode === 'overlay', 'layout-overlay': layoutConfig.menuMode === 'overlay',
'layout-static': layoutConfig.menuMode === 'static', 'layout-static': layoutConfig.menuMode === 'static',
'layout-overlay-active': layoutState.overlayMenuActive, 'layout-overlay-active': layoutState.overlayMenuActive,
'layout-mobile-active': layoutState.mobileMenuActive, 'layout-mobile-active': layoutState.mobileMenuActive,
'layout-static-inactive': layoutState.staticMenuInactive '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> </script>
<template> <template>
<div class="layout-wrapper" :class="containerClass"> <!-- Layout 2: Rail + Painel + Main (full-width) -->
<AppTopbar /> <template v-if="layoutConfig.variant === 'rail' && isDesktop()">
<AppSidebar /> <div class="l2-root">
<div class="layout-main-container"> <AppRail />
<div class="layout-main"> <div class="l2-body">
<router-view /> <AppRailTopbar />
</div> <div class="l2-content">
<AppFooter /> <AppRailPanel />
<div class="l2-main">
<router-view />
</div>
</div> </div>
<div class="layout-mask animate-fadein" @click="hideMobileMenu" /> </div>
</div> </div>
<Toast /> <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> </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>

View File

@@ -7,114 +7,84 @@ import AppMenuItem from './AppMenuItem.vue'
import AppMenuFooterPanel from './AppMenuFooterPanel.vue' import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.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 { useMenuStore } from '@/stores/menuStore'
import { getMenuByRole } from '@/navigation'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { layoutState } = useLayout() const { layoutState } = useLayout()
const tenantStore = useTenantStore() const menuStore = useMenuStore()
const entitlementsStore = useEntitlementsStore()
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)
// ======================================================
/** // raw (pode piscar vazio)
* ✅ Role canônico pro MENU: const rawModel = computed(() => menuStore.model || [])
* - 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 || '')
// ✅ blindagem por contexto // último menu válido
if (p.startsWith('/therapist')) return 'therapist' const lastGoodModel = ref([])
if (p.startsWith('/admin') || p.startsWith('/clinic')) return 'clinic_admin'
if (p.startsWith('/patient')) return 'patient'
// ✅ dentro de tenant: confia no role do tenant // debounce curto para aceitar "vazio real" (ex.: logout) sem travar UI
if (tenantId.value) return tenantStore.activeRole || null let acceptEmptyT = null
// ✅ fora de tenant: fallback pro sessionRole function setLastGoodIfValid (m) {
return sessionRole.value || null if (Array.isArray(m) && m.length) {
}) lastGoodModel.value = m
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
} }
}
return [...base].sort((a, b) => priorityOrder(a) - priorityOrder(b))
})
// quando troca tenant -> recarrega entitlements
watch( watch(
tenantId, rawModel,
async (id) => { (m) => {
entitlementsStore.invalidate() // se veio com itens, atualiza na hora
if (id) await entitlementsStore.loadForTenant(id, { force: true }) 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 // model final exibido (com fallback)
watch( const model = computed(() => {
() => navRole.value, const m = rawModel.value
async () => { if (Array.isArray(m) && m.length) return m
if (!tenantId.value) return if (Array.isArray(lastGoodModel.value) && lastGoodModel.value.length) return lastGoodModel.value
entitlementsStore.invalidate() return []
await entitlementsStore.loadForTenant(tenantId.value, { force: true }) })
}
)
// ✅ rota -> activePath (NÃO fecha menu em nenhum cenário) // ✅ rota -> activePath (NÃO fecha menu)
watch( watch(
() => route.path, () => route.path,
(p) => { layoutState.activePath = p }, (p) => { layoutState.activePath = p },
{ immediate: true } { immediate: true }
) )
// ============================== // ======================================================
// 🔎 Busca no menu (flatten + resultados) // 🔎 Busca no menu (mantive igual)
// ============================== // ======================================================
const query = ref('') const query = ref('')
const showResults = ref(false) const showResults = ref(false)
const activeIndex = ref(-1) const activeIndex = ref(-1)
// ✅ garante Ctrl/Cmd+K mesmo sem recentes
const forcedOpen = ref(false) const forcedOpen = ref(false)
// ref do InputText (pra Ctrl/Cmd + K)
const searchEl = ref(null) const searchEl = ref(null)
// wrapper pra click-outside
const searchWrapEl = ref(null) const searchWrapEl = ref(null)
// Recentes
const RECENT_KEY = 'menu_search_recent' const RECENT_KEY = 'menu_search_recent'
const recent = ref([]) const recent = ref([])
@@ -136,15 +106,11 @@ loadRecent()
watch(query, (v) => { watch(query, (v) => {
const hasText = !!v?.trim() const hasText = !!v?.trim()
// digitou: abre e sai do modo "forced"
if (hasText) { if (hasText) {
forcedOpen.value = false forcedOpen.value = false
showResults.value = true showResults.value = true
return return
} }
// vazio: mantém aberto apenas se foi "forçado" (Ctrl/Cmd+K ou foco)
showResults.value = forcedOpen.value showResults.value = forcedOpen.value
}) })
@@ -163,10 +129,17 @@ function norm (s) {
.trim() .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 = []) { function flattenMenu (items, trail = []) {
const out = [] const out = []
for (const it of (items || [])) { for (const it of (items || [])) {
if (it?.visible === false) continue if (!isVisibleItem(it)) continue
const nextTrail = [...trail, it?.label].filter(Boolean) const nextTrail = [...trail, it?.label].filter(Boolean)
@@ -176,7 +149,10 @@ function flattenMenu (items, trail = []) {
to: it.to, to: it.to,
icon: it.icon, icon: it.icon,
trail: nextTrail, trail: nextTrail,
proBadge: !!it.proBadge,
// ✅ usa cálculo dinâmico vindo do navigation (quando existir)
proBadge: !!(it.__showProBadge ?? it.proBadge),
feature: it.feature || null feature: it.feature || null
}) })
} }
@@ -210,7 +186,6 @@ watch(results, (list) => {
activeIndex.value = list.length ? 0 : -1 activeIndex.value = list.length ? 0 : -1
}) })
// ===== highlight =====
function escapeHtml (s) { function escapeHtml (s) {
return String(s || '') return String(s || '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -235,7 +210,6 @@ function highlight (text, q) {
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}` return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`
} }
// ===== teclado =====
function onSearchKeydown (e) { function onSearchKeydown (e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
showResults.value = false showResults.value = false
@@ -273,7 +247,6 @@ function isTypingTarget (el) {
return tag === 'input' || tag === 'textarea' || el.isContentEditable return tag === 'input' || tag === 'textarea' || el.isContentEditable
} }
// ===== Ctrl/Cmd + K =====
function focusSearch () { function focusSearch () {
forcedOpen.value = true forcedOpen.value = true
showResults.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) { function applyRecent (q) {
query.value = q query.value = q
forcedOpen.value = true forcedOpen.value = true
showResults.value = true showResults.value = true
activeIndex.value = 0 activeIndex.value = 0
nextTick(() => focusSearch())
nextTick(() => {
// garante foco e teclado funcionando
focusSearch()
})
} }
// click outside para fechar painel
function onDocMouseDown (e) { function onDocMouseDown (e) {
if (!showResults.value) return if (!showResults.value) return
const root = searchWrapEl.value const root = searchWrapEl.value
if (!root) return if (!root) return
if (!root.contains(e.target)) { if (!root.contains(e.target)) {
showResults.value = false showResults.value = false
forcedOpen.value = false forcedOpen.value = false
@@ -334,6 +300,7 @@ onMounted(() => {
document.addEventListener('mousedown', onDocMouseDown) document.addEventListener('mousedown', onDocMouseDown)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (acceptEmptyT) clearTimeout(acceptEmptyT)
window.removeEventListener('keydown', onGlobalKeydown, true) window.removeEventListener('keydown', onGlobalKeydown, true)
document.removeEventListener('mousedown', onDocMouseDown) document.removeEventListener('mousedown', onDocMouseDown)
}) })
@@ -356,7 +323,6 @@ const quickDialog = ref(false)
function onQuickCreate () { quickDialog.value = true } function onQuickCreate () { quickDialog.value = true }
function onQuickCreated () { quickDialog.value = false } function onQuickCreated () { quickDialog.value = false }
// controle de “recentes”: mostrar ao focar (mesmo sem recentes, para exibir dicas)
function onSearchFocus () { function onSearchFocus () {
if (!query.value?.trim()) { if (!query.value?.trim()) {
forcedOpen.value = true forcedOpen.value = true
@@ -370,12 +336,29 @@ function onSearchFocus () {
<!-- 🔎 TOPO FIXO --> <!-- 🔎 TOPO FIXO -->
<div ref="searchWrapEl" class="pb-2 border-b border-[var(--surface-border)] bg-[var(--surface-card)]"> <div ref="searchWrapEl" class="pb-2 border-b border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative"> <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"> <FloatLabel variant="on" class="w-full">
<IconField class="w-full"> <IconField class="w-full">
<InputIcon class="pi pi-search" /> <InputIcon class="pi pi-search" />
<InputText <InputText
ref="searchEl" ref="searchEl"
id="menu_search" 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" v-model="query"
class="w-full pr-10" class="w-full pr-10"
variant="filled" variant="filled"
@@ -383,10 +366,9 @@ function onSearchFocus () {
@keydown="onSearchKeydown" @keydown="onSearchKeydown"
/> />
</IconField> </IconField>
<label for="menu_search">Buscar no menu</label> <label for="menu_search">Encontrar menu...</label>
</FloatLabel> </FloatLabel>
<!-- botão limpar busca -->
<button <button
v-if="query.trim()" v-if="query.trim()"
type="button" type="button"
@@ -398,7 +380,6 @@ function onSearchFocus () {
</button> </button>
</div> </div>
<!-- Recentes (quando query vazio) -->
<div <div
v-if="showResults && !query.trim() && recent.length" 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" 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> </button>
</div> </div>
<!-- Resultados -->
<div <div
v-else-if="showResults && results.length" v-else-if="showResults && results.length"
class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden" class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
> >
<button <button
v-for="(r, i) in results" v-for="(r, i) in results"
:key="r.to" :key="String(r.to)"
type="button" type="button"
@mousedown.prevent="goTo(r)" @mousedown.prevent="goTo(r)"
:class="[ :class="[
@@ -449,7 +429,7 @@ function onSearchFocus () {
</div> </div>
<span <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" class="text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"
> >
PRO PRO
@@ -457,48 +437,19 @@ function onSearchFocus () {
</button> </button>
</div> </div>
<div <div v-else-if="showResults && query && !results.length" class="mt-2 px-3 py-2 text-sm opacity-70">
v-else-if="showResults && query && !results.length"
class="mt-2 px-3 py-2 text-sm opacity-70"
>
Nenhum item encontrado. Nenhum item encontrado.
</div> </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> </div>
<!-- SOMENTE O MENU ROLA -->
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
<ul class="layout-menu pb-20"> <ul class="layout-menu pb-20">
<template v-for="(item, i) in model" :key="i"> <template v-for="(item, i) in model" :key="i">
<AppMenuItem <AppMenuItem :item="item" :index="i" :root="true" @quick-create="onQuickCreate" />
:item="item"
:index="i"
:root="true"
@quick-create="onQuickCreate"
/>
</template> </template>
</ul> </ul>
</div> </div>
<!-- rodapé fixo -->
<AppMenuFooterPanel /> <AppMenuFooterPanel />
<ComponentCadastroRapido <ComponentCadastroRapido

View File

@@ -2,6 +2,8 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import Popover from 'primevue/popover'
import { sessionUser, sessionRole } from '@/app/session' import { sessionUser, sessionRole } from '@/app/session'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
@@ -13,7 +15,7 @@ const pop = ref(null)
// ------------------------------------------------------ // ------------------------------------------------------
// RBAC (Tenant): fonte da verdade para permissões por papel // RBAC (Tenant): fonte da verdade para permissões por papel
// ------------------------------------------------------ // ------------------------------------------------------
const { role, canSee, isPatient } = useRoleGuard() const { role, canSee } = useRoleGuard()
// ------------------------------------------------------ // ------------------------------------------------------
// UI labels (nome/iniciais) // UI labels (nome/iniciais)
@@ -33,22 +35,21 @@ const label = computed(() => {
/** /**
* sublabel: * sublabel:
* Aqui eu recomendo exibir o papel do TENANT (role do useRoleGuard), * Prefere exibir o papel do TENANT (role do useRoleGuard),
* porque é ele que realmente governa a UI dentro da clínica. * porque 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.
*/ */
const sublabel = computed(() => { const sublabel = computed(() => {
const r = role.value || sessionRole.value const r = role.value || sessionRole.value
if (!r) return 'Sessão' if (!r) return 'Sessão'
// tenant roles (confirmados no banco): tenant_admin | therapist | patient // tenant roles
if (r === 'tenant_admin') return 'Administrador' if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return 'Administrador'
if (r === 'therapist') return 'Terapeuta' 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 return r
}) })
@@ -60,69 +61,52 @@ function toggle (e) {
} }
function close () { function close () {
try { try { pop.value?.hide() } catch {}
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) { async function safePush (target, fallback) {
try { try {
await router.push(target) const r = router.resolve(target)
} catch (e) { if (r?.matched?.length) return await router.push(target)
// fallback quando o "name" não existe no router } catch {}
if (fallback) {
try { if (fallback) {
await router.push(fallback) try { return await router.push(fallback) } catch {}
} catch {
await router.push('/')
}
} else {
await router.push('/')
}
} }
return router.push('/')
} }
// ------------------------------------------------------
// Actions
// ------------------------------------------------------
function goMyProfile () { function goMyProfile () {
close() close()
safePush({ name: 'account-profile' }, '/account/profile')
// Navegação segura para Account → Profile
safePush(
{ name: 'account-profile' },
'/account/profile'
)
} }
function goSettings () { function goSettings () {
close() close()
// ✅ Decide por RBAC (tenant role), não por sessionRole // ✅ Configurações é RBAC (quem pode ver, vê)
if (canSee('settings.view')) { if (canSee('settings.view')) {
router.push({ name: 'ConfiguracoesAgenda' }) return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings') // fallback genérico
return
} }
// Se não pode ver configurações, manda paciente pro portal. // ✅ quem não pode (ex.: paciente), manda pro portal correto
// (Se amanhã você criar outro papel, esta regra continua segura.) return safePush({ name: 'portal-sessoes' }, '/portal')
if (isPatient.value) {
router.push('/patient/portal')
return
}
router.push('/')
} }
function goSecurity () { function goSecurity () {
close() close()
// ✅ 1) tenta por NAME (recomendado) // ✅ Segurança é "Account": todos podem acessar
// ✅ 2) fallback: caminhos mais prováveis do teu projeto return safePush(
// Ajuste/defina a rota no router como name: 'AdminSecurity' para ficar perfeito { name: 'account-security' },
safePush( '/account/security'
{ name: 'AdminSecurity' },
'/admin/settings/security'
) )
} }
@@ -147,9 +131,10 @@ async function signOut () {
> >
<!-- avatar --> <!-- avatar -->
<img <img
v-if="sessionUser.value?.user_metadata?.avatar_url" v-if="sessionUser?.user_metadata?.avatar_url"
:src="sessionUser.value.user_metadata.avatar_url" :src="sessionUser.user_metadata.avatar_url"
class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]" class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]"
alt="avatar"
/> />
<div <div
v-else v-else

View File

@@ -1,10 +1,10 @@
<!-- src/layout/AppMenuItem.vue -->
<script setup> <script setup>
import { useLayout } from '@/layout/composables/layout' 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 { useRouter } from 'vue-router'
import Popover from 'primevue/popover' import Popover from 'primevue/popover'
import Button from 'primevue/button'
import { useTenantStore } from '@/stores/tenantStore' import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore' 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) { function isSameRoute (current, target) {
if (!current || !target) return false const cur = typeof current === 'string' ? current : toPath(current)
return current === target || current.startsWith(target + '/') const tar = typeof target === 'string' ? target : toPath(target)
if (!cur || !tar) return false
return cur === tar || cur.startsWith(tar + '/')
} }
function hasActiveDescendant (node, currentPath) { function hasActiveDescendant (node, currentPath) {
const children = node?.items || [] const children = node?.items || []
for (const child of children) { 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 if (child?.items?.length && hasActiveDescendant(child, currentPath)) return true
} }
return false return false
} }
const isActive = computed(() => { const isActive = computed(() => {
const current = layoutState.activePath || '' const current = typeof layoutState.activePath === 'string'
? layoutState.activePath
: toPath(layoutState.activePath)
const item = props.item const item = props.item
// grupo com submenu: active se qualquer descendente estiver ativo
if (item?.items?.length) { if (item?.items?.length) {
if (hasActiveDescendant(item, current)) return true 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 return item.path ? current.startsWith(fullPath.value || '') : false
} }
// folha: active se rota igual ao to const leafTo = toPath(item?.to)
return item?.to ? isSameRoute(current, item.to) : false return leafTo ? isSameRoute(current, leafTo) : false
}) })
// ============================== // ==============================
// Feature lock + label // ✅ PRO badge (agora 100% por entitlementsStore)
// ============================== // ==============================
const ownerId = computed(() => tenantStore.activeTenantId || null) const showProBadge = computed(() => {
const isLocked = computed(() => {
const feature = props.item?.feature 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 itemDisabled = computed(() => !!props.item?.disabled)
const isBlocked = computed(() => itemDisabled.value || isLocked.value) const isBlocked = computed(() => itemDisabled.value || isLocked.value)
const labelText = computed(() => { const labelText = computed(() => {
const base = props.item?.label || '' return props.item?.label || ''
return props.item?.proBadge && isLocked.value ? `${base} (PRO)` : base
}) })
const itemClick = async (event, item) => { const itemClick = async (event, item) => {
@@ -96,17 +127,14 @@ const itemClick = async (event, item) => {
return return
} }
// 🚫 disabled -> bloqueia
if (itemDisabled.value) { if (itemDisabled.value) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
return return
} }
// commands
if (item?.command) item.command({ originalEvent: event, item }) if (item?.command) item.command({ originalEvent: event, item })
// ✅ submenu: expande/colapsa e não navega
if (item?.items?.length) { if (item?.items?.length) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -114,24 +142,22 @@ const itemClick = async (event, item) => {
if (isActive.value) { if (isActive.value) {
layoutState.activePath = props.parentPath || '' layoutState.activePath = props.parentPath || ''
} else { } else {
layoutState.activePath = fullPath.value layoutState.activePath = fullPath.value || ''
layoutState.menuHoverActive = true layoutState.menuHoverActive = true
} }
return return
} }
// ✅ leaf: marca ativo e NÃO fecha menu if (item?.to) layoutState.activePath = toPath(item.to)
if (item?.to) layoutState.activePath = item.to
} }
const onMouseEnter = () => { const onMouseEnter = () => {
if (isDesktop() && props.root && props.item?.items && layoutState.menuHoverActive) { if (isDesktop() && props.root && props.item?.items && layoutState.menuHoverActive) {
layoutState.activePath = fullPath.value layoutState.activePath = fullPath.value || ''
} }
} }
/* ---------- POPUP + ---------- */ /* ---------- POPUP + ---------- */
function togglePopover (event) { function togglePopover (event) {
if (isBlocked.value) return if (isBlocked.value) return
pop.value?.toggle(event) pop.value?.toggle(event)
@@ -143,10 +169,7 @@ function closePopover () {
function abrirCadastroRapido () { function abrirCadastroRapido () {
closePopover() closePopover()
emit('quick-create', { emit('quick-create', { entity: props.item?.quickCreateEntity || 'patient', mode: 'rapido' })
entity: props.item?.quickCreateEntity || 'patient',
mode: 'rapido'
})
} }
async function irCadastroCompleto () { async function irCadastroCompleto () {
@@ -157,17 +180,17 @@ async function irCadastroCompleto () {
layoutState.menuHoverActive = false layoutState.menuHoverActive = false
await nextTick() await nextTick()
router.push('/admin/patients/cadastro') router.push({ name: 'admin-pacientes-cadastro' })
} }
</script> </script>
<template> <template>
<li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }"> <li v-show="isVisible" :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
<div v-if="root && item.visible !== false" class="layout-menuitem-root-text"> <div v-if="root" class="layout-menuitem-root-text">
{{ item.label }} {{ item.label }}
</div> </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 <component
:is="item.to && !item.items ? 'router-link' : 'a'" :is="item.to && !item.items ? 'router-link' : 'a'"
v-bind="item.to && !item.items ? { to: item.to } : { href: item.url }" v-bind="item.to && !item.items ? { to: item.to } : { href: item.url }"
@@ -183,8 +206,14 @@ async function irCadastroCompleto () {
<span class="layout-menuitem-text"> <span class="layout-menuitem-text">
{{ labelText }} {{ labelText }}
<!-- (debug) pode remover depois --> </span>
<small style="opacity:.6">[locked={{ isLocked }}]</small>
<!-- 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> </span>
<i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" /> <i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" />
@@ -209,7 +238,7 @@ async function irCadastroCompleto () {
</div> </div>
</Popover> </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"> <ul v-show="root ? true : isActive" class="layout-submenu">
<app-menu-item <app-menu-item
v-for="child in item.items" v-for="child in item.items"
@@ -222,4 +251,4 @@ async function irCadastroCompleto () {
</ul> </ul>
</Transition> </Transition>
</li> </li>
</template> </template>

329
src/layout/AppRail.vue Normal file
View 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
View 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>

View 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>

View File

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

View File

@@ -1,66 +1,73 @@
<script setup> <script setup>
import { useLayout } from '@/layout/composables/layout'; import { useLayout } from '@/layout/composables/layout'
import { onBeforeUnmount, ref, watch } from 'vue'; import { onBeforeUnmount, ref, watch } from 'vue'
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router'
import AppMenu from './AppMenu.vue'; import AppMenu from './AppMenu.vue'
const { layoutState, isDesktop, hasOpenOverlay } = useLayout(); const { layoutState, isDesktop, hasOpenOverlay, closeMenuOnNavigate } = useLayout()
const route = useRoute(); const route = useRoute()
const sidebarRef = ref(null); const sidebarRef = ref(null)
let outsideClickListener = null; let outsideClickListener = null
// ✅ rota mudou:
// - atualiza activePath sempre (desktop e mobile)
// - fecha menu SOMENTE no mobile (evita “sumir” no desktop / inconsistências)
watch( watch(
() => route.path, () => route.path,
(newPath) => { (newPath) => {
if (isDesktop()) layoutState.activePath = null; layoutState.activePath = newPath
else layoutState.activePath = newPath; closeMenuOnNavigate?.()
},
layoutState.overlayMenuActive = false; { immediate: true }
layoutState.mobileMenuActive = false; )
layoutState.menuHoverActive = false;
},
{ immediate: true }
);
// mantém o outside click só quando overlay está aberto e estamos em desktop
watch(hasOpenOverlay, (newVal) => { watch(hasOpenOverlay, (newVal) => {
if (isDesktop()) { if (isDesktop()) {
if (newVal) bindOutsideClickListener(); if (newVal) bindOutsideClickListener()
else unbindOutsideClickListener(); else unbindOutsideClickListener()
} }
}); })
const bindOutsideClickListener = () => { const bindOutsideClickListener = () => {
if (!outsideClickListener) { if (!outsideClickListener) {
outsideClickListener = (event) => { outsideClickListener = (event) => {
if (isOutsideClicked(event)) { if (isOutsideClicked(event)) {
layoutState.overlayMenuActive = false; layoutState.overlayMenuActive = false
} }
};
document.addEventListener('click', outsideClickListener);
} }
};
document.addEventListener('click', outsideClickListener)
}
}
const unbindOutsideClickListener = () => { const unbindOutsideClickListener = () => {
if (outsideClickListener) { if (outsideClickListener) {
document.removeEventListener('click', outsideClickListener); document.removeEventListener('click', outsideClickListener)
outsideClickListener = null; outsideClickListener = null
} }
}; }
const isOutsideClicked = (event) => { 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(() => { onBeforeUnmount(() => {
unbindOutsideClickListener(); unbindOutsideClickListener()
}); })
</script> </script>
<template> <template>
<div ref="sidebarRef" class="layout-sidebar"> <div ref="sidebarRef" class="layout-sidebar">
<AppMenu /> <AppMenu />
</div> </div>
</template> </template>

View File

@@ -1,66 +1,132 @@
<!-- src/layout/AppTopbar.vue -->
<script setup> <script setup>
import { computed, ref, onMounted, provide, nextTick } from 'vue' 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 { useLayout } from '@/layout/composables/layout'
import AppConfigurator from './AppConfigurator.vue' import AppConfigurator from './AppConfigurator.vue'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
import { useEntitlementsStore } from '@/stores/entitlementsStore' import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useTenantStore } from '@/stores/tenantStore' 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 { applyThemeEngine } from '@/theme/theme.options'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
const toast = useToast() const toast = useToast()
const entitlementsStore = useEntitlementsStore() const entitlementsStore = useEntitlementsStore()
const tenantStore = useTenantStore() const tenantStore = useTenantStore()
const { toggleMenu, toggleDarkMode, isDarkTheme, layoutConfig, changeMenuMode } = useLayout() const { toggleMenu, toggleDarkMode, isDarkTheme, layoutConfig, changeMenuMode } = useLayout()
const router = useRouter() const router = useRouter()
const route = useRoute()
const planBtn = ref(null)
/* ---------------------------- /* ----------------------------
Persistência (1 instância) Persistência
----------------------------- */ ----------------------------- */
const { init: initUserSettings, queuePatch } = useUserSettingsPersistence() const { init: initUserSettings, queuePatch } = useUserSettingsPersistence()
provide('queueUserSettingsPatch', queuePatch) 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 Fonte da verdade: DOM
----------------------------- */ ----------------------------- */
function isDarkNow() { function isDarkNow () {
return document.documentElement.classList.contains('app-dark') return document.documentElement.classList.contains('app-dark')
} }
function setDarkMode(shouldBeDark) { function setDarkMode (shouldBeDark) {
const now = isDarkNow() const now = isDarkNow()
if (shouldBeDark !== now) toggleDarkMode() if (shouldBeDark !== now) toggleDarkMode()
} }
async function waitForDarkFlip(before, timeoutMs = 900) { async function waitForDarkFlip (before, timeoutMs = 900) {
const start = performance.now() const start = performance.now()
while (performance.now() - start < timeoutMs) { while (performance.now() - start < timeoutMs) {
await nextTick() await nextTick()
await new Promise((r) => requestAnimationFrame(r)) await new Promise((r) => requestAnimationFrame(r))
const now = isDarkNow() const now = isDarkNow()
if (now !== before) return now if (now !== before) return now
} }
return isDarkNow() return isDarkNow()
} }
/* ---------------------------- async function loadAndApplyUserSettings () {
Bootstrap: carrega e aplica
----------------------------- */
async function loadAndApplyUserSettings() {
try { try {
const { data: u, error: uErr } = await supabase.auth.getUser() const { data: u, error: uErr } = await supabase.auth.getUser()
if (uErr) throw uErr if (uErr) throw uErr
@@ -74,27 +140,27 @@ async function loadAndApplyUserSettings() {
.maybeSingle() .maybeSingle()
if (error) throw error if (error) throw error
if (!settings) { if (!settings) return
console.log('[Topbar][bootstrap] sem user_settings ainda')
return
}
console.log('[Topbar][bootstrap] settings=', settings) // 1) dark/light (DOM é a fonte da verdade)
// dark/light
if (settings.theme_mode) setDarkMode(settings.theme_mode === 'dark') if (settings.theme_mode) setDarkMode(settings.theme_mode === 'dark')
// layoutConfig // 2) layoutConfig
if (settings.preset) layoutConfig.preset = settings.preset if (settings.preset) layoutConfig.preset = settings.preset
if (settings.primary_color) layoutConfig.primary = settings.primary_color if (settings.primary_color) layoutConfig.primary = settings.primary_color
if (settings.surface_color) layoutConfig.surface = settings.surface_color if (settings.surface_color) layoutConfig.surface = settings.surface_color
if (settings.menu_mode) layoutConfig.menuMode = settings.menu_mode if (settings.menu_mode) layoutConfig.menuMode = settings.menu_mode
// aplica tema via engine única // 3) aplica engine UMA vez
applyThemeEngine(layoutConfig) applyThemeEngine(layoutConfig)
// aplica menu mode // ✅ IMPORTANTE:
try { changeMenuMode() } catch (e) { // 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) console.warn('[Topbar][bootstrap] changeMenuMode falhou:', e?.message || e)
} }
} catch (e) { } catch (e) {
@@ -102,45 +168,88 @@ async function loadAndApplyUserSettings() {
} }
} }
/* ---------------------------- async function toggleDarkAndPersistSilently () {
Atalho topbar: Dark/Light
----------------------------- */
async function toggleDarkAndPersistSilently() {
try { try {
const before = isDarkNow() const before = isDarkNow()
console.log('[Topbar][theme] click. before=', before ? 'dark' : 'light')
toggleDarkMode() toggleDarkMode()
const after = await waitForDarkFlip(before) const after = await waitForDarkFlip(before)
const theme_mode = after ? 'dark' : 'light' const theme_mode = after ? 'dark' : 'light'
console.log('[Topbar][theme] after=', theme_mode, 'isDarkTheme=', !!isDarkTheme)
await queuePatch({ theme_mode }, { flushNow: true }) await queuePatch({ theme_mode }, { flushNow: true })
console.log('[Topbar][theme] saved theme_mode=', theme_mode)
} catch (e) { } catch (e) {
console.error('[Topbar][theme] falhou:', e?.message || 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) const trocandoPlano = ref(false)
async function getPlanIdByKey(planKey) { const enablePlanToggle = computed(() => {
const { data, error } = await supabase.from('plans').select('id, key').eq('key', planKey).single() 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 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 const { data, error } = await supabase
.from('subscriptions') .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('tenant_id', tid)
.eq('status', 'active') .eq('status', 'active')
.order('updated_at', { ascending: false }) .order('updated_at', { ascending: false })
@@ -151,69 +260,273 @@ async function getActiveSubscriptionByTenant(tid) {
return data || null return data || null
} }
async function getPlanKeyById(planId) { async function listActivePlansByTarget (target) {
const { data, error } = await supabase.from('plans').select('key').eq('id', planId).single() 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 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 if (trocandoPlano.value) return
trocandoPlano.value = true trocandoPlano.value = true
try { try {
const tid = tenantId.value const sub = planMenuSub.value
if (!tid) { if (!sub?.id) throw new Error('Subscription inválida.')
toast.add({ severity: 'warn', summary: 'Sem tenant ativo', detail: 'Selecione/entre em uma clínica (tenant) antes de trocar o plano.', life: 4500 })
return
}
const sub = await getActiveSubscriptionByTenant(tid)
if (!sub?.id) {
toast.add({ severity: 'warn', summary: 'Sem assinatura ativa', detail: 'Esse tenant ainda não tem subscription ativa. Ative via intenção/pagamento manual.', life: 5000 })
return
}
const atualKey = await getPlanKeyById(sub.plan_id)
const novoKey = atualKey === 'pro' ? 'free' : 'pro'
const novoPlanId = await getPlanIdByKey(novoKey)
const { error: rpcError } = await supabase.rpc('change_subscription_plan', { const { error: rpcError } = await supabase.rpc('change_subscription_plan', {
p_subscription_id: sub.id, p_subscription_id: sub.id,
p_new_plan_id: novoPlanId p_new_plan_id: newPlanId
}) })
if (rpcError) throw rpcError if (rpcError) throw rpcError
entitlementsStore.clear?.() planMenuSub.value = { ...sub, plan_id: newPlanId }
await entitlementsStore.fetch(tid, { force: true })
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) { } catch (err) {
console.error('[PLANO] Erro ao alternar:', err?.message || err) console.error('[PLANO] Erro ao trocar:', err?.message || err)
toast.add({ severity: 'error', summary: 'Erro ao alternar plano', detail: err?.message || 'Falha desconhecida.', life: 5000 }) toast.add({
severity: 'error',
summary: 'Erro ao trocar plano',
detail: err?.message || 'Falha desconhecida.',
life: 6000
})
} finally { } finally {
trocandoPlano.value = false trocandoPlano.value = false
} }
} }
async function logout() { /* ----------------------------
Logout
----------------------------- */
async function logout () {
const tenant = useTenantStore()
const ent = useEntitlementsStore()
const tf = useTenantFeaturesStore()
try { try {
await supabase.auth.signOut() await supabase.auth.signOut()
} finally { } finally {
// limpa possíveis intenções guardadas tenant.reset()
sessionStorage.removeItem('redirect_after_login') ent.invalidate()
sessionStorage.removeItem('intended_area') tf.invalidate()
// ✅ vai para HomeCards sessionStorage.clear()
router.replace('/') localStorage.clear()
// Use router.replace('/') e não push,
// assim o usuário não consegue voltar com o botão "voltar" para uma rota protegida. 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 () => { onMounted(async () => {
await initUserSettings() await initUserSettings()
await loadAndApplyUserSettings() await loadAndApplyUserSettings()
await loadSessionIdentity()
await bootstrapEntitlements()
}) })
</script> </script>
@@ -228,10 +541,24 @@ onMounted(async () => {
<router-link to="/" class="layout-topbar-logo"> <router-link to="/" class="layout-topbar-logo">
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- ... SVG gigante ... --> <!-- ... SVG ... -->
</svg> </svg>
<span>SAKAI</span> <span>SAKAI</span>
</router-link> </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>
<div class="layout-topbar-actions"> <div class="layout-topbar-actions">
@@ -277,13 +604,23 @@ onMounted(async () => {
<div class="layout-topbar-menu hidden lg:block"> <div class="layout-topbar-menu hidden lg:block">
<div class="layout-topbar-menu-content"> <div class="layout-topbar-menu-content">
<Button <Button
label="Plano" v-if="showPlanDevMenu"
icon="pi pi-sync" ref="planBtn"
label="Plano (DEV)"
icon="pi pi-sliders-h"
severity="contrast" severity="contrast"
outlined outlined
:loading="trocandoPlano" :loading="planMenuLoading || trocandoPlano"
:disabled="trocandoPlano" :disabled="planMenuLoading || trocandoPlano"
@click="alternarPlano" @click="openPlanMenu"
/>
<Menu
ref="planMenu"
:model="planMenuModel"
popup
appendTo="body"
:baseZIndex="3000"
/> />
<button type="button" class="layout-topbar-action"> <button type="button" class="layout-topbar-action">
@@ -309,3 +646,46 @@ onMounted(async () => {
</div> </div>
</div> </div>
</template> </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>

View 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 tempo ao prontuário: sessão marcada sessão realizada evolução.
</div>
</template>
</Card>
</div>
</div>
<!-- CONTEÚDO (seção selecionada) -->
<div class="col-span-12 lg:col-span-8 xl:col-span-9">
<!-- Aqui entra /configuracoes/agenda etc -->
<router-view />
</div>
</div>
</div>
</template>

View File

@@ -1,15 +1,17 @@
<!-- src/layout/ConfiguracoesPage.vue --> <!-- src/layout/ConfiguracoesPage.vue -->
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router' 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 route = useRoute()
const router = useRouter() const router = useRouter()
// ── Hero sticky ────────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
const secoes = [ const secoes = [
{ {
key: 'agenda', key: 'agenda',
@@ -57,41 +59,51 @@ function ir(to) {
if (!to) return if (!to) return
if (route.path !== to) router.push(to) 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> </script>
<template> <template>
<div class="p-4"> <!-- Sentinel -->
<!-- HEADER CONCEITUAL --> <div ref="headerSentinelRef" class="cfg-sentinel" />
<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"> <!-- Hero sticky -->
<div class="flex items-start justify-between gap-3"> <div ref="headerEl" class="cfg-hero mb-4" :class="{ 'cfg-hero--stuck': headerStuck }">
<div> <div class="cfg-hero__blobs" aria-hidden="true">
<div class="text-900 text-2xl font-semibold leading-none">Configurações</div> <div class="cfg-hero__blob cfg-hero__blob--1" />
<div class="text-600 mt-2 max-w-2xl"> <div class="cfg-hero__blob cfg-hero__blob--2" />
Defina como sua clínica funciona: agenda, cadastros e preferências. Tudo no mesmo lugar sem espalhar opções pelo sistema. <div class="cfg-hero__blob cfg-hero__blob--3" />
</div> </div>
</div>
<Button <div class="cfg-hero__row1">
label="Voltar" <div class="cfg-hero__brand">
icon="pi pi-arrow-left" <div class="cfg-hero__icon"><i class="pi pi-cog text-lg" /></div>
severity="secondary" <div class="min-w-0">
outlined <div class="cfg-hero__title">Configurações</div>
class="hidden md:inline-flex" <div class="cfg-hero__sub">Defina como sua agenda e clínica funcionam</div>
@click="router.back()"
/>
</div>
</div> </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>
<div class="pt-0">
<div class="grid grid-cols-12 gap-4"> <div class="grid grid-cols-12 gap-4">
<!-- SIDEBAR (seções) --> <!-- SIDEBAR (seções) -->
@@ -151,18 +163,6 @@ function ir(to) {
</div> </div>
</template> </template>
</Card> </Card>
<!-- Card pequeno atalhos opcional -->
<div class="mt-4 hidden lg:block">
<Card>
<template #content>
<div class="text-900 font-medium">Dica</div>
<div class="text-600 text-sm mt-2 leading-relaxed">
Comece pela <b>Agenda</b>. É ela que tempo ao prontuário: sessão marcada sessão realizada evolução.
</div>
</template>
</Card>
</div>
</div> </div>
<!-- CONTEÚDO (seção selecionada) --> <!-- CONTEÚDO (seção selecionada) -->
@@ -173,3 +173,45 @@ function ir(to) {
</div> </div>
</div> </div>
</template> </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>

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
<template><AppLayout area="admin" /></template>
<script setup>
import AppLayout from '../AppLayout.vue'
</script>

View File

@@ -0,0 +1,4 @@
<template><AppLayout area="portal" /></template>
<script setup>
import AppLayout from '../AppLayout.vue'
</script>

View File

@@ -0,0 +1,4 @@
<template><AppLayout area="therapist" /></template>
<script setup>
import AppLayout from '../AppLayout.vue'
</script>

View File

@@ -1,38 +1,76 @@
import { computed, reactive } from 'vue' 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({ const layoutConfig = reactive({
preset: 'Aura', preset: 'Aura',
primary: 'emerald', primary: 'emerald',
surface: null, surface: null,
darkTheme: false, darkTheme: false,
menuMode: 'static' menuMode: 'static',
variant: _loadVariant() // 'classic' | 'rail'
}) })
const layoutState = reactive({ const layoutState = reactive({
staticMenuInactive: false, staticMenuInactive: false,
overlayMenuActive: false, overlayMenuActive: false,
mobileMenuActive: false, // ✅ ADICIONA (estava faltando) mobileMenuActive: false,
profileSidebarVisible: false, profileSidebarVisible: false,
configSidebarVisible: false, configSidebarVisible: false,
sidebarExpanded: false, sidebarExpanded: false,
menuHoverActive: false, menuHoverActive: false,
anchored: false,
activeMenuItem: null, 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 () { 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 = () => { const toggleDarkMode = () => {
if (!document.startViewTransition) { if (!document.startViewTransition) {
executeDarkModeToggle() executeDarkModeToggle()
return return
} }
document.startViewTransition(() => executeDarkModeToggle(event)) // ✅ não usa "event" (undefined) e mantém transição suave quando suportado
} document.startViewTransition(() => executeDarkModeToggle())
const executeDarkModeToggle = () => {
layoutConfig.darkTheme = !layoutConfig.darkTheme
document.documentElement.classList.toggle('app-dark')
} }
const isDesktop = () => window.innerWidth > 991 const isDesktop = () => window.innerWidth > 991
@@ -57,6 +95,8 @@ export function useLayout () {
const hideMobileMenu = () => { const hideMobileMenu = () => {
layoutState.mobileMenuActive = false layoutState.mobileMenuActive = false
layoutState.overlayMenuActive = false
layoutState.menuHoverActive = false
} }
// ✅ use isso ao navegar: mantém menu aberto no desktop, fecha só no mobile // ✅ 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.staticMenuInactive = false
layoutState.overlayMenuActive = false
layoutState.mobileMenuActive = false layoutState.mobileMenuActive = false
layoutState.sidebarExpanded = false layoutState.sidebarExpanded = false
layoutState.menuHoverActive = false layoutState.menuHoverActive = false
layoutState.anchored = 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 isDarkTheme = computed(() => layoutConfig.darkTheme)
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive) const hasOpenOverlay = computed(() => layoutState.overlayMenuActive)
@@ -88,9 +154,10 @@ export function useLayout () {
toggleConfigSidebar, toggleConfigSidebar,
toggleMenu, toggleMenu,
hideMobileMenu, hideMobileMenu,
closeMenuOnNavigate, // ✅ exporta closeMenuOnNavigate,
changeMenuMode, changeMenuMode,
setVariant,
isDesktop, isDesktop,
hasOpenOverlay hasOpenOverlay
} }
} }

View 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 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

View File

@@ -9,12 +9,44 @@ import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice' import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice' import ToastService from 'primevue/toastservice'
// ── 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/tailwind.css'
import '@/assets/styles.scss' import '@/assets/styles.scss'
import { supabase } from '@/lib/supabase/client' 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 { try {
const { data } = await supabase.auth.getUser() const { data } = await supabase.auth.getUser()
const user = data?.user const user = data?.user
@@ -34,7 +66,6 @@ async function applyUserThemeEarly() {
const root = document.documentElement const root = document.documentElement
root.classList.toggle('app-dark', isDark) root.classList.toggle('app-dark', isDark)
// opcional: marca em storage pra teu layout composable ler depois
localStorage.setItem('ui_theme_mode', settings.theme_mode) localStorage.setItem('ui_theme_mode', settings.theme_mode)
} catch {} } catch {}
} }
@@ -49,39 +80,69 @@ window.__fromVisibilityRefresh = false
window.__appBootstrapped = false window.__appBootstrapped = false
// ======================================== // ========================================
// 🛟 ao voltar da aba: refresh leve (sem concorrência + com flag global) // 🛟 ao voltar da aba: refresh leve, sem martelar e sem rodar antes do app subir
let refreshing = false
let refreshTimer = null
let lastVisibilityRefreshAt = 0 let lastVisibilityRefreshAt = 0
document.addEventListener('visibilitychange', async () => { document.addEventListener('visibilitychange', async () => {
if (document.visibilityState !== 'visible') return if (document.visibilityState !== 'visible') return
// só depois do app montar (evita refresh no meio do bootstrap)
if (!window.__appBootstrapped) return
const now = Date.now() 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 if (now - lastVisibilityRefreshAt < 10_000) return
// se já tem refresh em andamento, não entra // se já tem refresh em andamento, não entra
if (window.__sessionRefreshing) return 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 lastVisibilityRefreshAt = now
console.log('[VISIBILITY] Aba voltou -> refreshSession()') console.log('[VISIBILITY] Aba voltou -> refreshSession()')
try { try {
window.__sessionRefreshing = true window.__sessionRefreshing = true
window.__fromVisibilityRefresh = true
await refreshSession() 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 { } finally {
window.__fromVisibilityRefresh = false
window.__sessionRefreshing = false window.__sessionRefreshing = false
} }
}) })
async function bootstrap () { async function bootstrap () {
await initSession({ initial: true }) await initSession({ initial: true })
listenAuthChanges() listenAuthChanges()
await applyUserThemeEarly() await applyUserThemeEarly()
const app = createApp(App) const app = createApp(App)
@@ -94,19 +155,39 @@ async function bootstrap () {
// ✅ garante router pronto antes de montar // ✅ garante router pronto antes de montar
await router.isReady() await router.isReady()
// ✅ PrimeVue global config (tema + locale pt-BR)
app.use(PrimeVue, { app.use(PrimeVue, {
locale: ptBR, // 🔥 isso traduz Calendar/DatePicker
theme: { theme: {
preset: Aura, preset: Aura,
options: { darkModeSelector: '.app-dark' } options: { darkModeSelector: '.app-dark' }
} }
}) })
app.use(ToastService) app.use(ToastService)
app.use(ConfirmationService) 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') app.mount('#app')
// ✅ marca boot completo // ✅ marca boot completo
window.__appBootstrapped = true window.__appBootstrapped = true
} }
bootstrap() bootstrap()

View File

@@ -4,42 +4,175 @@
// 📦 Importação dos menus base por área // 📦 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 therapistMenu from './menus/therapist.menu'
import supervisorMenu from './menus/supervisor.menu'
import editorMenu from './menus/editor.menu'
import portalMenu from './menus/portal.menu' import portalMenu from './menus/portal.menu'
import sakaiDemoMenu from './menus/sakai.demo.menu' import sakaiDemoMenu from './menus/sakai.demo.menu'
import saasMenu from './menus/saas.menu' import saasMenu from './menus/saas.menu'
import { useSaasHealthStore } from '@/stores/saasHealthStore' import { useSaasHealthStore } from '@/stores/saasHealthStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore' import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
// ====================================================== // ======================================================
// 🎭 Mapeamento de role → menu base // 🎭 Mapeamento de role → menu base
// ====================================================== // ======================================================
const MENUS = { const MENUS = {
// ✅ role real do tenant
clinic_admin: adminMenu, clinic_admin: adminMenu,
tenant_admin: adminMenu, // alias
therapist: therapistMenu, therapist: therapistMenu,
supervisor: supervisorMenu,
editor: editorMenu,
patient: portalMenu, patient: portalMenu,
portal_user: portalMenu, // alias (globalRole do paciente)
// ✅ compatibilidade profiles.role saas_admin: saasMenu
admin: adminMenu,
// ✅ legado
tenant_admin: adminMenu
} }
// ====================================================== // ======================================================
// 🧠 Função utilitária // 🧠 Helpers
// Permite que o menu seja:
// - Array direto
// - ou função (ctx) => Array
// ====================================================== // ======================================================
function resolveMenu (builder, ctx) { function resolveMenu (builder, ctx) {
if (!builder) return [] 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 = {}) { 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 saasHealthStore = useSaasHealthStore()
const mismatchCount = saasHealthStore?.mismatchCount || 0 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 tenantFeaturesStore = useTenantFeaturesStore()
const entitlementsStore = useEntitlementsStore()
// 🔹 SaaS overlay aparece somente para SaaS master
const isSaas = sessionCtx?.isSaasAdmin === true 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 = { const ctx = {
...sessionCtx, ...sessionCtx,
mismatchCount, mismatchCount,
tenantFeaturesStore, tenantFeaturesStore,
tenantFeatureEnabled: (key) => { tenantFeatureEnabled,
try { return !!tenantFeaturesStore?.isEnabled?.(key) } catch { return false } tenantLoading,
} tenantFeaturesLoading,
entitlementsStore,
hasFeature
} }
// 🔹 Menu base da role // ✅ role normalizado
const base = resolveMenu(MENUS[role], ctx) const r = normalizeRole(role)
// 🔹 Resolve menu SaaS (array ou função) const baseRaw = resolveMenu(MENUS[r], ctx)
const saas = typeof saasMenu === 'function' const base = decorateMenu(baseRaw, hasFeature)
const saasRaw = typeof saasMenu === 'function'
? saasMenu(ctx, { mismatchCount }) ? saasMenu(ctx, { mismatchCount })
: saasMenu : saasMenu
const saas = decorateMenu(saasRaw, hasFeature)
// ====================================================== // 🔒 SaaS master: somente área SaaS
// 🚀 Menu final if (isSaas) {
// - base sempre const out = [
// - overlay SaaS só para SaaS master ...(saas.length ? saas : coreMenu()),
// - Demo Sakai só para SaaS master em DEV ...(import.meta.env.DEV ? [{ separator: true }, ...sakaiDemoMenu] : [])
// ====================================================== ]
return out
}
return [ // ✅ fallback: nunca retorna vazio
...base, if (!base || !base.length) {
return coreMenu()
}
...(isSaas && saas.length ? [{ separator: true }, ...saas] : []), return [...base]
...(isSaas && import.meta.env.DEV
? [{ separator: true }, ...sakaiDemoMenu]
: [])
]
} }

View File

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

View 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
}

View 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' }
]
}
]

View File

@@ -6,10 +6,11 @@ export default [
// ✅ Básico (sempre) // ✅ Básico (sempre)
// ====================== // ======================
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/portal' }, { label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/portal' },
{ label: 'Minha Agenda', icon: 'pi pi-fw pi-calendar-plus', to: '/portal/agenda' }, { label: 'Minhas sessões', icon: 'pi pi-fw pi-user', to: '/portal/sessoes' },
{ label: 'Agendar Sessão', icon: 'pi pi-fw pi-user', to: '/portal/agenda/new' },
// ✅ Conta é global, não do portal // ✅ 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) // 🔒 PRO (exemplos futuros no portal do paciente)

View File

@@ -5,7 +5,6 @@ export default function saasMenu (sessionCtx, opts = {}) {
const mismatchCount = Number(opts?.mismatchCount || 0) const mismatchCount = Number(opts?.mismatchCount || 0)
// ✅ helper p/ evitar repetir spread + manter comentários intactos
const mismatchBadge = mismatchCount > 0 const mismatchBadge = mismatchCount > 0
? { badge: String(mismatchCount), badgeClass: 'p-badge p-badge-danger' } ? { badge: String(mismatchCount), badgeClass: 'p-badge p-badge-danger' }
: {} : {}
@@ -14,49 +13,40 @@ export default function saasMenu (sessionCtx, opts = {}) {
{ {
label: 'SaaS', label: 'SaaS',
icon: 'pi pi-building', icon: 'pi pi-building',
path: '/saas', // ✅ necessário p/ expandir e controlar activePath path: '/saas',
items: [ items: [
{ label: 'Dashboard', icon: 'pi pi-chart-bar', to: '/saas' }, { label: 'Dashboard', icon: 'pi pi-chart-bar', to: '/saas' },
{ {
label: 'Planos', label: 'Planos',
icon: 'pi pi-star', icon: 'pi pi-star',
path: '/saas/plans', // ✅ absoluto (mais confiável p/ active/expand) path: '/saas/plans',
items: [ items: [
{ label: 'Listagem de Planos', icon: 'pi pi-list', to: '/saas/plans' }, { label: 'Planos e Preços', icon: 'pi pi-list', to: '/saas/plans' },
{ label: 'Vitrine Pública', icon: 'pi pi-megaphone', to: '/saas/plans-public' },
// ✅ vitrine pública (pricing page) { label: 'Recursos', icon: 'pi pi-bolt', to: '/saas/features' },
{ label: 'Vitrine Pública', icon: 'pi pi-megaphone', to: '/saas/plans-public' }, { 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: 'Recursos', icon: 'pi pi-bolt', to: '/saas/features' },
{ label: 'Controle de Recursos', icon: 'pi pi-th-large', to: '/saas/plan-features' }
] ]
}, },
{ {
label: 'Assinaturas', label: 'Assinaturas',
icon: 'pi pi-credit-card', icon: 'pi pi-credit-card',
path: '/saas/subscriptions', // ✅ absoluto path: '/saas/subscriptions',
items: [ items: [
{ label: 'Listagem de Assinaturas', icon: 'pi pi-list', to: '/saas/subscriptions' }, { label: 'Listagem de Assinaturas', icon: 'pi pi-list', to: '/saas/subscriptions' },
{ label: 'Histórico', icon: 'pi pi-history', to: '/saas/subscription-events' }, { 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', label: 'Saúde das Assinaturas',
icon: 'pi pi-shield', icon: 'pi pi-shield',
to: '/saas/subscription-health', 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' } { label: 'Clínicas (Tenants)', icon: 'pi pi-users', to: '/saas/tenants' }
] ]
} }

View File

@@ -1,8 +1,4 @@
export default [ export default [
{
label: 'Home',
items: [{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' }]
},
{ {
label: 'UI Components', label: 'UI Components',
path: '/uikit', path: '/uikit',
@@ -29,7 +25,6 @@ export default [
icon: 'pi pi-fw pi-prime', icon: 'pi pi-fw pi-prime',
path: '/blocks', path: '/blocks',
items: [ items: [
{ label: 'Free Blocks', icon: 'pi pi-fw pi-eye', to: '/utilities' },
{ label: 'All Blocks', icon: 'pi pi-fw pi-globe', url: 'https://blocks.primevue.org/', target: '_blank' } { label: '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: '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: '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: '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', label: 'Get Started',
path: '/start', path: '/start',
items: [ 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' } { label: 'View Source', icon: 'pi pi-fw pi-github', url: 'https://github.com/primefaces/sakai-vue', target: '_blank' }
] ]
} }

View 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' }
]
}
]

View File

@@ -2,7 +2,7 @@
export default [ export default [
{ {
label: 'Therapist', label: 'Terapeuta',
items: [ items: [
// ====================================================== // ======================================================
// 📊 DASHBOARD // 📊 DASHBOARD
@@ -12,7 +12,10 @@ export default [
// ====================================================== // ======================================================
// 📅 AGENDA // 📅 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 // 👥 PATIENTS
@@ -34,14 +37,16 @@ export default [
label: 'Online Scheduling', label: 'Online Scheduling',
icon: 'pi pi-fw pi-globe', icon: 'pi pi-fw pi-globe',
to: '/therapist/online-scheduling', to: '/therapist/online-scheduling',
feature: 'online_scheduling.manage', feature: 'online_scheduling',
proBadge: true proBadge: true
}, },
// ====================================================== // ======================================================
// 👤 ACCOUNT // 👤 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' }
] ]
} }
] ]

View File

@@ -9,10 +9,13 @@ import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore' import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
import { buildUpgradeUrl } from '@/utils/upgradeContext' import { buildUpgradeUrl } from '@/utils/upgradeContext'
import { useMenuStore } from '@/stores/menuStore'
import { getMenuByRole } from '@/navigation'
import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session' import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session'
// ✅ separa RBAC (papel) vs Plano (upgrade) // ✅ 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) // cache simples (evita bater no banco em toda navegação)
let sessionUidCache = null 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 || '')) 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) { function roleToPath (role) {
// ✅ clínica: aceita nomes canônicos e legado // ✅ clínica: aceita nomes canônicos e legado
if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin' if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin'
if (role === 'therapist') return '/therapist' 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 === 'patient') return '/portal'
if (role === 'portal_user') return '/portal'
// ✅ saas master // ✅ saas master
if (role === 'saas_admin') return '/saas' if (role === 'saas_admin') return '/saas'
@@ -70,19 +106,22 @@ async function waitSessionIfRefreshing () {
async function isSaasAdmin (uid) { async function isSaasAdmin (uid) {
if (!uid) return false if (!uid) return false
if (saasAdminCacheUid === uid && typeof saasAdminCacheIsAdmin === 'boolean') { if (saasAdminCacheUid === uid && typeof saasAdminCacheIsAdmin === 'boolean') {
return saasAdminCacheIsAdmin return saasAdminCacheIsAdmin
} }
const { data, error } = await supabase const { data, error } = await supabase
.from('saas_admins') .from('profiles')
.select('user_id') .select('role')
.eq('user_id', uid) .eq('id', uid)
.maybeSingle() .single()
const ok = !error && data?.role === 'saas_admin'
const ok = !error && !!data
saasAdminCacheUid = uid saasAdminCacheUid = uid
saasAdminCacheIsAdmin = ok saasAdminCacheIsAdmin = ok
return 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) { function matchesRoles (roles, activeRole) {
if (!Array.isArray(roles) || !roles.length) return true 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) { export function applyGuards (router) {
@@ -165,20 +315,96 @@ export function applyGuards (router) {
return { path: '/auth/login' } 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, // ✅ Pending invite (Modelo B)
// redireciona para /accept-invite antes de qualquer load pesado.
// ========================================== // ==========================================
const pendingInviteToken = readPendingInviteToken() const pendingInviteToken = readPendingInviteToken()
// Se tiver lixo no storage, limpa para não “travar” o app.
if (pendingInviteToken && !isUuid(pendingInviteToken)) { if (pendingInviteToken && !isUuid(pendingInviteToken)) {
clearPendingInviteToken() 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 ( if (
pendingInviteToken && pendingInviteToken &&
isUuid(pendingInviteToken) && isUuid(pendingInviteToken) &&
@@ -199,6 +425,11 @@ export function applyGuards (router) {
const tf0 = useTenantFeaturesStore() const tf0 = useTenantFeaturesStore()
if (typeof tf0.invalidate === 'function') tf0.invalidate() 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) { if (to.meta?.saasAdmin) {
console.timeLog(tlabel, 'isSaasAdmin') 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' } } 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) console.timeEnd(tlabel)
return true 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 // ✅ Abaixo daqui é tudo tenant-app
// ================================ // ================================
@@ -232,21 +553,33 @@ export function applyGuards (router) {
} }
// se não tem tenant ativo: // 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) { if (!tenant.activeTenantId) {
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : [] const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
// 1) tenta casar role da rota (ex.: therapist) com membership // 1) tenta casar role da rota (ex.: therapist) com membership
const wantedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : [] const wantedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : []
const preferred = wantedRoles.length const wantedNorm = wantedRoles.map(normalizeRole)
? mem.find(m => m && m.status === 'active' && m.tenant_id && wantedRoles.includes(m.role))
const preferred = wantedNorm.length
? mem.find(m =>
m &&
m.status === 'active' &&
m.tenant_id &&
wantedNorm.includes(normalizeRole(m.role, m.kind))
)
: null : null
// 2) fallback: primeiro active // 2) fallback: primeiro active
const firstActive = preferred || mem.find(m => m && m.status === 'active' && m.tenant_id) const firstActive = preferred || mem.find(m => m && m.status === 'active' && m.tenant_id)
if (!firstActive) { 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 } if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
console.timeEnd(tlabel) console.timeEnd(tlabel)
return { path: '/pages/access' } 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 (!tenantId) {
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true } if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
console.timeEnd(tlabel) console.timeEnd(tlabel)
return { path: '/pages/access' } 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) // se trocar tenant, invalida cache de tenant_features (evita ler feature de tenant errado)
const tfSwitch = useTenantFeaturesStore() const tfSwitch = useTenantFeaturesStore()
if (tfSwitch.loadedForTenantId && tfSwitch.loadedForTenantId !== tenantId) { 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) // entitlements (✅ carrega só quando precisa)
@@ -280,6 +686,17 @@ export function applyGuards (router) {
await loadEntitlementsSafe(ent, tenantId, true) 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) // ✅ tenant_features (módulos ativáveis por clínica)
// meta.tenantFeature = 'patients' | ... // meta.tenantFeature = 'patients' | ...
@@ -288,10 +705,14 @@ export function applyGuards (router) {
if (requiredTenantFeature) { if (requiredTenantFeature) {
const tf = useTenantFeaturesStore() const tf = useTenantFeaturesStore()
console.timeLog(tlabel, 'tenantFeatures.fetchForTenant') console.timeLog(tlabel, 'tenantFeatures.fetchForTenant')
await tf.fetchForTenant(tenantId, { force: false }) await fetchTenantFeaturesSafe(tf, tenantId)
if (!tf.isEnabled(requiredTenantFeature)) { // ✅ IMPORTANTÍSSIMO: passa tenantId
// evita loop const enabled = typeof tf.isEnabled === 'function'
? tf.isEnabled(requiredTenantFeature, tenantId)
: false
if (!enabled) {
if (to.path === '/admin/clinic/features') { console.timeEnd(tlabel); return true } if (to.path === '/admin/clinic/features') { console.timeEnd(tlabel); return true }
console.timeEnd(tlabel) console.timeEnd(tlabel)
@@ -304,65 +725,73 @@ export function applyGuards (router) {
// ------------------------------------------------ // ------------------------------------------------
// ✅ RBAC (roles) — BLOQUEIA se não for compatível // ✅ 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 const allowedRolesRaw = Array.isArray(to.meta?.roles) ? to.meta.roles : null
if (allowedRoles && allowedRoles.length && !allowedRoles.includes(tenant.activeRole)) { 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 mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
const compatible = mem.find(m => 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) { if (compatible) {
// muda role ativo para o compatível (mesmo tenant) tenant.activeRole = normalizeRole(compatible.role, compatible.kind)
tenant.activeRole = compatible.role
} else { } else {
// 🔥 aqui era o "furo": antes ajustava se achasse, mas se não achasse, deixava passar.
console.timeEnd(tlabel) console.timeEnd(tlabel)
return denyByRole({ to, currentRole: tenant.activeRole }) return denyByRole({ to, currentRole: tenant.activeRole })
} }
} }
// role guard (singular) - mantém compatibilidade // role guard (singular)
const requiredRole = to.meta?.role const requiredRoleRaw = to.meta?.role
if (requiredRole && tenant.activeRole !== requiredRole) { const requiredRole = requiredRoleRaw ? normalizeRole(requiredRoleRaw) : null
// RBAC singular também é "papel" → cai fora (não é upgrade)
if (requiredRole && normalizeRole(tenant.activeRole) !== requiredRole) {
console.timeEnd(tlabel) console.timeEnd(tlabel)
return denyByRole({ to, currentRole: tenant.activeRole }) return denyByRole({ to, currentRole: tenant.activeRole })
} }
// ------------------------------------------------ // ------------------------------------------------
// ✅ feature guard (entitlements/plano → upgrade) // ✅ 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 const requiredFeature = to.meta?.feature
if (requiredFeature && ent?.can && !ent.can(requiredFeature)) { if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
// evita loop
if (to.name === 'upgrade') { console.timeEnd(tlabel); return true } if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
// Mantém compatibilidade com seu fluxo existente (buildUpgradeUrl)
const url = buildUpgradeUrl({ const url = buildUpgradeUrl({
missingKeys: [requiredFeature], missingKeys: [requiredFeature],
redirectTo: to.fullPath 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) console.timeEnd(tlabel)
return url return url
} }
// ======================================================
// ✅ MENU: monta 1x por contexto APÓS estabilizar tenant+role
// ======================================================
await ensureMenuBuilt({
uid,
tenantId,
tenantRole: tenant.activeRole,
globalRole
})
console.timeEnd(tlabel) console.timeEnd(tlabel)
return true return true
} catch (e) { } catch (e) {
console.error('[guards] erro no beforeEach:', e) console.error('[guards] erro no beforeEach:', e)
// fallback seguro
if (to.path.startsWith('/auth')) return true if (to.path.startsWith('/auth')) return true
if (to.meta?.public) return true if (to.meta?.public) return true
if (to.path === '/pages/access') 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) { if (!window.__supabaseAuthListenerBound) {
window.__supabaseAuthListenerBound = true window.__supabaseAuthListenerBound = true
supabase.auth.onAuthStateChange(() => { supabase.auth.onAuthStateChange((event, sess) => {
sessionUidCache = null // ⚠️ NÃO derrubar caches em token refresh / eventos redundantes.
saasAdminCacheUid = null const uid = sess?.user?.id || null
saasAdminCacheIsAdmin = null
try { // ✅ SIGNED_OUT: aqui sim zera tudo
const tf = useTenantFeaturesStore() if (event === 'SIGNED_OUT') {
if (typeof tf.invalidate === 'function') tf.invalidate() sessionUidCache = null
} catch {} 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
}) })
} }
} }

View File

@@ -2,15 +2,17 @@ import { createRouter, createWebHistory, isNavigationFailure, NavigationFailureT
import configuracoesRoutes from './routes.configs'; import configuracoesRoutes from './routes.configs';
import meRoutes from './routes.account'; import meRoutes from './routes.account';
import adminRoutes from './routes.admin'; import adminRoutes from './routes.clinic';
import authRoutes from './routes.auth'; import authRoutes from './routes.auth';
import billingRoutes from './routes.billing'; import billingRoutes from './routes.billing';
import demoRoutes from './routes.demo'; import demoRoutes from './routes.demo';
import miscRoutes from './routes.misc'; import miscRoutes from './routes.misc';
import patientRoutes from './routes.portal'; import portalRoutes from './routes.portal';
import publicRoutes from './routes.public'; import publicRoutes from './routes.public';
import saasRoutes from './routes.saas'; import saasRoutes from './routes.saas';
import therapistRoutes from './routes.therapist'; import therapistRoutes from './routes.therapist';
import supervisorRoutes from './routes.supervisor';
import editorRoutes from './routes.editor';
import featuresRoutes from './routes.features' import featuresRoutes from './routes.features'
import { applyGuards } from './guards'; import { applyGuards } from './guards';
@@ -24,7 +26,9 @@ const routes = [
...(Array.isArray(meRoutes) ? meRoutes : [meRoutes]), ...(Array.isArray(meRoutes) ? meRoutes : [meRoutes]),
...(Array.isArray(adminRoutes) ? adminRoutes : [adminRoutes]), ...(Array.isArray(adminRoutes) ? adminRoutes : [adminRoutes]),
...(Array.isArray(therapistRoutes) ? therapistRoutes : [therapistRoutes]), ...(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(demoRoutes) ? demoRoutes : [demoRoutes]),
...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]), ...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]),
...(Array.isArray(featuresRoutes) ? featuresRoutes : [featuresRoutes]), ...(Array.isArray(featuresRoutes) ? featuresRoutes : [featuresRoutes]),

View File

@@ -3,7 +3,7 @@ import AppLayout from '@/layout/AppLayout.vue'
export default { export default {
path: '/account', path: '/account',
component: AppLayout, component: AppLayout,
meta: { requiresAuth: true }, meta: { requiresAuth: true, area: 'account' },
children: [ children: [
{ {
path: '', path: '',

View File

@@ -1,28 +1,24 @@
// src/router/routes.admin.js // src/router/routes.clinic.js
import AppLayout from '@/layout/AppLayout.vue' import AppLayout from '@/layout/AppLayout.vue'
export default { export default {
path: '/admin', path: '/admin',
component: AppLayout, component: AppLayout,
meta: { meta: {
// 🔐 Tudo aqui dentro exige login // 🔐 Tudo aqui dentro exige login
requiresAuth: true, area: 'admin',
requiresAuth: true,
// 👤 Perfil de acesso (tenant-level) // 👤 Perfil de acesso (tenant-level)
// tenantStore normaliza tenant_admin -> clinic_admin, mas mantemos compatibilidade // tenantStore normaliza tenant_admin -> clinic_admin, mas mantemos compatibilidade
roles: ['clinic_admin', 'tenant_admin'] roles: ['clinic_admin']
}, },
children: [ children: [
// ====================================================== // ======================================================
// 📊 DASHBOARD // 📊 DASHBOARD
// ====================================================== // ======================================================
{ { path: '', name: 'admin.dashboard', component: () => import('@/views/pages/clinic/ClinicDashboard.vue') },
path: '',
name: 'admin-dashboard',
component: () => import('@/views/pages/admin/AdminDashboard.vue')
},
// ====================================================== // ======================================================
// 🧩 CLÍNICA — MÓDULOS (tenant_features) // 🧩 CLÍNICA — MÓDULOS (tenant_features)
@@ -30,53 +26,54 @@ export default {
{ {
path: 'clinic/features', path: 'clinic/features',
name: 'admin-clinic-features', name: 'admin-clinic-features',
component: () => import('@/views/pages/admin/clinic/ClinicFeaturesPage.vue'), component: () => import('@/views/pages/clinic/clinic/ClinicFeaturesPage.vue'),
meta: { meta: {
// opcional: restringir apenas para admin canônico // opcional: restringir apenas para admin canônico
roles: ['clinic_admin', 'tenant_admin'] 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', path: 'clinic/professionals',
name: 'admin-agenda', name: 'admin-clinic-professionals',
component: () => import('@/views/pages/admin/agenda/MyAppointmentsPage.vue'), component: () => import('@/views/pages/clinic/clinic/ClinicProfessionalsPage.vue'),
meta: { 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 // ✅ NOVO: Compromissos determinísticos (tipos)
{
path: 'agenda/adicionar',
name: 'admin-agenda-adicionar',
component: () => import('@/views/pages/admin/agenda/NewAppointmentPage.vue'),
meta: {
feature: 'agenda.manage'
}
},
{ {
path: 'agenda/clinica', path: 'agenda/compromissos',
name: 'admin-agenda-clinica', name: 'admin-agenda-compromissos',
component: () => import('@/features/agenda/pages/AgendaClinicaPage.vue'), component: () => import('@/features/agenda/pages/CompromissosDeterminados.vue'),
meta: { meta: {
feature: 'agenda.view', feature: 'agenda.view',
roles: ['clinic_admin', 'tenant_admin'] 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', path: 'online-scheduling',
name: 'admin-online-scheduling', name: 'admin-online-scheduling',
component: () => import('@/views/pages/admin/OnlineSchedulingAdminPage.vue'), component: () => import('@/views/pages/clinic/OnlineSchedulingAdminPage.vue'),
meta: { meta: {
feature: 'online_scheduling.manage' feature: 'online_scheduling.manage'
} }

View File

@@ -22,11 +22,6 @@ const configuracoesRoutes = {
name: 'ConfiguracoesAgenda', name: 'ConfiguracoesAgenda',
component: () => import('@/layout/configuracoes/ConfiguracoesAgendaPage.vue') 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') },
] ]
} }
] ]

View File

@@ -4,7 +4,12 @@ export default {
// ✅ não use '/' aqui (conflita com HomeCards) // ✅ não use '/' aqui (conflita com HomeCards)
path: '/demo', path: '/demo',
component: AppLayout, 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: [ children: [
{ path: 'uikit/formlayout', name: 'uikit-formlayout', component: () => import('@/views/uikit/FormLayout.vue') }, { path: 'uikit/formlayout', name: 'uikit-formlayout', component: () => import('@/views/uikit/FormLayout.vue') },
{ path: 'uikit/input', name: 'uikit-input', component: () => import('@/views/uikit/InputDoc.vue') }, { path: 'uikit/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/empty', name: 'pages-empty', component: () => import('@/views/pages/Empty.vue') },
{ path: 'pages/crud', name: 'pages-crud', component: () => import('@/views/pages/Crud.vue') } { path: 'pages/crud', name: 'pages-crud', component: () => import('@/views/pages/Crud.vue') }
] ]
} }

View 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')
}
]
}

View File

@@ -4,24 +4,22 @@ import AppLayout from '@/layout/AppLayout.vue'
export default { export default {
path: '/portal', path: '/portal',
component: AppLayout, component: AppLayout,
meta: { requiresAuth: true, roles: ['patient'] }, meta: { area: 'portal', requiresAuth: true, profileRole: 'portal_user' },
children: [ children: [
{ { path: '', name: 'portal.dashboard', component: () => import('@/views/pages/portal/PortalDashboard.vue') },
path: '', {
name: 'portal-dashboard', path: 'sessoes',
component: () => import('@/views/pages/portal/PortalDashboard.vue') name: 'portal-sessoes',
component: () => import('@/views/pages/portal/MinhasSessoes.vue')
}, },
// ✅ Appointments (era agenda) // ======================================================
// 💳 MEU PLANO (assinatura pessoal do paciente)
// ======================================================
{ {
path: 'agenda', path: 'meu-plano',
name: 'portal-agenda', name: 'portal-meu-plano',
component: () => import('@/views/pages/portal/agenda/MyAppointmentsPage.vue') component: () => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')
},
{
path: 'agenda/new',
name: 'portal-agenda-new',
component: () => import('@/views/pages/portal/agenda/NewAppointmentPage.vue')
} }
] ]
} }

View File

@@ -30,6 +30,11 @@ export default {
name: 'saas-plan-features', name: 'saas-plan-features',
component: () => import('@/views/pages/saas/SaasPlanFeaturesMatrixPage.vue') component: () => import('@/views/pages/saas/SaasPlanFeaturesMatrixPage.vue')
}, },
{
path: 'plan-limits',
name: 'saas-plan-limits',
component: () => import('@/views/pages/saas/SaasPlanLimitsPage.vue')
},
{ {
path: 'subscriptions', path: 'subscriptions',
name: 'saas-subscriptions', name: 'saas-subscriptions',
@@ -45,7 +50,7 @@ export default {
name: 'saas-subscription-health', name: 'saas-subscription-health',
component: () => import('@/views/pages/saas/SaasSubscriptionHealthPage.vue') component: () => import('@/views/pages/saas/SaasSubscriptionHealthPage.vue')
}, },
{ {
path: 'subscription-intents', path: 'subscription-intents',
name: 'saas.subscriptionIntents', name: 'saas.subscriptionIntents',
component: () => import('@/views/pages/saas/SubscriptionIntentsPage.vue'), component: () => import('@/views/pages/saas/SubscriptionIntentsPage.vue'),
@@ -57,4 +62,4 @@ export default {
component: () => import('@/views/pages/saas/SaasPlaceholder.vue') component: () => import('@/views/pages/saas/SaasPlaceholder.vue')
} }
] ]
} }

View 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')
}
]
}

View File

@@ -5,24 +5,13 @@ export default {
path: '/therapist', path: '/therapist',
component: AppLayout, component: AppLayout,
meta: { meta: { area: 'therapist', requiresAuth: true, roles: ['therapist'] },
// 🔐 Tudo aqui dentro exige login
requiresAuth: true,
// 👤 Perfil de acesso (tenant-level)
roles: ['therapist']
},
children: [ children: [
// ====================================================== // ======================================================
// 📊 DASHBOARD // 📊 DASHBOARD
// ====================================================== // ======================================================
{ { path: '', name: 'therapist.dashboard', component: () => import('@/views/pages/therapist/TherapistDashboard.vue') },
path: '',
name: 'therapist-dashboard',
component: () => import('@/views/pages/therapist/TherapistDashboard.vue')
// herda requiresAuth + roles do pai
},
// ====================================================== // ======================================================
// 📅 AGENDA // 📅 AGENDA
@@ -30,81 +19,81 @@ export default {
{ {
path: 'agenda', path: 'agenda',
name: 'therapist-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: { meta: {
feature: 'agenda.view' feature: 'agenda.view'
} }
}, },
// ✅ Compromissos determinísticos
{ {
path: 'agenda/adicionar', path: 'agenda/compromissos',
name: 'therapist-agenda-adicionar', name: 'therapist-agenda-compromissos',
component: () => import('@/views/pages/therapist/agenda/NewAppointmentPage.vue'), component: () => import('@/features/agenda/pages/CompromissosDeterminados.vue'),
meta: { 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 // 👥 PATIENTS
// ====================================================== // ======================================================
{ {
path: 'patients', path: 'patients',
name: 'therapist-patients', name: 'therapist-patients',
component: () => import('@/features/patients/PatientsListPage.vue') component: () => import('@/features/patients/PatientsListPage.vue')
}, },
// Create patient
{ {
path: 'patients/cadastro', path: 'patients/cadastro',
name: 'therapist-patients-create', name: 'therapist-patients-create',
component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue') component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue')
}, },
{ {
path: 'patients/cadastro/:id', path: 'patients/cadastro/:id',
name: 'therapist-patients-edit', name: 'therapist-patients-edit',
component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue'), component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue'),
props: true props: true
}, },
// 👥 Groups
{ {
path: 'patients/grupos', path: 'patients/grupos',
name: 'therapist-patients-groups', name: 'therapist-patients-groups',
component: () => import('@/features/patients/grupos/GruposPacientesPage.vue') component: () => import('@/features/patients/grupos/GruposPacientesPage.vue')
}, },
// 🏷️ Tags
{ {
path: 'patients/tags', path: 'patients/tags',
name: 'therapist-patients-tags', name: 'therapist-patients-tags',
component: () => import('@/features/patients/tags/TagsPage.vue') component: () => import('@/features/patients/tags/TagsPage.vue')
}, },
// 🔗 External Link
{ {
path: 'patients/link-externo', path: 'patients/link-externo',
name: 'therapist-patients-link-externo', name: 'therapist-patients-link-externo',
component: () => import('@/features/patients/cadastro/PatientsExternalLinkPage.vue') component: () => import('@/features/patients/cadastro/PatientsExternalLinkPage.vue')
}, },
// 📥 Received Registrations
{ {
path: 'patients/cadastro/recebidos', path: 'patients/cadastro/recebidos',
name: 'therapist-patients-recebidos', name: 'therapist-patients-recebidos',
component: () => component: () => import('@/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue')
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', path: 'online-scheduling',
name: 'therapist-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', path: 'settings/security',
name: 'therapist-settings-security', name: 'therapist-settings-security',
component: () => import('@/views/pages/auth/SecurityPage.vue') 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