agenda: C5+C6 testes OK + atalho Gerar fatura + RPC idempotência fix
DB - migration 20260519000001: create_financial_record_for_session passa a ignorar status='cancelled' na idempotência (era bug — cancelar e tentar regerar travava silencioso retornando o cancelado) Cenário 5 (convênio) — fixes pra save + visualização - Convênio: amount lia 'price' (null) → agora detecta via insurance_plan_id e usa insurance_value. payment_method forçado 'convenio' (era 'asaas') - Popover: ev.price era null em convênio → normalize expõe insurance_value e paymentLabel faz fallback. Linha mostra "A receber R$ X" corretamente - /financeiro: branch novo pra payment_method='convenio' → pill violeta com pi-id-card (antes ficava sem indicador, igual particular) Cenário 6 (recorrente sem pacote, Maria Magali) — materialização - chargeMode='none' não materializava a 1ª (todas viravam virtuais, sem badge $). Agora materializa a 1ª no fluxo de criação recorrente - Bug intermediário: usei 'paciente_id' (Portuguese) mas agendaRepository dropa esse campo. Corrigido pra 'patient_id' (English DB column) Atalho "Gerar fatura" no popover - Pill amber pequeno ao lado de "A cobrar R$ X" no popover (paymentVariant ='none' + sessão materializada) - Wire em MelissaLayout via emit gerar-cobranca + handler onGerarCobrancaQuick (chama gerarCobrancaManual, fecha popover pra impedir double-click) - Bulk-load do useMelissaAgenda e fetchRecord do AgendaEventoFinanceiroPanel agora filtram status='cancelled' (resolve badge $ residual + botão sumido) Header do popover: info de pacote/série - "Sessão · Pacote · N sessões" ou "Sessão X de Y" abaixo do tipo (computed seriesLabel lê do _raw da rule) Título do dialog "Sessão do Pacote · Sessão" - Quando commitment name é "Sessão" (default), drop pra evitar duplicação - Outros nomes (Avaliação, etc) permanecem com forma completa Excluir série inteira (popover) - Novo botão "Excluir série" no popover quando evento pertence a recorrência - Hard delete: financial_records pendentes → agenda_eventos materializados → recurrence_rules (CASCADE leva exceptions + rule_services) - Bloqueia se algum record tem status='paid' (estornar primeiro) cancel_session some da agenda - useRecurrence.expandRules agora pula occurrence com exception type= 'cancel_session' (era visível com status cancelado; doc dizia "some da agenda" mas código mantinha. Honra a promessa do diálogo) - patient_missed / therapist_canceled / holiday_block permanecem visíveis como histórico UX outros - "+ Novo convênio" toolbar em ConfiguracoesConveniosPage (botão faltava — empty state mandava clicar em botão inexistente) - InsurancePlanServiceQuickCreateDialog: cadastrar procedimento POR CIMA do AgendaEventDialog sem sair da agenda. Auto-seleciona quando nada estava selecionado antes - Hint contextual abaixo do card Sessão/Honorários: convênio = "Nº guia opcional"; gratuito = "sem cobrança". Particular sem hint - recurrence_exceptions cancel agora usa upsert com onConflict (idempotente, não quebra com unique violation em re-cancel) - goToConveniosConfig removida (dead code após quick-create inline) CSS - .aed-row-50 perdeu margin-bottom (user request) - .field-card.mb-4 ganhou margin-top: 1rem (scoped a composer wrappers) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -152,6 +152,10 @@ function normalizeForMelissa(r, paymentStateMap = null) {
|
||||
recurrence_date: r.recurrence_date ?? r.original_date ?? null,
|
||||
paymentState,
|
||||
price: r.price != null ? Number(r.price) : null,
|
||||
// insurance_value: pra convênio, o valor cobrado mora aqui (não em
|
||||
// price). Popover e Resumo usam fallback `price ?? insurance_value`
|
||||
// pra mostrar o valor da cobrança independente do tipo.
|
||||
insurance_value: r.insurance_value != null ? Number(r.insurance_value) : null,
|
||||
_raw: r // ← consumido pelo MelissaLayout pra popular dialogEventRow
|
||||
};
|
||||
}
|
||||
@@ -459,10 +463,14 @@ export function useMelissaAgenda() {
|
||||
// resolver (default 'none').
|
||||
const realIds = (rows.value || []).map((r) => r.id).filter(Boolean);
|
||||
if (realIds.length) {
|
||||
// Filtra cancelados: cobrança cancelada não deve manter
|
||||
// paymentState='pending' (badge $ residual). Tratamos cancelled
|
||||
// como "sem cobrança ativa" → cai pro default 'none'.
|
||||
const { data: recs } = await supabase
|
||||
.from('financial_records')
|
||||
.select('agenda_evento_id, paid_at')
|
||||
.in('agenda_evento_id', realIds);
|
||||
.select('agenda_evento_id, paid_at, status')
|
||||
.in('agenda_evento_id', realIds)
|
||||
.neq('status', 'cancelled');
|
||||
const map = {};
|
||||
for (const id of realIds) map[id] = 'none';
|
||||
for (const rec of recs || []) {
|
||||
@@ -1551,6 +1559,53 @@ function _buildOnDialogSave(deps) {
|
||||
}
|
||||
}
|
||||
|
||||
// chargeMode='none' (C6 doc): materializa a 1ª ocorrência
|
||||
// sem criar financial_record. As demais ficam virtuais.
|
||||
// Honra: "1ª materializada com badge $ a cobrar; outras 3
|
||||
// virtuais expandidas em runtime, limpas até interação".
|
||||
// package/per_session ja materializam dentro dos proprios
|
||||
// helpers; package 'saldo' intencionalmente NÃO materializa
|
||||
// (doc C8 — modelo Cliniko, todas virtuais ate interacao).
|
||||
// IMPORTANTE: agendaRepository.createAgendaEvento dropa
|
||||
// 'paciente_id' (campo legado). USAR 'patient_id' (English),
|
||||
// que é o nome real da coluna em agenda_eventos.
|
||||
const _patientIdForFirst = normalized.patient_id ?? normalized.paciente_id ?? null;
|
||||
if (createdRule?.id && recChargeMode === 'none' && _patientIdForFirst) {
|
||||
try {
|
||||
const durMin = createdRule.duration_min || 50;
|
||||
const [hh, mm] = String(createdRule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
|
||||
const firstISO = createdRule.start_date;
|
||||
const startDt = new Date(`${firstISO}T00:00:00`);
|
||||
startDt.setHours(hh, mm, 0, 0);
|
||||
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
|
||||
await create({
|
||||
owner_id: createdRule.owner_id,
|
||||
tenant_id: clinicId,
|
||||
terapeuta_id: createdRule.therapist_id ?? null,
|
||||
recurrence_id: createdRule.id,
|
||||
recurrence_date: firstISO,
|
||||
tipo: normalized.tipo || 'sessao',
|
||||
status: normalized.status || 'agendado',
|
||||
titulo: normalized.titulo || 'Sessão',
|
||||
inicio_em: startDt.toISOString(),
|
||||
fim_em: endDt.toISOString(),
|
||||
patient_id: _patientIdForFirst,
|
||||
determined_commitment_id: normalized.determined_commitment_id ?? null,
|
||||
modalidade: normalized.modalidade ?? 'presencial',
|
||||
observacoes: normalized.observacoes ?? null,
|
||||
extra_fields: normalized.extra_fields ?? null,
|
||||
price: normalized.price ?? null,
|
||||
insurance_plan_id: normalized.insurance_plan_id ?? null,
|
||||
insurance_guide_number: normalized.insurance_guide_number ?? null,
|
||||
insurance_value: normalized.insurance_value ?? null,
|
||||
insurance_plan_service_id: normalized.insurance_plan_service_id ?? null,
|
||||
visibility_scope: normalized.visibility_scope || 'public'
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[useMelissaAgenda] materializa 1ª (chargeMode=none) falhou:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
const detail = recorrencia.qtdSessoes ? `${recorrencia.qtdSessoes} sessões criadas` : 'Série recorrente criada';
|
||||
toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 });
|
||||
if (chargeInfo?.toast) toast.add(chargeInfo.toast);
|
||||
@@ -1790,7 +1845,15 @@ function _buildOnDialogSave(deps) {
|
||||
// status='paid' apenas quando markPaidNow && method !== 'link'.
|
||||
if (arg?.chargeMode === 'session' && createdEventId) {
|
||||
try {
|
||||
const amount = normalized.price ?? 0;
|
||||
// Convênio: valor vem em insurance_value (não price);
|
||||
// payment_method fixo em 'convenio' (não asaas/pix/etc) —
|
||||
// o plano paga a clínica no fechamento mensal, nunca via
|
||||
// gateway ou no caixa. markPaidNow ignorado pra convênio
|
||||
// (record sempre nasce pending até a baixa via Financeiro).
|
||||
const isConvenio = !!normalized.insurance_plan_id;
|
||||
const amount = isConvenio
|
||||
? (normalized.insurance_value ?? 0)
|
||||
: (normalized.price ?? 0);
|
||||
const dueDate = normalized.inicio_em
|
||||
? new Date(normalized.inicio_em).toISOString().slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
@@ -1805,12 +1868,16 @@ function _buildOnDialogSave(deps) {
|
||||
if (cobErr) throw cobErr;
|
||||
|
||||
// Pós-RPC: ajusta payment_method (sempre) e status (só se
|
||||
// markPaidNow=true e método direto).
|
||||
// method='link' → payment_method='asaas', status=pending
|
||||
// method=pix/etc + markPaidNow=false → payment_method=<>, status=pending
|
||||
// method=pix/etc + markPaidNow=true → payment_method=<>, status='paid', paid_at=now()
|
||||
const method = arg?.paymentMethod || 'link';
|
||||
const paidNow = arg?.markPaidNow === true && method !== 'link';
|
||||
// markPaidNow=true e método direto). Convênio força method
|
||||
// = 'convenio' e ignora markPaidNow.
|
||||
// convenio → payment_method='convenio', status=pending
|
||||
// method='link' → payment_method='asaas', status=pending
|
||||
// method=pix/etc + markPaidNow=false → payment_method=<>, status=pending
|
||||
// method=pix/etc + markPaidNow=true → payment_method=<>, status='paid', paid_at=now()
|
||||
const method = isConvenio
|
||||
? 'convenio'
|
||||
: (arg?.paymentMethod || 'link');
|
||||
const paidNow = !isConvenio && arg?.markPaidNow === true && method !== 'link';
|
||||
try {
|
||||
const { data: recRow } = await supabase
|
||||
.from('financial_records')
|
||||
@@ -1839,14 +1906,15 @@ function _buildOnDialogSave(deps) {
|
||||
pix: 'PIX',
|
||||
dinheiro: 'dinheiro',
|
||||
deposito: 'depósito',
|
||||
cartao_maquininha: 'cartão (maquininha)'
|
||||
cartao_maquininha: 'cartão (maquininha)',
|
||||
convenio: 'convênio'
|
||||
}[method] || null;
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: paidNow ? 'Cobrança paga' : 'Cobrança gerada',
|
||||
detail: paidNow
|
||||
? `R$ ${Number(amount).toFixed(2).replace('.', ',')} recebido via ${methodLabel}.`
|
||||
: `R$ ${Number(amount).toFixed(2).replace('.', ',')} com vencimento em ${dueDate.split('-').reverse().join('/')}.`,
|
||||
: `R$ ${Number(amount).toFixed(2).replace('.', ',')} com vencimento em ${dueDate.split('-').reverse().join('/')}${methodLabel ? ` (${methodLabel})` : ''}.`,
|
||||
life: 3500
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user