/* |-------------------------------------------------------------------------- | 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'; import { valorExtenso } from '@/utils/valorExtenso'; const BUCKET = 'generated-docs'; // Mapa tipo do template → tipo_documento (CHECK constraint de documents). // Recibos vão pra 'recibo'; demais ficam em 'outro' (CHECK não tem entradas // específicas pra cada consent form, mas 'outro' é válido). const TEMPLATE_TYPE_TO_DOC_TYPE = { recibo_pagamento: 'recibo', declaracao_comparecimento: 'declaracao', declaracao_inicio_tratamento: 'declaracao', atestado_psicologico: 'atestado', relatorio_acompanhamento: 'relatorio_externo', laudo_psicologico: 'laudo', parecer_psicologico: 'laudo', encaminhamento: 'declaracao' }; function mapTipoDocumento(templateTipo) { return TEMPLATE_TYPE_TO_DOC_TYPE[templateTipo] || 'outro'; } /** * Formata o registro profissional: "CRP 12345/SP". * Funciona com qualquer conselho (CRP, CRM, CRFa, CREFITO etc). */ function formatRegistroProfissional({ tipo, numero, uf }) { if (!tipo || !numero) return ''; const parts = [tipo, numero]; if (uf) parts[1] = `${numero}/${uf}`; return parts.join(' '); } // ── 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). * * Inclui registro profissional (CRP/CRM/CRFa etc) adicionado pela migration * 20260521000003. Retorna: * - terapeuta_registro: "CRP 12345/SP" auto-formatado (genérico p/ qualquer conselho) * - terapeuta_crp: compat — só preenchido se o tipo for CRP (templates legacy) * - terapeuta_registro_tipo / _numero / _uf: campos individuais p/ uso fino */ export async function loadTherapistData() { const ownerId = await getOwnerId(); const { data: profile } = await supabase .from('profiles') .select('full_name, phone, professional_registration_type, professional_registration_number, professional_registration_uf') .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 || ''; const tipo = profile?.professional_registration_type || ''; const numero = profile?.professional_registration_number || ''; const uf = profile?.professional_registration_uf || ''; const registro = formatRegistroProfissional({ tipo, numero, uf }); return { terapeuta_nome: profile?.full_name || '', terapeuta_email: email, terapeuta_telefone: profile?.phone || '', terapeuta_registro: registro, terapeuta_registro_tipo: tipo, terapeuta_registro_numero: numero, terapeuta_registro_uf: uf, // Compat: templates antigos referenciam {{terapeuta_crp}} — preenche só // o número/UF (sem prefixo) pra não duplicar com o "CRP" já no HTML. // Quando o registro não é CRP, retorna vazio (template visualmente errado // pede pra usar {{terapeuta_registro}}). terapeuta_crp: tipo === 'CRP' ? (uf ? `${numero}/${uf}` : numero) : '' }; } /** * Busca dados da clinica (tenant). * * `tenants.cpf_cnpj` aceita 11 (CPF) ou 14 (CNPJ) dígitos. Pra `clinica_cnpj` * só preenche se tiver 14 dígitos (formatado XX.XXX.XXX/XXXX-XX). */ export async function loadClinicData(tenantId) { const { data: tenant } = await supabase .from('tenants') .select('*') .eq('id', tenantId) .maybeSingle(); if (!tenant) { return { clinica_nome: '', clinica_endereco: '', clinica_telefone: '', clinica_cnpj: '' }; } const endereco = tenant.logradouro ? [tenant.logradouro, tenant.numero, tenant.bairro, tenant.cidade, tenant.estado] .filter(Boolean).join(', ') : tenant.address || ''; const digits = String(tenant.cpf_cnpj || '').replace(/\D/g, ''); let cnpj = ''; if (digits.length === 14) { cnpj = digits.replace(/^(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})$/, '$1.$2.$3/$4-$5'); } else if (digits.length === 11) { cnpj = digits.replace(/^(\d{3})(\d{3})(\d{3})(\d{2})$/, '$1.$2.$3-$4'); } return { clinica_nome: tenant.name || '', clinica_endereco: endereco, clinica_telefone: tenant.phone || '', clinica_cnpj: 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. * * @param {string} patientId * @param {string|null} agendaEventoId - sessão p/ data_sessao + valor * @param {object} extras - overrides ad-hoc (ex: { valor: 150, forma_pagamento: 'PIX' }) */ export async function loadAllVariables(patientId, agendaEventoId = null, extras = {}) { const ownerId = await getOwnerId(); const tenantId = await getActiveTenantId(ownerId); const [patient, session, therapist, clinic] = await Promise.all([ loadPatientData(patientId), loadSessionData(agendaEventoId), loadTherapistData(), loadClinicData(tenantId) ]); // Resolve valor numérico (extras tem prioridade sobre session) const valorNum = extras.valor != null ? Number(extras.valor) : (session.valor ? Number(String(session.valor).replace(/[R$\s.]/g, '').replace(',', '.')) : null); const valorFormatted = isFinite(valorNum) ? `R$ ${valorNum.toFixed(2).replace('.', ',')}` : (session.valor || ''); const valorExtensoStr = isFinite(valorNum) ? valorExtenso(valorNum) : ''; const merged = { ...patient, ...session, ...therapist, ...clinic, ...getDateVariables(), // Overrides explícitos (forma_pagamento, valor, qualquer outra chave) ...extras, // Computados — sempre rebaixam extras nos campos que controlamos valor: valorFormatted, valor_extenso: valorExtensoStr, forma_pagamento: extras.forma_pagamento || session.forma_pagamento || '', cidade_estado: clinic.clinica_endereco ? `${clinic.clinica_endereco.split(', ').slice(-2).join('/')}` : '' }; return merged; } // ── 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 `
${cabecalho}
${corpo}
`.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, templateTipo }) { 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: mapTipoDocumento(templateTipo), descricao: `Gerado a partir do template: ${templateNome || 'documento'}`, tags: ['gerado'], visibilidade: 'privado', status_revisao: 'aprovado', uploaded_by: ownerId }); } return data; } // ── Quick path: emitir recibo de sessão ───────────────────── // // Wrapper de alto nível que tudo numa chamada só: // 1. busca template global 'recibo_pagamento' // 2. carrega variáveis (paciente + sessão + terapeuta + clínica + valor + extenso) // 3. gera PDF // 4. salva no Storage + tabelas document_generated + documents // 5. dispara download // // Usado pelo popover da agenda e painel financeiro do paciente. // Pra fluxo customizado (escolher template, editar variáveis, etc), // continue usando DocumentGenerateDialog → useDocumentGenerate. // export async function emitirReciboParaSessao(agendaEventoId, { patientId, valor, formaPagamento } = {}) { if (!agendaEventoId && !patientId) { throw new Error('Informe agendaEventoId ou patientId.'); } // Resolve patient_id pela sessão se não veio let resolvedPatientId = patientId; if (!resolvedPatientId && agendaEventoId) { const { data, error } = await supabase .from('agenda_eventos') .select('paciente_id') .eq('id', agendaEventoId) .single(); if (error) throw error; resolvedPatientId = data?.paciente_id; if (!resolvedPatientId) throw new Error('Sessão sem paciente vinculado.'); } // Busca template global recibo_pagamento const { data: tpl, error: tplErr } = await supabase .from('document_templates') .select('*') .eq('tipo', 'recibo_pagamento') .eq('is_global', true) .eq('ativo', true) .limit(1) .single(); if (tplErr) throw tplErr; if (!tpl) throw new Error('Template de recibo não encontrado.'); // Variáveis (extras sobrescrevem session.valor / forma_pagamento) const extras = {}; if (valor != null) extras.valor = valor; if (formaPagamento) extras.forma_pagamento = formaPagamento; const variables = await loadAllVariables(resolvedPatientId, agendaEventoId, extras); // Gera PDF const blob = await generatePdfBlob(tpl, variables); // Salva const saved = await saveGeneratedDocument({ templateId: tpl.id, patientId: resolvedPatientId, dadosPreenchidos: variables, pdfBlob: blob, templateNome: tpl.nome_template, templateTipo: tpl.tipo }); // Download client-side const a = document.createElement('a'); a.href = URL.createObjectURL(blob); const dataStr = (variables.data_sessao || variables.data_atual || '').replace(/\//g, '-'); a.download = `recibo_${(variables.paciente_nome || 'paciente').replace(/\s+/g, '_')}_${dataStr || 'sessao'}.pdf`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(a.href), 5000); return { generated: saved, variables, blob }; } /** * 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 || []; }