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:
@@ -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;
|
||||
Reference in New Issue
Block a user