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

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