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
+98
View File
@@ -0,0 +1,98 @@
/**
* Converte um valor monetário em reais (pt-BR) para extenso.
*
* Ex: 1234.56 → "mil duzentos e trinta e quatro reais e cinquenta e seis centavos"
* 150 → "cento e cinquenta reais"
* 0.75 → "setenta e cinco centavos"
*
* Usado em recibos profissionais e documentos formais (CFP exige valor por
* extenso em alguns modelos). Não-localizado a R$ — só o número por extenso
* (o template adiciona "R$" no prefixo se quiser).
*/
const UNIDADES = ['', 'um', 'dois', 'três', 'quatro', 'cinco', 'seis', 'sete', 'oito', 'nove'];
const DEZ_A_DEZENOVE = ['dez', 'onze', 'doze', 'treze', 'quatorze', 'quinze', 'dezesseis', 'dezessete', 'dezoito', 'dezenove'];
const DEZENAS = ['', '', 'vinte', 'trinta', 'quarenta', 'cinquenta', 'sessenta', 'setenta', 'oitenta', 'noventa'];
const CENTENAS = ['', 'cento', 'duzentos', 'trezentos', 'quatrocentos', 'quinhentos', 'seiscentos', 'setecentos', 'oitocentos', 'novecentos'];
function centenaPorExtenso(n) {
if (n === 0) return '';
if (n === 100) return 'cem';
const c = Math.floor(n / 100);
const dezAUm = n % 100;
const d = Math.floor(dezAUm / 10);
const u = dezAUm % 10;
const parts = [];
if (c > 0) parts.push(CENTENAS[c]);
if (d === 1) {
parts.push(DEZ_A_DEZENOVE[u]);
} else {
if (d > 1) parts.push(DEZENAS[d]);
if (u > 0) {
if (d > 1) parts.push('e ' + UNIDADES[u]);
else parts.push(UNIDADES[u]);
}
}
return parts.join(' e ').replace(/ e $/, '');
}
function grupoPorExtenso(n) {
if (n === 0) return '';
return centenaPorExtenso(n);
}
/**
* Converte um número inteiro até 999.999.999 para extenso em português.
*/
function inteiroPorExtenso(n) {
if (n === 0) return 'zero';
const milhao = Math.floor(n / 1000000);
const milhar = Math.floor((n % 1000000) / 1000);
const centena = n % 1000;
const parts = [];
if (milhao > 0) {
parts.push(milhao === 1 ? 'um milhão' : grupoPorExtenso(milhao) + ' milhões');
}
if (milhar > 0) {
parts.push(milhar === 1 ? 'mil' : grupoPorExtenso(milhar) + ' mil');
}
if (centena > 0) {
parts.push(grupoPorExtenso(centena));
}
return parts.join(' e ').trim();
}
/**
* Valor monetário (Number) → "X reais e Y centavos".
*
* @param {number|string} valor
* @returns {string} — vazio se valor inválido
*/
export function valorExtenso(valor) {
const num = Number(valor);
if (!isFinite(num) || num < 0) return '';
const reais = Math.floor(num);
const centavos = Math.round((num - reais) * 100);
const partes = [];
if (reais > 0) {
partes.push(inteiroPorExtenso(reais) + (reais === 1 ? ' real' : ' reais'));
}
if (centavos > 0) {
partes.push(inteiroPorExtenso(centavos) + (centavos === 1 ? ' centavo' : ' centavos'));
}
if (partes.length === 0) return 'zero real';
return partes.join(' e ');
}
export default valorExtenso;