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:
@@ -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 \\\\\\))"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Atestado_Psicológico_1774873197838.pdf
Normal file
BIN
Atestado_Psicológico_1774873197838.pdf
Normal file
Binary file not shown.
BIN
Atestado_Psicológico_1774873520538.pdf
Normal file
BIN
Atestado_Psicológico_1774873520538.pdf
Normal file
Binary file not shown.
96
database-novo/README-GENERATE-DASHBOARD.md
Normal file
96
database-novo/README-GENERATE-DASHBOARD.md
Normal 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`)
|
||||||
489
database-novo/agenciapsi-db-dashboard.html
Normal file
489
database-novo/agenciapsi-db-dashboard.html
Normal file
File diff suppressed because one or more lines are too long
1651
database-novo/backups/2026-03-27/data.sql
Normal file
1651
database-novo/backups/2026-03-27/data.sql
Normal file
File diff suppressed because it is too large
Load Diff
21202
database-novo/backups/2026-03-27/full_dump.sql
Normal file
21202
database-novo/backups/2026-03-27/full_dump.sql
Normal file
File diff suppressed because it is too large
Load Diff
19202
database-novo/backups/2026-03-27/schema.sql
Normal file
19202
database-novo/backups/2026-03-27/schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
1799
database-novo/backups/2026-03-29/data.sql
Normal file
1799
database-novo/backups/2026-03-29/data.sql
Normal file
File diff suppressed because it is too large
Load Diff
22428
database-novo/backups/2026-03-29/full_dump.sql
Normal file
22428
database-novo/backups/2026-03-29/full_dump.sql
Normal file
File diff suppressed because it is too large
Load Diff
20279
database-novo/backups/2026-03-29/schema.sql
Normal file
20279
database-novo/backups/2026-03-29/schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
457
database-novo/generate-dashboard.cjs
Normal file
457
database-novo/generate-dashboard.cjs
Normal 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`);
|
||||||
57
database-novo/migrations/002_setup_wizard1_fields.sql
Normal file
57
database-novo/migrations/002_setup_wizard1_fields.sql
Normal 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';
|
||||||
33
database-novo/migrations/003_tenants_address_fields.sql
Normal file
33
database-novo/migrations/003_tenants_address_fields.sql
Normal 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)';
|
||||||
147
database-novo/migrations/20260328000001_create_medicos.sql
Normal file
147
database-novo/migrations/20260328000001_create_medicos.sql
Normal 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
|
||||||
|
-- ==========================================================================
|
||||||
119
database-novo/migrations/20260328000002_patients_new_columns.sql
Normal file
119
database-novo/migrations/20260328000002_patients_new_columns.sql
Normal 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: 08h–18h.';
|
||||||
|
|
||||||
|
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
|
||||||
|
-- ==========================================================================
|
||||||
@@ -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
|
||||||
|
-- ==========================================================================
|
||||||
@@ -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
|
||||||
|
-- ==========================================================================
|
||||||
@@ -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
|
||||||
|
-- ==========================================================================
|
||||||
@@ -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
|
||||||
|
-- ==========================================================================
|
||||||
@@ -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
|
||||||
|
-- ==========================================================================
|
||||||
661
database-novo/migrations/migration_patients.sql
Normal file
661
database-novo/migrations/migration_patients.sql
Normal 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
|
||||||
|
-- =============================================================================
|
||||||
@@ -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;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
-- ════════════════════════════════════════════════════════════════
|
-- ════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
230
database-novo/seeds/seed_015_document_templates.sql
Normal file
230
database-novo/seeds/seed_015_document_templates.sql
Normal 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
|
||||||
|
-- ==========================================================================
|
||||||
@@ -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 → 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">→ documents</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-file">saveGeneratedDocument() → 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 → editar variaveis (auto-preenchidas com dados paciente/sessao/terapeuta/clinica) → preview via iframe sandbox → "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>
|
||||||
@@ -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>
|
||||||
372
docs/architecture/Pacientes/cadastro_pacientes_levantamento.html
Normal file
372
docs/architecture/Pacientes/cadastro_pacientes_levantamento.html
Normal 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>
|
||||||
964
docs/architecture/Pacientes/pacientes_status_implementacao.html
Normal file
964
docs/architecture/Pacientes/pacientes_status_implementacao.html
Normal 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 → 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 → 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 → 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 ↔ 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 → 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 → 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 → 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) — 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 <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 → history</span></div>
|
||||||
|
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">status → timeline</span></div>
|
||||||
|
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">risco → 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 → 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>
|
||||||
20
index.html
20
index.html
@@ -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
445
mvp-assessment.html
Normal 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 · ~487 componentes Vue · 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">(1–2 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">(2–3 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
793
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
36
src/App.vue
36
src/App.vue
@@ -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(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
351
src/components/CadastroRapidoConvenio.vue
Normal file
351
src/components/CadastroRapidoConvenio.vue
Normal 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>
|
||||||
646
src/components/CadastroRapidoMedico.vue
Normal file
646
src/components/CadastroRapidoMedico.vue
Normal 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 & 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 só 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 — só renderiza quando há 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>
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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!"
|
||||||
|
|||||||
203
src/components/ui/JoditEmailEditor.vue
Normal file
203
src/components/ui/JoditEmailEditor.vue
Normal 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>
|
||||||
@@ -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">
|
||||||
|
|||||||
183
src/composables/useFormValidation.js
Normal file
183
src/composables/useFormValidation.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
297
src/features/documents/DocumentTemplatesPage.vue
Normal file
297
src/features/documents/DocumentTemplatesPage.vue
Normal 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>
|
||||||
377
src/features/documents/DocumentsListPage.vue
Normal file
377
src/features/documents/DocumentsListPage.vue
Normal 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>
|
||||||
159
src/features/documents/components/DocumentCard.vue
Normal file
159
src/features/documents/components/DocumentCard.vue
Normal 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>
|
||||||
266
src/features/documents/components/DocumentGenerateDialog.vue
Normal file
266
src/features/documents/components/DocumentGenerateDialog.vue
Normal 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>
|
||||||
174
src/features/documents/components/DocumentPreviewDialog.vue
Normal file
174
src/features/documents/components/DocumentPreviewDialog.vue
Normal 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>
|
||||||
245
src/features/documents/components/DocumentShareDialog.vue
Normal file
245
src/features/documents/components/DocumentShareDialog.vue
Normal 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>
|
||||||
306
src/features/documents/components/DocumentSignatureDialog.vue
Normal file
306
src/features/documents/components/DocumentSignatureDialog.vue
Normal 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>
|
||||||
123
src/features/documents/components/DocumentTagsInput.vue
Normal file
123
src/features/documents/components/DocumentTagsInput.vue
Normal 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>
|
||||||
207
src/features/documents/components/DocumentTemplateEditor.vue
Normal file
207
src/features/documents/components/DocumentTemplateEditor.vue
Normal 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">{{</span>
|
||||||
|
{{ v.label }}
|
||||||
|
<span class="font-mono text-[0.65rem] opacity-60">}}</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>
|
||||||
279
src/features/documents/components/DocumentUploadDialog.vue
Normal file
279
src/features/documents/components/DocumentUploadDialog.vue
Normal 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>
|
||||||
197
src/features/documents/composables/useDocumentGenerate.js
Normal file
197
src/features/documents/composables/useDocumentGenerate.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
213
src/features/documents/composables/useDocumentTemplates.js
Normal file
213
src/features/documents/composables/useDocumentTemplates.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
231
src/features/documents/composables/useDocuments.js
Normal file
231
src/features/documents/composables/useDocuments.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
965
src/features/patients/cadastro/PatientsCadastroPage - Bkp.vue
Normal file
965
src/features/patients/cadastro/PatientsCadastroPage - Bkp.vue
Normal 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>
|
||||||
679
src/features/patients/cadastro/PatientsCadastroPage--preview.vue
Normal file
679
src/features/patients/cadastro/PatientsCadastroPage--preview.vue
Normal 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: '08h–20h',
|
||||||
|
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$ {{ 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 & 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
971
src/features/patients/medicos/MedicosPage.vue
Normal file
971
src/features/patients/medicos/MedicosPage.vue
Normal 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>
|
||||||
1101
src/features/patients/prontuario/PatientProntuario - design1.vue
Normal file
1101
src/features/patients/prontuario/PatientProntuario - design1.vue
Normal file
File diff suppressed because it is too large
Load Diff
1101
src/features/patients/prontuario/PatientProntuario - design2.vue
Normal file
1101
src/features/patients/prontuario/PatientProntuario - design2.vue
Normal file
File diff suppressed because it is too large
Load Diff
667
src/features/patients/prontuario/PatientProntuario - design3.vue
Normal file
667
src/features/patients/prontuario/PatientProntuario - design3.vue
Normal 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: '08h–18h',
|
||||||
|
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 & 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 há</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
1807
src/features/setup/SetupWizardPage - Copia.vue
Normal file
1807
src/features/setup/SetupWizardPage - Copia.vue
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ─────────────
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">Só 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">Só 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
115
src/main.js
115
src/main.js
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
// ======================================================
|
// ======================================================
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
// ======================================================
|
// ======================================================
|
||||||
|
|||||||
144
src/services/DocumentAuditLog.service.js
Normal file
144
src/services/DocumentAuditLog.service.js
Normal 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;
|
||||||
|
}
|
||||||
386
src/services/DocumentGenerate.service.js
Normal file
386
src/services/DocumentGenerate.service.js
Normal 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 || [];
|
||||||
|
}
|
||||||
166
src/services/DocumentShareLinks.service.js
Normal file
166
src/services/DocumentShareLinks.service.js
Normal 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}`;
|
||||||
|
}
|
||||||
172
src/services/DocumentSignatures.service.js
Normal file
172
src/services/DocumentSignatures.service.js
Normal 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;
|
||||||
|
}
|
||||||
247
src/services/DocumentTemplates.service.js
Normal file
247
src/services/DocumentTemplates.service.js
Normal 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)];
|
||||||
|
}
|
||||||
313
src/services/Documents.service.js
Normal file
313
src/services/Documents.service.js
Normal 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'));
|
||||||
|
}
|
||||||
224
src/services/Medicos.service.js
Normal file
224
src/services/Medicos.service.js
Normal 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
113
src/services/pdf.service.js
Normal 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
199
src/utils/validators.js
Normal 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}`
|
||||||
|
}
|
||||||
1074
src/views/pages/account/Negociopage.vue
Normal file
1074
src/views/pages/account/Negociopage.vue
Normal file
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
Reference in New Issue
Block a user