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:
@@ -91,6 +91,7 @@ import { useMelissaWhatsapp } from './composables/useMelissaWhatsapp';
|
||||
import { useMelissaAgenda, MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
|
||||
import { useMelissaDockPins } from './composables/useMelissaDockPins';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
|
||||
@@ -613,6 +614,7 @@ const melissaAgendaRef = ref(null); // pra chamar openProntuario + setView
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const tenantStore = useTenantStore();
|
||||
const { gerarCobrancaManual } = useAgendaFinanceiro();
|
||||
const conversationDrawerStore = useConversationDrawerStore();
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
@@ -957,13 +959,17 @@ async function onDeleteEvento() {
|
||||
eventoBusy.value = true;
|
||||
try {
|
||||
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||||
const { error } = await supabase.from('recurrence_exceptions').insert({
|
||||
// upsert pra ser idempotente: se já existe exception
|
||||
// pra (recurrence_id, original_date) — edição anterior,
|
||||
// conflito de criação, cancel duplicado — sobrescreve em
|
||||
// vez de quebrar com unique violation.
|
||||
const { error } = await supabase.from('recurrence_exceptions').upsert({
|
||||
recurrence_id: recId,
|
||||
tenant_id: tenantId,
|
||||
original_date: origDate,
|
||||
type: 'cancel_session',
|
||||
reason: 'Cancelado pelo terapeuta antes de qualquer interação'
|
||||
});
|
||||
}, { onConflict: 'recurrence_id,original_date' });
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Ocorrência cancelada', life: 2500 });
|
||||
M.refetch();
|
||||
@@ -1032,14 +1038,16 @@ async function onDeleteEvento() {
|
||||
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||||
const origDate = ev.recurrence_date || ev.original_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
|
||||
if (origDate) {
|
||||
const { error: exErr } = await supabase.from('recurrence_exceptions').insert({
|
||||
// upsert pra ser idempotente: se já existe exception
|
||||
// pra (recurrence_id, original_date), sobrescreve.
|
||||
const { error: exErr } = await supabase.from('recurrence_exceptions').upsert({
|
||||
recurrence_id: ev.recurrence_id || ev.serie_id,
|
||||
tenant_id: tenantId,
|
||||
original_date: origDate,
|
||||
type: 'cancel_session',
|
||||
reason: 'Cancelado pelo terapeuta'
|
||||
});
|
||||
if (exErr) console.warn('[Excluir] exception insert falhou:', exErr?.message);
|
||||
}, { onConflict: 'recurrence_id,original_date' });
|
||||
if (exErr) console.warn('[Excluir] exception upsert falhou:', exErr?.message);
|
||||
}
|
||||
}
|
||||
const { error } = await supabase.from('agenda_eventos').delete().eq('id', ev.id);
|
||||
@@ -1058,6 +1066,158 @@ async function onDeleteEvento() {
|
||||
});
|
||||
}
|
||||
|
||||
// Excluir SÉRIE INTEIRA — hard delete (escolha do user em 19/05).
|
||||
// Deleta recurrence_rules (CASCADE leva exceptions + recurrence_rule_services),
|
||||
// agenda_eventos materializados (linha por linha pra disparar handlers/triggers),
|
||||
// e financial_records pendentes ligados. Bloqueia se houver QUALQUER record pago
|
||||
// (precisa estornar pelo Financeiro primeiro).
|
||||
async function onDeleteSeries() {
|
||||
const ev = eventoSelecionado.value;
|
||||
if (!ev || eventoBusy.value) return;
|
||||
const ruleId = ev.recurrence_id || ev.serie_id;
|
||||
if (!ruleId) {
|
||||
toast.add({ severity: 'warn', summary: 'Sem série', detail: 'Este evento não pertence a uma recorrência.', life: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Conta eventos materializados + records vinculados pra montar mensagem
|
||||
// e detectar paid blockers ANTES de confirmar.
|
||||
let materializedCount = 0;
|
||||
let recordsPending = 0;
|
||||
let hasPaid = false;
|
||||
try {
|
||||
const { data: evts } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id')
|
||||
.eq('recurrence_id', ruleId);
|
||||
materializedCount = (evts || []).length;
|
||||
const evtIds = (evts || []).map((e) => e.id);
|
||||
if (evtIds.length) {
|
||||
const { data: recs } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id, status')
|
||||
.in('agenda_evento_id', evtIds)
|
||||
.is('deleted_at', null);
|
||||
for (const r of recs || []) {
|
||||
if (r.status === 'paid') hasPaid = true;
|
||||
else recordsPending++;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[onDeleteSeries] contagem falhou:', e?.message);
|
||||
}
|
||||
|
||||
if (hasPaid) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Série com pagamento confirmado',
|
||||
detail: 'Uma ou mais sessões desta série têm cobrança paga. Estorne primeiro pelo Financeiro antes de excluir a série.',
|
||||
life: 6000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const msgParts = [];
|
||||
msgParts.push(`Esta ação remove **toda a série de recorrência**:`);
|
||||
if (materializedCount > 0) msgParts.push(`${materializedCount} sessão(ões) já materializada(s)`);
|
||||
if (recordsPending > 0) msgParts.push(`${recordsPending} cobrança(s) pendente(s)`);
|
||||
msgParts.push('e a própria regra. As ocorrências futuras param de aparecer na agenda. **A ação não pode ser desfeita.** Confirmar?');
|
||||
|
||||
confirm.require({
|
||||
header: 'Excluir série inteira',
|
||||
message: msgParts.join(' '),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Sim, excluir série',
|
||||
rejectLabel: 'Manter',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
eventoBusy.value = true;
|
||||
try {
|
||||
// 1) Apaga financial_records pendentes vinculados a eventos
|
||||
// materializados desta série
|
||||
const { data: evts2 } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id')
|
||||
.eq('recurrence_id', ruleId);
|
||||
const evtIds = (evts2 || []).map((e) => e.id);
|
||||
if (evtIds.length) {
|
||||
const { error: recErr } = await supabase
|
||||
.from('financial_records')
|
||||
.delete()
|
||||
.in('agenda_evento_id', evtIds);
|
||||
if (recErr) throw recErr;
|
||||
}
|
||||
// 2) Apaga eventos materializados
|
||||
if (evtIds.length) {
|
||||
const { error: evErr } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.delete()
|
||||
.in('id', evtIds);
|
||||
if (evErr) throw evErr;
|
||||
}
|
||||
// 3) Apaga a regra (CASCADE: exceptions + rule_services)
|
||||
const { error: ruleErr } = await supabase
|
||||
.from('recurrence_rules')
|
||||
.delete()
|
||||
.eq('id', ruleId);
|
||||
if (ruleErr) throw ruleErr;
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Série excluída',
|
||||
detail: `Regra + ${materializedCount} sessão(ões)${recordsPending > 0 ? ` + ${recordsPending} cobrança(s)` : ''} removida(s).`,
|
||||
life: 3500
|
||||
});
|
||||
M.refetch();
|
||||
refetchEventosHoje();
|
||||
fecharEvento();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir série.', life: 5000 });
|
||||
} finally {
|
||||
eventoBusy.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Atalho do popover: gera cobrança da sessão atual via gerarCobrancaManual
|
||||
// sem precisar abrir o AgendaEventDialog. Mesmo RPC do botão "Gerar
|
||||
// cobrança" do AgendaEventoFinanceiroPanel. Só vale pra sessão real
|
||||
// (eventoSelecionado.id existe + não é virtual). Apos sucesso refetch
|
||||
// pra badge $ aparecer (paymentState='pending').
|
||||
async function onGerarCobrancaQuick() {
|
||||
const ev = eventoSelecionado.value;
|
||||
if (!ev || eventoBusy.value) return;
|
||||
if (!ev.id || ev.is_occurrence) {
|
||||
toast.add({ severity: 'warn', summary: 'Não disponível', detail: 'Esta ocorrência ainda não está materializada.', life: 3500 });
|
||||
return;
|
||||
}
|
||||
eventoBusy.value = true;
|
||||
try {
|
||||
// gerarCobrancaManual le evento.price; passamos a row crua via _raw
|
||||
// que ja inclui price/insurance_value/billing_contract_id.
|
||||
const eventoRaw = ev._raw || ev;
|
||||
const result = await gerarCobrancaManual(eventoRaw);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
const valor = eventoRaw.price ?? eventoRaw.insurance_value ?? 0;
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Cobrança gerada',
|
||||
detail: `R$ ${Number(valor).toFixed(2).replace('.', ',')} agendado para recebimento.`,
|
||||
life: 3000
|
||||
});
|
||||
M.refetch();
|
||||
refetchEventosHoje();
|
||||
// Fecha o popover apos sucesso pra impedir click duplicado
|
||||
// gerando outra fatura. User reabre se quiser ver estado novo.
|
||||
fecharEvento();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao gerar cobrança.', life: 4000 });
|
||||
} finally {
|
||||
eventoBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onWhatsapp() {
|
||||
const ev = eventoSelecionado.value;
|
||||
if (!ev?.patient_id) {
|
||||
@@ -2060,6 +2220,8 @@ function onKeydown(e) {
|
||||
@remarcar="onRemarcar"
|
||||
@edit-sessao="onEditEvento"
|
||||
@delete-sessao="onDeleteEvento"
|
||||
@delete-series="onDeleteSeries"
|
||||
@gerar-cobranca="onGerarCobrancaQuick"
|
||||
@ver-lancamentos="onVerLancamentos"
|
||||
@antecipar-pagamento="onAnteciparPagamento"
|
||||
@edit-paciente="onEditPaciente"
|
||||
|
||||
Reference in New Issue
Block a user