Compare commits
2 Commits
114d755f84
...
36402cd0bf
| Author | SHA1 | Date | |
|---|---|---|---|
| 36402cd0bf | |||
| 6ae651a8ae |
@@ -1282,3 +1282,39 @@ channel factory). Por ora, terapeuta envia link manualmente.
|
|||||||
|
|
||||||
PROXIMO: outras 5 secoes do ROADMAP Fase 1 (Asaas Fase B bloqueada,
|
PROXIMO: outras 5 secoes do ROADMAP Fase 1 (Asaas Fase B bloqueada,
|
||||||
UX §1.3, Fiscal §1.4, Qualidade §1.5).
|
UX §1.3, Fiscal §1.4, Qualidade §1.5).
|
||||||
|
|
||||||
|
## [2026-05-21 evening] session | ROADMAP #14 Recibo profissional PDF
|
||||||
|
Touched: none (durable em development/02-auditoria/PADRONIZACAO.md + memoria)
|
||||||
|
|
||||||
|
Fecha §1.4 Fiscal minimo (parcial — #15 NFS-e fica pra depois).
|
||||||
|
|
||||||
|
src/utils/valorExtenso.js — helper pt-BR completo ate 999 milhoes.
|
||||||
|
"R$ 1.234,56" -> "mil duzentos e trinta e quatro reais e cinquenta
|
||||||
|
e seis centavos".
|
||||||
|
|
||||||
|
DocumentGenerate.service estendido:
|
||||||
|
- loadTherapistData puxa professional_registration_type/numero/uf
|
||||||
|
(#5 migration) e auto-formata terapeuta_registro: "CRP 12345/SP".
|
||||||
|
terapeuta_crp legacy mantido pra compat.
|
||||||
|
- loadClinicData formata tenants.cpf_cnpj (11 dig CPF, 14 dig CNPJ).
|
||||||
|
- loadAllVariables aceita extras + computa valor + valor_extenso +
|
||||||
|
forma_pagamento.
|
||||||
|
- saveGeneratedDocument ganha templateTipo + mapping
|
||||||
|
TEMPLATE_TYPE_TO_DOC_TYPE (recibo_pagamento -> 'recibo', laudo ->
|
||||||
|
'laudo' etc). Antes era hardcoded 'laudo' pra TUDO — bug.
|
||||||
|
- emitirReciboParaSessao(eventoId, opts) — quick path: busca
|
||||||
|
template, carrega vars, gera PDF, salva, download. One-call.
|
||||||
|
|
||||||
|
Migration 20260521000008 substitui no template recibo_pagamento
|
||||||
|
"Psicologo(a) - CRP {{terapeuta_crp}}" por "{{terapeuta_registro}}".
|
||||||
|
Universal pra qualquer conselho (CRP/CRM/CRFa/CREFITO/CRESS/CRN).
|
||||||
|
|
||||||
|
DocumentTemplates.service.TEMPLATE_VARIABLES ganha 4 entries de
|
||||||
|
registro profissional. useDocumentGenerate passa templateTipo.
|
||||||
|
|
||||||
|
AgendaEventoFinanceiroPanel ganha botao "Emitir recibo" outlined
|
||||||
|
quando record.status === 'paid'. Toast + loading state.
|
||||||
|
|
||||||
|
PROXIMO: UX §1.3 (busca global + recently viewed + papel timbrado
|
||||||
|
+ relatorios export) OU sweep residual (M4 cutover billing decisoes
|
||||||
|
#2/#3/#6).
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- ROADMAP #1.4 #14 — Recibo profissional usa terapeuta_registro genérico
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- O template recibo_pagamento (seed_015) usa "Psicólogo(a) — CRP {{terapeuta_crp}}".
|
||||||
|
-- Como agora suportamos múltiplos conselhos (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS)
|
||||||
|
-- via #5 (migration 20260521000003), o recibo precisa ser CFP-agnóstico.
|
||||||
|
--
|
||||||
|
-- Esta migration substitui no recibo_pagamento:
|
||||||
|
-- "Psicólogo(a) — CRP {{terapeuta_crp}}" → "{{terapeuta_registro}}"
|
||||||
|
-- e atualiza variaveis[] removendo terapeuta_crp + adicionando terapeuta_registro.
|
||||||
|
--
|
||||||
|
-- {{terapeuta_registro}} é auto-formatado server-side como "CRP 12345/SP",
|
||||||
|
-- "CRM 12345/SP" etc, então não precisa de "Psicólogo(a) —" hardcoded.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE public.document_templates
|
||||||
|
SET corpo_html = REPLACE(
|
||||||
|
corpo_html,
|
||||||
|
'Psicólogo(a) — CRP {{terapeuta_crp}}',
|
||||||
|
'{{terapeuta_registro}}'
|
||||||
|
),
|
||||||
|
variaveis = ARRAY(
|
||||||
|
SELECT DISTINCT v FROM (
|
||||||
|
SELECT unnest(variaveis) v
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'terapeuta_registro'
|
||||||
|
) sub
|
||||||
|
WHERE v <> 'terapeuta_crp'
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE tipo = 'recibo_pagamento' AND is_global = true;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -113,6 +113,8 @@ Do `project_graphify_findings_20260504`:
|
|||||||
- #7 (assinatura eletrônica no portal): 2 migrations RPC — `20260521000006` cria `sign_document_by_signature_id` + `sign_document_by_token` + `get_signable_document_by_token` (IP/UA capturados server-side via `inet_client_addr()` + `current_setting('request.headers')`); `20260521000007` cria `list_my_signatures` (cruzamento auth.uid() por 3 caminhos). `DocumentSignatures.service` estendido. `useDocumentSignatures` composable novo. `PortalDocumentos.vue` lista pendências do paciente logado. `SharedDocumentPage.vue` estendida com painel azul de assinatura (aviso LGPD + checkbox aceite + Assinar/Recusar). `DocumentSignatureDialog` (terapeuta-side, já existia) ganha checkbox "Gerar link público" + select de validade + bloco com URL gerado/copy.
|
- #7 (assinatura eletrônica no portal): 2 migrations RPC — `20260521000006` cria `sign_document_by_signature_id` + `sign_document_by_token` + `get_signable_document_by_token` (IP/UA capturados server-side via `inet_client_addr()` + `current_setting('request.headers')`); `20260521000007` cria `list_my_signatures` (cruzamento auth.uid() por 3 caminhos). `DocumentSignatures.service` estendido. `useDocumentSignatures` composable novo. `PortalDocumentos.vue` lista pendências do paciente logado. `SharedDocumentPage.vue` estendida com painel azul de assinatura (aviso LGPD + checkbox aceite + Assinar/Recusar). `DocumentSignatureDialog` (terapeuta-side, já existia) ganha checkbox "Gerar link público" + select de validade + bloco com URL gerado/copy.
|
||||||
- #8 (nome social): JÁ INTEGRADO — `patients.nome_social` schema existia + UI em 7 arquivos.
|
- #8 (nome social): JÁ INTEGRADO — `patients.nome_social` schema existia + UI em 7 arquivos.
|
||||||
- #9 (especialidades): `20260521000004_specialties.sql` (tabela + profile_specialties M:N + RLS) + `seed_050_specialties.sql` (33 specialties) + `src/services/specialtiesService.js`.
|
- #9 (especialidades): `20260521000004_specialties.sql` (tabela + profile_specialties M:N + RLS) + `seed_050_specialties.sql` (33 specialties) + `src/services/specialtiesService.js`.
|
||||||
|
- [x] **Recibo profissional PDF (#14 · 2026-05-21)** — `valorExtenso.js` helper pt-BR. `DocumentGenerate.service` puxa registro profissional do profile (auto-formato `CRP 12345/SP`), formata `cpf_cnpj` do tenant, computa `valor`+`valor_extenso`, mapeia `templateTipo` → `tipo_documento` (recibo_pagamento → 'recibo'). Migration `20260521000008` substitui `{{terapeuta_crp}}` por `{{terapeuta_registro}}` no template — universal pra qualquer conselho. `emitirReciboParaSessao(eventoId, opts)` é quick path one-call. Botão "Emitir recibo" no `AgendaEventoFinanceiroPanel` quando `record.status === 'paid'`. #15 NFS-e ainda em aberto.
|
||||||
|
- [ ] NFS-e emissão (#15) — Esforço L, decisão de provider pendente (Focus NF-e vs prefeitura direta).
|
||||||
- [ ] E2E Playwright crítico (#16)
|
- [ ] E2E Playwright crítico (#16)
|
||||||
- [ ] Sentry (#18)
|
- [ ] Sentry (#18)
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { useConfirm } from 'primevue/useconfirm';
|
|||||||
|
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro';
|
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro';
|
||||||
|
import { emitirReciboParaSessao } from '@/services/DocumentGenerate.service';
|
||||||
|
|
||||||
// ── props / emits ─────────────────────────────────────────────────────────────
|
// ── props / emits ─────────────────────────────────────────────────────────────
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -56,6 +57,7 @@ const { gerarCobrancaManual, loading: finLoading, error: finError } = useAgendaF
|
|||||||
const record = ref(null); // financial_record vinculado
|
const record = ref(null); // financial_record vinculado
|
||||||
const fetching = ref(false);
|
const fetching = ref(false);
|
||||||
const generating = ref(false);
|
const generating = ref(false);
|
||||||
|
const emittingRecibo = ref(false);
|
||||||
|
|
||||||
// ── opções de método de pagamento ─────────────────────────────────────────────
|
// ── opções de método de pagamento ─────────────────────────────────────────────
|
||||||
const PAYMENT_METHODS = [
|
const PAYMENT_METHODS = [
|
||||||
@@ -224,6 +226,27 @@ function requestCancel() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Emitir recibo PDF da sessão ─────────────────────────────────────────────
|
||||||
|
// Gera, salva (Storage + documents/document_generated) e baixa um recibo
|
||||||
|
// pré-preenchido com paciente/sessão/valor/forma de pagamento + registro
|
||||||
|
// profissional do terapeuta (CRP/CRM/CRFa etc — auto-formatado).
|
||||||
|
async function onEmitirRecibo() {
|
||||||
|
if (emittingRecibo.value) return;
|
||||||
|
emittingRecibo.value = true;
|
||||||
|
try {
|
||||||
|
await emitirReciboParaSessao(props.evento.id, {
|
||||||
|
patientId: props.evento.patient_id || props.evento.paciente_id,
|
||||||
|
valor: record.value?.final_amount ?? record.value?.amount ?? props.evento.price,
|
||||||
|
formaPagamento: paymentLabel(record.value?.payment_method)
|
||||||
|
});
|
||||||
|
toast.add({ severity: 'success', summary: 'Recibo emitido', detail: 'PDF baixado e salvo nos documentos do paciente.', life: 3000 });
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Erro ao emitir recibo', detail: e?.message || 'Tente novamente.', life: 4500 });
|
||||||
|
} finally {
|
||||||
|
emittingRecibo.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -292,6 +315,20 @@ function requestCancel() {
|
|||||||
<Button label="Receber" icon="pi pi-check" size="small" class="rounded-full flex-1" @click="openPayDialog" />
|
<Button label="Receber" icon="pi pi-check" size="small" class="rounded-full flex-1" @click="openPayDialog" />
|
||||||
<Button icon="pi pi-times" size="small" severity="danger" outlined class="rounded-full h-7 w-7" v-tooltip.top="'Cancelar cobrança'" @click="requestCancel" />
|
<Button icon="pi pi-times" size="small" severity="danger" outlined class="rounded-full h-7 w-7" v-tooltip.top="'Cancelar cobrança'" @click="requestCancel" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Ação: pago — emitir recibo PDF -->
|
||||||
|
<div v-else-if="record.status === 'paid'" class="flex gap-1.5 mt-3">
|
||||||
|
<Button
|
||||||
|
label="Emitir recibo"
|
||||||
|
icon="pi pi-file-pdf"
|
||||||
|
size="small"
|
||||||
|
outlined
|
||||||
|
class="rounded-full flex-1"
|
||||||
|
:loading="emittingRecibo"
|
||||||
|
v-tooltip.top="'Gera PDF e salva nos documentos do paciente'"
|
||||||
|
@click="onEmitirRecibo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ export function useDocumentGenerate() {
|
|||||||
patientId,
|
patientId,
|
||||||
dadosPreenchidos: { ...variables.value },
|
dadosPreenchidos: { ...variables.value },
|
||||||
pdfBlob: blob,
|
pdfBlob: blob,
|
||||||
templateNome
|
templateNome,
|
||||||
|
templateTipo: selectedTemplate.value.tipo
|
||||||
});
|
});
|
||||||
generatedDocs.value.unshift(result);
|
generatedDocs.value.unshift(result);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -15,9 +15,39 @@
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
|
import { valorExtenso } from '@/utils/valorExtenso';
|
||||||
|
|
||||||
const BUCKET = 'generated-docs';
|
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 ──────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async function getOwnerId() {
|
async function getOwnerId() {
|
||||||
@@ -106,13 +136,19 @@ export async function loadSessionData(agendaEventoId) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Busca dados do terapeuta (profile + tenant_member).
|
* 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() {
|
export async function loadTherapistData() {
|
||||||
const ownerId = await getOwnerId();
|
const ownerId = await getOwnerId();
|
||||||
|
|
||||||
const { data: profile } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('full_name, phone')
|
.select('full_name, phone, professional_registration_type, professional_registration_number, professional_registration_uf')
|
||||||
.eq('id', ownerId)
|
.eq('id', ownerId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@@ -120,20 +156,34 @@ export async function loadTherapistData() {
|
|||||||
const { data: userData } = await supabase.auth.getUser();
|
const { data: userData } = await supabase.auth.getUser();
|
||||||
const email = userData?.user?.email || '';
|
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 {
|
return {
|
||||||
terapeuta_nome: profile?.full_name || '',
|
terapeuta_nome: profile?.full_name || '',
|
||||||
terapeuta_crp: '', // CRP ainda nao existe no banco — preencher manualmente
|
|
||||||
terapeuta_email: email,
|
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).
|
* 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) {
|
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
|
const { data: tenant } = await supabase
|
||||||
.from('tenants')
|
.from('tenants')
|
||||||
.select('*')
|
.select('*')
|
||||||
@@ -144,17 +194,24 @@ export async function loadClinicData(tenantId) {
|
|||||||
return { clinica_nome: '', clinica_endereco: '', clinica_telefone: '', clinica_cnpj: '' };
|
return { clinica_nome: '', clinica_endereco: '', clinica_telefone: '', clinica_cnpj: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usa campos estruturados se disponiveis, senao cai no address texto livre
|
|
||||||
const endereco = tenant.logradouro
|
const endereco = tenant.logradouro
|
||||||
? [tenant.logradouro, tenant.numero, tenant.bairro, tenant.cidade, tenant.estado]
|
? [tenant.logradouro, tenant.numero, tenant.bairro, tenant.cidade, tenant.estado]
|
||||||
.filter(Boolean).join(', ')
|
.filter(Boolean).join(', ')
|
||||||
: tenant.address || '';
|
: 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 {
|
return {
|
||||||
clinica_nome: tenant.name || '',
|
clinica_nome: tenant.name || '',
|
||||||
clinica_endereco: endereco,
|
clinica_endereco: endereco,
|
||||||
clinica_telefone: tenant.phone || '',
|
clinica_telefone: tenant.phone || '',
|
||||||
clinica_cnpj: ''
|
clinica_cnpj: cnpj
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,8 +231,12 @@ function getDateVariables() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Carrega todos os dados necessarios para preencher um template.
|
* 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 ownerId = await getOwnerId();
|
||||||
const tenantId = await getActiveTenantId(ownerId);
|
const tenantId = await getActiveTenantId(ownerId);
|
||||||
|
|
||||||
@@ -186,16 +247,35 @@ export async function loadAllVariables(patientId, agendaEventoId = null) {
|
|||||||
loadClinicData(tenantId)
|
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,
|
...patient,
|
||||||
...session,
|
...session,
|
||||||
...therapist,
|
...therapist,
|
||||||
...clinic,
|
...clinic,
|
||||||
...getDateVariables(),
|
...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
|
cidade_estado: clinic.clinica_endereco
|
||||||
? `${clinic.clinica_endereco.split(', ').slice(-2).join('/')}`
|
? `${clinic.clinica_endereco.split(', ').slice(-2).join('/')}`
|
||||||
: ''
|
: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Preencher template ──────────────────────────────────────
|
// ── 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)
|
* @param {Blob} params.pdfBlob - PDF gerado (opcional — pode ser null se so print)
|
||||||
* @returns {object} registro criado
|
* @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 ownerId = await getOwnerId();
|
||||||
const tenantId = await getActiveTenantId(ownerId);
|
const tenantId = await getActiveTenantId(ownerId);
|
||||||
|
|
||||||
@@ -356,7 +436,7 @@ export async function saveGeneratedDocument({ templateId, patientId, dadosPreenc
|
|||||||
nome_original: filename.replace(/_/g, ' ').replace('.pdf', '') + '.pdf',
|
nome_original: filename.replace(/_/g, ' ').replace('.pdf', '') + '.pdf',
|
||||||
mime_type: 'application/pdf',
|
mime_type: 'application/pdf',
|
||||||
tamanho_bytes: pdfBlob?.size || null,
|
tamanho_bytes: pdfBlob?.size || null,
|
||||||
tipo_documento: 'laudo',
|
tipo_documento: mapTipoDocumento(templateTipo),
|
||||||
descricao: `Gerado a partir do template: ${templateNome || 'documento'}`,
|
descricao: `Gerado a partir do template: ${templateNome || 'documento'}`,
|
||||||
tags: ['gerado'],
|
tags: ['gerado'],
|
||||||
visibilidade: 'privado',
|
visibilidade: 'privado',
|
||||||
@@ -368,6 +448,82 @@ export async function saveGeneratedDocument({ templateId, patientId, dadosPreenc
|
|||||||
return data;
|
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.
|
* Lista documentos gerados de um paciente.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ export const TEMPLATE_VARIABLES = [
|
|||||||
|
|
||||||
// Terapeuta
|
// Terapeuta
|
||||||
{ key: 'terapeuta_nome', label: 'Nome do terapeuta', grupo: '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_email', label: 'E-mail do terapeuta', grupo: 'Terapeuta' },
|
||||||
{ key: 'terapeuta_telefone', label: 'Telefone do terapeuta', grupo: 'Terapeuta' },
|
{ key: 'terapeuta_telefone', label: 'Telefone do terapeuta', grupo: 'Terapeuta' },
|
||||||
|
|
||||||
|
|||||||
@@ -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