roadmap #14: recibo profissional PDF — gerador + quick path da agenda

ROADMAP item #1.4 #14. Fecha Fase 1.4 Fiscal minimo (parcial — #15
NFS-e fica pra depois).

DocumentGenerate.service estendido:
- loadTherapistData puxa registro profissional (#5 migration) e
  expoe terapeuta_registro auto-formatado ("CRP 12345/SP", "CRM
  98765/RJ"). terapeuta_crp legacy mantido por compat — preenche
  somente quando tipo=CRP.
- loadClinicData formata tenants.cpf_cnpj (11 ou 14 digitos) em
  CPF (XXX.XXX.XXX-XX) ou CNPJ (XX.XXX.XXX/XXXX-XX).
- loadAllVariables aceita {extras} (valor, formaPagamento) e
  computa valor_extenso via novo helper utils/valorExtenso.js
  (pt-BR completo ate 999 milhoes).
- saveGeneratedDocument ganha templateTipo + usa
  TEMPLATE_TYPE_TO_DOC_TYPE mapping (recibo_pagamento -> 'recibo',
  laudo -> 'laudo', atestado -> 'atestado' etc) em vez de
  hardcoded 'laudo'.
- emitirReciboParaSessao(eventoId, opts) — quick path one-call:
  busca template recibo_pagamento global, carrega variaveis,
  gera PDF blob, salva no Storage + documents + document_generated,
  dispara download.

Migration 20260521000008 substitui no template recibo_pagamento
"Psicologo(a) - CRP {{terapeuta_crp}}" por "{{terapeuta_registro}}"
e atualiza variaveis[]. Universal — funciona com qualquer conselho
(CRP/CRM/CRFa/CREFITO/CRESS/CRN).

DocumentTemplates.service.TEMPLATE_VARIABLES ganha terapeuta_
registro + _tipo + _numero + _uf (terapeuta_crp marcado legacy).

useDocumentGenerate.generateAndSave passa templateTipo no save.

AgendaEventoFinanceiroPanel ganha botao "Emitir recibo" (icon
pi-file-pdf, outlined, full width) que aparece SOMENTE quando
record.status === 'paid'. Toast de sucesso/erro. Loading state.

Fluxo end-to-end: terapeuta marca sessao como paga -> botao
"Emitir recibo" aparece -> click -> PDF baixado + aparece em
/clinic/documents/templates do paciente como tipo 'recibo'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 05:05:17 -03:00
parent 114d755f84
commit 6ae651a8ae
6 changed files with 344 additions and 13 deletions
+167 -11
View File
@@ -15,9 +15,39 @@
|--------------------------------------------------------------------------
*/
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() {
@@ -106,13 +136,19 @@ export async function loadSessionData(agendaEventoId) {
/**
* 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')
.select('full_name, phone, professional_registration_type, professional_registration_number, professional_registration_uf')
.eq('id', ownerId)
.single();
@@ -120,20 +156,34 @@ export async function loadTherapistData() {
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_crp: '', // CRP ainda nao existe no banco — preencher manualmente
terapeuta_email: email,
terapeuta_telefone: profile?.phone || ''
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) {
// 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('*')
@@ -144,17 +194,24 @@ export async function loadClinicData(tenantId) {
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 || '';
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: ''
clinica_cnpj: cnpj
};
}
@@ -174,8 +231,12 @@ function getDateVariables() {
/**
* 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) {
export async function loadAllVariables(patientId, agendaEventoId = null, extras = {}) {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
@@ -186,16 +247,35 @@ export async function loadAllVariables(patientId, agendaEventoId = null) {
loadClinicData(tenantId)
]);
return {
// 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 ──────────────────────────────────────
@@ -303,7 +383,7 @@ export async function printDocument(template, variables = {}) {
* @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 }) {
export async function saveGeneratedDocument({ templateId, patientId, dadosPreenchidos, pdfBlob, templateNome, templateTipo }) {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
@@ -356,7 +436,7 @@ export async function saveGeneratedDocument({ templateId, patientId, dadosPreenc
nome_original: filename.replace(/_/g, ' ').replace('.pdf', '') + '.pdf',
mime_type: 'application/pdf',
tamanho_bytes: pdfBlob?.size || null,
tipo_documento: 'laudo',
tipo_documento: mapTipoDocumento(templateTipo),
descricao: `Gerado a partir do template: ${templateNome || 'documento'}`,
tags: ['gerado'],
visibilidade: 'privado',
@@ -368,6 +448,82 @@ export async function saveGeneratedDocument({ templateId, patientId, dadosPreenc
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.
*/
+5 -1
View File
@@ -64,7 +64,11 @@ export const TEMPLATE_VARIABLES = [
// Terapeuta
{ key: 'terapeuta_nome', label: 'Nome do terapeuta', grupo: 'Terapeuta' },
{ key: 'terapeuta_crp', label: 'CRP do terapeuta', grupo: 'Terapeuta' },
{ key: 'terapeuta_registro', label: 'Registro profissional (ex: CRP 12345/SP)', grupo: 'Terapeuta' },
{ key: 'terapeuta_registro_tipo', label: 'Tipo do registro (CRP/CRM/CRFa…)', grupo: 'Terapeuta' },
{ key: 'terapeuta_registro_numero', label: 'Número do registro', grupo: 'Terapeuta' },
{ key: 'terapeuta_registro_uf', label: 'UF do registro', grupo: 'Terapeuta' },
{ key: 'terapeuta_crp', label: 'CRP (legacy — prefira terapeuta_registro)', grupo: 'Terapeuta' },
{ key: 'terapeuta_email', label: 'E-mail do terapeuta', grupo: 'Terapeuta' },
{ key: 'terapeuta_telefone', label: 'Telefone do terapeuta', grupo: 'Terapeuta' },