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:
@@ -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;
|
||||
}
|
||||
@@ -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 || [];
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user