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
+144
View File
@@ -0,0 +1,144 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/services/DocumentAuditLog.service.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
async function getActiveTenantId(uid) {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('user_id', uid)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.single();
if (error) throw error;
if (!data?.tenant_id) throw new Error('Tenant não encontrado.');
return data.tenant_id;
}
// ── Registrar acesso ────────────────────────────────────────
/**
* Registra acesso a um documento (visualizacao, download, etc.).
* Tabela imutavel — somente INSERT.
*
* @param {string} documentoId
* @param {string} acao - 'visualizou' | 'baixou' | 'imprimiu' | 'compartilhou' | 'assinou'
*/
export async function logAccess(documentoId, acao) {
if (!documentoId || !acao) return;
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const { error } = await supabase
.from('document_access_logs')
.insert({
documento_id: documentoId,
tenant_id: tenantId,
acao,
user_id: ownerId
});
// Nao lancar erro para nao interromper o fluxo principal
if (error) console.error('[DocumentAuditLog] Erro ao registrar acesso:', error.message);
}
// ── Listar historico de acessos ─────────────────────────────
/**
* Retorna historico de acessos de um documento.
*/
export async function listAccessLogs(documentoId) {
if (!documentoId) return [];
const { data, error } = await supabase
.from('document_access_logs')
.select('*, profiles:user_id(full_name)')
.eq('documento_id', documentoId)
.order('acessado_em', { ascending: false });
if (error) throw error;
return data || [];
}
/**
* Retorna historico de acessos de todos os documentos do tenant.
* Util para auditoria geral.
*
* @param {object} filters - { dataInicio, dataFim, acao, userId }
* @param {number} limit - maximo de registros (default 100)
*/
export async function listAllAccessLogs(filters = {}, limit = 100) {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
let query = supabase
.from('document_access_logs')
.select('*, profiles:user_id(full_name), documents:documento_id(nome_original, patient_id)')
.eq('tenant_id', tenantId)
.order('acessado_em', { ascending: false })
.limit(limit);
if (filters.acao) {
query = query.eq('acao', filters.acao);
}
if (filters.userId) {
query = query.eq('user_id', filters.userId);
}
if (filters.dataInicio) {
query = query.gte('acessado_em', filters.dataInicio);
}
if (filters.dataFim) {
query = query.lte('acessado_em', filters.dataFim);
}
const { data, error } = await query;
if (error) throw error;
return data || [];
}
/**
* Conta acessos por tipo de acao para um documento.
* Util para exibir badges (ex: "visualizado 5x, baixado 2x").
*/
export async function countAccessByAction(documentoId) {
if (!documentoId) return {};
const { data, error } = await supabase
.from('document_access_logs')
.select('acao')
.eq('documento_id', documentoId);
if (error) throw error;
const counts = {};
for (const row of data || []) {
counts[row.acao] = (counts[row.acao] || 0) + 1;
}
return counts;
}
+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 || [];
}
+166
View File
@@ -0,0 +1,166 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/services/DocumentShareLinks.service.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
async function getActiveTenantId(uid) {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('user_id', uid)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.single();
if (error) throw error;
if (!data?.tenant_id) throw new Error('Tenant não encontrado.');
return data.tenant_id;
}
// ── Criar link temporario ───────────────────────────────────
/**
* Gera link temporario para compartilhar documento com profissional externo.
*
* @param {string} documentoId
* @param {object} opts - { expiracaoHoras: 48, usosMax: 5 }
* @returns {object} registro com token para montar a URL
*/
export async function createShareLink(documentoId, opts = {}) {
if (!documentoId) throw new Error('Documento não informado.');
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const expiracaoHoras = opts.expiracaoHoras || 48;
const expiraEm = new Date();
expiraEm.setHours(expiraEm.getHours() + expiracaoHoras);
const { data, error } = await supabase
.from('document_share_links')
.insert({
documento_id: documentoId,
tenant_id: tenantId,
expira_em: expiraEm.toISOString(),
usos_max: opts.usosMax || 5,
criado_por: ownerId
})
.select('*')
.single();
if (error) throw error;
return data;
}
// ── Listar links de um documento ────────────────────────────
export async function listShareLinks(documentoId) {
if (!documentoId) return [];
const ownerId = await getOwnerId();
const { data, error } = await supabase
.from('document_share_links')
.select('*')
.eq('documento_id', documentoId)
.eq('criado_por', ownerId)
.order('criado_em', { ascending: false });
if (error) throw error;
return data || [];
}
// ── Validar token (acesso publico) ──────────────────────────
/**
* Valida token de compartilhamento e retorna dados do documento.
* Incrementa o contador de usos.
*
* @param {string} token
* @returns {object|null} - { link, document } ou null se invalido/expirado
*/
export async function validateShareToken(token) {
if (!token) return null;
// Buscar link ativo
const { data: link, error } = await supabase
.from('document_share_links')
.select('*')
.eq('token', token)
.eq('ativo', true)
.single();
if (error || !link) return null;
// Verificar expiracao
if (new Date(link.expira_em) < new Date()) return null;
// Verificar limite de usos
if (link.usos >= link.usos_max) return null;
// Incrementar uso
await supabase
.from('document_share_links')
.update({ usos: link.usos + 1 })
.eq('id', link.id);
// Buscar documento
const { data: doc } = await supabase
.from('documents')
.select('id, nome_original, mime_type, bucket_path, storage_bucket')
.eq('id', link.documento_id)
.single();
return { link, document: doc };
}
// ── Desativar link ──────────────────────────────────────────
export async function deactivateShareLink(linkId) {
if (!linkId) throw new Error('ID inválido.');
const ownerId = await getOwnerId();
const { error } = await supabase
.from('document_share_links')
.update({ ativo: false })
.eq('id', linkId)
.eq('criado_por', ownerId);
if (error) throw error;
return true;
}
// ── Montar URL publica ──────────────────────────────────────
/**
* Monta a URL de compartilhamento a partir do token.
* A rota publica deve ser configurada no router.
*/
export function buildShareUrl(token) {
const base = window.location.origin;
return `${base}/shared/document/${token}`;
}
+172
View File
@@ -0,0 +1,172 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/services/DocumentSignatures.service.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
async function getActiveTenantId(uid) {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('user_id', uid)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.single();
if (error) throw error;
if (!data?.tenant_id) throw new Error('Tenant não encontrado.');
return data.tenant_id;
}
// ── Hash do documento ───────────────────────────────────────
/**
* Gera hash SHA-256 de um ArrayBuffer (conteudo do arquivo).
*/
export async function hashDocument(arrayBuffer) {
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// ── Criar solicitacao de assinatura ─────────────────────────
/**
* Cria uma ou mais solicitacoes de assinatura para um documento.
*
* @param {string} documentoId - UUID do documento
* @param {Array} signatarios - [{ tipo, nome, email, id? }]
* tipo: 'paciente' | 'responsavel_legal' | 'terapeuta'
*/
export async function createSignatureRequests(documentoId, signatarios = []) {
if (!documentoId) throw new Error('Documento não informado.');
if (!signatarios.length) throw new Error('Ao menos um signatário é necessário.');
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const rows = signatarios.map((s, idx) => ({
documento_id: documentoId,
tenant_id: tenantId,
signatario_tipo: s.tipo || 'paciente',
signatario_id: s.id || null,
signatario_nome: s.nome || null,
signatario_email: s.email || null,
ordem: idx + 1,
status: 'pendente'
}));
const { data, error } = await supabase
.from('document_signatures')
.insert(rows)
.select('*');
if (error) throw error;
return data || [];
}
// ── Registrar assinatura ────────────────────────────────────
/**
* Registra que um signatario assinou o documento.
*
* @param {string} signatureId - UUID da solicitacao de assinatura
* @param {object} meta - { ip, user_agent, hash_documento }
*/
export async function registerSignature(signatureId, meta = {}) {
if (!signatureId) throw new Error('ID da assinatura inválido.');
const { data, error } = await supabase
.from('document_signatures')
.update({
status: 'assinado',
ip: meta.ip || null,
user_agent: meta.user_agent || null,
assinado_em: new Date().toISOString(),
hash_documento: meta.hash_documento || null
})
.eq('id', signatureId)
.select('*')
.single();
if (error) throw error;
return data;
}
// ── Listar assinaturas de um documento ──────────────────────
export async function listSignatures(documentoId) {
if (!documentoId) return [];
const { data, error } = await supabase
.from('document_signatures')
.select('*')
.eq('documento_id', documentoId)
.order('ordem', { ascending: true });
if (error) throw error;
return data || [];
}
// ── Status geral do documento ───────────────────────────────
/**
* Retorna o status consolidado de assinaturas de um documento.
*
* @returns {{ total, assinados, pendentes, status }}
* status: 'completo' | 'parcial' | 'pendente' | 'sem_assinaturas'
*/
export async function getSignatureStatus(documentoId) {
const sigs = await listSignatures(documentoId);
if (!sigs.length) return { total: 0, assinados: 0, pendentes: 0, status: 'sem_assinaturas' };
const assinados = sigs.filter(s => s.status === 'assinado').length;
const pendentes = sigs.length - assinados;
let status = 'pendente';
if (assinados === sigs.length) status = 'completo';
else if (assinados > 0) status = 'parcial';
return { total: sigs.length, assinados, pendentes, status };
}
// ── Recusar assinatura ──────────────────────────────────────
export async function refuseSignature(signatureId) {
if (!signatureId) throw new Error('ID da assinatura inválido.');
const { data, error } = await supabase
.from('document_signatures')
.update({
status: 'recusado',
atualizado_em: new Date().toISOString()
})
.eq('id', signatureId)
.select('*')
.single();
if (error) throw error;
return data;
}
+247
View File
@@ -0,0 +1,247 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/services/DocumentTemplates.service.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
async function getActiveTenantId(uid) {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('user_id', uid)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.single();
if (error) throw error;
if (!data?.tenant_id) throw new Error('Tenant não encontrado.');
return data.tenant_id;
}
// ── Variaveis disponíveis ───────────────────────────────────
/**
* Variaveis que podem ser usadas nos templates.
* Cada variavel tem: key, label (pt-BR), grupo.
*/
export const TEMPLATE_VARIABLES = [
// Paciente
{ key: 'paciente_nome', label: 'Nome do paciente', grupo: 'Paciente' },
{ key: 'paciente_nome_social', label: 'Nome social', grupo: 'Paciente' },
{ key: 'paciente_cpf', label: 'CPF do paciente', grupo: 'Paciente' },
{ key: 'paciente_data_nascimento', label: 'Data de nascimento', grupo: 'Paciente' },
{ key: 'paciente_telefone', label: 'Telefone do paciente', grupo: 'Paciente' },
{ key: 'paciente_email', label: 'E-mail do paciente', grupo: 'Paciente' },
{ key: 'paciente_endereco', label: 'Endereço do paciente', grupo: 'Paciente' },
// Sessao
{ key: 'data_sessao', label: 'Data da sessão', grupo: 'Sessão' },
{ key: 'hora_inicio', label: 'Hora início', grupo: 'Sessão' },
{ key: 'hora_fim', label: 'Hora fim', grupo: 'Sessão' },
{ key: 'modalidade', label: 'Modalidade (presencial/online)', grupo: 'Sessão' },
// Terapeuta
{ key: 'terapeuta_nome', label: 'Nome do terapeuta', grupo: 'Terapeuta' },
{ key: 'terapeuta_crp', label: 'CRP do terapeuta', grupo: 'Terapeuta' },
{ key: 'terapeuta_email', label: 'E-mail do terapeuta', grupo: 'Terapeuta' },
{ key: 'terapeuta_telefone', label: 'Telefone do terapeuta', grupo: 'Terapeuta' },
// Clinica
{ key: 'clinica_nome', label: 'Nome da clínica', grupo: 'Clínica' },
{ key: 'clinica_endereco', label: 'Endereço da clínica', grupo: 'Clínica' },
{ key: 'clinica_telefone', label: 'Telefone da clínica', grupo: 'Clínica' },
{ key: 'clinica_cnpj', label: 'CNPJ da clínica', grupo: 'Clínica' },
// Financeiro
{ key: 'valor', label: 'Valor (R$)', grupo: 'Financeiro' },
{ key: 'valor_extenso', label: 'Valor por extenso', grupo: 'Financeiro' },
{ key: 'forma_pagamento', label: 'Forma de pagamento', grupo: 'Financeiro' },
// Datas
{ key: 'data_atual', label: 'Data atual', grupo: 'Datas' },
{ key: 'data_atual_extenso', label: 'Data atual por extenso', grupo: 'Datas' },
{ key: 'cidade_estado', label: 'Cidade/UF', grupo: 'Datas' }
];
// ── List ────────────────────────────────────────────────────
/**
* Lista templates disponíveis: globais + do tenant do usuario.
*/
export async function listTemplates() {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const { data, error } = await supabase
.from('document_templates')
.select('*')
.or(`is_global.eq.true,tenant_id.eq.${tenantId}`)
.eq('ativo', true)
.order('nome_template', { ascending: true });
if (error) throw error;
return data || [];
}
/**
* Lista todos os templates (incluindo inativos) — para pagina de gestao.
*/
export async function listAllTemplates() {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const { data, error } = await supabase
.from('document_templates')
.select('*')
.or(`is_global.eq.true,tenant_id.eq.${tenantId}`)
.order('is_global', { ascending: false })
.order('nome_template', { ascending: true });
if (error) throw error;
return data || [];
}
// ── Get one ─────────────────────────────────────────────────
export async function getTemplate(id) {
const { data, error } = await supabase
.from('document_templates')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
return data;
}
// ── Create ──────────────────────────────────────────────────
export async function createTemplate(payload) {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const nome = String(payload.nome_template || '').trim();
if (!nome) throw new Error('Nome do template é obrigatório.');
const row = {
owner_id: ownerId,
tenant_id: tenantId,
nome_template: nome,
tipo: payload.tipo || 'outro',
descricao: payload.descricao || null,
corpo_html: payload.corpo_html || '',
cabecalho_html: payload.cabecalho_html || null,
rodape_html: payload.rodape_html || null,
variaveis: payload.variaveis || [],
logo_url: payload.logo_url || null,
is_global: false,
ativo: true
};
const { data, error } = await supabase
.from('document_templates')
.insert(row)
.select('*')
.single();
if (error) throw error;
return data;
}
// ── Update ──────────────────────────────────────────────────
export async function updateTemplate(id, payload) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const row = {};
if (payload.nome_template !== undefined) row.nome_template = String(payload.nome_template).trim();
if (payload.tipo !== undefined) row.tipo = payload.tipo;
if (payload.descricao !== undefined) row.descricao = payload.descricao;
if (payload.corpo_html !== undefined) row.corpo_html = payload.corpo_html;
if (payload.cabecalho_html !== undefined) row.cabecalho_html = payload.cabecalho_html;
if (payload.rodape_html !== undefined) row.rodape_html = payload.rodape_html;
if (payload.variaveis !== undefined) row.variaveis = payload.variaveis;
if (payload.logo_url !== undefined) row.logo_url = payload.logo_url;
if (payload.ativo !== undefined) row.ativo = payload.ativo;
row.updated_at = new Date().toISOString();
const { data, error } = await supabase
.from('document_templates')
.update(row)
.eq('id', id)
.eq('owner_id', ownerId)
.select('*')
.single();
if (error) throw error;
return data;
}
// ── Delete (soft) ───────────────────────────────────────────
export async function deleteTemplate(id) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const { error } = await supabase
.from('document_templates')
.update({ ativo: false, updated_at: new Date().toISOString() })
.eq('id', id)
.eq('owner_id', ownerId);
if (error) throw error;
return true;
}
// ── Duplicate ───────────────────────────────────────────────
export async function duplicateTemplate(id) {
const original = await getTemplate(id);
if (!original) throw new Error('Template não encontrado.');
return createTemplate({
nome_template: original.nome_template + ' (cópia)',
tipo: original.tipo,
descricao: original.descricao,
corpo_html: original.corpo_html,
cabecalho_html: original.cabecalho_html,
rodape_html: original.rodape_html,
variaveis: original.variaveis,
logo_url: original.logo_url
});
}
// ── Extrair variaveis do HTML ───────────────────────────────
/**
* Extrai variaveis {{nome}} do corpo HTML de um template.
*/
export function extractVariablesFromHtml(html) {
const matches = String(html || '').match(/\{\{(\w+)\}\}/g) || [];
const keys = matches.map(m => m.replace(/\{\{|\}\}/g, ''));
return [...new Set(keys)];
}
+313
View File
@@ -0,0 +1,313 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/services/Documents.service.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
const BUCKET = 'documents';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
async function getActiveTenantId(uid) {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('user_id', uid)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.single();
if (error) throw error;
if (!data?.tenant_id) throw new Error('Tenant não encontrado.');
return data.tenant_id;
}
function buildStoragePath(tenantId, patientId, fileName) {
const timestamp = Date.now();
const safe = String(fileName || 'arquivo').replace(/[^a-zA-Z0-9._-]/g, '_');
return `${tenantId}/${patientId}/${timestamp}-${safe}`;
}
// ── Upload ──────────────────────────────────────────────────
/**
* Faz upload de arquivo ao Storage e registra na tabela documents.
*
* @param {File} file - Objeto File do input
* @param {string} patientId - UUID do paciente
* @param {object} meta - { tipo_documento, categoria, descricao, tags[], agenda_evento_id, visibilidade }
* @returns {object} - Registro criado em documents
*/
export async function uploadDocument(file, patientId, meta = {}) {
if (!file) throw new Error('Nenhum arquivo selecionado.');
if (!patientId) throw new Error('Paciente não informado.');
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
// Upload ao Storage
const path = buildStoragePath(tenantId, patientId, file.name);
const { error: upErr } = await supabase.storage
.from(BUCKET)
.upload(path, file, { contentType: file.type });
if (upErr) throw upErr;
// Insert na tabela
const row = {
owner_id: ownerId,
tenant_id: tenantId,
patient_id: patientId,
bucket_path: path,
storage_bucket: BUCKET,
nome_original: file.name,
mime_type: file.type || null,
tamanho_bytes: file.size || null,
tipo_documento: meta.tipo_documento || 'outro',
categoria: meta.categoria || null,
descricao: meta.descricao || null,
tags: meta.tags || [],
agenda_evento_id: meta.agenda_evento_id || null,
visibilidade: meta.visibilidade || 'privado',
compartilhado_portal: meta.compartilhado_portal || false,
compartilhado_supervisor: meta.compartilhado_supervisor || false,
enviado_pelo_paciente: meta.enviado_pelo_paciente || false,
status_revisao: meta.enviado_pelo_paciente ? 'pendente' : 'aprovado',
uploaded_by: ownerId
};
const { data, error } = await supabase
.from('documents')
.insert(row)
.select('*')
.single();
if (error) {
// Tenta limpar o arquivo do Storage em caso de erro no insert
await supabase.storage.from(BUCKET).remove([path]).catch(() => {});
throw error;
}
return data;
}
// ── List ────────────────────────────────────────────────────
/**
* Lista documentos de um paciente (excluindo soft-deleted).
*
* @param {string} patientId
* @param {object} filters - { tipo_documento, categoria, tag, search }
*/
export async function listDocuments(patientId, filters = {}) {
const ownerId = await getOwnerId();
let query = supabase
.from('documents')
.select('*')
.eq('owner_id', ownerId)
.eq('patient_id', patientId)
.is('deleted_at', null)
.order('uploaded_at', { ascending: false });
if (filters.tipo_documento) {
query = query.eq('tipo_documento', filters.tipo_documento);
}
if (filters.categoria) {
query = query.eq('categoria', filters.categoria);
}
if (filters.tag) {
query = query.contains('tags', [filters.tag]);
}
if (filters.search) {
query = query.ilike('nome_original', `%${filters.search}%`);
}
const { data, error } = await query;
if (error) throw error;
return data || [];
}
/**
* Lista todos os documentos do owner (todos os pacientes).
*/
export async function listAllDocuments(filters = {}) {
const ownerId = await getOwnerId();
let query = supabase
.from('documents')
.select('*, patients!inner(nome_completo)')
.eq('owner_id', ownerId)
.is('deleted_at', null)
.order('uploaded_at', { ascending: false });
if (filters.tipo_documento) {
query = query.eq('tipo_documento', filters.tipo_documento);
}
if (filters.search) {
query = query.ilike('nome_original', `%${filters.search}%`);
}
const { data, error } = await query;
if (error) throw error;
return data || [];
}
// ── Get one ─────────────────────────────────────────────────
export async function getDocument(id) {
const ownerId = await getOwnerId();
const { data, error } = await supabase
.from('documents')
.select('*')
.eq('id', id)
.eq('owner_id', ownerId)
.single();
if (error) throw error;
return data;
}
// ── Update ──────────────────────────────────────────────────
export async function updateDocument(id, payload) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const row = {};
if (payload.tipo_documento !== undefined) row.tipo_documento = payload.tipo_documento;
if (payload.categoria !== undefined) row.categoria = payload.categoria;
if (payload.descricao !== undefined) row.descricao = payload.descricao;
if (payload.tags !== undefined) row.tags = payload.tags;
if (payload.visibilidade !== undefined) row.visibilidade = payload.visibilidade;
if (payload.compartilhado_portal !== undefined) row.compartilhado_portal = payload.compartilhado_portal;
if (payload.compartilhado_supervisor !== undefined) row.compartilhado_supervisor = payload.compartilhado_supervisor;
if (payload.status_revisao !== undefined) {
row.status_revisao = payload.status_revisao;
row.revisado_por = ownerId;
row.revisado_em = new Date().toISOString();
}
row.updated_at = new Date().toISOString();
const { data, error } = await supabase
.from('documents')
.update(row)
.eq('id', id)
.eq('owner_id', ownerId)
.select('*')
.single();
if (error) throw error;
return data;
}
// ── Soft Delete ─────────────────────────────────────────────
/**
* Soft delete com retencao. O arquivo permanece no Storage.
* retencaoAnos: numero de anos de retencao (padrao 5 — CFP).
*/
export async function softDeleteDocument(id, retencaoAnos = 5) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const retencaoAte = new Date();
retencaoAte.setFullYear(retencaoAte.getFullYear() + retencaoAnos);
const { error } = await supabase
.from('documents')
.update({
deleted_at: new Date().toISOString(),
deleted_by: ownerId,
retencao_ate: retencaoAte.toISOString(),
updated_at: new Date().toISOString()
})
.eq('id', id)
.eq('owner_id', ownerId);
if (error) throw error;
return true;
}
/**
* Restaurar documento soft-deleted.
*/
export async function restoreDocument(id) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const { error } = await supabase
.from('documents')
.update({
deleted_at: null,
deleted_by: null,
retencao_ate: null,
updated_at: new Date().toISOString()
})
.eq('id', id)
.eq('owner_id', ownerId);
if (error) throw error;
return true;
}
// ── Download URL ────────────────────────────────────────────
/**
* Gera URL assinada para download (valida por 60s por padrao).
*/
export async function getDownloadUrl(bucketPath, expiresIn = 60, bucket = BUCKET) {
const { data, error } = await supabase.storage
.from(bucket)
.createSignedUrl(bucketPath, expiresIn);
if (error) throw error;
return data?.signedUrl;
}
// ── Tags (autocomplete) ────────────────────────────────────
/**
* Retorna tags unicas ja usadas pelo owner (para autocomplete).
*/
export async function getUsedTags() {
const ownerId = await getOwnerId();
const { data, error } = await supabase
.from('documents')
.select('tags')
.eq('owner_id', ownerId)
.is('deleted_at', null);
if (error) throw error;
const set = new Set();
for (const row of data || []) {
for (const tag of row.tags || []) {
if (tag) set.add(tag);
}
}
return [...set].sort((a, b) => a.localeCompare(b, 'pt-BR'));
}
+224
View File
@@ -0,0 +1,224 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/services/Medicos.service.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
async function getActiveTenantId(uid) {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('user_id', uid)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.single();
if (error) throw error;
if (!data?.tenant_id) throw new Error('Tenant não encontrado.');
return data.tenant_id;
}
function normalizeNome(s) {
return String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
}
function isUniqueViolation(err) {
if (!err) return false;
if (err.code === '23505') return true;
return /duplicate key value violates unique constraint/i.test(String(err.message || ''));
}
// ── List ─────────────────────────────────────────────────────
/**
* Lista médicos ativos do owner com contagem de pacientes encaminhados.
* A contagem é feita buscando quantos patients possuem o nome do médico
* no campo `encaminhado_por` (text).
*/
export async function listMedicosWithPatientCounts() {
const ownerId = await getOwnerId();
const { data: medicos, error } = await supabase
.from('medicos')
.select('id, nome, crm, especialidade, telefone_profissional, telefone_pessoal, email, clinica, cidade, estado, observacoes, ativo, owner_id, tenant_id, created_at, updated_at')
.eq('owner_id', ownerId)
.eq('ativo', true)
.order('nome', { ascending: true });
if (error) throw error;
// Busca pacientes do owner para contar encaminhamentos por médico
const { data: patients, error: pErr } = await supabase
.from('patients')
.select('id, encaminhado_por')
.eq('owner_id', ownerId);
if (pErr) throw pErr;
const countMap = new Map();
for (const med of medicos || []) {
countMap.set(med.id, 0);
}
for (const p of patients || []) {
const enc = String(p.encaminhado_por || '').toLowerCase();
if (!enc) continue;
for (const med of medicos || []) {
const nomeLower = med.nome.toLowerCase();
if (enc.includes(nomeLower)) {
countMap.set(med.id, (countMap.get(med.id) || 0) + 1);
}
}
}
return (medicos || []).map((m) => ({
...m,
patients_count: countMap.get(m.id) || 0
}));
}
// ── Create ───────────────────────────────────────────────────
export async function createMedico(payload) {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const nome = String(payload.nome || '').trim();
if (!nome) throw new Error('Nome do médico é obrigatório.');
const row = {
owner_id: ownerId,
tenant_id: tenantId,
nome,
crm: String(payload.crm || '').trim() || null,
especialidade: payload.especialidade || null,
telefone_profissional: payload.telefone_profissional || null,
telefone_pessoal: payload.telefone_pessoal || null,
email: String(payload.email || '').trim() || null,
clinica: String(payload.clinica || '').trim() || null,
cidade: String(payload.cidade || '').trim() || null,
estado: String(payload.estado || '').trim() || null,
observacoes: String(payload.observacoes || '').trim() || null,
ativo: true
};
const { data, error } = await supabase
.from('medicos')
.insert(row)
.select('*')
.single();
if (error) {
if (isUniqueViolation(error)) throw new Error('Já existe um médico com este CRM.');
throw error;
}
return data;
}
// ── Update ───────────────────────────────────────────────────
export async function updateMedico(id, payload) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const nome = String(payload.nome || '').trim();
if (!nome) throw new Error('Nome do médico é obrigatório.');
const row = {
nome,
crm: String(payload.crm || '').trim() || null,
especialidade: payload.especialidade || null,
telefone_profissional: payload.telefone_profissional || null,
telefone_pessoal: payload.telefone_pessoal || null,
email: String(payload.email || '').trim() || null,
clinica: String(payload.clinica || '').trim() || null,
cidade: String(payload.cidade || '').trim() || null,
estado: String(payload.estado || '').trim() || null,
observacoes: String(payload.observacoes || '').trim() || null,
updated_at: new Date().toISOString()
};
const { data, error } = await supabase
.from('medicos')
.update(row)
.eq('id', id)
.eq('owner_id', ownerId)
.select('*')
.single();
if (error) {
if (isUniqueViolation(error)) throw new Error('Já existe um médico com este CRM.');
throw error;
}
return data;
}
// ── Delete (soft) ────────────────────────────────────────────
export async function deleteMedico(id) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const { error } = await supabase
.from('medicos')
.update({ ativo: false, updated_at: new Date().toISOString() })
.eq('id', id)
.eq('owner_id', ownerId);
if (error) throw error;
return true;
}
// ── Pacientes de um médico ───────────────────────────────────
/**
* Busca pacientes do owner cujo campo `encaminhado_por` contém o nome do médico.
*/
export async function fetchPatientsByMedicoNome(medicoNome) {
const ownerId = await getOwnerId();
const nomeLower = String(medicoNome || '').trim().toLowerCase();
if (!nomeLower) return [];
const { data, error } = await supabase
.from('patients')
.select('id, nome_completo, email_principal, telefone, avatar_url, encaminhado_por')
.eq('owner_id', ownerId)
.ilike('encaminhado_por', `%${nomeLower}%`);
if (error) throw error;
return (data || [])
.map((p) => ({
id: p.id,
full_name: p.nome_completo || '—',
email: p.email_principal || '—',
phone: p.telefone || '—',
avatar_url: p.avatar_url || null,
encaminhado_por: p.encaminhado_por || ''
}))
.sort((a, b) => String(a.full_name).localeCompare(String(b.full_name), 'pt-BR'));
}
+113
View File
@@ -0,0 +1,113 @@
/*
|--------------------------------------------------------------------------
| PDF SERVICE — jsPDF + html2canvas
|--------------------------------------------------------------------------
|
| Gera PDF a partir de HTML renderizado no browser.
| Retorna Blob para download local e upload ao Storage.
|
*/
import { jsPDF } from 'jspdf';
import html2canvas from 'html2canvas-pro';
const A4 = { width: 595.28, height: 841.89 }; // pontos (72dpi)
const MARGIN = 40; // pontos
/**
* Renderiza HTML completo em um Blob PDF.
*
* @param {string} html - HTML completo do documento (com <html>, <style>, etc.)
* @returns {Promise<Blob>} PDF blob
*/
export async function htmlToPdfBlob(html) {
// Cria container temporario oculto para renderizar o HTML
const container = document.createElement('div');
container.style.cssText = `
position: fixed; left: -9999px; top: 0;
width: 794px;
background: white;
font-family: 'Segoe UI', Arial, sans-serif;
color: #1a1a1a;
`;
// 794px ≈ A4 width a 96dpi
// Injeta o HTML (extrai o body content se vier documento completo)
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
const styleMatch = html.match(/<style[^>]*>([\s\S]*?)<\/style>/i);
if (styleMatch) {
const style = document.createElement('style');
style.textContent = styleMatch[1];
container.appendChild(style);
}
const content = document.createElement('div');
content.innerHTML = bodyMatch ? bodyMatch[1] : html;
container.appendChild(content);
document.body.appendChild(container);
try {
const canvas = await html2canvas(container, {
scale: 1.5, // boa qualidade sem exagerar no tamanho
useCORS: true,
backgroundColor: '#ffffff',
width: 794,
windowWidth: 794
});
const imgData = canvas.toDataURL('image/jpeg', 0.85);
const pdf = new jsPDF('p', 'pt', 'a4');
const imgWidth = A4.width - (MARGIN * 2);
const imgHeight = (canvas.height * imgWidth) / canvas.width;
const pageHeight = A4.height - (MARGIN * 2);
let position = MARGIN;
let heightLeft = imgHeight;
pdf.addImage(imgData, 'JPEG', MARGIN, position, imgWidth, imgHeight, undefined, 'FAST');
heightLeft -= pageHeight;
while (heightLeft > 0) {
position = -(imgHeight - heightLeft) + MARGIN;
pdf.addPage();
pdf.addImage(imgData, 'JPEG', MARGIN, position, imgWidth, imgHeight, undefined, 'FAST');
heightLeft -= pageHeight;
}
return pdf.output('blob');
} finally {
document.body.removeChild(container);
}
}
/**
* Gera PDF e dispara download no browser.
*
* @param {string} html - HTML completo
* @param {string} filename - nome do arquivo
*/
export async function htmlToPdfDownload(html, filename = 'documento.pdf') {
const blob = await htmlToPdfBlob(html);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Gera PDF e abre em nova aba para impressao.
*
* @param {string} html - HTML completo
*/
export async function htmlToPdfOpen(html) {
const blob = await htmlToPdfBlob(html);
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
}