Documentos Pacientes, Template Documentos Pacientes Saas, Documentos prontuários, Documentos Externos, Visualização Externa, Permissão de Visualização, Render Otimização

This commit is contained in:
Leonardo
2026-03-30 14:08:19 -03:00
parent 0658e2e9bf
commit d088a89fb7
112 changed files with 115867 additions and 5266 deletions

View File

@@ -43,7 +43,11 @@
"Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/schema.sql\")", "Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/schema.sql\")",
"Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/data.sql\")", "Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/data.sql\")",
"Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/full_dump.sql\")", "Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/full_dump.sql\")",
"Bash(wc:*)" "Bash(wc:*)",
"Bash(python _wizard_patch.py)",
"Bash(rm _wizard_patch.py)",
"Bash(npm ls:*)",
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/src/features/patients -type f \\\\\\(-name *.vue -o -name *.js \\\\\\))"
] ]
} }
} }

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,96 @@
# README — generate-dashboard.js
Script Node.js que lê o `schema.sql` do backup mais recente e gera um `dashboard.html` interativo com a visão completa do banco de dados do projeto.
---
## Como usar
Coloque o `generate-dashboard.js` na **raiz do projeto** (mesma pasta do `db.cjs`) e rode:
```bash
# Usa o backup mais recente automaticamente
node generate-dashboard.js
# Ou especifica uma data
node generate-dashboard.js 2026-03-27
```
O arquivo `dashboard.html` será gerado na raiz do projeto. Basta abrir no browser.
---
## Fluxo recomendado
Sempre que fizer alterações no banco, rode os dois comandos em sequência:
```bash
node db.cjs backup # gera o backup em database-novo/backups/YYYY-MM-DD/
node generate-dashboard.js # lê o backup mais recente e gera o dashboard.html
```
---
## O que o dashboard mostra
- **Visão geral** — cards com os 9 domínios do projeto, quantidade de tabelas e FKs por domínio
- **Tabelas** — todas as 86 tabelas com colunas, tipos, badges PK/FK
- **Foreign Keys** — cada FK aparece como link clicável que pula direto para a tabela destino
- **Views** — lista das 24 views do schema público
- **Busca** — busca em tempo real por nome de tabela ou nome de coluna
- **Sidebar** — navegação por domínio
---
## Estrutura de pastas esperada
O script espera essa estrutura para funcionar:
```
raiz-do-projeto/
├── db.cjs
├── db.config.json
├── generate-dashboard.js ← script
├── dashboard.html ← gerado aqui
└── database-novo/
└── backups/
└── 2026-03-27/
├── schema.sql ← lido pelo script
├── data.sql
└── full_dump.sql
```
---
## Tabelas novas não aparecem no domínio certo?
Quando você criar uma migration nova com uma tabela nova, ela aparecerá no dashboard na seção **"Outros"** e o script vai avisar no terminal:
```
⚠ Tabelas novas sem domínio definido (aparecerão em "Outros"):
- minha_tabela_nova
→ Edite DOMAIN_TABLES no script para mapeá-las.
```
Para corrigir, abra o `generate-dashboard.js` e adicione a tabela no domínio correto dentro do objeto `DOMAIN_TABLES` no topo do arquivo:
```js
const DOMAIN_TABLES = {
'Agenda': [
'agenda_eventos',
'agenda_configuracoes',
// ...
'minha_tabela_nova', // ← adiciona aqui
],
// ...
};
```
Depois rode `node generate-dashboard.js` novamente.
---
## Requisitos
- Node.js instalado (qualquer versão >= 14)
- Sem dependências externas — usa apenas módulos nativos (`fs`, `path`)

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,457 @@
#!/usr/bin/env node
// =============================================================================
// AgenciaPsi — Dashboard Generator
// =============================================================================
// Uso:
// node generate-dashboard.js → usa backup mais recente
// node generate-dashboard.js 2026-03-27 → usa backup de data específica
//
// Lê de: ./database-novo/backups/YYYY-MM-DD/schema.sql
// Gera: ./dashboard.html (na mesma pasta do script)
// =============================================================================
const fs = require('fs');
const path = require('path');
const BACKUPS_DIR = path.join(__dirname, 'backups');
const OUTPUT_FILE = path.join(__dirname, 'dashboard.html');
// ---------------------------------------------------------------------------
// Cores por domínio
// ---------------------------------------------------------------------------
const DOMAIN_COLORS = {
'SaaS / Planos': '#4f8cff',
'Tenants / Multi-tenant': '#6ee7b7',
'Pacientes': '#f472b6',
'Agenda': '#fb923c',
'Financeiro': '#a78bfa',
'Serviços / Commitments': '#34d399',
'Notificações': '#38bdf8',
'Email / Comunicação': '#fbbf24',
'SaaS Admin': '#94a3b8',
};
// ---------------------------------------------------------------------------
// Mapeamento domínio → tabelas
// Adicione novas tabelas aqui quando criar migrations
// ---------------------------------------------------------------------------
const DOMAIN_TABLES = {
'SaaS / Planos': [
'plans','plan_features','plan_prices','plan_public','plan_public_bullets',
'features','modules','module_features','subscriptions','subscription_events',
'subscription_intents_personal','subscription_intents_tenant','subscription_intents_legacy',
'addon_products','addon_credits','addon_transactions',
'tenant_features','tenant_modules','entitlements_invalidation','tenant_feature_exceptions_log',
],
'Tenants / Multi-tenant': [
'tenants','tenant_members','tenant_invites','owner_users',
'profiles','company_profiles','billing_contracts','payment_settings','support_sessions',
],
'Pacientes': [
'patients','patient_groups','patient_group_patient','patient_tags',
'patient_patient_tag','patient_discounts','patient_invites','patient_intake_requests',
],
'Agenda': [
'agenda_eventos','agenda_configuracoes','agenda_bloqueios','agenda_excecoes',
'agenda_online_slots','agenda_regras_semanais','agenda_slots_bloqueados_semanais',
'agenda_slots_regras','agendador_configuracoes','agendador_solicitacoes',
'feriados','recurrence_rules','recurrence_exceptions','recurrence_rule_services',
],
'Financeiro': [
'financial_records','financial_categories','financial_exceptions',
'therapist_payouts','therapist_payout_records',
'insurance_plans','insurance_plan_services','professional_pricing',
],
'Serviços / Commitments': [
'services','determined_commitments','determined_commitment_fields',
'commitment_services','commitment_time_logs',
],
'Notificações': [
'notifications','notification_channels','notification_templates',
'notification_queue','notification_logs','notification_preferences',
'notification_schedules','twilio_subaccount_usage',
],
'Email / Comunicação': [
'email_templates_global','email_templates_tenant','email_layout_config',
'global_notices','notice_dismissals','login_carousel_slides',
],
'SaaS Admin': [
'saas_admins','saas_docs','saas_doc_votos','saas_faq','saas_faq_itens',
'user_settings','dev_user_credentials','_db_migrations',
],
};
// ---------------------------------------------------------------------------
// 1. Resolve qual schema.sql usar
// ---------------------------------------------------------------------------
function resolveSchema() {
const arg = process.argv[2];
if (!fs.existsSync(BACKUPS_DIR)) {
console.error(`✖ Pasta não encontrada: ${BACKUPS_DIR}`);
console.error(` Certifique-se que o script está na raiz do projeto.`);
process.exit(1);
}
const available = fs.readdirSync(BACKUPS_DIR)
.filter(f => /^\d{4}-\d{2}-\d{2}$/.test(f))
.sort()
.reverse();
if (available.length === 0) {
console.error('✖ Nenhum backup encontrado em database-novo/backups/');
console.error(' Rode primeiro: node db.cjs backup');
process.exit(1);
}
const date = (arg && /^\d{4}-\d{2}-\d{2}$/.test(arg)) ? arg : available[0];
if (!available.includes(date)) {
console.error(`✖ Backup não encontrado para: ${date}`);
console.error(` Disponíveis: ${available.join(', ')}`);
process.exit(1);
}
const schemaPath = path.join(BACKUPS_DIR, date, 'schema.sql');
if (!fs.existsSync(schemaPath)) {
console.error(`✖ schema.sql não encontrado em database-novo/backups/${date}/`);
process.exit(1);
}
return { schemaPath, date, available };
}
// ---------------------------------------------------------------------------
// 2. Parse do schema.sql — extrai tabelas, colunas e FKs
// ---------------------------------------------------------------------------
function parseSchema(content) {
const tables = {};
// Tabelas public.*
const tableRe = /CREATE TABLE (public\.\S+)\s*\(([\s\S]*?)\);/gm;
let m;
while ((m = tableRe.exec(content)) !== null) {
const name = m[1].replace('public.', '');
const body = m[2];
const columns = [];
for (let line of body.split('\n')) {
line = line.trim().replace(/,$/, '');
if (!line || line.startsWith('--')) continue;
if (/^(CONSTRAINT|PRIMARY KEY|UNIQUE|CHECK|FOREIGN KEY|EXCLUDE)/i.test(line)) continue;
const col = line.match(
/^(\w+)\s+([\w\[\]"()\s,]+?)(?:\s+DEFAULT\s+|\s+NOT NULL|\s+NULL|\s+GENERATED|\s+REFERENCES\s|$)/
);
if (col) {
columns.push({
name: col[1],
type: col[2].trim().split('(')[0].trim(),
pk: col[1] === 'id',
});
}
}
tables[name] = { columns, fks: [] };
}
// FKs via ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY
const fkRe = /ALTER TABLE ONLY public\.(\w+)\s+ADD CONSTRAINT \S+ FOREIGN KEY \((\w+)\) REFERENCES public\.(\w+)\((\w+)\)/gm;
while ((m = fkRe.exec(content)) !== null) {
const [, fromTable, fromCol, toTable, toCol] = m;
if (tables[fromTable]) {
tables[fromTable].fks.push({ from_col: fromCol, to_table: toTable, to_col: toCol });
}
}
// Views
const viewRe = /CREATE(?:\s+OR REPLACE)?\s+VIEW\s+public\.(\S+)\s+AS/gm;
const views = [];
while ((m = viewRe.exec(content)) !== null) views.push(m[1]);
return { tables, views };
}
// ---------------------------------------------------------------------------
// 3. Monta os domínios
// Tabelas novas que ainda não estão mapeadas vão para "Outros"
// ---------------------------------------------------------------------------
function buildDomains(tables) {
const mapped = new Set(Object.values(DOMAIN_TABLES).flat());
const others = Object.keys(tables).filter(t => !mapped.has(t));
const domains = {};
for (const [domain, list] of Object.entries(DOMAIN_TABLES)) {
const present = list.filter(t => tables[t]);
if (present.length > 0) domains[domain] = present;
}
if (others.length > 0) {
domains['Outros'] = others;
DOMAIN_COLORS['Outros'] = '#6b7280';
}
return domains;
}
// ---------------------------------------------------------------------------
// 4. Gera o HTML final (standalone, sem dependências externas de JS)
// ---------------------------------------------------------------------------
function generateHTML(tables, views, domains, date, available) {
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
const totalCols = Object.values(tables).reduce((a, t) => a + t.columns.length, 0);
const generated = new Date().toLocaleString('pt-BR');
// Serializa dados para embutir no HTML
const jsonData = JSON.stringify({ tables, views, domains });
const jsonColors = JSON.stringify(DOMAIN_COLORS);
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AgenciaPsi DB · ${date}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
:root{--bg:#0b0d12;--bg2:#111520;--bg3:#181e2d;--border:#1e2740;--border2:#263050;--text:#e2e8f8;--text2:#7d8fb3;--text3:#4a5a80;--accent:#4f8cff;--accent2:#6ee7b7;--pk:#fbbf24;--fk:#f472b6}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--text);font-family:'Space Grotesk',sans-serif;min-height:100vh;overflow-x:hidden}
.topbar{position:sticky;top:0;z-index:100;background:rgba(11,13,18,.94);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 28px;height:56px;display:flex;align-items:center;gap:20px}
.brand{font-weight:700;font-size:15px;letter-spacing:-.3px}.brand span{color:var(--accent)}
.gen{font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
.pills{display:flex;gap:10px;margin-left:auto}
.pill{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:4px 12px}
.pill strong{color:var(--text);font-size:13px}
.search{background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:6px 12px;color:var(--text);font-family:'Space Grotesk',sans-serif;font-size:13px;outline:none;width:200px;transition:border-color .2s,width .2s}
.search:focus{border-color:var(--accent);width:280px}
.search::placeholder{color:var(--text3)}
.layout{display:flex;height:calc(100vh - 56px)}
.sidebar{width:240px;flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;padding:16px 0}
.sidebar::-webkit-scrollbar{width:4px}.sidebar::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
.sb-h{font-size:10px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:var(--text3);padding:8px 20px 4px}
.sb-i{display:flex;align-items:center;gap:10px;padding:7px 20px;cursor:pointer;font-size:13px;color:var(--text2);border-left:2px solid transparent;transition:all .15s;user-select:none}
.sb-i:hover{color:var(--text);background:var(--bg3)}
.sb-i.active{color:var(--text);border-left-color:var(--accent);background:rgba(79,140,255,.08)}
.sb-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.sb-c{margin-left:auto;font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
.main{flex:1;overflow-y:auto}
.main::-webkit-scrollbar{width:5px}.main::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
.overview{padding:32px 36px;border-bottom:1px solid var(--border)}
.ov-t{font-size:22px;font-weight:700;margin-bottom:6px}
.ov-s{font-size:14px;color:var(--text2);margin-bottom:28px}
.dgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
.dc{background:var(--bg3);border:1px solid var(--border);border-radius:12px;padding:16px 18px;cursor:pointer;transition:all .2s;position:relative;overflow:hidden}
.dc::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:var(--c)}
.dc:hover{border-color:var(--border2);transform:translateY(-1px)}
.dc-n{font-size:14px;font-weight:600;margin-bottom:6px}
.dc-m{font-size:12px;color:var(--text2);font-family:'IBM Plex Mono',monospace}
.dc-m span{font-weight:600}
.section{padding:28px 36px}
.sec-h{display:flex;align-items:center;gap:14px;margin-bottom:20px}
.sec-t{font-size:18px;font-weight:700}
.sec-b{font-size:11px;font-family:'IBM Plex Mono',monospace;background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:3px 10px;color:var(--text2)}
.tgrid{display:flex;flex-direction:column;gap:10px}
.tc{background:var(--bg3);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:border-color .15s}
.tc:hover{border-color:var(--border2)}.tc.hl{border-color:var(--accent)}
.tc-h{display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;user-select:none}
.tc-n{font-family:'IBM Plex Mono',monospace;font-size:13px;font-weight:600}
.tc-m{font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
.tc-f{font-size:11px;color:var(--fk);font-family:'IBM Plex Mono',monospace;margin-left:4px}
.tc-tg{margin-left:auto;color:var(--text3);font-size:11px;transition:transform .2s}
.tc-tg.open{transform:rotate(180deg)}
.tc-b{display:none;border-top:1px solid var(--border)}.tc-b.open{display:block}
.cols{padding:6px 0}
.cr{display:flex;align-items:center;gap:10px;padding:5px 16px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--text2)}
.cr:hover{background:rgba(255,255,255,.02)}
.bdg{font-size:9px;font-weight:700;letter-spacing:.5px;padding:1px 5px;border-radius:3px;width:26px;text-align:center;flex-shrink:0}
.bdg.pk{background:rgba(251,191,36,.15);color:var(--pk)}.bdg.fk{background:rgba(244,114,182,.15);color:var(--fk)}.bdg.x{background:transparent}
.cn{color:var(--text)}.ct{color:var(--text3);margin-left:auto;font-size:11px}
.fksec{border-top:1px solid var(--border);padding:10px 16px}
.fkt{font-size:10px;font-weight:600;letter-spacing:1px;color:var(--text3);text-transform:uppercase;margin-bottom:8px}
.fkr{display:flex;align-items:center;gap:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--text2);padding:3px 0}
.fka{color:var(--fk)}.fkl{color:var(--accent);cursor:pointer}.fkl:hover{text-decoration:underline}
.vsec{padding:0 36px 32px}
.vgrid{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
.vc{background:rgba(110,231,183,.08);border:1px solid rgba(110,231,183,.2);border-radius:6px;padding:5px 12px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--accent2)}
.empty{padding:40px;text-align:center;color:var(--text3);font-size:14px}
mark{background:rgba(79,140,255,.3);color:#fff;border-radius:2px}
</style>
</head>
<body>
<div class="topbar">
<div class="brand">Agência<span>Psi</span> DB</div>
<span class="gen">${date} · ${generated}</span>
<input class="search" id="si" placeholder="Buscar tabela ou coluna..." oninput="search(this.value)">
<div class="pills">
<div class="pill"><strong>${Object.keys(tables).length}</strong> tabelas</div>
<div class="pill"><strong>${totalFKs}</strong> FKs</div>
<div class="pill"><strong>${views.length}</strong> views</div>
<div class="pill"><strong>${totalCols}</strong> colunas</div>
</div>
</div>
<div class="layout">
<nav class="sidebar" id="sb"></nav>
<main class="main" id="mn"></main>
</div>
<script>
const D=${jsonData};
const C=${jsonColors};
const T2D={};
Object.entries(D.domains).forEach(([d,ts])=>ts.forEach(t=>T2D[t]=d));
let dom=null,q='';
function gc(d){return C[d]||'#6b7280';}
function buildSB(){
let h=\`<div class="sb-h">Visão Geral</div>
<div class="sb-i \${!dom?'active':''}" onclick="sel(null)">
<div class="sb-dot" style="background:#4f8cff"></div>Todos
<span class="sb-c">\${Object.keys(D.tables).length}</span>
</div>
<div class="sb-h" style="margin-top:8px">Domínios</div>\`;
for(const[d,ts]of Object.entries(D.domains)){
h+=\`<div class="sb-i \${dom===d?'active':''}" onclick="sel(\`+JSON.stringify(d)+\`)">
<div class="sb-dot" style="background:\${gc(d)}"></div>\${d}
<span class="sb-c">\${ts.length}</span>
</div>\`;
}
document.getElementById('sb').innerHTML=h;
}
function buildMN(){
const mn=document.getElementById('mn');
let h='';
if(q){
const matches=Object.entries(D.tables).filter(([n,t])=>n.includes(q)||t.columns.some(c=>c.name.includes(q)));
h+=\`<div class="section"><div class="sec-h"><div class="sec-t">"\${q}"</div><div class="sec-b">\${matches.length} tabelas</div></div><div class="tgrid">\`;
h+=matches.length?matches.map(([n,t])=>card(n,t,q)).join(''):'<div class="empty">Nenhum resultado.</div>';
h+='</div></div>';
} else {
const ds=dom?{[dom]:D.domains[dom]}:D.domains;
if(!dom){
h+=\`<div class="overview"><div class="ov-t">AgenciaPsi — Banco de Dados</div>
<div class="ov-s">Schema público · \${Object.keys(D.tables).length} tabelas · \${Object.values(D.tables).reduce((a,t)=>a+t.fks.length,0)} FKs · \${D.views.length} views</div>
<div class="dgrid">\`;
for(const[d,ts]of Object.entries(D.domains)){
const fks=ts.reduce((a,t)=>a+(D.tables[t]?.fks?.length||0),0);
h+=\`<div class="dc" style="--c:\${gc(d)}" onclick="sel(\`+JSON.stringify(d)+\`)">
<div class="dc-n">\${d}</div>
<div class="dc-m"><span style="color:\${gc(d)}">\${ts.length}</span> tabelas · \${fks} FKs</div>
</div>\`;
}
h+='</div></div>';
}
for(const[d,ts]of Object.entries(ds)){
h+=\`<div class="section"><div class="sec-h">
<div class="sec-t" style="color:\${gc(d)}">\${d}</div>
<div class="sec-b">\${ts.length} tabelas</div>
</div><div class="tgrid">\`;
ts.forEach(n=>{if(D.tables[n])h+=card(n,D.tables[n],'');});
h+='</div></div>';
}
if(!dom){
h+=\`<div class="vsec"><div class="sec-h">
<div class="sec-t" style="color:#6ee7b7">Views</div>
<div class="sec-b">\${D.views.length}</div>
</div><div class="vgrid">\${D.views.map(v=>\`<div class="vc">\${v}</div>\`).join('')}</div></div>\`;
}
}
mn.innerHTML=h;
}
function card(name,t,hl){
const fkCols=new Set(t.fks.map(f=>f.from_col));
const c=gc(T2D[name]);
const cols=t.columns.map(col=>{
let n=col.name;
if(hl&&n.includes(hl))n=n.replace(new RegExp(\`(\${hl})\`,'gi'),'<mark>$1</mark>');
const b=col.pk?'pk':fkCols.has(col.name)?'fk':'x';
const l=col.pk?'PK':fkCols.has(col.name)?'FK':'';
return \`<div class="cr"><span class="bdg \${b}">\${l}</span><span class="cn">\${n}</span><span class="ct">\${col.type}</span></div>\`;
}).join('');
const fks=t.fks.length?\`<div class="fksec"><div class="fkt">Foreign Keys</div>\${
t.fks.map(f=>\`<div class="fkr"><span>\${f.from_col}</span><span class="fka">→</span><span class="fkl" onclick="jump('\${f.to_table}')">\${f.to_table}.\${f.to_col}</span></div>\`).join('')
}</div>\`:'';
return \`<div class="tc \${hl&&name.includes(hl)?'hl':''}" id="tc-\${name}">
<div class="tc-h" onclick="tog('\${name}')">
<div style="width:8px;height:8px;border-radius:50%;background:\${c};flex-shrink:0"></div>
<div class="tc-n">\${name}</div>
<span class="tc-m">\${t.columns.length} cols</span>
\${t.fks.length?\`<span class="tc-f">\${t.fks.length} FK</span>\`:''}
<span class="tc-tg" id="tg-\${name}">▼</span>
</div>
<div class="tc-b" id="bd-\${name}"><div class="cols">\${cols}</div>\${fks}</div>
</div>\`;
}
function tog(n){
document.getElementById('bd-'+n)?.classList.toggle('open');
document.getElementById('tg-'+n)?.classList.toggle('open');
}
function sel(d){
dom=d;q='';document.getElementById('si').value='';
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
}
function jump(name){
dom=T2D[name]||null;q='';document.getElementById('si').value='';
buildSB();buildMN();
setTimeout(()=>{
const el=document.getElementById('tc-'+name);
if(!el)return;
el.scrollIntoView({behavior:'smooth',block:'center'});
const bd=document.getElementById('bd-'+name);
const tg=document.getElementById('tg-'+name);
if(bd&&!bd.classList.contains('open')){bd.classList.add('open');tg?.classList.add('open');}
el.style.borderColor='#4f8cff';
setTimeout(()=>el.style.borderColor='',2000);
},80);
}
let st;
function search(v){
clearTimeout(st);q=v.trim();
st=setTimeout(()=>{dom=null;buildSB();buildMN();},200);
}
buildSB();buildMN();
</script>
</body>
</html>`;
}
// ---------------------------------------------------------------------------
// 5. Execução
// ---------------------------------------------------------------------------
console.log('\n═══ AgenciaPsi — Dashboard Generator ═══\n');
const { schemaPath, date, available } = resolveSchema();
console.log(` → Schema: ${schemaPath}`);
if (available.length > 1) console.log(` → Outros backups: ${available.slice(1).join(', ')}`);
const content = fs.readFileSync(schemaPath, 'utf8');
console.log(` → Lendo schema... (${(content.length / 1024).toFixed(0)} KB)`);
const { tables, views } = parseSchema(content);
const domains = buildDomains(tables);
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
console.log(`${Object.keys(tables).length} tabelas · ${totalFKs} FKs · ${views.length} views`);
// Avisa sobre tabelas novas não mapeadas
if (domains['Outros']) {
console.log(`\n ⚠ Tabelas novas sem domínio definido (aparecerão em "Outros"):`);
domains['Outros'].forEach(t => console.log(` - ${t}`));
console.log(` → Edite DOMAIN_TABLES no script para mapeá-las.\n`);
}
const html = generateHTML(tables, views, domains, date, available);
fs.writeFileSync(OUTPUT_FILE, html, 'utf8');
console.log(`\n✔ Gerado: ${OUTPUT_FILE}`);
console.log(` Tamanho: ${(fs.statSync(OUTPUT_FILE).size / 1024).toFixed(0)} KB`);
console.log(` Abra no browser: file://${OUTPUT_FILE}\n`);

View File

@@ -0,0 +1,57 @@
-- ============================================================
-- Migration 002 — SetupWizard1: campos Negócio e Atendimento
-- ============================================================
-- Tabela: tenants (Step 2 — Negócio)
-- Tabela: agenda_configuracoes (Step 3 — Atendimento)
-- ============================================================
-- ----------------------------------------------------------
-- tenants: dados do negócio
-- ----------------------------------------------------------
ALTER TABLE public.tenants
ADD COLUMN IF NOT EXISTS business_type text,
ADD COLUMN IF NOT EXISTS logo_url text,
ADD COLUMN IF NOT EXISTS address text,
ADD COLUMN IF NOT EXISTS phone text,
ADD COLUMN IF NOT EXISTS contact_email text,
ADD COLUMN IF NOT EXISTS site_url text,
ADD COLUMN IF NOT EXISTS social_instagram text;
-- Valores aceitos: consultorio | clinica | instituto | grupo
ALTER TABLE public.tenants
ADD CONSTRAINT tenants_business_type_check
CHECK (business_type IS NULL OR business_type = ANY (ARRAY[
'consultorio'::text,
'clinica'::text,
'instituto'::text,
'grupo'::text
]));
-- ----------------------------------------------------------
-- agenda_configuracoes: modo de atendimento
-- ----------------------------------------------------------
ALTER TABLE public.agenda_configuracoes
ADD COLUMN IF NOT EXISTS atendimento_mode text DEFAULT 'particular'::text;
ALTER TABLE public.agenda_configuracoes
ADD CONSTRAINT agenda_configuracoes_atendimento_mode_check
CHECK (atendimento_mode IS NULL OR atendimento_mode = ANY (ARRAY[
'particular'::text,
'convenio'::text,
'ambos'::text
]));
-- ----------------------------------------------------------
-- Comments
-- ----------------------------------------------------------
COMMENT ON COLUMN public.tenants.business_type IS 'Tipo de negócio: consultorio, clinica, instituto, grupo';
COMMENT ON COLUMN public.tenants.logo_url IS 'URL da logo do negócio (Storage bucket)';
COMMENT ON COLUMN public.tenants.address IS 'Endereço do negócio (texto livre)';
COMMENT ON COLUMN public.tenants.phone IS 'Telefone/WhatsApp do negócio';
COMMENT ON COLUMN public.tenants.contact_email IS 'E-mail público de contato do negócio';
COMMENT ON COLUMN public.tenants.site_url IS 'Site do negócio';
COMMENT ON COLUMN public.tenants.social_instagram IS 'Instagram do negócio (sem @)';
COMMENT ON COLUMN public.agenda_configuracoes.atendimento_mode IS 'Modo de atendimento: particular | convenio | ambos';

View File

@@ -0,0 +1,33 @@
-- ============================================================
-- Migration 003 — Tenants: campos de endereço detalhado
-- ============================================================
-- Substitui o campo address (texto livre) por campos estruturados
-- preenchidos via consulta de CEP (ViaCEP)
-- ============================================================
ALTER TABLE public.tenants
ADD COLUMN IF NOT EXISTS cep text,
ADD COLUMN IF NOT EXISTS logradouro text,
ADD COLUMN IF NOT EXISTS numero text,
ADD COLUMN IF NOT EXISTS complemento text,
ADD COLUMN IF NOT EXISTS bairro text,
ADD COLUMN IF NOT EXISTS cidade text,
ADD COLUMN IF NOT EXISTS estado text;
-- Migra dados existentes do campo address para logradouro
UPDATE public.tenants
SET logradouro = address
WHERE address IS NOT NULL
AND logradouro IS NULL;
-- ----------------------------------------------------------
-- Comments
-- ----------------------------------------------------------
COMMENT ON COLUMN public.tenants.cep IS 'CEP do endereço do negócio';
COMMENT ON COLUMN public.tenants.logradouro IS 'Logradouro (rua, avenida, etc.)';
COMMENT ON COLUMN public.tenants.numero IS 'Número do endereço';
COMMENT ON COLUMN public.tenants.complemento IS 'Complemento (sala, andar, etc.)';
COMMENT ON COLUMN public.tenants.bairro IS 'Bairro';
COMMENT ON COLUMN public.tenants.cidade IS 'Cidade';
COMMENT ON COLUMN public.tenants.estado IS 'UF (2 letras)';

View File

@@ -0,0 +1,147 @@
-- ==========================================================================
-- Agência PSI — Migração: tabela `medicos`
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026 · São Carlos/SP — Brasil
--
-- Propósito:
-- Armazena médicos e profissionais de referência (psiquiatras, neurologistas,
-- clínicos gerais, etc.) que encaminham pacientes ou fazem parte da rede de
-- suporte clínico do terapeuta.
--
-- Usado em:
-- - PatientsCadastroPage: campo "Encaminhado por" (FK medico_id)
-- - CadastroRapidoMedico.vue: cadastro rápido dentro do formulário
-- - MedicosCadastroPage.vue: página completa de gestão de médicos
--
-- Relacionamentos:
-- medicos.owner_id → auth.users(id)
-- medicos.tenant_id → tenants(id)
-- patients.medico_encaminhador_id → medicos(id) (opcional, ver abaixo)
--
-- RLS: owner_id = auth.uid() — cada profissional vê apenas seus médicos.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela principal
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.medicos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto de acesso
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
-- Identidade profissional
nome text NOT NULL,
crm text, -- Ex: "123456/SP"
especialidade text, -- Ex: "Psiquiatria"
-- Contatos — telefone_pessoal é sensível (exibido com ícone de olho)
telefone_profissional text, -- Consultório / clínica
telefone_pessoal text, -- WhatsApp / pessoal
email text,
-- Local de atuação
clinica text, -- Nome da clínica/hospital
cidade text,
estado text DEFAULT 'SP',
-- Notas internas do terapeuta
observacoes text,
-- Controle
ativo boolean DEFAULT true NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT medicos_pkey PRIMARY KEY (id),
-- CRM único por owner (mesmo terapeuta não cadastra o mesmo CRM duas vezes)
CONSTRAINT medicos_crm_owner_unique UNIQUE NULLS NOT DISTINCT (owner_id, crm)
);
-- --------------------------------------------------------------------------
-- 2. Índices de performance
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS medicos_owner_idx
ON public.medicos USING btree (owner_id);
CREATE INDEX IF NOT EXISTS medicos_tenant_idx
ON public.medicos USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS medicos_nome_idx
ON public.medicos USING btree (nome);
CREATE INDEX IF NOT EXISTS medicos_especialidade_idx
ON public.medicos USING btree (especialidade);
-- Busca textual por nome e especialidade
CREATE INDEX IF NOT EXISTS medicos_nome_trgm_idx
ON public.medicos USING gin (nome gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger de updated_at
-- --------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.set_medicos_updated_at()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_medicos_updated_at
BEFORE UPDATE ON public.medicos
FOR EACH ROW
EXECUTE FUNCTION public.set_medicos_updated_at();
-- --------------------------------------------------------------------------
-- 4. Row Level Security
-- --------------------------------------------------------------------------
ALTER TABLE public.medicos ENABLE ROW LEVEL SECURITY;
-- Owner tem acesso total aos seus próprios médicos
CREATE POLICY "medicos: owner full access"
ON public.medicos
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- --------------------------------------------------------------------------
-- 5. Comentários de documentação
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.medicos IS 'Médicos e profissionais de referência cadastrados pelo terapeuta.';
COMMENT ON COLUMN public.medicos.owner_id IS 'Terapeuta dono do cadastro (auth.uid()).';
COMMENT ON COLUMN public.medicos.tenant_id IS 'Tenant do terapeuta.';
COMMENT ON COLUMN public.medicos.nome IS 'Nome completo do médico/profissional.';
COMMENT ON COLUMN public.medicos.crm IS 'CRM com UF. Ex: 123456/SP. Único por owner_id.';
COMMENT ON COLUMN public.medicos.especialidade IS 'Especialidade médica. Ex: Psiquiatria, Neurologia.';
COMMENT ON COLUMN public.medicos.telefone_profissional IS 'Telefone do consultório ou clínica.';
COMMENT ON COLUMN public.medicos.telefone_pessoal IS 'Telefone pessoal / WhatsApp. Campo sensível.';
COMMENT ON COLUMN public.medicos.email IS 'E-mail profissional.';
COMMENT ON COLUMN public.medicos.clinica IS 'Nome da clínica ou hospital onde atua.';
COMMENT ON COLUMN public.medicos.cidade IS 'Cidade de atuação.';
COMMENT ON COLUMN public.medicos.estado IS 'UF de atuação. Default SP.';
COMMENT ON COLUMN public.medicos.observacoes IS 'Notas internas do terapeuta sobre o médico.';
COMMENT ON COLUMN public.medicos.ativo IS 'Soft delete: false oculta da listagem.';
-- --------------------------------------------------------------------------
-- 6. Coluna FK opcional em patients
-- (Conecta "Encaminhado por" ao cadastro de médico)
-- Execute apenas se quiser a FK estruturada; caso contrário,
-- o campo encaminhado_por (text) no PatientsCadastroPage já funciona.
-- --------------------------------------------------------------------------
-- ALTER TABLE public.patients
-- ADD COLUMN IF NOT EXISTS medico_encaminhador_id uuid
-- REFERENCES public.medicos(id) ON DELETE SET NULL;
-- CREATE INDEX IF NOT EXISTS patients_medico_encaminhador_idx
-- ON public.patients USING btree (medico_encaminhador_id);
-- COMMENT ON COLUMN public.patients.medico_encaminhador_id
-- IS 'FK para medicos.id — quem encaminhou o paciente.';
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================

View File

@@ -0,0 +1,119 @@
-- ==========================================================================
-- Agência PSI — Migração: novos campos em `patients`
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000002_patients_new_columns.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- Adiciona as colunas identificadas na engenharia reversa da tela de detalhe
-- (PatientsDetailPage) que ainda não existiam na tabela `patients`.
--
-- Também ajusta os CHECK constraints de `status` e `patient_scope` para
-- aceitar os valores usados no novo formulário de cadastro.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Colunas novas
-- --------------------------------------------------------------------------
-- Identidade
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS pronomes text,
ADD COLUMN IF NOT EXISTS nome_social text,
ADD COLUMN IF NOT EXISTS etnia text;
-- Contato
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS canal_preferido text,
ADD COLUMN IF NOT EXISTS horario_contato text;
-- Clínico / convênio
-- convenio: nome de exibição (badge azul no header)
-- convenio_id: FK para insurance_plans (opcional — permite vincular ao cadastro)
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS convenio text,
ADD COLUMN IF NOT EXISTS convenio_id uuid REFERENCES public.insurance_plans(id) ON DELETE SET NULL;
-- Origem
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS metodo_pagamento_preferido text,
ADD COLUMN IF NOT EXISTS motivo_saida text;
-- --------------------------------------------------------------------------
-- 2. Ajuste do CHECK constraint de `status`
-- Valores originais: Ativo | Inativo | Alta | Encaminhado | Arquivado
-- Valores novos: + Em espera
-- --------------------------------------------------------------------------
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_status_check;
ALTER TABLE public.patients
ADD CONSTRAINT patients_status_check CHECK (
status = ANY (ARRAY[
'Ativo'::text,
'Em espera'::text,
'Inativo'::text,
'Alta'::text,
'Encaminhado'::text,
'Arquivado'::text
])
);
-- --------------------------------------------------------------------------
-- 3. Ajuste do CHECK constraint de `patient_scope`
-- Valores originais: clinic | therapist (valores técnicos internos)
-- Valores novos: + Clínica | Particular | Online | Híbrido
-- Estratégia: remover o constraint restritivo e deixar livre (text),
-- pois o controle já é feito no frontend via Select com opções fixas.
-- --------------------------------------------------------------------------
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_patient_scope_check;
-- Também remove a constraint de consistência que dependia do scope antigo
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_therapist_scope_consistency;
-- --------------------------------------------------------------------------
-- 4. Índices de performance
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS patients_convenio_id_idx
ON public.patients USING btree (convenio_id);
CREATE INDEX IF NOT EXISTS patients_pronomes_idx
ON public.patients USING btree (pronomes);
CREATE INDEX IF NOT EXISTS patients_etnia_idx
ON public.patients USING btree (etnia);
-- --------------------------------------------------------------------------
-- 5. Comentários
-- --------------------------------------------------------------------------
COMMENT ON COLUMN public.patients.pronomes
IS 'Pronomes de tratamento. Ex: ela/dela, ele/dele. Exibido no header do perfil.';
COMMENT ON COLUMN public.patients.nome_social
IS 'Nome social / como prefere ser chamado(a) no atendimento.';
COMMENT ON COLUMN public.patients.etnia
IS 'Etnia / raça autodeclarada. Exibida no card "Dados pessoais".';
COMMENT ON COLUMN public.patients.canal_preferido
IS 'Canal preferido de contato. Ex: WhatsApp, Telefone, E-mail.';
COMMENT ON COLUMN public.patients.horario_contato
IS 'Horário preferido para contato. Ex: 08h18h.';
COMMENT ON COLUMN public.patients.convenio
IS 'Nome do convênio para exibição (badge azul no header). Derivado de convenio_id.';
COMMENT ON COLUMN public.patients.convenio_id
IS 'FK para insurance_plans.id. Vincula o paciente ao convênio cadastrado.';
COMMENT ON COLUMN public.patients.metodo_pagamento_preferido
IS 'Método de pagamento preferido. Ex: PIX, Cartão crédito. Exibido no card Origem.';
COMMENT ON COLUMN public.patients.motivo_saida
IS 'Motivo de encerramento do acompanhamento. Exibido no card Origem quando preenchido.';
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================

View File

@@ -0,0 +1,70 @@
-- ==========================================================================
-- Agência PSI — Migração: remove check constraints dos novos campos
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000003_patients_drop_check_constraints.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- O banco tinha CHECK constraints nos novos campos que foram adicionados
-- pela migration anterior (ou que já existiam no schema ao vivo).
-- O frontend já controla os valores via Select com opções fixas,
-- então os constraints são desnecessários e serão removidos.
-- ==========================================================================
-- canal_preferido
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_canal_preferido_check;
-- horario_contato
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_horario_contato_check;
-- pronomes
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_pronomes_check;
-- nome_social
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_nome_social_check;
-- etnia
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_etnia_check;
-- convenio
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_convenio_check;
-- metodo_pagamento_preferido
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_metodo_pagamento_preferido_check;
-- motivo_saida
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_motivo_saida_check;
-- status (já ajustado na migration anterior, mas garante)
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_status_check;
ALTER TABLE public.patients
ADD CONSTRAINT patients_status_check CHECK (
status = ANY (ARRAY[
'Ativo'::text,
'Em espera'::text,
'Inativo'::text,
'Alta'::text,
'Encaminhado'::text,
'Arquivado'::text
])
);
-- patient_scope (já ajustado na migration anterior, mas garante)
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_patient_scope_check;
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_therapist_scope_consistency;
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================

View File

@@ -0,0 +1,56 @@
-- ==========================================================================
-- Agência PSI — Migração: tabela `patient_support_contacts`
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000004_create_patient_support_contacts.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- Contatos da rede de suporte do paciente.
-- Alimenta o card "Contatos & rede de suporte" na tela de detalhe.
-- is_primario = true → badge vermelho "emergência" no perfil.
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.patient_support_contacts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
nome text,
relacao text, -- Ex: mãe, psiquiatra, cônjuge
tipo text, -- emergencia | familiar | profissional_saude | amigo | outro
telefone text,
email text,
is_primario boolean DEFAULT false NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT patient_support_contacts_pkey PRIMARY KEY (id)
);
-- Índices
CREATE INDEX IF NOT EXISTS psc_patient_idx ON public.patient_support_contacts USING btree (patient_id);
CREATE INDEX IF NOT EXISTS psc_owner_idx ON public.patient_support_contacts USING btree (owner_id);
-- Trigger updated_at
CREATE TRIGGER trg_psc_updated_at
BEFORE UPDATE ON public.patient_support_contacts
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
-- RLS
ALTER TABLE public.patient_support_contacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "psc: owner full access"
ON public.patient_support_contacts
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- Comentários
COMMENT ON TABLE public.patient_support_contacts IS 'Rede de suporte do paciente. Exibida no card "Contatos & rede de suporte" do perfil.';
COMMENT ON COLUMN public.patient_support_contacts.is_primario IS 'true = badge vermelho "emergência" no perfil do paciente.';
COMMENT ON COLUMN public.patient_support_contacts.tipo IS 'emergencia | familiar | profissional_saude | amigo | outro';
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================

View File

@@ -0,0 +1,454 @@
-- ==========================================================================
-- Agencia PSI — Migracao: tabelas de Documentos & Arquivos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Proposito:
-- Modulo completo de documentos do paciente.
-- Tabelas: documents, document_access_logs, document_signatures,
-- document_share_links.
--
-- Relacionamentos:
-- documents.patient_id → patients(id)
-- documents.owner_id → auth.users(id)
-- documents.tenant_id → tenants(id)
-- documents.agenda_evento_id → agenda_eventos(id) (opcional)
-- document_access_logs.documento_id → documents(id)
-- document_signatures.documento_id → documents(id)
-- document_share_links.documento_id → documents(id)
--
-- RLS: owner_id = auth.uid() para documents, signatures e share_links.
-- access_logs: somente INSERT (imutavel) + SELECT por tenant.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela principal: documents
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.documents (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto de acesso
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
-- Vinculo com paciente
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
-- Arquivo no Storage
bucket_path text NOT NULL,
storage_bucket text NOT NULL DEFAULT 'documents',
nome_original text NOT NULL,
mime_type text,
tamanho_bytes bigint,
-- Classificacao
tipo_documento text NOT NULL DEFAULT 'outro',
-- laudo | receita | exame | termo_assinado | relatorio_externo
-- identidade | convenio | declaracao | atestado | recibo | outro
categoria text,
descricao text,
tags text[] DEFAULT '{}',
-- Vinculo opcional com sessao/nota
agenda_evento_id uuid REFERENCES public.agenda_eventos(id) ON DELETE SET NULL,
session_note_id uuid,
-- Visibilidade & controle de acesso
visibilidade text NOT NULL DEFAULT 'privado',
-- privado | compartilhado_supervisor | compartilhado_portal
compartilhado_portal boolean DEFAULT false NOT NULL,
compartilhado_supervisor boolean DEFAULT false NOT NULL,
compartilhado_em timestamptz,
expira_compartilhamento timestamptz,
-- Upload pelo paciente (portal)
enviado_pelo_paciente boolean DEFAULT false NOT NULL,
status_revisao text DEFAULT 'aprovado',
-- pendente | aprovado | rejeitado
revisado_por uuid,
revisado_em timestamptz,
-- Quem fez upload
uploaded_by uuid NOT NULL,
uploaded_at timestamptz DEFAULT now() NOT NULL,
-- Soft delete com retencao (LGPD / CFP)
deleted_at timestamptz,
deleted_by uuid,
retencao_ate timestamptz,
-- Controle
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT documents_pkey PRIMARY KEY (id),
-- Validacoes
CONSTRAINT documents_tipo_check CHECK (
tipo_documento = ANY (ARRAY[
'laudo', 'receita', 'exame', 'termo_assinado', 'relatorio_externo',
'identidade', 'convenio', 'declaracao', 'atestado', 'recibo', 'outro'
])
),
CONSTRAINT documents_visibilidade_check CHECK (
visibilidade = ANY (ARRAY['privado', 'compartilhado_supervisor', 'compartilhado_portal'])
),
CONSTRAINT documents_status_revisao_check CHECK (
status_revisao = ANY (ARRAY['pendente', 'aprovado', 'rejeitado'])
)
);
-- --------------------------------------------------------------------------
-- 2. Indices — documents
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS docs_patient_idx
ON public.documents USING btree (patient_id);
CREATE INDEX IF NOT EXISTS docs_owner_idx
ON public.documents USING btree (owner_id);
CREATE INDEX IF NOT EXISTS docs_tenant_idx
ON public.documents USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS docs_tipo_idx
ON public.documents USING btree (patient_id, tipo_documento);
CREATE INDEX IF NOT EXISTS docs_tags_idx
ON public.documents USING gin (tags);
CREATE INDEX IF NOT EXISTS docs_uploaded_at_idx
ON public.documents USING btree (patient_id, uploaded_at DESC);
-- Excluir soft-deleted da listagem padrao
CREATE INDEX IF NOT EXISTS docs_active_idx
ON public.documents USING btree (patient_id, uploaded_at DESC)
WHERE deleted_at IS NULL;
-- Busca textual no nome do arquivo
CREATE INDEX IF NOT EXISTS docs_nome_trgm_idx
ON public.documents USING gin (nome_original gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger updated_at — documents
-- --------------------------------------------------------------------------
CREATE TRIGGER trg_documents_updated_at
BEFORE UPDATE ON public.documents
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- --------------------------------------------------------------------------
-- 4. Trigger: registrar na patient_timeline ao adicionar documento
-- --------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.fn_documents_timeline_insert()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
INSERT INTO public.patient_timeline (
patient_id, tenant_id, evento_tipo,
titulo, descricao, icone_cor,
link_ref_tipo, link_ref_id,
gerado_por, ocorrido_em
) VALUES (
NEW.patient_id,
NEW.tenant_id,
'documento_adicionado',
'Documento adicionado: ' || COALESCE(NEW.nome_original, 'arquivo'),
'Tipo: ' || COALESCE(NEW.tipo_documento, 'outro'),
'blue',
'documento',
NEW.id,
NEW.uploaded_by,
NEW.uploaded_at
);
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_documents_timeline_insert
AFTER INSERT ON public.documents
FOR EACH ROW
EXECUTE FUNCTION public.fn_documents_timeline_insert();
-- --------------------------------------------------------------------------
-- 5. RLS — documents
-- --------------------------------------------------------------------------
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "documents: owner full access"
ON public.documents
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- --------------------------------------------------------------------------
-- 6. Comentarios — documents
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.documents IS 'Documentos e arquivos vinculados a pacientes. Armazenados no Supabase Storage.';
COMMENT ON COLUMN public.documents.owner_id IS 'Terapeuta dono do documento (auth.uid()).';
COMMENT ON COLUMN public.documents.tenant_id IS 'Tenant do terapeuta.';
COMMENT ON COLUMN public.documents.patient_id IS 'Paciente ao qual o documento pertence.';
COMMENT ON COLUMN public.documents.bucket_path IS 'Caminho do arquivo no Supabase Storage bucket.';
COMMENT ON COLUMN public.documents.storage_bucket IS 'Nome do bucket no Storage. Default: documents.';
COMMENT ON COLUMN public.documents.nome_original IS 'Nome original do arquivo enviado.';
COMMENT ON COLUMN public.documents.mime_type IS 'MIME type do arquivo. Ex: application/pdf, image/jpeg.';
COMMENT ON COLUMN public.documents.tamanho_bytes IS 'Tamanho do arquivo em bytes.';
COMMENT ON COLUMN public.documents.tipo_documento IS 'Tipo: laudo|receita|exame|termo_assinado|relatorio_externo|identidade|convenio|declaracao|atestado|recibo|outro.';
COMMENT ON COLUMN public.documents.categoria IS 'Categoria livre para organizacao adicional.';
COMMENT ON COLUMN public.documents.tags IS 'Tags livres para busca e filtro. Array de text.';
COMMENT ON COLUMN public.documents.visibilidade IS 'privado|compartilhado_supervisor|compartilhado_portal.';
COMMENT ON COLUMN public.documents.compartilhado_portal IS 'true = visivel para o paciente no portal.';
COMMENT ON COLUMN public.documents.compartilhado_supervisor IS 'true = visivel para o supervisor.';
COMMENT ON COLUMN public.documents.enviado_pelo_paciente IS 'true = upload feito pelo paciente via portal.';
COMMENT ON COLUMN public.documents.status_revisao IS 'pendente|aprovado|rejeitado — para uploads do paciente.';
COMMENT ON COLUMN public.documents.deleted_at IS 'Soft delete: data da exclusao. NULL = ativo.';
COMMENT ON COLUMN public.documents.retencao_ate IS 'LGPD/CFP: arquivo retido ate esta data mesmo apos soft delete.';
-- ==========================================================================
-- 7. Tabela: document_access_logs (imutavel — auditoria)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_access_logs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Acao realizada
acao text NOT NULL,
-- visualizou | baixou | imprimiu | compartilhou | assinou
user_id uuid,
ip inet,
user_agent text,
acessado_em timestamptz DEFAULT now() NOT NULL,
CONSTRAINT document_access_logs_pkey PRIMARY KEY (id),
CONSTRAINT dal_acao_check CHECK (
acao = ANY (ARRAY['visualizou', 'baixou', 'imprimiu', 'compartilhou', 'assinou'])
)
);
-- Indices
CREATE INDEX IF NOT EXISTS dal_documento_idx
ON public.document_access_logs USING btree (documento_id, acessado_em DESC);
CREATE INDEX IF NOT EXISTS dal_tenant_idx
ON public.document_access_logs USING btree (tenant_id, acessado_em DESC);
CREATE INDEX IF NOT EXISTS dal_user_idx
ON public.document_access_logs USING btree (user_id, acessado_em DESC);
-- RLS — somente INSERT (imutavel) + SELECT
ALTER TABLE public.document_access_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dal: tenant members can insert"
ON public.document_access_logs
FOR INSERT
WITH CHECK (true);
CREATE POLICY "dal: tenant members can select"
ON public.document_access_logs
FOR SELECT
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- Comentarios
COMMENT ON TABLE public.document_access_logs IS 'Log imutavel de acessos a documentos. Conformidade CFP e LGPD. Sem UPDATE/DELETE.';
COMMENT ON COLUMN public.document_access_logs.acao IS 'visualizou|baixou|imprimiu|compartilhou|assinou.';
-- ==========================================================================
-- 8. Tabela: document_signatures (assinatura eletronica)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_signatures (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Signatario
signatario_tipo text NOT NULL,
-- paciente | responsavel_legal | terapeuta
signatario_id uuid,
signatario_nome text,
signatario_email text,
-- Ordem e status
ordem smallint DEFAULT 1 NOT NULL,
status text NOT NULL DEFAULT 'pendente',
-- pendente | enviado | assinado | recusado | expirado
-- Dados da assinatura (preenchidos ao assinar)
ip inet,
user_agent text,
assinado_em timestamptz,
hash_documento text,
-- Controle
criado_em timestamptz DEFAULT now(),
atualizado_em timestamptz DEFAULT now(),
CONSTRAINT document_signatures_pkey PRIMARY KEY (id),
CONSTRAINT ds_signatario_tipo_check CHECK (
signatario_tipo = ANY (ARRAY['paciente', 'responsavel_legal', 'terapeuta'])
),
CONSTRAINT ds_status_check CHECK (
status = ANY (ARRAY['pendente', 'enviado', 'assinado', 'recusado', 'expirado'])
)
);
-- Indices
CREATE INDEX IF NOT EXISTS ds_documento_idx
ON public.document_signatures USING btree (documento_id, ordem);
CREATE INDEX IF NOT EXISTS ds_tenant_idx
ON public.document_signatures USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS ds_status_idx
ON public.document_signatures USING btree (documento_id, status);
-- Trigger updated_at
CREATE TRIGGER trg_ds_updated_at
BEFORE UPDATE ON public.document_signatures
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- Trigger: ao assinar, registrar na patient_timeline
CREATE OR REPLACE FUNCTION public.fn_document_signature_timeline()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_patient_id uuid;
v_tenant_id uuid;
v_doc_nome text;
BEGIN
IF NEW.status = 'assinado' AND (OLD.status IS NULL OR OLD.status <> 'assinado') THEN
SELECT d.patient_id, d.tenant_id, d.nome_original
INTO v_patient_id, v_tenant_id, v_doc_nome
FROM public.documents d
WHERE d.id = NEW.documento_id;
IF v_patient_id IS NOT NULL THEN
INSERT INTO public.patient_timeline (
patient_id, tenant_id, evento_tipo,
titulo, descricao, icone_cor,
link_ref_tipo, link_ref_id,
gerado_por, ocorrido_em
) VALUES (
v_patient_id,
v_tenant_id,
'documento_assinado',
'Documento assinado: ' || COALESCE(v_doc_nome, 'documento'),
'Assinado por ' || COALESCE(NEW.signatario_nome, NEW.signatario_tipo),
'green',
'documento',
NEW.documento_id,
NEW.signatario_id,
NEW.assinado_em
);
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_ds_timeline
AFTER UPDATE ON public.document_signatures
FOR EACH ROW
EXECUTE FUNCTION public.fn_document_signature_timeline();
-- RLS
ALTER TABLE public.document_signatures ENABLE ROW LEVEL SECURITY;
CREATE POLICY "ds: tenant members access"
ON public.document_signatures
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
))
WITH CHECK (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- Comentarios
COMMENT ON TABLE public.document_signatures IS 'Assinaturas eletronicas de documentos. Cada signatario tem seu registro.';
COMMENT ON COLUMN public.document_signatures.signatario_tipo IS 'paciente|responsavel_legal|terapeuta.';
COMMENT ON COLUMN public.document_signatures.status IS 'pendente|enviado|assinado|recusado|expirado.';
COMMENT ON COLUMN public.document_signatures.hash_documento IS 'Hash SHA-256 do documento no momento da assinatura. Garante integridade.';
COMMENT ON COLUMN public.document_signatures.ip IS 'IP do signatario no momento da assinatura.';
-- ==========================================================================
-- 9. Tabela: document_share_links (links temporarios)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_share_links (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Token unico para o link
token text NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
-- Limites
expira_em timestamptz NOT NULL,
usos_max smallint DEFAULT 5 NOT NULL,
usos smallint DEFAULT 0 NOT NULL,
-- Quem criou
criado_por uuid NOT NULL,
criado_em timestamptz DEFAULT now(),
-- Controle
ativo boolean DEFAULT true NOT NULL,
CONSTRAINT document_share_links_pkey PRIMARY KEY (id),
CONSTRAINT dsl_token_unique UNIQUE (token)
);
-- Indices
CREATE INDEX IF NOT EXISTS dsl_documento_idx
ON public.document_share_links USING btree (documento_id);
CREATE INDEX IF NOT EXISTS dsl_token_idx
ON public.document_share_links USING btree (token)
WHERE ativo = true;
CREATE INDEX IF NOT EXISTS dsl_expira_idx
ON public.document_share_links USING btree (expira_em)
WHERE ativo = true;
-- RLS
ALTER TABLE public.document_share_links ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dsl: creator full access"
ON public.document_share_links
USING (criado_por = auth.uid())
WITH CHECK (criado_por = auth.uid());
-- Politica publica de leitura por token (para acesso externo sem login)
CREATE POLICY "dsl: public read by token"
ON public.document_share_links
FOR SELECT
USING (ativo = true AND expira_em > now() AND usos < usos_max);
-- Comentarios
COMMENT ON TABLE public.document_share_links IS 'Links temporarios assinados para compartilhar documento com profissional externo.';
COMMENT ON COLUMN public.document_share_links.token IS 'Token unico gerado automaticamente (32 bytes hex).';
COMMENT ON COLUMN public.document_share_links.expira_em IS 'Data/hora de expiracao do link.';
COMMENT ON COLUMN public.document_share_links.usos_max IS 'Numero maximo de acessos permitidos.';
COMMENT ON COLUMN public.document_share_links.usos IS 'Numero de vezes que o link ja foi acessado.';
-- ==========================================================================
-- FIM DA MIGRACAO 005
-- ==========================================================================

View File

@@ -0,0 +1,260 @@
-- ==========================================================================
-- Agencia PSI — Migracao: tabelas de Templates de Documentos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Proposito:
-- Templates de documentos (declaracao, atestado, recibo, relatorio etc.)
-- e registro de cada documento gerado (instancia PDF).
--
-- Tabelas: document_templates, document_generated.
--
-- Relacionamentos:
-- document_templates.tenant_id → tenants(id)
-- document_templates.owner_id → auth.users(id)
-- document_generated.template_id → document_templates(id)
-- document_generated.patient_id → patients(id)
-- document_generated.tenant_id → tenants(id)
--
-- Templates globais: is_global = true, tenant_id = NULL.
-- Templates do tenant: is_global = false, tenant_id preenchido.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela: document_templates
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.document_templates (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto
tenant_id uuid,
owner_id uuid,
-- Identificacao
nome_template text NOT NULL,
tipo text NOT NULL DEFAULT 'outro',
-- declaracao_comparecimento | atestado_psicologico
-- relatorio_acompanhamento | recibo_pagamento
-- termo_consentimento | encaminhamento | outro
descricao text,
-- Corpo do template
corpo_html text NOT NULL DEFAULT '',
cabecalho_html text,
rodape_html text,
-- Variaveis que o template utiliza
variaveis text[] DEFAULT '{}',
-- Ex: {paciente_nome, paciente_cpf, data_sessao, terapeuta_nome, ...}
-- Personalizacao visual
logo_url text,
-- Escopo
is_global boolean DEFAULT false NOT NULL,
-- true = template padrao do sistema (visivel para todos)
-- false = template criado pelo tenant/terapeuta
-- Controle
ativo boolean DEFAULT true NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT document_templates_pkey PRIMARY KEY (id),
CONSTRAINT dt_tipo_check CHECK (
tipo = ANY (ARRAY[
'declaracao_comparecimento', 'atestado_psicologico',
'relatorio_acompanhamento', 'recibo_pagamento',
'termo_consentimento', 'encaminhamento',
'contrato_servicos', 'tcle', 'autorizacao_menor',
'laudo_psicologico', 'parecer_psicologico',
'termo_sigilo', 'declaracao_inicio_tratamento',
'termo_alta', 'tcle_online', 'outro'
])
)
);
-- --------------------------------------------------------------------------
-- 2. Indices — document_templates
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS dt_tenant_idx
ON public.document_templates USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS dt_owner_idx
ON public.document_templates USING btree (owner_id);
CREATE INDEX IF NOT EXISTS dt_global_idx
ON public.document_templates USING btree (is_global)
WHERE is_global = true;
CREATE INDEX IF NOT EXISTS dt_tipo_idx
ON public.document_templates USING btree (tipo);
CREATE INDEX IF NOT EXISTS dt_nome_trgm_idx
ON public.document_templates USING gin (nome_template gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger updated_at
-- --------------------------------------------------------------------------
CREATE TRIGGER trg_dt_updated_at
BEFORE UPDATE ON public.document_templates
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- --------------------------------------------------------------------------
-- 4. RLS — document_templates
-- --------------------------------------------------------------------------
ALTER TABLE public.document_templates ENABLE ROW LEVEL SECURITY;
-- Templates globais: todos podem ler
CREATE POLICY "dt: global templates readable by all"
ON public.document_templates
FOR SELECT
USING (is_global = true);
-- Templates do tenant: membros do tenant podem ler
CREATE POLICY "dt: tenant members can select"
ON public.document_templates
FOR SELECT
USING (
is_global = false
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- Owner pode inserir/atualizar/deletar seus templates
CREATE POLICY "dt: owner can insert"
ON public.document_templates
FOR INSERT
WITH CHECK (owner_id = auth.uid() AND is_global = false);
CREATE POLICY "dt: owner can update"
ON public.document_templates
FOR UPDATE
USING (owner_id = auth.uid() AND is_global = false)
WITH CHECK (owner_id = auth.uid() AND is_global = false);
CREATE POLICY "dt: owner can delete"
ON public.document_templates
FOR DELETE
USING (owner_id = auth.uid() AND is_global = false);
-- SaaS admin pode gerenciar templates globais (usa funcao public.is_saas_admin())
CREATE POLICY "dt: saas admin can insert global"
ON public.document_templates
FOR INSERT
WITH CHECK (is_global = true AND public.is_saas_admin());
CREATE POLICY "dt: saas admin can update global"
ON public.document_templates
FOR UPDATE
USING (is_global = true AND public.is_saas_admin())
WITH CHECK (is_global = true AND public.is_saas_admin());
CREATE POLICY "dt: saas admin can delete global"
ON public.document_templates
FOR DELETE
USING (is_global = true AND public.is_saas_admin());
-- --------------------------------------------------------------------------
-- 5. Comentarios — document_templates
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.document_templates IS 'Templates de documentos para geracao automatica (declaracao, atestado, recibo etc.).';
COMMENT ON COLUMN public.document_templates.nome_template IS 'Nome do template. Ex: Declaracao de Comparecimento.';
COMMENT ON COLUMN public.document_templates.tipo IS 'declaracao_comparecimento|atestado_psicologico|relatorio_acompanhamento|recibo_pagamento|termo_consentimento|encaminhamento|outro.';
COMMENT ON COLUMN public.document_templates.corpo_html IS 'Corpo do template em HTML com variaveis {{nome_variavel}}.';
COMMENT ON COLUMN public.document_templates.cabecalho_html IS 'HTML do cabecalho (logo, nome da clinica etc.).';
COMMENT ON COLUMN public.document_templates.rodape_html IS 'HTML do rodape (CRP, endereco, contato etc.).';
COMMENT ON COLUMN public.document_templates.variaveis IS 'Array com nomes das variaveis usadas no template. Ex: {paciente_nome, data_sessao}.';
COMMENT ON COLUMN public.document_templates.is_global IS 'true = template padrao do sistema visivel para todos. false = template do tenant.';
COMMENT ON COLUMN public.document_templates.logo_url IS 'URL do logo personalizado para o cabecalho do documento.';
-- ==========================================================================
-- 6. Tabela: document_generated (cada PDF gerado)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_generated (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Origem
template_id uuid NOT NULL REFERENCES public.document_templates(id) ON DELETE RESTRICT,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Dados usados no preenchimento (snapshot — permite auditoria futura)
dados_preenchidos jsonb NOT NULL DEFAULT '{}',
-- PDF gerado
pdf_path text NOT NULL,
storage_bucket text NOT NULL DEFAULT 'generated-docs',
-- Vinculo opcional com documento pai (se o PDF gerado tambem for registrado em documents)
documento_id uuid REFERENCES public.documents(id) ON DELETE SET NULL,
-- Quem gerou
gerado_por uuid NOT NULL,
gerado_em timestamptz DEFAULT now() NOT NULL,
CONSTRAINT document_generated_pkey PRIMARY KEY (id)
);
-- --------------------------------------------------------------------------
-- 7. Indices — document_generated
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS dg_template_idx
ON public.document_generated USING btree (template_id);
CREATE INDEX IF NOT EXISTS dg_patient_idx
ON public.document_generated USING btree (patient_id, gerado_em DESC);
CREATE INDEX IF NOT EXISTS dg_tenant_idx
ON public.document_generated USING btree (tenant_id, gerado_em DESC);
CREATE INDEX IF NOT EXISTS dg_gerado_por_idx
ON public.document_generated USING btree (gerado_por, gerado_em DESC);
-- --------------------------------------------------------------------------
-- 8. RLS — document_generated
-- --------------------------------------------------------------------------
ALTER TABLE public.document_generated ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dg: generator full access"
ON public.document_generated
USING (gerado_por = auth.uid())
WITH CHECK (gerado_por = auth.uid());
-- Membros do tenant podem visualizar
CREATE POLICY "dg: tenant members can select"
ON public.document_generated
FOR SELECT
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- --------------------------------------------------------------------------
-- 9. Comentarios — document_generated
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.document_generated IS 'Registro de cada documento PDF gerado a partir de um template.';
COMMENT ON COLUMN public.document_generated.template_id IS 'Template usado para gerar o documento.';
COMMENT ON COLUMN public.document_generated.dados_preenchidos IS 'Snapshot JSON dos dados usados no preenchimento. Permite auditoria futura.';
COMMENT ON COLUMN public.document_generated.pdf_path IS 'Caminho do PDF gerado no Supabase Storage bucket.';
COMMENT ON COLUMN public.document_generated.documento_id IS 'FK opcional para documents — se o PDF gerado tambem foi registrado como documento do paciente.';
COMMENT ON COLUMN public.document_generated.gerado_por IS 'Usuario que gerou o documento (auth.uid()).';
-- ==========================================================================
-- FIM DA MIGRACAO 006
-- ==========================================================================

View File

@@ -0,0 +1,93 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Storage Buckets para Documentos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Cria os buckets no Supabase Storage para documentos de pacientes
-- e PDFs gerados pelo sistema.
-- ==========================================================================
-- Bucket: documents (uploads de terapeuta/paciente)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'documents',
'documents',
false,
52428800, -- 50 MB
ARRAY[
'application/pdf',
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain'
]
)
ON CONFLICT (id) DO NOTHING;
-- Bucket: generated-docs (PDFs gerados pelo sistema)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'generated-docs',
'generated-docs',
false,
20971520, -- 20 MB
ARRAY['application/pdf']
)
ON CONFLICT (id) DO NOTHING;
-- --------------------------------------------------------------------------
-- Storage RLS Policies — bucket: documents
-- --------------------------------------------------------------------------
-- Upload: usuario autenticado pode fazer upload no path do seu tenant
CREATE POLICY "documents: authenticated upload"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'documents');
-- Download: usuario autenticado pode ler arquivos do seu tenant
CREATE POLICY "documents: authenticated read"
ON storage.objects
FOR SELECT
TO authenticated
USING (bucket_id = 'documents');
-- Delete: usuario autenticado pode deletar seus arquivos
CREATE POLICY "documents: authenticated delete"
ON storage.objects
FOR DELETE
TO authenticated
USING (bucket_id = 'documents');
-- --------------------------------------------------------------------------
-- Storage RLS Policies — bucket: generated-docs
-- --------------------------------------------------------------------------
CREATE POLICY "generated-docs: authenticated upload"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'generated-docs');
CREATE POLICY "generated-docs: authenticated read"
ON storage.objects
FOR SELECT
TO authenticated
USING (bucket_id = 'generated-docs');
CREATE POLICY "generated-docs: authenticated delete"
ON storage.objects
FOR DELETE
TO authenticated
USING (bucket_id = 'generated-docs');
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================

View File

@@ -0,0 +1,661 @@
-- =============================================================================
-- MIGRATION: patients — melhorias completas
-- Gerado em: 2025-03
-- Estratégia: cirúrgico — só adiciona, nunca destrói o que existe
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. ALTERAÇÕES NA TABELA patients
-- Novos campos adicionados sem tocar nos existentes
-- -----------------------------------------------------------------------------
ALTER TABLE public.patients
-- Identidade & pronomes
ADD COLUMN IF NOT EXISTS nome_social text,
ADD COLUMN IF NOT EXISTS pronomes text,
-- Dados socioeconômicos (opcionais, clínicamente relevantes)
ADD COLUMN IF NOT EXISTS etnia text,
ADD COLUMN IF NOT EXISTS religiao text,
ADD COLUMN IF NOT EXISTS faixa_renda text,
-- Preferências de comunicação (alimenta lembretes automáticos)
ADD COLUMN IF NOT EXISTS canal_preferido text DEFAULT 'whatsapp',
ADD COLUMN IF NOT EXISTS horario_contato_inicio time DEFAULT '08:00',
ADD COLUMN IF NOT EXISTS horario_contato_fim time DEFAULT '20:00',
ADD COLUMN IF NOT EXISTS idioma text DEFAULT 'pt-BR',
-- Origem estruturada (permite filtros e relatórios)
ADD COLUMN IF NOT EXISTS origem text,
-- Financeiro
ADD COLUMN IF NOT EXISTS metodo_pagamento_preferido text,
-- Ciclo de vida
ADD COLUMN IF NOT EXISTS motivo_saida text,
ADD COLUMN IF NOT EXISTS data_saida date,
ADD COLUMN IF NOT EXISTS encaminhado_para text,
-- Risco clínico (flag de atenção visível no topo do cadastro)
ADD COLUMN IF NOT EXISTS risco_elevado boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS risco_nota text,
ADD COLUMN IF NOT EXISTS risco_sinalizado_em timestamp with time zone,
ADD COLUMN IF NOT EXISTS risco_sinalizado_por uuid REFERENCES auth.users(id) ON DELETE SET NULL;
-- Constraints de validação para novos campos enum-like
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_canal_preferido_check,
ADD CONSTRAINT patients_canal_preferido_check
CHECK (canal_preferido IS NULL OR canal_preferido = ANY (
ARRAY['whatsapp','email','sms','telefone']
));
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_metodo_pagamento_check,
ADD CONSTRAINT patients_metodo_pagamento_check
CHECK (metodo_pagamento_preferido IS NULL OR metodo_pagamento_preferido = ANY (
ARRAY['pix','cartao','dinheiro','deposito','convenio']
));
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_faixa_renda_check,
ADD CONSTRAINT patients_faixa_renda_check
CHECK (faixa_renda IS NULL OR faixa_renda = ANY (
ARRAY['ate_1sm','1_3sm','3_6sm','6_10sm','acima_10sm','nao_informado']
));
-- Constraint: risco_elevado = true exige nota e sinalizante
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_risco_consistency_check,
ADD CONSTRAINT patients_risco_consistency_check
CHECK (
(risco_elevado = false)
OR (risco_elevado = true AND risco_nota IS NOT NULL AND risco_sinalizado_por IS NOT NULL)
);
-- Comments
COMMENT ON COLUMN public.patients.nome_social IS 'Nome social preferido — exibido no lugar do nome completo quando preenchido';
COMMENT ON COLUMN public.patients.pronomes IS 'Pronomes preferidos: ele/dele, ela/dela, eles/deles, etc.';
COMMENT ON COLUMN public.patients.etnia IS 'Autodeclaração étnico-racial (opcional)';
COMMENT ON COLUMN public.patients.religiao IS 'Religião ou espiritualidade (opcional, relevante clinicamente)';
COMMENT ON COLUMN public.patients.faixa_renda IS 'Faixa de renda em salários mínimos — usado para precificação solidária';
COMMENT ON COLUMN public.patients.canal_preferido IS 'Canal de comunicação preferido para lembretes e notificações';
COMMENT ON COLUMN public.patients.horario_contato_inicio IS 'Início da janela de horário preferida para contato';
COMMENT ON COLUMN public.patients.horario_contato_fim IS 'Fim da janela de horário preferida para contato';
COMMENT ON COLUMN public.patients.origem IS 'Como o paciente chegou: indicacao, agendador, redes_sociais, encaminhamento, outro';
COMMENT ON COLUMN public.patients.metodo_pagamento_preferido IS 'Método de pagamento habitual — sugerido ao criar cobrança';
COMMENT ON COLUMN public.patients.motivo_saida IS 'Motivo da alta, inativação ou encaminhamento';
COMMENT ON COLUMN public.patients.data_saida IS 'Data em que o paciente foi desligado/encaminhado';
COMMENT ON COLUMN public.patients.encaminhado_para IS 'Nome ou serviço para onde o paciente foi encaminhado';
COMMENT ON COLUMN public.patients.risco_elevado IS 'Flag de atenção clínica — exibe alerta no topo do cadastro e prontuário';
COMMENT ON COLUMN public.patients.risco_nota IS 'Descrição do risco (obrigatória quando risco_elevado = true)';
COMMENT ON COLUMN public.patients.risco_sinalizado_em IS 'Timestamp em que o risco foi sinalizado';
COMMENT ON COLUMN public.patients.risco_sinalizado_por IS 'Usuário que sinalizou o risco';
-- Índices úteis para filtros frequentes
CREATE INDEX IF NOT EXISTS idx_patients_risco_elevado
ON public.patients (tenant_id, risco_elevado)
WHERE risco_elevado = true;
CREATE INDEX IF NOT EXISTS idx_patients_status_tenant
ON public.patients (tenant_id, status);
CREATE INDEX IF NOT EXISTS idx_patients_origem
ON public.patients (tenant_id, origem)
WHERE origem IS NOT NULL;
-- -----------------------------------------------------------------------------
-- 2. TABELA patient_contacts
-- Substitui os campos soltos nome_parente/telefone_parente na tabela principal
-- Os campos antigos ficam intactos (retrocompatibilidade)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.patient_contacts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
-- Identificação
nome text NOT NULL,
tipo text NOT NULL, -- emergencia | responsavel_legal | profissional_saude | outro
relacao text, -- mãe, pai, psiquiatra, médico, cônjuge...
-- Contato
telefone text,
email text,
cpf text,
-- Profissional de saúde
especialidade text, -- preenchido quando tipo = profissional_saude
registro_profissional text, -- CRM, CRP, etc.
-- Flags
is_primario boolean DEFAULT false NOT NULL, -- contato principal de emergência
ativo boolean DEFAULT true NOT NULL,
-- Auditoria
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT patient_contacts_pkey PRIMARY KEY (id),
CONSTRAINT patient_contacts_tipo_check CHECK (tipo = ANY (
ARRAY['emergencia','responsavel_legal','profissional_saude','outro']
))
);
COMMENT ON TABLE public.patient_contacts IS 'Contatos vinculados ao paciente: emergência, responsável legal, outros profissionais de saúde';
COMMENT ON COLUMN public.patient_contacts.tipo IS 'Categoria do contato: emergencia | responsavel_legal | profissional_saude | outro';
COMMENT ON COLUMN public.patient_contacts.is_primario IS 'Contato de emergência principal — exibido em destaque no cadastro';
-- Garante no máximo 1 contato primário por paciente
CREATE UNIQUE INDEX IF NOT EXISTS uq_patient_contacts_primario
ON public.patient_contacts (patient_id)
WHERE is_primario = true AND ativo = true;
CREATE INDEX IF NOT EXISTS idx_patient_contacts_patient
ON public.patient_contacts (patient_id);
CREATE INDEX IF NOT EXISTS idx_patient_contacts_tenant
ON public.patient_contacts (tenant_id);
-- updated_at automático
CREATE TRIGGER trg_patient_contacts_updated_at
BEFORE UPDATE ON public.patient_contacts
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
-- RLS — mesmas regras de patients
ALTER TABLE public.patient_contacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY patient_contacts_select ON public.patient_contacts
FOR SELECT USING (
public.is_clinic_tenant(tenant_id)
AND public.is_tenant_member(tenant_id)
AND public.tenant_has_feature(tenant_id, 'patients.view')
);
CREATE POLICY patient_contacts_write ON public.patient_contacts
USING (
public.is_clinic_tenant(tenant_id)
AND public.is_tenant_member(tenant_id)
AND public.tenant_has_feature(tenant_id, 'patients.edit')
)
WITH CHECK (
public.is_clinic_tenant(tenant_id)
AND public.is_tenant_member(tenant_id)
AND public.tenant_has_feature(tenant_id, 'patients.edit')
);
-- -----------------------------------------------------------------------------
-- 3. TABELA patient_status_history
-- Trilha de auditoria de todas as mudanças de status do paciente
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.patient_status_history (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
status_anterior text, -- NULL na primeira inserção
status_novo text NOT NULL,
motivo text,
encaminhado_para text, -- preenchido quando status = Encaminhado
data_saida date, -- preenchido quando Alta/Encaminhado/Arquivado
alterado_por uuid REFERENCES auth.users(id) ON DELETE SET NULL,
alterado_em timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT patient_status_history_pkey PRIMARY KEY (id),
CONSTRAINT psh_status_novo_check CHECK (status_novo = ANY (
ARRAY['Ativo','Inativo','Alta','Encaminhado','Arquivado']
))
);
COMMENT ON TABLE public.patient_status_history IS 'Histórico imutável de todas as mudanças de status do paciente — não editar, apenas inserir';
CREATE INDEX IF NOT EXISTS idx_psh_patient
ON public.patient_status_history (patient_id, alterado_em DESC);
CREATE INDEX IF NOT EXISTS idx_psh_tenant
ON public.patient_status_history (tenant_id, alterado_em DESC);
-- RLS
ALTER TABLE public.patient_status_history ENABLE ROW LEVEL SECURITY;
CREATE POLICY psh_select ON public.patient_status_history
FOR SELECT USING (
public.is_clinic_tenant(tenant_id)
AND public.is_tenant_member(tenant_id)
AND public.tenant_has_feature(tenant_id, 'patients.view')
);
CREATE POLICY psh_insert ON public.patient_status_history
FOR INSERT WITH CHECK (
public.is_clinic_tenant(tenant_id)
AND public.is_tenant_member(tenant_id)
AND public.tenant_has_feature(tenant_id, 'patients.edit')
);
-- Trigger: registra automaticamente no histórico quando status muda em patients
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_history()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
INSERT INTO public.patient_status_history (
patient_id, tenant_id,
status_anterior, status_novo,
motivo, encaminhado_para, data_saida,
alterado_por, alterado_em
) VALUES (
NEW.id, NEW.tenant_id,
CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.status END,
NEW.status,
NEW.motivo_saida,
NEW.encaminhado_para,
NEW.data_saida,
auth.uid(),
now()
);
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_patient_status_history ON public.patients;
CREATE TRIGGER trg_patient_status_history
AFTER INSERT OR UPDATE OF status ON public.patients
FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_history();
-- -----------------------------------------------------------------------------
-- 4. TABELA patient_timeline
-- Feed cronológico automático de eventos relevantes do paciente
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.patient_timeline (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
-- Tipo do evento
evento_tipo text NOT NULL,
-- Exemplos: primeira_sessao | sessao_realizada | sessao_cancelada | falta |
-- status_alterado | risco_sinalizado | documento_assinado |
-- escala_respondida | pagamento_vencido | pagamento_recebido |
-- tarefa_combinada | contato_adicionado | prontuario_editado
titulo text NOT NULL, -- Ex: "Sessão realizada"
descricao text, -- Ex: "Sessão 47 · presencial · 50min"
icone_cor text DEFAULT 'gray', -- green | blue | amber | red | gray
link_ref_tipo text, -- agenda_evento | financial_record | documento | escala
link_ref_id uuid, -- FK genérico — sem constraint formal (polimórfico)
gerado_por uuid REFERENCES auth.users(id) ON DELETE SET NULL,
ocorrido_em timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT patient_timeline_pkey PRIMARY KEY (id),
CONSTRAINT pt_evento_tipo_check CHECK (evento_tipo = ANY (ARRAY[
'primeira_sessao','sessao_realizada','sessao_cancelada','falta',
'status_alterado','risco_sinalizado','risco_removido',
'documento_assinado','documento_adicionado',
'escala_respondida','escala_enviada',
'pagamento_vencido','pagamento_recebido',
'tarefa_combinada','contato_adicionado',
'prontuario_editado','nota_adicionada','manual'
])),
CONSTRAINT pt_icone_cor_check CHECK (icone_cor = ANY (
ARRAY['green','blue','amber','red','gray','purple']
))
);
COMMENT ON TABLE public.patient_timeline IS 'Feed cronológico de eventos do paciente — alimentado por triggers e inserções manuais';
COMMENT ON COLUMN public.patient_timeline.link_ref_tipo IS 'Tipo da entidade referenciada (polimórfico): agenda_evento | financial_record | documento | escala';
COMMENT ON COLUMN public.patient_timeline.link_ref_id IS 'ID da entidade referenciada — sem FK formal para suportar múltiplos tipos';
CREATE INDEX IF NOT EXISTS idx_pt_patient_ocorrido
ON public.patient_timeline (patient_id, ocorrido_em DESC);
CREATE INDEX IF NOT EXISTS idx_pt_tenant
ON public.patient_timeline (tenant_id, ocorrido_em DESC);
CREATE INDEX IF NOT EXISTS idx_pt_evento_tipo
ON public.patient_timeline (patient_id, evento_tipo);
-- RLS
ALTER TABLE public.patient_timeline ENABLE ROW LEVEL SECURITY;
CREATE POLICY pt_select ON public.patient_timeline
FOR SELECT USING (
public.is_clinic_tenant(tenant_id)
AND public.is_tenant_member(tenant_id)
AND public.tenant_has_feature(tenant_id, 'patients.view')
);
CREATE POLICY pt_insert ON public.patient_timeline
FOR INSERT WITH CHECK (
public.is_clinic_tenant(tenant_id)
AND public.is_tenant_member(tenant_id)
AND public.tenant_has_feature(tenant_id, 'patients.edit')
);
-- Trigger: registra na timeline quando risco é sinalizado/removido
CREATE OR REPLACE FUNCTION public.trg_fn_patient_risco_timeline()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF OLD.risco_elevado IS DISTINCT FROM NEW.risco_elevado THEN
INSERT INTO public.patient_timeline (
patient_id, tenant_id,
evento_tipo, titulo, descricao, icone_cor,
gerado_por, ocorrido_em
) VALUES (
NEW.id, NEW.tenant_id,
CASE WHEN NEW.risco_elevado THEN 'risco_sinalizado' ELSE 'risco_removido' END,
CASE WHEN NEW.risco_elevado THEN 'Risco elevado sinalizado' ELSE 'Sinalização de risco removida' END,
NEW.risco_nota,
CASE WHEN NEW.risco_elevado THEN 'red' ELSE 'green' END,
auth.uid(),
now()
);
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_patient_risco_timeline ON public.patients;
CREATE TRIGGER trg_patient_risco_timeline
AFTER UPDATE OF risco_elevado ON public.patients
FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_risco_timeline();
-- Trigger: registra na timeline quando status muda
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_timeline()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
INSERT INTO public.patient_timeline (
patient_id, tenant_id,
evento_tipo, titulo, descricao, icone_cor,
gerado_por, ocorrido_em
) VALUES (
NEW.id, NEW.tenant_id,
'status_alterado',
'Status alterado para ' || NEW.status,
CASE
WHEN TG_OP = 'INSERT' THEN 'Paciente cadastrado'
ELSE 'De ' || OLD.status || '' || NEW.status ||
CASE WHEN NEW.motivo_saida IS NOT NULL THEN ' · ' || NEW.motivo_saida ELSE '' END
END,
CASE NEW.status
WHEN 'Ativo' THEN 'green'
WHEN 'Alta' THEN 'blue'
WHEN 'Inativo' THEN 'gray'
WHEN 'Encaminhado' THEN 'amber'
WHEN 'Arquivado' THEN 'gray'
ELSE 'gray'
END,
auth.uid(),
now()
);
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_patient_status_timeline ON public.patients;
CREATE TRIGGER trg_patient_status_timeline
AFTER INSERT OR UPDATE OF status ON public.patients
FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_timeline();
-- -----------------------------------------------------------------------------
-- 5. VIEW v_patient_engajamento
-- Score calculado em tempo real — sem armazenar, sem inconsistência
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW public.v_patient_engajamento
WITH (security_invoker = on)
AS
WITH sessoes AS (
SELECT
ae.patient_id,
ae.tenant_id,
COUNT(*) FILTER (WHERE ae.status = 'realizado') AS total_realizadas,
COUNT(*) FILTER (WHERE ae.status IN ('realizado','cancelado','faltou')) AS total_marcadas,
COUNT(*) FILTER (WHERE ae.status = 'faltou') AS total_faltas,
MAX(ae.inicio_em) FILTER (WHERE ae.status = 'realizado') AS ultima_sessao_em,
MIN(ae.inicio_em) FILTER (WHERE ae.status = 'realizado') AS primeira_sessao_em,
COUNT(*) FILTER (WHERE ae.status = 'realizado'
AND ae.inicio_em >= now() - interval '30 days') AS sessoes_ultimo_mes
FROM public.agenda_eventos ae
WHERE ae.patient_id IS NOT NULL
GROUP BY ae.patient_id, ae.tenant_id
),
financeiro AS (
SELECT
fr.patient_id,
fr.tenant_id,
COALESCE(SUM(fr.final_amount) FILTER (WHERE fr.status = 'paid'), 0) AS total_pago,
COALESCE(AVG(fr.final_amount) FILTER (WHERE fr.status = 'paid'), 0) AS ticket_medio,
COUNT(*) FILTER (WHERE fr.status IN ('pending','overdue')
AND fr.due_date < now()) AS cobr_vencidas,
COUNT(*) FILTER (WHERE fr.status IN ('pending','overdue')) AS cobr_pendentes,
COUNT(*) FILTER (WHERE fr.type = 'receita' AND fr.status = 'paid') AS cobr_pagas
FROM public.financial_records fr
WHERE fr.patient_id IS NOT NULL
AND fr.deleted_at IS NULL
GROUP BY fr.patient_id, fr.tenant_id
)
SELECT
p.id AS patient_id,
p.tenant_id,
p.nome_completo,
p.status,
p.risco_elevado,
-- Sessões
COALESCE(s.total_realizadas, 0) AS total_sessoes,
COALESCE(s.sessoes_ultimo_mes, 0) AS sessoes_ultimo_mes,
s.primeira_sessao_em,
s.ultima_sessao_em,
EXTRACT(DAY FROM now() - s.ultima_sessao_em)::int AS dias_sem_sessao,
-- Taxa de comparecimento (%)
CASE
WHEN COALESCE(s.total_marcadas, 0) = 0 THEN NULL
ELSE ROUND((s.total_realizadas::numeric / s.total_marcadas) * 100, 1)
END AS taxa_comparecimento,
-- Financeiro
COALESCE(f.total_pago, 0) AS ltv_total,
ROUND(COALESCE(f.ticket_medio, 0), 2) AS ticket_medio,
COALESCE(f.cobr_vencidas, 0) AS cobr_vencidas,
COALESCE(f.cobr_pagas, 0) AS cobr_pagas,
-- Taxa de pagamentos em dia (%)
CASE
WHEN COALESCE(f.cobr_pagas + f.cobr_vencidas, 0) = 0 THEN NULL
ELSE ROUND(
f.cobr_pagas::numeric / (f.cobr_pagas + f.cobr_vencidas) * 100, 1
)
END AS taxa_pagamentos_dia,
-- Score de engajamento composto (0-100)
-- Pesos: comparecimento 50%, pagamentos 30%, recência 20%
ROUND(
LEAST(100,
COALESCE(
(
-- Comparecimento (50 pts)
CASE WHEN COALESCE(s.total_marcadas, 0) = 0 THEN 50
ELSE LEAST(50, (s.total_realizadas::numeric / s.total_marcadas) * 50)
END
+
-- Pagamentos em dia (30 pts)
CASE WHEN COALESCE(f.cobr_pagas + f.cobr_vencidas, 0) = 0 THEN 30
ELSE LEAST(30, f.cobr_pagas::numeric / (f.cobr_pagas + f.cobr_vencidas) * 30)
END
+
-- Recência (20 pts — penaliza quem está há muito tempo sem sessão)
CASE
WHEN s.ultima_sessao_em IS NULL THEN 0
WHEN EXTRACT(DAY FROM now() - s.ultima_sessao_em) <= 14 THEN 20
WHEN EXTRACT(DAY FROM now() - s.ultima_sessao_em) <= 30 THEN 15
WHEN EXTRACT(DAY FROM now() - s.ultima_sessao_em) <= 60 THEN 8
ELSE 0
END
), 0
)
)
, 0) AS engajamento_score,
-- Duração do tratamento
CASE
WHEN s.primeira_sessao_em IS NULL THEN NULL
ELSE EXTRACT(DAY FROM now() - s.primeira_sessao_em)::int
END AS duracao_tratamento_dias
FROM public.patients p
LEFT JOIN sessoes s ON s.patient_id = p.id AND s.tenant_id = p.tenant_id
LEFT JOIN financeiro f ON f.patient_id = p.id AND f.tenant_id = p.tenant_id;
COMMENT ON VIEW public.v_patient_engajamento IS
'Score de engajamento e métricas consolidadas por paciente. Calculado em tempo real via RLS (security_invoker=on).';
-- -----------------------------------------------------------------------------
-- 6. VIEW v_patients_risco
-- Lista rápida de pacientes que precisam de atenção imediata
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW public.v_patients_risco
WITH (security_invoker = on)
AS
SELECT
p.id,
p.tenant_id,
p.nome_completo,
p.status,
p.risco_elevado,
p.risco_nota,
p.risco_sinalizado_em,
e.dias_sem_sessao,
e.engajamento_score,
e.taxa_comparecimento,
-- Motivo do alerta
CASE
WHEN p.risco_elevado THEN 'risco_sinalizado'
WHEN COALESCE(e.dias_sem_sessao, 999) > 30
AND p.status = 'Ativo' THEN 'sem_sessao_30d'
WHEN COALESCE(e.taxa_comparecimento, 100) < 60 THEN 'baixo_comparecimento'
WHEN COALESCE(e.cobr_vencidas, 0) > 0 THEN 'cobranca_vencida'
ELSE 'ok'
END AS alerta_tipo
FROM public.patients p
JOIN public.v_patient_engajamento e ON e.patient_id = p.id
WHERE p.status = 'Ativo'
AND (
p.risco_elevado = true
OR COALESCE(e.dias_sem_sessao, 999) > 30
OR COALESCE(e.taxa_comparecimento, 100) < 60
OR COALESCE(e.cobr_vencidas, 0) > 0
);
COMMENT ON VIEW public.v_patients_risco IS
'Pacientes ativos que precisam de atenção: risco clínico, sem sessão há 30+ dias, baixo comparecimento ou cobrança vencida';
-- -----------------------------------------------------------------------------
-- 7. Migração de dados: popular patient_contacts com os dados já existentes
-- Roda só uma vez — protegido por WHERE NOT EXISTS
-- -----------------------------------------------------------------------------
INSERT INTO public.patient_contacts (
patient_id, tenant_id,
nome, tipo, relacao,
telefone, is_primario, ativo
)
SELECT
p.id,
p.tenant_id,
p.nome_parente,
'emergencia',
p.grau_parentesco,
p.telefone_parente,
true,
true
FROM public.patients p
WHERE p.nome_parente IS NOT NULL
AND p.telefone_parente IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM public.patient_contacts pc
WHERE pc.patient_id = p.id AND pc.tipo = 'emergencia'
);
-- Migra responsável legal quando diferente do parente de emergência
INSERT INTO public.patient_contacts (
patient_id, tenant_id,
nome, tipo, relacao,
telefone, cpf,
is_primario, ativo
)
SELECT
p.id,
p.tenant_id,
p.nome_responsavel,
'responsavel_legal',
'Responsável legal',
p.telefone_responsavel,
p.cpf_responsavel,
false,
true
FROM public.patients p
WHERE p.nome_responsavel IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM public.patient_contacts pc
WHERE pc.patient_id = p.id AND pc.tipo = 'responsavel_legal'
);
-- -----------------------------------------------------------------------------
-- 8. Seed do histórico de status para pacientes já existentes
-- Cria a primeira entrada de histórico com o status atual
-- -----------------------------------------------------------------------------
INSERT INTO public.patient_status_history (
patient_id, tenant_id,
status_anterior, status_novo,
motivo, alterado_em
)
SELECT
p.id,
p.tenant_id,
NULL,
p.status,
'Status inicial — migração de dados',
COALESCE(p.created_at, now())
FROM public.patients p
WHERE NOT EXISTS (
SELECT 1 FROM public.patient_status_history psh
WHERE psh.patient_id = p.id
);
-- =============================================================================
-- FIM DO MIGRATION
-- Resumo do que foi feito:
-- 1. ALTER TABLE patients — 16 novos campos (pronomes, risco, origem, etc.)
-- 2. CREATE TABLE patient_contacts — múltiplos contatos por paciente
-- 3. CREATE TABLE patient_status_history — trilha imutável de mudanças de status
-- 4. CREATE TABLE patient_timeline — feed cronológico de eventos
-- 5. Triggers automáticos — status history, timeline de risco e status
-- 6. VIEW v_patient_engajamento — score 0-100 + métricas calculadas em tempo real
-- 7. VIEW v_patients_risco — lista de pacientes que precisam de atenção
-- 8. Migração de dados — popula patient_contacts e status_history com dados existentes
-- =============================================================================

View File

@@ -60,7 +60,14 @@ VALUES
-- ── Branding / API / Auditoria ── -- ── Branding / API / Auditoria ──
('f393178c-284d-422f-b096-8793f85428d5', 'custom_branding', 'Custom branding', '2026-03-01 09:59:15.432733+00', 'Personalização de marca', 'Marca Personalizada'), ('f393178c-284d-422f-b096-8793f85428d5', 'custom_branding', 'Custom branding', '2026-03-01 09:59:15.432733+00', 'Personalização de marca', 'Marca Personalizada'),
('d6f54674-ea8b-484b-af0e-99127a510da2', 'api_access', 'API/Integrations access', '2026-03-01 09:59:15.432733+00', 'Integrações/API', 'Acesso à API'), ('d6f54674-ea8b-484b-af0e-99127a510da2', 'api_access', 'API/Integrations access', '2026-03-01 09:59:15.432733+00', 'Integrações/API', 'Acesso à API'),
('a5593d96-dd95-46bb-bef0-bd379b56ad50', 'audit_log', 'Audit log capability', '2026-03-01 09:59:15.432733+00', 'Auditoria', 'Log de Auditoria') ('a5593d96-dd95-46bb-bef0-bd379b56ad50', 'audit_log', 'Audit log capability', '2026-03-01 09:59:15.432733+00', 'Auditoria', 'Log de Auditoria'),
-- ── Documentos ──
('b1a2c3d4-1111-4aaa-bbbb-000000000001', 'documents.upload', 'Upload e gestão de arquivos do paciente', '2026-03-29 00:00:00.000000+00', 'Upload de documentos', 'Upload de Documentos'),
('b1a2c3d4-1111-4aaa-bbbb-000000000002', 'documents.templates', 'Geração de documentos a partir de templates', '2026-03-29 00:00:00.000000+00', 'Templates de documentos', 'Templates de Documentos'),
('b1a2c3d4-1111-4aaa-bbbb-000000000003', 'documents.signatures', 'Assinatura eletrônica de documentos', '2026-03-29 00:00:00.000000+00', 'Assinaturas eletrônicas', 'Assinaturas Eletrônicas'),
('b1a2c3d4-1111-4aaa-bbbb-000000000004', 'documents.share_links', 'Links temporários para compartilhamento de documentos', '2026-03-29 00:00:00.000000+00', 'Links de compartilhamento', 'Links de Compartilhamento'),
('b1a2c3d4-1111-4aaa-bbbb-000000000005', 'documents.patient_portal', 'Acesso a documentos pelo portal do paciente', '2026-03-29 00:00:00.000000+00', 'Portal do paciente (documentos)', 'Portal do Paciente (Documentos)')
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
key = EXCLUDED.key, key = EXCLUDED.key,
@@ -71,7 +78,7 @@ ON CONFLICT (id) DO UPDATE SET
DO $$ DO $$
BEGIN BEGIN
RAISE NOTICE 'seed_011_features: 26 features inseridas/atualizadas.'; RAISE NOTICE 'seed_011_features: 31 features inseridas/atualizadas.';
END; END;
$$; $$;

View File

@@ -52,7 +52,13 @@ INSERT INTO public.plan_features (plan_id, feature_id, enabled, limits) VALUES
-- PRO exclusivo -- PRO exclusivo
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'f393178c-284d-422f-b096-8793f85428d5', true, NULL), -- custom_branding ('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'f393178c-284d-422f-b096-8793f85428d5', true, NULL), -- custom_branding
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'd6f54674-ea8b-484b-af0e-99127a510da2', true, NULL), -- api_access ('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'd6f54674-ea8b-484b-af0e-99127a510da2', true, NULL), -- api_access
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', true, NULL); -- audit_log ('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', true, NULL), -- audit_log
-- Documentos
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000001', true, '{"max_storage_mb": 5000}'), -- documents.upload
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000002', true, NULL), -- documents.templates
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000003', true, NULL), -- documents.signatures
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000004', true, NULL), -- documents.share_links
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000005', true, NULL); -- documents.patient_portal
-- ════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════
@@ -88,7 +94,13 @@ INSERT INTO public.plan_features (plan_id, feature_id, enabled, limits) VALUES
('01a5867f-0705-4714-ac97-a23470949157', '74fc1321-4d17-49c3-b72e-db3a7f4be451', false, NULL), -- rooms (PRO) ('01a5867f-0705-4714-ac97-a23470949157', '74fc1321-4d17-49c3-b72e-db3a7f4be451', false, NULL), -- rooms (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'f393178c-284d-422f-b096-8793f85428d5', false, NULL), -- custom_branding (PRO) ('01a5867f-0705-4714-ac97-a23470949157', 'f393178c-284d-422f-b096-8793f85428d5', false, NULL), -- custom_branding (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'd6f54674-ea8b-484b-af0e-99127a510da2', false, NULL), -- api_access (PRO) ('01a5867f-0705-4714-ac97-a23470949157', 'd6f54674-ea8b-484b-af0e-99127a510da2', false, NULL), -- api_access (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', false, NULL); -- audit_log (PRO) ('01a5867f-0705-4714-ac97-a23470949157', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', false, NULL), -- audit_log (PRO)
-- Documentos
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000001', true, '{"max_storage_mb": 500}'), -- documents.upload (FREE)
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000002', false, NULL), -- documents.templates (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000003', false, NULL), -- documents.signatures (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000004', false, NULL), -- documents.share_links (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000005', false, NULL); -- documents.patient_portal (PRO)
-- ════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════
@@ -122,7 +134,13 @@ INSERT INTO public.plan_features (plan_id, feature_id, enabled, limits) VALUES
-- PRO exclusivo -- PRO exclusivo
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'f393178c-284d-422f-b096-8793f85428d5', true, NULL), -- custom_branding ('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'f393178c-284d-422f-b096-8793f85428d5', true, NULL), -- custom_branding
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'd6f54674-ea8b-484b-af0e-99127a510da2', true, NULL), -- api_access ('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'd6f54674-ea8b-484b-af0e-99127a510da2', true, NULL), -- api_access
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', true, NULL); -- audit_log ('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', true, NULL), -- audit_log
-- Documentos
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000001', true, '{"max_storage_mb": 5000}'), -- documents.upload
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000002', true, NULL), -- documents.templates
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000003', true, NULL), -- documents.signatures
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000004', true, NULL), -- documents.share_links
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000005', true, NULL); -- documents.patient_portal
-- ════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════
@@ -152,7 +170,13 @@ INSERT INTO public.plan_features (plan_id, feature_id, enabled, limits) VALUES
-- PRO-only (desabilitado) -- PRO-only (desabilitado)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'f393178c-284d-422f-b096-8793f85428d5', false, NULL), -- custom_branding (PRO) ('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'f393178c-284d-422f-b096-8793f85428d5', false, NULL), -- custom_branding (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'd6f54674-ea8b-484b-af0e-99127a510da2', false, NULL), -- api_access (PRO) ('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'd6f54674-ea8b-484b-af0e-99127a510da2', false, NULL), -- api_access (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', false, NULL); -- audit_log (PRO) ('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', false, NULL), -- audit_log (PRO)
-- Documentos
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000001', true, '{"max_storage_mb": 500}'), -- documents.upload (FREE)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000002', false, NULL), -- documents.templates (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000003', false, NULL), -- documents.signatures (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000004', false, NULL), -- documents.share_links (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000005', false, NULL); -- documents.patient_portal (PRO)
-- ════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,230 @@
-- ==========================================================================
-- Agencia PSI — Seed: Templates globais de documentos
-- ==========================================================================
-- 4 templates padrao do sistema (is_global = true).
-- Disponiveis para todos os tenants como base para personalizacao.
-- ==========================================================================
INSERT INTO public.document_templates (
id, tenant_id, owner_id, nome_template, tipo, descricao,
corpo_html, cabecalho_html, rodape_html,
variaveis, is_global, ativo
) VALUES
-- ────────────────────────────────────────────────────────────
-- 1. Declaração de Comparecimento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Declaração de Comparecimento',
'declaracao_comparecimento',
'Declara que o paciente compareceu à sessão na data e horário indicados.',
E'<h2 style="text-align:center; margin-bottom:30px;">DECLARAÇÃO DE COMPARECIMENTO</h2>\n\n<p>Declaro, para os devidos fins, que <strong>{{paciente_nome}}</strong>, portador(a) do CPF nº <strong>{{paciente_cpf}}</strong>, compareceu a esta clínica/consultório no dia <strong>{{data_sessao}}</strong>, no horário das <strong>{{hora_inicio}}</strong> às <strong>{{hora_fim}}</strong>, para atendimento psicológico.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','data_sessao','hora_inicio','hora_fim','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 2. Atestado Psicológico
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Atestado Psicológico',
'atestado_psicologico',
'Atesta acompanhamento psicológico do paciente conforme Resolução CFP.',
E'<h2 style="text-align:center; margin-bottom:30px;">ATESTADO PSICOLÓGICO</h2>\n\n<p>Atesto, para os devidos fins, que <strong>{{paciente_nome}}</strong>, nascido(a) em <strong>{{paciente_data_nascimento}}</strong>, encontra-se em acompanhamento psicológico neste consultório/clínica.</p>\n\n<p>O presente atestado é emitido com base no atendimento realizado, em conformidade com o Código de Ética Profissional do Psicólogo e as Resoluções do Conselho Federal de Psicologia.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 3. Relatório de Acompanhamento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Relatório de Acompanhamento',
'relatorio_acompanhamento',
'Modelo base para relatório de acompanhamento psicológico.',
E'<h2 style="text-align:center; margin-bottom:30px;">RELATÓRIO DE ACOMPANHAMENTO PSICOLÓGICO</h2>\n\n<table style="width:100%; border-collapse:collapse; margin-bottom:20px;">\n <tr><td style="padding:4px 8px; font-weight:bold; width:180px;">Paciente:</td><td style="padding:4px 8px;">{{paciente_nome}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Data de nascimento:</td><td style="padding:4px 8px;">{{paciente_data_nascimento}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">CPF:</td><td style="padding:4px 8px;">{{paciente_cpf}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Profissional:</td><td style="padding:4px 8px;">{{terapeuta_nome}} — CRP {{terapeuta_crp}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Data do relatório:</td><td style="padding:4px 8px;">{{data_atual}}</td></tr>\n</table>\n\n<h3>1. Demanda inicial</h3>\n<p>[Descreva a queixa ou demanda que motivou o início do acompanhamento.]</p>\n\n<h3>2. Procedimentos utilizados</h3>\n<p>[Descreva os métodos, técnicas e instrumentos utilizados.]</p>\n\n<h3>3. Evolução observada</h3>\n<p>[Descreva a evolução do paciente ao longo do acompanhamento.]</p>\n\n<h3>4. Considerações finais</h3>\n<p>[Conclusões e recomendações.]</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Este documento é de caráter sigiloso, conforme Art. 9º do Código de Ética do Psicólogo.\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','paciente_cpf','terapeuta_nome','terapeuta_crp','data_atual','data_atual_extenso','cidade_estado','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 4. Recibo de Pagamento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Recibo de Pagamento',
'recibo_pagamento',
'Recibo para pagamento de sessão de atendimento psicológico.',
E'<h2 style="text-align:center; margin-bottom:30px;">RECIBO DE PAGAMENTO</h2>\n\n<p>Recebi de <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, a quantia de <strong>{{valor}}</strong> ({{valor_extenso}}), referente a sessão de atendimento psicológico realizada em <strong>{{data_sessao}}</strong>.</p>\n\n<p><strong>Forma de pagamento:</strong> {{forma_pagamento}}</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · CNPJ: {{clinica_cnpj}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','valor','valor_extenso','data_sessao','forma_pagamento','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_cnpj','clinica_telefone'],
true, true
)
,
-- ────────────────────────────────────────────────────────────
-- 5. Contrato de Prestação de Serviços Psicológicos
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Contrato de Prestação de Serviços',
'contrato_servicos',
'Contrato padrão entre profissional/clínica e paciente para prestação de serviços psicológicos.',
E'<h2 style="text-align:center; margin-bottom:30px;">CONTRATO DE PRESTAÇÃO DE SERVIÇOS PSICOLÓGICOS</h2>\n\n<p>Pelo presente instrumento particular, de um lado <strong>{{terapeuta_nome}}</strong>, Psicólogo(a), inscrito(a) no CRP sob o nº <strong>{{terapeuta_crp}}</strong>, CPF nº <strong>{{terapeuta_cpf}}</strong>, doravante denominado(a) <strong>CONTRATADO(A)</strong>, e de outro lado <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, doravante denominado(a) <strong>CONTRATANTE</strong>, têm entre si justo e contratado o seguinte:</p>\n\n<h3>CLÁUSULA 1ª — DO OBJETO</h3>\n<p>O presente contrato tem por objeto a prestação de serviços de atendimento psicológico clínico, na modalidade <strong>{{modalidade_atendimento}}</strong>, pelo(a) CONTRATADO(A) ao CONTRATANTE.</p>\n\n<h3>CLÁUSULA 2ª — DA PERIODICIDADE</h3>\n<p>As sessões ocorrerão com frequência <strong>{{frequencia_sessoes}}</strong>, com duração aproximada de <strong>{{duracao_sessao}}</strong> minutos, em dia e horário previamente acordados entre as partes.</p>\n\n<h3>CLÁUSULA 3ª — DOS HONORÁRIOS</h3>\n<p>O valor de cada sessão será de <strong>{{valor}}</strong> ({{valor_extenso}}), a ser pago <strong>{{forma_pagamento}}</strong>.</p>\n<p>Em caso de reajuste, o(a) CONTRATADO(A) comunicará o CONTRATANTE com antecedência mínima de 30 (trinta) dias.</p>\n\n<h3>CLÁUSULA 4ª — DO CANCELAMENTO E FALTAS</h3>\n<p>O cancelamento ou remarcação de sessões deverá ser comunicado com antecedência mínima de <strong>24 (vinte e quatro) horas</strong>. Faltas sem aviso prévio serão cobradas integralmente.</p>\n\n<h3>CLÁUSULA 5ª — DO SIGILO</h3>\n<p>O(A) CONTRATADO(A) compromete-se a manter sigilo absoluto sobre todas as informações obtidas durante o atendimento, conforme o Código de Ética Profissional do Psicólogo (Resolução CFP nº 010/2005) e a Lei Geral de Proteção de Dados (Lei nº 13.709/2018).</p>\n\n<h3>CLÁUSULA 6ª — DA RESCISÃO</h3>\n<p>O presente contrato poderá ser rescindido por qualquer das partes, a qualquer tempo, mediante comunicação prévia, sem ônus adicionais além das sessões já realizadas.</p>\n\n<h3>CLÁUSULA 7ª — DO FORO</h3>\n<p>Para dirimir quaisquer controvérsias oriundas deste contrato, as partes elegem o foro da Comarca de <strong>{{cidade_estado}}</strong>.</p>\n\n<p style="margin-top:30px;">E por estarem assim justas e contratadas, as partes assinam o presente instrumento em 2 (duas) vias de igual teor e forma.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · CNPJ: {{clinica_cnpj}} · {{clinica_endereco}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','terapeuta_cpf','modalidade_atendimento','frequencia_sessoes','duracao_sessao','valor','valor_extenso','forma_pagamento','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco','clinica_telefone','clinica_cnpj'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 6. Termo de Consentimento Livre e Esclarecido (TCLE)
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Termo de Consentimento Livre e Esclarecido',
'tcle',
'TCLE para início de acompanhamento psicológico, conforme exigências éticas do CFP.',
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE CONSENTIMENTO LIVRE E ESCLARECIDO</h2>\n\n<p>Eu, <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, declaro que fui devidamente informado(a) e esclarecido(a) pelo(a) psicólogo(a) <strong>{{terapeuta_nome}}</strong>, CRP <strong>{{terapeuta_crp}}</strong>, sobre os seguintes pontos:</p>\n\n<h3>1. Natureza do serviço</h3>\n<p>O atendimento psicológico consiste em sessões de psicoterapia, com abordagem <strong>{{abordagem_terapeutica}}</strong>, visando o acolhimento e o tratamento das demandas apresentadas.</p>\n\n<h3>2. Sigilo profissional</h3>\n<p>Todas as informações compartilhadas durante as sessões são estritamente confidenciais, conforme o Código de Ética Profissional do Psicólogo (Resolução CFP nº 010/2005), podendo ser quebrado apenas nas hipóteses previstas em lei.</p>\n\n<h3>3. Registro de informações</h3>\n<p>O(A) profissional poderá realizar registros das sessões (prontuário psicológico) para fins de acompanhamento clínico, mantidos em sigilo conforme a LGPD (Lei nº 13.709/2018) e as normas do CFP.</p>\n\n<h3>4. Direitos do paciente</h3>\n<ul>\n <li>Receber informações sobre o processo terapêutico;</li>\n <li>Interromper o atendimento a qualquer momento;</li>\n <li>Solicitar encaminhamento a outro profissional;</li>\n <li>Ter acesso às informações registradas em seu prontuário.</li>\n</ul>\n\n<h3>5. Limitações</h3>\n<p>O acompanhamento psicológico não substitui tratamento médico ou psiquiátrico quando necessário. O(A) profissional poderá sugerir encaminhamentos complementares.</p>\n\n<p style="margin-top:30px;">Declaro que li e compreendi as informações acima e <strong>consinto livremente</strong> com o início do acompanhamento psicológico.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Este termo é regido pela Resolução CFP nº 010/2005 e pela Lei nº 13.709/2018 (LGPD).\n</div>',
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','abordagem_terapeutica','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 7. Encaminhamento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Encaminhamento',
'encaminhamento',
'Carta de encaminhamento do paciente para outro profissional ou serviço.',
E'<h2 style="text-align:center; margin-bottom:30px;">ENCAMINHAMENTO</h2>\n\n<p>Ao(À) profissional/serviço de <strong>{{especialidade_destino}}</strong>,</p>\n\n<p style="margin-top:20px;">Encaminho o(a) paciente <strong>{{paciente_nome}}</strong>, nascido(a) em <strong>{{paciente_data_nascimento}}</strong>, para avaliação e acompanhamento em <strong>{{especialidade_destino}}</strong>.</p>\n\n<p><strong>Motivo do encaminhamento:</strong></p>\n<p>{{motivo_encaminhamento}}</p>\n\n<p><strong>Informações relevantes:</strong></p>\n<p>{{informacoes_relevantes}}</p>\n\n<p style="margin-top:20px;">Coloco-me à disposição para troca de informações que se façam necessárias ao melhor atendimento do(a) paciente, respeitadas as normas de sigilo profissional.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}<br/>\n <span style="font-size:10pt; color:#666;">{{terapeuta_email}} · {{terapeuta_telefone}}</span>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','especialidade_destino','motivo_encaminhamento','informacoes_relevantes','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','terapeuta_email','terapeuta_telefone','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 8. Autorização para Atendimento de Menor
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Autorização para Atendimento de Menor',
'autorizacao_menor',
'Termo de autorização dos responsáveis legais para atendimento psicológico de criança ou adolescente.',
E'<h2 style="text-align:center; margin-bottom:30px;">AUTORIZAÇÃO PARA ATENDIMENTO PSICOLÓGICO DE MENOR</h2>\n\n<p>Eu, <strong>{{responsavel_nome}}</strong>, CPF nº <strong>{{responsavel_cpf}}</strong>, na qualidade de <strong>{{grau_parentesco}}</strong> e responsável legal do(a) menor <strong>{{paciente_nome}}</strong>, nascido(a) em <strong>{{paciente_data_nascimento}}</strong>, <strong>AUTORIZO</strong> a realização de atendimento psicológico pelo(a) profissional abaixo identificado(a).</p>\n\n<h3>Profissional responsável</h3>\n<p><strong>{{terapeuta_nome}}</strong><br/>\nPsicólogo(a) — CRP {{terapeuta_crp}}</p>\n\n<h3>Declarações</h3>\n<p>Declaro estar ciente de que:</p>\n<ul>\n <li>O atendimento seguirá as normas do Código de Ética Profissional do Psicólogo e do Estatuto da Criança e do Adolescente (ECA);</li>\n <li>As informações do atendimento são sigilosas, sendo compartilhadas com o responsável apenas o necessário para o bem-estar do(a) menor, conforme julgamento técnico do(a) profissional;</li>\n <li>Posso solicitar informações sobre a evolução do tratamento a qualquer momento;</li>\n <li>Posso revogar esta autorização a qualquer tempo, mediante comunicação por escrito.</li>\n</ul>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{responsavel_nome}}</strong><br/>\n Responsável legal<br/>\n CPF: {{responsavel_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','responsavel_nome','responsavel_cpf','grau_parentesco','terapeuta_nome','terapeuta_crp','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 9. Laudo Psicológico
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Laudo Psicológico',
'laudo_psicologico',
'Modelo de laudo psicológico conforme Resolução CFP nº 06/2019.',
E'<h2 style="text-align:center; margin-bottom:30px;">LAUDO PSICOLÓGICO</h2>\n\n<table style="width:100%; border-collapse:collapse; margin-bottom:20px;">\n <tr><td style="padding:4px 8px; font-weight:bold; width:200px;">Solicitante:</td><td style="padding:4px 8px;">{{solicitante}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Finalidade:</td><td style="padding:4px 8px;">{{finalidade}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Avaliado(a):</td><td style="padding:4px 8px;">{{paciente_nome}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Data de nascimento:</td><td style="padding:4px 8px;">{{paciente_data_nascimento}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">CPF:</td><td style="padding:4px 8px;">{{paciente_cpf}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Profissional:</td><td style="padding:4px 8px;">{{terapeuta_nome}} — CRP {{terapeuta_crp}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Data do laudo:</td><td style="padding:4px 8px;">{{data_atual}}</td></tr>\n</table>\n\n<h3>1. Descrição da demanda</h3>\n<p>[Apresente a demanda e o motivo da avaliação, conforme solicitação recebida.]</p>\n\n<h3>2. Procedimentos</h3>\n<p>[Descreva os recursos e instrumentos técnicos utilizados na avaliação: entrevistas, testes psicológicos (com nome, autor e parecer favorável do SATEPSI quando aplicável), observação, etc.]</p>\n\n<h3>3. Análise</h3>\n<p>[Apresente de forma clara e fundamentada os dados obtidos, integrando os resultados dos procedimentos realizados à luz da literatura científica da Psicologia. Não inclua informações que não tenham relação com a demanda.]</p>\n\n<h3>4. Conclusão</h3>\n<p>[Apresente o resultado da avaliação, indicando a resposta à demanda inicial. A conclusão deve ser coerente com a análise e os procedimentos utilizados.]</p>\n\n<p style="margin-top:40px; font-size:10pt; color:#666;"><em>Este laudo foi elaborado em conformidade com a Resolução CFP nº 06/2019, que institui regras para a elaboração de documentos escritos produzidos pelo psicólogo no exercício profissional.</em></p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Este documento é de caráter sigiloso, conforme Art. 9º do Código de Ética do Psicólogo e Resolução CFP nº 06/2019.\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','paciente_cpf','solicitante','finalidade','terapeuta_nome','terapeuta_crp','data_atual','data_atual_extenso','cidade_estado','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 10. Parecer Psicológico
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Parecer Psicológico',
'parecer_psicologico',
'Manifestação técnica sobre questão específica no campo da Psicologia, conforme Resolução CFP nº 06/2019.',
E'<h2 style="text-align:center; margin-bottom:30px;">PARECER PSICOLÓGICO</h2>\n\n<table style="width:100%; border-collapse:collapse; margin-bottom:20px;">\n <tr><td style="padding:4px 8px; font-weight:bold; width:200px;">Parecer nº:</td><td style="padding:4px 8px;">{{numero_parecer}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Solicitante:</td><td style="padding:4px 8px;">{{solicitante}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Assunto:</td><td style="padding:4px 8px;">{{assunto}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Profissional:</td><td style="padding:4px 8px;">{{terapeuta_nome}} — CRP {{terapeuta_crp}}</td></tr>\n</table>\n\n<h3>1. Exposição de motivos</h3>\n<p>[Descreva a questão ou consulta que originou o pedido de parecer, indicando quem solicitou e com qual finalidade.]</p>\n\n<h3>2. Análise fundamentada</h3>\n<p>[Apresente a análise técnica, com base em referencial teórico-científico da Psicologia, normas do CFP e legislação pertinente. O parecer deve se restringir ao campo de conhecimento do psicólogo.]</p>\n\n<h3>3. Conclusão</h3>\n<p>[Apresente a resposta técnica à questão formulada, de forma objetiva e fundamentada.]</p>\n\n<p style="margin-top:30px; font-size:10pt; color:#666;"><em>Parecer elaborado em conformidade com a Resolução CFP nº 06/2019.</em></p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Este documento é de caráter sigiloso, conforme Resolução CFP nº 06/2019.\n</div>',
ARRAY['numero_parecer','solicitante','assunto','terapeuta_nome','terapeuta_crp','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 11. Termo de Sigilo e Confidencialidade
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Termo de Sigilo e Confidencialidade',
'termo_sigilo',
'Termo reforçando o compromisso de sigilo entre profissional e paciente.',
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE SIGILO E CONFIDENCIALIDADE</h2>\n\n<p>Eu, <strong>{{terapeuta_nome}}</strong>, Psicólogo(a), CRP <strong>{{terapeuta_crp}}</strong>, declaro ao(à) paciente <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, que:</p>\n\n<ol style="line-height:2;">\n <li>Todas as informações verbais, escritas ou de qualquer natureza, obtidas durante o processo terapêutico, serão mantidas em <strong>sigilo absoluto</strong>;</li>\n <li>O prontuário psicológico é de acesso exclusivo do profissional e do paciente, nos termos da Resolução CFP nº 001/2009;</li>\n <li>Os dados pessoais serão tratados conforme a Lei Geral de Proteção de Dados (Lei nº 13.709/2018), sendo utilizados exclusivamente para fins de acompanhamento clínico;</li>\n <li>A quebra de sigilo somente ocorrerá nas hipóteses previstas no Código de Ética do Psicólogo:\n <ul>\n <li>Situações de risco à vida do paciente ou de terceiros;</li>\n <li>Determinação judicial;</li>\n <li>Atendimento de menor — informações necessárias aos responsáveis;</li>\n </ul>\n </li>\n <li>Dados anonimizados poderão ser utilizados para fins de estudo ou pesquisa, mediante consentimento prévio específico.</li>\n</ol>\n\n<p style="margin-top:30px;">Este termo entra em vigor na data de sua assinatura e permanece válido mesmo após o encerramento do acompanhamento.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Documento regido pelo Código de Ética Profissional do Psicólogo e pela LGPD (Lei nº 13.709/2018).\n</div>',
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 12. Declaração de Início de Tratamento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Declaração de Início de Tratamento',
'declaracao_inicio_tratamento',
'Declara que o paciente iniciou acompanhamento psicológico a partir de determinada data.',
E'<h2 style="text-align:center; margin-bottom:30px;">DECLARAÇÃO DE INÍCIO DE TRATAMENTO</h2>\n\n<p>Declaro, para os devidos fins, que <strong>{{paciente_nome}}</strong>, portador(a) do CPF nº <strong>{{paciente_cpf}}</strong>, encontra-se em acompanhamento psicológico neste consultório/clínica desde <strong>{{data_inicio_tratamento}}</strong>, com frequência <strong>{{frequencia_sessoes}}</strong>.</p>\n\n<p>O presente documento é expedido a pedido do(a) interessado(a) e não contém informações de caráter diagnóstico ou sigiloso.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','data_inicio_tratamento','frequencia_sessoes','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 13. Termo de Alta Terapêutica
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Termo de Alta Terapêutica',
'termo_alta',
'Documento formalizando o encerramento do acompanhamento psicológico.',
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE ALTA TERAPÊUTICA</h2>\n\n<p>Comunico que o(a) paciente <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, que esteve em acompanhamento psicológico desde <strong>{{data_inicio_tratamento}}</strong>, recebe <strong>alta terapêutica</strong> nesta data.</p>\n\n<h3>Motivo da alta</h3>\n<p>{{motivo_alta}}</p>\n\n<h3>Orientações</h3>\n<p>{{orientacoes_pos_alta}}</p>\n\n<p style="margin-top:20px;">O(A) paciente foi informado(a) de que poderá retornar ao acompanhamento a qualquer momento, caso considere necessário.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n Ciente\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','data_inicio_tratamento','motivo_alta','orientacoes_pos_alta','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 14. Termo de Consentimento para Atendimento Online
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Termo de Consentimento para Atendimento Online',
'tcle_online',
'Consentimento específico para atendimento psicológico por meios tecnológicos (Resolução CFP nº 11/2018).',
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE CONSENTIMENTO PARA ATENDIMENTO PSICOLÓGICO ONLINE</h2>\n\n<p>Eu, <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, declaro que fui informado(a) e concordo com a realização de atendimento psicológico na modalidade <strong>online</strong>, por meio de Tecnologias da Informação e Comunicação (TICs), conforme a Resolução CFP nº 11/2018.</p>\n\n<h3>1. Plataforma utilizada</h3>\n<p>As sessões serão realizadas por meio de <strong>{{plataforma_online}}</strong>, devendo ambas as partes garantir ambiente adequado, com privacidade e conexão estável.</p>\n\n<h3>2. Condições do atendimento</h3>\n<ul>\n <li>O(A) profissional está cadastrado(a) no e-Psi (plataforma do CFP) para prestação de serviços psicológicos online;</li>\n <li>Todas as regras de sigilo profissional aplicam-se igualmente ao atendimento online;</li>\n <li>A plataforma utilizada oferece criptografia e segurança dos dados;</li>\n <li>Não é permitida a gravação das sessões por nenhuma das partes, salvo acordo prévio por escrito.</li>\n</ul>\n\n<h3>3. Situações excepcionais</h3>\n<p>Em caso de instabilidade técnica que comprometa a sessão, o(a) profissional e o(a) paciente acordarão a melhor forma de dar continuidade (reagendar, trocar de plataforma, ou realizar sessão presencial).</p>\n\n<h3>4. Limitações</h3>\n<p>O(A) profissional reserva-se o direito de indicar atendimento presencial quando avaliar que a modalidade online não é adequada ao caso clínico.</p>\n\n<p style="margin-top:30px;">Declaro que compreendi as condições acima e <strong>consinto</strong> com a realização das sessões na modalidade online.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Atendimento online regulamentado pela Resolução CFP nº 11/2018. Profissional cadastrado(a) no e-Psi.\n</div>',
ARRAY['paciente_nome','paciente_cpf','plataforma_online','terapeuta_nome','terapeuta_crp','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
)
ON CONFLICT DO NOTHING;
-- ==========================================================================
-- FIM DO SEED — 14 templates globais
-- ==========================================================================

View File

@@ -0,0 +1,766 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documentos & Arquivos — Status de Implementacao</title>
<style>
:root {
--bg: #0d1117;
--bg-card: #161b22;
--bg-card-hover: #1c2129;
--border: #30363d;
--border-light: #21262d;
--text: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent: #58a6ff;
--accent-dim: #1f6feb33;
--green: #3fb950;
--green-dim: #23863633;
--orange: #d29922;
--orange-dim: #9e6a0333;
--red: #f85149;
--red-dim: #da363333;
--purple: #bc8cff;
--purple-dim: #8957e533;
--cyan: #39d2c0;
--cyan-dim: #1b7c6e33;
--pink: #f778ba;
--pink-dim: #db61a233;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
--radius: 8px;
--radius-lg: 12px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.6;
padding: 32px 24px 64px;
max-width: 1100px;
margin: 0 auto;
}
/* ── Header ─────────────────────────── */
.page-header {
text-align: center;
margin-bottom: 36px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.page-header h1 {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 6px;
}
.page-header .subtitle {
font-size: 13px;
color: var(--text-secondary);
}
.page-header .meta {
display: flex;
gap: 10px;
margin-top: 14px;
justify-content: center;
flex-wrap: wrap;
}
.meta-tag {
font-size: 11px;
font-family: var(--font-mono);
padding: 3px 10px;
border-radius: 20px;
border: 1px solid var(--border);
color: var(--text-secondary);
background: var(--bg-card);
}
/* ── Summary stats ─────────────────── */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px;
margin-bottom: 36px;
}
.summary-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 12px;
text-align: center;
}
.summary-card .number { font-size: 24px; font-weight: 700; line-height: 1.2; }
.summary-card .label { font-size: 10px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
.c-green .number { color: var(--green); }
.c-orange .number { color: var(--orange); }
.c-red .number { color: var(--red); }
.c-accent .number { color: var(--accent); }
.c-purple .number { color: var(--purple); }
.c-cyan .number { color: var(--cyan); }
/* ── Section ────────────────────────── */
.section {
margin-bottom: 32px;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.section-icon {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.section-icon.blue { background: var(--accent-dim); color: var(--accent); }
.section-icon.green { background: var(--green-dim); color: var(--green); }
.section-icon.orange { background: var(--orange-dim); color: var(--orange); }
.section-icon.purple { background: var(--purple-dim); color: var(--purple); }
.section-icon.cyan { background: var(--cyan-dim); color: var(--cyan); }
.section-icon.pink { background: var(--pink-dim); color: var(--pink); }
.section-icon.red { background: var(--red-dim); color: var(--red); }
.section-title { font-size: 16px; font-weight: 600; }
/* ── Layout: cards + sidebar ────────── */
.content-row {
display: grid;
grid-template-columns: 1fr 260px;
gap: 14px;
align-items: start;
}
@media (max-width: 800px) {
.content-row { grid-template-columns: 1fr; }
}
/* ── Cards ──────────────────────────── */
.cards {
display: flex;
flex-direction: column;
gap: 10px;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px 18px;
transition: border-color .15s;
}
.card:hover { border-color: #484f58; }
.card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.card-title {
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.card-desc {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.55;
}
.card-fields {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.field {
font-size: 10px;
font-family: var(--font-mono);
padding: 2px 7px;
border-radius: 12px;
border: 1px solid var(--border);
color: var(--text-muted);
background: var(--bg);
}
.card-file {
font-size: 11px;
font-family: var(--font-mono);
color: var(--accent);
margin-top: 6px;
opacity: .8;
}
/* ── Badges ─────────────────────────── */
.badge {
font-size: 10px;
font-weight: 600;
padding: 2px 9px;
border-radius: 20px;
white-space: nowrap;
flex-shrink: 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge-done { background: var(--green-dim); color: var(--green); }
.badge-partial { background: var(--orange-dim); color: var(--orange); }
.badge-pending { background: var(--red-dim); color: var(--red); }
.badge-db { background: var(--accent-dim); color: var(--accent); }
/* ── Sidebar ────────────────────────── */
.sidebar {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
position: sticky;
top: 20px;
}
.sidebar-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
font-size: 12px;
color: var(--text-secondary);
}
.sidebar-item .dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-green { background: var(--green); }
.dot-orange { background: var(--orange); }
.dot-red { background: var(--red); }
.sidebar-item .label { flex: 1; }
.sidebar-item .status {
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-muted);
}
.sidebar-divider {
height: 1px;
background: var(--border-light);
margin: 10px 0;
}
/* ── Note box ──────────────────────── */
.note {
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 12px 16px;
font-size: 12px;
color: var(--text-secondary);
margin-top: 12px;
line-height: 1.6;
}
.note strong { color: var(--text); }
.note.warn { border-left-color: var(--orange); }
/* ── Legend ─────────────────────────── */
.legend {
display: flex;
gap: 18px;
justify-content: center;
margin-bottom: 28px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-secondary);
}
.legend-item .dot { width: 8px; height: 8px; border-radius: 50%; }
</style>
</head>
<body>
<!-- ══════════════════════════════════ HEADER ══════════════════════════════════ -->
<div class="page-header">
<h1>Documentos & Arquivos</h1>
<div class="subtitle">Status de implementacao confrontado com o banco de dados</div>
<div class="meta">
<span class="meta-tag">AgenciaPsi v5</span>
<span class="meta-tag">Vue 3 + Supabase</span>
<span class="meta-tag">Atualizado: 2026-03-30</span>
</div>
</div>
<!-- ══════════════════════════════════ STATS ═══════════════════════════════════ -->
<div class="summary-grid">
<div class="summary-card c-green">
<div class="number">6/6</div>
<div class="label">Tabelas</div>
</div>
<div class="summary-card c-green">
<div class="number">2/2</div>
<div class="label">Buckets</div>
</div>
<div class="summary-card c-green">
<div class="number">7/7</div>
<div class="label">Services</div>
</div>
<div class="summary-card c-green">
<div class="number">3/3</div>
<div class="label">Composables</div>
</div>
<div class="summary-card c-green">
<div class="number">10/10</div>
<div class="label">Componentes</div>
</div>
<div class="summary-card c-green">
<div class="number">14</div>
<div class="label">Templates seed</div>
</div>
</div>
<!-- ══════════════════════════════════ LEGENDA ═════════════════════════════════ -->
<div class="legend">
<div class="legend-item"><span class="dot dot-green"></span> Implementado</div>
<div class="legend-item"><span class="dot dot-orange"></span> Parcial / Migration pendente</div>
<div class="legend-item"><span class="dot dot-red"></span> Nao implementado</div>
</div>
<!-- ══════════════════════════════════ 1. UPLOAD & ORGANIZACAO ═════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon blue">1</div>
<div class="section-title">Upload & Organizacao de Arquivos</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Upload de arquivo ao paciente</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">PDF, imagem, DOCX. Vinculado ao patient_id. Supabase Storage com path estruturado. Drag & drop + seletor. Validacao de tamanho (50MB) e tipo MIME.</div>
<div class="card-fields">
<span class="field">patient_id</span><span class="field">bucket_path</span><span class="field">storage_bucket</span>
<span class="field">nome_original</span><span class="field">mime_type</span><span class="field">tamanho_bytes</span>
<span class="field">uploaded_by</span><span class="field">uploaded_at</span>
</div>
<div class="card-file">Documents.service.js &rarr; uploadDocument()</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Tipo, categoria & tags</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">11 tipos (laudo, receita, exame, atestado, declaracao, recibo, etc.). Categoria livre. Tags[] com autocomplete. Filtros na listagem.</div>
<div class="card-fields">
<span class="field">tipo_documento</span><span class="field">categoria</span><span class="field">descricao</span><span class="field">tags[]</span>
</div>
<div class="card-file">DB: CHECK constraint + GIN index em tags</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Vinculo com sessao</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Arquivo linkado a agenda_eventos (sessao) ou session_note. Colunas nullable — nem todo arquivo tem sessao.</div>
<div class="card-fields">
<span class="field">agenda_evento_id</span><span class="field">session_note_id</span>
</div>
<div class="card-file">DB: FK para agenda_eventos (ON DELETE SET NULL)</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Visibilidade & controle de acesso</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Privado, compartilhado com supervisor, ou visivel no portal do paciente. Granular por arquivo. Expiracao de compartilhamento.</div>
<div class="card-fields">
<span class="field">visibilidade</span><span class="field">compartilhado_portal</span><span class="field">compartilhado_supervisor</span>
<span class="field">compartilhado_em</span><span class="field">expira_compartilhamento</span>
</div>
<div class="card-file">DB: CHECK (privado | compartilhado_supervisor | compartilhado_portal)</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Soft delete com retencao LGPD</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Arquivo "excluido" some da UI mas fica retido por 5 anos (CFP). Colunas de controle + index parcial para listagem ativa.</div>
<div class="card-fields">
<span class="field">deleted_at</span><span class="field">deleted_by</span><span class="field">retencao_ate</span>
</div>
<div class="card-file">DB: idx_documents_active (WHERE deleted_at IS NULL)</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Preview & download</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Preview inline de PDF e imagens via dialog. Download com URL assinada (60s). Suporte a storage_bucket dinamico (documents ou generated-docs).</div>
<div class="card-file">DocumentPreviewDialog.vue + getDownloadUrl(path, expires, bucket)</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Banco de Dados</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">documents</span><span class="status">27 cols</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">RLS owner_id</span><span class="status">ativo</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">Indexes</span><span class="status">9</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">Trigger timeline</span><span class="status">insert</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Storage</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">documents</span><span class="status">50MB</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">generated-docs</span><span class="status">20MB</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Frontend</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">DocumentsListPage</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">DocumentCard</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">DocumentUploadDialog</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">DocumentPreviewDialog</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">DocumentTagsInput</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">useDocuments.js</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════════ 2. GERACAO DE DOCUMENTOS ════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon green">2</div>
<div class="section-title">Geracao de Documentos (PDF)</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Templates de documentos</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">16 tipos de template. 14 templates globais no seed. Corpo HTML com {{variaveis}}. Cabecalho/rodape personalizaveis. Templates por tenant + globais do sistema.</div>
<div class="card-fields">
<span class="field">nome_template</span><span class="field">tipo</span><span class="field">corpo_html</span>
<span class="field">cabecalho_html</span><span class="field">rodape_html</span><span class="field">variaveis[]</span>
<span class="field">is_global</span><span class="field">logo_url</span><span class="field">ativo</span>
</div>
<div class="card-file">document_templates (DB) + seed_015_document_templates.sql</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Geracao de PDF (client-side)</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">jsPDF + html2canvas-pro (substituiu pdfmake por incompatibilidade com Vite). Renderiza HTML preenchido em canvas, converte para PDF A4 com paginacao. JPEG 85%, scale 1.5. ~200-400KB por documento.</div>
<div class="card-fields">
<span class="field">buildFullHtml()</span><span class="field">htmlToPdfBlob()</span><span class="field">fillTemplate()</span>
</div>
<div class="card-file">pdf.service.js + DocumentGenerate.service.js</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Documento gerado (instancia + listagem)</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Cada PDF gerado: salva snapshot em document_generated (dados preenchidos para auditoria) E automaticamente registra na tabela documents (para aparecer na listagem do paciente). Bucket: generated-docs. Nomes sanitizados (sem acentos).</div>
<div class="card-fields">
<span class="field">template_id</span><span class="field">dados_preenchidos</span><span class="field">pdf_path</span>
<span class="field">gerado_em</span><span class="field">gerado_por</span><span class="field">&rarr; documents</span>
</div>
<div class="card-file">saveGeneratedDocument() &rarr; document_generated + documents</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Fluxo de geracao (UI)</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Dialog 3 etapas: selecionar template &rarr; editar variaveis (auto-preenchidas com dados paciente/sessao/terapeuta/clinica) &rarr; preview via iframe sandbox &rarr; "Salvar documento" (online) ou "So baixar" (local).</div>
<div class="card-file">DocumentGenerateDialog.vue + useDocumentGenerate.js</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Dados da clinica no template</div>
<span class="badge badge-partial">parcial</span>
</div>
<div class="card-desc">loadClinicData() usa select('*') na tabela tenants. Atualmente so retorna name. Campos phone, contact_email, logradouro, numero, bairro, cidade, estado dependem da migration 003_tenants_address_fields.sql ser aplicada.</div>
<div class="card-file">Migration pendente: 003_tenants_address_fields.sql</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Editor de templates</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Editor rich text para corpo HTML. Insercao de variaveis via dropdown. Preview ao vivo. Config de cabecalho/rodape/logo. Gestao de templates globais e por tenant.</div>
<div class="card-file">DocumentTemplateEditor.vue + DocumentTemplatesPage.vue</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Banco de Dados</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">document_templates</span><span class="status">15 cols</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">document_generated</span><span class="status">10 cols</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">RLS</span><span class="status">ativo</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">14 seeds globais</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Motor PDF</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">jsPDF</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">html2canvas-pro</span></div>
<div class="sidebar-item"><span class="dot dot-red"></span><span class="label">pdfmake</span><span class="status">removido</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Pendencias</div>
<div class="sidebar-item"><span class="dot dot-orange"></span><span class="label">tenants address</span><span class="status">migration</span></div>
<div class="sidebar-item"><span class="dot dot-orange"></span><span class="label">terapeuta_crp</span><span class="status">campo</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════════ 3. ASSINATURA ELETRONICA ════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon purple">3</div>
<div class="section-title">Assinatura Eletronica</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> TCLE & consentimento</div>
<span class="badge badge-done">db pronto</span>
</div>
<div class="card-desc">Tabela document_signatures com rastreamento completo: IP, timestamp, hash SHA-256, user_agent. Suporte a 3 tipos de signatario (paciente, responsavel_legal, terapeuta). 5 status possiveis.</div>
<div class="card-fields">
<span class="field">documento_id</span><span class="field">signatario_tipo</span><span class="field">signatario_id</span>
<span class="field">ordem</span><span class="field">status</span><span class="field">ip</span>
<span class="field">hash_documento</span><span class="field">assinado_em</span>
</div>
<div class="card-file">document_signatures (DB) + DocumentSignatures.service.js</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> UI de assinatura</div>
<span class="badge badge-partial">parcial</span>
</div>
<div class="card-desc">Componente DocumentSignatureDialog.vue existe. Service DocumentSignatures.service.js existe. Fluxo completo de envio por link e assinatura pelo paciente ainda precisa ser validado end-to-end.</div>
<div class="card-file">DocumentSignatureDialog.vue</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Banco de Dados</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">document_signatures</span><span class="status">14 cols</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">RLS tenant</span><span class="status">ativo</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">Trigger timeline</span><span class="status">assinado</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Frontend</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">SignatureDialog</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">Signatures.service</span></div>
<div class="sidebar-item"><span class="dot dot-orange"></span><span class="label">Fluxo e2e</span><span class="status">validar</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════════ 4. COMPARTILHAMENTO ═════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon cyan">4</div>
<div class="section-title">Compartilhamento & Portal do Paciente</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Links temporarios de acesso</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Token hex 32 bytes, prazo de expiracao, limite de usos. RLS publica por token valido. Link seguro sem necessidade de login.</div>
<div class="card-fields">
<span class="field">token</span><span class="field">expira_em</span><span class="field">usos_max</span>
<span class="field">usos</span><span class="field">ativo</span><span class="field">criado_por</span>
</div>
<div class="card-file">document_share_links (DB) + DocumentShareLinks.service.js</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Documentos compartilhados com paciente</div>
<span class="badge badge-done">db pronto</span>
</div>
<div class="card-desc">Terapeuta decide quais arquivos ficam visiveis pro paciente. Campos compartilhado_portal e expira_compartilhamento na tabela documents.</div>
<div class="card-fields">
<span class="field">compartilhado_portal</span><span class="field">compartilhado_em</span><span class="field">expira_compartilhamento</span>
</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Upload pelo paciente</div>
<span class="badge badge-done">db pronto</span>
</div>
<div class="card-desc">Paciente envia exames/laudos pelo portal. Fila de "pendentes de revisao" para o terapeuta aprovar.</div>
<div class="card-fields">
<span class="field">enviado_pelo_paciente</span><span class="field">status_revisao</span>
<span class="field">revisado_por</span><span class="field">revisado_em</span>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Banco de Dados</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">document_share_links</span><span class="status">10 cols</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">RLS token publico</span><span class="status">ativo</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Frontend</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">ShareDialog</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">ShareLinks.service</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">SharedDocumentPage</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════════ 5. AUDITORIA ═══════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon red">5</div>
<div class="section-title">Auditoria & Conformidade</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Log de acesso a arquivos</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Tabela imutavel (somente INSERT + SELECT, sem UPDATE/DELETE). Cada visualizacao ou download registrado. Conformidade CFP e LGPD. Integrado no composable useDocuments (logAccess automatico).</div>
<div class="card-fields">
<span class="field">documento_id</span><span class="field">acao</span><span class="field">user_id</span>
<span class="field">ip</span><span class="field">user_agent</span><span class="field">acessado_em</span>
</div>
<div class="card-file">document_access_logs (DB) + DocumentAuditLog.service.js</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Timeline do paciente</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Triggers automaticos registram na patient_timeline quando: documento uploadado (INSERT em documents) e documento assinado (UPDATE em document_signatures).</div>
<div class="card-file">DB Triggers: trg_documents_timeline_insert + trg_ds_timeline</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Banco de Dados</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">document_access_logs</span><span class="status">8 cols</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">Imutavel</span><span class="status">no UPDATE</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">RLS tenant</span><span class="status">ativo</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Acoes rastreadas</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">visualizou</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">baixou</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">imprimiu</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">compartilhou</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">assinou</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════════ PENDENCIAS ══════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon orange">!</div>
<div class="section-title">Pendencias & Migrations Nao Aplicadas</div>
</div>
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Migration: tenants address fields</div>
<span class="badge badge-partial">pendente</span>
</div>
<div class="card-desc">003_tenants_address_fields.sql — adiciona cep, logradouro, numero, complemento, bairro, cidade, estado a tabela tenants. Tambem faltam phone e contact_email. Necessario para preencher variaveis clinica_endereco, clinica_telefone nos templates.</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Campo CRP do terapeuta</div>
<span class="badge badge-partial">pendente</span>
</div>
<div class="card-desc">Variavel terapeuta_crp nos templates retorna vazio. O campo CRP nao existe na tabela profiles nem em tenant_members. Precisa de migration para adicionar coluna crp em profiles.</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Fluxo de assinatura end-to-end</div>
<span class="badge badge-partial">validar</span>
</div>
<div class="card-desc">Tabela, service e componente existem. Falta validar: envio de link por email/whatsapp, pagina publica de assinatura, registro de IP/hash, notificacao ao terapeuta quando assinado.</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Portal do paciente — visualizacao de docs</div>
<span class="badge badge-partial">validar</span>
</div>
<div class="card-desc">Campos compartilhado_portal e visibilidade existem no banco. SharedDocumentPage.vue existe. Falta validar se o portal do paciente (CadastroPacienteExterno) exibe corretamente os documentos compartilhados.</div>
</div>
</div>
<div class="note warn" style="margin-top: 14px;">
<strong>Decisao tecnica — Motor PDF:</strong> pdfmake foi substituido por jsPDF + html2canvas-pro. O pdfmake (UMD) trava silenciosamente com Vite (ESM) — createPdf().getBlob()/getBuffer() nunca retornam. html2canvas-pro e um fork open source (MIT) com suporte a cores oklch usadas pelo PrimeVue/Tailwind.
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,870 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Plano de Implementacao — Modulo Documentos & Arquivos</title>
<style>
:root {
--bg: #0d1117;
--bg-card: #161b22;
--bg-card-hover: #1c2129;
--bg-table-head: #1c2129;
--bg-table-row: #161b22;
--bg-table-row-alt: #0d1117;
--border: #30363d;
--border-light: #21262d;
--text: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent: #58a6ff;
--accent-dim: #1f6feb33;
--green: #3fb950;
--green-dim: #23863633;
--orange: #d29922;
--orange-dim: #9e6a0333;
--purple: #bc8cff;
--purple-dim: #8957e533;
--red: #f85149;
--red-dim: #da363333;
--cyan: #39d2c0;
--cyan-dim: #1b7c6e33;
--pink: #f778ba;
--pink-dim: #db61a233;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
--radius: 8px;
--radius-lg: 12px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.6;
padding: 32px 24px 64px;
max-width: 1200px;
margin: 0 auto;
}
/* ── Header ─────────────────────────── */
.page-header {
margin-bottom: 40px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.page-header h1 {
font-size: 28px;
font-weight: 700;
color: var(--text);
margin-bottom: 6px;
letter-spacing: -0.02em;
}
.page-header .subtitle {
font-size: 14px;
color: var(--text-secondary);
}
.page-header .meta {
display: flex;
gap: 16px;
margin-top: 12px;
flex-wrap: wrap;
}
.meta-tag {
font-size: 12px;
font-family: var(--font-mono);
padding: 3px 10px;
border-radius: 20px;
border: 1px solid var(--border);
color: var(--text-secondary);
background: var(--bg-card);
}
/* ── Summary cards ──────────────────── */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
margin-bottom: 40px;
}
.summary-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
text-align: center;
}
.summary-card .number {
font-size: 26px;
font-weight: 700;
line-height: 1.2;
}
.summary-card .label {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 2px;
}
.summary-card.c-blue .number { color: var(--accent); }
.summary-card.c-green .number { color: var(--green); }
.summary-card.c-orange .number { color: var(--orange); }
.summary-card.c-purple .number { color: var(--purple); }
.summary-card.c-cyan .number { color: var(--cyan); }
.summary-card.c-pink .number { color: var(--pink); }
/* ── Sections ───────────────────────── */
.section {
margin-bottom: 36px;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.section-icon {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.section-icon.blue { background: var(--accent-dim); color: var(--accent); }
.section-icon.green { background: var(--green-dim); color: var(--green); }
.section-icon.orange { background: var(--orange-dim); color: var(--orange); }
.section-icon.purple { background: var(--purple-dim); color: var(--purple); }
.section-icon.cyan { background: var(--cyan-dim); color: var(--cyan); }
.section-icon.pink { background: var(--pink-dim); color: var(--pink); }
.section-icon.red { background: var(--red-dim); color: var(--red); }
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.section-desc {
font-size: 12px;
color: var(--text-muted);
margin-top: -6px;
margin-bottom: 14px;
padding-left: 38px;
}
/* ── Tables ─────────────────────────── */
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
thead th {
background: var(--bg-table-head);
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
}
tbody td {
padding: 10px 14px;
border-bottom: 1px solid var(--border-light);
vertical-align: top;
}
tbody tr:nth-child(odd) { background: var(--bg-table-row); }
tbody tr:nth-child(even) { background: var(--bg-table-row-alt); }
tbody tr:hover { background: var(--bg-card-hover); }
tbody tr:last-child td { border-bottom: none; }
.col-file {
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent);
white-space: nowrap;
}
.col-table {
font-family: var(--font-mono);
font-size: 12px;
color: var(--green);
white-space: nowrap;
}
.col-route {
font-family: var(--font-mono);
font-size: 12px;
color: var(--orange);
}
.col-key {
font-family: var(--font-mono);
font-size: 12px;
color: var(--purple);
}
.col-bucket {
font-family: var(--font-mono);
font-size: 12px;
color: var(--cyan);
}
/* ── Field chips ────────────────────── */
.fields {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.field {
font-size: 10px;
font-family: var(--font-mono);
padding: 2px 7px;
border-radius: 12px;
border: 1px solid var(--border);
color: var(--text-muted);
background: var(--bg);
}
/* ── Notes ──────────────────────────── */
.note {
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 12px 16px;
font-size: 13px;
color: var(--text-secondary);
margin-top: 12px;
line-height: 1.6;
}
/* ── Responsive ─────────────────────── */
@media (max-width: 700px) {
body { padding: 16px 12px 40px; }
.summary-grid { grid-template-columns: repeat(3, 1fr); }
table { font-size: 12px; }
thead th, tbody td { padding: 8px 10px; }
}
</style>
</head>
<body>
<!-- ════════════════════════════════════════ HEADER ════════════════════════════════════════ -->
<div class="page-header">
<h1>Plano de Implementacao — Documentos & Arquivos</h1>
<div class="subtitle">Modulo completo: upload, templates, geracao PDF, assinatura eletronica, portal do paciente, auditoria</div>
<div class="meta">
<span class="meta-tag">AgenciaPsi v5</span>
<span class="meta-tag">Vue 3 + Supabase</span>
<span class="meta-tag">2026-03-30</span>
<span class="meta-tag">Status: em andamento</span>
</div>
</div>
<!-- ════════════════════════════════════════ RESUMO ═══════════════════════════════════════ -->
<div class="summary-grid">
<div class="summary-card c-blue">
<div class="number">6</div>
<div class="label">Tabelas</div>
</div>
<div class="summary-card c-cyan">
<div class="number">2</div>
<div class="label">Buckets</div>
</div>
<div class="summary-card c-green">
<div class="number">7</div>
<div class="label">Services</div>
</div>
<div class="summary-card c-orange">
<div class="number">3</div>
<div class="label">Composables</div>
</div>
<div class="summary-card c-purple">
<div class="number">~10</div>
<div class="label">Componentes</div>
</div>
<div class="summary-card c-pink">
<div class="number">5</div>
<div class="label">Feature flags</div>
</div>
</div>
<!-- ════════════════════════════════════════ 1. BANCO ═════════════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon blue">1</div>
<div class="section-title">Banco de Dados — Migrations</div>
</div>
<div class="section-desc">Tabelas, RLS policies, indexes, triggers</div>
<table>
<thead>
<tr>
<th>Migration</th>
<th>Tabela / Objeto</th>
<th>O que faz</th>
<th>Campos principais</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-file" rowspan="4">005_create_documents_tables.sql</td>
<td class="col-table">documents</td>
<td>Arquivo vinculado a paciente. Path no Supabase Storage, tipo/categoria, visibilidade, tags, soft delete com retencao LGPD. Tabela central do modulo. O campo storage_bucket indica qual bucket do Storage contem o arquivo (documents ou generated-docs), permitindo que PDFs gerados aparecam na mesma listagem.</td>
<td>
<div class="fields">
<span class="field">id</span>
<span class="field">patient_id</span>
<span class="field">tenant_id</span>
<span class="field">owner_id</span>
<span class="field">bucket_path</span>
<span class="field">storage_bucket</span>
<span class="field">nome_original</span>
<span class="field">mime_type</span>
<span class="field">tamanho_bytes</span>
<span class="field">tipo_documento</span>
<span class="field">categoria</span>
<span class="field">descricao</span>
<span class="field">tags[]</span>
<span class="field">visibilidade</span>
<span class="field">compartilhado_portal</span>
<span class="field">compartilhado_supervisor</span>
<span class="field">agenda_evento_id</span>
<span class="field">session_note_id</span>
<span class="field">enviado_pelo_paciente</span>
<span class="field">status_revisao</span>
<span class="field">revisado_por</span>
<span class="field">revisado_em</span>
<span class="field">uploaded_by</span>
<span class="field">uploaded_at</span>
<span class="field">deleted_at</span>
<span class="field">deleted_by</span>
<span class="field">retencao_ate</span>
</div>
</td>
</tr>
<tr>
<td class="col-table">document_access_logs</td>
<td>Log imutavel de quem visualizou ou baixou cada arquivo. Conformidade CFP e LGPD. Sem UPDATE/DELETE — somente INSERT e SELECT.</td>
<td>
<div class="fields">
<span class="field">id</span>
<span class="field">documento_id</span>
<span class="field">acao</span>
<span class="field">user_id</span>
<span class="field">ip</span>
<span class="field">user_agent</span>
<span class="field">acessado_em</span>
</div>
</td>
</tr>
<tr>
<td class="col-table">document_signatures</td>
<td>Assinaturas eletronicas. Cada signatario (paciente, responsavel, terapeuta) tem seu registro com IP, timestamp e hash do documento.</td>
<td>
<div class="fields">
<span class="field">id</span>
<span class="field">documento_id</span>
<span class="field">signatario_tipo</span>
<span class="field">signatario_id</span>
<span class="field">ordem</span>
<span class="field">status</span>
<span class="field">ip</span>
<span class="field">user_agent</span>
<span class="field">assinado_em</span>
<span class="field">hash_documento</span>
</div>
</td>
</tr>
<tr>
<td class="col-table">document_share_links</td>
<td>Links temporarios assinados para compartilhar documento com profissional externo sem conta no sistema. Prazo e limite de usos.</td>
<td>
<div class="fields">
<span class="field">id</span>
<span class="field">documento_id</span>
<span class="field">token</span>
<span class="field">expira_em</span>
<span class="field">usos_max</span>
<span class="field">usos</span>
<span class="field">criado_por</span>
<span class="field">criado_em</span>
</div>
</td>
</tr>
<tr>
<td class="col-file" rowspan="2">006_create_document_templates.sql</td>
<td class="col-table">document_templates</td>
<td>Templates de documentos (declaracao de comparecimento, atestado, recibo etc.). Corpo HTML com variaveis. Templates globais do sistema + personalizados por tenant com logo/cabecalho.</td>
<td>
<div class="fields">
<span class="field">id</span>
<span class="field">tenant_id</span>
<span class="field">nome_template</span>
<span class="field">tipo</span>
<span class="field">corpo_html</span>
<span class="field">variaveis[]</span>
<span class="field">is_global</span>
<span class="field">owner_id</span>
<span class="field">logo_url</span>
<span class="field">cabecalho_html</span>
<span class="field">rodape_html</span>
<span class="field">ativo</span>
</div>
</td>
</tr>
<tr>
<td class="col-table">document_generated</td>
<td>Cada PDF gerado a partir de um template. Guarda os dados usados no preenchimento e o path do PDF resultante no Storage.</td>
<td>
<div class="fields">
<span class="field">id</span>
<span class="field">template_id</span>
<span class="field">patient_id</span>
<span class="field">tenant_id</span>
<span class="field">dados_preenchidos</span>
<span class="field">pdf_path</span>
<span class="field">gerado_em</span>
<span class="field">gerado_por</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 2. STORAGE ═══════════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon cyan">2</div>
<div class="section-title">Supabase Storage — Buckets</div>
</div>
<table>
<thead>
<tr>
<th>Bucket</th>
<th>Uso</th>
<th>Path pattern</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-bucket">documents</td>
<td>Arquivos enviados por terapeuta ou paciente (PDF, imagem, DOCX, etc.)</td>
<td class="col-file">{tenant_id}/{patient_id}/{timestamp}-{filename}</td>
</tr>
<tr>
<td class="col-bucket">generated-docs</td>
<td>PDFs gerados pelo sistema a partir de templates. Referenciado tanto por document_generated (snapshot) quanto por documents (listagem do paciente) via campo storage_bucket.</td>
<td class="col-file">{tenant_id}/{patient_id}/{template_nome_sanitizado}_{timestamp}.pdf</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 3. SERVICES ══════════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon green">3</div>
<div class="section-title">Services — Camada de dados</div>
</div>
<div class="section-desc">src/services/ — seguem o padrao Medicos.service.js (getOwnerId + getActiveTenantId + CRUD)</div>
<table>
<thead>
<tr>
<th>Arquivo</th>
<th>O que faz</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-file">Documents.service.js</td>
<td>CRUD completo de documentos: upload ao Storage + insert no banco, listagem por paciente com filtros (tipo, categoria, tags), soft delete com retencao, restauracao, download com URL assinada</td>
</tr>
<tr>
<td class="col-file">DocumentTemplates.service.js</td>
<td>CRUD de templates: criar/editar templates (globais e por tenant), listar variaveis disponiveis, duplicar template, ativar/desativar</td>
</tr>
<tr>
<td class="col-file">DocumentGenerate.service.js</td>
<td>Gerar PDF a partir de template: preencher variaveis com dados do paciente/sessao, renderizar HTML para PDF via pdf.service.js (jsPDF + html2canvas-pro), salvar no bucket generated-docs, registrar em document_generated E automaticamente na tabela documents (para aparecer na listagem do paciente). Nomes de arquivo sanitizados (sem acentos) para compatibilidade com Supabase Storage.</td>
</tr>
<tr>
<td class="col-file">pdf.service.js</td>
<td>Servico de geracao de PDF client-side usando jsPDF + html2canvas-pro. Substitui pdfmake que apresenta incompatibilidade com Vite (UMD vs ESM — getBlob/getBuffer travam silenciosamente). Recebe HTML completo, renderiza em canvas oculto (scale 1.5, JPEG 85%), gera PDF A4 com paginacao automatica. Retorna Blob para upload/download.</td>
</tr>
<tr>
<td class="col-file">DocumentSignatures.service.js</td>
<td>Criar solicitacao de assinatura, registrar assinatura (IP, hash, timestamp, user_agent), consultar status de cada signatario, verificar integridade via hash</td>
</tr>
<tr>
<td class="col-file">DocumentShareLinks.service.js</td>
<td>Gerar link temporario com token, validar token no acesso, registrar uso, expirar link</td>
</tr>
<tr>
<td class="col-file">DocumentAuditLog.service.js</td>
<td>Registrar log de acesso (visualizacao/download) e consultar historico de acessos por documento</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 4. COMPOSABLES ═══════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon orange">4</div>
<div class="section-title">Composables — Logica reativa</div>
</div>
<div class="section-desc">src/features/documents/composables/</div>
<table>
<thead>
<tr>
<th>Arquivo</th>
<th>O que faz</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-file">useDocuments.js</td>
<td>State reativo: lista de documentos do paciente, loading, filtros ativos (tipo, categoria, tags), operacoes CRUD, refresh automatico apos upload/delete</td>
</tr>
<tr>
<td class="col-file">useDocumentTemplates.js</td>
<td>State reativo: lista de templates disponiveis (globais + tenant), preview com dados ficticios, variaveis extraidas do corpo HTML</td>
</tr>
<tr>
<td class="col-file">useDocumentGenerate.js</td>
<td>Logica de geracao: carregar dados do paciente/sessao, mapear variaveis, chamar servico de geracao, retornar URL do PDF</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 5. PAGINAS & COMPONENTES ═════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon purple">5</div>
<div class="section-title">Paginas & Componentes Vue</div>
</div>
<div class="section-desc">src/features/documents/</div>
<table>
<thead>
<tr>
<th>Arquivo</th>
<th>Tipo</th>
<th>O que faz</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-file">DocumentsListPage.vue</td>
<td>Pagina</td>
<td>Pagina principal — lista todos os documentos do paciente com DataTable, filtros (tipo, categoria, tags), botoes de upload, preview, download. Hero header sticky com stats rapidos.</td>
</tr>
<tr>
<td class="col-file">DocumentUploadDialog.vue</td>
<td>Dialog</td>
<td>Upload de arquivo — drag & drop ou seletor, campos: tipo do documento, categoria, descricao, tags, vinculo com sessao (opcional), visibilidade. Validacao de tamanho e tipo de arquivo.</td>
</tr>
<tr>
<td class="col-file">DocumentPreviewDialog.vue</td>
<td>Dialog</td>
<td>Preview inline — renderiza PDF/imagem no dialog. Botoes: download, compartilhar, solicitar assinatura, excluir. Exibe metadados (tipo, tags, quem enviou, data).</td>
</tr>
<tr>
<td class="col-file">DocumentTemplatesPage.vue</td>
<td>Pagina</td>
<td>Gestao de templates — lista templates disponiveis (globais + do tenant), criar novo, editar, duplicar, ativar/desativar. Cards com preview do template.</td>
</tr>
<tr>
<td class="col-file">DocumentTemplateEditor.vue</td>
<td>Componente</td>
<td>Editor de template — edicao do corpo HTML (editor rich text), insercao de variaveis via dropdown, preview ao vivo com dados ficticios, config de cabecalho/rodape/logo.</td>
</tr>
<tr>
<td class="col-file">DocumentGenerateDialog.vue</td>
<td>Dialog</td>
<td>Gerar documento — selecionar template, campos preenchidos automaticamente com dados do paciente/sessao, edicao manual se necessario, preview final via iframe sandbox, botao "Salvar documento" (salva online, sem download automatico). Botao "So baixar" gera PDF local sem salvar no banco.</td>
</tr>
<tr>
<td class="col-file">DocumentSignatureDialog.vue</td>
<td>Dialog</td>
<td>Solicitar assinatura — adicionar signatarios (paciente, responsavel, terapeuta), definir ordem, enviar link por email/whatsapp, acompanhar status de cada signatario.</td>
</tr>
<tr>
<td class="col-file">DocumentShareDialog.vue</td>
<td>Dialog</td>
<td>Compartilhar — gerar link temporario com prazo (24h, 48h, 7d) e limite de usos, copiar link, enviar por email. Exibe links ja criados com status.</td>
</tr>
<tr>
<td class="col-file">components/DocumentCard.vue</td>
<td>Componente</td>
<td>Card reutilizavel de documento — thumbnail (icone por tipo ou preview de imagem), nome, tipo, data, tags, menu de acoes (3 dots).</td>
</tr>
<tr>
<td class="col-file">components/DocumentTagsInput.vue</td>
<td>Componente</td>
<td>Input de tags livres — chips editaveis com autocomplete baseado em tags ja usadas pelo terapeuta. Criacao de novas tags inline.</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 6. INTEGRACAO PRONTUARIO ═════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon pink">6</div>
<div class="section-title">Integracao com Prontuario (arquivo existente)</div>
</div>
<table>
<thead>
<tr>
<th>Arquivo existente</th>
<th>Alteracao</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-file">src/features/patients/prontuario/PatientProntuario.vue</td>
<td>Adicionar aba/secao "Documentos" que renderiza DocumentsListPage filtrada pelo patient_id atual. Botao rapido de upload direto do prontuario.</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 7. ROTAS ═════════════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon orange">7</div>
<div class="section-title">Rotas</div>
</div>
<div class="section-desc">Adicionadas em routes.therapist.js e routes.clinic.js</div>
<table>
<thead>
<tr>
<th>Rota</th>
<th>Pagina</th>
<th>Descricao</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-route">/therapist/documents</td>
<td class="col-file">DocumentsListPage.vue</td>
<td>Lista geral de documentos (todos os pacientes do terapeuta)</td>
</tr>
<tr>
<td class="col-route">/therapist/documents/templates</td>
<td class="col-file">DocumentTemplatesPage.vue</td>
<td>Gestao de templates do terapeuta</td>
</tr>
<tr>
<td class="col-route">/therapist/patients/:id/documents</td>
<td class="col-file">DocumentsListPage.vue</td>
<td>Documentos de um paciente especifico (via props)</td>
</tr>
<tr>
<td class="col-route">/clinic/documents/templates</td>
<td class="col-file">DocumentTemplatesPage.vue</td>
<td>Templates da clinica (admin configura templates compartilhados)</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 8. MENUS ═════════════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon green">8</div>
<div class="section-title">Menus de Navegacao</div>
</div>
<table>
<thead>
<tr>
<th>Arquivo</th>
<th>Item adicionado</th>
<th>Onde no menu</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-file">therapist.menu.js</td>
<td>"Documentos" — icon: pi-file, to: /therapist/documents</td>
<td>Grupo "Pacientes", abaixo de "Tags"</td>
</tr>
<tr>
<td class="col-file">therapist.menu.js</td>
<td>"Templates" — icon: pi-file-edit, to: /therapist/documents/templates</td>
<td>Sub-item de Documentos</td>
</tr>
<tr>
<td class="col-file">clinic.menu.js</td>
<td>"Templates de Documentos" — icon: pi-file-edit, to: /clinic/documents/templates</td>
<td>Grupo "Configuracoes"</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 9. SAAS FEATURES ═════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon red">9</div>
<div class="section-title">SaaS — Feature Flags</div>
</div>
<div class="section-desc">Inseridas em saas_features e vinculadas aos planos via plan_features</div>
<table>
<thead>
<tr>
<th>Feature key</th>
<th>Descricao</th>
<th>Planos</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-key">documents.upload</td>
<td>Upload de arquivos a pacientes — funcionalidade base</td>
<td>Free + Pro</td>
</tr>
<tr>
<td class="col-key">documents.templates</td>
<td>Templates de documentos (declaracao, atestado, recibo etc.)</td>
<td>Pro</td>
</tr>
<tr>
<td class="col-key">documents.signatures</td>
<td>Assinatura eletronica (TCLE, consentimentos)</td>
<td>Pro</td>
</tr>
<tr>
<td class="col-key">documents.share_links</td>
<td>Links temporarios para compartilhamento externo</td>
<td>Pro</td>
</tr>
<tr>
<td class="col-key">documents.patient_portal</td>
<td>Paciente visualiza e envia documentos pelo portal</td>
<td>Pro</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 10. SEED DATA ════════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon cyan">10</div>
<div class="section-title">Seed Data — Templates Padrao</div>
</div>
<table>
<thead>
<tr>
<th>Arquivo</th>
<th>O que insere</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-file">seed_015_document_templates.sql</td>
<td>
4 templates globais (is_global = true) com corpo HTML e variaveis mapeadas:
<div class="fields" style="margin-top: 8px;">
<span class="field">Declaracao de Comparecimento</span>
<span class="field">Atestado Psicologico</span>
<span class="field">Relatorio de Acompanhamento</span>
<span class="field">Recibo de Pagamento</span>
</div>
</td>
</tr>
</tbody>
</table>
<div class="note">
<strong>Variaveis dos templates:</strong> {{paciente_nome}}, {{paciente_cpf}}, {{data_sessao}}, {{hora_inicio}}, {{hora_fim}}, {{terapeuta_nome}}, {{terapeuta_crp}}, {{clinica_nome}}, {{clinica_endereco}}, {{valor}}, {{data_atual}}, entre outras. Cada template define quais variaveis utiliza no campo variaveis[].
</div>
<div class="note" style="border-left-color: var(--orange); margin-top: 8px;">
<strong>Decisao tecnica — Motor PDF:</strong> pdfmake foi substituido por jsPDF + html2canvas-pro. O pdfmake (UMD) trava silenciosamente com Vite (ESM) — createPdf().getBlob()/getBuffer() nunca retornam, mesmo com optimizeDeps configurado. A solucao final usa html2canvas-pro (fork com suporte a cores oklch do PrimeVue/Tailwind) para renderizar o HTML preenchido em canvas, e jsPDF para converter em PDF A4 com paginacao. Resultado: ~200-400KB por documento (JPEG 85%, scale 1.5).
</div>
</div>
<!-- ════════════════════════════════════════ ORDEM DE EXECUCAO ════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon blue">!</div>
<div class="section-title">Ordem de Execucao Sugerida</div>
</div>
<table>
<thead>
<tr>
<th>Fase</th>
<th>O que</th>
<th>Depende de</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>1</strong></td>
<td>Migrations (tabelas, RLS, triggers, indexes)</td>
<td></td>
</tr>
<tr>
<td><strong>2</strong></td>
<td>Buckets no Supabase Storage</td>
<td>Fase 1</td>
</tr>
<tr>
<td><strong>3</strong></td>
<td>Services (camada de dados)</td>
<td>Fase 1 + 2</td>
</tr>
<tr>
<td><strong>4</strong></td>
<td>Composables (logica reativa)</td>
<td>Fase 3</td>
</tr>
<tr>
<td><strong>5</strong></td>
<td>Componentes e Paginas Vue</td>
<td>Fase 4</td>
</tr>
<tr>
<td><strong>6</strong></td>
<td>Rotas, menus, feature flags</td>
<td>Fase 5</td>
</tr>
<tr>
<td><strong>7</strong></td>
<td>Integracao com Prontuario</td>
<td>Fase 5</td>
</tr>
<tr>
<td><strong>8</strong></td>
<td>Seed data (templates padrao)</td>
<td>Fase 1</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,372 @@
<style>
* { box-sizing: border-box; }
.page { padding: 0 0 32px; }
.section { margin-bottom: 28px; }
.section-title { font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-tertiary); margin: 0 0 10px; padding-bottom: 6px; border-bottom: 0.5px solid var(--color-border-tertiary); }
.cards { display: grid; gap: 10px; }
.cards-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.cards-3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
.card { background: var(--color-background-primary); border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-lg); padding: 14px 16px; }
.card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.card-title { font-size: 13px; font-weight: 500; color: var(--color-text-primary); margin: 0; display: flex; align-items: center; gap: 7px; }
.card-desc { font-size: 12px; color: var(--color-text-secondary); line-height: 1.55; margin: 0; }
.card-fields { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 5px; }
.field { font-size: 11px; padding: 3px 8px; border-radius: 20px; border: 0.5px solid var(--color-border-secondary); color: var(--color-text-secondary); background: var(--color-background-secondary); font-family: var(--font-mono); }
.field-has { background: #EAF3DE; border-color: #C0DD97; color: #27500A; }
.field-miss { background: #FCEBEB; border-color: #F7C1C1; color: #791F1F; }
.badge { font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: 20px; white-space: nowrap; flex-shrink: 0; align-self: flex-start; margin-top: 1px; }
.badge-has { background: #EAF3DE; color: #27500A; }
.badge-part { background: #FAEEDA; color: #633806; }
.badge-miss { background: #FCEBEB; color: #791F1F; }
.badge-diff { background: #E6F1FB; color: #0C447C; }
@media (prefers-color-scheme: dark) {
.field-has { background: #173404; border-color: #27500A; color: #C0DD97; }
.field-miss { background: #501313; border-color: #791F1F; color: #F7C1C1; }
.badge-has { background: #173404; color: #C0DD97; }
.badge-part { background: #412402; color: #FAC775; }
.badge-miss { background: #501313; color: #F7C1C1; }
.badge-diff { background: #042C53; color: #B5D4F4; }
}
.icon-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; display: inline-block; margin-top: 3px; }
.dot-has { background: #639922; }
.dot-part { background: #EF9F27; }
.dot-miss { background: #E24B4A; }
.dot-diff { background: #378ADD; }
.legend { display: flex; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--color-text-secondary); }
.sub { font-size: 11px; color: var(--color-text-tertiary); margin: 2px 0 6px; font-family: var(--font-mono); }
.note { font-size: 12px; color: var(--color-text-secondary); background: var(--color-background-secondary); border-left: 2px solid var(--color-border-secondary); padding: 8px 12px; margin-top: 10px; line-height: 1.5; border-radius: 0; }
</style>
<div class="page">
<div class="legend">
<div class="legend-item"><span class="icon-dot dot-has"></span> você já tem</div>
<div class="legend-item"><span class="icon-dot dot-part"></span> tem parcialmente</div>
<div class="legend-item"><span class="icon-dot dot-miss"></span> faltando</div>
<div class="legend-item"><span class="icon-dot dot-diff"></span> diferencial de mercado</div>
</div>
<div class="section">
<div class="section-title">1 · Identificação & dados pessoais</div>
<div class="cards cards-2">
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Dados básicos de identificação</div><div class="sub">núcleo do cadastro</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Nome, email, telefone, data de nascimento, CPF, RG, gênero, naturalidade, estado civil, escolaridade e profissão.</div>
<div class="card-fields">
<span class="field field-has">nome_completo</span><span class="field field-has">email_principal</span><span class="field field-has">telefone</span><span class="field field-has">data_nascimento</span><span class="field field-has">cpf</span><span class="field field-has">rg</span><span class="field field-has">genero</span><span class="field field-has">estado_civil</span><span class="field field-has">escolaridade</span><span class="field field-has">profissao</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-part"></span> Gênero & pronomes</div><div class="sub">campo genero existe, pronomes não</div></div>
<span class="badge badge-part">parcial</span>
</div>
<div class="card-desc">Você tem o campo <span style="font-family:var(--font-mono);font-size:11px">genero</span> como texto livre. Faltam pronomes preferidos (ele/ela/eles) — padrão nos sistemas modernos de saúde mental, especialmente para público LGBTQIA+.</div>
<div class="card-fields">
<span class="field field-has">genero</span>
<span class="field field-miss">pronomes</span>
<span class="field field-miss">nome_social</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Endereço completo</div><div class="sub">CEP, cidade, estado, complemento</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">CEP, endereço, número, bairro, complemento, cidade, estado e país. Estrutura adequada.</div>
<div class="card-fields">
<span class="field field-has">cep</span><span class="field field-has">endereco</span><span class="field field-has">numero</span><span class="field field-has">bairro</span><span class="field field-has">cidade</span><span class="field field-has">estado</span><span class="field field-has">pais</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-miss"></span> Dados socioeconômicos</div><div class="sub">renda e contexto social</div></div>
<span class="badge badge-miss">faltando</span>
</div>
<div class="card-desc">Faixa de renda, religião/espiritualidade, etnia. Campos opcionais mas relevantes clinicamente e para política de precificação solidária. SimplePractice e Psicologia Viva coletam isso.</div>
<div class="card-fields">
<span class="field field-miss">faixa_renda</span>
<span class="field field-miss">etnia</span>
<span class="field field-miss">religiao</span>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">2 · Contatos & rede de suporte</div>
<div class="cards cards-2">
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-part"></span> Contato de emergência</div><div class="sub">só um contato, sem estrutura</div></div>
<span class="badge badge-part">parcial</span>
</div>
<div class="card-desc">Você tem <span style="font-family:var(--font-mono);font-size:11px">nome_parente</span>, <span style="font-family:var(--font-mono);font-size:11px">grau_parentesco</span> e <span style="font-family:var(--font-mono);font-size:11px">telefone_parente</span> como campos soltos na tabela. Falta suporte a múltiplos contatos e campo de email do contato.</div>
<div class="card-fields">
<span class="field field-has">nome_parente</span><span class="field field-has">grau_parentesco</span><span class="field field-has">telefone_parente</span>
<span class="field field-miss">email_contato</span><span class="field field-miss">multiplos_contatos</span><span class="field field-miss">contato_primario</span>
</div>
<div class="note">Ideal: tabela separada <span style="font-family:var(--font-mono)">patient_contacts</span> com N contatos por paciente.</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Responsável legal</div><div class="sub">para menores de idade</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Nome, CPF, telefone do responsável e flag de cobrança no responsável. Cobre bem o caso de pacientes menores.</div>
<div class="card-fields">
<span class="field field-has">nome_responsavel</span><span class="field field-has">telefone_responsavel</span><span class="field field-has">cpf_responsavel</span><span class="field field-has">cobranca_no_responsavel</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-miss"></span> Outros profissionais de saúde</div><div class="sub">psiquiatra, médico, nutricionista</div></div>
<span class="badge badge-miss">faltando</span>
</div>
<div class="card-desc">Nome e contato do psiquiatra, médico ou outros profissionais que acompanham o paciente. Essencial para coordenação de cuidados. Presente no SimplePractice e TheraNest.</div>
<div class="card-fields">
<span class="field field-miss">nome_profissional</span><span class="field field-miss">especialidade</span><span class="field field-miss">telefone_profissional</span><span class="field field-miss">email_profissional</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-diff"></span> Preferências de comunicação</div><div class="sub">como o paciente quer ser contatado</div></div>
<span class="badge badge-diff">diferencial</span>
</div>
<div class="card-desc">Canal preferido (WhatsApp, email, SMS), horário preferido para contato, idioma preferido. Alimenta diretamente os lembretes automáticos com as preferências do paciente.</div>
<div class="card-fields">
<span class="field field-miss">canal_preferido</span><span class="field field-miss">horario_contato</span><span class="field field-miss">idioma</span>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">3 · Origem & encaminhamento</div>
<div class="cards cards-2">
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-part"></span> Como chegou ao terapeuta</div><div class="sub">campos existem mas são texto livre</div></div>
<span class="badge badge-part">parcial</span>
</div>
<div class="card-desc">Você tem <span style="font-family:var(--font-mono);font-size:11px">onde_nos_conheceu</span> e <span style="font-family:var(--font-mono);font-size:11px">encaminhado_por</span> como texto livre. Ideal ser enum + texto opcional para permitir filtros e relatórios de origem.</div>
<div class="card-fields">
<span class="field field-has">onde_nos_conheceu</span><span class="field field-has">encaminhado_por</span>
<span class="field field-miss">origem_enum</span><span class="field field-miss">agendador_publico_ref</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-miss"></span> Motivo de inatividade ou alta</div><div class="sub">por que o paciente saiu</div></div>
<span class="badge badge-miss">faltando</span>
</div>
<div class="card-desc">Quando paciente vai para "Alta", "Inativo" ou "Encaminhado" — qual o motivo? Alta terapêutica, abandono, encaminhamento, mudança de cidade. Essencial para relatórios e qualidade clínica.</div>
<div class="card-fields">
<span class="field field-miss">motivo_saida</span><span class="field field-miss">data_saida</span><span class="field field-miss">encaminhado_para</span>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">4 · Status & ciclo de vida do paciente</div>
<div class="cards cards-2">
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Status do paciente</div><div class="sub">Ativo, Inativo, Alta, Encaminhado, Arquivado</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Enum bem definido com os 5 status mais relevantes. Constraint no banco garante integridade.</div>
<div class="card-fields">
<span class="field field-has">Ativo</span><span class="field field-has">Inativo</span><span class="field field-has">Alta</span><span class="field field-has">Encaminhado</span><span class="field field-has">Arquivado</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-miss"></span> Histórico de mudanças de status</div><div class="sub">trilha de auditoria do ciclo de vida</div></div>
<span class="badge badge-miss">faltando</span>
</div>
<div class="card-desc">Quando o status mudou, quem mudou e por quê. Permite ver o histórico completo: "Ativo → Inativo (01/03) → Ativo (15/04)". Exigência de auditoria clínica.</div>
<div class="card-fields">
<span class="field field-miss">status_anterior</span><span class="field field-miss">status_novo</span><span class="field field-miss">motivo</span><span class="field field-miss">alterado_por</span><span class="field field-miss">alterado_em</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Escopo do paciente (clínica vs. terapeuta)</div><div class="sub">patient_scope bem modelado</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Distinção entre paciente da clínica (qualquer terapeuta pode atender) e paciente particular do terapeuta. Com constraint de consistência.</div>
<div class="card-fields">
<span class="field field-has">patient_scope</span><span class="field field-has">therapist_member_id</span><span class="field field-has">responsible_member_id</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-diff"></span> Alerta & flag de risco</div><div class="sub">sinalização visível no topo do cadastro</div></div>
<span class="badge badge-diff">diferencial</span>
</div>
<div class="card-desc">Flag booleano de risco elevado com nota associada. Exibe alerta vermelho no topo do cadastro e do prontuário. Terapeuta sinaliza pacientes que precisam de atenção especial (ideação, crise recente).</div>
<div class="card-fields">
<span class="field field-miss">risco_elevado</span><span class="field field-miss">nota_risco</span><span class="field field-miss">sinalizado_em</span><span class="field field-miss">sinalizado_por</span>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">5 · Organização & segmentação</div>
<div class="cards cards-2">
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Tags de paciente</div><div class="sub">patient_tags + patient_patient_tag</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Tags com nome e cor, por tenant, com many-to-many. Bem estruturado.</div>
<div class="card-fields">
<span class="field field-has">patient_tags</span><span class="field field-has">patient_patient_tag</span><span class="field field-has">cor</span><span class="field field-has">is_padrao</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Grupos de pacientes</div><div class="sub">patient_groups com many-to-many</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Grupos com nome, cor, descrição, status ativo e flag de sistema. Relação many-to-many com <span style="font-family:var(--font-mono);font-size:11px">patient_group_patient</span>.</div>
<div class="card-fields">
<span class="field field-has">patient_groups</span><span class="field field-has">patient_group_patient</span><span class="field field-has">is_system</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Cor de identificação</div><div class="sub">identification_color na agenda</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Cor atribuída ao paciente para visualização rápida na agenda. Diferencial visual que poucos sistemas brasileiros têm.</div>
<div class="card-fields">
<span class="field field-has">identification_color</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-diff"></span> Score de engajamento</div><div class="sub">calculado automaticamente</div></div>
<span class="badge badge-diff">diferencial</span>
</div>
<div class="card-desc">Score calculado por view/função baseado em: frequência de sessões, taxa de comparecimento, dias desde última sessão, pagamentos em dia. Exibido como indicador no card do paciente. Ajuda a identificar quem precisa de atenção.</div>
<div class="card-fields">
<span class="field field-miss">engajamento_score</span><span class="field field-miss">taxa_comparecimento</span><span class="field field-miss">dias_sem_sessao</span>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">6 · Financeiro vinculado ao paciente</div>
<div class="cards cards-2">
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Descontos individuais</div><div class="sub">patient_discounts bem modelado</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Desconto percentual ou fixo por paciente, com período de validade e motivo. Bem estruturado com active_from e active_to.</div>
<div class="card-fields">
<span class="field field-has">discount_pct</span><span class="field field-has">discount_flat</span><span class="field field-has">active_from</span><span class="field field-has">active_to</span><span class="field field-has">reason</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-miss"></span> Limite de sessões por período</div><div class="sub">controle de plano ou convênio</div></div>
<span class="badge badge-miss">faltando</span>
</div>
<div class="card-desc">Pacientes de convênio frequentemente têm limite de sessões autorizadas por mês. Campo para registrar o limite e controlar o consumo — alerta quando está próximo do teto.</div>
<div class="card-fields">
<span class="field field-miss">limite_sessoes_mes</span><span class="field field-miss">sessoes_usadas</span><span class="field field-miss">periodo_referencia</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-miss"></span> Método de pagamento preferido</div><div class="sub">como esse paciente costuma pagar</div></div>
<span class="badge badge-miss">faltando</span>
</div>
<div class="card-desc">PIX, cartão, dinheiro, convênio. Aparece como sugestão padrão ao registrar cobrança. Evita perguntar toda vez como o paciente paga.</div>
<div class="card-fields">
<span class="field field-miss">metodo_pagamento_preferido</span><span class="field field-miss">dados_pagamento_obs</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-diff"></span> LTV & métricas financeiras do paciente</div><div class="sub">calculado por view</div></div>
<span class="badge badge-diff">diferencial</span>
</div>
<div class="card-desc">Total pago desde o início, ticket médio por sessão, total de sessões realizadas. Calculado por view em cima de financial_records — sem armazenar, sem inconsistência.</div>
<div class="card-fields">
<span class="field field-miss">v_patient_ltv</span><span class="field field-miss">total_pago</span><span class="field field-miss">ticket_medio</span><span class="field field-miss">total_sessoes</span>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">7 · Observações & notas internas</div>
<div class="cards cards-2">
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-part"></span> Observações gerais</div><div class="sub">dois campos de texto soltos</div></div>
<span class="badge badge-part">parcial</span>
</div>
<div class="card-desc">Você tem <span style="font-family:var(--font-mono);font-size:11px">observacoes</span> e <span style="font-family:var(--font-mono);font-size:11px">notas_internas</span> como campos de texto livre. Funciona, mas sem distinção clara de propósito ou histórico de edições.</div>
<div class="card-fields">
<span class="field field-has">observacoes</span><span class="field field-has">notas_internas</span>
<span class="field field-miss">historico_edicoes</span><span class="field field-miss">editado_por</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-diff"></span> Linha do tempo do paciente</div><div class="sub">feed cronológico de tudo que aconteceu</div></div>
<span class="badge badge-diff">diferencial</span>
</div>
<div class="card-desc">Feed automático com eventos relevantes: "Primeira sessão", "Mudança de status", "Documento assinado", "Escala respondida", "Pagamento em atraso". Visível no topo do cadastro como timeline. SimplePractice tem isso.</div>
<div class="card-fields">
<span class="field field-miss">patient_timeline</span><span class="field field-miss">evento_tipo</span><span class="field field-miss">descricao</span><span class="field field-miss">ocorrido_em</span>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,964 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pacientes — Status de Implementacao</title>
<style>
:root {
--bg: #ffffff;
--bg-card: #f8f9fb;
--bg-card-hover: #f1f3f6;
--bg-sidebar: #f4f5f7;
--border: #e2e5ea;
--border-light: #eceef2;
--text: #1a1d23;
--text-secondary: #5f6775;
--text-muted: #8a91a0;
--accent: #2563eb;
--accent-dim: #2563eb14;
--green: #16a34a;
--green-dim: #16a34a14;
--green-bg: #dcfce7;
--orange: #d97706;
--orange-dim: #d9770614;
--orange-bg: #fef3c7;
--red: #dc2626;
--red-dim: #dc262614;
--red-bg: #fee2e2;
--purple: #7c3aed;
--purple-dim: #7c3aed14;
--cyan: #0891b2;
--cyan-dim: #0891b214;
--pink: #db2777;
--pink-dim: #db277714;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
--radius: 8px;
--radius-lg: 12px;
--shadow: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
--shadow-lg: 0 4px 12px rgba(0,0,0,.08);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.6;
padding: 40px 24px 80px;
max-width: 1120px;
margin: 0 auto;
}
/* ── Header ─────────────────────────── */
.page-header {
text-align: center;
margin-bottom: 36px;
padding-bottom: 28px;
border-bottom: 1px solid var(--border);
}
.page-header h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--text);
margin-bottom: 6px;
}
.page-header .subtitle {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 14px;
}
.meta {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
}
.meta-tag {
font-size: 11px;
font-family: var(--font-mono);
padding: 4px 12px;
border-radius: 20px;
border: 1px solid var(--border);
color: var(--text-secondary);
background: var(--bg-card);
}
/* ── Summary grid ──────────────────── */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 10px;
margin-bottom: 36px;
}
.summary-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 14px;
text-align: center;
box-shadow: var(--shadow);
}
.summary-card .number { font-size: 26px; font-weight: 700; line-height: 1.2; }
.summary-card .label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.07em; margin-top: 3px; }
.c-green .number { color: var(--green); }
.c-orange .number { color: var(--orange); }
.c-red .number { color: var(--red); }
.c-accent .number { color: var(--accent); }
.c-purple .number { color: var(--purple); }
.c-cyan .number { color: var(--cyan); }
.c-pink .number { color: var(--pink); }
/* ── Legend ─────────────────────────── */
.legend {
display: flex;
gap: 20px;
justify-content: center;
margin-bottom: 32px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.legend-item .dot { width: 9px; height: 9px; border-radius: 50%; }
.dot-green { background: var(--green); }
.dot-orange { background: var(--orange); }
.dot-red { background: var(--red); }
/* ── Section ────────────────────────── */
.section { margin-bottom: 36px; }
.section-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 2px solid var(--border-light);
}
.section-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
}
.section-icon.blue { background: var(--accent-dim); color: var(--accent); }
.section-icon.green { background: var(--green-dim); color: var(--green); }
.section-icon.orange { background: var(--orange-dim); color: var(--orange); }
.section-icon.purple { background: var(--purple-dim); color: var(--purple); }
.section-icon.cyan { background: var(--cyan-dim); color: var(--cyan); }
.section-icon.pink { background: var(--pink-dim); color: var(--pink); }
.section-icon.red { background: var(--red-dim); color: var(--red); }
.section-title { font-size: 17px; font-weight: 600; }
/* ── Content: cards + sidebar ───────── */
.content-row {
display: grid;
grid-template-columns: 1fr 270px;
gap: 16px;
align-items: start;
}
@media (max-width: 850px) {
.content-row { grid-template-columns: 1fr; }
}
/* ── Cards ──────────────────────────── */
.cards { display: flex; flex-direction: column; gap: 10px; }
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px 20px;
box-shadow: var(--shadow);
transition: box-shadow .15s, border-color .15s;
}
.card:hover { box-shadow: var(--shadow-lg); border-color: #d0d4db; }
.card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.card-title {
font-size: 13.5px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.card-desc {
font-size: 12.5px;
color: var(--text-secondary);
line-height: 1.6;
}
.card-fields {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 10px;
}
.field {
font-size: 10px;
font-family: var(--font-mono);
padding: 3px 8px;
border-radius: 12px;
border: 1px solid var(--border);
color: var(--text-muted);
background: var(--bg);
}
.card-file {
font-size: 11px;
font-family: var(--font-mono);
color: var(--accent);
margin-top: 8px;
opacity: .75;
}
/* ── Badges ─────────────────────────── */
.badge {
font-size: 10px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
white-space: nowrap;
flex-shrink: 0;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge-done { background: var(--green-bg); color: var(--green); }
.badge-partial { background: var(--orange-bg); color: var(--orange); }
.badge-pending { background: var(--red-bg); color: var(--red); }
/* ── Sidebar ────────────────────────── */
.sidebar {
background: var(--bg-sidebar);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 18px;
position: sticky;
top: 20px;
box-shadow: var(--shadow);
}
.sidebar-title {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
font-size: 12px;
color: var(--text-secondary);
}
.sidebar-item .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.sidebar-item .label { flex: 1; }
.sidebar-item .status {
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-muted);
}
.sidebar-divider { height: 1px; background: var(--border-light); margin: 12px 0; }
/* ── Note ───────────────────────────── */
.note {
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 14px 18px;
font-size: 12.5px;
color: var(--text-secondary);
margin-top: 14px;
line-height: 1.6;
box-shadow: var(--shadow);
}
.note strong { color: var(--text); }
.note.warn { border-left-color: var(--orange); }
.note.info { border-left-color: var(--cyan); }
</style>
</head>
<body>
<!-- ══════════════════════════════ HEADER ══════════════════════════════ -->
<div class="page-header">
<h1>Modulo Pacientes</h1>
<div class="subtitle">Status de implementacao confrontado com banco de dados, services e frontend</div>
<div class="meta">
<span class="meta-tag">AgenciaPsi v5</span>
<span class="meta-tag">Vue 3 + Supabase</span>
<span class="meta-tag">Atualizado: 2026-03-30</span>
</div>
</div>
<!-- ══════════════════════════════ STATS ═══════════════════════════════ -->
<div class="summary-grid">
<div class="summary-card c-green">
<div class="number">5</div>
<div class="label">Tabelas core</div>
</div>
<div class="summary-card c-accent">
<div class="number">4</div>
<div class="label">Tabelas aux</div>
</div>
<div class="summary-card c-green">
<div class="number">2</div>
<div class="label">Views</div>
</div>
<div class="summary-card c-green">
<div class="number">3</div>
<div class="label">Services</div>
</div>
<div class="summary-card c-green">
<div class="number">10</div>
<div class="label">Componentes</div>
</div>
<div class="summary-card c-green">
<div class="number">8+8</div>
<div class="label">Rotas (T+C)</div>
</div>
<div class="summary-card c-purple">
<div class="number">50+</div>
<div class="label">Colunas patients</div>
</div>
<div class="summary-card c-cyan">
<div class="number">5</div>
<div class="label">Triggers</div>
</div>
</div>
<!-- ══════════════════════════════ LEGENDA ═════════════════════════════ -->
<div class="legend">
<div class="legend-item"><span class="dot dot-green"></span> Implementado (DB + frontend)</div>
<div class="legend-item"><span class="dot dot-orange"></span> Parcial / migration pendente</div>
<div class="legend-item"><span class="dot dot-red"></span> Planejado / nao implementado</div>
</div>
<!-- ══════════════════════════════ 1. CADASTRO ═════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon blue">1</div>
<div class="section-title">Cadastro & Dados Pessoais</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Identidade & dados pessoais</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Formulario completo com 6 secoes em accordion. Nome completo, nome social, pronomes, data nascimento, genero, estado civil, CPF (validacao checksum), RG, naturalidade, etnia, profissao, escolaridade. Avatar com upload ao Storage.</div>
<div class="card-fields">
<span class="field">nome_completo</span><span class="field">nome_social</span><span class="field">pronomes</span>
<span class="field">data_nascimento</span><span class="field">genero</span><span class="field">estado_civil</span>
<span class="field">cpf</span><span class="field">rg</span><span class="field">etnia</span>
<span class="field">profissao</span><span class="field">escolaridade</span><span class="field">avatar_url</span>
</div>
<div class="card-file">PatientsCadastroPage.vue &rarr; secao "Identidade"</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Contato & preferencias</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Telefone principal e alternativo, email principal e alternativo. Canal preferido de contato (WhatsApp, Telefone, E-mail, SMS). Horario preferido para contato com janela inicio/fim. Idioma.</div>
<div class="card-fields">
<span class="field">telefone</span><span class="field">telefone_alternativo</span><span class="field">email_principal</span>
<span class="field">email_alternativo</span><span class="field">canal_preferido</span>
<span class="field">horario_contato_inicio</span><span class="field">horario_contato_fim</span><span class="field">idioma</span>
</div>
<div class="card-file">DB: CHECK canal_preferido IN (whatsapp, email, sms, telefone)</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Endereco com auto-preenchimento</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">CEP com consulta ViaCEP automatica (onBlur). Preenche logradouro, bairro, cidade, estado. Complemento e numero manuais. Pais default Brasil.</div>
<div class="card-fields">
<span class="field">cep</span><span class="field">endereco</span><span class="field">numero</span>
<span class="field">bairro</span><span class="field">complemento</span><span class="field">cidade</span>
<span class="field">estado</span><span class="field">pais</span>
</div>
<div class="card-file">PatientsCadastroPage.vue &rarr; secao "Endereco" + ViaCEP API</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Responsavel legal</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Para menores ou cobranca em terceiro. Nome, CPF (validacao), telefone, observacao. Flag de cobranca no responsavel.</div>
<div class="card-fields">
<span class="field">nome_responsavel</span><span class="field">cpf_responsavel</span>
<span class="field">telefone_responsavel</span><span class="field">observacao_responsavel</span>
<span class="field">cobranca_no_responsavel</span>
</div>
<div class="card-file">PatientsCadastroPage.vue &rarr; secao "Responsavel"</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Cadastro rapido & link externo</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">3 modos de criacao: Cadastro rapido (nome, email, telefone), Cadastro completo (formulario full), Link externo (paciente preenche). Convite via token com validade.</div>
<div class="card-file">ComponentCadastroRapido.vue + PatientCreatePopover.vue + PatientsExternalLinkPage.vue</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Dados socioeconomicos</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Religiao, faixa de renda (ate_1sm, 1_3sm, 3_6sm, 6_10sm, acima_10sm, nao_informado), origem (indicacao, agendador, redes_sociais, encaminhamento).</div>
<div class="card-fields">
<span class="field">religiao</span><span class="field">faixa_renda</span><span class="field">origem</span>
<span class="field">onde_nos_conheceu</span><span class="field">encaminhado_por</span>
</div>
<div class="card-file">DB: CHECK constraints com valores permitidos</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Banco de Dados</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">patients</span><span class="status">50+ cols</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">RLS por owner + tenant</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">12+ indexes</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">5 triggers</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">CPF checksum</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Frontend</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">PatientsCadastroPage</span><span class="status">1985 ln</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">CadastroRapido</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">PatientCreatePopover</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">ExternalLinkPage</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Validacao</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">useFormValidation</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">validators.js</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">CPF, Phone, Email, CEP</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════ 2. LISTAGEM & BUSCA ════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon green">2</div>
<div class="section-title">Listagem, Busca & Organizacao</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Lista de pacientes com filtros</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">DataTable com busca por nome/email/telefone (debounce 250ms). Filtros: status, grupo, tag, data de criacao. Colunas dinamicas com visibilidade configuravel. Vista tabela (desktop) e cards (mobile). Vista agrupada por grupo.</div>
<div class="card-fields">
<span class="field">search</span><span class="field">status</span><span class="field">groupId</span>
<span class="field">tagId</span><span class="field">createdFrom</span><span class="field">createdTo</span>
</div>
<div class="card-file">PatientsListPage.vue (1457 linhas)</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Grupos de pacientes</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">CRUD completo de grupos com cor. Associacao paciente &harr; grupo via junction table. Contagem de pacientes por grupo. Pagina de gestao dedicada.</div>
<div class="card-fields">
<span class="field">patient_groups</span><span class="field">patient_group_patient</span>
</div>
<div class="card-file">GruposPacientesPage.vue + GruposPacientes.service.js</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Tags de pacientes</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">CRUD de tags com cor e nome. Multi-select no cadastro. Autocomplete. Contagem de pacientes por tag. Tags padrao do sistema.</div>
<div class="card-fields">
<span class="field">patient_tags</span><span class="field">patient_patient_tag</span>
</div>
<div class="card-file">TagsPage.vue + patientTags.service.js</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Medicos & referencias</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Cadastro de medicos que encaminham pacientes. CRM, especialidade (13 opcoes), contatos. Contagem de pacientes por medico. Soft delete. Busca de pacientes referidos.</div>
<div class="card-fields">
<span class="field">medicos</span><span class="field">nome</span><span class="field">crm</span>
<span class="field">especialidade</span><span class="field">encaminhado_por</span>
</div>
<div class="card-file">MedicosPage.vue + Medicos.service.js</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Lista de espera</div>
<span class="badge badge-partial">placeholder</span>
</div>
<div class="card-desc">Tab "Lista de espera" existe na PatientsListPage mas e um placeholder. Comentario no codigo: "Quando voce quiser, podemos ligar isso a uma tabela (ex: patient_waitlist)". Nao tem tabela no banco.</div>
<div class="card-file">PatientsListPage.vue &rarr; tab placeholder</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Cadastros recebidos (intake)</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Formularios de cadastro externo submetidos por pacientes prospectivos. Status: new, converted, rejected. Pagina de gestao dedicada com badge no menu.</div>
<div class="card-fields">
<span class="field">patient_intake_requests</span><span class="field">status</span><span class="field">converted_patient_id</span>
</div>
<div class="card-file">CadastrosRecebidosPage.vue</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Tabelas Auxiliares</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">patient_groups</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">patient_group_patient</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">patient_tags</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">patient_patient_tag</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">medicos</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">patient_intake_requests</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">patient_invites</span></div>
<div class="sidebar-item"><span class="dot dot-red"></span><span class="label">patient_waitlist</span><span class="status">nao existe</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Rotas</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">/therapist/patients</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">/therapist/.../grupos</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">/therapist/.../tags</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">/therapist/.../medicos</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">/admin/pacientes/*</span><span class="status">8 rotas</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════ 3. PRONTUARIO ══════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon purple">3</div>
<div class="section-title">Prontuario do Paciente</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Perfil completo (tab 1)</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Sidebar com avatar, badges (status, convenio, scope), tags e metricas (sessoes, comparecimento %, LTV, dias sem sessao). Corpo com 6 sub-secoes em accordion: dados pessoais, contato, endereco, dados adicionais, responsavel, anotacoes.</div>
<div class="card-file">PatientProntuario.vue &rarr; tab "Perfil" (1167 linhas total)</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Prontuario clinico (tab 2)</div>
<span class="badge badge-partial">estrutura</span>
</div>
<div class="card-desc">Tab existe no componente mas conteudo clinico (notas de sessao, evolucao, plano terapeutico) ainda precisa ser detalhado. Placeholder no modal.</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Agenda do paciente (tab 3)</div>
<span class="badge badge-partial">estrutura</span>
</div>
<div class="card-desc">Tab de sessoes/agenda existe. Lista de agenda_eventos carregada. Falta validar se a UI mostra corretamente os agendamentos futuros e historico completo.</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Financeiro do paciente (tab 4)</div>
<span class="badge badge-partial">estrutura</span>
</div>
<div class="card-desc">Tab existe. patient_discounts funciona (desconto percentual ou valor fixo, periodo de validade). Detalhes de cobrancas e pagamentos por paciente a validar.</div>
<div class="card-fields">
<span class="field">patient_discounts</span><span class="field">discount_pct</span><span class="field">discount_flat</span>
</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Documentos do paciente (tab 5)</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">DocumentsListPage embarcada no prontuario com prop embedded. Upload, preview, download, geracao de PDF, compartilhamento. Integrado com modulo de documentos completo.</div>
<div class="card-file">DocumentsListPage.vue (embedded)</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Flag de risco clinico</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Banner vermelho no prontuario quando risco_elevado = true. Obriga risco_nota e risco_sinalizado_por (CHECK constraint). Trigger registra na patient_timeline. View v_patients_risco para dashboard.</div>
<div class="card-fields">
<span class="field">risco_elevado</span><span class="field">risco_nota</span>
<span class="field">risco_sinalizado_em</span><span class="field">risco_sinalizado_por</span>
</div>
<div class="card-file">DB: trg_patient_risco_timeline + v_patients_risco</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Tabs do Prontuario</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">1. Perfil</span><span class="status">completo</span></div>
<div class="sidebar-item"><span class="dot dot-orange"></span><span class="label">2. Prontuario</span><span class="status">estrutura</span></div>
<div class="sidebar-item"><span class="dot dot-orange"></span><span class="label">3. Agenda</span><span class="status">estrutura</span></div>
<div class="sidebar-item"><span class="dot dot-orange"></span><span class="label">4. Financeiro</span><span class="status">estrutura</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">5. Documentos</span><span class="status">completo</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Metricas Sidebar</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">Total sessoes</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">Comparecimento %</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">LTV total</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">Dias sem sessao</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">Risk flag</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════ 4. REDE DE SUPORTE ═════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon cyan">4</div>
<div class="section-title">Rede de Suporte & Contatos</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Contatos de suporte (legado)</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">patient_support_contacts: nome, relacao, tipo (emergencia, familiar, profissional_saude, amigo, outro), telefone, email, flag is_primario. CRUD no cadastro do paciente.</div>
<div class="card-fields">
<span class="field">nome</span><span class="field">relacao</span><span class="field">tipo</span>
<span class="field">telefone</span><span class="field">email</span><span class="field">is_primario</span>
</div>
<div class="card-file">PatientsCadastroPage.vue &rarr; secao "Rede de Suporte"</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Contatos estruturados (novo)</div>
<span class="badge badge-partial">db pronto</span>
</div>
<div class="card-desc">patient_contacts: tabela mais completa que substitui campos legado (nome_parente, telefone_parente). Inclui CPF, especialidade, registro profissional (CRM/CRP). Unique constraint para contato primario. Migrada com dados legados. Frontend ainda usa patient_support_contacts.</div>
<div class="card-fields">
<span class="field">nome</span><span class="field">tipo</span><span class="field">cpf</span>
<span class="field">especialidade</span><span class="field">registro_profissional</span>
<span class="field">is_primario</span><span class="field">ativo</span>
</div>
<div class="card-file">DB: patient_contacts (migration_patients.sql) &mdash; frontend pendente</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Tabelas de Contatos</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">patient_support_contacts</span><span class="status">em uso</span></div>
<div class="sidebar-item"><span class="dot dot-orange"></span><span class="label">patient_contacts</span><span class="status">db ok</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Campos legado (patients)</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">nome_parente</span><span class="status">legado</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">telefone_parente</span><span class="status">legado</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">nome_responsavel</span><span class="status">legado</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════ 5. STATUS & TIMELINE ═══════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon pink">5</div>
<div class="section-title">Status, Timeline & Engajamento</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Gestao de status</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">6 status: Ativo, Em espera, Inativo, Alta, Encaminhado, Arquivado. Campos de saida: motivo_saida, data_saida, encaminhado_para. Historico automatico via trigger.</div>
<div class="card-fields">
<span class="field">status</span><span class="field">motivo_saida</span><span class="field">data_saida</span>
<span class="field">encaminhado_para</span>
</div>
<div class="card-file">DB: patient_status_history (trigger automatico)</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Historico de status</div>
<span class="badge badge-done">db pronto</span>
</div>
<div class="card-desc">Tabela imutavel patient_status_history. Registra automaticamente: status anterior, novo, motivo, encaminhamento, data saida, quem alterou. Trigger trg_patient_status_history.</div>
<div class="card-fields">
<span class="field">status_anterior</span><span class="field">status_novo</span><span class="field">motivo</span>
<span class="field">alterado_por</span><span class="field">alterado_em</span>
</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Timeline do paciente</div>
<span class="badge badge-done">db pronto</span>
</div>
<div class="card-desc">patient_timeline: feed cronologico com 18 tipos de evento. Auto-populada por triggers (status, risco, documentos, assinaturas). Cores por tipo. Referencia polimorfica para links.</div>
<div class="card-fields">
<span class="field">evento_tipo</span><span class="field">titulo</span><span class="field">descricao</span>
<span class="field">icone_cor</span><span class="field">link_ref_tipo</span><span class="field">link_ref_id</span>
</div>
<div class="card-file">DB: 18 event types + 3 triggers auto-insert</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> View de engajamento</div>
<span class="badge badge-done">db pronto</span>
</div>
<div class="card-desc">v_patient_engajamento: score 0-100 calculado em real-time. Metricas: total sessoes, sessoes ultimo mes, taxa comparecimento, LTV, ticket medio, cobrancas vencidas/pagas, taxa pagamentos em dia, duracao tratamento. Formula: 50% frequencia + 30% financeiro + 20% recencia.</div>
<div class="card-file">DB: VIEW v_patient_engajamento (security_invoker = on)</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> View de pacientes em risco</div>
<span class="badge badge-done">db pronto</span>
</div>
<div class="card-desc">v_patients_risco: lista pacientes que precisam de atencao. Criterios: risco_elevado, sem sessao ha 30+ dias, comparecimento &lt;60%, cobranca vencida. Alertas categorizados.</div>
<div class="card-file">DB: VIEW v_patients_risco</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> UI de timeline no prontuario</div>
<span class="badge badge-partial">pendente</span>
</div>
<div class="card-desc">A tabela patient_timeline e as views estao prontas no banco. Falta o componente frontend para exibir a timeline visualmente no prontuario do paciente (feed cronologico com icones e cores).</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Tabelas</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">patient_status_history</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">patient_timeline</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Views</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">v_patient_engajamento</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">v_patients_risco</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Triggers automaticos</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">status &rarr; history</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">status &rarr; timeline</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">risco &rarr; timeline</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">18 Eventos Timeline</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">primeira_sessao</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">sessao_realizada</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">status_alterado</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">risco_sinalizado</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">documento_adicionado</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">pagamento_recebido</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">+ 12 outros</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════ 6. FINANCEIRO ══════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon orange">6</div>
<div class="section-title">Financeiro & Convenios</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Convenio / plano de saude</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Associacao paciente &rarr; insurance_plans. Campo convenio (texto livre) + convenio_id (FK). Cadastro rapido de convenio via dialog. Exibido no prontuario como badge.</div>
<div class="card-fields">
<span class="field">convenio</span><span class="field">convenio_id</span><span class="field">insurance_plans</span>
</div>
<div class="card-file">CadastroRapidoConvenio.vue + PatientsCadastroPage.vue</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Metodo de pagamento preferido</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">PIX, cartao, dinheiro, deposito, convenio. CHECK constraint no banco. Selecionavel no cadastro.</div>
<div class="card-fields">
<span class="field">metodo_pagamento_preferido</span>
</div>
<div class="card-file">DB: CHECK (pix, cartao, dinheiro, deposito, convenio)</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Descontos por paciente</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">patient_discounts: desconto percentual ou valor fixo, motivo, periodo de validade (active_from, active_to). Gerenciavel pela listagem de pacientes.</div>
<div class="card-fields">
<span class="field">discount_pct</span><span class="field">discount_flat</span><span class="field">reason</span>
<span class="field">active_from</span><span class="field">active_to</span>
</div>
</div>
</div>
<div class="sidebar">
<div class="sidebar-title">Tabelas</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">insurance_plans</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">patient_discounts</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Validacao</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">metodo_pagamento CHECK</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">convenio_id FK</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════ PENDENCIAS ══════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon red">!</div>
<div class="section-title">Pendencias & Itens a Implementar</div>
</div>
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Prontuario clinico (tab 2) — notas de sessao e evolucao</div>
<span class="badge badge-partial">estrutura</span>
</div>
<div class="card-desc">Tab existe no modal mas sem conteudo clinico detalhado. Precisa: notas de sessao vinculadas a agenda_eventos, evolucao terapeutica, plano de tratamento, hipoteses diagnosticas.</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> UI da timeline no prontuario</div>
<span class="badge badge-partial">db pronto</span>
</div>
<div class="card-desc">Tabela patient_timeline com 18 event types e triggers automaticos existe. Falta componente frontend para exibir feed cronologico no prontuario (icones, cores, links para entidades referenciadas).</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Dashboard de engajamento e risco</div>
<span class="badge badge-partial">db pronto</span>
</div>
<div class="card-desc">Views v_patient_engajamento e v_patients_risco existem no banco. Score de engajamento (0-100) calculado em real-time. Falta UI para exibir dashboard de risco e engajamento geral dos pacientes.</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Migrar frontend para patient_contacts</div>
<span class="badge badge-partial">db pronto</span>
</div>
<div class="card-desc">Tabela patient_contacts (mais completa: CPF, especialidade, registro profissional) existe e foi populada com dados legados. Frontend ainda usa patient_support_contacts (mais simples). Migrar UI para usar a nova tabela.</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-red"></span> Lista de espera (patient_waitlist)</div>
<span class="badge badge-pending">nao existe</span>
</div>
<div class="card-desc">Tab placeholder na PatientsListPage. Nao existe tabela no banco. Precisa: criacao da tabela, service, UI com gestao de fila (posicao, prioridade, data de entrada, notificacao quando vaga abrir).</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Botao "+ Sessao" no prontuario</div>
<span class="badge badge-partial">placeholder</span>
</div>
<div class="card-desc">Botao existe no header do prontuario mas sem click handler. Precisa abrir dialog de agendamento rapido pre-preenchido com o paciente atual.</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Tabs Agenda e Financeiro no prontuario</div>
<span class="badge badge-partial">estrutura</span>
</div>
<div class="card-desc">Tabs existem no modal. Dados de agenda_eventos e patient_discounts carregam. Falta validar se a UI mostra corretamente: agendamentos futuros, historico completo, cobrancas, pagamentos, recibos.</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Migrations nao aplicadas</div>
<span class="badge badge-partial">verificar</span>
</div>
<div class="card-desc">Verificar se as migrations estao aplicadas no banco local: 20260328000002 (new columns), 20260328000003 (drop constraints), 20260328000004 (support_contacts), migration_patients.sql (timeline, contacts, views, risk). Algumas dependem de tabelas que podem nao existir ainda (insurance_plans, etc).</div>
</div>
</div>
<div class="note info" style="margin-top: 16px;">
<strong>Multi-tenant:</strong> Todas as queries filtram por owner_id (terapeuta individual) ou tenant_id (clinica). RLS no banco garante isolamento. Feature flags (patients.view, patients.create, patients.edit, patients.delete) controlam acesso por plano. Rotas admin usam meta tenantFeature: 'patients'.
</div>
<div class="note" style="margin-top: 10px;">
<strong>Escopo dual:</strong> patient_scope = 'clinic' (paciente da clinica, sem therapist_member_id) ou 'therapist' (paciente particular, com therapist_member_id obrigatorio). CHECK constraint garante consistencia.
</div>
</div>
</body>
</html>

View File

@@ -1,17 +1,21 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="pt-BR">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Agência PSI</title>
<title>Sakai Vue</title> <meta name="description" content="Plataforma para gestão clínica e atendimento psicológico." />
<link href="https://fonts.cdnfonts.com/css/lato" rel="stylesheet"> <link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="preconnect" href="https://fonts.cdnfonts.com" crossorigin />
<link href="https://fonts.cdnfonts.com/css/lato?display=swap" rel="stylesheet" />
<link href="/src/main.js" as="script" />
<meta name="theme-color" content="#fff" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

445
mvp-assessment.html Normal file
View File

@@ -0,0 +1,445 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AgenciaPsi — Avaliação MVP</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
padding: 2rem;
}
h1 { font-size: 1.8rem; font-weight: 700; color: #f8fafc; }
h2 { font-size: 1.1rem; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 1rem; }
.subtitle { color: #64748b; margin-top: .3rem; margin-bottom: 2rem; font-size: .95rem; }
.grid { display: grid; gap: 1.5rem; }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
.card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 1.5rem;
}
.kpi { text-align: center; }
.kpi .value { font-size: 2.5rem; font-weight: 800; line-height: 1; }
.kpi .label { font-size: .8rem; color: #64748b; margin-top: .4rem; text-transform: uppercase; letter-spacing: .06em; }
.green { color: #4ade80; }
.yellow { color: #fbbf24; }
.red { color: #f87171; }
.blue { color: #60a5fa; }
/* Progress bars */
.progress-list { display: flex; flex-direction: column; gap: .9rem; }
.progress-item { }
.progress-header { display: flex; justify-content: space-between; margin-bottom: .35rem; font-size: .88rem; }
.progress-label { color: #cbd5e1; }
.progress-pct { font-weight: 700; }
.bar-bg { background: #0f172a; border-radius: 999px; height: 8px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 999px; transition: width .6s ease; }
.fill-green { background: linear-gradient(90deg, #22c55e, #4ade80); }
.fill-yellow { background: linear-gradient(90deg, #d97706, #fbbf24); }
.fill-red { background: linear-gradient(90deg, #dc2626, #f87171); }
.fill-blue { background: linear-gradient(90deg, #2563eb, #60a5fa); }
/* Badges */
.badge { display: inline-block; padding: .2rem .6rem; border-radius: 999px; font-size: .75rem; font-weight: 600; }
.badge-green { background: #052e16; color: #4ade80; border: 1px solid #166534; }
.badge-yellow { background: #1c1508; color: #fbbf24; border: 1px solid #854d0e; }
.badge-red { background: #1c0a0a; color: #f87171; border: 1px solid #991b1b; }
/* Feature table */
.feature-row {
display: flex; align-items: center; justify-content: space-between;
padding: .6rem 0;
border-bottom: 1px solid #1e293b;
font-size: .88rem;
}
.feature-row:last-child { border-bottom: none; }
.feature-name { color: #cbd5e1; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: .5rem; flex-shrink: 0; }
.dot-green { background: #4ade80; }
.dot-yellow { background: #fbbf24; }
.dot-red { background: #f87171; }
/* Timeline */
.timeline { display: flex; flex-direction: column; gap: 0; }
.tl-item { display: flex; gap: 1rem; }
.tl-line { display: flex; flex-direction: column; align-items: center; }
.tl-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; margin-top: .2rem; }
.tl-connector { width: 2px; background: #334155; flex: 1; min-height: 1.5rem; }
.tl-item:last-child .tl-connector { display: none; }
.tl-content { padding-bottom: 1.2rem; }
.tl-title { font-size: .9rem; font-weight: 600; color: #f1f5f9; }
.tl-desc { font-size: .8rem; color: #64748b; margin-top: .2rem; line-height: 1.4; }
/* Donut center */
.chart-wrap { position: relative; }
.donut-center {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
text-align: center; pointer-events: none;
}
.donut-center .big { font-size: 2rem; font-weight: 800; color: #f8fafc; }
.donut-center .sm { font-size: .7rem; color: #64748b; text-transform: uppercase; letter-spacing: .06em; }
canvas { max-width: 100%; }
@media (max-width: 900px) {
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<h1>AgenciaPsi — Avaliação MVP</h1>
<p class="subtitle">Snapshot de 25 de março de 2026 &nbsp;·&nbsp; ~487 componentes Vue &nbsp;·&nbsp; v5.0.0</p>
<!-- KPIs -->
<div class="grid grid-4" style="margin-bottom:1.5rem">
<div class="card kpi">
<div class="value green">78%</div>
<div class="label">Pronto para MVP</div>
</div>
<div class="card kpi">
<div class="value blue">487</div>
<div class="label">Componentes Vue</div>
</div>
<div class="card kpi">
<div class="value green">10</div>
<div class="label">Módulos prontos</div>
</div>
<div class="card kpi">
<div class="value red">2</div>
<div class="label">Gaps críticos</div>
</div>
</div>
<!-- Donut + Progresso por módulo -->
<div class="grid grid-2" style="margin-bottom:1.5rem">
<div class="card">
<h2>Completude por Módulo</h2>
<div class="progress-list">
<div class="progress-item">
<div class="progress-header"><span class="progress-label">Autenticação & Permissões</span><span class="progress-pct green">100%</span></div>
<div class="bar-bg"><div class="bar-fill fill-green" style="width:100%"></div></div>
</div>
<div class="progress-item">
<div class="progress-header"><span class="progress-label">Agenda / Calendário</span><span class="progress-pct green">95%</span></div>
<div class="bar-bg"><div class="bar-fill fill-green" style="width:95%"></div></div>
</div>
<div class="progress-item">
<div class="progress-header"><span class="progress-label">Pacientes (CRUD + Prontuário)</span><span class="progress-pct green">90%</span></div>
<div class="bar-bg"><div class="bar-fill fill-green" style="width:90%"></div></div>
</div>
<div class="progress-item">
<div class="progress-header"><span class="progress-label">Financeiro</span><span class="progress-pct green">85%</span></div>
<div class="bar-bg"><div class="bar-fill fill-green" style="width:85%"></div></div>
</div>
<div class="progress-item">
<div class="progress-header"><span class="progress-label">Agendador Público</span><span class="progress-pct green">90%</span></div>
<div class="bar-bg"><div class="bar-fill fill-green" style="width:90%"></div></div>
</div>
<div class="progress-item">
<div class="progress-header"><span class="progress-label">Configurações (15 págs)</span><span class="progress-pct green">88%</span></div>
<div class="bar-bg"><div class="bar-fill fill-green" style="width:88%"></div></div>
</div>
<div class="progress-item">
<div class="progress-header"><span class="progress-label">WhatsApp / SMS / Email</span><span class="progress-pct yellow">75%</span></div>
<div class="bar-bg"><div class="bar-fill fill-yellow" style="width:75%"></div></div>
</div>
<div class="progress-item">
<div class="progress-header"><span class="progress-label">SaaS Admin</span><span class="progress-pct yellow">80%</span></div>
<div class="bar-bg"><div class="bar-fill fill-yellow" style="width:80%"></div></div>
</div>
<div class="progress-item">
<div class="progress-header"><span class="progress-label">Dashboard da Clínica</span><span class="progress-pct yellow">35%</span></div>
<div class="bar-bg"><div class="bar-fill fill-yellow" style="width:35%"></div></div>
</div>
<div class="progress-item">
<div class="progress-header"><span class="progress-label">Relatórios</span><span class="progress-pct yellow">40%</span></div>
<div class="bar-bg"><div class="bar-fill fill-yellow" style="width:40%"></div></div>
</div>
<div class="progress-item">
<div class="progress-header"><span class="progress-label">Gateway de Pagamento</span><span class="progress-pct red">10%</span></div>
<div class="bar-bg"><div class="bar-fill fill-red" style="width:10%"></div></div>
</div>
<div class="progress-item">
<div class="progress-header"><span class="progress-label">Testes Automatizados</span><span class="progress-pct red">15%</span></div>
<div class="bar-bg"><div class="bar-fill fill-red" style="width:15%"></div></div>
</div>
</div>
</div>
<div class="card" style="display:flex; flex-direction:column; gap:1.5rem">
<div>
<h2>Status Geral</h2>
<div class="chart-wrap" style="max-width:260px; margin:0 auto; position:relative">
<canvas id="donutChart" height="260"></canvas>
<div class="donut-center">
<div class="big">78%</div>
<div class="sm">MVP Ready</div>
</div>
</div>
</div>
<div>
<h2>Distribuição de Módulos</h2>
<canvas id="barChart" height="160"></canvas>
</div>
</div>
</div>
<!-- Features / gaps -->
<div class="grid grid-2" style="margin-bottom:1.5rem">
<div class="card">
<h2>Funcionalidades — Checklist</h2>
<div>
<!-- Prontos -->
<div class="feature-row"><span><span class="dot dot-green"></span><span class="feature-name">Autenticação (login, reset, sessão)</span></span><span class="badge badge-green">Pronto</span></div>
<div class="feature-row"><span><span class="dot dot-green"></span><span class="feature-name">Calendário + Recorrências</span></span><span class="badge badge-green">Pronto</span></div>
<div class="feature-row"><span><span class="dot dot-green"></span><span class="feature-name">CRUD de Pacientes + Prontuário</span></span><span class="badge badge-green">Pronto</span></div>
<div class="feature-row"><span><span class="dot dot-green"></span><span class="feature-name">Dashboard Financeiro</span></span><span class="badge badge-green">Pronto</span></div>
<div class="feature-row"><span><span class="dot dot-green"></span><span class="feature-name">Agendador Público (/agendar/:slug)</span></span><span class="badge badge-green">Pronto</span></div>
<div class="feature-row"><span><span class="dot dot-green"></span><span class="feature-name">15 Páginas de Configurações</span></span><span class="badge badge-green">Pronto</span></div>
<div class="feature-row"><span><span class="dot dot-green"></span><span class="feature-name">WhatsApp + SMS (Twilio)</span></span><span class="badge badge-green">Pronto</span></div>
<div class="feature-row"><span><span class="dot dot-green"></span><span class="feature-name">Templates de Email (Jodit)</span></span><span class="badge badge-green">Pronto</span></div>
<div class="feature-row"><span><span class="dot dot-green"></span><span class="feature-name">Multi-tenant</span></span><span class="badge badge-green">Pronto</span></div>
<div class="feature-row"><span><span class="dot dot-green"></span><span class="feature-name">Dark mode + 3 temas</span></span><span class="badge badge-green">Pronto</span></div>
<div class="feature-row"><span><span class="dot dot-green"></span><span class="feature-name">SaaS Admin (planos, assinaturas)</span></span><span class="badge badge-green">Pronto</span></div>
<!-- Parcial -->
<div class="feature-row"><span><span class="dot dot-yellow"></span><span class="feature-name">Dashboard da Clínica</span></span><span class="badge badge-yellow">Parcial</span></div>
<div class="feature-row"><span><span class="dot dot-yellow"></span><span class="feature-name">Relatórios</span></span><span class="badge badge-yellow">Parcial</span></div>
<div class="feature-row"><span><span class="dot dot-yellow"></span><span class="feature-name">Onboarding / Setup Wizard</span></span><span class="badge badge-yellow">Parcial</span></div>
<div class="feature-row"><span><span class="dot dot-yellow"></span><span class="feature-name">Validação de formulários (CPF/CNPJ)</span></span><span class="badge badge-yellow">Parcial</span></div>
<div class="feature-row"><span><span class="dot dot-yellow"></span><span class="feature-name">Mobile responsiveness</span></span><span class="badge badge-yellow">Não testado</span></div>
<!-- Faltando -->
<div class="feature-row"><span><span class="dot dot-red"></span><span class="feature-name">Gateway de Pagamento (Stripe)</span></span><span class="badge badge-red">Faltando</span></div>
<div class="feature-row"><span><span class="dot dot-red"></span><span class="feature-name">Testes E2E</span></span><span class="badge badge-red">Faltando</span></div>
<div class="feature-row"><span><span class="dot dot-red"></span><span class="feature-name">Export PDF / Excel</span></span><span class="badge badge-red">Faltando</span></div>
<div class="feature-row"><span><span class="dot dot-red"></span><span class="feature-name">Google Calendar sync</span></span><span class="badge badge-red">Faltando</span></div>
</div>
</div>
<div class="card">
<h2>Radar de Módulos</h2>
<canvas id="radarChart" height="300"></canvas>
</div>
</div>
<!-- Roadmap / Timeline -->
<div class="card" style="margin-bottom:1.5rem">
<h2>Roadmap para Lançamento</h2>
<div class="grid grid-3" style="gap:2rem">
<div>
<div style="display:flex; align-items:center; gap:.5rem; margin-bottom:1rem">
<span class="dot dot-red" style="width:10px;height:10px"></span>
<span style="font-weight:700; color:#f87171">Fase 1 — Antes do MVP</span>
<span style="color:#64748b; font-size:.8rem">(12 semanas)</span>
</div>
<div class="timeline">
<div class="tl-item">
<div class="tl-line"><div class="tl-dot" style="background:#ef4444"></div><div class="tl-connector"></div></div>
<div class="tl-content"><div class="tl-title">Gateway de Pagamento</div><div class="tl-desc">Integrar Stripe ou PagSeguro. Cobrança real de assinaturas e sessões avulsas.</div></div>
</div>
<div class="tl-item">
<div class="tl-line"><div class="tl-dot" style="background:#ef4444"></div><div class="tl-connector"></div></div>
<div class="tl-content"><div class="tl-title">Testes dos Fluxos Críticos</div><div class="tl-desc">Login → criar sessão → agendador público → cobrança.</div></div>
</div>
<div class="tl-item">
<div class="tl-line"><div class="tl-dot" style="background:#f97316"></div><div class="tl-connector"></div></div>
<div class="tl-content"><div class="tl-title">Dashboard da Clínica</div><div class="tl-desc">Expandir com KPIs reais (espelhar dashboard do terapeuta).</div></div>
</div>
<div class="tl-item">
<div class="tl-line"><div class="tl-dot" style="background:#f97316"></div><div class="tl-connector"></div></div>
<div class="tl-content"><div class="tl-title">Mobile — Teste em Produção</div><div class="tl-desc">Tailwind implementado mas nunca testado em dispositivos reais.</div></div>
</div>
<div class="tl-item">
<div class="tl-line"><div class="tl-dot" style="background:#eab308"></div></div>
<div class="tl-content"><div class="tl-title">Validação de Formulários</div><div class="tl-desc">CPF, CNPJ, telefone no cadastro de pacientes.</div></div>
</div>
</div>
</div>
<div>
<div style="display:flex; align-items:center; gap:.5rem; margin-bottom:1rem">
<span class="dot dot-yellow" style="width:10px;height:10px"></span>
<span style="font-weight:700; color:#fbbf24">Fase 2 — Pós-MVP</span>
<span style="color:#64748b; font-size:.8rem">(1 mês)</span>
</div>
<div class="timeline">
<div class="tl-item">
<div class="tl-line"><div class="tl-dot" style="background:#eab308"></div><div class="tl-connector"></div></div>
<div class="tl-content"><div class="tl-title">Relatórios PDF / Excel</div><div class="tl-desc">Export de lançamentos, sessões, e KPIs para relatórios mensais.</div></div>
</div>
<div class="tl-item">
<div class="tl-line"><div class="tl-dot" style="background:#eab308"></div><div class="tl-connector"></div></div>
<div class="tl-content"><div class="tl-title">Google Calendar Sync</div><div class="tl-desc">Sincronização bidirecional com o Google Calendar.</div></div>
</div>
<div class="tl-item">
<div class="tl-line"><div class="tl-dot" style="background:#eab308"></div><div class="tl-connector"></div></div>
<div class="tl-content"><div class="tl-title">Analytics / Tracking</div><div class="tl-desc">Rastrear adoção de features e comportamento de usuários.</div></div>
</div>
<div class="tl-item">
<div class="tl-line"><div class="tl-dot" style="background:#84cc16"></div></div>
<div class="tl-content"><div class="tl-title">Webhooks Twilio (entrada)</div><div class="tl-desc">Receber e processar mensagens WhatsApp de pacientes.</div></div>
</div>
</div>
</div>
<div>
<div style="display:flex; align-items:center; gap:.5rem; margin-bottom:1rem">
<span class="dot dot-green" style="width:10px;height:10px"></span>
<span style="font-weight:700; color:#4ade80">Fase 3 — Expansão</span>
<span style="color:#64748b; font-size:.8rem">(23 meses)</span>
</div>
<div class="timeline">
<div class="tl-item">
<div class="tl-line"><div class="tl-dot" style="background:#22c55e"></div><div class="tl-connector"></div></div>
<div class="tl-content"><div class="tl-title">App Mobile</div><div class="tl-desc">React Native ou Flutter para terapeuta e paciente.</div></div>
</div>
<div class="tl-item">
<div class="tl-line"><div class="tl-dot" style="background:#22c55e"></div><div class="tl-connector"></div></div>
<div class="tl-content"><div class="tl-title">Videochamada Integrada</div><div class="tl-desc">Sessões online sem sair da plataforma.</div></div>
</div>
<div class="tl-item">
<div class="tl-line"><div class="tl-dot" style="background:#22c55e"></div><div class="tl-connector"></div></div>
<div class="tl-content"><div class="tl-title">API REST Pública</div><div class="tl-desc">Integrações de terceiros (CRMs, plataformas de saúde).</div></div>
</div>
<div class="tl-item">
<div class="tl-line"><div class="tl-dot" style="background:#22c55e"></div></div>
<div class="tl-content"><div class="tl-title">IA / Prontuário Inteligente</div><div class="tl-desc">Sugestões de diagnóstico e análise de sessões.</div></div>
</div>
</div>
</div>
</div>
</div>
<!-- Tabela de rotas -->
<div class="card">
<h2>Cobertura de Rotas por Área</h2>
<canvas id="routesChart" height="100"></canvas>
</div>
<script>
// Donut
new Chart(document.getElementById('donutChart'), {
type: 'doughnut',
data: {
labels: ['Pronto', 'Parcial', 'Faltando'],
datasets: [{
data: [78, 14, 8],
backgroundColor: ['#22c55e', '#f59e0b', '#ef4444'],
borderWidth: 0,
hoverOffset: 6,
}]
},
options: {
cutout: '72%',
plugins: { legend: { display: false } },
animation: { animateScale: true }
}
});
// Bar — módulos
new Chart(document.getElementById('barChart'), {
type: 'bar',
data: {
labels: ['Auth', 'Agenda', 'Pacientes', 'Financ.', 'Agenda. Pub.', 'Config.', 'Comun.', 'SaaS', 'Dash. Clín.', 'Relat.', 'Pagto.', 'Testes'],
datasets: [{
data: [100, 95, 90, 85, 90, 88, 75, 80, 35, 40, 10, 15],
backgroundColor: [
'#22c55e','#22c55e','#22c55e','#22c55e','#22c55e','#22c55e',
'#f59e0b','#f59e0b',
'#f59e0b','#f59e0b',
'#ef4444','#ef4444'
],
borderRadius: 4,
}]
},
options: {
plugins: { legend: { display: false } },
scales: {
y: { min: 0, max: 100, ticks: { color: '#64748b', callback: v => v + '%' }, grid: { color: '#1e293b' } },
x: { ticks: { color: '#64748b', font: { size: 10 } }, grid: { display: false } }
}
}
});
// Radar
new Chart(document.getElementById('radarChart'), {
type: 'radar',
data: {
labels: ['Agenda', 'Pacientes', 'Financeiro', 'Comunicação', 'Pagamento', 'Testes', 'Relatórios', 'Mobile', 'Auth', 'Config'],
datasets: [
{
label: 'Implementado',
data: [95, 90, 85, 75, 10, 15, 40, 60, 100, 88],
borderColor: '#22c55e',
backgroundColor: 'rgba(34,197,94,0.15)',
pointBackgroundColor: '#22c55e',
borderWidth: 2,
},
{
label: 'Meta MVP',
data: [95, 90, 90, 80, 80, 70, 60, 80, 100, 88],
borderColor: '#60a5fa',
backgroundColor: 'rgba(96,165,250,0.05)',
pointBackgroundColor: '#60a5fa',
borderWidth: 1.5,
borderDash: [5, 5],
}
]
},
options: {
plugins: {
legend: {
labels: { color: '#94a3b8', font: { size: 11 } }
}
},
scales: {
r: {
min: 0, max: 100,
ticks: { display: false },
grid: { color: '#334155' },
pointLabels: { color: '#94a3b8', font: { size: 11 } },
angleLines: { color: '#334155' },
}
}
}
});
// Horizontal bar — rotas por área
new Chart(document.getElementById('routesChart'), {
type: 'bar',
data: {
labels: ['SaaS Admin', 'Configurações', 'Terapeuta', 'Clínica (Admin)', 'Auth', 'Público', 'Portal Paciente', 'Supervisor'],
datasets: [{
label: 'Nº de rotas',
data: [24, 15, 14, 13, 6, 5, 3, 3],
backgroundColor: ['#7c3aed','#2563eb','#059669','#0891b2','#d97706','#64748b','#db2777','#0d9488'],
borderRadius: 4,
}]
},
options: {
indexAxis: 'y',
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: '#64748b' }, grid: { color: '#334155' } },
y: { ticks: { color: '#cbd5e1' }, grid: { display: false } }
}
}
});
</script>
</body>
</html>

793
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,12 @@
"@supabase/supabase-js": "^2.95.3", "@supabase/supabase-js": "^2.95.3",
"chart.js": "3.3.2", "chart.js": "3.3.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"html-to-pdfmake": "^2.5.33",
"html2canvas-pro": "^2.0.2",
"html2pdf.js": "^0.14.0",
"jodit": "^4.11.15", "jodit": "^4.11.15",
"jspdf": "^4.2.1",
"pdfmake": "^0.3.7",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.5.4", "primevue": "^4.5.4",

View File

@@ -33,6 +33,10 @@ function isTenantArea(path = '') {
} }
// ── Setup Wizard redirect ──────────────────────────────────────── // ── Setup Wizard redirect ────────────────────────────────────────
// Cache por sessão: uma vez confirmado, não verifica de novo
let _setupClearedUid = null;
let _setupClearedIsClinic = null;
async function checkSetupWizard() { async function checkSetupWizard() {
if (!isTenantArea(route.path)) return; if (!isTenantArea(route.path)) return;
if (route.path.includes('/setup')) return; if (route.path.includes('/setup')) return;
@@ -40,19 +44,33 @@ async function checkSetupWizard() {
const uid = tenantStore.user?.id; const uid = tenantStore.user?.id;
if (!uid) return; if (!uid) return;
const { data } = await supabase.from('agenda_configuracoes').select('setup_concluido, setup_clinica_concluido').eq('owner_id', uid).maybeSingle(); const activeMembership = tenantStore.memberships?.find((m) => m.tenant_id === tenantStore.activeTenantId);
if (!data) return;
const activeMembership = tenantStore.memberships?.find((m) => m.id === tenantStore.activeTenantId);
const kind = activeMembership?.kind ?? tenantStore.activeRole ?? ''; const kind = activeMembership?.kind ?? tenantStore.activeRole ?? '';
const isClinic = kind.startsWith('clinic'); const isClinic = kind.startsWith('clinic');
const setupDone = isClinic ? data.setup_clinica_concluido : data.setup_concluido; // Se já confirmamos que este uid passou o setup, não verifica de novo
if (!setupDone) { if (_setupClearedUid === uid && _setupClearedIsClinic === isClinic) return;
const dest = isClinic ? '/admin/setup' : '/therapist/setup';
router.push(dest); const { data } = await supabase
.from('agenda_configuracoes')
.select('setup_concluido, setup_clinica_concluido, atendimento_mode')
.eq('owner_id', uid)
.maybeSingle();
if (!data) return; // sem linha = setup nunca iniciado, não redireciona
// Considera completo se qualquer flag de conclusão estiver setada
const setupDone = data.setup_concluido || data.setup_clinica_concluido || !!data.atendimento_mode;
if (setupDone) {
// Grava cache: não verifica mais nesta sessão
_setupClearedUid = uid;
_setupClearedIsClinic = isClinic;
return;
} }
const dest = isClinic ? '/admin/setup' : '/therapist/setup';
router.push(dest);
} }
onMounted(() => { onMounted(() => {

View File

@@ -273,3 +273,23 @@
.app-dark .p-datatable tr.row-new-highlight td { .app-dark .p-datatable tr.row-new-highlight td {
background-color: color-mix(in srgb, var(--primary-color) 20%, transparent) !important; background-color: color-mix(in srgb, var(--primary-color) 20%, transparent) !important;
} }
/* ── Agenda Preview ────────────────────────── */
.app-dark .fc-scrollgrid-section-sticky > * {
background: var(--surface-hover);
}
.app-dark .fc-theme-standard td,
.app-dark .fc-theme-standard th {
border: 1px solid var(--surface-border);
}
.app-dark .fc-theme-standard .fc-scrollgrid {
border: 1px solid var(--surface-border);
}
.app-dark .fc-timegrid-event-harness-inset .fc-timegrid-event,
.app-dark .fc-timegrid-event.fc-event-mirror,
.fc-timegrid-more-link {
box-shadow: 0 0 0 1px #000000;
}

View File

@@ -0,0 +1,351 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI CadastroRapidoConvenio.vue
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Componente de seleção e cadastro rápido de convênios.
| Usado dentro do PatientsCadastroPage na seção "Clínico & origem".
|
| Props:
| modelValue (String|null) id do insurance_plan selecionado
| visible (Boolean) controla visibilidade do dialog
|
| Emits:
| update:modelValue string id selecionado
| update:visible fecha o dialog
| selected { id, name, notes, default_value } do plano escolhido
|
| Tabela: public.insurance_plans
| id uuid, owner_id uuid, tenant_id uuid,
| name text, notes text, default_value numeric, active boolean
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
// ─────────────────────────────────────────────────────────
const props = defineProps({
modelValue: { type: String, default: null }, // id selecionado
visible: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'update:visible', 'selected'])
const toast = useToast()
const tenantStore = useTenantStore()
// ─────────────────────────────────────────────────────────
// Auth / tenant helpers
// ─────────────────────────────────────────────────────────
async function getOwnerId () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
if (!uid) throw new Error('Sessão inválida.')
return uid
}
async function getTenantId () {
const tid = tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
if (tid) return tid
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('tenant_members').select('tenant_id')
.eq('user_id', ownerId).eq('status', 'active')
.order('created_at', { ascending: false }).limit(1).single()
if (error) throw error
return data?.tenant_id
}
// ─────────────────────────────────────────────────────────
// Estado
// ─────────────────────────────────────────────────────────
const plans = ref([])
const loading = ref(false)
const searchTerm = ref('')
// Form de criação
const showForm = ref(false)
const saving = ref(false)
const formErr = ref('')
const newPlan = ref({ name: '', notes: '', default_value: '' })
// ─────────────────────────────────────────────────────────
// Computed
// ─────────────────────────────────────────────────────────
const filteredPlans = computed(() => {
const q = searchTerm.value.toLowerCase().trim()
if (!q) return plans.value
return plans.value.filter(p =>
p.name.toLowerCase().includes(q) ||
(p.notes||'').toLowerCase().includes(q)
)
})
const selectedPlan = computed(() =>
plans.value.find(p => p.id === props.modelValue) || null
)
// ─────────────────────────────────────────────────────────
// Load
// ─────────────────────────────────────────────────────────
async function loadPlans () {
loading.value = true
try {
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('insurance_plans')
.select('id, name, notes, default_value, active')
.eq('owner_id', ownerId)
.eq('active', true)
.order('name', { ascending: true })
if (error) throw error
plans.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar convênios.', life: 3500 })
} finally { loading.value = false }
}
watch(() => props.visible, (v) => {
if (v) { loadPlans(); showForm.value = false; searchTerm.value = ''; formErr.value = '' }
})
// ─────────────────────────────────────────────────────────
// Selecionar
// ─────────────────────────────────────────────────────────
function selectPlan (plan) {
emit('update:modelValue', plan.id)
emit('selected', plan)
close()
}
function clearSelection () {
emit('update:modelValue', null)
emit('selected', null)
close()
}
// ─────────────────────────────────────────────────────────
// Criar
// ─────────────────────────────────────────────────────────
function openForm () {
formErr.value = ''
newPlan.value = { name: '', notes: '', default_value: '' }
showForm.value = true
}
function cancelForm () {
showForm.value = false
formErr.value = ''
}
async function savePlan () {
const name = String(newPlan.value.name || '').trim()
if (!name) { formErr.value = 'Informe o nome do convênio.'; return }
saving.value = true; formErr.value = ''
try {
const ownerId = await getOwnerId()
const tenantId = await getTenantId()
const payload = {
owner_id: ownerId,
tenant_id: tenantId,
name,
notes: String(newPlan.value.notes || '').trim() || null,
default_value: newPlan.value.default_value !== '' ? Number(newPlan.value.default_value) : null,
active: true,
}
const { data, error } = await supabase
.from('insurance_plans').insert(payload)
.select('id, name, notes, default_value, active').single()
if (error) throw error
plans.value = [...plans.value, data].sort((a, b) => a.name.localeCompare(b.name))
toast.add({ severity: 'success', summary: 'Convênio criado', detail: `"${data.name}" adicionado.`, life: 2500 })
selectPlan(data)
} catch (e) {
const msg = e?.message || ''
formErr.value = /duplicate/i.test(msg) ? 'Já existe um convênio com esse nome.' : (msg || 'Falha ao criar.')
} finally { saving.value = false }
}
function close () { emit('update:visible', false) }
</script>
<template>
<Dialog
:visible="visible"
@update:visible="$emit('update:visible', $event)"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
maximizable
class="dc-dialog w-[36rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
}"
pt:mask:class="backdrop-blur-xs"
>
<!-- Header -->
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<span class="flex items-center justify-center w-7 h-7 rounded-lg bg-blue-100 text-blue-600 text-[0.8rem] shrink-0">
<i class="pi pi-shield"/>
</span>
<div class="min-w-0">
<div class="text-base font-semibold truncate">Convênios</div>
<div class="text-xs opacity-50">Selecione ou cadastre um novo</div>
</div>
</div>
</div>
</template>
<!-- Corpo -->
<div class="flex flex-col gap-0">
<!-- Selecionado atualmente -->
<div v-if="selectedPlan && !showForm" class="flex items-center gap-2 mb-3 p-2.5 rounded-lg bg-blue-50 border border-blue-200/60">
<i class="pi pi-check-circle text-blue-500 shrink-0"/>
<span class="text-[0.82rem] font-semibold text-blue-700 flex-1 truncate">{{ selectedPlan.name }}</span>
<button type="button" class="text-[0.7rem] text-blue-400 hover:text-blue-600 underline" @click="clearSelection">remover</button>
</div>
<!-- Form de criação inline -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
leave-active-class="transition-all duration-150 ease-in"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="showForm" class="mb-4 p-3.5 rounded-xl border border-blue-200/60 bg-blue-50/60">
<div class="flex items-center gap-2 mb-3">
<span class="text-[0.7rem] font-bold uppercase tracking-widest text-blue-500">Novo convênio</span>
<div class="flex-1 h-px bg-blue-200/50"/>
</div>
<div class="flex flex-col gap-3">
<div>
<FloatLabel variant="on">
<InputText id="cn_name" v-model="newPlan.name" class="w-full" variant="filled" autofocus @keydown.enter="savePlan"/>
<label for="cn_name">Nome do convênio *</label>
</FloatLabel>
<div class="mt-1 text-[0.65rem] text-blue-500/80">Ex: Unimed, Amil, Bradesco Saúde.</div>
</div>
<div>
<FloatLabel variant="on">
<InputText id="cn_notes" v-model="newPlan.notes" class="w-full" variant="filled" @keydown.enter="savePlan"/>
<label for="cn_notes">Observações (opcional)</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-dollar"/>
<InputNumber
id="cn_value"
v-model="newPlan.default_value"
class="w-full"
variant="filled"
:min="0"
:minFractionDigits="2"
:maxFractionDigits="2"
locale="pt-BR"
placeholder="0,00"
/>
</IconField>
<label for="cn_value">Valor padrão da sessão (opcional)</label>
</FloatLabel>
<div class="mt-1 text-[0.65rem] text-blue-500/80">Pré-preenchido ao criar sessão com este convênio.</div>
</div>
<div v-if="formErr" class="text-[0.8rem] text-red-500 flex items-center gap-1.5">
<i class="pi pi-exclamation-circle shrink-0"/>{{ formErr }}
</div>
<div class="flex gap-2 pt-1">
<Button label="Cancelar" severity="secondary" text class="flex-1 rounded-full hover:!text-red-500" @click="cancelForm"/>
<Button label="Salvar convênio" icon="pi pi-check" class="flex-1 rounded-full" :loading="saving" @click="savePlan"/>
</div>
</div>
</div>
</Transition>
<!-- Busca -->
<div v-if="!showForm" class="mb-3">
<IconField>
<InputIcon class="pi pi-search"/>
<InputText v-model="searchTerm" class="w-full" variant="filled" placeholder="Buscar convênio…"/>
</IconField>
</div>
<!-- Lista -->
<div v-if="!showForm" class="flex flex-col gap-1 max-h-[280px] overflow-y-auto pr-1">
<div v-if="loading" class="flex items-center justify-center py-8 gap-2 text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner"/> Carregando
</div>
<div v-else-if="!filteredPlans.length" class="text-center py-8">
<i class="pi pi-shield text-3xl text-[var(--text-color-secondary)] opacity-30 block mb-2"/>
<div class="text-[0.82rem] text-[var(--text-color-secondary)]">
{{ searchTerm ? 'Nenhum convênio encontrado.' : 'Nenhum convênio cadastrado ainda.' }}
</div>
</div>
<button
v-for="plan in filteredPlans" :key="plan.id"
type="button"
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-left border transition-all duration-100 w-full group"
:class="modelValue === plan.id
? 'bg-blue-500/10 border-blue-300/50 text-blue-700'
: 'border-transparent hover:bg-[var(--surface-ground)] text-[var(--text-color)]'"
@click="selectPlan(plan)"
>
<span
class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 text-[0.8rem] font-bold transition-colors"
:class="modelValue === plan.id
? 'bg-blue-200 text-blue-700'
: 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)] group-hover:bg-blue-100 group-hover:text-blue-600'"
>
{{ plan.name.slice(0,2).toUpperCase() }}
</span>
<div class="flex-1 min-w-0">
<div class="text-[0.88rem] font-semibold leading-tight truncate">{{ plan.name }}</div>
<div v-if="plan.notes" class="text-[0.7rem] text-[var(--text-color-secondary)] truncate mt-0.5">{{ plan.notes }}</div>
</div>
<div v-if="plan.default_value" class="text-[0.75rem] font-semibold text-emerald-600 shrink-0">
R$ {{ Number(plan.default_value).toLocaleString('pt-BR', { minimumFractionDigits: 2 }) }}
</div>
<i v-if="modelValue === plan.id" class="pi pi-check text-blue-500 shrink-0"/>
</button>
</div>
<!-- Botão cadastrar novo -->
<div v-if="!showForm && !loading" class="border-t border-[var(--surface-border)] mt-3 pt-3">
<Button
label="Cadastrar novo convênio"
icon="pi pi-plus"
severity="secondary"
outlined
class="rounded-full w-full"
@click="openForm"
/>
</div>
</div>
<!-- Footer -->
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button
label="Fechar"
severity="secondary"
text
class="rounded-full hover:!text-red-500"
:disabled="saving"
@click="close"
/>
</div>
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,646 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI CadastroRapidoMedico.vue
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Dialog de cadastro rápido de médicos / profissionais de referência.
| Usado em PatientsCadastroPage (campo "Encaminhado por") e acessível
| pela futura MedicosCadastroPage.
|
| Props:
| visible (Boolean)
|
| Emits:
| update:visible
| created objeto do médico recém-criado
| selected médico selecionado da lista (para preencher campo no form)
|
| Tabela: public.medicos (ver medicos.sql)
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { digitsOnly, fmtPhone } from '@/utils/validators'
// ─────────────────────────────────────────────────────────
const props = defineProps({
visible: { type: Boolean, default: false },
editId: { type: String, default: null }, // uuid do médico a editar (null = novo)
})
const emit = defineEmits(['update:visible', 'created', 'selected'])
const toast = useToast()
const tenantStore = useTenantStore()
// ─────────────────────────────────────────────────────────
// Auth / tenant
// ─────────────────────────────────────────────────────────
async function getOwnerId () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
if (!uid) throw new Error('Sessão inválida.')
return uid
}
async function getTenantId () {
const tid = tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
if (tid) return tid
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('tenant_members').select('tenant_id')
.eq('user_id', ownerId).eq('status', 'active')
.order('created_at', { ascending: false }).limit(1).single()
if (error) throw error
return data?.tenant_id
}
// ─────────────────────────────────────────────────────────
// Views: 'list' | 'create' | 'edit'
// ─────────────────────────────────────────────────────────
const view = ref('list')
const medicos = ref([])
const loading = ref(false)
const searchTerm = ref('')
const editingId = ref(null) // uuid do médico sendo editado
// Form
const saving = ref(false)
const formErr = ref('')
const showTelProfissional = ref(false)
const showTelPessoal = ref(false)
function resetForm () {
return {
nome: '',
crm: '',
especialidade: '',
especialidade_outra: '',
telefone_profissional: '',
telefone_pessoal: '',
email: '',
clinica: '',
cidade: '',
estado: 'SP',
observacoes: '',
}
}
const form = ref(resetForm())
// ─────────────────────────────────────────────────────────
// Especialidades
// ─────────────────────────────────────────────────────────
const especialidadesOpts = [
{ label: 'Psiquiatria', value: 'Psiquiatria' },
{ label: 'Neurologia', value: 'Neurologia' },
{ label: 'Neuropsiquiatria infantil', value: 'Neuropsiquiatria infantil' },
{ label: 'Clínica geral', value: 'Clínica geral' },
{ label: 'Pediatria', value: 'Pediatria' },
{ label: 'Geriatria', value: 'Geriatria' },
{ label: 'Endocrinologia', value: 'Endocrinologia' },
{ label: 'Psicologia (encaminhador)', value: 'Psicologia (encaminhador)' },
{ label: 'Assistência social', value: 'Assistência social' },
{ label: 'Fonoaudiologia', value: 'Fonoaudiologia' },
{ label: 'Terapia ocupacional', value: 'Terapia ocupacional' },
{ label: 'Fisioterapia', value: 'Fisioterapia' },
{ label: 'Outra', value: '__outra__' },
]
const especialidadeFinal = computed(() =>
form.value.especialidade === '__outra__'
? (form.value.especialidade_outra.trim() || null)
: (form.value.especialidade || null)
)
// ─────────────────────────────────────────────────────────
// Computed
// ─────────────────────────────────────────────────────────
const filteredMedicos = computed(() => {
const q = searchTerm.value.toLowerCase().trim()
if (!q) return medicos.value
return medicos.value.filter(m =>
(m.nome || '').toLowerCase().includes(q) ||
(m.especialidade || '').toLowerCase().includes(q) ||
(m.crm || '').toLowerCase().includes(q) ||
(m.clinica || '').toLowerCase().includes(q)
)
})
// ─────────────────────────────────────────────────────────
// Load
// ─────────────────────────────────────────────────────────
async function loadMedicos () {
loading.value = true
try {
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('medicos')
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
.eq('owner_id', ownerId)
.eq('ativo', true)
.order('nome', { ascending: true })
if (error) throw error
medicos.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar médicos.', life: 3500 })
} finally { loading.value = false }
}
watch(() => props.visible, async (v) => {
if (v) {
searchTerm.value = ''
formErr.value = ''
showTelProfissional.value = false
showTelPessoal.value = false
if (props.editId) {
// Abre direto no form de edição com os dados carregados
await loadMedicoForEdit(props.editId)
} else {
view.value = 'list'
loadMedicos()
}
}
})
async function loadMedicoForEdit (id) {
try {
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('medicos').select('*').eq('id', id).eq('owner_id', ownerId).single()
if (error) throw error
form.value = {
nome: data.nome || '',
crm: data.crm || '',
especialidade: data.especialidade || '',
especialidade_outra: '',
telefone_profissional: data.telefone_profissional ? fmtPhone(data.telefone_profissional) : '',
telefone_pessoal: data.telefone_pessoal ? fmtPhone(data.telefone_pessoal) : '',
email: data.email || '',
clinica: data.clinica || '',
cidade: data.cidade || '',
estado: data.estado || 'SP',
observacoes: data.observacoes || '',
}
editingId.value = id
view.value = 'edit'
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar médico.', life: 3000 })
view.value = 'list'
loadMedicos()
}
}
// ─────────────────────────────────────────────────────────
// Ações lista
// ─────────────────────────────────────────────────────────
function openCreate () {
form.value = resetForm()
formErr.value = ''
editingId.value = null
showTelProfissional.value = false
showTelPessoal.value = false
view.value = 'create'
}
function backToList () {
view.value = 'list'
formErr.value = ''
editingId.value = null
loadMedicos()
}
function selectMedico (m) {
emit('selected', m)
close()
}
// ─────────────────────────────────────────────────────────
// Salvar
// ─────────────────────────────────────────────────────────
async function saveMedico () {
const nome = String(form.value.nome || '').trim()
if (!nome) { formErr.value = 'Informe o nome do médico.'; return }
if (form.value.especialidade === '__outra__' && !form.value.especialidade_outra.trim()) {
formErr.value = 'Informe a especialidade.'; return
}
saving.value = true; formErr.value = ''
const isUpdate = !!editingId.value
try {
const ownerId = await getOwnerId()
const tenantId = await getTenantId()
const payload = {
owner_id: ownerId,
tenant_id: tenantId,
nome,
crm: String(form.value.crm || '').trim() || null,
especialidade: especialidadeFinal.value,
telefone_profissional: form.value.telefone_profissional ? digitsOnly(form.value.telefone_profissional) : null,
telefone_pessoal: form.value.telefone_pessoal ? digitsOnly(form.value.telefone_pessoal) : null,
email: String(form.value.email || '').trim() || null,
clinica: String(form.value.clinica || '').trim() || null,
cidade: String(form.value.cidade || '').trim() || null,
estado: String(form.value.estado || '').trim() || null,
observacoes: String(form.value.observacoes || '').trim() || null,
ativo: true,
}
let data
if (isUpdate) {
const { data: d, error } = await supabase
.from('medicos').update({ ...payload, updated_at: new Date().toISOString() })
.eq('id', editingId.value).eq('owner_id', ownerId)
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
.single()
if (error) throw error
data = d
toast.add({ severity: 'success', summary: 'Médico atualizado', detail: `Dr(a). ${data.nome} atualizado.`, life: 2500 })
} else {
const { data: d, error } = await supabase
.from('medicos').insert(payload)
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
.single()
if (error) throw error
data = d
toast.add({ severity: 'success', summary: 'Médico cadastrado', detail: `Dr(a). ${data.nome} adicionado.`, life: 2500 })
}
emit(isUpdate ? 'selected' : 'created', data)
emit('selected', data)
close()
} catch (e) {
const msg = e?.message || ''
if (e?.code === '23505' || /duplicate/i.test(msg)) {
formErr.value = 'Já existe um cadastro com este CRM para este profissional.'
} else {
formErr.value = msg || 'Falha ao salvar.'
}
} finally { saving.value = false }
}
function close () {
emit('update:visible', false)
}
</script>
<template>
<Dialog
:visible="visible"
@update:visible="$emit('update:visible', $event)"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
maximizable
class="dc-dialog w-[50rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
}"
pt:mask:class="backdrop-blur-xs"
>
<!-- Header -->
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<span class="flex items-center justify-center w-7 h-7 rounded-lg bg-teal-100 text-teal-600 text-[0.8rem] shrink-0">
<i class="pi pi-user-plus"/>
</span>
<div class="min-w-0">
<div class="text-base font-semibold truncate">Médicos &amp; referências</div>
<div class="text-xs opacity-50">
<template v-if="view === 'list'">Selecione ou cadastre um novo profissional</template>
<template v-else-if="editingId">Editar dados do médico</template>
<template v-else>Novo médico / profissional de referência</template>
</div>
</div>
</div>
</div>
</template>
<!--
VIEW: LISTA
-->
<div v-if="view === 'list'" class="flex flex-col -mt-1">
<!-- Busca -->
<div class="mb-3">
<IconField>
<InputIcon class="pi pi-search"/>
<InputText v-model="searchTerm" class="w-full" variant="filled" placeholder="Buscar por nome, especialidade, CRM…"/>
</IconField>
</div>
<!-- Lista -->
<div class="flex flex-col gap-1 max-h-[300px] overflow-y-auto pr-0.5">
<div v-if="loading" class="flex items-center justify-center py-8 gap-2 text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner"/> Carregando
</div>
<div v-else-if="!filteredMedicos.length" class="flex flex-col items-center py-8 gap-2 text-center">
<div class="w-12 h-12 rounded-full bg-teal-50 flex items-center justify-center">
<i class="pi pi-user-plus text-xl text-teal-300"/>
</div>
<div class="text-[0.82rem] text-[var(--text-color-secondary)]">
{{ searchTerm ? 'Nenhum médico encontrado.' : 'Nenhum médico cadastrado ainda.' }}
</div>
</div>
<button
v-for="m in filteredMedicos" :key="m.id"
type="button"
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-left border border-transparent hover:bg-[var(--surface-ground)] hover:border-teal-100 transition-all duration-100 w-full group"
@click="selectMedico(m)"
>
<!-- Iniciais -->
<div class="w-9 h-9 rounded-full bg-teal-100 flex items-center justify-center font-black text-[0.75rem] text-teal-700 shrink-0 group-hover:bg-teal-200 transition-colors select-none">
{{ (m.nome||'?').split(' ').filter(Boolean).map(w=>w[0].toUpperCase()).slice(0,2).join('') }}
</div>
<div class="flex-1 min-w-0">
<div class="text-[0.88rem] font-semibold text-[var(--text-color)] truncate leading-tight">
Dr(a). {{ m.nome }}
</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] truncate mt-0.5">
<template v-if="m.especialidade">{{ m.especialidade }}</template>
<template v-if="m.crm"> · CRM {{ m.crm }}</template>
<template v-if="m.clinica"> · {{ m.clinica }}</template>
<template v-if="m.cidade"> · {{ m.cidade }}<template v-if="m.estado">/{{ m.estado }}</template></template>
</div>
</div>
<i class="pi pi-chevron-right text-[0.68rem] text-[var(--text-color-secondary)] opacity-30 group-hover:opacity-70 shrink-0"/>
</button>
</div>
<div class="border-t border-[var(--surface-border)] mt-3 pt-3">
<Button
label="Cadastrar novo médico"
icon="pi pi-plus"
severity="secondary"
outlined
class="rounded-full w-full"
@click="openCreate"
/>
</div>
</div>
<!--
VIEW: CRIAR
-->
<div v-else class="flex flex-col gap-3.5 -mt-1">
<!-- Voltar -->
<button
type="button"
class="flex items-center gap-1.5 text-[0.77rem] text-[var(--text-color-secondary)] hover:text-teal-600 transition-colors w-fit"
@click="backToList"
>
<i class="pi pi-arrow-left text-[0.72rem]"/> Voltar para a lista
</button>
<!-- Nome + CRM -->
<div class="grid grid-cols-1 gap-3.5 sm:grid-cols-[1fr_150px]">
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user"/>
<InputText id="m_nome" v-model="form.nome" class="w-full" variant="filled" autofocus/>
</IconField>
<label for="m_nome">Nome completo *</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<InputText id="m_crm" v-model="form.crm" class="w-full" variant="filled"/>
<label for="m_crm">CRM (ex: 123456/SP)</label>
</FloatLabel>
</div>
</div>
<!-- Especialidade -->
<div>
<FloatLabel variant="on">
<Select
id="m_esp"
v-model="form.especialidade"
:options="especialidadesOpts"
optionLabel="label"
optionValue="value"
class="w-full"
variant="filled"
filter
filterPlaceholder="Buscar especialidade…"
/>
<label for="m_esp">Especialidade</label>
</FloatLabel>
</div>
<!-- Especialidade "Outra" aparece condicionalmente -->
<Transition
enter-active-class="transition-all duration-150 ease-out"
enter-from-class="opacity-0 -translate-y-1"
leave-active-class="transition-all duration-100 ease-in"
leave-to-class="opacity-0 -translate-y-1"
>
<div v-if="form.especialidade === '__outra__'">
<FloatLabel variant="on">
<InputText
id="m_esp_outra"
v-model="form.especialidade_outra"
class="w-full"
variant="filled"
placeholder="Descreva a especialidade"
/>
<label for="m_esp_outra">Qual especialidade? *</label>
</FloatLabel>
</div>
</Transition>
<!-- Divider contatos -->
<div class="flex items-center gap-2">
<span class="text-[0.63rem] font-bold uppercase tracking-widest text-teal-500">Contatos</span>
<div class="flex-1 h-px bg-teal-200/50"/>
</div>
<!-- Telefone profissional máscara normal, olho aparece quando preenchido -->
<div>
<div class="relative">
<InputMask
id="m_tel_prof"
v-model="form.telefone_profissional"
mask="(99) 99999-9999"
:unmask="false"
class="w-full"
:class="form.telefone_profissional ? 'pr-10' : ''"
variant="filled"
placeholder="(00) 00000-0000"
autocomplete="off"
/>
<!-- Olho renderiza quando dígitos preenchidos -->
<button
v-if="form.telefone_profissional?.replace(/\D/g,'').length >= 10"
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 transition-colors z-10"
:class="showTelProfissional ? 'text-teal-600' : 'text-[var(--text-color-secondary)] hover:text-teal-600'"
tabindex="-1"
:title="showTelProfissional ? 'Ocultar número' : 'Revelar número completo'"
@click="showTelProfissional = !showTelProfissional"
>
<i :class="showTelProfissional ? 'pi pi-eye-slash' : 'pi pi-eye'" class="text-[0.9rem]"/>
</button>
</div>
<!-- Número revelado abaixo do campo -->
<div
v-if="showTelProfissional && form.telefone_profissional"
class="mt-1.5 flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-teal-50 border border-teal-200/60"
>
<i class="pi pi-phone text-teal-500 text-[0.75rem] shrink-0"/>
<span class="text-[0.82rem] font-semibold text-teal-700 tracking-wide">{{ form.telefone_profissional }}</span>
</div>
<div v-else class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">
Número do consultório ou clínica.
</div>
</div>
<!-- Telefone pessoal mesma lógica -->
<div>
<div class="relative">
<InputMask
id="m_tel_pes"
v-model="form.telefone_pessoal"
mask="(99) 99999-9999"
:unmask="false"
class="w-full"
:class="form.telefone_pessoal ? 'pr-10' : ''"
variant="filled"
placeholder="(00) 00000-0000"
autocomplete="off"
/>
<button
v-if="form.telefone_pessoal?.replace(/\D/g,'').length >= 10"
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 transition-colors z-10"
:class="showTelPessoal ? 'text-teal-600' : 'text-[var(--text-color-secondary)] hover:text-teal-600'"
tabindex="-1"
:title="showTelPessoal ? 'Ocultar número' : 'Revelar número completo'"
@click="showTelPessoal = !showTelPessoal"
>
<i :class="showTelPessoal ? 'pi pi-eye-slash' : 'pi pi-eye'" class="text-[0.9rem]"/>
</button>
</div>
<div
v-if="showTelPessoal && form.telefone_pessoal"
class="mt-1.5 flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-teal-50 border border-teal-200/60"
>
<i class="pi pi-mobile text-teal-500 text-[0.75rem] shrink-0"/>
<span class="text-[0.82rem] font-semibold text-teal-700 tracking-wide">{{ form.telefone_pessoal }}</span>
</div>
<div v-else class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">
Pessoal / WhatsApp toque no olho para revelar após digitar.
</div>
</div>
<!-- Email -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-envelope"/>
<InputText id="m_email" v-model="form.email" class="w-full" variant="filled"/>
</IconField>
<label for="m_email">E-mail profissional</label>
</FloatLabel>
</div>
<!-- Divider localização -->
<div class="flex items-center gap-2">
<span class="text-[0.63rem] font-bold uppercase tracking-widest text-teal-500">Localização</span>
<div class="flex-1 h-px bg-teal-200/50"/>
</div>
<!-- Clínica + Cidade + UF -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-building"/>
<InputText id="m_clinica" v-model="form.clinica" class="w-full" variant="filled"/>
</IconField>
<label for="m_clinica">Clínica / Hospital</label>
</FloatLabel>
</div>
<div class="grid grid-cols-[1fr_90px] gap-3">
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-map-marker"/>
<InputText id="m_cidade" v-model="form.cidade" class="w-full" variant="filled"/>
</IconField>
<label for="m_cidade">Cidade</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<InputText id="m_uf" v-model="form.estado" class="w-full" variant="filled"/>
<label for="m_uf">UF</label>
</FloatLabel>
</div>
</div>
<!-- Observações -->
<div>
<FloatLabel variant="on">
<Textarea id="m_obs" v-model="form.observacoes" rows="2" class="w-full" variant="filled"/>
<label for="m_obs">Observações internas</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">
Ex: aceita WhatsApp, convênios atendidos, melhor horário.
</div>
</div>
<!-- Erro -->
<div v-if="formErr" class="flex items-start gap-1.5 text-[0.82rem] text-red-500 font-medium">
<i class="pi pi-exclamation-circle mt-0.5 shrink-0"/> {{ formErr }}
</div>
</div>
<!-- Footer -->
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button
v-if="view !== 'list'"
label="Cancelar"
severity="secondary"
text
class="rounded-full hover:!text-red-500"
:disabled="saving"
@click="backToList"
/>
<Button
v-else
label="Fechar"
severity="secondary"
text
class="rounded-full hover:!text-red-500"
@click="close"
/>
<Button
v-if="view !== 'list'"
:label="editingId ? 'Salvar alterações' : 'Salvar médico'"
icon="pi pi-check"
class="rounded-full"
:loading="saving"
@click="saveMedico"
/>
</div>
</template>
</Dialog>
</template>

View File

@@ -18,6 +18,7 @@
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useRoleGuard } from '@/composables/useRoleGuard'; import { useRoleGuard } from '@/composables/useRoleGuard';
import { isValidEmail, isValidPhone, sanitizeDigits } from '@/utils/validators';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
@@ -130,18 +131,8 @@ function close() {
function onHide() {} function onHide() {}
function isValidEmail(v) {
return /.+@.+\..+/.test(String(v || '').trim());
}
function isValidPhone(v) {
const digits = String(v || '').replace(/\D/g, '');
return digits.length === 10 || digits.length === 11;
}
function normalizePhoneDigits(v) { function normalizePhoneDigits(v) {
const digits = String(v || '').replace(/\D/g, ''); return sanitizeDigits(v);
return digits || null;
} }
async function getOwnerId() { async function getOwnerId() {

View File

@@ -36,7 +36,7 @@
/> />
</g> </g>
</svg> </svg>
<h4 class="font-medium text-3xl text-surface-900 dark:text-surface-0">SAKAI</h4> <h4 class="font-medium text-3xl text-surface-900 dark:text-surface-0">Agência PSI</h4>
</a> </a>
</div> </div>

View File

@@ -46,7 +46,7 @@ function smoothScroll(id) {
/> />
</g> </g>
</svg> </svg>
<span class="text-surface-900 dark:text-surface-0 font-medium text-2xl leading-normal mr-20">SAKAI</span> <span class="text-surface-900 dark:text-surface-0 font-medium text-2xl leading-normal mr-20">Agência PSI</span>
</a> </a>
<Button <Button
class="lg:hidden!" class="lg:hidden!"

View File

@@ -0,0 +1,203 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/ui/JoditEmailEditor.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { Jodit } from 'jodit/esm/index.js';
import 'jodit/es2021/jodit.min.css';
const props = defineProps({
modelValue: { type: String, default: '' },
minHeight: { type: Number, default: 150 },
// true → toolbar enxuta + botões ▣ de layout para header/footer
layoutButtons: { type: Boolean, default: false },
// URL da logo do tenant usada nos snippets de layout
logoUrl: { type: String, default: null }
});
const emit = defineEmits(['update:modelValue']);
const container = ref(null);
let jodit = null;
let _ignoreChange = false;
let _themeObserver = null;
// ── Dark mode ─────────────────────────────────────────────────
function isDark() {
return document.documentElement.classList.contains('app-dark');
}
// ── Snippets de layout ────────────────────────────────────────
function logoSnippet(url) {
return url
? `<img src="${url}" width="72" height="72" style="display:block;object-fit:contain;border-radius:4px;" alt="Logo" />`
: `<div style="width:72px;height:72px;background:#e5e7eb;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:10px;color:#9ca3af;">[logo]</div>`;
}
function snippetLogoLeft(logo) {
return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr>
<td width="88" valign="middle" style="padding-right:16px;">${logoSnippet(logo)}</td>
<td valign="middle"><p style="margin:0;font-size:14px;color:#374151;">Seu texto aqui</p></td>
</tr>
</table>`;
}
function snippetLogoRight(logo) {
return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr>
<td valign="middle" style="padding-right:16px;"><p style="margin:0;font-size:14px;color:#374151;">Seu texto aqui</p></td>
<td width="88" valign="middle" style="text-align:right;">${logoSnippet(logo)}</td>
</tr>
</table>`;
}
function snippetLogoCenter(logo) {
return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr>
<td align="center" style="padding-bottom:8px;">${logoSnippet(logo)}</td>
</tr>
<tr>
<td align="center"><p style="margin:0;font-size:14px;color:#374151;">Seu texto aqui</p></td>
</tr>
</table>`;
}
// ── Config Jodit ─────────────────────────────────────────────
function buildConfig() {
// Botões customizados de layout (somente nos editores de header/footer)
const layoutExtraButtons = props.layoutButtons
? [
{
name: 'layout-logo-left',
tooltip: 'Logo à esquerda, texto à direita',
text: '▣ Logo Esq.',
exec(editor) {
editor.selection.insertHTML(snippetLogoLeft(props.logoUrl));
}
},
{
name: 'layout-logo-right',
tooltip: 'Logo à direita, texto à esquerda',
text: '▣ Logo Dir.',
exec(editor) {
editor.selection.insertHTML(snippetLogoRight(props.logoUrl));
}
},
{
name: 'layout-logo-center',
tooltip: 'Logo centralizada, texto abaixo',
text: '▣ Logo Centro',
exec(editor) {
editor.selection.insertHTML(snippetLogoCenter(props.logoUrl));
}
}
]
: [];
// Toolbar enxuta para header/footer — sem hr, eraser, source
const layoutButtons = [
'bold', 'italic', 'underline', '|',
'font', 'fontsize', 'brush', '|',
'align', '|',
'link', '|',
'layout-logo-left', 'layout-logo-right', 'layout-logo-center'
];
// Toolbar completa para o corpo do e-mail
const bodyButtons = [
'bold', 'italic', 'underline', 'strikethrough', '|',
'ul', 'ol', '|',
'font', 'fontsize', 'brush', 'paragraph', '|',
'align', '|',
'link', 'table', '|',
'hr', 'eraser', '|',
'source'
];
return {
height: props.minHeight,
language: 'pt_br',
theme: isDark() ? 'dark' : 'default',
toolbarAdaptive: false,
toolbarSticky: false,
showCharsCounter: false,
showWordsCounter: false,
showXPathInStatusbar: false,
disablePlugins: ['about', 'stat'],
buttons: props.layoutButtons ? layoutButtons : bodyButtons,
extraButtons: layoutExtraButtons,
uploader: { insertImageAsBase64URI: false },
filebrowser: { ajax: { url: '' } }
};
}
// ── Init / destroy ────────────────────────────────────────────
function initJodit() {
if (jodit) {
jodit.destruct();
jodit = null;
}
jodit = Jodit.make(container.value, buildConfig());
if (props.modelValue) jodit.value = props.modelValue;
jodit.events.on('change', (content) => {
if (!_ignoreChange) emit('update:modelValue', content);
});
}
// ── Lifecycle ─────────────────────────────────────────────────
onMounted(() => {
initJodit();
// Recria o editor se o tema mudar enquanto o componente estiver montado
_themeObserver = new MutationObserver(() => {
const current = isDark() ? 'dark' : 'default';
if (jodit && jodit.o?.theme !== current) {
const saved = jodit.value;
initJodit();
if (saved) jodit.value = saved;
}
});
_themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
});
onBeforeUnmount(() => {
_themeObserver?.disconnect();
_themeObserver = null;
jodit?.destruct();
jodit = null;
});
watch(
() => props.modelValue,
(val) => {
if (!jodit) return;
if (jodit.value !== (val ?? '')) {
_ignoreChange = true;
jodit.value = val ?? '';
_ignoreChange = false;
}
}
);
// ── API exposta ───────────────────────────────────────────────
defineExpose({
insertHTML: (html) => jodit?.selection.insertHTML(html)
});
</script>
<template>
<div ref="container" />
</template>

View File

@@ -31,10 +31,8 @@ import { ref, computed } from 'vue';
import Popover from 'primevue/popover'; import Popover from 'primevue/popover';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import PatientCadastroDialog from './PatientCadastroDialog.vue';
const emit = defineEmits(['quick-create']); const emit = defineEmits(['quick-create', 'go-complete', 'show', 'hide']);
const showCadastroDialog = ref(false);
const toast = useToast(); const toast = useToast();
const popRef = ref(null); const popRef = ref(null);
@@ -83,7 +81,7 @@ function onQuickCreate() {
} }
function onGoComplete() { function onGoComplete() {
close(); close();
showCadastroDialog.value = true; emit('go-complete');
} }
async function copyLink() { async function copyLink() {
@@ -114,9 +112,7 @@ defineExpose({ toggle, close });
</script> </script>
<template> <template>
<PatientCadastroDialog v-model="showCadastroDialog" /> <Popover ref="popRef" @show="emit('show')" @hide="emit('hide')">
<Popover ref="popRef">
<div class="flex flex-col min-w-[230px]"> <div class="flex flex-col min-w-[230px]">
<!-- Cadastro rápido --> <!-- Cadastro rápido -->
<button class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="onQuickCreate"> <button class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="onQuickCreate">

View File

@@ -0,0 +1,183 @@
/**
* useFormValidation — composable para validação de formulários com PrimeVue
*
* Retorna funções de validação prontas para usar em :invalid e mensagens de erro.
*
* Uso:
* const { validateCPF, validatePhone, validateEmail, validateCEP } = useFormValidation()
*
* // No template:
* <InputText v-model="cpf" :invalid="errors.cpf" @blur="errors.cpf = !validateCPF(cpf).valid" />
* <small v-if="errors.cpf" class="p-error">{{ validateCPF(cpf).message }}</small>
*/
import {
isValidCPF,
isValidCNPJ,
isValidPhone,
isValidEmail,
isValidCEP,
fmtCPF,
fmtCNPJ,
fmtPhone,
fmtCEP,
sanitizeDigits,
toISODate,
digitsOnly,
} from '@/utils/validators'
export function useFormValidation() {
/** CPF — campo: `cpf` ou `cpf_responsavel` */
function validateCPF(v, { required = false } = {}) {
if (!v || digitsOnly(v).length === 0) {
return required
? { valid: false, message: 'CPF é obrigatório.' }
: { valid: true, message: '' }
}
if (!isValidCPF(v)) return { valid: false, message: 'CPF inválido.' }
return { valid: true, message: '' }
}
/** CNPJ */
function validateCNPJ(v, { required = false } = {}) {
if (!v || digitsOnly(v).length === 0) {
return required
? { valid: false, message: 'CNPJ é obrigatório.' }
: { valid: true, message: '' }
}
if (!isValidCNPJ(v)) return { valid: false, message: 'CNPJ inválido.' }
return { valid: true, message: '' }
}
/** Telefone — campos: `telefone`, `telefone_alternativo`, `telefone_parente`, `telefone_responsavel` */
function validatePhone(v, { required = false } = {}) {
if (!v || digitsOnly(v).length === 0) {
return required
? { valid: false, message: 'Telefone é obrigatório.' }
: { valid: true, message: '' }
}
if (!isValidPhone(v)) return { valid: false, message: 'Telefone inválido. Use (XX) XXXXX-XXXX.' }
return { valid: true, message: '' }
}
/** Email — campos: `email_principal`, `email_alternativo` */
function validateEmail(v, { required = false } = {}) {
if (!v || String(v).trim().length === 0) {
return required
? { valid: false, message: 'E-mail é obrigatório.' }
: { valid: true, message: '' }
}
if (!isValidEmail(v)) return { valid: false, message: 'E-mail inválido.' }
return { valid: true, message: '' }
}
/** CEP — campo: `cep` */
function validateCEP(v, { required = false } = {}) {
if (!v || digitsOnly(v).length === 0) {
return required
? { valid: false, message: 'CEP é obrigatório.' }
: { valid: true, message: '' }
}
if (!isValidCEP(v)) return { valid: false, message: 'CEP inválido. Use 00000-000.' }
return { valid: true, message: '' }
}
/** Nome completo — campo: `nome_completo` */
function validateNomeCompleto(v, { required = true, minWords = 2 } = {}) {
const s = String(v ?? '').trim()
if (!s) {
return required
? { valid: false, message: 'Nome completo é obrigatório.' }
: { valid: true, message: '' }
}
const words = s.split(/\s+/).filter(Boolean)
if (words.length < minWords) return { valid: false, message: 'Informe o nome completo (mínimo 2 palavras).' }
return { valid: true, message: '' }
}
/**
* Valida um objeto de formulário de paciente de uma só vez.
* Retorna { valid: boolean, errors: { campo: mensagem } }
*
* Exemplo:
* const { valid, errors } = validatePatientForm(form, { cpfRequired: false })
*/
function validatePatientForm(form, { cpfRequired = false, emailRequired = false, phoneRequired = false } = {}) {
const errors = {}
const nome = validateNomeCompleto(form.nome_completo)
if (!nome.valid) errors.nome_completo = nome.message
if (form.cpf || cpfRequired) {
const cpf = validateCPF(form.cpf, { required: cpfRequired })
if (!cpf.valid) errors.cpf = cpf.message
}
if (form.cpf_responsavel) {
const cpfResp = validateCPF(form.cpf_responsavel)
if (!cpfResp.valid) errors.cpf_responsavel = cpfResp.message
}
if (form.telefone || phoneRequired) {
const tel = validatePhone(form.telefone, { required: phoneRequired })
if (!tel.valid) errors.telefone = tel.message
}
if (form.telefone_alternativo) {
const telAlt = validatePhone(form.telefone_alternativo)
if (!telAlt.valid) errors.telefone_alternativo = telAlt.message
}
if (form.telefone_parente) {
const telPar = validatePhone(form.telefone_parente)
if (!telPar.valid) errors.telefone_parente = telPar.message
}
if (form.telefone_responsavel) {
const telResp = validatePhone(form.telefone_responsavel)
if (!telResp.valid) errors.telefone_responsavel = telResp.message
}
if (form.email_principal || emailRequired) {
const email = validateEmail(form.email_principal, { required: emailRequired })
if (!email.valid) errors.email_principal = email.message
}
if (form.email_alternativo) {
const emailAlt = validateEmail(form.email_alternativo)
if (!emailAlt.valid) errors.email_alternativo = emailAlt.message
}
if (form.cep) {
const cep = validateCEP(form.cep)
if (!cep.valid) errors.cep = cep.message
}
return { valid: Object.keys(errors).length === 0, errors }
}
return {
// Validadores individuais
validateCPF,
validateCNPJ,
validatePhone,
validateEmail,
validateCEP,
validateNomeCompleto,
// Validação completa do formulário de paciente
validatePatientForm,
// Re-exporta formatadores para usar junto
fmtCPF,
fmtCNPJ,
fmtPhone,
fmtCEP,
// Re-exporta utilitários
sanitizeDigits,
toISODate,
digitsOnly,
}
}

View File

@@ -0,0 +1,297 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/documents/DocumentTemplatesPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Menu from 'primevue/menu'
import { useDocumentTemplates } from './composables/useDocumentTemplates'
import DocumentTemplateEditor from './components/DocumentTemplateEditor.vue'
const toast = useToast()
const confirm = useConfirm()
const {
templates, loading, error,
globalTemplates, tenantTemplates,
TIPOS_TEMPLATE,
fetchTemplates, create, update, remove, duplicate
} = useDocumentTemplates()
// ── Views ───────────────────────────────────────────────────
const view = ref('list') // list | create | edit
const editingTemplate = ref({})
const editingId = ref(null)
// ── Mobile menu ─────────────────────────────────────────────
const mobileMenuRef = ref(null)
const mobileMenuItems = [
{ label: 'Novo template', icon: 'pi pi-plus', command: () => openCreate() },
{ separator: true },
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchTemplates(true) }
]
// ── Lifecycle ───────────────────────────────────────────────
onMounted(() => fetchTemplates(true))
// ── Acoes ───────────────────────────────────────────────────
function openCreate() {
editingId.value = null
editingTemplate.value = {}
view.value = 'create'
}
function openEdit(tpl) {
if (tpl.is_global) {
toast.add({ severity: 'warn', summary: 'Somente leitura', detail: 'Templates padrão não podem ser editados. Duplique para personalizar.', life: 3000 })
return
}
editingId.value = tpl.id
editingTemplate.value = { ...tpl }
view.value = 'edit'
}
async function onSave(payload) {
try {
if (view.value === 'create') {
await create(payload)
toast.add({ severity: 'success', summary: 'Criado', detail: payload.nome_template, life: 3000 })
} else {
await update(editingId.value, payload)
toast.add({ severity: 'success', summary: 'Salvo', detail: payload.nome_template, life: 3000 })
}
view.value = 'list'
fetchTemplates(true)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
}
}
function onDuplicate(tpl) {
confirm.require({
message: `Deseja copiar "${tpl.nome_template}" para os seus templates? Você poderá editá-lo livremente.`,
header: 'Duplicar template',
icon: 'pi pi-copy',
acceptLabel: 'Copiar',
rejectLabel: 'Cancelar',
accept: async () => {
try {
await duplicate(tpl.id)
toast.add({ severity: 'success', summary: 'Duplicado', detail: `"${tpl.nome_template}" copiado para Meus Templates.`, life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
}
}
})
}
function onDelete(tpl) {
confirm.require({
message: `Desativar template "${tpl.nome_template}"?`,
header: 'Confirmar',
icon: 'pi pi-exclamation-triangle',
accept: async () => {
try {
await remove(tpl.id)
toast.add({ severity: 'success', summary: 'Desativado', life: 2000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
}
}
})
}
function onCancel() {
view.value = 'list'
}
// ── Tipo label ──────────────────────────────────────────────
function tipoLabel(tipo) {
return TIPOS_TEMPLATE.find(t => t.value === tipo)?.label || tipo
}
// ── Template card menu ──────────────────────────────────────
function getCardMenuItems(tpl) {
const items = [
{ label: 'Duplicar', icon: 'pi pi-copy', command: () => onDuplicate(tpl) }
]
if (!tpl.is_global) {
items.push(
{ label: 'Editar', icon: 'pi pi-pencil', command: () => openEdit(tpl) },
{ separator: true },
{ label: 'Desativar', icon: 'pi pi-trash', class: 'text-red-500', command: () => onDelete(tpl) }
)
}
return items
}
</script>
<template>
<div class="px-4 py-6 max-w-[1200px] mx-auto">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<div>
<div class="flex items-center gap-2">
<Button
v-if="view !== 'list'"
icon="pi pi-arrow-left"
text
rounded
size="small"
@click="view = 'list'"
/>
<h1 class="text-xl font-bold">
<template v-if="view === 'list'">Templates de documentos</template>
<template v-else-if="view === 'create'">Novo template</template>
<template v-else>Editar template</template>
</h1>
</div>
<p v-if="view === 'list'" class="text-sm text-[var(--text-color-secondary)]">
Modelos para declarações, atestados, recibos e outros documentos
</p>
</div>
<div v-if="view === 'list'" class="hidden sm:flex items-center gap-2">
<Button label="Novo template" icon="pi pi-plus" size="small" @click="openCreate" />
</div>
<div v-if="view === 'list'" class="sm:hidden">
<Button icon="pi pi-ellipsis-v" text rounded @click="mobileMenuRef.toggle($event)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
<!-- List view -->
<template v-if="view === 'list'">
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
</div>
<!-- Empty -->
<div v-else-if="!templates.length" class="flex flex-col items-center justify-center py-16 text-[var(--text-color-secondary)]">
<i class="pi pi-file-edit text-4xl opacity-30 mb-3" />
<div class="text-sm mb-1">Nenhum template encontrado.</div>
<Button label="Criar primeiro template" icon="pi pi-plus" text size="small" class="mt-2" @click="openCreate" />
</div>
<template v-else>
<!-- Templates globais (padrao) -->
<div v-if="globalTemplates.length" class="mb-6">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">
Templates padrão do sistema
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div
v-for="tpl in globalTemplates"
:key="tpl.id"
class="group relative flex flex-col p-4 rounded-lg border border-[var(--surface-border)] hover:border-[var(--surface-400)] hover:bg-[var(--surface-hover)] transition-all cursor-pointer"
@click="onDuplicate(tpl)"
>
<div class="flex items-start gap-3">
<span class="flex-shrink-0 w-9 h-9 rounded-lg bg-blue-500/10 flex items-center justify-center">
<i class="pi pi-file text-blue-500" />
</span>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium">{{ tpl.nome_template }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">{{ tipoLabel(tpl.tipo) }}</div>
<div v-if="tpl.descricao" class="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-2">{{ tpl.descricao }}</div>
</div>
</div>
<span class="absolute top-2 right-2 text-[0.6rem] px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-600">
padrão
</span>
<div class="mt-2 text-[0.65rem] text-[var(--text-color-secondary)] opacity-0 group-hover:opacity-100 transition-opacity">
Clique para duplicar e personalizar
</div>
</div>
</div>
</div>
<!-- Templates do tenant -->
<div v-if="tenantTemplates.length">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">
Meus templates
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div
v-for="tpl in tenantTemplates"
:key="tpl.id"
class="group relative flex flex-col p-4 rounded-lg border border-[var(--surface-border)] hover:border-primary hover:bg-primary/5 transition-all cursor-pointer"
@click="openEdit(tpl)"
>
<div class="flex items-start gap-3">
<span class="flex-shrink-0 w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
<i class="pi pi-file-edit text-primary" />
</span>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium">{{ tpl.nome_template }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">{{ tipoLabel(tpl.tipo) }}</div>
<div v-if="tpl.descricao" class="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-2">{{ tpl.descricao }}</div>
</div>
</div>
<!-- Menu de acoes -->
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
icon="pi pi-ellipsis-v"
text
rounded
size="small"
class="!w-7 !h-7"
@click.stop="$refs[`menu_${tpl.id}`]?.[0]?.toggle($event)"
/>
<Menu :ref="`menu_${tpl.id}`" :model="getCardMenuItems(tpl)" :popup="true" />
</div>
<div class="flex items-center gap-2 mt-2">
<span
v-if="!tpl.ativo"
class="text-[0.6rem] px-1.5 py-0.5 rounded-full bg-red-500/10 text-red-500"
>
inativo
</span>
<span class="text-[0.6rem] text-[var(--text-color-secondary)]">
{{ tpl.variaveis?.length || 0 }} variáveis
</span>
</div>
</div>
</div>
</div>
</template>
</template>
<!-- Create / Edit view -->
<template v-if="view === 'create' || view === 'edit'">
<DocumentTemplateEditor
v-model="editingTemplate"
:mode="view"
@save="onSave"
@cancel="onCancel"
/>
</template>
<ConfirmDialog />
</div>
</template>

View File

@@ -0,0 +1,377 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/documents/DocumentsListPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Menu from 'primevue/menu'
import { useDocuments } from './composables/useDocuments'
import DocumentCard from './components/DocumentCard.vue'
import DocumentUploadDialog from './components/DocumentUploadDialog.vue'
import DocumentPreviewDialog from './components/DocumentPreviewDialog.vue'
import DocumentGenerateDialog from './components/DocumentGenerateDialog.vue'
import DocumentSignatureDialog from './components/DocumentSignatureDialog.vue'
import DocumentShareDialog from './components/DocumentShareDialog.vue'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
// ── Props (pode receber patientId via route ou prop) ────────
const props = defineProps({
patientId: { type: String, default: null },
patientName: { type: String, default: '' },
embedded: { type: Boolean, default: false }
})
const resolvedPatientId = computed(() => props.patientId || route.params.id || null)
const {
documents, loading, error, filters, usedTags, stats,
TIPOS_DOCUMENTO,
fetchDocuments, upload, update, remove, restore,
download, getPreviewUrl, fetchUsedTags, clearFilters,
formatSize, mimeIcon
} = useDocuments(() => resolvedPatientId.value)
// ── Dialogs ─────────────────────────────────────────────────
const uploadDlg = ref(false)
const previewDlg = ref(false)
const generateDlg = ref(false)
const signatureDlg = ref(false)
const shareDlg = ref(false)
const selectedDoc = ref(null)
const previewUrl = ref('')
// ── Mobile menu ─────────────────────────────────────────────
const mobileMenuRef = ref(null)
const mobileMenuItems = computed(() => [
{ label: 'Upload', icon: 'pi pi-upload', command: () => uploadDlg.value = true },
{ label: 'Gerar documento', icon: 'pi pi-file-pdf', command: () => generateDlg.value = true },
{ separator: true },
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchDocuments() }
])
// ── Hero sticky ─────────────────────────────────────────────
const headerEl = ref(null)
const headerStuck = ref(false)
// ── Lifecycle ───────────────────────────────────────────────
onMounted(async () => {
await Promise.all([fetchDocuments(), fetchUsedTags()])
})
// ── Acoes ───────────────────────────────────────────────────
async function onUploaded({ file, meta }) {
try {
await upload(file, resolvedPatientId.value, meta)
toast.add({ severity: 'success', summary: 'Enviado', detail: file.name, life: 3000 })
fetchUsedTags()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro no upload', detail: e?.message })
}
}
async function onPreview(doc) {
selectedDoc.value = doc
try {
previewUrl.value = await getPreviewUrl(doc)
} catch {
previewUrl.value = ''
}
previewDlg.value = true
}
function onDownload(doc) {
download(doc)
}
function onEdit(doc) {
selectedDoc.value = doc
// TODO: abrir dialog de edicao de metadados
toast.add({ severity: 'info', summary: 'Em breve', detail: 'Edição de metadados será implementada.', life: 2000 })
}
function onDelete(doc) {
confirm.require({
message: `Excluir "${doc.nome_original}"? O arquivo será retido por 5 anos conforme LGPD/CFP.`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: async () => {
try {
await remove(doc.id)
toast.add({ severity: 'success', summary: 'Excluído', detail: doc.nome_original, life: 2000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
}
}
})
}
function onShare(doc) {
selectedDoc.value = doc
shareDlg.value = true
}
function onSign(doc) {
selectedDoc.value = doc
signatureDlg.value = true
}
function onGenerated() {
fetchDocuments()
}
// ── Computed: filtro ativo ───────────────────────────────────
const hasActiveFilter = computed(() =>
filters.value.tipo_documento || filters.value.tag || filters.value.search
)
// ── Watch filtros ───────────────────────────────────────────
watch(filters, () => fetchDocuments(), { deep: true })
</script>
<template>
<div :class="embedded ? '' : 'px-4 py-6 max-w-[1200px] mx-auto'">
<!-- Hero header -->
<div
v-if="!embedded"
ref="headerEl"
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6"
>
<div>
<h1 class="text-xl font-bold">Documentos</h1>
<p class="text-sm text-[var(--text-color-secondary)]">
{{ resolvedPatientId ? patientName || 'Paciente' : 'Todos os pacientes' }}
</p>
</div>
<!-- Desktop actions -->
<div class="hidden sm:flex items-center gap-2">
<Button
label="Gerar documento"
icon="pi pi-file-pdf"
outlined
size="small"
@click="generateDlg = true"
:disabled="!resolvedPatientId"
/>
<Button
label="Upload"
icon="pi pi-upload"
size="small"
@click="uploadDlg = true"
:disabled="!resolvedPatientId"
/>
</div>
<!-- Mobile menu -->
<div class="sm:hidden">
<Button icon="pi pi-ellipsis-v" text rounded @click="mobileMenuRef.toggle($event)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
<!-- Embedded header (dentro do prontuario) -->
<div v-else class="flex items-center justify-between gap-2 mb-4">
<span class="text-sm font-semibold text-[var(--text-color-secondary)] uppercase tracking-wider">Documentos</span>
<div class="flex gap-1.5">
<Button
icon="pi pi-file-pdf"
text
rounded
size="small"
v-tooltip.top="'Gerar documento'"
@click="generateDlg = true"
/>
<Button
icon="pi pi-upload"
text
rounded
size="small"
v-tooltip.top="'Upload'"
@click="uploadDlg = true"
/>
</div>
</div>
<!-- Quick stats -->
<div v-if="!embedded && documents.length" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<span class="text-lg font-bold">{{ stats.total }}</span>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Total</span>
</div>
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<span class="text-lg font-bold">{{ formatSize(stats.tamanhoTotal) }}</span>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Tamanho</span>
</div>
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<span class="text-lg font-bold">{{ Object.keys(stats.porTipo).length }}</span>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Tipos</span>
</div>
<div v-if="stats.pendentesRevisao" class="flex flex-col items-center p-3 rounded-lg bg-amber-500/5 border border-amber-500/20">
<span class="text-lg font-bold text-amber-600">{{ stats.pendentesRevisao }}</span>
<span class="text-[0.65rem] text-amber-600 uppercase tracking-wider">Pendentes</span>
</div>
</div>
<!-- Filtros -->
<div class="flex flex-wrap items-center gap-2 mb-4">
<IconField>
<InputIcon class="pi pi-search" />
<InputText
v-model="filters.search"
placeholder="Buscar..."
class="!w-[200px]"
size="small"
/>
</IconField>
<Select
v-model="filters.tipo_documento"
:options="TIPOS_DOCUMENTO"
optionLabel="label"
optionValue="value"
placeholder="Tipo"
showClear
class="!w-[160px]"
size="small"
/>
<Select
v-if="usedTags.length"
v-model="filters.tag"
:options="usedTags.map(t => ({ label: t, value: t }))"
optionLabel="label"
optionValue="value"
placeholder="Tag"
showClear
class="!w-[140px]"
size="small"
/>
<Button
v-if="hasActiveFilter"
icon="pi pi-filter-slash"
text
rounded
size="small"
v-tooltip.top="'Limpar filtros'"
@click="clearFilters(); fetchDocuments()"
/>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
</div>
<!-- Empty -->
<div v-else-if="!documents.length" class="flex flex-col items-center justify-center py-16 text-[var(--text-color-secondary)]">
<i class="pi pi-inbox text-4xl opacity-30 mb-3" />
<div class="text-sm mb-1">
{{ hasActiveFilter ? 'Nenhum documento encontrado com esses filtros.' : 'Nenhum documento ainda.' }}
</div>
<Button
v-if="resolvedPatientId && !hasActiveFilter"
label="Enviar primeiro documento"
icon="pi pi-upload"
text
size="small"
class="mt-2"
@click="uploadDlg = true"
/>
</div>
<!-- Lista de documentos -->
<div v-else class="flex flex-col gap-2">
<DocumentCard
v-for="doc in documents"
:key="doc.id"
:doc="doc"
@preview="onPreview"
@download="onDownload"
@edit="onEdit"
@delete="onDelete"
@share="onShare"
@sign="onSign"
/>
</div>
<!-- Error -->
<div v-if="error" class="mt-4 p-3 rounded-lg bg-red-500/5 border border-red-500/20 text-sm text-red-500">
<i class="pi pi-exclamation-circle mr-1" /> {{ error }}
</div>
<!-- Dialogs -->
<DocumentUploadDialog
:visible="uploadDlg"
@update:visible="uploadDlg = $event"
:patientId="resolvedPatientId"
:patientName="patientName"
:usedTags="usedTags"
@uploaded="onUploaded"
/>
<DocumentPreviewDialog
:visible="previewDlg"
@update:visible="previewDlg = $event"
:doc="selectedDoc"
:previewUrl="previewUrl"
@download="onDownload"
@edit="onEdit"
@delete="d => { previewDlg = false; onDelete(d) }"
@share="d => { previewDlg = false; onShare(d) }"
@sign="d => { previewDlg = false; onSign(d) }"
/>
<DocumentGenerateDialog
:visible="generateDlg"
@update:visible="generateDlg = $event"
:patientId="resolvedPatientId"
:patientName="patientName"
@generated="onGenerated"
/>
<DocumentSignatureDialog
:visible="signatureDlg"
@update:visible="signatureDlg = $event"
:doc="selectedDoc"
/>
<DocumentShareDialog
:visible="shareDlg"
@update:visible="shareDlg = $event"
:doc="selectedDoc"
/>
<ConfirmDialog />
</div>
</template>

View File

@@ -0,0 +1,159 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Arquivo: src/features/documents/components/DocumentCard.vue
| Card reutilizavel de documento thumbnail, nome, tipo, data, tags, acoes.
|--------------------------------------------------------------------------
-->
<script setup>
import { computed } from 'vue'
const props = defineProps({
doc: { type: Object, required: true },
selected: { type: Boolean, default: false }
})
const emit = defineEmits(['preview', 'download', 'edit', 'delete', 'share', 'sign'])
// ── Helpers ──────────────────────────────────────────────────
const mimeIcon = computed(() => {
const m = String(props.doc.mime_type || '')
if (m.startsWith('image/')) return 'pi pi-image'
if (m === 'application/pdf') return 'pi pi-file-pdf'
if (m.includes('word') || m.includes('document')) return 'pi pi-file-word'
if (m.includes('excel') || m.includes('sheet')) return 'pi pi-file-excel'
return 'pi pi-file'
})
const mimeColor = computed(() => {
const m = String(props.doc.mime_type || '')
if (m.startsWith('image/')) return 'bg-purple-500/10 text-purple-500'
if (m === 'application/pdf') return 'bg-red-500/10 text-red-500'
if (m.includes('word')) return 'bg-blue-500/10 text-blue-500'
if (m.includes('excel')) return 'bg-green-500/10 text-green-500'
return 'bg-gray-500/10 text-gray-500'
})
const tipoLabel = computed(() => {
const map = {
laudo: 'Laudo', receita: 'Receita', exame: 'Exame',
termo_assinado: 'Termo', relatorio_externo: 'Relatório',
identidade: 'Identidade', convenio: 'Convênio',
declaracao: 'Declaração', atestado: 'Atestado',
recibo: 'Recibo', outro: 'Outro'
}
return map[props.doc.tipo_documento] || 'Documento'
})
const formattedSize = computed(() => {
const b = props.doc.tamanho_bytes
if (!b) return '—'
if (b < 1024) return b + ' B'
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
return (b / (1024 * 1024)).toFixed(1) + ' MB'
})
const formattedDate = computed(() => {
const d = props.doc.uploaded_at
if (!d) return '—'
return new Date(d).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' })
})
const isImage = computed(() => String(props.doc.mime_type || '').startsWith('image/'))
const menuItems = computed(() => [
{ label: 'Visualizar', icon: 'pi pi-eye', command: () => emit('preview', props.doc) },
{ label: 'Baixar', icon: 'pi pi-download', command: () => emit('download', props.doc) },
{ label: 'Editar', icon: 'pi pi-pencil', command: () => emit('edit', props.doc) },
{ label: 'Compartilhar', icon: 'pi pi-share-alt', command: () => emit('share', props.doc) },
{ label: 'Assinar', icon: 'pi pi-check-square', command: () => emit('sign', props.doc) },
{ separator: true },
{ label: 'Excluir', icon: 'pi pi-trash', class: 'text-red-500', command: () => emit('delete', props.doc) }
])
</script>
<template>
<div
class="group relative flex items-start gap-3 p-3 rounded-lg border transition-all cursor-pointer"
:class="[
selected
? 'border-primary bg-primary/5'
: 'border-[var(--surface-border)] hover:border-[var(--surface-400)] hover:bg-[var(--surface-hover)]'
]"
@click="emit('preview', doc)"
>
<!-- Icone / Thumbnail -->
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" :class="mimeColor">
<i :class="mimeIcon" class="text-lg" />
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate">{{ doc.nome_original }}</span>
<span
v-if="doc.enviado_pelo_paciente"
class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-600 whitespace-nowrap"
>
paciente
</span>
<span
v-if="doc.status_revisao === 'pendente'"
class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-600 whitespace-nowrap"
>
pendente
</span>
</div>
<div class="flex items-center gap-2 mt-0.5 text-xs text-[var(--text-color-secondary)]">
<span>{{ tipoLabel }}</span>
<span class="opacity-30">|</span>
<span>{{ formattedSize }}</span>
<span class="opacity-30">|</span>
<span>{{ formattedDate }}</span>
</div>
<!-- Tags -->
<div v-if="doc.tags?.length" class="flex flex-wrap gap-1 mt-1.5">
<span
v-for="tag in doc.tags"
:key="tag"
class="text-[0.65rem] px-1.5 py-0.5 rounded-full border border-[var(--surface-border)] text-[var(--text-color-secondary)]"
>
{{ tag }}
</span>
</div>
</div>
<!-- Menu de acoes -->
<div class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
icon="pi pi-ellipsis-v"
text
rounded
size="small"
class="!w-7 !h-7"
@click.stop="$refs.menu.toggle($event)"
/>
<Menu ref="menu" :model="menuItems" :popup="true" />
</div>
<!-- Badges de visibilidade -->
<div class="absolute top-2 right-2 flex gap-1" v-if="doc.compartilhado_portal || doc.compartilhado_supervisor">
<i
v-if="doc.compartilhado_portal"
class="pi pi-user text-[0.6rem] p-1 rounded-full bg-blue-500/10 text-blue-500"
v-tooltip.top="'Visível no portal do paciente'"
/>
<i
v-if="doc.compartilhado_supervisor"
class="pi pi-eye text-[0.6rem] p-1 rounded-full bg-teal-500/10 text-teal-500"
v-tooltip.top="'Compartilhado com supervisor'"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,266 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Arquivo: src/features/documents/components/DocumentGenerateDialog.vue
| Gerar documento: selecionar template, preencher, preview, gerar PDF.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, watch, computed } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useDocumentGenerate } from '../composables/useDocumentGenerate'
import { useDocumentTemplates } from '../composables/useDocumentTemplates'
const props = defineProps({
visible: { type: Boolean, default: false },
patientId: { type: String, default: null },
patientName: { type: String, default: '' },
agendaEventoId: { type: String, default: null }
})
const emit = defineEmits(['update:visible', 'generated'])
const toast = useToast()
const step = ref('select') // select | edit | preview
const {
loading: generating,
error: genError,
variables,
selectedTemplate,
previewHtml,
loadVariables,
selectTemplate,
setVariable,
updatePreview,
generateAndSave,
downloadOnly,
printDocument,
reset
} = useDocumentGenerate()
const {
templates,
loading: loadingTemplates,
fetchTemplates,
TEMPLATE_VARIABLES
} = useDocumentTemplates()
// ── Reset ao abrir ──────────────────────────────────────────
watch(() => props.visible, async (v) => {
if (v) {
step.value = 'select'
reset()
await Promise.all([
fetchTemplates(),
props.patientId ? loadVariables(props.patientId, props.agendaEventoId) : Promise.resolve()
])
}
})
// ── Selecionar template ─────────────────────────────────────
async function onSelectTemplate(tpl) {
await selectTemplate(tpl.id)
step.value = 'edit'
}
// ── Variaveis editaveis ─────────────────────────────────────
const editableVars = computed(() => {
if (!selectedTemplate.value?.variaveis?.length) return []
return selectedTemplate.value.variaveis.map(key => {
const meta = TEMPLATE_VARIABLES.find(v => v.key === key)
return {
key,
label: meta?.label || key,
grupo: meta?.grupo || 'Outros',
value: variables.value[key] || ''
}
})
})
const varGroups = computed(() => {
const groups = {}
for (const v of editableVars.value) {
if (!groups[v.grupo]) groups[v.grupo] = []
groups[v.grupo].push(v)
}
return groups
})
function onVarChange(key, val) {
setVariable(key, val)
}
// ── Gerar ───────────────────────────────────────────────────
async function onGenerate() {
try {
const result = await generateAndSave(props.patientId)
toast.add({ severity: 'success', summary: 'Documento salvo', detail: 'Disponível nos documentos do paciente.', life: 3000 })
emit('generated', result)
close()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao gerar PDF.' })
}
}
async function onDownloadOnly() {
try {
await downloadOnly()
toast.add({ severity: 'info', summary: 'Download', detail: 'PDF baixado (não salvo no sistema).', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao gerar PDF.' })
}
}
function close() {
emit('update:visible', false)
}
</script>
<template>
<Dialog
:visible="visible"
@update:visible="$emit('update:visible', $event)"
modal
maximizable
:draggable="false"
:closable="!generating"
:dismissableMask="!generating"
class="w-[60rem]"
:breakpoints="{ '1199px': '90vw', '768px': '96vw' }"
:pt="{
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
content: { class: '!p-4' },
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex items-center gap-3">
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-green-500/10">
<i class="pi pi-file-pdf text-green-600" />
</span>
<div>
<div class="text-base font-semibold">Gerar documento</div>
<div class="text-xs text-[var(--text-color-secondary)]">
<template v-if="step === 'select'">Selecione um template</template>
<template v-else-if="step === 'edit'">{{ selectedTemplate?.nome_template }} {{ patientName }}</template>
<template v-else>Preview do documento</template>
</div>
</div>
</div>
</template>
<!-- Step 1: Selecionar template -->
<div v-if="step === 'select'">
<div v-if="loadingTemplates" class="flex items-center justify-center py-12">
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
</div>
<div v-else-if="!templates.length" class="text-center py-12 text-[var(--text-color-secondary)]">
<i class="pi pi-inbox text-3xl opacity-40 mb-2" />
<div class="text-sm">Nenhum template disponível.</div>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
v-for="tpl in templates"
:key="tpl.id"
class="flex items-start gap-3 p-3 rounded-lg border border-[var(--surface-border)] hover:border-primary hover:bg-primary/5 text-left transition-all"
@click="onSelectTemplate(tpl)"
>
<span class="flex-shrink-0 w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
<i class="pi pi-file text-primary" />
</span>
<div class="min-w-0">
<div class="text-sm font-medium">{{ tpl.nome_template }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">{{ tpl.descricao || tpl.tipo }}</div>
<span
v-if="tpl.is_global"
class="inline-block mt-1 text-[0.6rem] px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-600"
>
padrão
</span>
</div>
</button>
</div>
</div>
<!-- Step 2: Editar variaveis -->
<div v-else-if="step === 'edit'" class="flex flex-col gap-4">
<div v-for="(vars, grupo) in varGroups" :key="grupo">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">{{ grupo }}</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div v-for="v in vars" :key="v.key" class="flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">{{ v.label }}</label>
<InputText
:modelValue="variables[v.key] || ''"
@update:modelValue="onVarChange(v.key, $event)"
class="w-full"
/>
</div>
</div>
</div>
<div class="flex justify-end gap-2 mt-2">
<Button label="Voltar" text icon="pi pi-arrow-left" @click="step = 'select'; reset()" />
<Button label="Preview" icon="pi pi-eye" @click="updatePreview(); step = 'preview'" />
</div>
</div>
<!-- Step 3: Preview -->
<div v-else-if="step === 'preview'">
<div class="border border-[var(--surface-border)] rounded-lg bg-white overflow-hidden">
<iframe
:srcdoc="previewHtml"
class="w-full min-h-[60vh] border-0"
sandbox=""
/>
</div>
</div>
<!-- Erro -->
<div v-if="genError" class="mt-3 text-sm text-red-500 flex items-center gap-1.5">
<i class="pi pi-exclamation-circle text-xs" />
{{ genError }}
</div>
<template #footer>
<div class="flex items-center justify-between gap-2">
<div>
<Button
v-if="step === 'preview'"
label="Editar"
text
icon="pi pi-arrow-left"
@click="step = 'edit'"
:disabled="generating"
/>
</div>
<div class="flex gap-2">
<Button label="Cancelar" text @click="close" :disabled="generating" />
<Button
v-if="step === 'preview'"
label="Só baixar"
text
icon="pi pi-download"
@click="onDownloadOnly"
:loading="generating"
/>
<Button
v-if="step === 'preview'"
label="Salvar documento"
icon="pi pi-check"
@click="onGenerate"
:loading="generating"
/>
</div>
</div>
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,174 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Arquivo: src/features/documents/components/DocumentPreviewDialog.vue
| Preview inline de PDF/imagem + metadados + acoes.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, watch, computed } from 'vue'
import { useToast } from 'primevue/usetoast'
const props = defineProps({
visible: { type: Boolean, default: false },
doc: { type: Object, default: null },
previewUrl: { type: String, default: '' }
})
const emit = defineEmits(['update:visible', 'download', 'edit', 'delete', 'share', 'sign'])
const toast = useToast()
const activeTab = ref('preview')
// ── Computed ────────────────────────────────────────────────
const isImage = computed(() => String(props.doc?.mime_type || '').startsWith('image/'))
const isPdf = computed(() => props.doc?.mime_type === 'application/pdf')
const canPreview = computed(() => isImage.value || isPdf.value)
const tipoLabel = computed(() => {
const map = {
laudo: 'Laudo', receita: 'Receita', exame: 'Exame',
termo_assinado: 'Termo assinado', relatorio_externo: 'Relatório externo',
identidade: 'Identidade', convenio: 'Convênio',
declaracao: 'Declaração', atestado: 'Atestado',
recibo: 'Recibo', outro: 'Outro'
}
return map[props.doc?.tipo_documento] || 'Documento'
})
const formattedSize = computed(() => {
const b = props.doc?.tamanho_bytes
if (!b) return '—'
if (b < 1024) return b + ' B'
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
return (b / (1024 * 1024)).toFixed(1) + ' MB'
})
const formattedDate = computed(() => {
const d = props.doc?.uploaded_at
if (!d) return '—'
return new Date(d).toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' })
})
const visibilidadeLabel = computed(() => {
const map = {
privado: 'Privado',
compartilhado_supervisor: 'Supervisor',
compartilhado_portal: 'Portal paciente'
}
return map[props.doc?.visibilidade] || 'Privado'
})
function close() {
emit('update:visible', false)
}
</script>
<template>
<Dialog
:visible="visible"
@update:visible="$emit('update:visible', $event)"
modal
maximizable
:draggable="false"
class="w-[55rem]"
:breakpoints="{ '1199px': '90vw', '768px': '96vw' }"
:pt="{
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
content: { class: '!p-0' },
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex items-center gap-3 flex-1 min-w-0">
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-500/10">
<i class="pi pi-eye text-indigo-500" />
</span>
<div class="min-w-0">
<div class="text-base font-semibold truncate">{{ doc?.nome_original }}</div>
<div class="text-xs text-[var(--text-color-secondary)]">{{ tipoLabel }} · {{ formattedSize }} · {{ formattedDate }}</div>
</div>
</div>
</template>
<div v-if="doc" class="flex flex-col lg:flex-row">
<!-- Preview area -->
<div class="flex-1 min-h-[400px] flex items-center justify-center bg-[var(--surface-ground)] p-4">
<template v-if="canPreview && previewUrl">
<img
v-if="isImage"
:src="previewUrl"
:alt="doc.nome_original"
class="max-w-full max-h-[70vh] rounded shadow-sm"
/>
<iframe
v-else-if="isPdf"
:src="previewUrl"
class="w-full h-[70vh] rounded border-0"
/>
</template>
<div v-else class="flex flex-col items-center gap-3 text-[var(--text-color-secondary)]">
<i class="pi pi-file text-5xl opacity-40" />
<span class="text-sm">Preview não disponível para este tipo de arquivo.</span>
<Button label="Baixar arquivo" icon="pi pi-download" size="small" @click="emit('download', doc)" />
</div>
</div>
<!-- Sidebar de detalhes -->
<div class="w-full lg:w-[240px] border-t lg:border-t-0 lg:border-l border-[var(--surface-border)] p-4 flex flex-col gap-4">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">Detalhes</div>
<div class="flex flex-col gap-3">
<div>
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-0.5">Tipo</div>
<div class="text-sm">{{ tipoLabel }}</div>
</div>
<div v-if="doc.categoria">
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-0.5">Categoria</div>
<div class="text-sm">{{ doc.categoria }}</div>
</div>
<div>
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-0.5">Visibilidade</div>
<div class="text-sm">{{ visibilidadeLabel }}</div>
</div>
<div v-if="doc.descricao">
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-0.5">Descrição</div>
<div class="text-sm">{{ doc.descricao }}</div>
</div>
<div v-if="doc.tags?.length">
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">Tags</div>
<div class="flex flex-wrap gap-1">
<span
v-for="tag in doc.tags"
:key="tag"
class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary"
>
{{ tag }}
</span>
</div>
</div>
</div>
<!-- Acoes -->
<div class="mt-auto flex flex-col gap-1.5 pt-3 border-t border-[var(--surface-border)]">
<Button label="Baixar" icon="pi pi-download" size="small" class="w-full" @click="emit('download', doc)" />
<Button label="Editar" icon="pi pi-pencil" size="small" text class="w-full" @click="emit('edit', doc)" />
<Button label="Compartilhar" icon="pi pi-share-alt" size="small" text class="w-full" @click="emit('share', doc)" />
<Button label="Assinar" icon="pi pi-check-square" size="small" text class="w-full" @click="emit('sign', doc)" />
<Button label="Excluir" icon="pi pi-trash" size="small" text severity="danger" class="w-full" @click="emit('delete', doc)" />
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<Button label="Fechar" text @click="close" />
</div>
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,245 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Arquivo: src/features/documents/components/DocumentShareDialog.vue
| Gerar link temporario para compartilhamento externo.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, watch, computed } from 'vue'
import { useToast } from 'primevue/usetoast'
import {
createShareLink,
listShareLinks,
deactivateShareLink,
buildShareUrl
} from '@/services/DocumentShareLinks.service'
const props = defineProps({
visible: { type: Boolean, default: false },
doc: { type: Object, default: null }
})
const emit = defineEmits(['update:visible'])
const toast = useToast()
const loading = ref(false)
const creating = ref(false)
const links = ref([])
const OPCOES_EXPIRACAO = [
{ value: 24, label: '24 horas' },
{ value: 48, label: '48 horas' },
{ value: 168, label: '7 dias' },
{ value: 720, label: '30 dias' }
]
const formExpiracao = ref(48)
const formUsosMax = ref(5)
// ── Reset ao abrir ──────────────────────────────────────────
watch(() => props.visible, async (v) => {
if (v && props.doc) {
formExpiracao.value = 48
formUsosMax.value = 5
await fetchLinks()
}
})
async function fetchLinks() {
loading.value = true
try {
links.value = await listShareLinks(props.doc.id)
} catch {
links.value = []
} finally {
loading.value = false
}
}
// ── Criar link ──────────────────────────────────────────────
async function criarLink() {
creating.value = true
try {
const link = await createShareLink(props.doc.id, {
expiracaoHoras: formExpiracao.value,
usosMax: formUsosMax.value
})
links.value.unshift(link)
toast.add({ severity: 'success', summary: 'Link criado', detail: 'Link copiado para a área de transferência.', life: 3000 })
copyUrl(link.token)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao criar link.' })
} finally {
creating.value = false
}
}
// ── Copiar URL ──────────────────────────────────────────────
function copyUrl(token) {
const url = buildShareUrl(token)
navigator.clipboard.writeText(url).catch(() => {})
}
// ── Desativar link ──────────────────────────────────────────
async function desativar(linkId) {
try {
await deactivateShareLink(linkId)
const idx = links.value.findIndex(l => l.id === linkId)
if (idx >= 0) links.value[idx].ativo = false
toast.add({ severity: 'info', summary: 'Link desativado', life: 2000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
}
}
// ── Helpers ──────────────────────────────────────────────────
function isExpired(link) {
return new Date(link.expira_em) < new Date()
}
function isExhausted(link) {
return link.usos >= link.usos_max
}
function formatDate(d) {
if (!d) return '—'
return new Date(d).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
}
function close() {
emit('update:visible', false)
}
</script>
<template>
<Dialog
:visible="visible"
@update:visible="$emit('update:visible', $event)"
modal
:draggable="false"
class="w-[36rem]"
:breakpoints="{ '768px': '94vw' }"
:pt="{
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
content: { class: '!p-4' },
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex items-center gap-3">
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-orange-500/10">
<i class="pi pi-share-alt text-orange-500" />
</span>
<div>
<div class="text-base font-semibold">Compartilhar documento</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate max-w-[300px]">{{ doc?.nome_original }}</div>
</div>
</div>
</template>
<div class="flex flex-col gap-4">
<!-- Criar novo link -->
<div class="p-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">Novo link</div>
<div class="grid grid-cols-2 gap-3 mb-3">
<div class="flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">Expira em</label>
<Select
v-model="formExpiracao"
:options="OPCOES_EXPIRACAO"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">Limite de acessos</label>
<InputNumber v-model="formUsosMax" :min="1" :max="100" class="w-full" />
</div>
</div>
<Button
label="Gerar link"
icon="pi pi-link"
size="small"
:loading="creating"
@click="criarLink"
class="w-full"
/>
</div>
<!-- Links existentes -->
<div v-if="links.length">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">Links criados</div>
<div class="flex flex-col gap-1.5 max-h-[250px] overflow-y-auto">
<div
v-for="link in links"
:key="link.id"
class="flex items-center gap-2 p-2.5 rounded-md border border-[var(--surface-border)]"
:class="{ 'opacity-50': !link.ativo || isExpired(link) || isExhausted(link) }"
>
<i
:class="
!link.ativo ? 'pi pi-ban text-gray-400' :
isExpired(link) ? 'pi pi-clock text-red-400' :
isExhausted(link) ? 'pi pi-exclamation-circle text-amber-400' :
'pi pi-link text-green-500'
"
class="text-sm flex-shrink-0"
/>
<div class="flex-1 min-w-0">
<div class="text-xs text-[var(--text-color-secondary)]">
Expira: {{ formatDate(link.expira_em) }}
</div>
<div class="text-xs text-[var(--text-color-secondary)]">
Usos: {{ link.usos }}/{{ link.usos_max }}
</div>
</div>
<div class="flex gap-1">
<Button
v-if="link.ativo && !isExpired(link)"
icon="pi pi-copy"
text
rounded
size="small"
class="!w-7 !h-7"
v-tooltip.top="'Copiar link'"
@click="copyUrl(link.token)"
/>
<Button
v-if="link.ativo"
icon="pi pi-ban"
text
rounded
size="small"
severity="danger"
class="!w-7 !h-7"
v-tooltip.top="'Desativar'"
@click="desativar(link.id)"
/>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<Button label="Fechar" text @click="close" />
</div>
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,306 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Arquivo: src/features/documents/components/DocumentSignatureDialog.vue
| Solicitar assinatura: adicionar signatarios, acompanhar status.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, reactive, watch, computed } from 'vue'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import {
createSignatureRequests,
listSignatures,
getSignatureStatus
} from '@/services/DocumentSignatures.service'
const props = defineProps({
visible: { type: Boolean, default: false },
doc: { type: Object, default: null }
})
const emit = defineEmits(['update:visible', 'requested'])
const toast = useToast()
const saving = ref(false)
const loading = ref(false)
const existingSignatures = ref([])
const signatureStatus = ref(null)
const TIPOS_SIGNATARIO = [
{ value: 'paciente', label: 'Paciente' },
{ value: 'responsavel_legal', label: 'Responsável legal' },
{ value: 'terapeuta', label: 'Terapeuta' }
]
// Signatarios a adicionar
const signatarios = ref([])
const patientEmails = ref([])
function addSignatario() {
signatarios.value.push({ tipo: 'paciente', nome: '', email: '' })
}
function removeSignatario(idx) {
signatarios.value.splice(idx, 1)
}
// ── Buscar emails do paciente ──────────────────────────────
async function fetchPatientEmails(patientId) {
if (!patientId) { patientEmails.value = []; return }
try {
const { data } = await supabase
.from('patients')
.select('email_principal, email_alternativo')
.eq('id', patientId)
.single()
const emails = []
if (data?.email_principal) emails.push(data.email_principal)
if (data?.email_alternativo && data.email_alternativo !== data.email_principal) emails.push(data.email_alternativo)
patientEmails.value = emails
} catch {
patientEmails.value = []
}
}
function useEmail(email) {
// Preenche o último signatário adicionado que não tenha email, ou o primeiro vazio
const target = signatarios.value.findLast(s => !s.email?.trim()) || signatarios.value[signatarios.value.length - 1]
if (target) target.email = email
}
// ── Reset ao abrir ──────────────────────────────────────────
watch(() => props.visible, async (v) => {
if (v && props.doc) {
signatarios.value = []
loading.value = true
try {
const [sigs, status] = await Promise.all([
listSignatures(props.doc.id),
getSignatureStatus(props.doc.id),
fetchPatientEmails(props.doc.patient_id)
])
existingSignatures.value = sigs
signatureStatus.value = status
} catch {
existingSignatures.value = []
signatureStatus.value = null
} finally {
loading.value = false
}
}
})
// ── Status badge ────────────────────────────────────────────
const statusColor = computed(() => {
const s = signatureStatus.value?.status
if (s === 'completo') return 'bg-green-500/10 text-green-600'
if (s === 'parcial') return 'bg-amber-500/10 text-amber-600'
return 'bg-gray-500/10 text-gray-500'
})
const statusLabel = computed(() => {
const s = signatureStatus.value?.status
if (s === 'completo') return 'Todas assinaturas completas'
if (s === 'parcial') return `${signatureStatus.value.assinados}/${signatureStatus.value.total} assinado(s)`
if (s === 'pendente') return 'Aguardando assinaturas'
return 'Sem assinaturas'
})
// ── Enviar solicitacao ──────────────────────────────────────
async function submit() {
if (!signatarios.value.length) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Adicione ao menos um signatário.' })
return
}
const semNome = signatarios.value.find(s => !s.nome?.trim())
if (semNome) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Preencha o nome de todos os signatários.' })
return
}
const semEmail = signatarios.value.find(s => !s.email?.trim())
if (semEmail) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Preencha o e-mail de todos os signatários.' })
return
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const emailInvalido = signatarios.value.find(s => !emailRegex.test(s.email?.trim()))
if (emailInvalido) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: `E-mail inválido: ${emailInvalido.email}` })
return
}
saving.value = true
try {
const result = await createSignatureRequests(props.doc.id, signatarios.value)
toast.add({ severity: 'success', summary: 'Solicitação enviada', detail: `${result.length} signatário(s) adicionado(s).`, life: 3000 })
emit('requested', result)
emit('update:visible', false)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao solicitar assinatura.' })
} finally {
saving.value = false
}
}
function close() {
emit('update:visible', false)
}
</script>
<template>
<Dialog
:visible="visible"
@update:visible="$emit('update:visible', $event)"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
class="w-[38rem]"
:breakpoints="{ '768px': '94vw' }"
:pt="{
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
content: { class: '!p-4' },
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex items-center gap-3">
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-500/10">
<i class="pi pi-check-square text-teal-600" />
</span>
<div>
<div class="text-base font-semibold">Assinatura eletrônica</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate max-w-[300px]">{{ doc?.nome_original }}</div>
</div>
</div>
</template>
<div v-if="loading" class="flex items-center justify-center py-8">
<i class="pi pi-spinner pi-spin text-xl text-[var(--text-color-secondary)]" />
</div>
<div v-else class="flex flex-col gap-4">
<!-- Status atual -->
<div v-if="existingSignatures.length" class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Assinaturas existentes</span>
<span class="text-[0.65rem] px-2 py-0.5 rounded-full" :class="statusColor">{{ statusLabel }}</span>
</div>
<div class="flex flex-col gap-1.5">
<div
v-for="sig in existingSignatures"
:key="sig.id"
class="flex items-center gap-2 p-2 rounded-md bg-[var(--surface-ground)]"
>
<i
:class="sig.status === 'assinado' ? 'pi pi-check-circle text-green-500' : sig.status === 'recusado' ? 'pi pi-times-circle text-red-500' : 'pi pi-clock text-amber-500'"
class="text-sm"
/>
<div class="flex-1 min-w-0">
<span class="text-sm">{{ sig.signatario_nome || sig.signatario_tipo }}</span>
<span class="text-xs text-[var(--text-color-secondary)] ml-2">{{ sig.signatario_tipo }}</span>
</div>
<span v-if="sig.assinado_em" class="text-xs text-[var(--text-color-secondary)]">
{{ new Date(sig.assinado_em).toLocaleDateString('pt-BR') }}
</span>
</div>
</div>
</div>
<!-- Adicionar novos signatarios -->
<div>
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Novos signatários</span>
<Button label="Adicionar" icon="pi pi-plus" size="small" text @click="addSignatario" />
</div>
<div v-if="!signatarios.length" class="text-center py-4 text-sm text-[var(--text-color-secondary)]">
Clique em "Adicionar" para incluir signatários.
</div>
<div v-else class="flex flex-col gap-2.5">
<div
v-for="(sig, idx) in signatarios"
:key="idx"
class="grid grid-cols-[120px_1fr_1fr_auto] gap-2 items-end"
>
<div class="flex flex-col gap-1">
<label class="text-[0.65rem] text-[var(--text-color-secondary)]">Tipo</label>
<Select
v-model="sig.tipo"
:options="TIPOS_SIGNATARIO"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-[0.65rem] text-[var(--text-color-secondary)]">Nome <span class="text-red-400">*</span></label>
<InputText v-model="sig.nome" placeholder="Nome" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="text-[0.65rem] text-[var(--text-color-secondary)]">E-mail <span class="text-red-400">*</span></label>
<InputText v-model="sig.email" placeholder="email@..." class="w-full" />
</div>
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="removeSignatario(idx)" class="mb-0.5" />
</div>
</div>
</div>
<!-- Emails cadastrados do paciente -->
<div class="p-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">E-mails do paciente</div>
<div v-if="patientEmails.length" class="flex flex-col gap-1.5">
<div
v-for="(email, i) in patientEmails"
:key="i"
class="flex items-center gap-2"
>
<InputText :modelValue="email" readonly class="w-full !text-xs !bg-transparent" />
<Button
icon="pi pi-copy"
text
rounded
size="small"
class="!w-7 !h-7 flex-shrink-0"
v-tooltip.top="'Copiar e usar'"
@click="useEmail(email)"
/>
</div>
</div>
<div v-else class="text-xs text-[var(--text-color-secondary)] italic py-1">
Nenhum e-mail cadastrado anteriormente foi encontrado.
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2">
<Button label="Cancelar" text @click="close" :disabled="saving" />
<Button
label="Solicitar assinatura"
icon="pi pi-send"
:loading="saving"
:disabled="!signatarios.length"
@click="submit"
/>
</div>
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,123 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Arquivo: src/features/documents/components/DocumentTagsInput.vue
| Input de tags livres com chips editaveis e autocomplete.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
suggestions: { type: Array, default: () => [] },
placeholder: { type: String, default: 'Adicionar tag...' },
maxTags: { type: Number, default: 20 }
})
const emit = defineEmits(['update:modelValue'])
const inputValue = ref('')
const inputRef = ref(null)
const showSuggestions = ref(false)
const tags = computed({
get: () => props.modelValue || [],
set: (val) => emit('update:modelValue', val)
})
const filteredSuggestions = computed(() => {
const q = inputValue.value.toLowerCase().trim()
if (!q) return []
return props.suggestions
.filter(s => s.toLowerCase().includes(q) && !tags.value.includes(s))
.slice(0, 8)
})
function addTag(value) {
const tag = String(value || '').trim().toLowerCase()
if (!tag) return
if (tags.value.includes(tag)) return
if (tags.value.length >= props.maxTags) return
tags.value = [...tags.value, tag]
inputValue.value = ''
showSuggestions.value = false
}
function removeTag(index) {
const copy = [...tags.value]
copy.splice(index, 1)
tags.value = copy
}
function onKeydown(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addTag(inputValue.value)
}
if (e.key === 'Backspace' && !inputValue.value && tags.value.length) {
removeTag(tags.value.length - 1)
}
}
function onInput() {
showSuggestions.value = inputValue.value.trim().length > 0
}
function selectSuggestion(s) {
addTag(s)
inputRef.value?.$el?.focus()
}
</script>
<template>
<div class="relative">
<div
class="flex flex-wrap items-center gap-1.5 min-h-[2.5rem] px-2.5 py-1.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-overlay)] focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/20 transition-colors"
>
<!-- Tags existentes -->
<span
v-for="(tag, idx) in tags"
:key="tag"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary"
>
{{ tag }}
<i
class="pi pi-times text-[0.55rem] cursor-pointer opacity-60 hover:opacity-100"
@click="removeTag(idx)"
/>
</span>
<!-- Input -->
<InputText
ref="inputRef"
v-model="inputValue"
:placeholder="tags.length ? '' : placeholder"
class="!border-0 !shadow-none !ring-0 !p-0 !min-w-[80px] flex-1 text-sm !bg-transparent"
@keydown="onKeydown"
@input="onInput"
@focus="onInput"
@blur="setTimeout(() => showSuggestions = false, 150)"
/>
</div>
<!-- Dropdown sugestoes -->
<div
v-if="showSuggestions && filteredSuggestions.length"
class="absolute z-50 top-full left-0 right-0 mt-1 py-1 rounded-md border border-[var(--surface-border)] bg-[var(--surface-overlay)] shadow-lg max-h-[200px] overflow-y-auto"
>
<button
v-for="s in filteredSuggestions"
:key="s"
class="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--surface-hover)] transition-colors"
@mousedown.prevent="selectSuggestion(s)"
>
{{ s }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,207 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Arquivo: src/features/documents/components/DocumentTemplateEditor.vue
| Editor de template: edicao HTML, insercao de variaveis, preview ao vivo.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, watch, computed } from 'vue'
import { useDocumentTemplates } from '../composables/useDocumentTemplates'
import JoditEmailEditor from '@/components/ui/JoditEmailEditor.vue'
const props = defineProps({
modelValue: { type: Object, default: () => ({}) },
mode: { type: String, default: 'create' } // create | edit
})
const emit = defineEmits(['update:modelValue', 'save', 'cancel'])
const { TIPOS_TEMPLATE, TEMPLATE_VARIABLES, variablesGrouped, previewHtml } = useDocumentTemplates()
const activeTab = ref('editor') // editor | preview
// ── Form reativo synced com modelValue ──────────────────────
const form = ref({ ...defaultForm(), ...props.modelValue })
function defaultForm() {
return {
nome_template: '',
tipo: 'outro',
descricao: '',
corpo_html: '',
cabecalho_html: '',
rodape_html: '',
variaveis: [],
logo_url: ''
}
}
watch(() => props.modelValue, (val) => {
form.value = { ...defaultForm(), ...val }
}, { deep: true })
watch(form, (val) => {
emit('update:modelValue', { ...val })
}, { deep: true })
// ── Preview ─────────────────────────────────────────────────
const renderedPreview = computed(() => previewHtml(form.value.corpo_html))
const renderedCabecalho = computed(() => previewHtml(form.value.cabecalho_html || ''))
const renderedRodape = computed(() => previewHtml(form.value.rodape_html || ''))
// ── Inserir variavel no corpo ───────────────────────────────
const cursorField = ref('corpo_html') // qual campo esta ativo
const editorCabecalho = ref(null)
const editorCorpo = ref(null)
const editorRodape = ref(null)
function insertVariable(varKey) {
const tag = `{{${varKey}}}`
const editorMap = {
cabecalho_html: editorCabecalho,
corpo_html: editorCorpo,
rodape_html: editorRodape
}
const editorRef = editorMap[cursorField.value]
if (editorRef?.value?.insertHTML) {
editorRef.value.insertHTML(tag)
} else {
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag
}
// Adiciona a variavel na lista se nao estiver
if (!form.value.variaveis.includes(varKey)) {
form.value.variaveis = [...form.value.variaveis, varKey]
}
}
// ── Save ────────────────────────────────────────────────────
function onSave() {
emit('save', { ...form.value })
}
</script>
<template>
<div class="flex flex-col gap-4">
<!-- Header: nome e tipo -->
<div class="grid grid-cols-1 sm:grid-cols-[1fr_200px] gap-3">
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Nome do template</label>
<InputText v-model="form.nome_template" placeholder="Ex: Declaração de comparecimento" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Tipo</label>
<Select
v-model="form.tipo"
:options="TIPOS_TEMPLATE"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Descrição</label>
<InputText v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" />
</div>
<!-- Tabs: Editor / Preview -->
<div class="flex items-center gap-1 border-b border-[var(--surface-border)]">
<button
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === 'editor' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
@click="activeTab = 'editor'"
>
Editor
</button>
<button
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === 'preview' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
@click="activeTab = 'preview'"
>
Preview
</button>
</div>
<!-- Editor -->
<div v-show="activeTab === 'editor'" class="flex flex-col lg:flex-row gap-4">
<!-- Campos HTML -->
<div class="flex-1 flex flex-col gap-3">
<div class="flex flex-col gap-1" @focusin="cursorField = 'cabecalho_html'">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Cabeçalho</label>
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
</div>
<div class="flex flex-col gap-1" @focusin="cursorField = 'corpo_html'">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Corpo do documento</label>
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="350" />
</div>
<div class="flex flex-col gap-1" @focusin="cursorField = 'rodape_html'">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Rodapé</label>
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">URL do logo (opcional)</label>
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
</div>
</div>
<!-- Painel de variaveis -->
<div class="w-full lg:w-[220px] flex-shrink-0">
<div class="sticky top-0">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">
Variáveis
</div>
<div class="text-[0.65rem] text-[var(--text-color-secondary)] mb-3">
Clique para inserir no campo ativo
</div>
<div class="flex flex-col gap-3 max-h-[500px] overflow-y-auto pr-1">
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo">
<div class="text-[0.65rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">{{ grupo }}</div>
<div class="flex flex-col gap-0.5">
<button
v-for="v in vars"
:key="v.key"
class="text-left text-xs px-2 py-1 rounded hover:bg-primary/10 hover:text-primary transition-colors truncate"
:title="v.key"
@click="insertVariable(v.key)"
>
<span class="font-mono text-[0.65rem] opacity-60">&lbrace;&lbrace;</span>
{{ v.label }}
<span class="font-mono text-[0.65rem] opacity-60">&rbrace;&rbrace;</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Preview -->
<div v-show="activeTab === 'preview'" class="border border-[var(--surface-border)] rounded-lg bg-white overflow-hidden">
<div class="p-6 text-black" style="font-family: 'Segoe UI', Arial, sans-serif; font-size: 12pt; line-height: 1.6;">
<div v-if="form.cabecalho_html" class="text-center mb-4 pb-3 border-b border-gray-300" v-html="renderedCabecalho" />
<div class="min-h-[300px]" v-html="renderedPreview" />
<div v-if="form.rodape_html" class="mt-8 pt-3 border-t border-gray-300 text-center text-[10pt] text-gray-500" v-html="renderedRodape" />
</div>
</div>
<!-- Acoes -->
<div class="flex items-center justify-end gap-2 pt-2">
<Button label="Cancelar" text @click="emit('cancel')" />
<Button :label="mode === 'create' ? 'Criar template' : 'Salvar'" icon="pi pi-check" @click="onSave" />
</div>
</div>
</template>

View File

@@ -0,0 +1,279 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Arquivo: src/features/documents/components/DocumentUploadDialog.vue
| Dialog de upload drag & drop, tipo, categoria, tags, visibilidade.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, reactive, watch, computed } from 'vue'
import { useToast } from 'primevue/usetoast'
import DocumentTagsInput from './DocumentTagsInput.vue'
const props = defineProps({
visible: { type: Boolean, default: false },
patientId: { type: String, default: null },
patientName: { type: String, default: '' },
usedTags: { type: Array, default: () => [] },
sessions: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:visible', 'uploaded'])
const toast = useToast()
// ── State ───────────────────────────────────────────────────
const file = ref(null)
const filePreviewUrl = ref('')
const dragging = ref(false)
const saving = ref(false)
const formErr = ref('')
const form = reactive({
tipo_documento: 'outro',
categoria: '',
descricao: '',
tags: [],
agenda_evento_id: null,
visibilidade: 'privado',
compartilhado_portal: false,
compartilhado_supervisor: false
})
const TIPOS = [
{ value: 'laudo', label: 'Laudo' },
{ value: 'receita', label: 'Receita' },
{ value: 'exame', label: 'Exame' },
{ value: 'termo_assinado', label: 'Termo assinado' },
{ value: 'relatorio_externo', label: 'Relatório externo' },
{ value: 'identidade', label: 'Identidade' },
{ value: 'convenio', label: 'Convênio' },
{ value: 'declaracao', label: 'Declaração' },
{ value: 'atestado', label: 'Atestado' },
{ value: 'recibo', label: 'Recibo' },
{ value: 'outro', label: 'Outro' }
]
const VISIBILIDADES = [
{ value: 'privado', label: 'Privado (só eu)' },
{ value: 'compartilhado_supervisor', label: 'Compartilhado com supervisor' },
{ value: 'compartilhado_portal', label: 'Visível no portal do paciente' }
]
// ── Reset ao abrir ──────────────────────────────────────────
watch(() => props.visible, (v) => {
if (v) {
file.value = null
filePreviewUrl.value = ''
formErr.value = ''
Object.assign(form, {
tipo_documento: 'outro',
categoria: '',
descricao: '',
tags: [],
agenda_evento_id: null,
visibilidade: 'privado',
compartilhado_portal: false,
compartilhado_supervisor: false
})
}
})
// ── Sync visibilidade ───────────────────────────────────────
watch(() => form.visibilidade, (v) => {
form.compartilhado_portal = v === 'compartilhado_portal'
form.compartilhado_supervisor = v === 'compartilhado_supervisor'
})
// ── File handling ───────────────────────────────────────────
const MAX_SIZE = 50 * 1024 * 1024 // 50MB
function onFileSelected(e) {
const f = e.target?.files?.[0]
if (f) setFile(f)
}
function onDrop(e) {
dragging.value = false
const f = e.dataTransfer?.files?.[0]
if (f) setFile(f)
}
function setFile(f) {
if (f.size > MAX_SIZE) {
toast.add({ severity: 'warn', summary: 'Arquivo grande', detail: 'Máximo 50 MB.' })
return
}
file.value = f
filePreviewUrl.value = f.type.startsWith('image/') ? URL.createObjectURL(f) : ''
formErr.value = ''
}
function removeFile() {
file.value = null
if (filePreviewUrl.value) URL.revokeObjectURL(filePreviewUrl.value)
filePreviewUrl.value = ''
}
const fileSizeFormatted = computed(() => {
if (!file.value) return ''
const b = file.value.size
if (b < 1024) return b + ' B'
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
return (b / (1024 * 1024)).toFixed(1) + ' MB'
})
// ── Submit ──────────────────────────────────────────────────
async function submit() {
if (!file.value) { formErr.value = 'Selecione um arquivo.'; return }
if (!props.patientId) { formErr.value = 'Paciente não informado.'; return }
saving.value = true
formErr.value = ''
try {
emit('uploaded', { file: file.value, meta: { ...form } })
close()
} catch (e) {
formErr.value = e?.message || 'Erro ao enviar arquivo.'
} finally {
saving.value = false
}
}
function close() {
emit('update:visible', false)
}
</script>
<template>
<Dialog
:visible="visible"
@update:visible="$emit('update:visible', $event)"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
class="w-[40rem]"
:breakpoints="{ '768px': '94vw' }"
:pt="{
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
content: { class: '!p-4' },
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex items-center gap-3">
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-500/10">
<i class="pi pi-upload text-blue-500" />
</span>
<div>
<div class="text-base font-semibold">Upload de documento</div>
<div class="text-xs text-[var(--text-color-secondary)]" v-if="patientName">{{ patientName }}</div>
</div>
</div>
</template>
<!-- Drop zone -->
<div
v-if="!file"
class="flex flex-col items-center justify-center gap-3 p-8 rounded-lg border-2 border-dashed transition-colors cursor-pointer"
:class="dragging ? 'border-primary bg-primary/5' : 'border-[var(--surface-border)] hover:border-[var(--surface-400)]'"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDrop"
@click="$refs.fileInput.click()"
>
<i class="pi pi-cloud-upload text-3xl text-[var(--text-color-secondary)]" />
<div class="text-sm text-[var(--text-color-secondary)] text-center">
<span class="font-medium text-primary">Clique para selecionar</span> ou arraste o arquivo aqui
</div>
<div class="text-xs text-[var(--text-color-secondary)] opacity-60">
PDF, imagem, Word, Excel até 50 MB
</div>
<input ref="fileInput" type="file" class="hidden" @change="onFileSelected" />
</div>
<!-- Arquivo selecionado -->
<div v-else class="flex items-center gap-3 p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<img v-if="filePreviewUrl" :src="filePreviewUrl" class="w-12 h-12 rounded object-cover" />
<i v-else class="pi pi-file text-2xl text-[var(--text-color-secondary)]" />
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{{ file.name }}</div>
<div class="text-xs text-[var(--text-color-secondary)]">{{ fileSizeFormatted }}</div>
</div>
<Button icon="pi pi-times" text rounded size="small" severity="danger" @click="removeFile" />
</div>
<!-- Campos -->
<div class="flex flex-col gap-3.5 mt-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3.5">
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Tipo do documento</label>
<Select
v-model="form.tipo_documento"
:options="TIPOS"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Visibilidade</label>
<Select
v-model="form.visibilidade"
:options="VISIBILIDADES"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
</div>
<div class="flex flex-col gap-1" v-if="sessions.length">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Vincular a sessão (opcional)</label>
<Select
v-model="form.agenda_evento_id"
:options="sessions"
optionLabel="label"
optionValue="value"
placeholder="Nenhuma sessão"
showClear
class="w-full"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Descrição (opcional)</label>
<Textarea v-model="form.descricao" rows="2" autoResize class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Tags</label>
<DocumentTagsInput v-model="form.tags" :suggestions="usedTags" />
</div>
</div>
<!-- Erro -->
<div v-if="formErr" class="mt-3 text-sm text-red-500 flex items-center gap-1.5">
<i class="pi pi-exclamation-circle text-xs" />
{{ formErr }}
</div>
<template #footer>
<div class="flex items-center justify-end gap-2">
<Button label="Cancelar" text @click="close" :disabled="saving" />
<Button label="Enviar" icon="pi pi-upload" :loading="saving" @click="submit" :disabled="!file" />
</div>
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,197 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/documents/composables/useDocumentGenerate.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import {
loadAllVariables,
fillTemplate,
buildFullHtml,
generatePdfBlob,
generateAndDownloadPdf,
printDocument as printPdf,
saveGeneratedDocument,
listGeneratedDocuments
} from '@/services/DocumentGenerate.service';
import { getTemplate } from '@/services/DocumentTemplates.service';
// ── Composable ──────────────────────────────────────────────
export function useDocumentGenerate() {
const loading = ref(false);
const error = ref(null);
const generatedDocs = ref([]);
// Dados carregados para preenchimento
const variables = ref({});
const selectedTemplate = ref(null);
const previewHtml = ref('');
// ── Carregar variaveis do paciente/sessao ───────────────
async function loadVariables(patientId, agendaEventoId = null) {
loading.value = true;
error.value = null;
try {
variables.value = await loadAllVariables(patientId, agendaEventoId);
} catch (e) {
error.value = e?.message || 'Erro ao carregar dados do paciente.';
variables.value = {};
} finally {
loading.value = false;
}
}
// ── Selecionar template e gerar preview ─────────────────
async function selectTemplate(templateId) {
loading.value = true;
error.value = null;
try {
selectedTemplate.value = await getTemplate(templateId);
updatePreview();
} catch (e) {
error.value = e?.message || 'Erro ao carregar template.';
selectedTemplate.value = null;
previewHtml.value = '';
} finally {
loading.value = false;
}
}
// ── Atualizar preview ───────────────────────────────────
function updatePreview() {
if (!selectedTemplate.value) {
previewHtml.value = '';
return;
}
previewHtml.value = buildFullHtml(selectedTemplate.value, variables.value);
}
// ── Atualizar variavel individual ───────────────────────
function setVariable(key, value) {
variables.value[key] = value;
updatePreview();
}
// ── Gerar PDF (client-side) ────────────────────────────
/**
* Gera PDF blob, faz download, salva no Storage + banco.
*/
async function generateAndSave(patientId) {
if (!selectedTemplate.value) throw new Error('Nenhum template selecionado.');
loading.value = true;
error.value = null;
try {
const templateNome = selectedTemplate.value.nome_template || 'documento';
// Gera PDF blob
const blob = await generatePdfBlob(selectedTemplate.value, variables.value);
// Salva no Storage + banco (generated-docs + documents)
const result = await saveGeneratedDocument({
templateId: selectedTemplate.value.id,
patientId,
dadosPreenchidos: { ...variables.value },
pdfBlob: blob,
templateNome
});
generatedDocs.value.unshift(result);
return result;
} catch (e) {
error.value = e?.message || 'Erro ao gerar documento.';
throw e;
} finally {
loading.value = false;
}
}
/**
* Gera somente o PDF e faz download, sem salvar no banco.
*/
async function downloadOnly() {
if (!selectedTemplate.value) return;
loading.value = true;
error.value = null;
try {
const templateNome = selectedTemplate.value?.nome_template || 'documento';
const filename = `${templateNome.replace(/\s+/g, '_')}_${Date.now()}.pdf`;
await generateAndDownloadPdf(selectedTemplate.value, variables.value, filename);
} catch (e) {
error.value = e?.message || 'Erro ao gerar PDF.';
throw e;
} finally {
loading.value = false;
}
}
/**
* Abre PDF em nova aba para impressao.
*/
function printDocument() {
if (!selectedTemplate.value) return;
printPdf(selectedTemplate.value, variables.value);
}
// ── Carregar historico de documentos gerados ────────────
async function fetchGeneratedDocs(patientId) {
loading.value = true;
error.value = null;
try {
generatedDocs.value = await listGeneratedDocuments(patientId);
} catch (e) {
error.value = e?.message || 'Erro ao carregar documentos gerados.';
generatedDocs.value = [];
} finally {
loading.value = false;
}
}
// ── Reset ───────────────────────────────────────────────
function reset() {
selectedTemplate.value = null;
variables.value = {};
previewHtml.value = '';
error.value = null;
}
return {
// State
loading,
error,
variables,
selectedTemplate,
previewHtml,
generatedDocs,
// Actions
loadVariables,
selectTemplate,
updatePreview,
setVariable,
generateAndSave,
downloadOnly,
printDocument,
fetchGeneratedDocs,
reset
};
}

View File

@@ -0,0 +1,213 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/documents/composables/useDocumentTemplates.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import {
listTemplates,
listAllTemplates,
getTemplate,
createTemplate,
updateTemplate,
deleteTemplate,
duplicateTemplate,
extractVariablesFromHtml,
TEMPLATE_VARIABLES
} from '@/services/DocumentTemplates.service';
// ── Composable ──────────────────────────────────────────────
export function useDocumentTemplates() {
const templates = ref([]);
const loading = ref(false);
const error = ref(null);
const currentTemplate = ref(null);
// ── Tipos de template (para selects) ────────────────────
const TIPOS_TEMPLATE = [
{ value: 'declaracao_comparecimento', label: 'Declaração de comparecimento' },
{ value: 'atestado_psicologico', label: 'Atestado psicológico' },
{ value: 'relatorio_acompanhamento', label: 'Relatório de acompanhamento' },
{ value: 'recibo_pagamento', label: 'Recibo de pagamento' },
{ value: 'termo_consentimento', label: 'Termo de consentimento (TCLE)' },
{ value: 'encaminhamento', label: 'Encaminhamento' },
{ value: 'outro', label: 'Outro' }
];
// ── Computed ────────────────────────────────────────────
const globalTemplates = computed(() =>
templates.value.filter(t => t.is_global)
);
const tenantTemplates = computed(() =>
templates.value.filter(t => !t.is_global)
);
const activeTemplates = computed(() =>
templates.value.filter(t => t.ativo)
);
// ── Variaveis agrupadas (para dropdown no editor) ───────
const variablesGrouped = computed(() => {
const groups = {};
for (const v of TEMPLATE_VARIABLES) {
if (!groups[v.grupo]) groups[v.grupo] = [];
groups[v.grupo].push(v);
}
return groups;
});
// ── Carregar ────────────────────────────────────────────
async function fetchTemplates(includeInactive = false) {
loading.value = true;
error.value = null;
try {
templates.value = includeInactive
? await listAllTemplates()
: await listTemplates();
} catch (e) {
error.value = e?.message || 'Erro ao carregar templates.';
templates.value = [];
} finally {
loading.value = false;
}
}
async function fetchTemplate(id) {
loading.value = true;
error.value = null;
try {
currentTemplate.value = await getTemplate(id);
} catch (e) {
error.value = e?.message || 'Erro ao carregar template.';
currentTemplate.value = null;
} finally {
loading.value = false;
}
}
// ── CRUD ────────────────────────────────────────────────
async function create(payload) {
const created = await createTemplate(payload);
templates.value.unshift(created);
return created;
}
async function update(id, payload) {
const updated = await updateTemplate(id, payload);
const idx = templates.value.findIndex(t => t.id === id);
if (idx >= 0) templates.value[idx] = updated;
if (currentTemplate.value?.id === id) currentTemplate.value = updated;
return updated;
}
async function remove(id) {
await deleteTemplate(id);
templates.value = templates.value.filter(t => t.id !== id);
}
async function duplicate(id) {
const copy = await duplicateTemplate(id);
templates.value.unshift(copy);
return copy;
}
// ── Extrair variaveis do HTML ───────────────────────────
function extractVariables(html) {
return extractVariablesFromHtml(html);
}
// ── Preview com dados ficticios ─────────────────────────
const SAMPLE_DATA = {
paciente_nome: 'Maria Silva Santos',
paciente_nome_social: 'Maria Santos',
paciente_cpf: '123.456.789-00',
paciente_data_nascimento: '15/03/1990',
paciente_telefone: '(16) 99999-0000',
paciente_email: 'maria@exemplo.com',
paciente_endereco: 'Rua das Flores, 123, Centro, São Carlos/SP',
data_sessao: '28/03/2026',
hora_inicio: '14:00',
hora_fim: '14:50',
modalidade: 'Presencial',
terapeuta_nome: 'Dr. João Oliveira',
terapeuta_crp: '06/12345',
terapeuta_email: 'joao@clinica.com',
terapeuta_telefone: '(16) 3333-0000',
clinica_nome: 'Clínica Exemplo',
clinica_endereco: 'Av. São Carlos, 500, Centro, São Carlos/SP',
clinica_telefone: '(16) 3333-1111',
clinica_cnpj: '12.345.678/0001-00',
valor: 'R$ 200,00',
valor_extenso: 'duzentos reais',
forma_pagamento: 'PIX',
data_atual: new Date().toLocaleDateString('pt-BR'),
data_atual_extenso: formatDateExtenso(new Date()),
cidade_estado: 'São Carlos/SP'
};
function formatDateExtenso(date) {
const meses = [
'janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho',
'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'
];
return `${date.getDate()} de ${meses[date.getMonth()]} de ${date.getFullYear()}`;
}
function previewHtml(html) {
return String(html || '').replace(/\{\{(\w+)\}\}/g, (match, key) => {
return SAMPLE_DATA[key] !== undefined
? `<span style="background:#fef3c7;padding:1px 4px;border-radius:3px;">${SAMPLE_DATA[key]}</span>`
: `<span style="background:#fee2e2;padding:1px 4px;border-radius:3px;">${match}</span>`;
});
}
return {
// State
templates,
loading,
error,
currentTemplate,
// Constants
TIPOS_TEMPLATE,
TEMPLATE_VARIABLES,
SAMPLE_DATA,
// Computed
globalTemplates,
tenantTemplates,
activeTemplates,
variablesGrouped,
// Actions
fetchTemplates,
fetchTemplate,
create,
update,
remove,
duplicate,
extractVariables,
previewHtml
};
}

View File

@@ -0,0 +1,231 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/documents/composables/useDocuments.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref, computed, watch } from 'vue';
import {
listDocuments,
listAllDocuments,
uploadDocument,
updateDocument,
softDeleteDocument,
restoreDocument,
getDownloadUrl,
getUsedTags
} from '@/services/Documents.service';
import { logAccess } from '@/services/DocumentAuditLog.service';
// ── Composable ──────────────────────────────────────────────
export function useDocuments(patientId = null) {
const documents = ref([]);
const loading = ref(false);
const error = ref(null);
const usedTags = ref([]);
// Filtros reativos
const filters = ref({
tipo_documento: null,
categoria: null,
tag: null,
search: ''
});
// ── Computed: stats rapidos ─────────────────────────────
const stats = computed(() => {
const docs = documents.value;
const total = docs.length;
const porTipo = {};
const pendentesRevisao = docs.filter(d => d.status_revisao === 'pendente').length;
for (const d of docs) {
const tipo = d.tipo_documento || 'outro';
porTipo[tipo] = (porTipo[tipo] || 0) + 1;
}
const tamanhoTotal = docs.reduce((sum, d) => sum + (d.tamanho_bytes || 0), 0);
return { total, porTipo, pendentesRevisao, tamanhoTotal };
});
// ── Tipos de documento (para filtros) ───────────────────
const TIPOS_DOCUMENTO = [
{ value: 'laudo', label: 'Laudo' },
{ value: 'receita', label: 'Receita' },
{ value: 'exame', label: 'Exame' },
{ value: 'termo_assinado', label: 'Termo assinado' },
{ value: 'relatorio_externo', label: 'Relatório externo' },
{ value: 'identidade', label: 'Identidade' },
{ value: 'convenio', label: 'Convênio' },
{ value: 'declaracao', label: 'Declaração' },
{ value: 'atestado', label: 'Atestado' },
{ value: 'recibo', label: 'Recibo' },
{ value: 'outro', label: 'Outro' }
];
// ── Carregar documentos ─────────────────────────────────
async function fetchDocuments() {
loading.value = true;
error.value = null;
try {
const activeFilters = {};
if (filters.value.tipo_documento) activeFilters.tipo_documento = filters.value.tipo_documento;
if (filters.value.categoria) activeFilters.categoria = filters.value.categoria;
if (filters.value.tag) activeFilters.tag = filters.value.tag;
if (filters.value.search) activeFilters.search = filters.value.search;
const pid = typeof patientId === 'function' ? patientId() : patientId;
if (pid) {
documents.value = await listDocuments(pid, activeFilters);
} else {
documents.value = await listAllDocuments(activeFilters);
}
} catch (e) {
error.value = e?.message || 'Erro ao carregar documentos.';
documents.value = [];
} finally {
loading.value = false;
}
}
// ── Upload ──────────────────────────────────────────────
async function upload(file, targetPatientId, meta = {}) {
const pid = targetPatientId || (typeof patientId === 'function' ? patientId() : patientId);
if (!pid) throw new Error('Paciente não informado.');
const doc = await uploadDocument(file, pid, meta);
documents.value.unshift(doc);
return doc;
}
// ── Update ──────────────────────────────────────────────
async function update(id, payload) {
const updated = await updateDocument(id, payload);
const idx = documents.value.findIndex(d => d.id === id);
if (idx >= 0) documents.value[idx] = updated;
return updated;
}
// ── Soft delete ─────────────────────────────────────────
async function remove(id) {
await softDeleteDocument(id);
documents.value = documents.value.filter(d => d.id !== id);
}
// ── Restore ─────────────────────────────────────────────
async function restore(id) {
await restoreDocument(id);
await fetchDocuments();
}
// ── Download com auditoria ──────────────────────────────
async function download(doc) {
const bucket = doc.storage_bucket || undefined;
const url = await getDownloadUrl(doc.bucket_path, 60, bucket);
logAccess(doc.id, 'baixou');
// Abrir download
const a = document.createElement('a');
a.href = url;
a.download = doc.nome_original || 'arquivo';
a.target = '_blank';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// ── Preview com auditoria ───────────────────────────────
async function getPreviewUrl(doc) {
const bucket = doc.storage_bucket || undefined;
const url = await getDownloadUrl(doc.bucket_path, 300, bucket);
logAccess(doc.id, 'visualizou');
return url;
}
// ── Tags ────────────────────────────────────────────────
async function fetchUsedTags() {
try {
usedTags.value = await getUsedTags();
} catch {
usedTags.value = [];
}
}
// ── Limpar filtros ──────────────────────────────────────
function clearFilters() {
filters.value = { tipo_documento: null, categoria: null, tag: null, search: '' };
}
// ── Helper: formatar tamanho ────────────────────────────
function formatSize(bytes) {
if (!bytes) return '—';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ── Helper: icone por mime type ─────────────────────────
function mimeIcon(mimeType) {
const m = String(mimeType || '');
if (m.startsWith('image/')) return 'pi pi-image';
if (m === 'application/pdf') return 'pi pi-file-pdf';
if (m.includes('word') || m.includes('document')) return 'pi pi-file-word';
if (m.includes('excel') || m.includes('sheet')) return 'pi pi-file-excel';
if (m.startsWith('text/')) return 'pi pi-file';
return 'pi pi-file';
}
return {
// State
documents,
loading,
error,
filters,
usedTags,
// Computed
stats,
TIPOS_DOCUMENTO,
// Actions
fetchDocuments,
upload,
update,
remove,
restore,
download,
getPreviewUrl,
fetchUsedTags,
clearFilters,
// Helpers
formatSize,
mimeIcon
};
}

View File

@@ -771,7 +771,7 @@ function isRecent(row) {
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchAll" /> <Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchAll" />
<Button icon="pi pi-percentage" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.top="'Descontos'" @click="router.push('/configuracoes/descontos')" /> <Button icon="pi pi-percentage" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.top="'Descontos'" @click="router.push('/configuracoes/descontos')" />
<Button label="Novo" icon="pi pi-user-plus" class="rounded-full" @click="(e) => createPopoverRef?.toggle(e)" /> <Button label="Novo" icon="pi pi-user-plus" class="rounded-full" @click="(e) => createPopoverRef?.toggle(e)" />
<PatientCreatePopover ref="createPopoverRef" @quick-create="openQuickCreate" /> <PatientCreatePopover ref="createPopoverRef" @quick-create="openQuickCreate" @go-complete="goCreateFull" />
<PatientCadastroDialog v-model="cadastroFullDialog" :patient-id="editPatientId" @created="onPatientCreated" /> <PatientCadastroDialog v-model="cadastroFullDialog" :patient-id="editPatientId" @created="onPatientCreated" />
</div> </div>

View File

@@ -0,0 +1,965 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/cadastro/PatientsCadastroPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoleGuard } from '@/composables/useRoleGuard'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { digitsOnly, fmtCPF, fmtRG, fmtPhone, sanitizeDigits, toISODate, generateCPF } from '@/utils/validators'
const props = defineProps({
dialogMode: { type: Boolean, default: false },
patientId: { type: String, default: null }
})
const emit = defineEmits(['cancel', 'created'])
const { canSee } = useRoleGuard()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const tenantStore = useTenantStore()
// ── Tenant helpers ────────────────────────────────────────
async function getCurrentTenantId () {
return tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
}
async function getCurrentMemberId (tenantId) {
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')
.eq('tenant_id', tenantId).eq('user_id', uid).eq('status', 'active').single()
if (error) throw error
if (!data?.id) throw new Error('Responsible member not found')
return data.id
}
// ── Accordion ─────────────────────────────────────────────
const activeValue = ref('0')
const panelHeaderRefs = ref([])
function setPanelHeaderRef (el, idx) { if (!el) return; panelHeaderRefs.value[idx] = el }
async function openPanel (i) {
activeValue.value = String(i)
await nextTick()
const headerRef = panelHeaderRefs.value?.[i]
const el = headerRef?.$el ?? headerRef
if (!el) return
const scrollContainer = el.closest('.l2-main') || document.querySelector('.l2-main')
if (scrollContainer) {
const containerRect = scrollContainer.getBoundingClientRect()
const elRect = el.getBoundingClientRect()
const offset = elRect.top - containerRect.top + scrollContainer.scrollTop - 16
scrollContainer.scrollTo({ top: Math.max(0, offset), behavior: 'smooth' })
} else if (typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
// ── Nav items ─────────────────────────────────────────────
const navItems = [
{ value: '0', label: 'Informações pessoais', icon: 'pi pi-user' },
{ value: '1', label: 'Endereço', icon: 'pi pi-map-marker' },
{ value: '2', label: 'Dados adicionais', icon: 'pi pi-briefcase' },
{ value: '3', label: 'Responsável', icon: 'pi pi-users' },
{ value: '4', label: 'Anotações internas', icon: 'pi pi-lock' },
]
const navPopover = ref(null)
function toggleNav (event) { navPopover.value?.toggle(event) }
function selectNav (item) { openPanel(Number(item.value)); navPopover.value?.hide() }
const selectedNav = computed(() => navItems.find(i => i.value === activeValue.value) || null)
// Responsivo < 1200px
const isCompact = ref(false)
let mql = null
let mqlHandler = null
function syncCompact () { isCompact.value = !!mql?.matches }
onMounted(() => {
mql = window.matchMedia('(max-width: 1199px)')
syncCompact()
mqlHandler = () => syncCompact()
if (mql.addEventListener) mql.addEventListener('change', mqlHandler)
else mql.addListener(mqlHandler)
})
onBeforeUnmount(() => {
if (!mql || !mqlHandler) return
if (mql.removeEventListener) mql.removeEventListener('change', mqlHandler)
else mql.removeListener(mqlHandler)
})
// ── Route helpers ─────────────────────────────────────────
const patientId = computed(() =>
props.dialogMode
? (props.patientId || null)
: (String(route.params?.id || '').trim() || null)
)
const isEdit = computed(() => !!patientId.value)
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 () {
if (props.dialogMode) { emit('cancel'); return }
const { listName, listPath } = getPatientsRoutes()
if (window.history.length > 1) router.back()
else safePush({ name: listName }, listPath)
}
// ── Avatar ────────────────────────────────────────────────
const avatarFile = ref(null)
const avatarPreviewUrl = ref('')
const avatarUploading = ref(false)
const AVATAR_BUCKET = 'avatars'
function isImageFile (file) { return !!file && typeof file.type === 'string' && file.type.startsWith('image/') }
function safeExtFromFile (file) { const name = String(file?.name || ''); const ext = name.includes('.') ? name.split('.').pop() : ''; return String(ext || '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'png' }
function revokePreview () { if (avatarPreviewUrl.value?.startsWith('blob:')) { try { URL.revokeObjectURL(avatarPreviewUrl.value) } catch (_) {} } avatarPreviewUrl.value = '' }
function onAvatarPicked (ev) {
const file = ev?.target?.files?.[0] || null
avatarFile.value = null; revokePreview()
if (!file) return
if (!isImageFile(file)) { toast.add({ severity: 'warn', summary: 'Avatar', detail: 'Selecione um arquivo de imagem (PNG/JPG/WebP).', life: 3000 }); return }
avatarFile.value = file
avatarPreviewUrl.value = URL.createObjectURL(file)
toast.add({ severity: 'info', summary: 'Avatar', detail: 'Preview carregado. Clique em "Salvar" para enviar.', life: 2500 })
}
async function getReadableAvatarUrl (path) {
try { const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path); if (pub?.publicUrl) return pub.publicUrl } catch (_) {}
const { data, error } = await supabase.storage.from(AVATAR_BUCKET).createSignedUrl(path, 60 * 60 * 24 * 7)
if (error) throw error
if (!data?.signedUrl) throw new Error('Não consegui gerar signed URL do avatar.')
return data.signedUrl
}
async function uploadAvatarToStorage ({ ownerId, patientId, file }) {
if (!ownerId) throw new Error('ownerId ausente.')
if (!patientId) throw new Error('patientId ausente.')
if (!file) throw new Error('Arquivo de avatar ausente.')
if (!isImageFile(file)) throw new Error('Selecione um arquivo de imagem (PNG/JPG/WebP).')
if (file.size > 3 * 1024 * 1024) throw new Error('Imagem muito grande. Use até 3MB.')
const ext = safeExtFromFile(file)
const path = `owners/${ownerId}/patients/${patientId}/avatar.${ext}`
const { error: upErr } = await supabase.storage.from(AVATAR_BUCKET).upload(path, file, { upsert: true, cacheControl: '3600', contentType: file.type || 'image/*' })
if (upErr) throw upErr
return { publicUrl: await getReadableAvatarUrl(path), path }
}
async function maybeUploadAvatar (ownerId, id) {
if (!avatarFile.value) return null
avatarUploading.value = true
try {
const { publicUrl } = await uploadAvatarToStorage({ ownerId, patientId: id, file: avatarFile.value })
form.value.avatar_url = publicUrl; avatarFile.value = null; revokePreview(); avatarPreviewUrl.value = publicUrl
await updatePatient(id, { avatar_url: publicUrl })
return publicUrl
} catch (e) {
toast.add({ severity: 'warn', summary: 'Avatar', detail: e?.message || 'Falha ao enviar avatar.', life: 4500 }); return null
} finally { avatarUploading.value = false }
}
// ── Form state ────────────────────────────────────────────
function resetForm () {
return {
nome_completo: '', telefone: '', email_principal: '', email_alternativo: '', telefone_alternativo: '',
data_nascimento: '', genero: '', estado_civil: '', cpf: '', rg: '', naturalidade: '',
observacoes: '', onde_nos_conheceu: '', encaminhado_por: '',
cep: '', pais: 'Brasil', cidade: '', estado: 'SP', endereco: '', numero: '', bairro: '', complemento: '',
escolaridade: '', profissao: '', nome_parente: '', grau_parentesco: '', telefone_parente: '',
nome_responsavel: '', cpf_responsavel: '', telefone_responsavel: '', observacao_responsavel: '',
cobranca_no_responsavel: false, notas_internas: '', avatar_url: ''
}
}
const form = ref(resetForm())
// ── Helpers ───────────────────────────────────────────────
function parseDDMMYYYY (s) {
const str = String(s || '').trim(); const m = /^(\d{2})-(\d{2})-(\d{4})$/.exec(str); if (!m) return null
const dd = Number(m[1]), mm = Number(m[2]), yyyy = Number(m[3]); const dt = new Date(yyyy, mm - 1, dd)
if (Number.isNaN(dt.getTime())) return null
if (dt.getFullYear() !== yyyy || dt.getMonth() !== mm - 1 || dt.getDate() !== dd) return null
return dt
}
function isoToDDMMYYYY (value) {
if (!value) return ''; const s = String(value).trim()
if (/^\d{2}-\d{2}-\d{4}$/.test(s)) return s
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s); if (m) return `${m[3]}-${m[2]}-${m[1]}`
const d = new Date(s); if (Number.isNaN(d.getTime())) return ''
return `${String(d.getDate()).padStart(2,'0')}-${String(d.getMonth()+1).padStart(2,'0')}-${d.getFullYear()}`
}
const ageLabel = computed(() => {
const dt = parseDDMMYYYY(form.value?.data_nascimento); if (!dt) return '—'
const now = new Date(); let age = now.getFullYear() - dt.getFullYear()
const mm = now.getMonth() - dt.getMonth()
if (mm < 0 || (mm === 0 && now.getDate() < dt.getDate())) age--
if (age < 0 || age > 130) return '—'
return `${age} anos`
})
// ── DB map ────────────────────────────────────────────────
function mapDbToForm (p) {
return { ...resetForm(), nome_completo: p.nome_completo ?? '', telefone: fmtPhone(p.telefone ?? ''), email_principal: p.email_principal ?? '', email_alternativo: p.email_alternativo ?? '', telefone_alternativo: fmtPhone(p.telefone_alternativo ?? ''), data_nascimento: p.data_nascimento ? isoToDDMMYYYY(p.data_nascimento) : '', genero: p.genero ?? '', estado_civil: p.estado_civil ?? '', cpf: fmtCPF(p.cpf ?? ''), rg: fmtRG(p.rg ?? ''), naturalidade: p.naturalidade ?? '', observacoes: p.observacoes ?? '', onde_nos_conheceu: p.onde_nos_conheceu ?? '', encaminhado_por: p.encaminhado_por ?? '', cep: p.cep ?? '', pais: p.pais ?? 'Brasil', cidade: p.cidade ?? '', estado: p.estado ?? 'SP', endereco: p.endereco ?? '', numero: p.numero ?? '', bairro: p.bairro ?? '', complemento: p.complemento ?? '', escolaridade: p.escolaridade ?? '', profissao: p.profissao ?? '', nome_parente: p.nome_parente ?? '', grau_parentesco: p.grau_parentesco ?? '', telefone_parente: fmtPhone(p.telefone_parente ?? ''), nome_responsavel: p.nome_responsavel ?? '', cpf_responsavel: fmtCPF(p.cpf_responsavel ?? ''), telefone_responsavel: fmtPhone(p.telefone_responsavel ?? ''), observacao_responsavel: p.observacao_responsavel ?? '', cobranca_no_responsavel: !!p.cobranca_no_responsavel, notas_internas: p.notas_internas ?? '', avatar_url: p.avatar_url ?? '' }
}
// ── Auth ──────────────────────────────────────────────────
async function getOwnerId () {
const { data, error } = await supabase.auth.getUser(); if (error) throw error
const uid = data?.user?.id; if (!uid) throw new Error('Sessão inválida (auth.getUser).'); return uid
}
// ── Sanitize ──────────────────────────────────────────────
const PACIENTES_COLUNAS_PERMITIDAS = new Set(['owner_id','tenant_id','responsible_member_id','nome_completo','telefone','email_principal','email_alternativo','telefone_alternativo','data_nascimento','genero','estado_civil','cpf','rg','naturalidade','observacoes','onde_nos_conheceu','encaminhado_por','pais','cep','cidade','estado','endereco','numero','bairro','complemento','escolaridade','profissao','nome_parente','grau_parentesco','telefone_parente','nome_responsavel','cpf_responsavel','telefone_responsavel','observacao_responsavel','cobranca_no_responsavel','notas_internas','avatar_url'])
function sanitizePayload (raw, ownerId) {
const payload = { owner_id: ownerId, nome_completo: raw.nome_completo, telefone: raw.telefone, email_principal: raw.email_principal, email_alternativo: raw.email_alternativo || null, telefone_alternativo: raw.telefone_alternativo || null, data_nascimento: raw.data_nascimento || null, genero: raw.genero || null, estado_civil: raw.estado_civil || null, cpf: raw.cpf || null, rg: raw.rg || null, naturalidade: raw.naturalidade || null, observacoes: raw.observacoes || null, onde_nos_conheceu: raw.onde_nos_conheceu || null, encaminhado_por: raw.encaminhado_por || null, cep: raw.cep || null, pais: raw.pais || null, cidade: raw.cidade || null, estado: raw.estado || null, endereco: raw.endereco || null, numero: raw.numero || null, bairro: raw.bairro || null, complemento: raw.complemento || null, escolaridade: raw.escolaridade || null, profissao: raw.profissao || null, nome_parente: raw.nome_parente || null, grau_parentesco: raw.grau_parentesco || null, telefone_parente: raw.telefone_parente || null, nome_responsavel: raw.nome_responsavel || null, cpf_responsavel: raw.cpf_responsavel || null, telefone_responsavel: raw.telefone_responsavel || null, observacao_responsavel: raw.observacao_responsavel || null, cobranca_no_responsavel: !!raw.cobranca_no_responsavel, notas_internas: raw.notas_internas || null, avatar_url: raw.avatar_url || null }
Object.keys(payload).forEach(k => { if (payload[k] === '') payload[k] = null; if (typeof payload[k] === 'string') { const t = payload[k].trim(); payload[k] = t === '' ? null : t } })
payload.cpf = payload.cpf ? digitsOnly(payload.cpf) : null
payload.rg = payload.rg ? digitsOnly(payload.rg) : null
payload.cpf_responsavel = payload.cpf_responsavel ? digitsOnly(payload.cpf_responsavel) : null
payload.telefone = payload.telefone ? digitsOnly(payload.telefone) : null
payload.telefone_alternativo = payload.telefone_alternativo ? digitsOnly(payload.telefone_alternativo) : null
payload.telefone_parente = payload.telefone_parente ? digitsOnly(payload.telefone_parente) : null
payload.telefone_responsavel = payload.telefone_responsavel ? digitsOnly(payload.telefone_responsavel) : null
payload.data_nascimento = payload.data_nascimento ? (toISODate(payload.data_nascimento) || null) : null
const filtrado = {}; Object.keys(payload).forEach(k => { if (PACIENTES_COLUNAS_PERMITIDAS.has(k)) filtrado[k] = payload[k] })
return filtrado
}
// ── DB calls ──────────────────────────────────────────────
async function listGroups () {
const probe = await supabase.from('patient_groups').select('*').limit(1); if (probe.error) throw probe.error
const row = probe.data?.[0] || {}; const hasPT = ('nome' in row) || ('cor' in row); const hasEN = ('name' in row) || ('color' in row)
if (hasPT) { const { data, error } = await supabase.from('patient_groups').select('id,nome,descricao,cor,is_system,is_active').eq('is_active', true).order('nome', { ascending: true }); if (error) throw error; return (data || []).map(g => ({ ...g, name: g.nome, color: g.cor })) }
if (hasEN) { const { data, error } = await supabase.from('patient_groups').select('id,name,description,color,is_system,is_active').eq('is_active', true).order('name', { ascending: true }); if (error) throw error; return (data || []).map(g => ({ ...g, nome: g.name, cor: g.color })) }
const { data, error } = await supabase.from('patient_groups').select('*').order('id', { ascending: true }); if (error) throw error; return data || []
}
async function listTags () {
const probe = await supabase.from('patient_tags').select('*').limit(1); if (probe.error) throw probe.error
const row = probe.data?.[0] || {}; const hasEN = ('name' in row) || ('color' in row); const hasPT = ('nome' in row) || ('cor' in row)
if (hasEN) { const { data, error } = await supabase.from('patient_tags').select('id,name,color').order('name', { ascending: true }); if (error) throw error; return data || [] }
if (hasPT) { const { data, error } = await supabase.from('patient_tags').select('id,nome,cor').order('nome', { ascending: true }); if (error) throw error; return (data || []).map(t => ({ ...t, name: t.nome, color: t.cor })) }
const { data, error } = await supabase.from('patient_tags').select('*').order('id', { ascending: true }); if (error) throw error; return (data || []).map(t => ({ ...t, name: t.name ?? t.nome ?? '', color: t.color ?? t.cor ?? null }))
}
async function getPatientById (id) { const { data, error } = await supabase.from('patients').select('*').eq('id', id).single(); if (error) throw error; return data }
async function getPatientRelations (id) {
const { data: g, error: ge } = await supabase.from('patient_group_patient').select('patient_group_id').eq('patient_id', id); if (ge) throw ge
const { data: t, error: te } = await supabase.from('patient_patient_tag').select('tag_id').eq('patient_id', id); if (te) throw te
return { groupIds: (g || []).map(x => x.patient_group_id).filter(Boolean), tagIds: (t || []).map(x => x.tag_id).filter(Boolean) }
}
async function createPatient (payload) { const { data, error } = await supabase.from('patients').insert(payload).select('id').single(); if (error) throw error; return data }
async function updatePatient (id, payload) { const { error } = await supabase.from('patients').update({ ...payload, updated_at: new Date().toISOString() }).eq('id', id); if (error) throw error }
// ── Relations ─────────────────────────────────────────────
const groups = ref([])
const tags = ref([])
const grupoIdSelecionado = ref(null)
const tagIdsSelecionadas = ref([])
async function replacePatientGroups (patient_id, groupId) {
const { error: delErr } = await supabase.from('patient_group_patient').delete().eq('patient_id', patient_id); if (delErr) throw delErr
if (!groupId) return
const { tenantId } = await resolveTenantContextOrFail()
const { error: insErr } = await supabase.from('patient_group_patient').insert({ patient_id, patient_group_id: groupId, tenant_id: tenantId }); if (insErr) throw insErr
}
async function replacePatientTags (patient_id, tagIds) {
const ownerId = await getOwnerId()
const { error: delErr } = await supabase.from('patient_patient_tag').delete().eq('patient_id', patient_id).eq('owner_id', ownerId); if (delErr) throw delErr
const clean = Array.from(new Set([...(tagIds || [])].filter(Boolean))); if (!clean.length) return
const { tenantId } = await resolveTenantContextOrFail()
const rows = clean.map(tag_id => ({ owner_id: ownerId, patient_id, tag_id, tenant_id: tenantId }))
const { error: insErr } = await supabase.from('patient_patient_tag').insert(rows); if (insErr) throw insErr
}
// ── CEP ───────────────────────────────────────────────────
async function fetchCep (cepRaw) {
const cep = digitsOnly(cepRaw); if (cep.length !== 8) return null
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`)
const data = await res.json(); if (!data || data.erro) return null; return data
}
async function onCepBlur () {
try {
const d = await fetchCep(form.value.cep); if (!d) return
form.value.cidade = d.localidade || form.value.cidade; form.value.estado = d.uf || form.value.estado
form.value.bairro = d.bairro || form.value.bairro; form.value.endereco = d.logradouro || form.value.endereco
if (!form.value.complemento) form.value.complemento = d.complemento || ''
} catch (_) {}
}
// ── UI state ──────────────────────────────────────────────
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
// ── Fetch ─────────────────────────────────────────────────
async function fetchAll () {
loading.value = true
try {
const [gRes, tRes] = await Promise.allSettled([listGroups(), listTags()])
if (gRes.status === 'fulfilled') groups.value = gRes.value || []
else { groups.value = []; toast.add({ severity: 'warn', summary: 'Grupos', detail: gRes.reason?.message || 'Falha ao carregar grupos', life: 3500 }) }
if (tRes.status === 'fulfilled') tags.value = tRes.value || []
else { tags.value = []; toast.add({ severity: 'warn', summary: 'Tags', detail: tRes.reason?.message || 'Falha ao carregar tags', life: 3500 }) }
if (isEdit.value) {
const p = await getPatientById(patientId.value)
form.value = mapDbToForm(p)
avatarPreviewUrl.value = form.value.avatar_url || ''
const rel = await getPatientRelations(patientId.value)
grupoIdSelecionado.value = rel.groupIds?.[0] || null
tagIdsSelecionadas.value = rel.tagIds || []
} else {
grupoIdSelecionado.value = null; tagIdsSelecionadas.value = []; avatarFile.value = null; revokePreview()
}
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar cadastro', life: 3500 })
} finally { loading.value = false }
}
watch(patientId, fetchAll, { immediate: true })
// ── Tenant resolve ────────────────────────────────────────
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 storeTid = await getCurrentTenantId()
if (storeTid) { try { const mid = await getCurrentMemberId(storeTid); return { tenantId: storeTid, memberId: mid } } catch (_) {} }
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 }
}
// ── Submit ────────────────────────────────────────────────
async function onSubmit () {
try {
saving.value = true
const ownerId = await getOwnerId()
const { tenantId, memberId } = await resolveTenantContextOrFail()
const payload = sanitizePayload(form.value, ownerId)
payload.tenant_id = tenantId; payload.responsible_member_id = memberId
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 }); await openPanel(0); return }
if (isEdit.value) {
await updatePatient(patientId.value, payload)
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 })
if (props.dialogMode) { emit('created', { id: patientId.value }); return }
return
}
const created = await createPatient(payload)
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 })
if (props.dialogMode) { emit('created', created); return }
form.value = resetForm(); grupoIdSelecionado.value = null; tagIdsSelecionadas.value = []
avatarFile.value = null; revokePreview(); avatarPreviewUrl.value = ''
await openPanel(0)
} catch (e) {
console.error(e); toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar paciente.', life: 4000 })
} finally { saving.value = false }
}
// ── Delete ────────────────────────────────────────────────
function confirmDelete () {
if (!isEdit.value) return
confirm.require({ header: 'Excluir paciente', message: 'Tem certeza que deseja excluir este paciente? Essa ação não pode ser desfeita.', icon: 'pi pi-exclamation-triangle', acceptLabel: 'Excluir', rejectLabel: 'Cancelar', acceptClass: 'p-button-danger', accept: async () => doDelete() })
}
async function doDelete () {
if (!isEdit.value) return
deleting.value = true
try {
const pid = patientId.value
const { error: e1 } = await supabase.from('patient_group_patient').delete().eq('patient_id', pid); if (e1) throw e1
const { error: e2 } = await supabase.from('patient_patient_tag').delete().eq('patient_id', pid); if (e2) throw e2
const { error: e3 } = await supabase.from('patients').delete().eq('id', pid); if (e3) throw e3
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Paciente excluído.', life: 2500 })
if (props.dialogMode) { emit('created', null); return }
goBack()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao excluir paciente', life: 4000 })
} finally { deleting.value = false }
}
// ── Fake fill ─────────────────────────────────────────────
function randInt (min, max) { return Math.floor(Math.random() * (max - min + 1)) + min }
function pick (arr) { return arr[randInt(0, arr.length - 1)] }
function maybe (p = 0.5) { return Math.random() < p }
function pad2 (n) { return String(n).padStart(2, '0') }
function randomDateDDMMYYYY (minAge = 6, maxAge = 75) { const now = new Date(); const age = randInt(minAge, maxAge); return `${pad2(randInt(1,28))}-${pad2(randInt(1,12))}-${now.getFullYear() - age}` }
function randomPhoneBR () { return `+55 (${randInt(11,99)}) ${maybe(0.8)?'9':''}${randInt(1000,9999)}-${randInt(1000,9999)}` }
function randomCEP () { return `${randInt(10000,99999)}-${randInt(100,999)}` }
function randomEmailFromName (name) { return `${String(name||'paciente').normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/[^a-z0-9]+/g,'.').replace(/(^\.)|(\.$)/g,'')}.${randInt(10,999)}@email.com` }
function fillRandomPatient () {
const first = ['Ana','Bruno','Carla','Daniel','Eduarda','Felipe','Giovana','Henrique','Isabela','João','Larissa','Marcos','Nathalia','Otávio','Paula','Rafael','Sabrina','Thiago','Vanessa','Yasmin']
const last = ['Silva','Santos','Oliveira','Souza','Pereira','Lima','Ferreira','Almeida','Costa','Gomes','Ribeiro','Carvalho','Martins','Araújo','Barbosa']
const cities = ['São Carlos','Ribeirão Preto','Campinas','São Paulo','Araraquara','Bauru','Sorocaba','Santos']
const nomeCompleto = `${pick(first)} ${pick(last)} ${pick(last)}`
form.value = { ...resetForm(), nome_completo: nomeCompleto, telefone: randomPhoneBR(), email_principal: randomEmailFromName(nomeCompleto), email_alternativo: `alt.${randInt(10,999)}@email.com`, telefone_alternativo: randomPhoneBR(), data_nascimento: randomDateDDMMYYYY(6, 78), genero: pick(['Feminino','Masculino','Não-binário','Prefere não informar','Outro']), estado_civil: pick(['Solteiro(a)','Casado(a)','União estável','Divorciado(a)','Viúvo(a)']), cpf: fmtCPF(generateCPF()), rg: fmtRG(String(randInt(10000000,999999999))), naturalidade: pick(cities), observacoes: 'Paciente relata ansiedade e sobrecarga emocional.', onde_nos_conheceu: pick(['Instagram','Google','Indicação','Site','Threads','Outro']), encaminhado_por: `${pick(first)} ${pick(last)}`, cep: randomCEP(), pais: 'Brasil', cidade: pick(cities), estado: pick(['SP','RJ','MG','PR','SC','RS','BA']), endereco: pick(['Rua das Flores','Av. Brasil','Rua XV de Novembro']), numero: String(randInt(10,9999)), bairro: pick(['Centro','Jardim Paulista','Vila Prado','Santa Felícia']), complemento: `Apto ${randInt(10,999)}`, escolaridade: pick(['Ensino Médio','Superior incompleto','Superior completo','Pós-graduação']), profissao: pick(['Estudante','Professora','Desenvolvedor','Enfermeira','Autônomo']), nome_parente: `${pick(first)} ${pick(last)}`, grau_parentesco: pick(['Mãe','Pai','Irmã','Irmão','Cônjuge']), telefone_parente: randomPhoneBR(), nome_responsavel: `${pick(first)} ${pick(last)} ${pick(last)}`, cpf_responsavel: fmtCPF(generateCPF()), telefone_responsavel: randomPhoneBR(), observacao_responsavel: 'Responsável ciente do contrato.', cobranca_no_responsavel: true, notas_internas: 'Paciente apresenta discurso organizado. Acompanhar evolução clínica.', avatar_url: '' }
if (Array.isArray(groups.value) && groups.value.length) grupoIdSelecionado.value = pick(groups.value).id
if (Array.isArray(tags.value) && tags.value.length) { const sh = [...tags.value].sort(() => Math.random()-0.5); tagIdsSelecionadas.value = sh.slice(0, randInt(1, Math.min(3, tags.value.length))).map(t => t.id) }
toast.add({ severity: 'info', summary: 'Preenchido', detail: 'Paciente preenchido com dados fictícios.', life: 2500 })
}
const genderOptions = [
{ label: 'Feminino', value: 'Feminino' },
{ label: 'Masculino', value: 'Masculino' },
{ label: 'Não-binário', value: 'Não-binário' },
{ label: 'Prefere não informar', value: 'Prefere não informar' },
{ label: 'Outro', value: 'Outro' }
]
const maritalStatusOptions = [
{ label: 'Solteiro(a)', value: 'Solteiro(a)' },
{ label: 'Casado(a)', value: 'Casado(a)' },
{ label: 'União estável', value: 'União estável' },
{ label: 'Divorciado(a)', value: 'Divorciado(a)' },
{ label: 'Separado(a)', value: 'Separado(a)' },
{ label: 'Viúvo(a)', value: 'Viúvo(a)' },
{ label: 'Prefere não informar', value: 'Prefere não informar' }
]
// ── Dialogs Grupo / Tag ───────────────────────────────────
const createGroupDialog = ref(false); const createGroupSaving = ref(false); const createGroupError = ref(''); const newGroup = ref({ name: '', color: '#6366F1' })
const createTagDialog = ref(false); const createTagSaving = ref(false); const createTagError = ref(''); const newTag = ref({ name: '', color: '#22C55E' })
function openGroupDlg () { createGroupError.value = ''; newGroup.value = { name: '', color: '#6366F1' }; createGroupDialog.value = true }
function openTagDlg () { createTagError.value = ''; newTag.value = { name: '', color: '#22C55E' }; createTagDialog.value = true }
async function createGroupPersist () {
if (createGroupSaving.value) return; createGroupError.value = ''
const name = String(newGroup.value?.name || '').trim(); const color = String(newGroup.value?.color || '').trim() || '#6366F1'
if (!name) { createGroupError.value = 'Informe um nome para o grupo.'; return }
createGroupSaving.value = true
try {
const ownerId = await getOwnerId(); const { tenantId } = await resolveTenantContextOrFail()
const { data, error } = await supabase.from('patient_groups').insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, descricao: null, cor: color, is_system: false, is_active: true }).select('id').single()
if (error) throw error
groups.value = await listGroups()
if (data?.id) grupoIdSelecionado.value = data.id
toast.add({ severity: 'success', summary: 'Grupo', detail: 'Grupo criado.', life: 2500 }); createGroupDialog.value = false
} catch (e) {
const msg = e?.message || ''
createGroupError.value = (e?.code === '23505' || /duplicate key value/i.test(msg)) ? 'Já existe um grupo com esse nome.' : (msg || 'Falha ao criar grupo.')
} finally { createGroupSaving.value = false }
}
async function createTagPersist () {
if (createTagSaving.value) return; createTagError.value = ''
const name = String(newTag.value?.name || '').trim(); const color = String(newTag.value?.color || '').trim() || '#22C55E'
if (!name) { createTagError.value = 'Informe um nome para a tag.'; return }
createTagSaving.value = true
try {
const ownerId = await getOwnerId(); const { tenantId } = await resolveTenantContextOrFail()
const { data, error } = await supabase.from('patient_tags').insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, cor: color }).select('id').single()
if (error) throw error
tags.value = await listTags()
if (data?.id) { const set = new Set([...(tagIdsSelecionadas.value || []), data.id]); tagIdsSelecionadas.value = Array.from(set) }
toast.add({ severity: 'success', summary: 'Tag', detail: 'Tag criada.', life: 2500 }); createTagDialog.value = false
} catch (e) {
const msg = e?.message || ''
createTagError.value = (e?.code === '23505' || /duplicate key value/i.test(msg)) ? 'Já existe uma tag com esse nome.' : (msg || 'Falha ao criar tag.')
} finally { createTagSaving.value = false }
}
defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, canSee, isEdit })
</script>
<template>
<ConfirmDialog v-if="!dialogMode" />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!--
HERO sticky (oculto no modo dialog)
-->
<section
v-if="!dialogMode"
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<div class="relative z-1 flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-user-plus text-base" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">
{{ isEdit ? 'Editar paciente' : 'Cadastrar paciente' }}
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
<template v-if="isEdit">Idade: <b class="text-[var(--text-color)]">{{ ageLabel }}</b></template>
<template v-else">Preencha as informações do novo paciente</template>
</div>
</div>
</div>
<!-- Espaçador -->
<div class="flex-1" />
<!-- Ações (ocultas no modo dialog o Dialog tem seu próprio footer) -->
<div v-if="!dialogMode" class="flex items-center gap-1.5 shrink-0">
<Button
v-if="canSee('testMODE')"
label="Preencher tudo"
icon="pi pi-bolt"
severity="secondary"
outlined
size="small"
class="rounded-full hidden xl:flex"
@click="fillRandomPatient"
/>
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Voltar" @click="goBack" />
<Button
v-if="isEdit"
icon="pi pi-trash"
severity="danger"
outlined
class="h-9 w-9 rounded-full"
title="Excluir paciente"
:loading="deleting"
@click="confirmDelete"
/>
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="saving" @click="onSubmit" />
</div>
</div>
</section>
<!--
CORPO
-->
<div class="px-3 md:px-4 pb-6">
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16 text-[var(--text-color-secondary)] gap-2">
<i class="pi pi-spin pi-spinner" /> Carregando
</div>
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-[260px_1fr] max-w-[1100px] mx-auto">
<!-- SIDEBAR -->
<aside
class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-3.5 xl:sticky xl:self-start"
:class="dialogMode ? 'xl:top-4' : 'xl:top-[calc(var(--layout-sticky-top,56px)+3.5rem)]'"
>
<!-- Avatar -->
<div class="flex items-center gap-3 pb-3.5 mb-3.5 border-b border-[var(--surface-border,#e2e8f0)] xl:flex-col xl:items-center xl:gap-2">
<!-- Foto -->
<div class="w-16 h-16 xl:w-20 xl:h-20 rounded-full overflow-hidden border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] shrink-0">
<img
v-if="avatarPreviewUrl || form.avatar_url"
:src="avatarPreviewUrl || form.avatar_url"
alt="Avatar do paciente"
class="w-full h-full object-cover"
/>
<div v-else class="grid w-full h-full place-items-center">
<i class="pi pi-user text-2xl text-[var(--text-color-secondary)] opacity-40" />
</div>
</div>
<!-- Upload -->
<div class="flex-1 xl:w-full">
<input
type="file"
accept="image/*"
class="block w-full text-[1rem] text-[var(--text-color-secondary)]
file:mr-2 file:rounded-full file:border file:border-[var(--surface-border,#e2e8f0)]
file:bg-[var(--surface-ground,#f8fafc)] file:px-3 file:py-1 file:text-[0.75rem]
file:text-[var(--text-color)] file:cursor-pointer
hover:file:bg-[var(--surface-hover,#f1f5f9)] hover:file:border-indigo-300"
@change="onAvatarPicked"
/>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)] opacity-60">
Avatar opcional · máx 3 MB
<span v-if="avatarUploading" class="ml-1 text-indigo-500">(enviando)</span>
</div>
</div>
</div>
<!-- Nav desktop ( xl) -->
<div v-if="!isCompact" class="flex flex-col gap-1">
<button
v-for="item in navItems"
:key="item.value"
type="button"
class="flex items-center gap-2.5 rounded-md px-3 py-2 text-left text-[1rem] border transition-colors duration-100"
:class="activeValue === item.value
? 'bg-indigo-500/8 border-indigo-300/40 text-indigo-700 font-semibold'
: 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground,#f8fafc)] font-medium'"
@click="openPanel(Number(item.value))"
>
<i :class="item.icon" class="text-[1rem] opacity-70 shrink-0" />
<span>{{ item.label }}</span>
</button>
</div>
</aside>
<!-- MAIN -->
<main class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<!-- Nav compacto (<xl) -->
<div v-if="isCompact" class="sticky top-[calc(var(--layout-sticky-top,56px)+3.5rem)] z-30 border-b border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3.5 py-2">
<Button
type="button"
class="w-full !rounded-full"
icon="pi pi-chevron-down"
iconPos="right"
:label="selectedNav ? selectedNav.label : 'Selecionar seção'"
@click="toggleNav($event)"
/>
<Popover ref="navPopover" :pt="{ root: { class: 'z-[9999999]' } }">
<div class="flex min-w-[240px] flex-col gap-1 p-1">
<button
v-for="item in navItems"
:key="item.value"
type="button"
class="flex items-center gap-2.5 rounded-md px-3 py-2 text-left text-[1rem] border border-transparent cursor-pointer"
:class="activeValue === item.value ? 'bg-indigo-500/8 border-indigo-300/40 text-indigo-700 font-semibold' : 'text-[var(--text-color)] hover:bg-[var(--surface-ground,#f8fafc)] font-medium'"
@click="selectNav(item)"
>
<i :class="item.icon" class="text-[1rem] opacity-70 shrink-0" />
<span>{{ item.label }}</span>
</button>
</div>
</Popover>
</div>
<div class="p-4">
<Accordion :multiple="false" v-model:value="activeValue">
<!-- 0: Informações pessoais -->
<AccordionPanel value="0">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 0)">1. Informações pessoais</AccordionHeader>
<AccordionContent>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 pt-1">
<div class="xl:col-span-2">
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-user" /><InputText id="f_nome" v-model="form.nome_completo" class="w-full" variant="filled" /></IconField>
<label for="f_nome">Nome completo *</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-phone" /><InputMask id="f_telefone" v-model="form.telefone" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" /></IconField>
<label for="f_telefone">Telefone / celular *</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-envelope" /><InputText id="f_email" v-model="form.email_principal" class="w-full" variant="filled" /></IconField>
<label for="f_email">E-mail principal *</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-envelope" /><InputText id="f_email_alt" v-model="form.email_alternativo" class="w-full" variant="filled" /></IconField>
<label for="f_email_alt">E-mail alternativo</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-phone" /><InputMask id="f_tel_alt" v-model="form.telefone_alternativo" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" /></IconField>
<label for="f_tel_alt">Telefone alternativo</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-calendar" /><InputMask id="f_nasc" v-model="form.data_nascimento" mask="99-99-9999" :unmask="false" class="w-full" variant="filled" /></IconField>
<label for="f_nasc">Data de nascimento</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-user" /><Select id="f_genero" v-model="form.genero" :options="genderOptions" optionLabel="label" optionValue="value" class="w-full pl-[25px]" variant="filled" /></IconField>
<label for="f_genero">Gênero</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-heart" /><Select id="f_estado_civil" v-model="form.estado_civil" :options="maritalStatusOptions" optionLabel="label" optionValue="value" class="w-full pl-[25px]" variant="filled" /></IconField>
<label for="f_estado_civil">Estado civil</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-id-card" /><InputMask id="f_cpf" v-model="form.cpf" mask="999.999.999-99" :unmask="false" class="w-full" variant="filled" /></IconField>
<label for="f_cpf">CPF</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-id-card" /><InputText id="f_rg" v-model="form.rg" class="w-full" variant="filled" /></IconField>
<label for="f_rg">RG</label>
</FloatLabel>
</div>
<div class="xl:col-span-2">
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-map" /><InputText id="f_nat" v-model="form.naturalidade" class="w-full" variant="filled" /></IconField>
<label for="f_nat">Naturalidade</label>
</FloatLabel>
</div>
<div class="xl:col-span-2">
<FloatLabel variant="on">
<Textarea id="f_obs" v-model="form.observacoes" rows="3" class="w-full" variant="filled" />
<label for="f_obs">Observações</label>
</FloatLabel>
</div>
<!-- Grupo -->
<div>
<div class="flex gap-2">
<div class="flex-1 min-w-0">
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-folder-open" /><Select id="f_group" v-model="grupoIdSelecionado" :options="groups" optionLabel="nome" optionValue="id" class="w-full pl-[25px]" showClear filter variant="filled" /></IconField>
<label for="f_group">Grupo</label>
</FloatLabel>
<div class="mt-1 text-[0.72rem] text-[var(--text-color-secondary)] opacity-70">Usado para puxar um modelo de anamnese.</div>
</div>
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0" @click="openGroupDlg" />
</div>
</div>
<!-- Tags -->
<div>
<div class="flex gap-2">
<div class="flex-1 min-w-0">
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-tag" /><MultiSelect id="f_tags" v-model="tagIdsSelecionadas" :options="tags" optionLabel="name" optionValue="id" class="w-full pl-[25px]" display="chip" filter variant="filled" /></IconField>
<label for="f_tags">Tags</label>
</FloatLabel>
</div>
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0" @click="openTagDlg" />
</div>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-megaphone" /><InputText id="f_lead" v-model="form.onde_nos_conheceu" class="w-full" variant="filled" /></IconField>
<label for="f_lead">Como chegou até mim?</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-share-alt" /><InputText id="f_ref" v-model="form.encaminhado_por" class="w-full" variant="filled" /></IconField>
<label for="f_ref">Encaminhado por</label>
</FloatLabel>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!-- 1: Endereço -->
<AccordionPanel value="1">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 1)">2. Endereço</AccordionHeader>
<AccordionContent>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 pt-1">
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-map-marker" /><InputText id="f_cep" v-model="form.cep" class="w-full" @blur="onCepBlur" variant="filled" /></IconField><label for="f_cep">CEP</label></FloatLabel></div>
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-globe" /><InputText id="f_country" v-model="form.pais" class="w-full" variant="filled" /></IconField><label for="f_country">País</label></FloatLabel></div>
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-building" /><InputText id="f_city" v-model="form.cidade" class="w-full" variant="filled" /></IconField><label for="f_city">Cidade</label></FloatLabel></div>
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-compass" /><InputText id="f_state" v-model="form.estado" class="w-full" variant="filled" /></IconField><label for="f_state">Estado</label></FloatLabel></div>
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-map" /><InputText id="f_address" v-model="form.endereco" class="w-full" variant="filled" /></IconField><label for="f_address">Endereço</label></FloatLabel></div>
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-hashtag" /><InputText id="f_number" v-model="form.numero" class="w-full" variant="filled" /></IconField><label for="f_number">Número</label></FloatLabel></div>
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-map-marker" /><InputText id="f_neighborhood" v-model="form.bairro" class="w-full" variant="filled" /></IconField><label for="f_neighborhood">Bairro</label></FloatLabel></div>
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-align-left" /><InputText id="f_complement" v-model="form.complemento" class="w-full" variant="filled" /></IconField><label for="f_complement">Complemento</label></FloatLabel></div>
</div>
</AccordionContent>
</AccordionPanel>
<!-- 2: Dados adicionais -->
<AccordionPanel value="2">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 2)">3. Dados adicionais</AccordionHeader>
<AccordionContent>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 pt-1">
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-book" /><InputText id="f_escolaridade" v-model="form.escolaridade" class="w-full" variant="filled" /></IconField><label for="f_escolaridade">Escolaridade</label></FloatLabel></div>
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-briefcase" /><InputText id="f_profissao" v-model="form.profissao" class="w-full" variant="filled" /></IconField><label for="f_profissao">Profissão</label></FloatLabel></div>
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-user" /><InputText id="f_parente_nome" v-model="form.nome_parente" class="w-full" variant="filled" /></IconField><label for="f_parente_nome">Nome de um parente</label></FloatLabel></div>
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-users" /><InputText id="f_parentesco" v-model="form.grau_parentesco" class="w-full" variant="filled" /></IconField><label for="f_parentesco">Grau de parentesco</label></FloatLabel></div>
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-phone" /><InputMask id="f_parente_tel" v-model="form.telefone_parente" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" /></IconField><label for="f_parente_tel">Telefone do parente</label></FloatLabel></div>
<div class="xl:col-span-2">
<Button icon="pi pi-plus" label="Adicionar mais parentes (em breve)" severity="secondary" outlined disabled />
<div class="mt-1.5 text-[0.72rem] text-[var(--text-color-secondary)] opacity-60">Se você quiser, isso vira uma lista (1:N) depois.</div>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!-- 3: Responsável -->
<AccordionPanel value="3">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 3)">4. Responsável</AccordionHeader>
<AccordionContent>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 pt-1">
<div class="xl:col-span-2"><FloatLabel variant="on"><IconField><InputIcon class="pi pi-user" /><InputText id="f_resp_nome" v-model="form.nome_responsavel" class="w-full" variant="filled" /></IconField><label for="f_resp_nome">Nome do responsável</label></FloatLabel></div>
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-id-card" /><InputMask id="f_resp_cpf" v-model="form.cpf_responsavel" mask="999.999.999-99" :unmask="false" class="w-full" variant="filled" /></IconField><label for="f_resp_cpf">CPF do responsável</label></FloatLabel></div>
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-phone" /><InputMask id="f_resp_tel" v-model="form.telefone_responsavel" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" /></IconField><label for="f_resp_tel">Telefone do responsável</label></FloatLabel></div>
<div class="xl:col-span-2"><FloatLabel variant="on"><Textarea id="f_resp_obs" v-model="form.observacao_responsavel" rows="3" class="w-full" variant="filled" /><label for="f_resp_obs">Observações sobre o responsável</label></FloatLabel></div>
<div class="xl:col-span-2">
<div class="flex items-center gap-2">
<Checkbox inputId="f_bill" v-model="form.cobranca_no_responsavel" :binary="true" />
<label for="f_bill" class="text-[1rem] text-[var(--text-color)] cursor-pointer">Cobrança no responsável</label>
</div>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!-- 4: Anotações internas -->
<AccordionPanel value="4">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 4)">5. Anotações internas</AccordionHeader>
<AccordionContent>
<div class="mb-2.5 text-[0.75rem] text-[var(--text-color-secondary)] opacity-70 flex items-center gap-1.5">
<i class="pi pi-lock text-[1rem]" />
Campo interno: não aparece no cadastro externo.
</div>
<FloatLabel variant="on">
<Textarea id="f_notas" v-model="form.notas_internas" rows="7" class="w-full" variant="filled" />
<label for="f_notas">Notas internas</label>
</FloatLabel>
</AccordionContent>
</AccordionPanel>
</Accordion>
<!-- Botão salvar bottom (oculto no modo dialog o footer cuida disso) -->
<div v-if="!dialogMode" class="mt-4 flex justify-center">
<Button label="Salvar" icon="pi pi-check" :loading="saving" class="min-w-[200px] rounded-full" @click="onSubmit" />
</div>
</div>
</main>
</div>
</div>
<!--
Dialog: Criar grupo
-->
<Dialog
v-model:visible="createGroupDialog"
modal
:draggable="false"
header="Criar grupo"
:style="{ width: '26rem' }"
:closable="!createGroupSaving"
pt:mask:class="backdrop-blur-sm"
>
<div class="flex flex-col gap-4 pt-1">
<span class="text-[1rem] text-[var(--text-color-secondary)]">Crie um grupo para organizar seus pacientes.</span>
<div class="flex items-center gap-3">
<label for="group-name" class="w-20 text-[1rem] font-semibold shrink-0">Nome</label>
<InputText id="group-name" v-model="newGroup.name" class="flex-1" autocomplete="off" placeholder="Ex: Crianças" />
</div>
<div class="flex items-center gap-3">
<label class="w-20 text-[1rem] font-semibold shrink-0">Cor</label>
<div class="flex flex-1 items-center gap-2.5">
<input v-model="newGroup.color" type="color" class="h-9 w-12 cursor-pointer rounded-md border border-[var(--surface-border,#e2e8f0)] bg-transparent" />
<Chip :label="newGroup.color || '#—'" class="font-semibold" :style="{ backgroundColor: newGroup.color, color: '#fff' }" />
</div>
</div>
<div v-if="createGroupError" class="text-[1rem] text-red-500">{{ createGroupError }}</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="createGroupSaving" @click="createGroupDialog = false" />
<Button label="Criar" icon="pi pi-check" class="rounded-full" :loading="createGroupSaving" @click="createGroupPersist" />
</div>
</template>
</Dialog>
<!--
Dialog: Criar tag
-->
<Dialog
v-model:visible="createTagDialog"
modal
:draggable="false"
header="Criar tag"
:style="{ width: '26rem' }"
:closable="!createTagSaving"
pt:mask:class="backdrop-blur-sm"
>
<div class="flex flex-col gap-4 pt-1">
<span class="text-[1rem] text-[var(--text-color-secondary)]">Crie uma tag para facilitar filtros e organização.</span>
<div class="flex items-center gap-3">
<label for="tag-name" class="w-20 text-[1rem] font-semibold shrink-0">Nome</label>
<InputText id="tag-name" v-model="newTag.name" class="flex-1" autocomplete="off" placeholder="Ex: Ansiedade" />
</div>
<div class="flex items-center gap-3">
<label class="w-20 text-[1rem] font-semibold shrink-0">Cor</label>
<div class="flex flex-1 items-center gap-2.5">
<input v-model="newTag.color" type="color" class="h-9 w-12 cursor-pointer rounded-md border border-[var(--surface-border,#e2e8f0)] bg-transparent" />
<Chip :label="newTag.color || '#—'" class="font-semibold" :style="{ backgroundColor: newTag.color, color: '#fff' }" />
</div>
</div>
<div v-if="createTagError" class="text-[1rem] text-red-500">{{ createTagError }}</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="createTagSaving" @click="createTagDialog = false" />
<Button label="Criar" icon="pi pi-check" class="rounded-full" :loading="createTagSaving" @click="createTagPersist" />
</div>
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,679 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/detail/PatientsDetailPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
// ── Mock data ─────────────────────────────────────────────
const patient = ref({
nome_completo: 'Mariana Lima',
nome_social: null,
pronomes: 'ela/dela',
data_nascimento: '1992-06-14',
cpf: '12345678900',
genero: 'Feminino',
estado_civil: 'Solteira',
escolaridade: 'Superior completo',
profissao: 'Desenvolvedora',
etnia: null,
telefone: '(16) 99123-4567',
email: 'mariana@email.com',
canal_preferido: 'WhatsApp',
horario_contato: '08h20h',
cep: '13560-000',
cidade: 'São Carlos',
estado: 'SP',
status: 'Ativo',
risco_elevado: true,
risco_nota: 'Ideação passiva relatada em 12/03',
risco_sinalizado_por: 'Dra. Ana Lima',
risco_sinalizado_em: '2025-03-12',
tags: [{ nome: 'Ansiedade', cor: '#7F77DD' }, { nome: 'TCC', cor: '#1D9E75' }],
convenio: 'Unimed',
patient_scope: 'Clínica',
origem: 'Indicação',
encaminhado_por: 'Dr. Roberto (psiq.)',
metodo_pagamento_preferido: 'PIX',
motivo_saida: null,
metricas: {
total_sessoes: 47,
taxa_comparecimento: 92,
ltv_total: 8460,
dias_sem_sessao: 18,
taxa_pagamentos: 100,
taxa_tarefas: 60,
engajamento_score: 84,
duracao_meses: 14,
proxima_sessao: '27/03 às 14h'
}
})
const contatos = ref([
{ nome: 'Maria Lima', tipo: 'emergencia', relacao: 'mãe', telefone: '(16) 98888-0001', email: 'maria@email.com', is_primario: true },
{ nome: 'Dr. Roberto Oliveira', tipo: 'profissional_saude', relacao: 'psiquiatra', telefone: '(16) 3322-1100', email: null, is_primario: false }
])
const timeline = ref([
{ tipo: 'risco_sinalizado', titulo: 'Risco elevado sinalizado', descricao: 'Ideação passiva relatada', cor: 'red', data: '12/03/2025', autor: 'Dra. Ana Lima' },
{ tipo: 'escala_respondida', titulo: 'GAD-7 respondido', descricao: 'Score 12 — ansiedade moderada', cor: 'green', data: '10/03/2025', autor: 'via portal' },
{ tipo: 'documento_assinado', titulo: 'TCLE assinado digitalmente', descricao: null, cor: 'blue', data: '02/01/2024', autor: 'via portal' },
{ tipo: 'primeira_sessao', titulo: 'Primeira sessão realizada', descricao: 'Presencial · 50min', cor: 'green', data: '15/01/2024', autor: null }
])
// ── Computed helpers ──────────────────────────────────────
const idade = computed(() => {
if (!patient.value.data_nascimento) return null
const birth = new Date(patient.value.data_nascimento)
const now = new Date()
let age = now.getFullYear() - birth.getFullYear()
const m = now.getMonth() - birth.getMonth()
if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--
return age
})
const cpfMascarado = computed(() => {
const cpf = patient.value.cpf || ''
if (cpf.length < 2) return cpf
const visible = cpf.slice(-2)
const hidden = '•'.repeat(cpf.length - 2)
return hidden + visible
})
const iniciais = computed(() => {
return (patient.value.nome_completo || '')
.split(' ')
.filter(Boolean)
.map(w => w[0].toUpperCase())
.slice(0, 2)
.join('')
})
function initiaisFor(nome) {
return (nome || '')
.split(' ')
.filter(Boolean)
.map(w => w[0].toUpperCase())
.slice(0, 2)
.join('')
}
function dataNascFormatada(iso) {
if (!iso) return '—'
const [y, m, d] = iso.split('-')
return `${d}/${m}/${y}`
}
function progressSeverity(val) {
if (val >= 80) return 'success'
if (val >= 60) return 'warning'
return 'danger'
}
function progressColor(val) {
if (val >= 80) return 'var(--p-green-500)'
if (val >= 60) return 'var(--p-yellow-500)'
return 'var(--p-red-500)'
}
function scoreClass(val) {
if (val >= 80) return 'text-green-500'
if (val >= 60) return 'text-yellow-500'
return 'text-red-500'
}
function timelineMarkerStyle(cor) {
const map = {
red: 'var(--p-red-500)',
green: 'var(--p-green-500)',
blue: 'var(--p-blue-500)',
gray: 'var(--p-surface-400)'
}
return { background: map[cor] || map.gray }
}
function timelineIcon(tipo) {
const map = {
risco_sinalizado: 'pi pi-exclamation-triangle',
escala_respondida: 'pi pi-chart-bar',
documento_assinado: 'pi pi-file-check',
primeira_sessao: 'pi pi-star'
}
return map[tipo] || 'pi pi-circle'
}
function val(v) {
return v ?? '—'
}
function goBack() {
if (window.history.length > 1) router.back()
else router.push('/admin/pacientes')
}
// ── Tabs ─────────────────────────────────────────────────
const activeTab = ref(0)
const tabs = [
{ label: 'Perfil', icon: 'pi pi-user' },
{ label: 'Prontuário', icon: 'pi pi-clipboard' },
{ label: 'Agenda', icon: 'pi pi-calendar' },
{ label: 'Financeiro', icon: 'pi pi-wallet' },
{ label: 'Documentos', icon: 'pi pi-folder' }
]
</script>
<template>
<div class="flex flex-col min-h-screen bg-[var(--surface-ground)]">
<!-- Alerta de risco elevado -->
<Message
v-if="patient.risco_elevado"
severity="error"
:closable="false"
class="rounded-none border-0 border-b border-red-400 m-0"
pt:root:class="rounded-none"
>
<div class="flex items-start gap-3">
<i class="pi pi-exclamation-circle text-xl mt-0.5 shrink-0" />
<div>
<div class="font-bold text-[1rem]">Atenção paciente com risco elevado sinalizado</div>
<div class="text-[0.85rem] opacity-90 mt-0.5">
Sinalizado em {{ patient.risco_sinalizado_em?.split('-').reverse().join('/') }}
por {{ patient.risco_sinalizado_por }}
<span v-if="patient.risco_nota"> · {{ patient.risco_nota }}</span>
</div>
</div>
</div>
</Message>
<!-- Barra superior -->
<div class="flex items-center justify-between gap-3 px-4 py-3 bg-[var(--surface-card)] border-b border-[var(--surface-border)]">
<Button
icon="pi pi-arrow-left"
label="Pacientes"
severity="secondary"
text
class="font-semibold"
@click="goBack"
/>
<div class="flex items-center gap-2">
<Button
icon="pi pi-pencil"
label="Editar"
severity="secondary"
outlined
class="rounded-full"
/>
<Button
icon="pi pi-plus"
label="Sessão"
class="rounded-full"
/>
</div>
</div>
<!-- Card cabeçalho -->
<div class="px-4 pt-4 pb-0">
<Card class="shadow-none border border-[var(--surface-border)]">
<template #content>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6">
<!-- Avatar -->
<Avatar
:label="iniciais"
size="xlarge"
shape="circle"
class="shrink-0 text-white font-bold text-xl"
style="background: var(--p-primary-500); width: 4.5rem; height: 4.5rem; font-size: 1.4rem;"
/>
<!-- Nome + badges + métricas -->
<div class="flex-1 min-w-0">
<!-- Nome + info rápida -->
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-2">
<span class="text-2xl font-bold text-[var(--text-color)] leading-tight">
{{ patient.nome_completo }}
</span>
<span class="text-[var(--text-color-secondary)] text-[0.95rem]">
{{ idade }} anos · {{ patient.pronomes }} · {{ patient.cidade }}/{{ patient.estado }}
</span>
</div>
<!-- Badges -->
<div class="flex flex-wrap items-center gap-2 mb-4">
<Tag :value="patient.status" severity="success" />
<Tag :value="patient.convenio" severity="info" />
<Tag :value="patient.patient_scope" severity="secondary" />
<Tag
v-for="tag in patient.tags"
:key="tag.nome"
:value="tag.nome"
:style="{ background: tag.cor, color: '#fff', border: 'none' }"
/>
</div>
<!-- Métricas em linha -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="flex flex-col items-center p-3 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
<span class="text-2xl font-bold text-[var(--text-color)]">{{ patient.metricas.total_sessoes }}</span>
<span class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 text-center">Total sessões</span>
</div>
<div class="flex flex-col items-center p-3 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
<span class="text-2xl font-bold" :class="scoreClass(patient.metricas.taxa_comparecimento)">{{ patient.metricas.taxa_comparecimento }}%</span>
<span class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 text-center">Comparecimento</span>
</div>
<div class="flex flex-col items-center p-3 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
<span class="text-2xl font-bold text-[var(--text-color)]">R$&nbsp;{{ patient.metricas.ltv_total.toLocaleString('pt-BR') }}</span>
<span class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 text-center">LTV total</span>
</div>
<div class="flex flex-col items-center p-3 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
<span class="text-2xl font-bold text-[var(--text-color)]">{{ patient.metricas.dias_sem_sessao }}</span>
<span class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 text-center">Dias s/ sessão</span>
</div>
</div>
</div>
</div>
</template>
</Card>
</div>
<!-- Tabs -->
<div class="px-4 pt-3 pb-6 flex-1">
<TabView v-model:activeIndex="activeTab" class="shadow-none">
<!-- Aba: Perfil -->
<TabPanel>
<template #header>
<span class="flex items-center gap-2">
<i class="pi pi-user" />
Perfil
</span>
</template>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4 mt-2">
<!-- Coluna esquerda -->
<div class="flex flex-col gap-4">
<!-- Dados pessoais -->
<Card class="shadow-none border border-[var(--surface-border)]">
<template #title>
<span class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-id-card text-[var(--p-primary-500)]" />
Dados pessoais
</span>
</template>
<template #content>
<table class="w-full text-[0.9rem]">
<tbody>
<tr v-for="row in [
{ label: 'Nome completo', value: patient.nome_completo },
{ label: 'Nome social', value: patient.nome_social },
{ label: 'Pronomes', value: patient.pronomes },
{ label: 'Nascimento', value: `${dataNascFormatada(patient.data_nascimento)} (${idade} anos)` },
{ label: 'CPF', value: cpfMascarado },
{ label: 'Gênero', value: patient.genero },
{ label: 'Estado civil', value: patient.estado_civil },
{ label: 'Escolaridade', value: patient.escolaridade },
{ label: 'Profissão', value: patient.profissao },
{ label: 'Etnia', value: patient.etnia },
]" :key="row.label" class="border-b border-[var(--surface-border)] last:border-0">
<td class="py-2 pr-4 w-[38%] text-[var(--text-color-secondary)] font-medium align-top">{{ row.label }}</td>
<td class="py-2 align-top" :class="{ 'text-[var(--text-color-secondary)] italic': !row.value }">
{{ row.value || '—' }}
</td>
</tr>
</tbody>
</table>
</template>
</Card>
<!-- Contato -->
<Card class="shadow-none border border-[var(--surface-border)]">
<template #title>
<span class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-phone text-[var(--p-primary-500)]" />
Contato
</span>
</template>
<template #content>
<table class="w-full text-[0.9rem]">
<tbody>
<tr class="border-b border-[var(--surface-border)]">
<td class="py-2 pr-4 w-[38%] text-[var(--text-color-secondary)] font-medium">Telefone</td>
<td class="py-2">
<a :href="`tel:${patient.telefone}`" class="text-[var(--p-primary-500)] hover:underline">{{ patient.telefone }}</a>
</td>
</tr>
<tr class="border-b border-[var(--surface-border)]">
<td class="py-2 pr-4 text-[var(--text-color-secondary)] font-medium">E-mail</td>
<td class="py-2">
<a :href="`mailto:${patient.email}`" class="text-[var(--p-primary-500)] hover:underline">{{ patient.email }}</a>
</td>
</tr>
<tr class="border-b border-[var(--surface-border)]">
<td class="py-2 pr-4 text-[var(--text-color-secondary)] font-medium">Canal preferido</td>
<td class="py-2">{{ val(patient.canal_preferido) }}</td>
</tr>
<tr class="border-b border-[var(--surface-border)]">
<td class="py-2 pr-4 text-[var(--text-color-secondary)] font-medium">Horário</td>
<td class="py-2">{{ val(patient.horario_contato) }}</td>
</tr>
<tr>
<td class="py-2 pr-4 text-[var(--text-color-secondary)] font-medium">Cidade</td>
<td class="py-2">{{ patient.cep ? patient.cep + ' · ' : '' }}{{ patient.cidade }}/{{ patient.estado }}</td>
</tr>
</tbody>
</table>
</template>
</Card>
</div>
<!-- ─── Coluna direita ──────────────────── -->
<div class="flex flex-col gap-4">
<!-- Origem -->
<Card class="shadow-none border border-[var(--surface-border)]">
<template #title>
<span class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-send text-[var(--p-primary-500)]" />
Origem
</span>
</template>
<template #content>
<table class="w-full text-[0.9rem]">
<tbody>
<tr v-for="row in [
{ label: 'Como chegou', value: patient.origem },
{ label: 'Encaminhado por', value: patient.encaminhado_por },
{ label: 'Pagamento', value: patient.metodo_pagamento_preferido },
{ label: 'Motivo de saída', value: patient.motivo_saida },
]" :key="row.label" class="border-b border-[var(--surface-border)] last:border-0">
<td class="py-2 pr-4 w-[40%] text-[var(--text-color-secondary)] font-medium align-top">{{ row.label }}</td>
<td class="py-2 align-top" :class="{ 'text-[var(--text-color-secondary)] italic': !row.value }">
{{ row.value || '—' }}
</td>
</tr>
</tbody>
</table>
</template>
</Card>
<!-- Contatos & rede de suporte -->
<Card class="shadow-none border border-[var(--surface-border)]">
<template #title>
<span class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-users text-[var(--p-primary-500)]" />
Contatos &amp; rede de suporte
</span>
</template>
<template #content>
<div class="flex flex-col gap-3">
<div
v-for="contato in contatos"
:key="contato.nome"
class="flex items-start gap-3 p-3 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-section)]"
>
<Avatar
:label="initiaisFor(contato.nome)"
shape="circle"
class="shrink-0 text-white font-bold"
style="background: var(--p-primary-300); width: 2.5rem; height: 2.5rem;"
/>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span class="font-semibold text-[0.92rem]">{{ contato.nome }}</span>
<Tag
:value="contato.relacao"
severity="secondary"
class="text-[0.72rem]"
/>
<Tag
v-if="contato.is_primario"
value="emergência"
severity="danger"
class="text-[0.72rem]"
/>
</div>
<div class="text-[0.82rem] text-[var(--text-color-secondary)] flex flex-wrap gap-x-3 gap-y-0.5">
<span v-if="contato.telefone">
<i class="pi pi-phone mr-1" />
<a :href="`tel:${contato.telefone}`" class="hover:underline">{{ contato.telefone }}</a>
</span>
<span v-if="contato.email">
<i class="pi pi-envelope mr-1" />
<a :href="`mailto:${contato.email}`" class="hover:underline">{{ contato.email }}</a>
</span>
</div>
</div>
</div>
<Button
icon="pi pi-plus"
label="Adicionar contato"
severity="secondary"
outlined
class="rounded-full w-full mt-1"
/>
</div>
</template>
</Card>
<!-- Engajamento -->
<Card class="shadow-none border border-[var(--surface-border)]">
<template #title>
<span class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-chart-line text-[var(--p-primary-500)]" />
Engajamento
</span>
</template>
<template #content>
<!-- Barras de progresso -->
<div class="flex flex-col gap-4 mb-5">
<div>
<div class="flex justify-between text-[0.82rem] mb-1">
<span class="text-[var(--text-color-secondary)]">Comparecimento</span>
<span class="font-semibold" :style="{ color: progressColor(patient.metricas.taxa_comparecimento) }">
{{ patient.metricas.taxa_comparecimento }}%
</span>
</div>
<ProgressBar
:value="patient.metricas.taxa_comparecimento"
:showValue="false"
:class="`progress-${progressSeverity(patient.metricas.taxa_comparecimento)}`"
style="height: 8px; border-radius: 99px;"
/>
</div>
<div>
<div class="flex justify-between text-[0.82rem] mb-1">
<span class="text-[var(--text-color-secondary)]">Pagamentos em dia</span>
<span class="font-semibold" :style="{ color: progressColor(patient.metricas.taxa_pagamentos) }">
{{ patient.metricas.taxa_pagamentos }}%
</span>
</div>
<ProgressBar
:value="patient.metricas.taxa_pagamentos"
:showValue="false"
:class="`progress-${progressSeverity(patient.metricas.taxa_pagamentos)}`"
style="height: 8px; border-radius: 99px;"
/>
</div>
<div>
<div class="flex justify-between text-[0.82rem] mb-1">
<span class="text-[var(--text-color-secondary)]">Tarefas concluídas</span>
<span class="font-semibold" :style="{ color: progressColor(patient.metricas.taxa_tarefas) }">
{{ patient.metricas.taxa_tarefas }}%
</span>
</div>
<ProgressBar
:value="patient.metricas.taxa_tarefas"
:showValue="false"
:class="`progress-${progressSeverity(patient.metricas.taxa_tarefas)}`"
style="height: 8px; border-radius: 99px;"
/>
</div>
</div>
<!-- Score + info -->
<div class="flex items-center gap-4 p-4 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
<div class="flex flex-col items-center shrink-0">
<span
class="text-4xl font-black leading-none"
:class="scoreClass(patient.metricas.engajamento_score)"
>{{ patient.metricas.engajamento_score }}</span>
<span class="text-[0.7rem] text-[var(--text-color-secondary)] mt-1 uppercase tracking-wide">Score</span>
</div>
<div class="flex-1 flex flex-col gap-1 text-[0.85rem]">
<div class="flex items-center gap-2">
<i class="pi pi-clock text-[var(--text-color-secondary)]" />
<span>{{ patient.metricas.duracao_meses }} meses em tratamento</span>
</div>
<div class="flex items-center gap-2">
<i class="pi pi-calendar text-[var(--p-primary-500)]" />
<span>Próxima sessão: <strong>{{ patient.metricas.proxima_sessao }}</strong></span>
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- ─── Linha do tempo (full width) ─────────── -->
<Card class="shadow-none border border-[var(--surface-border)] mt-4">
<template #title>
<span class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-history text-[var(--p-primary-500)]" />
Linha do tempo
</span>
</template>
<template #content>
<Timeline :value="timeline" class="customized-timeline">
<template #marker="{ item }">
<span
class="flex items-center justify-center w-8 h-8 rounded-full text-white text-[0.8rem] shadow"
:style="timelineMarkerStyle(item.cor)"
>
<i :class="timelineIcon(item.tipo)" />
</span>
</template>
<template #content="{ item }">
<div class="pb-5">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 mb-0.5">
<span class="font-semibold text-[0.92rem]">{{ item.titulo }}</span>
<span class="text-[0.78rem] text-[var(--text-color-secondary)]">{{ item.data }}</span>
<span v-if="item.autor" class="text-[0.78rem] text-[var(--text-color-secondary)]">· {{ item.autor }}</span>
</div>
<p v-if="item.descricao" class="text-[0.85rem] text-[var(--text-color-secondary)] mt-0.5 m-0">
{{ item.descricao }}
</p>
</div>
</template>
</Timeline>
</template>
</Card>
</TabPanel>
<!-- ══ Aba: Prontuário ════════════════════════ -->
<TabPanel>
<template #header>
<span class="flex items-center gap-2">
<i class="pi pi-clipboard" />
Prontuário
</span>
</template>
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-color-secondary)] gap-3">
<i class="pi pi-clipboard text-5xl opacity-30" />
<span class="text-[1rem]">Prontuário — em breve</span>
</div>
</TabPanel>
<!-- ══ Aba: Agenda ════════════════════════════ -->
<TabPanel>
<template #header>
<span class="flex items-center gap-2">
<i class="pi pi-calendar" />
Agenda
</span>
</template>
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-color-secondary)] gap-3">
<i class="pi pi-calendar text-5xl opacity-30" />
<span class="text-[1rem]">Agenda — em breve</span>
</div>
</TabPanel>
<!-- ══ Aba: Financeiro ════════════════════════ -->
<TabPanel>
<template #header>
<span class="flex items-center gap-2">
<i class="pi pi-wallet" />
Financeiro
</span>
</template>
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-color-secondary)] gap-3">
<i class="pi pi-wallet text-5xl opacity-30" />
<span class="text-[1rem]">Financeiro — em breve</span>
</div>
</TabPanel>
<!-- ══ Aba: Documentos ════════════════════════ -->
<TabPanel>
<template #header>
<span class="flex items-center gap-2">
<i class="pi pi-folder" />
Documentos
</span>
</template>
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-color-secondary)] gap-3">
<i class="pi pi-folder text-5xl opacity-30" />
<span class="text-[1rem]">Documentos em breve</span>
</div>
</TabPanel>
</TabView>
</div>
</div>
</template>
<style scoped>
/* ProgressBar color overrides via severity class */
:deep(.progress-success .p-progressbar-value) {
background: var(--p-green-500) !important;
}
:deep(.progress-warning .p-progressbar-value) {
background: var(--p-yellow-500) !important;
}
:deep(.progress-danger .p-progressbar-value) {
background: var(--p-red-500) !important;
}
/* Timeline connector line */
:deep(.p-timeline-event-connector) {
background: var(--surface-border);
}
/* Remove TabView shadow */
:deep(.p-tabview .p-tabview-panels) {
background: transparent;
padding: 0;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,971 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/medicos/MedicosPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import Checkbox from 'primevue/checkbox';
import Menu from 'primevue/menu';
import {
listMedicosWithPatientCounts,
createMedico,
updateMedico,
deleteMedico,
fetchPatientsByMedicoNome
} from '@/services/Medicos.service.js';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
const router = useRouter();
const toast = useToast();
const confirm = useConfirm();
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null);
const headerSentinelRef = ref(null);
const headerStuck = ref(false);
let _observer = null;
// ── Mobile ────────────────────────────────────────────────
const mobileMenuRef = ref(null);
const searchDlgOpen = ref(false);
const mobileMenuItems = computed(() => [
{ label: 'Adicionar médico', icon: 'pi pi-plus', command: () => openCreate() },
{ label: 'Buscar', icon: 'pi pi-search', command: () => { searchDlgOpen.value = true; } },
{ separator: true },
...(selectedMedicos.value?.length
? [{ label: 'Excluir selecionados', icon: 'pi pi-trash', command: () => confirmDeleteSelected() }, { separator: true }]
: []),
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => fetchAll() }
]);
const dt = ref(null);
const loading = ref(false);
const hasLoaded = ref(false);
const medicos = ref([]);
const selectedMedicos = ref([]);
const filters = ref({ global: { value: null, matchMode: 'contains' } });
// ── Especialidades ────────────────────────────────────────
const especialidadesOpts = [
{ label: 'Psiquiatria', value: 'Psiquiatria' },
{ label: 'Neurologia', value: 'Neurologia' },
{ label: 'Neuropsiquiatria infantil', value: 'Neuropsiquiatria infantil' },
{ label: 'Clínica geral', value: 'Clínica geral' },
{ label: 'Pediatria', value: 'Pediatria' },
{ label: 'Geriatria', value: 'Geriatria' },
{ label: 'Endocrinologia', value: 'Endocrinologia' },
{ label: 'Psicologia (encaminhador)', value: 'Psicologia (encaminhador)' },
{ label: 'Assistência social', value: 'Assistência social' },
{ label: 'Fonoaudiologia', value: 'Fonoaudiologia' },
{ label: 'Terapia ocupacional', value: 'Terapia ocupacional' },
{ label: 'Fisioterapia', value: 'Fisioterapia' },
{ label: 'Outra', value: '__outra__' },
];
// ── Quick-stats ───────────────────────────────────────────
const quickStats = computed(() => {
const all = medicos.value || [];
const comPacs = cards.value.length;
const totalPacs = all.reduce((s, m) => s + Number(m.patients_count ?? 0), 0);
const especialidades = new Set(all.map((m) => m.especialidade).filter(Boolean)).size;
return [
{ label: 'Total de médicos', value: all.length, cls: '' },
{ label: 'Especialidades', value: especialidades, cls: '' },
{ label: 'Com pacientes', value: comPacs, cls: comPacs > 0 ? 'qs-ok' : '' },
{ label: 'Total encaminhados', value: totalPacs, cls: totalPacs > 0 ? 'qs-ok' : '' }
];
});
// ── Dialog Criar/Editar ──────────────────────────────────
const dlg = reactive({
open: false,
mode: 'create', // 'create' | 'edit'
id: '',
nome: '',
crm: '',
especialidade: '',
especialidade_outra: '',
telefone_profissional: '',
telefone_pessoal: '',
email: '',
clinica: '',
cidade: '',
estado: 'SP',
observacoes: '',
saving: false,
error: ''
});
const especialidadeFinal = computed(() =>
dlg.especialidade === '__outra__'
? (dlg.especialidade_outra.trim() || null)
: (dlg.especialidade || null)
);
// ── Dialog pacientes ──────────────────────────────────────
const patientsDialog = reactive({ open: false, loading: false, error: '', medico: null, items: [], search: '' });
// ── Cards painel lateral ──────────────────────────────────
const cards = computed(() =>
(medicos.value || [])
.filter((m) => Number(m.patients_count ?? 0) > 0)
.sort((a, b) => Number(b.patients_count ?? 0) - Number(a.patients_count ?? 0))
);
const patientsDialogFiltered = computed(() => {
const s = String(patientsDialog.search || '').trim().toLowerCase();
if (!s) return patientsDialog.items || [];
return (patientsDialog.items || []).filter(
(p) =>
String(p.full_name || '').toLowerCase().includes(s) ||
String(p.email || '').toLowerCase().includes(s) ||
String(p.phone || '').toLowerCase().includes(s)
);
});
function patientsLabel(n) {
return n === 1 ? '1 paciente' : `${n} pacientes`;
}
function humanizeError(err) {
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.';
const code = err?.code;
if (code === '23505' || /duplicate key value/i.test(msg)) return 'Já existe um médico com este CRM.';
return msg;
}
// ── Fetch ─────────────────────────────────────────────────
async function fetchAll() {
loading.value = true;
try {
medicos.value = await listMedicosWithPatientCounts();
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 });
} finally {
loading.value = false;
hasLoaded.value = true;
}
}
// ── Seleção ───────────────────────────────────────────────
function isSelected(row) {
return (selectedMedicos.value || []).some((s) => s.id === row.id);
}
function toggleRowSelection(row, checked) {
const sel = selectedMedicos.value || [];
selectedMedicos.value = checked
? (sel.some((s) => s.id === row.id) ? sel : [...sel, row])
: sel.filter((s) => s.id !== row.id);
}
// ── CRUD ──────────────────────────────────────────────────
function openCreate() {
dlg.open = true;
dlg.mode = 'create';
dlg.id = '';
dlg.nome = '';
dlg.crm = '';
dlg.especialidade = '';
dlg.especialidade_outra = '';
dlg.telefone_profissional = '';
dlg.telefone_pessoal = '';
dlg.email = '';
dlg.clinica = '';
dlg.cidade = '';
dlg.estado = 'SP';
dlg.observacoes = '';
dlg.error = '';
}
function openEdit(row) {
dlg.open = true;
dlg.mode = 'edit';
dlg.id = row.id;
dlg.nome = row.nome || '';
dlg.crm = row.crm || '';
dlg.especialidade = row.especialidade || '';
dlg.especialidade_outra = '';
dlg.telefone_profissional = fmtPhone(row.telefone_profissional);
dlg.telefone_pessoal = fmtPhone(row.telefone_pessoal);
dlg.email = row.email || '';
dlg.clinica = row.clinica || '';
dlg.cidade = row.cidade || '';
dlg.estado = row.estado || 'SP';
dlg.observacoes = row.observacoes || '';
dlg.error = '';
}
async function saveDialog() {
const nome = String(dlg.nome || '').trim();
if (!nome) {
dlg.error = 'Informe o nome do médico.';
return;
}
if (dlg.especialidade === '__outra__' && !dlg.especialidade_outra.trim()) {
dlg.error = 'Informe a especialidade.';
return;
}
dlg.saving = true;
dlg.error = '';
const payload = {
nome,
crm: dlg.crm.trim() || null,
especialidade: especialidadeFinal.value,
telefone_profissional: dlg.telefone_profissional ? digitsOnly(dlg.telefone_profissional) : null,
telefone_pessoal: dlg.telefone_pessoal ? digitsOnly(dlg.telefone_pessoal) : null,
email: dlg.email.trim() || null,
clinica: dlg.clinica.trim() || null,
cidade: dlg.cidade.trim() || null,
estado: dlg.estado.trim() || null,
observacoes: dlg.observacoes.trim() || null
};
try {
if (dlg.mode === 'create') {
await createMedico(payload);
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médico cadastrado.', life: 2500 });
} else {
await updateMedico(dlg.id, payload);
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médico atualizado.', life: 2500 });
}
dlg.open = false;
await fetchAll();
} catch (err) {
dlg.error = humanizeError(err);
} finally {
dlg.saving = false;
}
}
function confirmDeleteOne(row) {
confirm.require({
message: `Desativar "Dr(a). ${row.nome}"? O registro será ocultado da listagem.`,
header: 'Desativar médico',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Desativar',
rejectLabel: 'Cancelar',
accept: async () => {
try {
await deleteMedico(row.id);
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médico desativado.', life: 2500 });
await fetchAll();
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 });
}
}
});
}
function confirmDeleteSelected() {
const sel = selectedMedicos.value || [];
if (!sel.length) return;
confirm.require({
message: `Desativar ${sel.length} médico(s)?`,
header: 'Desativar selecionados',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Desativar',
rejectLabel: 'Cancelar',
accept: async () => {
try {
for (const m of sel) await deleteMedico(m.id);
selectedMedicos.value = [];
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médicos desativados.', life: 2500 });
await fetchAll();
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 });
}
}
});
}
// ── Helpers ───────────────────────────────────────────────
function initials(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
if (!parts.length) return '—';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function digitsOnly(v) {
return String(v ?? '').replace(/\D/g, '');
}
function fmtPhone(v) {
const d = String(v ?? '').replace(/\D/g, '');
if (!d) return '';
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`;
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`;
return d;
}
function fmtPhoneDash(v) {
const d = String(v ?? '').replace(/\D/g, '');
if (!d) return '—';
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`;
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`;
return d;
}
// ── Modal pacientes ───────────────────────────────────────
async function openMedicoPatientsModal(medicoRow) {
patientsDialog.open = true;
patientsDialog.loading = true;
patientsDialog.error = '';
patientsDialog.medico = medicoRow;
patientsDialog.items = [];
patientsDialog.search = '';
try {
patientsDialog.items = await fetchPatientsByMedicoNome(medicoRow.nome);
} catch (err) {
patientsDialog.error = humanizeError(err);
} finally {
patientsDialog.loading = false;
}
}
const editPatientId = ref(null);
const editPatientDialog = ref(false);
function abrirPaciente(patient) {
if (!patient?.id) return;
editPatientId.value = String(patient.id);
editPatientDialog.value = true;
}
watch(editPatientDialog, (isOpen) => {
if (!isOpen) editPatientId.value = null;
});
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(); });
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000;
function isRecent(row) {
if (!row?.created_at) return false;
return Date.now() - new Date(row.created_at).getTime() < HIGHLIGHT_MS;
}
</script>
<template>
<PatientCadastroDialog v-model="editPatientDialog" :patient-id="editPatientId" />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!--
HERO sticky
-->
<section
ref="headerEl"
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-teal-400/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
</div>
<div class="relative z-1 flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-teal-500/10 text-teal-600">
<i class="pi pi-heart text-base" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Médicos & Referências</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Gerencie os profissionais de referência que encaminham seus pacientes</div>
</div>
</div>
<!-- Busca desktop -->
<div class="hidden xl:flex flex-1 min-w-0 mx-2">
<div class="w-64">
<FloatLabel variant="on">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText id="medSearch" v-model="filters.global.value" class="w-full" :disabled="loading" />
</IconField>
<label for="medSearch">Buscar médico...</label>
</FloatLabel>
</div>
</div>
<!-- Ações desktop -->
<div class="hidden xl:flex items-center gap-1 shrink-0">
<Button v-if="selectedMedicos?.length" label="Desativar selecionados" icon="pi pi-trash" severity="danger" outlined class="rounded-full" @click="confirmDeleteSelected" />
<Button label="Novo médico" 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" @click="fetchAll" />
</div>
<!-- Mobile -->
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchDlgOpen = true" />
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="openCreate" />
<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>
</section>
<!-- Dialog busca mobile -->
<Dialog v-model:visible="searchDlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Buscar médico" 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, CRM, especialidade..." autofocus />
<Button v-if="filters.global.value" icon="pi pi-times" severity="secondary" @click="filters.global.value = null" />
</InputGroup>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchDlgOpen = false" />
</template>
</Dialog>
<!--
QUICK-STATS
-->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<template v-if="loading">
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="flex-1 min-w-[80px] rounded-md" />
</template>
<template v-else>
<div
v-for="s in quickStats"
:key="s.label"
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
:class="{
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]': !s.cls
}"
>
<div
class="text-[1.35rem] font-bold leading-none"
:class="{
'text-green-500': s.cls === 'qs-ok',
'text-[var(--text-color)]': !s.cls
}"
>
{{ s.value }}
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
</div>
</template>
</div>
<!--
CONTEÚDO: tabela (esq.) + painel lateral (dir.)
-->
<div class="flex flex-col lg:flex-row gap-3 px-3 md:px-4 pb-5">
<!-- TABELA -->
<div class="w-full lg:flex-1 min-w-0">
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<!-- Cabeçalho da seção -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-table text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Lista de médicos</span>
</div>
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-teal-500 text-white text-[1rem] font-bold">
{{ medicos.length }}
</span>
</div>
<DataTable
ref="dt"
v-model:selection="selectedMedicos"
:value="medicos"
dataKey="id"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 25]"
stripedRows
responsiveLayout="scroll"
:filters="filters"
filterDisplay="menu"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} médicos"
class="med-datatable"
:rowClass="(r) => (isRecent(r) ? 'row-new-highlight' : '')"
>
<!-- Seleção -->
<Column selectionMode="multiple" style="width: 3rem" :exportable="false">
<template #body="{ data }">
<Checkbox :binary="true" :modelValue="isSelected(data)" @update:modelValue="toggleRowSelection(data, $event)" />
</template>
</Column>
<Column field="nome" header="Nome" sortable style="min-width: 14rem">
<template #body="{ data }">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-teal-100 flex items-center justify-center font-black text-[0.7rem] text-teal-700 shrink-0">
{{ initials(data.nome) }}
</div>
<div class="min-w-0">
<div class="font-medium truncate">Dr(a). {{ data.nome }}</div>
<div v-if="data.crm" class="text-[0.72rem] text-[var(--text-color-secondary)]">CRM {{ data.crm }}</div>
</div>
</div>
</template>
</Column>
<Column field="especialidade" header="Especialidade" sortable style="min-width: 10rem">
<template #body="{ data }">
<Tag v-if="data.especialidade" :value="data.especialidade" severity="info" />
<span v-else class="text-[var(--text-color-secondary)] opacity-50"></span>
</template>
</Column>
<Column header="Contato" style="min-width: 10rem">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<div v-if="data.telefone_profissional" class="flex items-center gap-1 text-[0.78rem]">
<i class="pi pi-phone text-[0.65rem] text-teal-500" />
<span>{{ fmtPhoneDash(data.telefone_profissional) }}</span>
</div>
<div v-if="data.email" class="flex items-center gap-1 text-[0.78rem] text-[var(--text-color-secondary)]">
<i class="pi pi-envelope text-[0.65rem]" />
<span class="truncate max-w-[160px]">{{ data.email }}</span>
</div>
<span v-if="!data.telefone_profissional && !data.email" class="text-[var(--text-color-secondary)] opacity-50"></span>
</div>
</template>
</Column>
<Column header="Local" style="min-width: 9rem">
<template #body="{ data }">
<div class="flex flex-col gap-0.5 text-[0.78rem]">
<div v-if="data.clinica" class="font-medium truncate max-w-[160px]">{{ data.clinica }}</div>
<div v-if="data.cidade" class="text-[var(--text-color-secondary)]">
{{ data.cidade }}<template v-if="data.estado">/{{ data.estado }}</template>
</div>
<span v-if="!data.clinica && !data.cidade" class="text-[var(--text-color-secondary)] opacity-50"></span>
</div>
</template>
</Column>
<Column header="Pacientes" sortable sortField="patients_count" style="min-width: 8rem">
<template #body="{ data }">
<div class="flex items-center gap-1.5">
<span class="font-semibold text-[var(--text-color)]">{{ Number(data.patients_count ?? 0) }}</span>
<span class="text-[var(--text-color-secondary)] opacity-60 text-[0.73rem]">paciente(s)</span>
</div>
</template>
</Column>
<Column :exportable="false" header="Ações" style="width: 10rem">
<template #body="{ data }">
<div class="flex gap-1.5 justify-end">
<Button icon="pi pi-pencil" severity="secondary" outlined rounded size="small" v-tooltip.top="'Editar'" @click="openEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined rounded size="small" v-tooltip.top="'Desativar'" @click="confirmDeleteOne(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-md bg-teal-500/10 text-teal-600">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold text-[var(--text-color)]">Nenhum médico encontrado</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">Tente limpar o filtro ou cadastre um novo médico.</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="Novo médico" @click="openCreate" />
</div>
</div>
</template>
</DataTable>
</div>
<LoadedPhraseBlock v-if="hasLoaded" class="mt-3" />
</div>
<!-- PAINEL LATERAL: médicos com pacientes -->
<div class="w-full lg:w-[272px] lg:shrink-0">
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<!-- Header do painel -->
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
<div class="w-8 h-8 rounded-md flex items-center justify-center shrink-0 bg-teal-500/10 text-teal-600">
<i class="pi pi-users text-[0.9rem]" />
</div>
<div class="flex-1 min-w-0">
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Pacientes por médico</span>
<span class="block text-[0.72rem] text-[var(--text-color-secondary)]">Médicos com encaminhamentos</span>
</div>
<span v-if="cards.length" class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-teal-500 text-white text-[0.65rem] font-bold shrink-0">{{ cards.length }}</span>
</div>
<!-- Skeleton -->
<div v-if="loading" class="flex flex-col gap-2 p-3">
<Skeleton v-for="n in 4" :key="n" height="2.75rem" class="rounded-md" />
</div>
<!-- Empty -->
<div v-else-if="cards.length === 0" class="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center text-[var(--text-color-secondary)]">
<i class="pi pi-heart text-2xl opacity-20" />
<div class="font-semibold text-[0.8rem]">Nenhum encaminhamento</div>
<div class="text-[0.72rem] opacity-70 leading-relaxed">Quando um médico tiver pacientes encaminhados, ele aparecerá aqui.</div>
</div>
<!-- Lista de médicos com pacientes -->
<div v-else class="flex flex-col max-h-[480px] overflow-y-auto divide-y divide-[var(--surface-border,#f1f5f9)]">
<button
v-for="m in cards"
:key="m.id"
class="flex items-center gap-2.5 px-3.5 py-2.5 text-left w-full bg-transparent border-none hover:bg-[var(--surface-ground,#f8fafc)] transition-colors duration-100 cursor-pointer group"
@click="openMedicoPatientsModal(m)"
>
<!-- Avatar iniciais -->
<div class="w-7 h-7 rounded-full bg-teal-100 flex items-center justify-center font-black text-[0.6rem] text-teal-700 shrink-0 group-hover:bg-teal-200 transition-colors">
{{ initials(m.nome) }}
</div>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[0.8rem] truncate text-[var(--text-color)]">Dr(a). {{ m.nome }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ patientsLabel(Number(m.patients_count ?? 0)) }}
</div>
</div>
<!-- Badge contagem -->
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full font-bold text-[0.68rem] shrink-0 bg-teal-500/10 text-teal-600">
{{ Number(m.patients_count ?? 0) }}
</span>
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-30 group-hover:opacity-100 group-hover:text-teal-600 transition-all duration-150 shrink-0" />
</button>
</div>
<!-- Footer hint -->
<div v-if="cards.length" class="px-3.5 py-2 text-[1rem] text-[var(--text-color-secondary)] opacity-50 border-t border-[var(--surface-border,#f1f5f9)] text-center">
Clique para ver os pacientes encaminhados
</div>
</div>
</div>
</div>
<!--
Dialog: Criar / Editar médico
-->
<Dialog
v-model:visible="dlg.open"
modal
:draggable="false"
:closable="!dlg.saving"
:dismissableMask="!dlg.saving"
maximizable
class="w-[96vw] max-w-2xl"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-4' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<span class="flex items-center justify-center w-7 h-7 rounded-lg bg-teal-100 text-teal-600 text-[0.8rem] shrink-0">
<i class="pi pi-heart" />
</span>
<div class="min-w-0">
<div class="text-base font-semibold truncate">
{{ dlg.mode === 'create' ? 'Novo médico' : `Editar — Dr(a). ${dlg.nome || ''}` }}
</div>
<div class="text-xs opacity-50">
{{ dlg.mode === 'create' ? 'Cadastrar profissional de referência' : 'Atualizar dados do profissional' }}
</div>
</div>
</div>
</div>
</template>
<div class="flex flex-col gap-3.5">
<!-- Nome + CRM -->
<div class="grid grid-cols-1 gap-3.5 sm:grid-cols-[1fr_150px]">
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user" />
<InputText id="dlg_nome" v-model="dlg.nome" class="w-full" variant="filled" :disabled="dlg.saving" @keydown.enter.prevent="saveDialog" />
</IconField>
<label for="dlg_nome">Nome completo *</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<InputText id="dlg_crm" v-model="dlg.crm" class="w-full" variant="filled" :disabled="dlg.saving" />
<label for="dlg_crm">CRM (ex: 123456/SP)</label>
</FloatLabel>
</div>
</div>
<!-- Especialidade -->
<div>
<FloatLabel variant="on">
<Select
id="dlg_esp"
v-model="dlg.especialidade"
:options="especialidadesOpts"
optionLabel="label"
optionValue="value"
class="w-full"
variant="filled"
filter
filterPlaceholder="Buscar especialidade..."
:disabled="dlg.saving"
/>
<label for="dlg_esp">Especialidade</label>
</FloatLabel>
</div>
<!-- Especialidade "Outra" -->
<Transition
enter-active-class="transition-all duration-150 ease-out"
enter-from-class="opacity-0 -translate-y-1"
leave-active-class="transition-all duration-100 ease-in"
leave-to-class="opacity-0 -translate-y-1"
>
<div v-if="dlg.especialidade === '__outra__'">
<FloatLabel variant="on">
<InputText id="dlg_esp_outra" v-model="dlg.especialidade_outra" class="w-full" variant="filled" placeholder="Descreva a especialidade" :disabled="dlg.saving" />
<label for="dlg_esp_outra">Qual especialidade? *</label>
</FloatLabel>
</div>
</Transition>
<!-- Divider contatos -->
<div class="flex items-center gap-2">
<span class="text-[0.63rem] font-bold uppercase tracking-widest text-teal-500">Contatos</span>
<div class="flex-1 h-px bg-teal-200/50" />
</div>
<!-- Telefone profissional -->
<div>
<FloatLabel variant="on">
<InputMask id="dlg_tel_prof" v-model="dlg.telefone_profissional" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" placeholder="(00) 00000-0000" :disabled="dlg.saving" />
<label for="dlg_tel_prof">Telefone profissional</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">Consultório ou clínica.</div>
</div>
<!-- Telefone pessoal -->
<div>
<FloatLabel variant="on">
<InputMask id="dlg_tel_pes" v-model="dlg.telefone_pessoal" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" placeholder="(00) 00000-0000" :disabled="dlg.saving" />
<label for="dlg_tel_pes">Telefone pessoal / WhatsApp</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">Pessoal / WhatsApp.</div>
</div>
<!-- Email -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-envelope" />
<InputText id="dlg_email" v-model="dlg.email" class="w-full" variant="filled" :disabled="dlg.saving" />
</IconField>
<label for="dlg_email">E-mail profissional</label>
</FloatLabel>
</div>
<!-- Divider localização -->
<div class="flex items-center gap-2">
<span class="text-[0.63rem] font-bold uppercase tracking-widest text-teal-500">Localização</span>
<div class="flex-1 h-px bg-teal-200/50" />
</div>
<!-- Clínica -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-building" />
<InputText id="dlg_clinica" v-model="dlg.clinica" class="w-full" variant="filled" :disabled="dlg.saving" />
</IconField>
<label for="dlg_clinica">Clínica / Hospital</label>
</FloatLabel>
</div>
<!-- Cidade + UF -->
<div class="grid grid-cols-[1fr_90px] gap-3">
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-map-marker" />
<InputText id="dlg_cidade" v-model="dlg.cidade" class="w-full" variant="filled" :disabled="dlg.saving" />
</IconField>
<label for="dlg_cidade">Cidade</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<InputText id="dlg_uf" v-model="dlg.estado" class="w-full" variant="filled" :disabled="dlg.saving" />
<label for="dlg_uf">UF</label>
</FloatLabel>
</div>
</div>
<!-- Observações -->
<div>
<FloatLabel variant="on">
<Textarea id="dlg_obs" v-model="dlg.observacoes" rows="2" class="w-full" variant="filled" :disabled="dlg.saving" />
<label for="dlg_obs">Observações internas</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">Ex: aceita WhatsApp, convênios atendidos, melhor horário.</div>
</div>
<!-- Erro -->
<div v-if="dlg.error" class="flex items-start gap-1.5 text-[0.82rem] text-red-500 font-medium">
<i class="pi pi-exclamation-circle mt-0.5 shrink-0" /> {{ dlg.error }}
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" :disabled="dlg.saving" @click="dlg.open = false" />
<Button
:label="dlg.mode === 'create' ? 'Salvar médico' : 'Salvar alterações'"
icon="pi pi-check"
class="rounded-full"
:loading="dlg.saving"
:disabled="!String(dlg.nome || '').trim()"
@click="saveDialog"
/>
</div>
</template>
</Dialog>
<!--
Dialog: Pacientes do médico
-->
<Dialog
v-model:visible="patientsDialog.open"
modal
:draggable="false"
:style="{ width: '860px', maxWidth: '95vw' }"
:pt="{
root: { style: 'border: 4px solid #14b8a6' },
header: { style: 'border-bottom: 1px solid rgba(20,184,166,0.19)' }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-base shrink-0 bg-teal-500">
{{ initials(patientsDialog.medico?.nome) }}
</div>
<div>
<div class="text-[1rem] font-bold text-teal-600">Dr(a). {{ patientsDialog.medico?.nome }}</div>
<div class="text-[0.72rem] text-[var(--text-color-secondary)]">
<template v-if="patientsDialog.medico?.especialidade">{{ patientsDialog.medico.especialidade }} · </template>
{{ patientsDialog.items.length }} paciente{{ patientsDialog.items.length !== 1 ? 's' : '' }} encaminhado{{ patientsDialog.items.length !== 1 ? 's' : '' }}
</div>
</div>
</div>
</template>
<div class="flex flex-col gap-3">
<!-- Busca + contador -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<IconField class="w-full sm:w-72">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="patientsDialog.search" placeholder="Buscar paciente..." class="w-full" :disabled="patientsDialog.loading" />
</IconField>
<span v-if="!patientsDialog.loading" class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-teal-500/10 text-teal-600">
{{ patientsDialog.items.length }} paciente(s)
</span>
</div>
<!-- Loading -->
<div v-if="patientsDialog.loading" class="flex items-center gap-2 py-4 text-teal-600"><i class="pi pi-spin pi-spinner" /> Carregando...</div>
<Message v-else-if="patientsDialog.error" severity="error">{{ patientsDialog.error }}</Message>
<div v-else>
<!-- Empty -->
<div v-if="patientsDialog.items.length === 0" class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md bg-teal-500/10 text-teal-600">
<i class="pi pi-users text-xl" />
</div>
<div class="font-semibold">Nenhum paciente encaminhado</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">Associe pacientes a este médico no cadastro de pacientes.</div>
</div>
<!-- Tabela -->
<DataTable v-else :value="patientsDialogFiltered" dataKey="id" stripedRows responsiveLayout="scroll" paginator :rows="8" :rowsPerPageOptions="[8, 15, 30]">
<Column header="Paciente" sortable>
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="circle" />
<Avatar v-else :label="initials(data.full_name)" shape="circle" style="background: rgba(20,184,166,0.15); color: #14b8a6" />
<div class="min-w-0">
<div class="font-medium truncate">{{ data.full_name }}</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">{{ data.email || '—' }}</div>
</div>
</div>
</template>
</Column>
<Column header="Telefone" style="min-width: 11rem">
<template #body="{ data }">
<span class="text-[var(--text-color-secondary)]">{{ fmtPhoneDash(data.phone) }}</span>
</template>
</Column>
<Column header="Ação" style="width: 9rem">
<template #body="{ data }">
<Button label="Abrir" icon="pi pi-external-link" size="small" outlined class="!border-teal-500 !text-teal-600" @click="abrirPaciente(data)" />
</template>
</Column>
<template #empty>
<div class="py-8 text-center">
<i class="pi pi-search text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-[1rem]">Nenhum resultado</div>
<div class="text-[0.75rem] opacity-60 mt-1">Nenhum paciente corresponde à busca.</div>
<Button class="mt-3" severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar" size="small" @click="patientsDialog.search = ''" />
</div>
</template>
</DataTable>
</div>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" outlined class="rounded-full !border-teal-500 !text-teal-600" @click="patientsDialog.open = false" />
</template>
</Dialog>
<ConfirmDialog />
</template>

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,667 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/PatientsDetailPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
// ── DADOS MOCKADOS ──────────────────────────────────────────────
const patient = ref({
nome_completo: 'Mariana Lima',
nome_social: null,
pronomes: 'ela/dela',
data_nascimento: '1992-06-14',
cpf: '12345678990',
genero: 'Feminino',
estado_civil: 'Solteira',
escolaridade: 'Superior completo',
profissao: 'Desenvolvedora',
etnia: null,
naturalidade: 'São Carlos',
telefone: '16991234567',
email_principal: 'mariana@email.com',
canal_preferido: 'WhatsApp',
horario_contato: '08h18h',
cep: '13560-000',
cidade: 'São Carlos',
estado: 'SP',
status: 'Ativa',
convenio: 'Unimed',
patient_scope: 'Clínica',
risco_elevado: true,
onde_nos_conheceu: 'Indicação',
encaminhado_por: 'Dr. Roberto (psiq.)',
motivo_saida: null,
avatar_url: null,
})
const tags = ref([
{ id: '1', name: 'Ansiedade', color: '#8B5CF6' },
{ id: '2', name: 'TCC', color: '#10B981' },
])
const metricas = ref({
total_sessoes: 47,
comparecimento_pct: 92,
ltv_total: 8460,
dias_ultima_sessao: 18,
})
const contatos = ref([
{
id: '1', nome: 'Maria Lima', relacao: 'mãe',
telefone: '16988880001', email: 'maria@email.com', is_primario: true,
},
{
id: '2', nome: 'Dr. Roberto Oliveira', relacao: 'psiquiatra',
telefone: '1633221100', email: null, crm: 'CRM 54321', is_primario: false,
},
])
const engajamento = ref({
comparecimento_pct: 92,
pagamentos_em_dia_pct: 100,
tarefas_concluidas_pct: 60,
score_geral: 84,
em_tratamento_meses: 14,
proxima_sessao: '2025-03-27T14:00:00',
})
const timeline = ref([
{ id: '1', titulo: 'Risco elevado sinalizado', subtitulo: 'Atenção', data: '2025-03-12', autor: 'Dra. Ana Lima', cor: '#EF4444' },
{ id: '2', titulo: 'GAD-7 respondido · Score 12 (ansiedade moderada)', data: '2025-03-10', canal: 'via portal', cor: '#10B981' },
{ id: '3', titulo: 'TCLE assinado digitalmente', data: '2024-01-02', canal: 'via portal', cor: '#3B82F6' },
{ id: '4', titulo: 'Primeira sessão realizada', data: '2024-01-15', canal: 'presencial', cor: '#10B981' },
])
// ── NAVEGAÇÃO ────────────────────────────────────────────────────
const activeTab = ref('perfil')
const tabs = [
{ key: 'perfil', label: 'Perfil' },
{ key: 'prontuario', label: 'Prontuário' },
{ key: 'agenda', label: 'Agenda' },
{ key: 'financeiro', label: 'Financeiro' },
{ key: 'documentos', label: 'Documentos' },
]
const sideNavItems = [
{ key: 'dados', label: 'Dados pessoais', icon: 'pi pi-user' },
{ key: 'contato', label: 'Contato & origem', icon: 'pi pi-phone' },
{ key: 'rede', label: 'Rede de suporte', icon: 'pi pi-users' },
{ key: 'engajamento', label: 'Engajamento', icon: 'pi pi-chart-bar' },
{ key: 'timeline', label: 'Linha do tempo', icon: 'pi pi-history' },
]
const activeSideNav = ref('dados')
const isCompact = ref(false)
let mql = null, mqlHandler = null
function syncCompact() { isCompact.value = !!mql?.matches }
onMounted(() => {
mql = window.matchMedia('(max-width: 1023px)')
mqlHandler = () => syncCompact()
mql.addEventListener?.('change', mqlHandler)
mql.addListener?.(mqlHandler)
syncCompact()
})
onBeforeUnmount(() => {
mql?.removeEventListener?.('change', mqlHandler)
mql?.removeListener?.(mqlHandler)
})
function scrollToSection(key) {
activeSideNav.value = key
const el = document.getElementById(`section-${key}`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// ── FORMATADORES ─────────────────────────────────────────────────
function parseDateLoose(v) {
if (!v) return null
const s = String(v).trim()
if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
const d = new Date(s.slice(0, 10))
return isNaN(d) ? null : d
}
const d = new Date(s)
return isNaN(d) ? null : d
}
function calcAge(v) {
const d = parseDateLoose(v)
if (!d) return null
const now = new Date()
let age = now.getFullYear() - d.getFullYear()
const m = now.getMonth() - d.getMonth()
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--
return age
}
function fmtDateBR(v) {
const d = parseDateLoose(v)
if (!d) return '—'
return `${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()}`
}
function fmtPhone(v) {
const d = String(v ?? '').replace(/\D/g, '')
if (d.length === 11) return `(${d.slice(0,2)}) ${d.slice(2,7)}-${d.slice(7)}`
if (d.length === 10) return `(${d.slice(0,2)}) ${d.slice(2,6)}-${d.slice(6)}`
return v || '—'
}
function maskCPF(v) {
if (!v) return '—'
const d = String(v).replace(/\D/g, '')
return `•••${d.slice(3,6)}••••${d.slice(9)}`
}
function fmtCurrency(v) {
return `R$ ${Number(v).toLocaleString('pt-BR')}`
}
function fmtProximaSessao(iso) {
if (!iso) return '—'
const dt = new Date(iso)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')} às ${String(dt.getHours()).padStart(2,'0')}h`
}
const ageLabel = computed(() => calcAge(patient.value.data_nascimento))
const birthLabel = computed(() => {
const age = calcAge(patient.value.data_nascimento)
return `${fmtDateBR(patient.value.data_nascimento)}${age != null ? ` (${age} a)` : ''}`
})
function nameInitials(name) {
if (!name) return '?'
return String(name).split(' ').filter(Boolean).slice(0,2).map(w => w[0].toUpperCase()).join('')
}
const initials = computed(() => nameInitials(patient.value.nome_completo))
function hexToRgb(hex) {
const h = String(hex ?? '').replace('#','').trim()
if (h.length !== 6 && h.length !== 3) return null
const full = h.length === 3 ? h.split('').map(c=>c+c).join('') : h
return { r: parseInt(full.slice(0,2),16), g: parseInt(full.slice(2,4),16), b: parseInt(full.slice(4,6),16) }
}
function bestTextColor(hex) {
const rgb = hexToRgb(hex)
if (!rgb) return '#0f172a'
const lum = 0.2126*(rgb.r/255) + 0.7152*(rgb.g/255) + 0.0722*(rgb.b/255)
return lum < 0.45 ? '#ffffff' : '#0f172a'
}
function tagStyle(t) {
const bg = t?.color || t?.cor || ''
return bg ? { backgroundColor: bg, color: bestTextColor(bg) } : {}
}
</script>
<template>
<!-- BARRA SUPERIOR -->
<div class="sticky top-0 z-20 flex items-center justify-between
px-4 py-2.5 bg-[var(--surface-0)]
border-b border-[var(--surface-border)]">
<Button icon="pi pi-arrow-left" label="Pacientes"
severity="secondary" text size="small" />
<div class="flex gap-2">
<Button label="Editar" outlined size="small" />
<Button label="+ Sessão" size="small" />
</div>
</div>
<!-- LAYOUT PRINCIPAL -->
<div class="min-h-screen bg-[var(--surface-ground)]">
<div class="max-w-6xl mx-auto px-4 py-5">
<div class="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-4 items-start">
<!--
SIDEBAR ESQUERDA
-->
<aside class="lg:sticky lg:top-[57px] space-y-3">
<!-- Bloco avatar + nome + badges + métricas -->
<div class="rounded-xl border border-[var(--surface-border)]
bg-[var(--surface-card)] p-4 shadow-sm">
<div class="flex flex-col items-center text-center gap-2.5">
<!-- Avatar ou iniciais -->
<div v-if="patient.avatar_url"
class="w-16 h-16 rounded-full overflow-hidden">
<img :src="patient.avatar_url" class="w-full h-full object-cover" alt="avatar" />
</div>
<div v-else
class="w-16 h-16 rounded-full bg-indigo-100
flex items-center justify-center">
<span class="text-xl font-bold text-indigo-700 tracking-tight">{{ initials }}</span>
</div>
<!-- Nome e sub-linha -->
<div>
<p class="text-sm font-bold text-[var(--text-color)] leading-tight">
{{ patient.nome_completo }}
</p>
<p class="text-xs text-[var(--text-color-secondary)] mt-0.5">
{{ ageLabel }} anos · {{ patient.pronomes }}
</p>
<p class="text-xs text-[var(--text-color-secondary)]">
{{ patient.naturalidade }}, {{ patient.estado }}
</p>
</div>
<!-- Status + convenio + scope -->
<div class="flex flex-wrap justify-center gap-1">
<Tag value="Ativa" severity="success" class="!text-[0.7rem]" />
<Tag :value="patient.convenio" severity="info" class="!text-[0.7rem]" />
<Tag :value="patient.patient_scope" severity="secondary" class="!text-[0.7rem]" />
</div>
<!-- Tags com cor -->
<div class="flex flex-wrap justify-center gap-1">
<span v-for="tag in tags" :key="tag.id"
class="inline-flex items-center px-2 py-0.5 rounded-full text-[0.7rem] font-medium"
:style="tagStyle(tag)">
{{ tag.name }}
</span>
</div>
</div>
<Divider class="!my-3" />
<!-- Métricas 2x2 -->
<div class="grid grid-cols-2 gap-3 text-center">
<div>
<p class="text-xl font-bold text-[var(--text-color)]">{{ metricas.total_sessoes }}</p>
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">Sessões</p>
</div>
<div>
<p class="text-xl font-bold text-emerald-600">{{ metricas.comparecimento_pct }}%</p>
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">Comparec.</p>
</div>
<div>
<p class="text-base font-bold text-[var(--text-color)]">{{ fmtCurrency(metricas.ltv_total) }}</p>
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">LTV total</p>
</div>
<div>
<p class="text-xl font-bold text-amber-500">{{ metricas.dias_ultima_sessao }}d</p>
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">Últ. sessão</p>
</div>
</div>
</div>
<!-- Nav lateral (desktop + aba perfil) -->
<div v-if="!isCompact && activeTab === 'perfil'"
class="rounded-xl border border-[var(--surface-border)]
bg-[var(--surface-card)] p-2 shadow-sm">
<button
v-for="item in sideNavItems" :key="item.key"
type="button"
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2
text-left text-sm border transition-colors duration-100"
:class="activeSideNav === item.key
? 'bg-indigo-50 border-indigo-200 text-indigo-700 font-semibold'
: 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground)] font-medium'"
@click="scrollToSection(item.key)"
>
<i :class="item.icon" class="text-sm opacity-60 shrink-0" />
<span>{{ item.label }}</span>
</button>
</div>
</aside>
<!--
CONTEÚDO DIREITA
-->
<div class="min-w-0 space-y-4">
<!-- Banner risco elevado -->
<div v-if="patient.risco_elevado"
class="flex items-start gap-3 rounded-xl border border-red-200
bg-red-50 px-4 py-3">
<i class="pi pi-circle-fill text-red-500 mt-0.5 text-[0.5rem]" />
<div>
<p class="text-sm font-semibold text-red-700">
Atenção paciente com risco elevado sinalizado
</p>
<p class="text-xs text-red-500 mt-0.5">
Ideação passiva relatada em 12/03 · Sinalizado por Dra. Ana Lima
</p>
</div>
</div>
<!-- PAINEL COM TABS -->
<div class="rounded-xl border border-[var(--surface-border)]
bg-[var(--surface-card)] shadow-sm overflow-hidden">
<!-- Tab bar -->
<div class="flex border-b border-[var(--surface-border)] overflow-x-auto">
<button
v-for="tab in tabs" :key="tab.key"
type="button"
class="shrink-0 px-5 py-3 text-sm font-medium border-b-2
transition-colors duration-100 whitespace-nowrap"
:class="activeTab === tab.key
? 'border-[var(--primary-color)] text-[var(--primary-color)]'
: 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<!-- ABA PERFIL -->
<div v-if="activeTab === 'perfil'" class="p-4 space-y-4">
<!-- DADOS PESSOAIS + CONTATO/ORIGEM -->
<div id="section-dados" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Dados pessoais -->
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3">
DADOS PESSOAIS
</p>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Nome completo</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.nome_completo }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Nome social</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
<span class="text-[var(--text-color-secondary)]"></span>
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Pronomes</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
{{ patient.pronomes }}
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Data de nascimento</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ birthLabel }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">CPF</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right font-mono">{{ maskCPF(patient.cpf) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Gênero</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.genero }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Estado civil</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.estado_civil }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Escolaridade</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.escolaridade }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Profissão</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.profissao }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Etnia</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
<span class="italic text-[var(--text-color-secondary)]">Não informado</span>
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
</span>
</div>
</div>
<!-- Coluna direita: Contato + Origem -->
<div id="section-contato" class="space-y-4">
<!-- Contato -->
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3">
CONTATO
</p>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">WhatsApp</span>
<a :href="`tel:${patient.telefone}`"
class="text-[0.82rem] font-medium text-right text-[var(--primary-color)] hover:underline">
{{ fmtPhone(patient.telefone) }}
</a>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Email</span>
<a :href="`mailto:${patient.email_principal}`"
class="text-[0.82rem] font-medium text-right text-[var(--primary-color)] hover:underline truncate max-w-[180px] inline-block">
{{ patient.email_principal }}
</a>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Canal preferido</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
{{ patient.canal_preferido }}
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Horário de contato</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
{{ patient.horario_contato }}
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">CEP</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">
{{ patient.cep }} · {{ patient.cidade }}
</span>
</div>
</div>
<!-- Origem -->
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3">
ORIGEM
</p>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Como chegou</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.onde_nos_conheceu }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Encaminhado por</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.encaminhado_por }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Método de pag.</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
PIX
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Motivo de saída</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
<span class="text-[var(--text-color-secondary)]"></span>
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
</span>
</div>
</div>
</div>
</div>
<!-- REDE DE SUPORTE + ENGAJAMENTO -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Contatos & rede -->
<div id="section-rede"
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex items-center gap-2 mb-3">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
CONTATOS &amp; REDE DE SUPORTE
</p>
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">NOVO</span>
</div>
<div class="space-y-2">
<div v-for="c in contatos" :key="c.id"
class="flex items-start gap-3 rounded-lg border
border-[var(--surface-border)] p-3
bg-[var(--surface-ground)]">
<div class="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center shrink-0">
<span class="text-[0.65rem] font-bold text-indigo-700">{{ nameInitials(c.nome) }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 flex-wrap">
<span class="text-[0.82rem] font-semibold text-[var(--text-color)]">{{ c.nome }}</span>
<span class="text-[0.75rem] text-[var(--text-color-secondary)]">· {{ c.relacao }}</span>
<Tag v-if="c.is_primario" value="emergência" severity="danger"
class="!text-[0.65rem] !py-0 !px-1.5 shrink-0" />
</div>
<p class="text-[0.76rem] text-[var(--text-color-secondary)] mt-0.5">
<a :href="`tel:${c.telefone}`" class="text-[var(--primary-color)] hover:underline">{{ fmtPhone(c.telefone) }}</a>
<template v-if="c.email"> · {{ c.email }}</template>
<template v-if="c.crm"> · {{ c.crm }}</template>
</p>
</div>
</div>
</div>
<button type="button"
class="mt-3 w-full flex items-center gap-2.5 px-3 py-2 rounded-lg
border border-dashed border-[var(--surface-border)]
text-[0.82rem] text-[var(--text-color-secondary)]
hover:bg-[var(--surface-ground)] transition-colors">
<span class="w-7 h-7 rounded-full bg-[var(--surface-ground)] border border-[var(--surface-border)] flex items-center justify-center">
<i class="pi pi-plus text-[0.65rem]" />
</span>
Adicionar contato
</button>
</div>
<!-- Engajamento -->
<div id="section-engajamento"
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex items-center gap-2 mb-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
ENGAJAMENTO
</p>
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">NOVO</span>
</div>
<div class="space-y-3">
<div>
<div class="flex justify-between text-[0.78rem] mb-1.5">
<span class="text-[var(--text-color-secondary)]">Comparecimento</span>
<span class="font-semibold text-emerald-600">{{ engajamento.comparecimento_pct }}%</span>
</div>
<ProgressBar :value="engajamento.comparecimento_pct" :showValue="false" class="progress-success" />
</div>
<div>
<div class="flex justify-between text-[0.78rem] mb-1.5">
<span class="text-[var(--text-color-secondary)]">Pagamentos em dia</span>
<span class="font-semibold text-emerald-600">{{ engajamento.pagamentos_em_dia_pct }}%</span>
</div>
<ProgressBar :value="engajamento.pagamentos_em_dia_pct" :showValue="false" class="progress-success" />
</div>
<div>
<div class="flex justify-between text-[0.78rem] mb-1.5">
<span class="text-[var(--text-color-secondary)]">Tarefas concluídas</span>
<span class="font-semibold text-amber-500">{{ engajamento.tarefas_concluidas_pct }}%</span>
</div>
<ProgressBar :value="engajamento.tarefas_concluidas_pct" :showValue="false" class="progress-warning" />
</div>
</div>
<Divider class="!my-3" />
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Score geral</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">
<span class="text-lg font-bold">{{ engajamento.score_geral }}</span> / 100
</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Em tratamento </span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ engajamento.em_tratamento_meses }} meses</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Próxima sessão</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ fmtProximaSessao(engajamento.proxima_sessao) }}</span>
</div>
</div>
</div>
<!-- LINHA DO TEMPO -->
<div id="section-timeline"
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex items-center gap-2 mb-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
LINHA DO TEMPO
</p>
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">NOVO</span>
</div>
<div class="space-y-0">
<div v-for="(item, idx) in timeline" :key="item.id" class="flex gap-4">
<!-- Dot + linha vertical -->
<div class="flex flex-col items-center">
<div class="w-3 h-3 rounded-full mt-1 shrink-0 ring-2 ring-[var(--surface-card)] shadow-sm"
:style="{ backgroundColor: item.cor }" />
<div v-if="idx < timeline.length - 1"
class="w-px flex-1 bg-[var(--surface-border)] my-1" />
</div>
<!-- Conteúdo -->
<div class="pb-5 min-w-0">
<p class="text-[0.85rem] font-semibold text-[var(--text-color)] leading-snug">
{{ item.titulo }}
<span v-if="item.subtitulo" class="font-normal text-[var(--text-color-secondary)]"> · {{ item.subtitulo }}</span>
</p>
<p class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5">
{{ fmtDateBR(item.data) }}
<template v-if="item.autor"> · {{ item.autor }}</template>
<template v-else-if="item.canal"> · {{ item.canal }}</template>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- FIM ABA PERFIL -->
<!-- Placeholder outras abas -->
<div v-if="activeTab !== 'perfil'" class="p-10 text-center text-[var(--text-color-secondary)]">
<i class="pi pi-clock text-4xl mb-3 block opacity-20" />
<p class="text-sm">Em breve</p>
</div>
</div><!-- /painel tabs -->
</div><!-- /conteúdo direita -->
</div><!-- /grid -->
</div><!-- /max-w -->
</div><!-- /wrapper -->
</template>
<style scoped>
:deep(.progress-success .p-progressbar-value) { background: #22c55e; }
:deep(.progress-warning .p-progressbar-value) { background: #f59e0b; }
:deep(.p-progressbar) {
height: 0.45rem;
border-radius: 9999px;
overflow: hidden;
}
:deep(.p-progressbar-value) { border-radius: 9999px; }
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,7 @@ const presetModel = computed({
applyThemeEngine(layoutConfig); applyThemeEngine(layoutConfig);
queuePatch?.({ preset: val }); queuePatch?.({ preset: val });
saveThemeToStorage();
} }
}); });
@@ -68,11 +69,23 @@ const menuModeModel = computed({
} }
}); });
function saveThemeToStorage() {
try {
localStorage.setItem('ui_theme_config', JSON.stringify({
preset: layoutConfig.preset,
primary: layoutConfig.primary,
surface: layoutConfig.surface,
menuMode: layoutConfig.menuMode
}));
} catch {}
}
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);
queuePatch?.({ primary_color: item.name }); queuePatch?.({ primary_color: item.name });
saveThemeToStorage();
return; return;
} }
@@ -80,6 +93,7 @@ function updateColors(type, item) {
layoutConfig.surface = item.name; layoutConfig.surface = item.name;
applyThemeEngine(layoutConfig); applyThemeEngine(layoutConfig);
queuePatch?.({ surface_color: item.name }); queuePatch?.({ surface_color: item.name });
saveThemeToStorage();
} }
} }
</script> </script>

View File

@@ -18,7 +18,7 @@
<template> <template>
<div class="layout-footer"> <div class="layout-footer">
SAKAI by Agência PSI
<a href="https://primevue.org" target="_blank" rel="noopener noreferrer" class="text-primary font-bold hover:underline">PrimeVue</a> <a href="https://primevue.org" target="_blank" rel="noopener noreferrer" class="text-primary font-bold hover:underline">PrimeVue</a>
</div> </div>
</template> </template>

View File

@@ -176,7 +176,7 @@ onBeforeUnmount(() => {
<style> <style>
/* ────────────────────────────────────────────── /* ──────────────────────────────────────────────
LAYOUT CLÁSSICO — ajustes globais (não scoped) LAYOUT CLÁSSICO — ajustes globais (não scoped)
para sobrescrever o tema PrimeVue/Sakai para sobrescrever o tema PrimeVue
────────────────────────────────────────────── */ ────────────────────────────────────────────── */
/* ── Global Notice Banner: variável de altura ───────────── /* ── Global Notice Banner: variável de altura ─────────────

View File

@@ -46,11 +46,24 @@ function isDarkNow() {
return document.documentElement.classList.contains('app-dark'); 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));
const now = isDarkNow();
if (now !== before) return now;
}
return isDarkNow();
}
async function toggleDarkAndPersist() { async function toggleDarkAndPersist() {
try { try {
const before = isDarkNow();
toggleDarkMode(); toggleDarkMode();
await nextTick(); const after = await waitForDarkFlip(before);
const theme_mode = isDarkNow() ? 'dark' : 'light'; const theme_mode = after ? 'dark' : 'light';
try { localStorage.setItem('ui_theme_mode', theme_mode); } catch {}
await queuePatch({ theme_mode }, { flushNow: true }); await queuePatch({ theme_mode }, { flushNow: true });
} catch (e) { } catch (e) {
console.error('[FooterPanel][theme] falhou:', e?.message || e); console.error('[FooterPanel][theme] falhou:', e?.message || e);

View File

@@ -46,6 +46,7 @@ const emit = defineEmits(['quick-create']);
const props = defineProps({ const props = defineProps({
item: { type: Object, default: () => ({}) }, item: { type: Object, default: () => ({}) },
index: { type: Number, default: 0 },
root: { type: Boolean, default: false }, root: { type: Boolean, default: false },
parentPath: { type: String, default: null } parentPath: { type: String, default: null }
}); });

View File

@@ -95,6 +95,7 @@ const userName = computed(() => sessionUser.value?.user_metadata?.full_name || s
// ── Início (fixo) ──────────────────────────────────────────── // ── Início (fixo) ────────────────────────────────────────────
function selectHome() { function selectHome() {
if (layoutConfig.railOpenMode === 'hover') return;
if (layoutState.railSectionKey === '__home__' && layoutState.railPanelOpen) { if (layoutState.railSectionKey === '__home__' && layoutState.railPanelOpen) {
layoutState.railPanelOpen = false; layoutState.railPanelOpen = false;
} else { } else {
@@ -107,6 +108,7 @@ const isHomeActive = computed(() => layoutState.railSectionKey === '__home__' &&
// ── Seleção de seção ───────────────────────────────────────── // ── Seleção de seção ─────────────────────────────────────────
function selectSection(section) { function selectSection(section) {
if (layoutConfig.railOpenMode === 'hover') return;
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) { if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) {
layoutState.railPanelOpen = false; layoutState.railPanelOpen = false;
} else { } else {
@@ -115,13 +117,21 @@ function selectSection(section) {
} }
} }
// Verifica recursivamente se alguma rota do grupo está ativa
function _matchesActive(items, active) {
return items.some((i) => {
const p = typeof i.to === 'string' ? i.to : '';
if (p && active.startsWith(p)) return true;
if (Array.isArray(i.items) && i.items.length) return _matchesActive(i.items, active);
return false;
});
}
function isActiveSectionOrChild(section) { function isActiveSectionOrChild(section) {
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) return true; if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) return true;
const active = String(layoutState.activePath || ''); const active = String(layoutState.activePath || '');
return section.items.some((i) => { if (!active) return false;
const p = typeof i.to === 'string' ? i.to : ''; return _matchesActive(section.items, active);
return p && active.startsWith(p);
});
} }
// ── Menu do usuário (rodapé) ───────────────────────────────── // ── Menu do usuário (rodapé) ─────────────────────────────────
@@ -144,7 +154,6 @@ function toggleUserMenu(e) {
<button <button
class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105" class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
:class="isHomeActive ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''" :class="isHomeActive ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''"
v-tooltip.right="{ value: 'Início', showDelay: 0 }"
aria-label="Início" aria-label="Início"
@click="selectHome" @click="selectHome"
@mouseenter="onHomeHover" @mouseenter="onHomeHover"
@@ -157,7 +166,6 @@ function toggleUserMenu(e) {
:key="section.key" :key="section.key"
class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105" class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
:class="isActiveSectionOrChild(section) ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''" :class="isActiveSectionOrChild(section) ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''"
v-tooltip.right="{ value: section.label, showDelay: 0 }"
:aria-label="section.label" :aria-label="section.label"
@click="selectSection(section)" @click="selectSection(section)"
@mouseenter="onSectionHover(section)" @mouseenter="onSectionHover(section)"
@@ -170,7 +178,6 @@ function toggleUserMenu(e) {
<div class="w-full flex flex-col items-center gap-1.5 py-2 pb-3 border-t border-[var(--surface-border)]"> <div class="w-full flex flex-col items-center gap-1.5 py-2 pb-3 border-t border-[var(--surface-border)]">
<button <button
class="w-9 h-9 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-[0.875rem] shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105" class="w-9 h-9 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-[0.875rem] shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
v-tooltip.right="{ value: 'Configurações', showDelay: 0 }"
aria-label="Configurações" aria-label="Configurações"
@click="$router.push('/configuracoes')" @click="$router.push('/configuracoes')"
> >
@@ -180,7 +187,6 @@ function toggleUserMenu(e) {
<!-- Avatar trigger do menu de usuário --> <!-- Avatar trigger do menu de usuário -->
<button <button
class="w-9 h-9 rounded-[10px] border-none cursor-pointer overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center transition-[transform,box-shadow] duration-150 hover:scale-105 hover:shadow-[0_0_0_2px_var(--primary-color)]" class="w-9 h-9 rounded-[10px] border-none cursor-pointer overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center transition-[transform,box-shadow] duration-150 hover:scale-105 hover:shadow-[0_0_0_2px_var(--primary-color)]"
v-tooltip.right="{ value: userName, showDelay: 0 }"
:aria-label="userName" :aria-label="userName"
@click="toggleUserMenu" @click="toggleUserMenu"
> >

View File

@@ -114,12 +114,14 @@ function onPanelMouseEnter() {
} }
function onPanelMouseLeave() { function onPanelMouseLeave() {
if (layoutConfig.railOpenMode !== 'hover') return; if (layoutConfig.railOpenMode !== 'hover') return;
if (popoverOpen.value) return; // popover flutuante aberto — não fechar
scheduleRailHoverClose(200); scheduleRailHoverClose(200);
} }
// ── QuickCreate (Pacientes) ─────────────────────────────── // ── QuickCreate (Pacientes) ───────────────────────────────
const createPopover = ref(null); const createPopover = ref(null);
const quickDialog = ref(false); const quickDialog = ref(false);
const popoverOpen = ref(false);
function openQuickCreate(event, item) { function openQuickCreate(event, item) {
createPopover.value?.toggle(event); createPopover.value?.toggle(event);
@@ -482,7 +484,7 @@ async function goToResult(r) {
</nav> </nav>
<!-- PatientCreatePopover (shared) --> <!-- PatientCreatePopover (shared) -->
<PatientCreatePopover ref="createPopover" @quick-create="onQuickCreate" /> <PatientCreatePopover ref="createPopover" @quick-create="onQuickCreate" @show="popoverOpen = true" @hide="popoverOpen = false" />
<!-- Cadastro Rápido Dialog --> <!-- Cadastro Rápido Dialog -->
<ComponentCadastroRapido <ComponentCadastroRapido

View File

@@ -57,6 +57,7 @@ const presetModel = computed({
layoutConfig.preset = v; layoutConfig.preset = v;
applyThemeEngine(layoutConfig); applyThemeEngine(layoutConfig);
queuePatch?.({ preset: v }); queuePatch?.({ preset: v });
saveThemeToStorage();
} }
}); });
@@ -68,16 +69,29 @@ const menuModeModel = computed({
} }
}); });
function saveThemeToStorage() {
try {
localStorage.setItem('ui_theme_config', JSON.stringify({
preset: layoutConfig.preset,
primary: layoutConfig.primary,
surface: layoutConfig.surface,
menuMode: layoutConfig.menuMode
}));
} catch {}
}
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);
queuePatch?.({ primary_color: item.name }); queuePatch?.({ primary_color: item.name });
saveThemeToStorage();
} }
if (type === 'surface') { if (type === 'surface') {
layoutConfig.surface = item.name; layoutConfig.surface = item.name;
applyThemeEngine(layoutConfig); applyThemeEngine(layoutConfig);
queuePatch?.({ surface_color: item.name }); queuePatch?.({ surface_color: item.name });
saveThemeToStorage();
} }
} }

View File

@@ -140,6 +140,16 @@ async function loadAndApplyUserSettings() {
// 3) aplica engine UMA vez // 3) aplica engine UMA vez
applyThemeEngine(layoutConfig); applyThemeEngine(layoutConfig);
// 4) persiste no localStorage para carregamento instantâneo no próximo boot
try {
localStorage.setItem('ui_theme_config', JSON.stringify({
preset: layoutConfig.preset,
primary: layoutConfig.primary,
surface: layoutConfig.surface,
menuMode: layoutConfig.menuMode
}));
} catch {}
// ✅ IMPORTANTE: // ✅ IMPORTANTE:
// changeMenuMode NÃO é só "setar menuMode". // changeMenuMode NÃO é só "setar menuMode".
// Ele reseta estados do sidebar/overlay/mobile e previne drift. // Ele reseta estados do sidebar/overlay/mobile e previne drift.
@@ -165,6 +175,7 @@ async function toggleDarkAndPersistSilently() {
toggleDarkMode(); toggleDarkMode();
const after = await waitForDarkFlip(before); const after = await waitForDarkFlip(before);
const theme_mode = after ? 'dark' : 'light'; const theme_mode = after ? 'dark' : 'light';
try { localStorage.setItem('ui_theme_mode', theme_mode); } catch {}
await queuePatch({ theme_mode }, { flushNow: true }); await queuePatch({ theme_mode }, { flushNow: true });
} catch (e) { } catch (e) {
console.error('[Topbar][theme] falhou:', e?.message || e); console.error('[Topbar][theme] falhou:', e?.message || e);
@@ -632,7 +643,7 @@ onMounted(async () => {
} }
/* Hamburguer: visível apenas em ≤ xl (1280px) /* Hamburguer: visível apenas em ≤ xl (1280px)
!important necessário para sobrescrever CSS do tema Sakai (.layout-menu-button) */ !important necessário para sobrescrever CSS do tema (.layout-menu-button) */
.rail-topbar__hamburger { .rail-topbar__hamburger {
display: none !important; display: none !important;
} }

View File

@@ -36,12 +36,23 @@ function _loadRailOpenMode() {
return 'hover'; return 'hover';
} }
// ── resolve tema (preset/primary/surface) salvo no localStorage ─
function _loadSavedTheme() {
try {
const raw = localStorage.getItem('ui_theme_config');
if (raw) return JSON.parse(raw);
} catch {}
return {};
}
const _savedTheme = _loadSavedTheme();
const layoutConfig = reactive({ const layoutConfig = reactive({
preset: 'Aura', preset: _savedTheme.preset || 'Aura',
primary: 'emerald', primary: _savedTheme.primary || 'emerald',
surface: null, surface: _savedTheme.surface || null,
darkTheme: false, darkTheme: false,
menuMode: 'static', menuMode: _savedTheme.menuMode || 'static',
variant: _loadVariant(), // 'classic' | 'rail' variant: _loadVariant(), // 'classic' | 'rail'
railOpenMode: _loadRailOpenMode() // 'click' | 'hover' railOpenMode: _loadRailOpenMode() // 'click' | 'hover'
}); });

View File

@@ -15,18 +15,18 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
--> -->
<script setup> <script setup>
import { computed, ref, watch, onMounted, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import DatePicker from 'primevue/datepicker'; import DatePicker from 'primevue/datepicker';
import { useToast } from 'primevue/usetoast';
import PausasChipsEditor from '@/components/agenda/PausasChipsEditor.vue'; import PausasChipsEditor from '@/components/agenda/PausasChipsEditor.vue';
import FullCalendar from '@fullcalendar/vue3';
import timeGridPlugin from '@fullcalendar/timegrid';
import ptBrLocale from '@fullcalendar/core/locales/pt-br'; import ptBrLocale from '@fullcalendar/core/locales/pt-br';
import timeGridPlugin from '@fullcalendar/timegrid';
import FullCalendar from '@fullcalendar/vue3';
const toast = useToast(); const toast = useToast();
const tenantStore = useTenantStore(); const tenantStore = useTenantStore();
@@ -1385,7 +1385,7 @@ const jornadaEndDate = computed({
<div class="anim-child [--delay:120ms] xl:w-[42%] xl:top-4 xl:self-start"> <div class="anim-child [--delay:120ms] xl:w-[42%] xl:top-4 xl:self-start">
<div class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm agenda-altura"> <div class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm agenda-altura">
<!-- Header do preview --> <!-- Header do preview -->
<div class="sticky top-0 z-10 bg-white"> <div class="sticky top-0 z-10">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]"> <div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
<div class="font-semibold text-sm">Preview da agenda</div> <div class="font-semibold text-sm">Preview da agenda</div>
<div class="flex gap-1"> <div class="flex gap-1">

View File

@@ -19,7 +19,7 @@ import { ref, computed, onMounted } 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 Editor from 'primevue/editor'; import JoditEmailEditor from '@/components/ui/JoditEmailEditor.vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { renderEmail, renderTemplate, generateLayoutSection } from '@/lib/email/emailTemplateService'; import { renderEmail, renderTemplate, generateLayoutSection } from '@/lib/email/emailTemplateService';
import { MOCK_DATA, TEMPLATE_DOMAINS } from '@/lib/email/emailTemplateConstants'; import { MOCK_DATA, TEMPLATE_DOMAINS } from '@/lib/email/emailTemplateConstants';
@@ -135,19 +135,6 @@ const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success'
// ── Dialog layout (header/footer global) ────────────────────── // ── Dialog layout (header/footer global) ──────────────────────
const layoutDlg = ref({ open: false, saving: false }); const layoutDlg = ref({ open: false, saving: false });
const layoutForm = ref({ header: defaultSection(), footer: defaultSection() }); const layoutForm = ref({ header: defaultSection(), footer: defaultSection() });
const headerEditorRef = ref(null);
const footerEditorRef = ref(null);
const LAYOUT_OPTIONS = [
{ value: 'logo-left', label: 'Logo à esquerda' },
{ value: 'logo-right', label: 'Logo à direita' },
{ value: 'logo-center', label: 'Logo centralizada' }
];
const TEXT_OPTIONS = [
{ value: 'text-left', label: 'Texto à esquerda' },
{ value: 'text-center', label: 'Texto centralizado' },
{ value: 'text-right', label: 'Texto à direita' }
];
function openLayoutDlg() { function openLayoutDlg() {
layoutForm.value = { layoutForm.value = {
@@ -157,10 +144,6 @@ function openLayoutDlg() {
layoutDlg.value = { open: true, saving: false }; layoutDlg.value = { open: true, saving: false };
} }
function selectLayout(which, type) {
layoutForm.value[which].layout = type;
}
const headerLayoutPreview = computed(() => generateLayoutSection(layoutForm.value.header, profileLogoUrl.value, true)); const headerLayoutPreview = computed(() => generateLayoutSection(layoutForm.value.header, profileLogoUrl.value, true));
const footerLayoutPreview = computed(() => generateLayoutSection(layoutForm.value.footer, profileLogoUrl.value, false)); const footerLayoutPreview = computed(() => generateLayoutSection(layoutForm.value.footer, profileLogoUrl.value, false));
@@ -206,7 +189,6 @@ function openEdit(row) {
subject: ov?.subject ?? row.subject, subject: ov?.subject ?? row.subject,
body_html: ov?.body_html ?? row.body_html, body_html: ov?.body_html ?? row.body_html,
body_text: ov?.body_text ?? '', body_text: ov?.body_text ?? '',
enabled: ov?.enabled ?? true,
synced_version: row.version, synced_version: row.version,
variables: row.variables || {} variables: row.variables || {}
}; };
@@ -225,15 +207,11 @@ const formVariables = computed(() => {
function insertVar(varName) { function insertVar(varName) {
const snippet = `{{${varName}}}`; const snippet = `{{${varName}}}`;
const quill = editorRef.value?.quill; if (editorRef.value?.insertHTML) {
if (!quill) { editorRef.value.insertHTML(snippet);
} else {
form.value.body_html = (form.value.body_html || '') + snippet; form.value.body_html = (form.value.body_html || '') + snippet;
return;
} }
const range = quill.getSelection(true);
const index = range ? range.index : quill.getLength() - 1;
quill.insertText(index, snippet, 'user');
quill.setSelection(index + snippet.length, 0);
} }
async function save() { async function save() {
@@ -251,7 +229,7 @@ async function save() {
subject: form.value.use_custom_subject ? form.value.subject : null, subject: form.value.use_custom_subject ? form.value.subject : null,
body_html: form.value.use_custom_body ? form.value.body_html : null, body_html: form.value.use_custom_body ? form.value.body_html : null,
body_text: form.value.use_custom_body && form.value.body_text ? form.value.body_text : null, body_text: form.value.use_custom_body && form.value.body_text ? form.value.body_text : null,
enabled: form.value.enabled, enabled: true,
synced_version: form.value.synced_version synced_version: form.value.synced_version
}; };
if (dlg.value.mode === 'create') { if (dlg.value.mode === 'create') {
@@ -323,9 +301,19 @@ onMounted(async () => {
<template> <template>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<!-- Filtro --> <!-- Filtro + Layout global -->
<div class="flex gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<Button v-for="opt in DOMAIN_OPTIONS" :key="String(opt.value)" :label="opt.label" size="small" :severity="filterDomain === opt.value ? 'primary' : 'secondary'" :outlined="filterDomain !== opt.value" @click="filterDomain = opt.value" /> <Button v-for="opt in DOMAIN_OPTIONS" :key="String(opt.value)" :label="opt.label" size="small" :severity="filterDomain === opt.value ? 'primary' : 'secondary'" :outlined="filterDomain !== opt.value" @click="filterDomain = opt.value" />
<div class="ml-auto">
<Button
label="Layout global"
icon="pi pi-palette"
size="small"
:severity="layoutActive ? 'success' : 'secondary'"
:outlined="!layoutActive"
@click="openLayoutDlg"
/>
</div>
</div> </div>
<!-- SKELETON --> <!-- SKELETON -->
@@ -394,88 +382,21 @@ onMounted(async () => {
</div> </div>
<div v-if="layoutForm.header.enabled" class="px-4 py-4 flex flex-col gap-4"> <div v-if="layoutForm.header.enabled" class="px-4 py-4 flex flex-col gap-4">
<!-- Cards de layout --> <!-- Editor Jodit use os botões " Logo Esq./Dir./Centro" na toolbar para inserir layouts prontos -->
<div class="flex flex-col gap-2"> <JoditEmailEditor
<p class="text-xs font-semibold">Com logo</p> v-model="layoutForm.header.content"
<div class="flex gap-2"> :min-height="160"
<button v-for="opt in LAYOUT_OPTIONS" :key="opt.value" class="layout-card" :class="{ 'layout-card--active': layoutForm.header.layout === opt.value }" @click="selectLayout('header', opt.value)"> :layout-buttons="true"
<div class="layout-card__thumb"> :logo-url="profileLogoUrl"
<template v-if="opt.value === 'logo-left'"> />
<div class="lc-logo" />
<div class="lc-spacer" />
<div class="lc-lines">
<div class="lc-line" />
<div class="lc-line short" />
</div>
</template>
<template v-else-if="opt.value === 'logo-right'">
<div class="lc-lines">
<div class="lc-line" />
<div class="lc-line short" />
</div>
<div class="lc-spacer" />
<div class="lc-logo" />
</template>
<template v-else>
<div class="lc-center">
<div class="lc-logo" />
<div class="lc-line" style="width: 70%; margin-top: 5px" />
</div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
<p class="text-xs font-semibold mt-1"> texto</p>
<div class="flex gap-2">
<button v-for="opt in TEXT_OPTIONS" :key="opt.value" class="layout-card" :class="{ 'layout-card--active': layoutForm.header.layout === opt.value }" @click="selectLayout('header', opt.value)">
<div class="layout-card__thumb">
<template v-if="opt.value === 'text-left'">
<div class="lc-lines">
<div class="lc-line" />
<div class="lc-line short" />
</div>
</template>
<template v-else-if="opt.value === 'text-center'">
<div class="lc-lines lc-lines--center">
<div class="lc-line" style="width: 85%" />
<div class="lc-line short" style="width: 55%; align-self: center" />
</div>
</template>
<template v-else>
<div class="lc-lines lc-lines--right">
<div class="lc-line" />
<div class="lc-line short" style="align-self: flex-end" />
</div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
</div>
<!-- Editor de texto -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Texto</label>
<Editor ref="headerEditorRef" v-model="layoutForm.header.content" editor-style="min-height:100px;font-size:0.85rem;">
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" type="button" />
<button class="ql-italic" type="button" />
<button class="ql-underline" type="button" />
</span>
<span class="ql-formats">
<select class="ql-color" />
</span>
</template>
</Editor>
</div>
<!-- Preview --> <!-- Preview -->
<div v-if="headerLayoutPreview" class="rounded border border-[var(--surface-border)] bg-white p-3 text-sm text-gray-800"> <div v-if="headerLayoutPreview" class="rounded-lg border border-(--surface-border) bg-(--surface-ground) p-3">
<span class="text-[0.65rem] text-gray-400 uppercase tracking-widest block mb-2">Preview</span> <span class="text-[0.65rem] text-(--text-color-secondary) uppercase tracking-widest block mb-2">Preview</span>
<!-- eslint-disable-next-line vue/no-v-html --> <div class="rounded border border-(--surface-border) bg-white p-3 text-sm text-gray-800 shadow-sm">
<div v-html="headerLayoutPreview" /> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="headerLayoutPreview" />
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -494,88 +415,21 @@ onMounted(async () => {
</div> </div>
<div v-if="layoutForm.footer.enabled" class="px-4 py-4 flex flex-col gap-4"> <div v-if="layoutForm.footer.enabled" class="px-4 py-4 flex flex-col gap-4">
<!-- Cards de layout --> <!-- Editor Jodit use os botões " Logo Esq./Dir./Centro" na toolbar para inserir layouts prontos -->
<div class="flex flex-col gap-2"> <JoditEmailEditor
<p class="text-xs font-semibold">Com logo</p> v-model="layoutForm.footer.content"
<div class="flex gap-2"> :min-height="160"
<button v-for="opt in LAYOUT_OPTIONS" :key="opt.value" class="layout-card" :class="{ 'layout-card--active': layoutForm.footer.layout === opt.value }" @click="selectLayout('footer', opt.value)"> :layout-buttons="true"
<div class="layout-card__thumb"> :logo-url="profileLogoUrl"
<template v-if="opt.value === 'logo-left'"> />
<div class="lc-logo" />
<div class="lc-spacer" />
<div class="lc-lines">
<div class="lc-line" />
<div class="lc-line short" />
</div>
</template>
<template v-else-if="opt.value === 'logo-right'">
<div class="lc-lines">
<div class="lc-line" />
<div class="lc-line short" />
</div>
<div class="lc-spacer" />
<div class="lc-logo" />
</template>
<template v-else>
<div class="lc-center">
<div class="lc-logo" />
<div class="lc-line" style="width: 70%; margin-top: 5px" />
</div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
<p class="text-xs font-semibold mt-1"> texto</p>
<div class="flex gap-2">
<button v-for="opt in TEXT_OPTIONS" :key="opt.value" class="layout-card" :class="{ 'layout-card--active': layoutForm.footer.layout === opt.value }" @click="selectLayout('footer', opt.value)">
<div class="layout-card__thumb">
<template v-if="opt.value === 'text-left'">
<div class="lc-lines">
<div class="lc-line" />
<div class="lc-line short" />
</div>
</template>
<template v-else-if="opt.value === 'text-center'">
<div class="lc-lines lc-lines--center">
<div class="lc-line" style="width: 85%" />
<div class="lc-line short" style="width: 55%; align-self: center" />
</div>
</template>
<template v-else>
<div class="lc-lines lc-lines--right">
<div class="lc-line" />
<div class="lc-line short" style="align-self: flex-end" />
</div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
</div>
<!-- Editor de texto -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Texto</label>
<Editor ref="footerEditorRef" v-model="layoutForm.footer.content" editor-style="min-height:100px;font-size:0.85rem;">
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" type="button" />
<button class="ql-italic" type="button" />
<button class="ql-underline" type="button" />
</span>
<span class="ql-formats">
<select class="ql-color" />
</span>
</template>
</Editor>
</div>
<!-- Preview --> <!-- Preview -->
<div v-if="footerLayoutPreview" class="rounded border border-[var(--surface-border)] bg-white p-3 text-sm text-gray-800"> <div v-if="footerLayoutPreview" class="rounded-lg border border-(--surface-border) bg-(--surface-ground) p-3">
<span class="text-[0.65rem] text-gray-400 uppercase tracking-widest block mb-2">Preview</span> <span class="text-[0.65rem] text-(--text-color-secondary) uppercase tracking-widest block mb-2">Preview</span>
<!-- eslint-disable-next-line vue/no-v-html --> <div class="rounded border border-(--surface-border) bg-white p-3 text-sm text-gray-800 shadow-sm">
<div v-html="footerLayoutPreview" /> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="footerLayoutPreview" />
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -616,35 +470,7 @@ onMounted(async () => {
</div> </div>
<div class="px-4 py-3 flex flex-col gap-3"> <div class="px-4 py-3 flex flex-col gap-3">
<template v-if="form.use_custom_body"> <template v-if="form.use_custom_body">
<Editor ref="editorRef" v-model="form.body_html" editor-style="min-height:200px;font-size:0.85rem;"> <JoditEmailEditor ref="editorRef" v-model="form.body_html" :min-height="220" />
<template #toolbar>
<span class="ql-formats">
<select class="ql-header">
<option value="1">Título</option>
<option value="2">Subtítulo</option>
<option selected>Normal</option>
</select>
</span>
<span class="ql-formats">
<button class="ql-bold" type="button" />
<button class="ql-italic" type="button" />
<button class="ql-underline" type="button" />
</span>
<span class="ql-formats">
<select class="ql-align" />
<select class="ql-color" />
<select class="ql-background" />
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" type="button" />
<button class="ql-list" value="bullet" type="button" />
</span>
<span class="ql-formats">
<button class="ql-link" type="button" />
<button class="ql-clean" type="button" />
</span>
</template>
</Editor>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span> <span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
@@ -660,11 +486,6 @@ onMounted(async () => {
</div> </div>
</div> </div>
<!-- Override ativo -->
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.enabled" inputId="sw-enabled" />
<label for="sw-enabled" class="text-sm cursor-pointer select-none">Override ativo</label>
</div>
</div> </div>
<template #footer> <template #footer>
@@ -699,104 +520,4 @@ onMounted(async () => {
</template> </template>
<style scoped> <style scoped>
/* ── Layout cards ───────────────────────────────────────── */
.layout-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 8px;
border: 1.5px solid var(--surface-border);
border-radius: 8px;
background: var(--surface-card);
cursor: pointer;
transition:
border-color 0.15s,
box-shadow 0.15s,
background 0.15s;
}
.layout-card:hover {
border-color: color-mix(in srgb, var(--primary-color, #6366f1) 50%, transparent);
background: color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card));
}
.layout-card--active {
border-color: var(--primary-color, #6366f1);
background: color-mix(in srgb, var(--primary-color, #6366f1) 8%, var(--surface-card));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
}
.layout-card__thumb {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
height: 38px;
border: 1px solid #e5e7eb;
border-radius: 5px;
padding: 6px;
background: #f9fafb;
}
.layout-card__label {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-color-secondary);
text-align: center;
line-height: 1.3;
}
.layout-card--active .layout-card__label {
color: var(--primary-color, #6366f1);
}
/* Elementos internos dos cards */
.lc-logo {
width: 22px;
height: 22px;
border-radius: 3px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 35%, #e5e7eb);
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
}
.lc-spacer {
flex: 1;
min-width: 4px;
}
.lc-lines {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.lc-lines--center {
align-items: center;
}
.lc-lines--right {
align-items: flex-end;
}
.lc-line {
height: 3px;
background: #d1d5db;
border-radius: 2px;
}
.lc-line.short {
width: 60%;
}
.lc-center {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
gap: 2px;
}
.lc-center .lc-logo {
width: 18px;
height: 18px;
}
.lc-center .lc-line {
width: 100%;
}
/* Esconde botão de imagem do Quill em todos os editores desta página */
:deep(.ql-image) {
display: none !important;
}
</style> </style>

View File

@@ -378,6 +378,42 @@ function confirmRestoreTemplate(tpl) {
}); });
} }
// ══════════════════════════════════════════════════════════════
// ABA 2 — Emojis rápidos para o guia de formatação
// ══════════════════════════════════════════════════════════════
const QUICK_EMOJIS = [
{ char: '📅', label: 'Calendário' },
{ char: '⏰', label: 'Relógio / Lembrete' },
{ char: '✅', label: 'Confirmado' },
{ char: '❌', label: 'Cancelado' },
{ char: '🔔', label: 'Notificação' },
{ char: '💬', label: 'Mensagem' },
{ char: '💙', label: 'Cuidado / Saúde' },
{ char: '🌿', label: 'Bem-estar' },
{ char: '🤝', label: 'Parceria / Encontro' },
{ char: '📋', label: 'Formulário / Triagem' },
{ char: '💰', label: 'Financeiro' },
{ char: '🔗', label: 'Link' },
{ char: '📍', label: 'Local' },
{ char: '📞', label: 'Telefone' },
{ char: '🏥', label: 'Clínica' },
{ char: '🧠', label: 'Psicologia' },
{ char: '👤', label: 'Paciente' },
{ char: '🌟', label: 'Destaque' },
{ char: '⚠️', label: 'Atenção' },
{ char: '➡️', label: 'Seta / Próximo passo' },
{ char: '🗓️', label: 'Agenda' },
{ char: '🕐', label: 'Hora' },
{ char: '📩', label: 'Recebido' },
{ char: '🔄', label: 'Reagendamento' }
];
function copyEmoji(char) {
navigator.clipboard?.writeText(char).catch(() => {});
toast.add({ severity: 'info', summary: `${char} copiado!`, life: 1500 });
}
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
// ABA 3 — Logs de envio // ABA 3 — Logs de envio
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
@@ -533,47 +569,152 @@ onBeforeUnmount(() => {
<!-- ABA 2 Templates --> <!-- ABA 2 Templates -->
<TabPanel :value="1"> <TabPanel :value="1">
<div class="flex flex-col gap-3 pt-3"> <div class="flex gap-4 pt-3 items-start">
<!-- Skeleton loading -->
<template v-if="templatesLoading"> <!-- Coluna esquerda: cards de templates (65%) -->
<div v-for="n in 4" :key="n" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4"> <div class="flex flex-col gap-3 min-w-0" style="flex: 0 0 65%;">
<div class="flex items-center gap-2 mb-3"> <!-- Skeleton loading -->
<Skeleton width="5rem" height="1.4rem" border-radius="999px" /> <template v-if="templatesLoading">
<Skeleton width="10rem" height="1rem" /> <div v-for="n in 4" :key="n" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4">
<div class="flex items-center gap-2 mb-3">
<Skeleton width="5rem" height="1.4rem" border-radius="999px" />
<Skeleton width="10rem" height="1rem" />
</div>
<Skeleton width="100%" height="5rem" class="mb-2" />
<div class="flex gap-1">
<Skeleton v-for="i in 3" :key="i" width="6rem" height="1.6rem" border-radius="999px" />
</div>
</div> </div>
<Skeleton width="100%" height="5rem" class="mb-2" /> </template>
<div class="flex gap-1">
<Skeleton v-for="i in 3" :key="i" width="6rem" height="1.6rem" border-radius="999px" /> <!-- Cards de templates -->
<div v-else v-for="tpl in templates" :key="tpl.key" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex flex-col gap-3">
<!-- Header do card -->
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">{{ tpl.label }}</span>
<Tag :value="tpl.type" :severity="tpl.type_severity" class="text-[0.65rem]" />
<Tag v-if="tpl.is_custom" value="Personalizado" severity="success" class="text-[0.65rem]" />
</div> </div>
</div>
</template>
<!-- Cards de templates --> <!-- Textarea editável -->
<div v-else v-for="tpl in templates" :key="tpl.key" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex flex-col gap-3"> <Textarea :ref="(el) => setTextareaRef(tpl.key, el)" v-model="tpl.body_text" rows="5" auto-resize class="w-full text-sm font-mono" />
<!-- Header do card -->
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">{{ tpl.label }}</span>
<Tag :value="tpl.type" :severity="tpl.type_severity" class="text-[0.65rem]" />
<Tag v-if="tpl.is_custom" value="Personalizado" severity="success" class="text-[0.65rem]" />
</div>
<!-- Textarea editável --> <!-- Variáveis clicáveis -->
<Textarea :ref="(el) => setTextareaRef(tpl.key, el)" v-model="tpl.body_text" rows="5" auto-resize class="w-full text-sm font-mono" /> <div class="flex flex-col gap-1.5">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<!-- Variáveis clicáveis --> <div class="flex flex-wrap gap-1.5">
<div class="flex flex-col gap-1.5"> <Button v-for="v in tpl.variables" :key="v" :label="`{{${v}}}`" size="small" severity="secondary" outlined class="font-mono !text-[0.68rem] !py-1 !px-2" @click="insertVariable(tpl.key, v)" />
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span> </div>
<div class="flex flex-wrap gap-1.5">
<Button v-for="v in tpl.variables" :key="v" :label="`{{${v}}}`" size="small" severity="secondary" outlined class="font-mono !text-[0.68rem] !py-1 !px-2" @click="insertVariable(tpl.key, v)" />
</div> </div>
</div>
<!-- Ações --> <!-- Ações -->
<div class="flex items-center gap-2 justify-end"> <div class="flex items-center gap-2 justify-end">
<Button v-if="isTemplateModified(tpl)" label="Restaurar original" icon="pi pi-undo" size="small" severity="secondary" outlined @click="confirmRestoreTemplate(tpl)" /> <Button v-if="isTemplateModified(tpl)" label="Restaurar original" icon="pi pi-undo" size="small" severity="secondary" outlined @click="confirmRestoreTemplate(tpl)" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="templateSaving[tpl.key]" :disabled="templateSaving[tpl.key]" @click="saveTemplate(tpl)" /> <Button label="Salvar" icon="pi pi-check" size="small" :loading="templateSaving[tpl.key]" :disabled="templateSaving[tpl.key]" @click="saveTemplate(tpl)" />
</div>
</div> </div>
</div> </div>
<!-- Coluna direita: guia de formatação (35%) -->
<div class="flex flex-col gap-3 sticky top-4" style="flex: 0 0 35%;">
<div class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex flex-col gap-4">
<div class="flex items-center gap-2">
<i class="pi pi-book text-[var(--primary-color)]" />
<span class="font-semibold text-sm">Guia de formatação</span>
</div>
<!-- Formatação oficial -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-[0.7rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Formatação oficial</span>
<div class="flex-1 h-px bg-[var(--surface-border)]" />
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">*texto*</span>
<span class="text-xs font-bold">Negrito</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">_texto_</span>
<span class="text-xs italic">Itálico</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">~texto~</span>
<span class="text-xs line-through">Tachado</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">`texto`</span>
<span class="text-xs font-mono bg-[var(--surface-ground)] px-1 rounded">Monoespaçado</span>
</div>
</div>
</div>
<!-- Efeitos extras Unicode -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-[0.7rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Efeitos extras (Unicode)</span>
<div class="flex-1 h-px bg-[var(--surface-border)]" />
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-[var(--text-color-secondary)]">Negrito Unicode</span>
<span class="text-xs">𝙝𝙤𝙡𝙖</span>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Copie de sites de "font generator"</span>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-[var(--text-color-secondary)]">Cursiva Unicode</span>
<span class="text-xs">𝓽𝓮𝔁𝓽𝓸</span>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Cada letra é um caractere diferente</span>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-[var(--text-color-secondary)]">Small Caps</span>
<span class="text-xs">ᴛᴇxᴛ</span>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Bom para títulos curtos</span>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-[var(--text-color-secondary)]">Sublinhado</span>
<span class="text-xs">t̲e̲x̲t̲o̲</span>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">U+0332 após cada letra</span>
</div>
</div>
</div>
<!-- Emojis mais usados -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-[0.7rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Emojis mais usados</span>
<div class="flex-1 h-px bg-[var(--surface-border)]" />
</div>
<div class="flex flex-wrap gap-1">
<button
v-for="emoji in QUICK_EMOJIS"
:key="emoji.char"
v-tooltip.top="emoji.label"
class="text-base leading-none p-1 rounded hover:bg-[var(--surface-hover)] transition-colors cursor-pointer border-0 bg-transparent"
@click="copyEmoji(emoji.char)"
>{{ emoji.char }}</button>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Clique para copiar</span>
</div>
<!-- Dica -->
<div class="flex items-start gap-2 px-3 py-2.5 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<i class="pi pi-lightbulb text-amber-500 text-xs mt-0.5 shrink-0" />
<p class="text-[0.68rem] text-[var(--text-color-secondary)] m-0 leading-relaxed">
Use <strong>*negrito*</strong> para destacar horários e datas. Evite excesso de formatação mensagens simples têm maior taxa de leitura.
</p>
</div>
</div>
</div>
</div> </div>
</TabPanel> </TabPanel>

View File

@@ -1,9 +1,18 @@
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Agência PSI (OTIMIZADO) | Agência PSI — main.js
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
(function applyDarkModeImmediate() {
try {
const saved = localStorage.getItem('ui_theme_mode');
if (saved === 'dark' || saved === 'light') {
document.documentElement.classList.toggle('app-dark', saved === 'dark');
}
} catch { }
})();
import { pinia } from '@/plugins/pinia'; import { pinia } from '@/plugins/pinia';
import router from '@/router'; import router from '@/router';
import { createApp } from 'vue'; import { createApp } from 'vue';
@@ -11,32 +20,25 @@ import App from './App.vue';
import { initSession, listenAuthChanges, refreshSession, setOnSignedOut } from '@/app/session'; import { initSession, listenAuthChanges, refreshSession, setOnSignedOut } from '@/app/session';
// PrimeVue core
import Aura from '@primeuix/themes/aura';
import PrimeVue from 'primevue/config'; import PrimeVue from 'primevue/config';
import { applyThemeEngine } from '@/theme/theme.options';
import { useLayout } from '@/layout/composables/layout';
// serviços (ok global)
import ConfirmationService from 'primevue/confirmationservice'; import ConfirmationService from 'primevue/confirmationservice';
import ToastService from 'primevue/toastservice'; import ToastService from 'primevue/toastservice';
// ✅ SOMENTE COMPONENTES LEVES GLOBAIS
import Button from 'primevue/button'; import Button from 'primevue/button';
import Divider from 'primevue/divider';
import InputText from 'primevue/inputtext'; import InputText from 'primevue/inputtext';
import Tag from 'primevue/tag'; import Tag from 'primevue/tag';
import Toast from 'primevue/toast';
// seus componentes leves
import AppLoadingPhrases from '@/components/ui/AppLoadingPhrases.vue'; import AppLoadingPhrases from '@/components/ui/AppLoadingPhrases.vue';
import LoadedPhraseBlock from '@/components/ui/LoadedPhraseBlock.vue'; import LoadedPhraseBlock from '@/components/ui/LoadedPhraseBlock.vue';
// estilos
import '@/assets/styles.scss'; import '@/assets/styles.scss';
import '@/assets/tailwind.css'; import '@/assets/tailwind.css';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
// locale
const ptBR = { const ptBR = {
firstDayOfWeek: 1, firstDayOfWeek: 1,
dayNames: ['domingo', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado'], dayNames: ['domingo', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado'],
@@ -50,29 +52,49 @@ const ptBR = {
dateFormat: 'dd/mm/yy' dateFormat: 'dd/mm/yy'
}; };
// theme antecipado function syncThemeFromDB() {
async function applyUserThemeEarly() { const run = async () => {
try { try {
const { data } = await supabase.auth.getUser(); const { data } = await supabase.auth.getUser();
const user = data?.user; if (!data?.user) return;
if (!user) return;
const { data: settings } = await supabase.from('user_settings').select('theme_mode').eq('user_id', user.id).maybeSingle(); const { data: settings } = await supabase
.from('user_settings')
.select('theme_mode, preset, primary_color, surface_color, menu_mode')
.eq('user_id', data.user.id)
.maybeSingle();
if (!settings?.theme_mode) return; if (!settings) return;
const isDark = settings.theme_mode === 'dark'; if (settings.theme_mode) {
document.documentElement.classList.toggle('app-dark', isDark); document.documentElement.classList.toggle('app-dark', settings.theme_mode === 'dark');
localStorage.setItem('ui_theme_mode', settings.theme_mode); localStorage.setItem('ui_theme_mode', settings.theme_mode);
} catch {} }
const cfg = {};
if (settings.preset) cfg.preset = settings.preset;
if (settings.primary_color) cfg.primary = settings.primary_color;
if (settings.surface_color) cfg.surface = settings.surface_color;
if (settings.menu_mode) cfg.menuMode = settings.menu_mode;
if (Object.keys(cfg).length) {
try {
const prev = JSON.parse(localStorage.getItem('ui_theme_config') || '{}');
localStorage.setItem('ui_theme_config', JSON.stringify({ ...prev, ...cfg }));
} catch { }
}
} catch { }
};
if ('requestIdleCallback' in window) {
requestIdleCallback(run, { timeout: 4000 });
} else {
setTimeout(run, 300);
}
} }
// logout setOnSignedOut(() => router.replace('/auth/login'));
setOnSignedOut(() => {
router.replace('/auth/login');
});
// flags
window.__sessionRefreshing = false; window.__sessionRefreshing = false;
window.__fromVisibilityRefresh = false; window.__fromVisibilityRefresh = false;
window.__appBootstrapped = false; window.__appBootstrapped = false;
@@ -84,13 +106,15 @@ document.addEventListener('visibilitychange', async () => {
if (!window.__appBootstrapped) return; if (!window.__appBootstrapped) return;
const now = Date.now(); const now = Date.now();
if (now - lastVisibilityRefreshAt < 10000) return; if (now - lastVisibilityRefreshAt < 10_000) return;
if (window.__sessionRefreshing) return; if (window.__sessionRefreshing) return;
try { try {
const { data } = await supabase.auth.getUser(); const { data } = await supabase.auth.getUser();
if (!data?.user) return; if (!data?.user) return;
} catch {} } catch {
return;
}
lastVisibilityRefreshAt = now; lastVisibilityRefreshAt = now;
@@ -100,15 +124,14 @@ document.addEventListener('visibilitychange', async () => {
await refreshSession(); await refreshSession();
const path = router.currentRoute.value?.path || ''; const path = router.currentRoute.value?.path ?? '';
const isTenantArea = path.startsWith('/admin') || path.startsWith('/therapist') || path.startsWith('/saas'); const isTenantArea =
path.startsWith('/admin') ||
path.startsWith('/therapist') ||
path.startsWith('/saas');
if (isTenantArea) { if (isTenantArea) {
window.dispatchEvent( window.dispatchEvent(new CustomEvent('app:session-refreshed', { detail: { source: 'visibility' } }));
new CustomEvent('app:session-refreshed', {
detail: { source: 'visibility' }
})
);
} }
} finally { } finally {
window.__fromVisibilityRefresh = false; window.__fromVisibilityRefresh = false;
@@ -118,39 +141,35 @@ document.addEventListener('visibilitychange', async () => {
async function bootstrap() { async function bootstrap() {
await initSession({ initial: true }); await initSession({ initial: true });
listenAuthChanges();
await applyUserThemeEarly();
const app = createApp(App); const app = createApp(App);
app.use(pinia); app.use(pinia);
app.use(router); app.use(router);
await router.isReady(); await router.isReady();
listenAuthChanges();
syncThemeFromDB();
const { layoutConfig } = useLayout();
app.use(PrimeVue, { app.use(PrimeVue, {
locale: ptBR, locale: ptBR,
theme: { theme: { options: { darkModeSelector: '.app-dark' } }
preset: Aura,
options: { darkModeSelector: '.app-dark' }
}
}); });
applyThemeEngine(layoutConfig);
app.use(ToastService); app.use(ToastService);
app.use(ConfirmationService); app.use(ConfirmationService);
// ✅ globais leves
app.component('Button', Button); app.component('Button', Button);
app.component('InputText', InputText); app.component('InputText', InputText);
app.component('Tag', Tag); app.component('Tag', Tag);
app.component('Divider', Divider);
app.component('Toast', Toast);
app.component('AppLoadingPhrases', AppLoadingPhrases); app.component('AppLoadingPhrases', AppLoadingPhrases);
app.component('LoadedPhraseBlock', LoadedPhraseBlock); app.component('LoadedPhraseBlock', LoadedPhraseBlock);
app.mount('#app'); app.mount('#app');
window.__appBootstrapped = true; window.__appBootstrapped = true;
} }

View File

@@ -63,8 +63,10 @@ export default function adminMenu(ctx = {}) {
{ label: 'Lista de Pacientes', icon: 'pi pi-fw pi-users', to: { name: 'admin-pacientes' }, quickCreate: true, quickCreateRoute: 'admin-pacientes-cadastro', quickCreateLinkTo: { name: 'admin-pacientes-link-externo' } }, { label: 'Lista de Pacientes', icon: 'pi pi-fw pi-users', to: { name: 'admin-pacientes' }, quickCreate: true, quickCreateRoute: 'admin-pacientes-cadastro', quickCreateLinkTo: { name: 'admin-pacientes-link-externo' } },
{ label: 'Grupos', icon: 'pi pi-fw pi-sitemap', to: { name: 'admin-pacientes-grupos' } }, { 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: 'Tags', icon: 'pi pi-fw pi-tags', to: { name: 'admin-pacientes-tags' } },
{ label: 'Médicos & Referências', icon: 'pi pi-fw pi-heart', to: { name: 'admin-pacientes-medicos' } },
{ label: 'Link Externo', icon: 'pi pi-fw pi-link', to: { name: 'admin-pacientes-link-externo' } }, { 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' }, badgeKey: 'cadastrosRecebidos' } { label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' }, badgeKey: 'cadastrosRecebidos' },
{ label: 'Templates de Documentos', icon: 'pi pi-fw pi-file-edit', to: { name: 'admin-documents-templates' }, feature: 'documents.templates', proBadge: true }
] ]
}, },
@@ -94,6 +96,8 @@ export default function adminMenu(ctx = {}) {
{ {
label: 'Sistema', label: 'Sistema',
items: [ items: [
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' },
{ label: 'Meu Negócio', icon: 'pi pi-fw pi-building', to: '/account/negocio' },
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: { name: 'admin-settings-security' } }, { label: 'Segurança', icon: 'pi pi-fw pi-shield', to: { name: 'admin-settings-security' } },
{ {
label: 'Agendamento Online (PRO)', label: 'Agendamento Online (PRO)',

View File

@@ -88,7 +88,8 @@ export default function saasMenu(sessionCtx, opts = {}) {
{ label: 'FAQ', icon: 'pi pi-fw pi-comments', to: '/saas/faq' }, { label: 'FAQ', icon: 'pi pi-fw pi-comments', to: '/saas/faq' },
{ label: 'Carrossel Login', icon: 'pi pi-fw pi-images', to: '/saas/login-carousel' }, { label: 'Carrossel Login', icon: 'pi pi-fw pi-images', to: '/saas/login-carousel' },
{ label: 'Avisos Globais', icon: 'pi pi-fw pi-megaphone', to: '/saas/global-notices' }, { label: 'Avisos Globais', icon: 'pi pi-fw pi-megaphone', to: '/saas/global-notices' },
{ label: 'Templates de E-mail', icon: 'pi pi-fw pi-envelope', to: '/saas/email-templates' } { label: 'Templates de E-mail', icon: 'pi pi-fw pi-envelope', to: '/saas/email-templates' },
{ label: 'Templates de Documentos', icon: 'pi pi-fw pi-file-edit', to: '/saas/document-templates' }
] ]
} }
]; ];

View File

@@ -36,6 +36,9 @@ export default [
{ label: 'Meus pacientes', icon: 'pi pi-list', to: '/therapist/patients', quickCreate: true, quickCreateRoute: 'therapist-patients-cadastro', quickCreateLinkTo: '/therapist/patients/link-externo' }, { label: 'Meus pacientes', icon: 'pi pi-list', to: '/therapist/patients', quickCreate: true, quickCreateRoute: 'therapist-patients-cadastro', quickCreateLinkTo: '/therapist/patients/link-externo' },
{ label: 'Grupo de pacientes', icon: 'pi pi-fw pi-users', to: '/therapist/patients/grupos' }, { label: 'Grupo de pacientes', icon: 'pi pi-fw pi-users', to: '/therapist/patients/grupos' },
{ label: 'Tags', icon: 'pi pi-tags', to: '/therapist/patients/tags' }, { label: 'Tags', icon: 'pi pi-tags', to: '/therapist/patients/tags' },
{ label: 'Médicos & Referências', icon: 'pi pi-heart', to: '/therapist/patients/medicos' },
{ label: 'Documentos', icon: 'pi pi-file', to: '/therapist/documents', feature: 'documents.upload' },
{ label: 'Templates', icon: 'pi pi-file-edit', to: '/therapist/documents/templates', feature: 'documents.templates', proBadge: true },
{ label: 'Meu link de cadastro', icon: 'pi pi-link', to: '/therapist/patients/link-externo' }, { label: 'Meu link de cadastro', icon: 'pi pi-link', to: '/therapist/patients/link-externo' },
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: '/therapist/patients/cadastro/recebidos', badgeKey: 'cadastrosRecebidos' } { label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: '/therapist/patients/cadastro/recebidos', badgeKey: 'cadastrosRecebidos' }
] ]
@@ -80,6 +83,7 @@ export default [
items: [ items: [
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: '/therapist/meu-plano' }, { 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: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' },
{ label: 'Meu Negócio', icon: 'pi pi-fw pi-building', to: '/account/negocio' },
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' } { label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
] ]
} }

View File

@@ -60,7 +60,7 @@ function readPendingInviteToken() {
function clearPendingInviteToken() { function clearPendingInviteToken() {
try { try {
sessionStorage.removeItem(PENDING_INVITE_TOKEN_KEY); sessionStorage.removeItem(PENDING_INVITE_TOKEN_KEY);
} catch (_) {} } catch (_) { }
} }
function isUuid(v) { function isUuid(v) {
@@ -382,7 +382,7 @@ export function applyGuards(router) {
localStorage.removeItem('tenant_id'); localStorage.removeItem('tenant_id');
localStorage.removeItem('tenant'); localStorage.removeItem('tenant');
localStorage.removeItem('currentTenantId'); localStorage.removeItem('currentTenantId');
} catch (_) {} } catch (_) { }
_perfEnd(); _perfEnd();
return { path: '/portal' }; return { path: '/portal' };
@@ -438,11 +438,11 @@ export function applyGuards(router) {
if (['therapist', 'supervisor'].includes(_roleNorm) && _ent.loadedForUser !== uid) { if (['therapist', 'supervisor'].includes(_roleNorm) && _ent.loadedForUser !== uid) {
try { try {
await _ent.loadForUser(uid); await _ent.loadForUser(uid);
} catch {} } catch { }
} }
await ensureMenuBuilt({ uid, tenantId: _tid, tenantRole: _role, globalRole }); await ensureMenuBuilt({ uid, tenantId: _tid, tenantRole: _role, globalRole });
} }
} catch {} } catch { }
} }
_perfEnd(); _perfEnd();
return true; return true;
@@ -455,7 +455,7 @@ export function applyGuards(router) {
localStorage.removeItem('tenant_id'); localStorage.removeItem('tenant_id');
localStorage.removeItem('tenant'); localStorage.removeItem('tenant');
localStorage.removeItem('currentTenantId'); localStorage.removeItem('currentTenantId');
} catch (_) {} } catch (_) { }
} }
// ========================================== // ==========================================
@@ -489,7 +489,7 @@ export function applyGuards(router) {
try { try {
const menuStore = useMenuStore(); const menuStore = useMenuStore();
if (typeof menuStore.reset === 'function') menuStore.reset(); if (typeof menuStore.reset === 'function') menuStore.reset();
} catch {} } catch { }
} }
// ================================ // ================================
@@ -548,7 +548,7 @@ export function applyGuards(router) {
// ================================ // ================================
// 🚫 SaaS master: bloqueia tenant-app por padrão // 🚫 SaaS master: bloqueia tenant-app por padrão
// ✅ Mas libera rotas de DEMO em DEV (Sakai) // ✅ Mas libera rotas de DEMO em DEV
// ================================ // ================================
logGuard('saas.lockdown?'); logGuard('saas.lockdown?');
@@ -558,7 +558,7 @@ export function applyGuards(router) {
if (isSaas) { if (isSaas) {
const isSaasArea = to.path === '/saas' || to.path.startsWith('/saas/'); const isSaasArea = to.path === '/saas' || to.path.startsWith('/saas/');
// Rotas do Sakai Demo (no seu caso ficam em /demo/*) // Rotas do Tema Demo (no seu caso ficam em /demo/*)
const isDemoArea = import.meta.env.DEV && (to.path === '/demo' || to.path.startsWith('/demo/')); const isDemoArea = import.meta.env.DEV && (to.path === '/demo' || to.path.startsWith('/demo/'));
// Se for demo em DEV, libera // Se for demo em DEV, libera
@@ -693,19 +693,19 @@ export function applyGuards(router) {
try { try {
const entX = useEntitlementsStore(); const entX = useEntitlementsStore();
if (typeof entX.invalidate === 'function') entX.invalidate(); if (typeof entX.invalidate === 'function') entX.invalidate();
} catch {} } catch { }
// ✅ NÃO invalidar o cache inteiro — invalida somente o tenant anterior // ✅ NÃO invalidar o cache inteiro — invalida somente o tenant anterior
try { try {
const tfX = useTenantFeaturesStore(); const tfX = useTenantFeaturesStore();
if (typeof tfX.invalidate === 'function' && oldTenantId) tfX.invalidate(oldTenantId); if (typeof tfX.invalidate === 'function' && oldTenantId) tfX.invalidate(oldTenantId);
} catch {} } catch { }
// ✅ troca tenant => menu precisa recompôr (contexto mudou) // ✅ troca tenant => menu precisa recompôr (contexto mudou)
try { try {
const menuStore = useMenuStore(); const menuStore = useMenuStore();
if (typeof menuStore.reset === 'function') menuStore.reset(); if (typeof menuStore.reset === 'function') menuStore.reset();
} catch {} } catch { }
} else if (!desiredTenantId) { } else if (!desiredTenantId) {
logGuard('[guards] tenantScope sem match', { logGuard('[guards] tenantScope sem match', {
scope, scope,
@@ -858,7 +858,7 @@ export function applyGuards(router) {
globalRoleCacheUid = null; globalRoleCacheUid = null;
globalRoleCache = null; globalRoleCache = null;
try { resetAjuda(); } catch (_) {} try { resetAjuda(); } catch (_) { }
// ✅ FIX: limpa o localStorage de tenant na saída // ✅ FIX: limpa o localStorage de tenant na saída
// Sem isso, o próximo login restaura o tenant do usuário anterior. // Sem isso, o próximo login restaura o tenant do usuário anterior.
@@ -866,27 +866,27 @@ export function applyGuards(router) {
localStorage.removeItem('tenant_id'); localStorage.removeItem('tenant_id');
localStorage.removeItem('tenant'); localStorage.removeItem('tenant');
localStorage.removeItem('currentTenantId'); localStorage.removeItem('currentTenantId');
} catch (_) {} } catch (_) { }
try { try {
const tf = useTenantFeaturesStore(); const tf = useTenantFeaturesStore();
if (typeof tf.invalidate === 'function') tf.invalidate(); if (typeof tf.invalidate === 'function') tf.invalidate();
} catch {} } catch { }
try { try {
const ent = useEntitlementsStore(); const ent = useEntitlementsStore();
if (typeof ent.invalidate === 'function') ent.invalidate(); if (typeof ent.invalidate === 'function') ent.invalidate();
} catch {} } catch { }
try { try {
const tenant = useTenantStore(); const tenant = useTenantStore();
if (typeof tenant.reset === 'function') tenant.reset(); if (typeof tenant.reset === 'function') tenant.reset();
} catch {} } catch { }
try { try {
const menuStore = useMenuStore(); const menuStore = useMenuStore();
if (typeof menuStore.reset === 'function') menuStore.reset(); if (typeof menuStore.reset === 'function') menuStore.reset();
} catch {} } catch { }
return; return;
} }
@@ -912,17 +912,17 @@ export function applyGuards(router) {
try { try {
const tf = useTenantFeaturesStore(); const tf = useTenantFeaturesStore();
if (typeof tf.invalidate === 'function') tf.invalidate(); if (typeof tf.invalidate === 'function') tf.invalidate();
} catch {} } catch { }
try { try {
const ent = useEntitlementsStore(); const ent = useEntitlementsStore();
if (typeof ent.invalidate === 'function') ent.invalidate(); if (typeof ent.invalidate === 'function') ent.invalidate();
} catch {} } catch { }
try { try {
const menuStore = useMenuStore(); const menuStore = useMenuStore();
if (typeof menuStore.reset === 'function') menuStore.reset(); if (typeof menuStore.reset === 'function') menuStore.reset();
} catch {} } catch { }
// tenantStore carrega de novo no fluxo do guard quando precisar // tenantStore carrega de novo no fluxo do guard quando precisar
return; return;

View File

@@ -16,20 +16,22 @@
*/ */
import RouterPassthrough from '@/layout/RouterPassthrough.vue'; import RouterPassthrough from '@/layout/RouterPassthrough.vue';
// Rotas compartilhadas — acessíveis por qualquer role autenticada
export default { export default {
path: 'account', path: 'account',
component: RouterPassthrough, component: RouterPassthrough,
meta: { requiresAuth: true, area: 'account' }, meta: { requiresAuth: true },
children: [ children: [
{
path: '',
redirect: { name: 'account-profile' }
},
{ {
path: 'profile', path: 'profile',
name: 'account-profile', name: 'account-profile',
component: () => import('@/views/pages/account/ProfilePage.vue') component: () => import('@/views/pages/account/ProfilePage.vue')
}, },
{
path: 'negocio',
name: 'account-negocio',
component: () => import('@/views/pages/account/NegocioPage.vue')
},
{ {
path: 'security', path: 'security',
name: 'account-security', name: 'account-security',

View File

@@ -126,6 +126,12 @@ export default {
component: () => import('@/features/patients/tags/TagsPage.vue'), component: () => import('@/features/patients/tags/TagsPage.vue'),
meta: { tenantFeature: 'patients' } meta: { tenantFeature: 'patients' }
}, },
{
path: 'pacientes/medicos',
name: 'admin-pacientes-medicos',
component: () => import('@/features/patients/medicos/MedicosPage.vue'),
meta: { tenantFeature: 'patients' }
},
{ {
path: 'pacientes/link-externo', path: 'pacientes/link-externo',
name: 'admin-pacientes-link-externo', name: 'admin-pacientes-link-externo',
@@ -139,6 +145,16 @@ export default {
meta: { tenantFeature: 'patients' } meta: { tenantFeature: 'patients' }
}, },
// ======================================================
// 📄 DOCUMENTOS
// ======================================================
{
path: 'documents/templates',
name: 'admin-documents-templates',
component: () => import('@/features/documents/DocumentTemplatesPage.vue'),
meta: { feature: 'documents.templates' }
},
// ====================================================== // ======================================================
// 🔐 SEGURANÇA // 🔐 SEGURANÇA
// ====================================================== // ======================================================

View File

@@ -51,6 +51,13 @@ export default {
name: 'agendador.publico', name: 'agendador.publico',
component: () => import('@/views/pages/public/AgendadorPublicoPage.vue'), component: () => import('@/views/pages/public/AgendadorPublicoPage.vue'),
meta: { public: true } meta: { public: true }
},
// ✅ documento compartilhado via link temporário
{
path: '/shared/document/:token',
name: 'shared.document',
component: () => import('@/views/pages/public/SharedDocumentPage.vue'),
meta: { public: true }
} }
] ]
}; };

View File

@@ -138,6 +138,12 @@ export default {
name: 'saas-addons', name: 'saas-addons',
component: () => import('@/views/pages/saas/SaasAddonsPage.vue'), component: () => import('@/views/pages/saas/SaasAddonsPage.vue'),
meta: { requiresAuth: true, saasAdmin: true } meta: { requiresAuth: true, saasAdmin: true }
},
{
path: 'document-templates',
name: 'saas-document-templates',
component: () => import('@/views/pages/saas/SaasDocumentTemplatesPage.vue'),
meta: { requiresAuth: true, saasAdmin: true }
} }
] ]
}; };

View File

@@ -110,6 +110,11 @@ export default {
name: 'therapist-patients-tags', name: 'therapist-patients-tags',
component: () => import('@/features/patients/tags/TagsPage.vue') component: () => import('@/features/patients/tags/TagsPage.vue')
}, },
{
path: 'patients/medicos',
name: 'therapist-patients-medicos',
component: () => import('@/features/patients/medicos/MedicosPage.vue')
},
{ {
path: 'patients/link-externo', path: 'patients/link-externo',
name: 'therapist-patients-link-externo', name: 'therapist-patients-link-externo',
@@ -121,6 +126,29 @@ export default {
component: () => import('@/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue') component: () => import('@/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue')
}, },
// ======================================================
// 📄 DOCUMENTOS
// ======================================================
{
path: 'documents',
name: 'therapist-documents',
component: () => import('@/features/documents/DocumentsListPage.vue'),
meta: { feature: 'documents.upload' }
},
{
path: 'documents/templates',
name: 'therapist-documents-templates',
component: () => import('@/features/documents/DocumentTemplatesPage.vue'),
meta: { feature: 'documents.templates' }
},
{
path: 'patients/:id/documents',
name: 'therapist-patient-documents',
component: () => import('@/features/documents/DocumentsListPage.vue'),
props: true,
meta: { feature: 'documents.upload' }
},
// ====================================================== // ======================================================
// 🔒 PRO — Online Scheduling // 🔒 PRO — Online Scheduling
// ====================================================== // ======================================================

View File

@@ -0,0 +1,144 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/services/DocumentAuditLog.service.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
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;
}
// ── Registrar acesso ────────────────────────────────────────
/**
* Registra acesso a um documento (visualizacao, download, etc.).
* Tabela imutavel — somente INSERT.
*
* @param {string} documentoId
* @param {string} acao - 'visualizou' | 'baixou' | 'imprimiu' | 'compartilhou' | 'assinou'
*/
export async function logAccess(documentoId, acao) {
if (!documentoId || !acao) return;
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const { error } = await supabase
.from('document_access_logs')
.insert({
documento_id: documentoId,
tenant_id: tenantId,
acao,
user_id: ownerId
});
// Nao lancar erro para nao interromper o fluxo principal
if (error) console.error('[DocumentAuditLog] Erro ao registrar acesso:', error.message);
}
// ── Listar historico de acessos ─────────────────────────────
/**
* Retorna historico de acessos de um documento.
*/
export async function listAccessLogs(documentoId) {
if (!documentoId) return [];
const { data, error } = await supabase
.from('document_access_logs')
.select('*, profiles:user_id(full_name)')
.eq('documento_id', documentoId)
.order('acessado_em', { ascending: false });
if (error) throw error;
return data || [];
}
/**
* Retorna historico de acessos de todos os documentos do tenant.
* Util para auditoria geral.
*
* @param {object} filters - { dataInicio, dataFim, acao, userId }
* @param {number} limit - maximo de registros (default 100)
*/
export async function listAllAccessLogs(filters = {}, limit = 100) {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
let query = supabase
.from('document_access_logs')
.select('*, profiles:user_id(full_name), documents:documento_id(nome_original, patient_id)')
.eq('tenant_id', tenantId)
.order('acessado_em', { ascending: false })
.limit(limit);
if (filters.acao) {
query = query.eq('acao', filters.acao);
}
if (filters.userId) {
query = query.eq('user_id', filters.userId);
}
if (filters.dataInicio) {
query = query.gte('acessado_em', filters.dataInicio);
}
if (filters.dataFim) {
query = query.lte('acessado_em', filters.dataFim);
}
const { data, error } = await query;
if (error) throw error;
return data || [];
}
/**
* Conta acessos por tipo de acao para um documento.
* Util para exibir badges (ex: "visualizado 5x, baixado 2x").
*/
export async function countAccessByAction(documentoId) {
if (!documentoId) return {};
const { data, error } = await supabase
.from('document_access_logs')
.select('acao')
.eq('documento_id', documentoId);
if (error) throw error;
const counts = {};
for (const row of data || []) {
counts[row.acao] = (counts[row.acao] || 0) + 1;
}
return counts;
}

View File

@@ -0,0 +1,386 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/services/DocumentGenerate.service.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
const BUCKET = 'generated-docs';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
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;
}
// ── Carregar dados para preenchimento ───────────────────────
/**
* Busca dados do paciente para preencher variaveis do template.
*/
export async function loadPatientData(patientId) {
const { data, error } = await supabase
.from('patients')
.select(`
nome_completo, nome_social, cpf, data_nascimento,
telefone, email_principal,
endereco, numero, bairro, cidade, estado, cep
`)
.eq('id', patientId)
.single();
if (error) throw error;
const p = data;
const endereco = [p.endereco, p.numero, p.bairro, p.cidade, p.estado]
.filter(Boolean).join(', ');
return {
paciente_nome: p.nome_completo || '',
paciente_nome_social: p.nome_social || '',
paciente_cpf: p.cpf || '',
paciente_data_nascimento: p.data_nascimento
? new Date(p.data_nascimento).toLocaleDateString('pt-BR')
: '',
paciente_telefone: p.telefone || '',
paciente_email: p.email_principal || '',
paciente_endereco: endereco
};
}
/**
* Busca dados da sessao (agenda_evento) para preencher variaveis.
*/
export async function loadSessionData(agendaEventoId) {
if (!agendaEventoId) return {};
const { data, error } = await supabase
.from('agenda_eventos')
.select('inicio_em, fim_em, modalidade, price')
.eq('id', agendaEventoId)
.single();
if (error) return {};
const s = data;
const inicio = s.inicio_em ? new Date(s.inicio_em) : null;
const fim = s.fim_em ? new Date(s.fim_em) : null;
return {
data_sessao: inicio ? inicio.toLocaleDateString('pt-BR') : '',
hora_inicio: inicio ? inicio.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '',
hora_fim: fim ? fim.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '',
modalidade: s.modalidade || '',
valor: s.price ? `R$ ${Number(s.price).toFixed(2).replace('.', ',')}` : ''
};
}
/**
* Busca dados do terapeuta (profile + tenant_member).
*/
export async function loadTherapistData() {
const ownerId = await getOwnerId();
const { data: profile } = await supabase
.from('profiles')
.select('full_name, phone')
.eq('id', ownerId)
.single();
// Email vem de auth.users (nao existe em profiles)
const { data: userData } = await supabase.auth.getUser();
const email = userData?.user?.email || '';
return {
terapeuta_nome: profile?.full_name || '',
terapeuta_crp: '', // CRP ainda nao existe no banco — preencher manualmente
terapeuta_email: email,
terapeuta_telefone: profile?.phone || ''
};
}
/**
* Busca dados da clinica (tenant).
*/
export async function loadClinicData(tenantId) {
// Usa select('*') pois campos de endereço (logradouro, numero, etc.)
// dependem da migration 003_tenants_address_fields ter sido aplicada
const { data: tenant } = await supabase
.from('tenants')
.select('*')
.eq('id', tenantId)
.maybeSingle();
if (!tenant) {
return { clinica_nome: '', clinica_endereco: '', clinica_telefone: '', clinica_cnpj: '' };
}
// Usa campos estruturados se disponiveis, senao cai no address texto livre
const endereco = tenant.logradouro
? [tenant.logradouro, tenant.numero, tenant.bairro, tenant.cidade, tenant.estado]
.filter(Boolean).join(', ')
: tenant.address || '';
return {
clinica_nome: tenant.name || '',
clinica_endereco: endereco,
clinica_telefone: tenant.phone || '',
clinica_cnpj: ''
};
}
// ── Montar dados gerais ─────────────────────────────────────
function getDateVariables() {
const now = new Date();
const meses = [
'janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho',
'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'
];
return {
data_atual: now.toLocaleDateString('pt-BR'),
data_atual_extenso: `${now.getDate()} de ${meses[now.getMonth()]} de ${now.getFullYear()}`
};
}
/**
* Carrega todos os dados necessarios para preencher um template.
*/
export async function loadAllVariables(patientId, agendaEventoId = null) {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const [patient, session, therapist, clinic] = await Promise.all([
loadPatientData(patientId),
loadSessionData(agendaEventoId),
loadTherapistData(),
loadClinicData(tenantId)
]);
return {
...patient,
...session,
...therapist,
...clinic,
...getDateVariables(),
cidade_estado: clinic.clinica_endereco
? `${clinic.clinica_endereco.split(', ').slice(-2).join('/')}`
: ''
};
}
// ── Preencher template ──────────────────────────────────────
/**
* Substitui {{variavel}} no HTML pelos valores fornecidos.
*/
export function fillTemplate(html, variables = {}) {
return String(html || '').replace(/\{\{(\w+)\}\}/g, (match, key) => {
return variables[key] !== undefined ? String(variables[key]) : match;
});
}
/**
* Monta o HTML completo do documento (cabecalho + corpo + rodape).
*/
export function buildFullHtml(template, variables = {}) {
const cabecalho = fillTemplate(template.cabecalho_html || '', variables);
const corpo = fillTemplate(template.corpo_html || '', variables);
const rodape = fillTemplate(template.rodape_html || '', variables);
return `
<!DOCTYPE html>
<html lang="pt-BR" style="color-scheme:light;">
<head>
<meta charset="UTF-8">
<style>
*, *::before, *::after { color-scheme: light; }
@page { size: A4; margin: 20mm 15mm 25mm 15mm; }
html, body {
all: initial;
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 12pt;
line-height: 1.6;
color: #1a1a1a;
background: #ffffff;
-webkit-print-color-adjust: exact;
}
h1, h2, h3, h4, p, ul, ol, li, table, tr, td, th, div, span, strong, em, hr, a {
all: revert;
color: inherit;
font-family: inherit;
}
h2 { font-size: 16pt; margin-bottom: 16px; }
h3 { font-size: 13pt; margin-top: 20px; margin-bottom: 8px; }
p { margin: 8px 0; }
table { border-collapse: collapse; }
td { padding: 4px 8px; }
hr { border: none; border-top: 1px solid #333333; }
a { color: #2563eb; }
.doc-header { text-align: center; margin-bottom: 24px; padding-bottom: 12px; border-bottom: 1px solid #cccccc; }
.doc-header img { max-height: 60px; margin-bottom: 8px; }
.doc-content { min-height: 600px; }
.doc-footer { margin-top: 40px; padding-top: 12px; border-top: 1px solid #cccccc; font-size: 10pt; color: #666666; text-align: center; }
.signature-line { margin-top: 60px; text-align: center; }
.signature-line hr { width: 250px; margin: 0 auto 4px; border: none; border-top: 1px solid #333333; }
</style>
</head>
<body>
<div class="doc-header">${cabecalho}</div>
<div class="doc-content">${corpo}</div>
<div class="doc-footer">${rodape}</div>
</body>
</html>`.trim();
}
// ── Gerar PDF (jsPDF + html2canvas via pdf.service) ────────
import { htmlToPdfBlob, htmlToPdfDownload, htmlToPdfOpen } from '@/services/pdf.service';
/**
* Gera um Blob PDF a partir do template preenchido.
*/
export async function generatePdfBlob(template, variables = {}) {
const html = buildFullHtml(template, variables);
return await htmlToPdfBlob(html);
}
/**
* Gera PDF e dispara download automatico.
*/
export async function generateAndDownloadPdf(template, variables = {}, filename = 'documento.pdf') {
const html = buildFullHtml(template, variables);
await htmlToPdfDownload(html, filename);
}
/**
* Abre o PDF em nova aba para impressao.
*/
export async function printDocument(template, variables = {}) {
const html = buildFullHtml(template, variables);
await htmlToPdfOpen(html);
}
// ── Salvar documento gerado ─────────────────────────────────
/**
* Registra um documento gerado na tabela document_generated.
* O PDF deve ser passado como Blob (gerado client-side ou server-side).
*
* @param {object} params
* @param {string} params.templateId
* @param {string} params.patientId
* @param {object} params.dadosPreenchidos - snapshot dos dados usados
* @param {Blob} params.pdfBlob - PDF gerado (opcional — pode ser null se so print)
* @returns {object} registro criado
*/
export async function saveGeneratedDocument({ templateId, patientId, dadosPreenchidos, pdfBlob, templateNome }) {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
let pdfPath = '';
const timestamp = Date.now();
const safeNome = (templateNome || 'documento')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // remove acentos
.replace(/[^a-zA-Z0-9_-]/g, '_');
const filename = `${safeNome}_${timestamp}.pdf`;
// Se tiver um blob PDF, faz upload ao Storage
if (pdfBlob) {
pdfPath = `${tenantId}/${patientId}/${filename}`;
const { error: upErr } = await supabase.storage
.from(BUCKET)
.upload(pdfPath, pdfBlob, { contentType: 'application/pdf' });
if (upErr) throw upErr;
}
// Registra na tabela document_generated
const { data, error } = await supabase
.from('document_generated')
.insert({
template_id: templateId,
patient_id: patientId,
tenant_id: tenantId,
dados_preenchidos: dadosPreenchidos || {},
pdf_path: pdfPath,
storage_bucket: BUCKET,
gerado_por: ownerId
})
.select('*')
.single();
if (error) throw error;
// Registra na tabela documents para aparecer na lista do paciente
// Reutiliza o mesmo arquivo do bucket generated-docs (sem upload duplicado)
if (pdfPath) {
await supabase
.from('documents')
.insert({
owner_id: ownerId,
tenant_id: tenantId,
patient_id: patientId,
bucket_path: pdfPath,
storage_bucket: BUCKET,
nome_original: filename.replace(/_/g, ' ').replace('.pdf', '') + '.pdf',
mime_type: 'application/pdf',
tamanho_bytes: pdfBlob?.size || null,
tipo_documento: 'laudo',
descricao: `Gerado a partir do template: ${templateNome || 'documento'}`,
tags: ['gerado'],
visibilidade: 'privado',
status_revisao: 'aprovado',
uploaded_by: ownerId
});
}
return data;
}
/**
* Lista documentos gerados de um paciente.
*/
export async function listGeneratedDocuments(patientId) {
const ownerId = await getOwnerId();
const { data, error } = await supabase
.from('document_generated')
.select('*, document_templates(nome_template, tipo)')
.eq('gerado_por', ownerId)
.eq('patient_id', patientId)
.order('gerado_em', { ascending: false });
if (error) throw error;
return data || [];
}

View File

@@ -0,0 +1,166 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/services/DocumentShareLinks.service.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
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;
}
// ── Criar link temporario ───────────────────────────────────
/**
* Gera link temporario para compartilhar documento com profissional externo.
*
* @param {string} documentoId
* @param {object} opts - { expiracaoHoras: 48, usosMax: 5 }
* @returns {object} registro com token para montar a URL
*/
export async function createShareLink(documentoId, opts = {}) {
if (!documentoId) throw new Error('Documento não informado.');
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const expiracaoHoras = opts.expiracaoHoras || 48;
const expiraEm = new Date();
expiraEm.setHours(expiraEm.getHours() + expiracaoHoras);
const { data, error } = await supabase
.from('document_share_links')
.insert({
documento_id: documentoId,
tenant_id: tenantId,
expira_em: expiraEm.toISOString(),
usos_max: opts.usosMax || 5,
criado_por: ownerId
})
.select('*')
.single();
if (error) throw error;
return data;
}
// ── Listar links de um documento ────────────────────────────
export async function listShareLinks(documentoId) {
if (!documentoId) return [];
const ownerId = await getOwnerId();
const { data, error } = await supabase
.from('document_share_links')
.select('*')
.eq('documento_id', documentoId)
.eq('criado_por', ownerId)
.order('criado_em', { ascending: false });
if (error) throw error;
return data || [];
}
// ── Validar token (acesso publico) ──────────────────────────
/**
* Valida token de compartilhamento e retorna dados do documento.
* Incrementa o contador de usos.
*
* @param {string} token
* @returns {object|null} - { link, document } ou null se invalido/expirado
*/
export async function validateShareToken(token) {
if (!token) return null;
// Buscar link ativo
const { data: link, error } = await supabase
.from('document_share_links')
.select('*')
.eq('token', token)
.eq('ativo', true)
.single();
if (error || !link) return null;
// Verificar expiracao
if (new Date(link.expira_em) < new Date()) return null;
// Verificar limite de usos
if (link.usos >= link.usos_max) return null;
// Incrementar uso
await supabase
.from('document_share_links')
.update({ usos: link.usos + 1 })
.eq('id', link.id);
// Buscar documento
const { data: doc } = await supabase
.from('documents')
.select('id, nome_original, mime_type, bucket_path, storage_bucket')
.eq('id', link.documento_id)
.single();
return { link, document: doc };
}
// ── Desativar link ──────────────────────────────────────────
export async function deactivateShareLink(linkId) {
if (!linkId) throw new Error('ID inválido.');
const ownerId = await getOwnerId();
const { error } = await supabase
.from('document_share_links')
.update({ ativo: false })
.eq('id', linkId)
.eq('criado_por', ownerId);
if (error) throw error;
return true;
}
// ── Montar URL publica ──────────────────────────────────────
/**
* Monta a URL de compartilhamento a partir do token.
* A rota publica deve ser configurada no router.
*/
export function buildShareUrl(token) {
const base = window.location.origin;
return `${base}/shared/document/${token}`;
}

View File

@@ -0,0 +1,172 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/services/DocumentSignatures.service.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
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;
}
// ── Hash do documento ───────────────────────────────────────
/**
* Gera hash SHA-256 de um ArrayBuffer (conteudo do arquivo).
*/
export async function hashDocument(arrayBuffer) {
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// ── Criar solicitacao de assinatura ─────────────────────────
/**
* Cria uma ou mais solicitacoes de assinatura para um documento.
*
* @param {string} documentoId - UUID do documento
* @param {Array} signatarios - [{ tipo, nome, email, id? }]
* tipo: 'paciente' | 'responsavel_legal' | 'terapeuta'
*/
export async function createSignatureRequests(documentoId, signatarios = []) {
if (!documentoId) throw new Error('Documento não informado.');
if (!signatarios.length) throw new Error('Ao menos um signatário é necessário.');
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const rows = signatarios.map((s, idx) => ({
documento_id: documentoId,
tenant_id: tenantId,
signatario_tipo: s.tipo || 'paciente',
signatario_id: s.id || null,
signatario_nome: s.nome || null,
signatario_email: s.email || null,
ordem: idx + 1,
status: 'pendente'
}));
const { data, error } = await supabase
.from('document_signatures')
.insert(rows)
.select('*');
if (error) throw error;
return data || [];
}
// ── Registrar assinatura ────────────────────────────────────
/**
* Registra que um signatario assinou o documento.
*
* @param {string} signatureId - UUID da solicitacao de assinatura
* @param {object} meta - { ip, user_agent, hash_documento }
*/
export async function registerSignature(signatureId, meta = {}) {
if (!signatureId) throw new Error('ID da assinatura inválido.');
const { data, error } = await supabase
.from('document_signatures')
.update({
status: 'assinado',
ip: meta.ip || null,
user_agent: meta.user_agent || null,
assinado_em: new Date().toISOString(),
hash_documento: meta.hash_documento || null
})
.eq('id', signatureId)
.select('*')
.single();
if (error) throw error;
return data;
}
// ── Listar assinaturas de um documento ──────────────────────
export async function listSignatures(documentoId) {
if (!documentoId) return [];
const { data, error } = await supabase
.from('document_signatures')
.select('*')
.eq('documento_id', documentoId)
.order('ordem', { ascending: true });
if (error) throw error;
return data || [];
}
// ── Status geral do documento ───────────────────────────────
/**
* Retorna o status consolidado de assinaturas de um documento.
*
* @returns {{ total, assinados, pendentes, status }}
* status: 'completo' | 'parcial' | 'pendente' | 'sem_assinaturas'
*/
export async function getSignatureStatus(documentoId) {
const sigs = await listSignatures(documentoId);
if (!sigs.length) return { total: 0, assinados: 0, pendentes: 0, status: 'sem_assinaturas' };
const assinados = sigs.filter(s => s.status === 'assinado').length;
const pendentes = sigs.length - assinados;
let status = 'pendente';
if (assinados === sigs.length) status = 'completo';
else if (assinados > 0) status = 'parcial';
return { total: sigs.length, assinados, pendentes, status };
}
// ── Recusar assinatura ──────────────────────────────────────
export async function refuseSignature(signatureId) {
if (!signatureId) throw new Error('ID da assinatura inválido.');
const { data, error } = await supabase
.from('document_signatures')
.update({
status: 'recusado',
atualizado_em: new Date().toISOString()
})
.eq('id', signatureId)
.select('*')
.single();
if (error) throw error;
return data;
}

View File

@@ -0,0 +1,247 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/services/DocumentTemplates.service.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
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;
}
// ── Variaveis disponíveis ───────────────────────────────────
/**
* Variaveis que podem ser usadas nos templates.
* Cada variavel tem: key, label (pt-BR), grupo.
*/
export const TEMPLATE_VARIABLES = [
// Paciente
{ key: 'paciente_nome', label: 'Nome do paciente', grupo: 'Paciente' },
{ key: 'paciente_nome_social', label: 'Nome social', grupo: 'Paciente' },
{ key: 'paciente_cpf', label: 'CPF do paciente', grupo: 'Paciente' },
{ key: 'paciente_data_nascimento', label: 'Data de nascimento', grupo: 'Paciente' },
{ key: 'paciente_telefone', label: 'Telefone do paciente', grupo: 'Paciente' },
{ key: 'paciente_email', label: 'E-mail do paciente', grupo: 'Paciente' },
{ key: 'paciente_endereco', label: 'Endereço do paciente', grupo: 'Paciente' },
// Sessao
{ key: 'data_sessao', label: 'Data da sessão', grupo: 'Sessão' },
{ key: 'hora_inicio', label: 'Hora início', grupo: 'Sessão' },
{ key: 'hora_fim', label: 'Hora fim', grupo: 'Sessão' },
{ key: 'modalidade', label: 'Modalidade (presencial/online)', grupo: 'Sessão' },
// Terapeuta
{ key: 'terapeuta_nome', label: 'Nome do terapeuta', grupo: 'Terapeuta' },
{ key: 'terapeuta_crp', label: 'CRP do terapeuta', grupo: 'Terapeuta' },
{ key: 'terapeuta_email', label: 'E-mail do terapeuta', grupo: 'Terapeuta' },
{ key: 'terapeuta_telefone', label: 'Telefone do terapeuta', grupo: 'Terapeuta' },
// Clinica
{ key: 'clinica_nome', label: 'Nome da clínica', grupo: 'Clínica' },
{ key: 'clinica_endereco', label: 'Endereço da clínica', grupo: 'Clínica' },
{ key: 'clinica_telefone', label: 'Telefone da clínica', grupo: 'Clínica' },
{ key: 'clinica_cnpj', label: 'CNPJ da clínica', grupo: 'Clínica' },
// Financeiro
{ key: 'valor', label: 'Valor (R$)', grupo: 'Financeiro' },
{ key: 'valor_extenso', label: 'Valor por extenso', grupo: 'Financeiro' },
{ key: 'forma_pagamento', label: 'Forma de pagamento', grupo: 'Financeiro' },
// Datas
{ key: 'data_atual', label: 'Data atual', grupo: 'Datas' },
{ key: 'data_atual_extenso', label: 'Data atual por extenso', grupo: 'Datas' },
{ key: 'cidade_estado', label: 'Cidade/UF', grupo: 'Datas' }
];
// ── List ────────────────────────────────────────────────────
/**
* Lista templates disponíveis: globais + do tenant do usuario.
*/
export async function listTemplates() {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const { data, error } = await supabase
.from('document_templates')
.select('*')
.or(`is_global.eq.true,tenant_id.eq.${tenantId}`)
.eq('ativo', true)
.order('nome_template', { ascending: true });
if (error) throw error;
return data || [];
}
/**
* Lista todos os templates (incluindo inativos) — para pagina de gestao.
*/
export async function listAllTemplates() {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const { data, error } = await supabase
.from('document_templates')
.select('*')
.or(`is_global.eq.true,tenant_id.eq.${tenantId}`)
.order('is_global', { ascending: false })
.order('nome_template', { ascending: true });
if (error) throw error;
return data || [];
}
// ── Get one ─────────────────────────────────────────────────
export async function getTemplate(id) {
const { data, error } = await supabase
.from('document_templates')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
return data;
}
// ── Create ──────────────────────────────────────────────────
export async function createTemplate(payload) {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const nome = String(payload.nome_template || '').trim();
if (!nome) throw new Error('Nome do template é obrigatório.');
const row = {
owner_id: ownerId,
tenant_id: tenantId,
nome_template: nome,
tipo: payload.tipo || 'outro',
descricao: payload.descricao || null,
corpo_html: payload.corpo_html || '',
cabecalho_html: payload.cabecalho_html || null,
rodape_html: payload.rodape_html || null,
variaveis: payload.variaveis || [],
logo_url: payload.logo_url || null,
is_global: false,
ativo: true
};
const { data, error } = await supabase
.from('document_templates')
.insert(row)
.select('*')
.single();
if (error) throw error;
return data;
}
// ── Update ──────────────────────────────────────────────────
export async function updateTemplate(id, payload) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const row = {};
if (payload.nome_template !== undefined) row.nome_template = String(payload.nome_template).trim();
if (payload.tipo !== undefined) row.tipo = payload.tipo;
if (payload.descricao !== undefined) row.descricao = payload.descricao;
if (payload.corpo_html !== undefined) row.corpo_html = payload.corpo_html;
if (payload.cabecalho_html !== undefined) row.cabecalho_html = payload.cabecalho_html;
if (payload.rodape_html !== undefined) row.rodape_html = payload.rodape_html;
if (payload.variaveis !== undefined) row.variaveis = payload.variaveis;
if (payload.logo_url !== undefined) row.logo_url = payload.logo_url;
if (payload.ativo !== undefined) row.ativo = payload.ativo;
row.updated_at = new Date().toISOString();
const { data, error } = await supabase
.from('document_templates')
.update(row)
.eq('id', id)
.eq('owner_id', ownerId)
.select('*')
.single();
if (error) throw error;
return data;
}
// ── Delete (soft) ───────────────────────────────────────────
export async function deleteTemplate(id) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const { error } = await supabase
.from('document_templates')
.update({ ativo: false, updated_at: new Date().toISOString() })
.eq('id', id)
.eq('owner_id', ownerId);
if (error) throw error;
return true;
}
// ── Duplicate ───────────────────────────────────────────────
export async function duplicateTemplate(id) {
const original = await getTemplate(id);
if (!original) throw new Error('Template não encontrado.');
return createTemplate({
nome_template: original.nome_template + ' (cópia)',
tipo: original.tipo,
descricao: original.descricao,
corpo_html: original.corpo_html,
cabecalho_html: original.cabecalho_html,
rodape_html: original.rodape_html,
variaveis: original.variaveis,
logo_url: original.logo_url
});
}
// ── Extrair variaveis do HTML ───────────────────────────────
/**
* Extrai variaveis {{nome}} do corpo HTML de um template.
*/
export function extractVariablesFromHtml(html) {
const matches = String(html || '').match(/\{\{(\w+)\}\}/g) || [];
const keys = matches.map(m => m.replace(/\{\{|\}\}/g, ''));
return [...new Set(keys)];
}

View File

@@ -0,0 +1,313 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/services/Documents.service.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
const BUCKET = 'documents';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
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 buildStoragePath(tenantId, patientId, fileName) {
const timestamp = Date.now();
const safe = String(fileName || 'arquivo').replace(/[^a-zA-Z0-9._-]/g, '_');
return `${tenantId}/${patientId}/${timestamp}-${safe}`;
}
// ── Upload ──────────────────────────────────────────────────
/**
* Faz upload de arquivo ao Storage e registra na tabela documents.
*
* @param {File} file - Objeto File do input
* @param {string} patientId - UUID do paciente
* @param {object} meta - { tipo_documento, categoria, descricao, tags[], agenda_evento_id, visibilidade }
* @returns {object} - Registro criado em documents
*/
export async function uploadDocument(file, patientId, meta = {}) {
if (!file) throw new Error('Nenhum arquivo selecionado.');
if (!patientId) throw new Error('Paciente não informado.');
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
// Upload ao Storage
const path = buildStoragePath(tenantId, patientId, file.name);
const { error: upErr } = await supabase.storage
.from(BUCKET)
.upload(path, file, { contentType: file.type });
if (upErr) throw upErr;
// Insert na tabela
const row = {
owner_id: ownerId,
tenant_id: tenantId,
patient_id: patientId,
bucket_path: path,
storage_bucket: BUCKET,
nome_original: file.name,
mime_type: file.type || null,
tamanho_bytes: file.size || null,
tipo_documento: meta.tipo_documento || 'outro',
categoria: meta.categoria || null,
descricao: meta.descricao || null,
tags: meta.tags || [],
agenda_evento_id: meta.agenda_evento_id || null,
visibilidade: meta.visibilidade || 'privado',
compartilhado_portal: meta.compartilhado_portal || false,
compartilhado_supervisor: meta.compartilhado_supervisor || false,
enviado_pelo_paciente: meta.enviado_pelo_paciente || false,
status_revisao: meta.enviado_pelo_paciente ? 'pendente' : 'aprovado',
uploaded_by: ownerId
};
const { data, error } = await supabase
.from('documents')
.insert(row)
.select('*')
.single();
if (error) {
// Tenta limpar o arquivo do Storage em caso de erro no insert
await supabase.storage.from(BUCKET).remove([path]).catch(() => {});
throw error;
}
return data;
}
// ── List ────────────────────────────────────────────────────
/**
* Lista documentos de um paciente (excluindo soft-deleted).
*
* @param {string} patientId
* @param {object} filters - { tipo_documento, categoria, tag, search }
*/
export async function listDocuments(patientId, filters = {}) {
const ownerId = await getOwnerId();
let query = supabase
.from('documents')
.select('*')
.eq('owner_id', ownerId)
.eq('patient_id', patientId)
.is('deleted_at', null)
.order('uploaded_at', { ascending: false });
if (filters.tipo_documento) {
query = query.eq('tipo_documento', filters.tipo_documento);
}
if (filters.categoria) {
query = query.eq('categoria', filters.categoria);
}
if (filters.tag) {
query = query.contains('tags', [filters.tag]);
}
if (filters.search) {
query = query.ilike('nome_original', `%${filters.search}%`);
}
const { data, error } = await query;
if (error) throw error;
return data || [];
}
/**
* Lista todos os documentos do owner (todos os pacientes).
*/
export async function listAllDocuments(filters = {}) {
const ownerId = await getOwnerId();
let query = supabase
.from('documents')
.select('*, patients!inner(nome_completo)')
.eq('owner_id', ownerId)
.is('deleted_at', null)
.order('uploaded_at', { ascending: false });
if (filters.tipo_documento) {
query = query.eq('tipo_documento', filters.tipo_documento);
}
if (filters.search) {
query = query.ilike('nome_original', `%${filters.search}%`);
}
const { data, error } = await query;
if (error) throw error;
return data || [];
}
// ── Get one ─────────────────────────────────────────────────
export async function getDocument(id) {
const ownerId = await getOwnerId();
const { data, error } = await supabase
.from('documents')
.select('*')
.eq('id', id)
.eq('owner_id', ownerId)
.single();
if (error) throw error;
return data;
}
// ── Update ──────────────────────────────────────────────────
export async function updateDocument(id, payload) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const row = {};
if (payload.tipo_documento !== undefined) row.tipo_documento = payload.tipo_documento;
if (payload.categoria !== undefined) row.categoria = payload.categoria;
if (payload.descricao !== undefined) row.descricao = payload.descricao;
if (payload.tags !== undefined) row.tags = payload.tags;
if (payload.visibilidade !== undefined) row.visibilidade = payload.visibilidade;
if (payload.compartilhado_portal !== undefined) row.compartilhado_portal = payload.compartilhado_portal;
if (payload.compartilhado_supervisor !== undefined) row.compartilhado_supervisor = payload.compartilhado_supervisor;
if (payload.status_revisao !== undefined) {
row.status_revisao = payload.status_revisao;
row.revisado_por = ownerId;
row.revisado_em = new Date().toISOString();
}
row.updated_at = new Date().toISOString();
const { data, error } = await supabase
.from('documents')
.update(row)
.eq('id', id)
.eq('owner_id', ownerId)
.select('*')
.single();
if (error) throw error;
return data;
}
// ── Soft Delete ─────────────────────────────────────────────
/**
* Soft delete com retencao. O arquivo permanece no Storage.
* retencaoAnos: numero de anos de retencao (padrao 5 — CFP).
*/
export async function softDeleteDocument(id, retencaoAnos = 5) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const retencaoAte = new Date();
retencaoAte.setFullYear(retencaoAte.getFullYear() + retencaoAnos);
const { error } = await supabase
.from('documents')
.update({
deleted_at: new Date().toISOString(),
deleted_by: ownerId,
retencao_ate: retencaoAte.toISOString(),
updated_at: new Date().toISOString()
})
.eq('id', id)
.eq('owner_id', ownerId);
if (error) throw error;
return true;
}
/**
* Restaurar documento soft-deleted.
*/
export async function restoreDocument(id) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const { error } = await supabase
.from('documents')
.update({
deleted_at: null,
deleted_by: null,
retencao_ate: null,
updated_at: new Date().toISOString()
})
.eq('id', id)
.eq('owner_id', ownerId);
if (error) throw error;
return true;
}
// ── Download URL ────────────────────────────────────────────
/**
* Gera URL assinada para download (valida por 60s por padrao).
*/
export async function getDownloadUrl(bucketPath, expiresIn = 60, bucket = BUCKET) {
const { data, error } = await supabase.storage
.from(bucket)
.createSignedUrl(bucketPath, expiresIn);
if (error) throw error;
return data?.signedUrl;
}
// ── Tags (autocomplete) ────────────────────────────────────
/**
* Retorna tags unicas ja usadas pelo owner (para autocomplete).
*/
export async function getUsedTags() {
const ownerId = await getOwnerId();
const { data, error } = await supabase
.from('documents')
.select('tags')
.eq('owner_id', ownerId)
.is('deleted_at', null);
if (error) throw error;
const set = new Set();
for (const row of data || []) {
for (const tag of row.tags || []) {
if (tag) set.add(tag);
}
}
return [...set].sort((a, b) => a.localeCompare(b, 'pt-BR'));
}

View File

@@ -0,0 +1,224 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/services/Medicos.service.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
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 normalizeNome(s) {
return String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
}
function isUniqueViolation(err) {
if (!err) return false;
if (err.code === '23505') return true;
return /duplicate key value violates unique constraint/i.test(String(err.message || ''));
}
// ── List ─────────────────────────────────────────────────────
/**
* Lista médicos ativos do owner com contagem de pacientes encaminhados.
* A contagem é feita buscando quantos patients possuem o nome do médico
* no campo `encaminhado_por` (text).
*/
export async function listMedicosWithPatientCounts() {
const ownerId = await getOwnerId();
const { data: medicos, error } = await supabase
.from('medicos')
.select('id, nome, crm, especialidade, telefone_profissional, telefone_pessoal, email, clinica, cidade, estado, observacoes, ativo, owner_id, tenant_id, created_at, updated_at')
.eq('owner_id', ownerId)
.eq('ativo', true)
.order('nome', { ascending: true });
if (error) throw error;
// Busca pacientes do owner para contar encaminhamentos por médico
const { data: patients, error: pErr } = await supabase
.from('patients')
.select('id, encaminhado_por')
.eq('owner_id', ownerId);
if (pErr) throw pErr;
const countMap = new Map();
for (const med of medicos || []) {
countMap.set(med.id, 0);
}
for (const p of patients || []) {
const enc = String(p.encaminhado_por || '').toLowerCase();
if (!enc) continue;
for (const med of medicos || []) {
const nomeLower = med.nome.toLowerCase();
if (enc.includes(nomeLower)) {
countMap.set(med.id, (countMap.get(med.id) || 0) + 1);
}
}
}
return (medicos || []).map((m) => ({
...m,
patients_count: countMap.get(m.id) || 0
}));
}
// ── Create ───────────────────────────────────────────────────
export async function createMedico(payload) {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const nome = String(payload.nome || '').trim();
if (!nome) throw new Error('Nome do médico é obrigatório.');
const row = {
owner_id: ownerId,
tenant_id: tenantId,
nome,
crm: String(payload.crm || '').trim() || null,
especialidade: payload.especialidade || null,
telefone_profissional: payload.telefone_profissional || null,
telefone_pessoal: payload.telefone_pessoal || null,
email: String(payload.email || '').trim() || null,
clinica: String(payload.clinica || '').trim() || null,
cidade: String(payload.cidade || '').trim() || null,
estado: String(payload.estado || '').trim() || null,
observacoes: String(payload.observacoes || '').trim() || null,
ativo: true
};
const { data, error } = await supabase
.from('medicos')
.insert(row)
.select('*')
.single();
if (error) {
if (isUniqueViolation(error)) throw new Error('Já existe um médico com este CRM.');
throw error;
}
return data;
}
// ── Update ───────────────────────────────────────────────────
export async function updateMedico(id, payload) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const nome = String(payload.nome || '').trim();
if (!nome) throw new Error('Nome do médico é obrigatório.');
const row = {
nome,
crm: String(payload.crm || '').trim() || null,
especialidade: payload.especialidade || null,
telefone_profissional: payload.telefone_profissional || null,
telefone_pessoal: payload.telefone_pessoal || null,
email: String(payload.email || '').trim() || null,
clinica: String(payload.clinica || '').trim() || null,
cidade: String(payload.cidade || '').trim() || null,
estado: String(payload.estado || '').trim() || null,
observacoes: String(payload.observacoes || '').trim() || null,
updated_at: new Date().toISOString()
};
const { data, error } = await supabase
.from('medicos')
.update(row)
.eq('id', id)
.eq('owner_id', ownerId)
.select('*')
.single();
if (error) {
if (isUniqueViolation(error)) throw new Error('Já existe um médico com este CRM.');
throw error;
}
return data;
}
// ── Delete (soft) ────────────────────────────────────────────
export async function deleteMedico(id) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const { error } = await supabase
.from('medicos')
.update({ ativo: false, updated_at: new Date().toISOString() })
.eq('id', id)
.eq('owner_id', ownerId);
if (error) throw error;
return true;
}
// ── Pacientes de um médico ───────────────────────────────────
/**
* Busca pacientes do owner cujo campo `encaminhado_por` contém o nome do médico.
*/
export async function fetchPatientsByMedicoNome(medicoNome) {
const ownerId = await getOwnerId();
const nomeLower = String(medicoNome || '').trim().toLowerCase();
if (!nomeLower) return [];
const { data, error } = await supabase
.from('patients')
.select('id, nome_completo, email_principal, telefone, avatar_url, encaminhado_por')
.eq('owner_id', ownerId)
.ilike('encaminhado_por', `%${nomeLower}%`);
if (error) throw error;
return (data || [])
.map((p) => ({
id: p.id,
full_name: p.nome_completo || '—',
email: p.email_principal || '—',
phone: p.telefone || '—',
avatar_url: p.avatar_url || null,
encaminhado_por: p.encaminhado_por || ''
}))
.sort((a, b) => String(a.full_name).localeCompare(String(b.full_name), 'pt-BR'));
}

113
src/services/pdf.service.js Normal file
View File

@@ -0,0 +1,113 @@
/*
|--------------------------------------------------------------------------
| PDF SERVICE — jsPDF + html2canvas
|--------------------------------------------------------------------------
|
| Gera PDF a partir de HTML renderizado no browser.
| Retorna Blob para download local e upload ao Storage.
|
*/
import { jsPDF } from 'jspdf';
import html2canvas from 'html2canvas-pro';
const A4 = { width: 595.28, height: 841.89 }; // pontos (72dpi)
const MARGIN = 40; // pontos
/**
* Renderiza HTML completo em um Blob PDF.
*
* @param {string} html - HTML completo do documento (com <html>, <style>, etc.)
* @returns {Promise<Blob>} PDF blob
*/
export async function htmlToPdfBlob(html) {
// Cria container temporario oculto para renderizar o HTML
const container = document.createElement('div');
container.style.cssText = `
position: fixed; left: -9999px; top: 0;
width: 794px;
background: white;
font-family: 'Segoe UI', Arial, sans-serif;
color: #1a1a1a;
`;
// 794px ≈ A4 width a 96dpi
// Injeta o HTML (extrai o body content se vier documento completo)
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
const styleMatch = html.match(/<style[^>]*>([\s\S]*?)<\/style>/i);
if (styleMatch) {
const style = document.createElement('style');
style.textContent = styleMatch[1];
container.appendChild(style);
}
const content = document.createElement('div');
content.innerHTML = bodyMatch ? bodyMatch[1] : html;
container.appendChild(content);
document.body.appendChild(container);
try {
const canvas = await html2canvas(container, {
scale: 1.5, // boa qualidade sem exagerar no tamanho
useCORS: true,
backgroundColor: '#ffffff',
width: 794,
windowWidth: 794
});
const imgData = canvas.toDataURL('image/jpeg', 0.85);
const pdf = new jsPDF('p', 'pt', 'a4');
const imgWidth = A4.width - (MARGIN * 2);
const imgHeight = (canvas.height * imgWidth) / canvas.width;
const pageHeight = A4.height - (MARGIN * 2);
let position = MARGIN;
let heightLeft = imgHeight;
pdf.addImage(imgData, 'JPEG', MARGIN, position, imgWidth, imgHeight, undefined, 'FAST');
heightLeft -= pageHeight;
while (heightLeft > 0) {
position = -(imgHeight - heightLeft) + MARGIN;
pdf.addPage();
pdf.addImage(imgData, 'JPEG', MARGIN, position, imgWidth, imgHeight, undefined, 'FAST');
heightLeft -= pageHeight;
}
return pdf.output('blob');
} finally {
document.body.removeChild(container);
}
}
/**
* Gera PDF e dispara download no browser.
*
* @param {string} html - HTML completo
* @param {string} filename - nome do arquivo
*/
export async function htmlToPdfDownload(html, filename = 'documento.pdf') {
const blob = await htmlToPdfBlob(html);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Gera PDF e abre em nova aba para impressao.
*
* @param {string} html - HTML completo
*/
export async function htmlToPdfOpen(html) {
const blob = await htmlToPdfBlob(html);
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
}

199
src/utils/validators.js Normal file
View File

@@ -0,0 +1,199 @@
/**
* Validadores e formatadores centralizados — AgenciaPsi
*
* Nomenclatura alinhada ao schema do banco:
* nome_completo, cpf, cpf_responsavel, telefone, telefone_alternativo,
* telefone_parente, telefone_responsavel, email_principal, email_alternativo, cep
*
* Regra do banco: CPF é armazenado como 11 dígitos (sem máscara).
* Telefones são armazenados como dígitos apenas.
*/
// ─── Utilidade base ────────────────────────────────────────────────────────────
/** Remove tudo que não for dígito */
export function digitsOnly(v) {
return String(v ?? '').replace(/\D/g, '')
}
// ─── CPF ───────────────────────────────────────────────────────────────────────
/**
* Valida CPF (com ou sem máscara).
* Retorna false para sequências repetidas (111.111.111-11) e para dígitos inválidos.
*/
export function isValidCPF(v) {
const d = digitsOnly(v)
if (d.length !== 11) return false
if (/^(\d)\1+$/.test(d)) return false // sequências iguais
const calcDV = (base) => {
let sum = 0
for (let i = 0; i < base.length; i++) sum += Number(base[i]) * (base.length + 1 - i)
const mod = sum % 11
return mod < 2 ? 0 : 11 - mod
}
const dv1 = calcDV(d.slice(0, 9))
if (Number(d[9]) !== dv1) return false
const dv2 = calcDV(d.slice(0, 10))
if (Number(d[10]) !== dv2) return false
return true
}
/** Formata CPF para exibição: 000.000.000-00 */
export function fmtCPF(v) {
const d = digitsOnly(v).slice(0, 11)
if (!d) return ''
return d
.replace(/^(\d{3})(\d)/, '$1.$2')
.replace(/^(\d{3})\.(\d{3})(\d)/, '$1.$2.$3')
.replace(/\.(\d{3})(\d)/, '.$1-$2')
}
/** Gera um CPF válido (útil para testes/seed) */
export function generateCPF() {
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min
const n = Array.from({ length: 9 }, () => randInt(0, 9))
const calcDV = (base) => {
let sum = 0
for (let i = 0; i < base.length; i++) sum += base[i] * (base.length + 1 - i)
const mod = sum % 11
return mod < 2 ? 0 : 11 - mod
}
const d1 = calcDV(n)
const d2 = calcDV([...n, d1])
const cpf = [...n, d1, d2].join('')
if (/^(\d)\1+$/.test(cpf)) return generateCPF()
return cpf
}
// ─── CNPJ ──────────────────────────────────────────────────────────────────────
/**
* Valida CNPJ (com ou sem máscara).
* Rejeita sequências repetidas (00.000.000/0000-00).
*/
export function isValidCNPJ(v) {
const d = digitsOnly(v)
if (d.length !== 14) return false
if (/^(\d)\1+$/.test(d)) return false
const calcDV = (base, weights) => {
let sum = 0
for (let i = 0; i < base.length; i++) sum += Number(base[i]) * weights[i]
const mod = sum % 11
return mod < 2 ? 0 : 11 - mod
}
const w1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
const w2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
if (Number(d[12]) !== calcDV(d.slice(0, 12), w1)) return false
if (Number(d[13]) !== calcDV(d.slice(0, 13), w2)) return false
return true
}
/** Formata CNPJ para exibição: 00.000.000/0000-00 */
export function fmtCNPJ(v) {
const d = digitsOnly(v).slice(0, 14)
if (!d) return ''
return d
.replace(/^(\d{2})(\d)/, '$1.$2')
.replace(/^(\d{2})\.(\d{3})(\d)/, '$1.$2.$3')
.replace(/\.(\d{3})(\d)/, '.$1/$2')
.replace(/(\d{4})(\d)/, '$1-$2')
}
// ─── RG ────────────────────────────────────────────────────────────────────────
/** Formata RG para exibição: 00.000.000-0 */
export function fmtRG(v) {
if (!v) return ''
const d = digitsOnly(v).slice(0, 9)
if (!d) return ''
return d
.replace(/^(\d{2})(\d)/, '$1.$2')
.replace(/^(\d{2})\.(\d{3})(\d)/, '$1.$2.$3')
.replace(/\.(\d{3})(\d)/, '.$1-$2')
}
// ─── Telefone ──────────────────────────────────────────────────────────────────
/**
* Valida telefone brasileiro (com ou sem máscara, com ou sem DDD).
* Aceita 10 dígitos (fixo) ou 11 dígitos (celular).
*/
export function isValidPhone(v) {
const d = digitsOnly(v)
return d.length === 10 || d.length === 11
}
/**
* Formata telefone para exibição.
* 11 dígitos → (XX) XXXXX-XXXX (celular)
* 10 dígitos → (XX) XXXX-XXXX (fixo)
*/
export function fmtPhone(v) {
const d = digitsOnly(v)
if (!d) return ''
if (d.length === 11) return d.replace(/^(\d{2})(\d{5})(\d{4})$/, '($1) $2-$3')
if (d.length === 10) return d.replace(/^(\d{2})(\d{4})(\d{4})$/, '($1) $2-$3')
return d
}
// ─── Email ─────────────────────────────────────────────────────────────────────
/** Valida email (formato básico) */
export function isValidEmail(v) {
const s = String(v ?? '').trim()
if (!s) return false
return /.+@.+\..+/.test(s)
}
// ─── CEP ───────────────────────────────────────────────────────────────────────
/** Valida CEP brasileiro: 8 dígitos */
export function isValidCEP(v) {
const d = digitsOnly(v)
return d.length === 8
}
/** Formata CEP para exibição: 00000-000 */
export function fmtCEP(v) {
const d = digitsOnly(v).slice(0, 8)
if (!d) return ''
return d.replace(/^(\d{5})(\d)/, '$1-$2')
}
// ─── Sanitização para o banco ──────────────────────────────────────────────────
/**
* Converte valor formatado para apenas dígitos antes de salvar no banco.
* Retorna null para valores vazios.
*/
export function sanitizeDigits(v) {
const d = digitsOnly(v)
return d || null
}
/**
* Converte data de DD/MM/YYYY ou DD-MM-YYYY para YYYY-MM-DD (formato ISO para o banco).
* Retorna null se inválido.
*/
export function toISODate(v) {
if (!v) return null
const s = String(v).trim()
const match = s.match(/^(\d{2})[/\-](\d{2})[/\-](\d{4})$/)
if (!match) return null
const [, dd, mm, yyyy] = match
const date = new Date(`${yyyy}-${mm}-${dd}`)
if (isNaN(date.getTime())) return null
return `${yyyy}-${mm}-${dd}`
}

File diff suppressed because it is too large Load Diff

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