agenda: Fase 5 (status change/edit cobrada) + indicadores visuais + UX convenio

DB
- drop agenda_excecoes (substituida por financial_exceptions + lock-edit
  baseado em financial_records)
- financial_records.payment_link (Asaas + link compartilhavel)
- financial_exceptions.consume_on_miss (rotular nao-show consome ou nao)
- billing_contracts.charging_style (upfront/saldo/per_session)

Payment refactor
- paymentSettlement -> paymentMethod (string) + markPaidNow (bool).
  Handler aplica payment_method sempre; status='paid'+paid_at apenas
  quando markPaidNow=true && method != 'link'. Asaas (link) sempre
  liquida via webhook, nunca nasce paid.
- create_financial_record_for_session com pos-RPC patch pra payment_method
  e (opcional) status='paid' quando user marca "ja recebi".

Indicadores visuais (3 canais distintos por estado)
- Paid: barra esquerda emerald-500 4px na agenda (MelissaAgenda),
  pi-check-circle no popover/Resumo.
- Pending: badge \$ amber canto direito (mantido); linha amber no popover/
  Resumo "A receber R\$ X (cobranca pendente)".
- Neutro: sem badge nem barra (compromisso pessoal, bloqueio, ou
  ocorrencia virtual de pacote upfront/saldo).
- Bulk-load de paymentState em _reloadRange etapa 4 (1 query unica em
  financial_records mapeada por agenda_evento_id).
- AgendaEventDialog Resumo lateral ganha linha entre pi-clock e
  pi-map-marker via novo sessionPaymentRecord (sem guard de
  occurrenceMode, contrario ao occFinancialRecord que continua so pra
  Rail/Clinica). 5 estados: paid+paid_at, overdue+venceu, pending+vence,
  sem cobranca c/ valor, sem cobranca s/ valor.

UX de convenio
- InsurancePlanServiceQuickCreateDialog novo: cadastra procedimento
  POR CIMA do AgendaEventDialog sem sair da agenda. Auto-seleciona
  novo procedimento so quando nada estava selecionado antes.
- Caixa cinza "Cadastrar procedimento" sempre visivel quando convenio
  selecionado, com copy variavel (0 procedimentos: chamada urgente;
  1+: "se quiser adicionar mais").
- "+ Novo convenio" toolbar em ConfiguracoesConveniosPage (botao
  estava faltando, empty state mandava clicar em botao inexistente).
- Hint contextual abaixo do card Sessao/Honorarios: convenio = "N da
  guia eh opcional", gratuito = "sem cobranca", particular = sem hint.
  Label "N da Guia" tambem ganhou "(opcional)" no service-picker dialog.

Bug fixes
- pickDbFields whitelist faltando 'modalidade' (useMelissaAgenda.js:74)
  — sessoes avulsas eram salvas como presencial independente da
  escolha visual. Adicionado.
- goToConveniosConfig removida — fazia router.push("/therapist/
  configuracoes/convenios") mas /configuracoes/* eh rota raiz, nao
  filha. Substituida pelo quick-create inline (#1).
- bloqueioCobrindo + dialogBlockOverlap passados via deps em
  _buildHandlers (refs do useMelissaAgenda nao sao acessiveis no
  escopo de _buildHandlers).

Fase 5 (status change + edit cobrada)
- AgendaStatusChangeConfirmDialog: confirm dialog quando user muda
  status pra realizada/faltou/cancelado, com opcoes de markPaid ou
  gerar cobranca conforme o caso.
- useAgendaBloqueios novo composable: extrai logica de bloqueios
  cinza (background events) do MelissaAgenda.

Doc viva
- src/docs/agenda-compromisso-financeiro-cenarios.html: 13 cenarios
  de teste manual. C1-C4 ja validados. Cada teste validado vira parte
  da doc final pra area de ajuda (pos-Fase 9).

Wiki/handoff
- agenda-compromisso-fluxo e agenda-billing-pesquisa-mercado (decisoes
  arquiteturais sobre billing).
- HANDOFF.md atualizado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-19 08:31:18 -03:00
parent 41c44272a3
commit e95ed9b585
41 changed files with 8715 additions and 852 deletions
+573 -30
View File
@@ -18,6 +18,7 @@
import { ref, computed, watch, onMounted, onBeforeUnmount, provide, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { useLayout } from '@/layout/composables/layout';
import { applyThemeEngine, surfaces as THEME_SURFACES, presetOptions as THEME_PRESETS } from '@/theme/theme.options';
import { MELISSA_THEME_NAMES, findMelissaTheme } from './melissaThemes';
@@ -95,6 +96,7 @@ import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue';
import AgendaStatusChangeConfirmDialog from '@/features/agenda/components/AgendaStatusChangeConfirmDialog.vue';
// Topbar system actions trazidos do AppTopbar pra Melissa: plan switcher
// (DEV), notificações e ajuda. AppTopbar não monta na rota /melissa
// (fullscreen), então duplicamos os triggers + drawers aqui.
@@ -609,6 +611,7 @@ const eventoSelecionado = ref(null);
const eventoBusy = ref(false); // bloqueia botões enquanto UPDATE roda
const melissaAgendaRef = ref(null); // pra chamar openProntuario + setView
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
const conversationDrawerStore = useConversationDrawerStore();
@@ -630,6 +633,12 @@ const {
dialogEventRow: agendaDialogEventRow,
dialogStartISO: agendaDialogStartISO,
dialogEndISO: agendaDialogEndISO,
dialogBlockOverlap: agendaDialogBlockOverlap,
occDialogOpen: agendaOccDialogOpen,
occDialogEventRow: agendaOccDialogEventRow,
occDialogStartISO: agendaOccDialogStartISO,
occDialogEndISO: agendaOccDialogEndISO,
serieRefreshTick: agendaSerieRefreshTick,
ownerId: agendaOwnerId,
clinicTenantId: agendaClinicTenantId,
commitmentOptions: agendaCommitmentOptions,
@@ -638,7 +647,12 @@ const {
allEventsForDialog: agendaAllEvents,
feriados: agendaFeriados,
bloqueioDialogOpen: agendaBloqueioOpen,
bloqueioMode: agendaBloqueioMode
bloqueioMode: agendaBloqueioMode,
// Status change confirm dialog (Fase 5, 2026-05-14)
statusDialogOpen: agendaStatusDialogOpen,
statusDialogProps: agendaStatusDialogProps,
onStatusDialogConfirm: agendaOnStatusDialogConfirm,
onStatusDialogCancel: agendaOnStatusDialogCancel
} = M;
function abrirEvento(ev) {
@@ -650,12 +664,10 @@ function fecharEvento() {
}
// ── Actions do MelissaEventoPanel ──────────────────────────────
// updateStatus: muda status no DB e refetcha agenda. Pattern espelha
// AgendaTerapeutaPage (sem optimistic update por simplicidade no MVP).
//
// Quando `ev` é ocorrência VIRTUAL de recorrência (id `rec::...` sem row real),
// delega pro M.onUpdateSeriesEvent que materializa antes do UPDATE — sem isso
// PostgreSQL recusa o UPDATE com "invalid input syntax for type uuid".
// Fase 5 (2026-05-14): TODOS os status (realizado/faltou/cancelado/etc)
// passam por M.onUpdateSeriesEvent — que abre o AgendaStatusChangeConfirmDialog
// quando há regra de exceção, pacote saldo ou pending record. Antes, eventos
// reais faziam UPDATE direto sem passar pelo dialog (gap reportado pelo user).
async function updateEventoStatus(novoStatus, msgSucesso) {
const ev = eventoSelecionado.value;
if (!ev?.id || eventoBusy.value) return;
@@ -665,29 +677,18 @@ async function updateEventoStatus(novoStatus, msgSucesso) {
!!ev.is_occurrence ||
(typeof ev.id === 'string' && ev.id.startsWith('rec::'));
if (isVirtual) {
await M.onUpdateSeriesEvent({
id: null,
status: novoStatus,
recurrence_date:
ev.recurrence_date ||
ev.original_date ||
String(ev.inicio_em || '').slice(0, 10),
inicio_em: ev.inicio_em,
fim_em: ev.fim_em,
is_virtual: true,
// Passa o ev completo — sem isso o handler depende de
// dialogEventRow.value (que está vazio quando o user clica
// direto no evento do FC sem abrir o dialog antes).
row: ev
});
} else {
const { error } = await supabase
.from('agenda_eventos')
.update({ status: novoStatus })
.eq('id', ev.id);
if (error) throw error;
}
await M.onUpdateSeriesEvent({
id: isVirtual ? null : ev.id,
status: novoStatus,
recurrence_date:
ev.recurrence_date ||
ev.original_date ||
String(ev.inicio_em || '').slice(0, 10),
inicio_em: ev.inicio_em,
fim_em: ev.fim_em,
is_virtual: isVirtual,
row: ev
});
toast.add({ severity: 'success', summary: msgSucesso, life: 2200 });
// Refetch:
// - M.refetch() alimenta a Agenda (FullCalendar + ocorrências virtuais)
@@ -700,6 +701,7 @@ async function updateEventoStatus(novoStatus, msgSucesso) {
} catch (e) {
const msg = e?.message || 'Erro ao atualizar evento';
toast.add({ severity: 'error', summary: 'Falha ao atualizar', detail: msg, life: 4000 });
} finally {
eventoBusy.value = false;
}
}
@@ -708,6 +710,354 @@ function onConcluir() { updateEventoStatus('realizado', 'Sessão marcada como r
function onFaltou() { updateEventoStatus('faltou', 'Marcada como falta'); }
function onCancelar() { updateEventoStatus('cancelado', 'Evento cancelado'); }
// Excluir evento via popover (Fase 5, 2026-05-14). Regra: não permitir
// exclusão direta de ocorrência de série recorrente — usar fluxo do dialog
// pra encerrar série ou editar ocorrência. Pra avulsa: confirm + delete +
// remove cobranças vinculadas (com aviso explícito no confirm).
// Ver lançamentos da sessão — abre dialog com financial_records vinculados.
// Reusa o mesmo padrão do dialog dentro do AgendaEventDialog. 2026-05-14.
const lancamentosDialogOpen = ref(false);
const lancamentosList = ref([]);
const lancamentosLoading = ref(false);
const lancamentosEventoTitulo = ref('');
// Antecipar pagamento (Fase 5, 2026-05-14): paciente quer pagar antes da
// sessão (caso típico em pacote saldo). Materializa a ocorrência (se virtual)
// + cria financial_record paid (PIX/etc) ou pending (Asaas). NÃO decrementa
// sessions_used — só quando marcar Realizada depois.
const anteciparDialogOpen = ref(false);
const anteciparMethod = ref('pix');
const anteciparBusy = ref(false);
const anteciparEventoRef = ref(null); // snapshot do evento no momento do click
const anteciparMethodOptions = [
{ value: 'pix', label: 'Já recebi — PIX' },
{ value: 'dinheiro', label: 'Já recebi — Dinheiro' },
{ value: 'deposito', label: 'Já recebi — Depósito' },
{ value: 'cartao_maquininha', label: 'Já recebi — Cartão (maquininha)' },
{ value: 'link', label: 'Enviar link de pagamento (Asaas)' }
];
async function onAnteciparPagamento() {
const ev = eventoSelecionado.value;
if (!ev) return;
// Valida: precisa ter paciente, valor (price)
if (!ev.patient_id || !ev.price) {
toast.add({
severity: 'warn',
summary: 'Não é possível antecipar',
detail: 'Sessão precisa ter paciente e valor configurado.',
life: 4000
});
return;
}
anteciparEventoRef.value = ev;
anteciparMethod.value = 'pix';
anteciparDialogOpen.value = true;
}
async function confirmAnteciparPagamento() {
const ev = anteciparEventoRef.value;
if (!ev || anteciparBusy.value) return;
anteciparBusy.value = true;
try {
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
const ownerId = ev.owner_id || ev.terapeuta_id || null;
const settlement = anteciparMethod.value;
const amount = Number(ev.price) || 0;
const dueIso = ev.inicio_em ? new Date(ev.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
// 1) Materializa se virtual (cria agenda_evento real com status='agendado')
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
const isVirtual = ev.is_occurrence || isVirtualId;
let eventoId = ev.id;
if (isVirtual) {
const rid = ev.recurrence_id || ev.serie_id || null;
const rDate = ev.recurrence_date || ev.original_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
if (!rid || !rDate) throw new Error('Não foi possível identificar a regra de recorrência.');
// Confere se já não foi materializada
const { data: existing } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', rid)
.eq('recurrence_date', rDate)
.maybeSingle();
if (existing?.id) {
eventoId = existing.id;
} else {
const { data: created, error: cErr } = await supabase
.from('agenda_eventos')
.insert({
owner_id: ownerId,
tenant_id: tenantId,
recurrence_id: rid,
recurrence_date: rDate,
tipo: 'sessao',
status: 'agendado',
titulo: ev.titulo || 'Sessão',
inicio_em: ev.inicio_em,
fim_em: ev.fim_em,
patient_id: ev.patient_id,
determined_commitment_id: ev.determined_commitment_id || null,
modalidade: ev.modalidade || 'presencial',
price: amount,
visibility_scope: 'public'
})
.select('id')
.single();
if (cErr) throw cErr;
eventoId = created.id;
}
}
// 2) Verifica se já tem financial_record vinculado
const { data: existRec } = await supabase
.from('financial_records')
.select('id, status')
.eq('agenda_evento_id', eventoId)
.is('deleted_at', null)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (existRec?.status === 'paid') {
toast.add({ severity: 'info', summary: 'Já está pago', detail: 'Esta sessão já tem cobrança paga.', life: 3500 });
return;
}
// 3) Cria record via RPC (ou usa existente pending pra marcar paid)
let recordId = existRec?.id || null;
if (!recordId) {
const { error: rpcErr } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: ownerId,
p_patient_id: ev.patient_id,
p_agenda_evento_id: eventoId,
p_amount: amount,
p_due_date: dueIso
});
if (rpcErr) throw rpcErr;
const { data: newRec } = await supabase
.from('financial_records')
.select('id')
.eq('agenda_evento_id', eventoId)
.order('created_at', { ascending: false })
.limit(1)
.single();
recordId = newRec?.id;
}
// 4) Aplica status conforme settlement
if (recordId) {
const patch = { updated_at: new Date().toISOString() };
if (settlement === 'link') {
patch.payment_method = 'asaas';
// status fica pending
} else {
patch.status = 'paid';
patch.paid_at = new Date().toISOString();
patch.payment_method = settlement;
}
await supabase.from('financial_records').update(patch).eq('id', recordId);
}
const methodLabel = anteciparMethodOptions.find((o) => o.value === settlement)?.label || settlement;
toast.add({
severity: 'success',
summary: settlement === 'link' ? 'Cobrança gerada' : 'Pagamento registrado',
detail: `R$ ${amount.toFixed(2).replace('.', ',')}${methodLabel}`,
life: 4000
});
anteciparDialogOpen.value = false;
M.refetch();
refetchEventosHoje();
fecharEvento();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao antecipar pagamento.', life: 5000 });
} finally {
anteciparBusy.value = false;
}
}
async function onVerLancamentos() {
const ev = eventoSelecionado.value;
if (!ev?.id) return;
// Ocorrência virtual ainda não foi materializada — id é sintético
// `rec::<rule>::<date>`, não bate com agenda_evento_id (uuid).
// Aborta sem query e avisa o user. 2026-05-14.
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
if (ev.is_occurrence || isVirtualId) {
toast.add({
severity: 'info',
summary: 'Sem lançamentos ainda',
detail: 'Esta ocorrência ainda não foi materializada. Lançamentos aparecem após a primeira ação na sessão (status, edição etc).',
life: 5000
});
return;
}
lancamentosEventoTitulo.value = ev.pacienteNome || ev.label || ev.titulo || 'Sessão';
lancamentosDialogOpen.value = true;
lancamentosLoading.value = true;
try {
const { data, error } = await supabase
.from('financial_records')
.select('id, description, amount, final_amount, status, due_date, paid_at, payment_method, created_at')
.eq('agenda_evento_id', ev.id)
.is('deleted_at', null)
.order('created_at', { ascending: true });
if (error) throw error;
lancamentosList.value = data || [];
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao carregar lançamentos.', life: 4000 });
lancamentosList.value = [];
} finally {
lancamentosLoading.value = false;
}
}
function _fmtLancBRL(v) {
return Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function _fmtLancDate(d) {
if (!d) return '—';
try { return new Date(d).toLocaleDateString('pt-BR'); } catch { return '—'; }
}
const _lancMethodLabels = {
pix: 'PIX', dinheiro: 'Dinheiro', deposito: 'Depósito', cartao: 'Cartão',
cartao_maquininha: 'Cartão (maquininha)', convenio: 'Convênio', asaas: 'Asaas'
};
const _lancStatusLabels = {
pending: 'Pendente', paid: 'Pago', overdue: 'Vencido',
cancelled: 'Cancelado', refunded: 'Reembolsado', partial: 'Parcial'
};
function _lancStatusSeverity(s) {
return { pending: 'info', paid: 'success', overdue: 'danger', cancelled: 'secondary', refunded: 'warn', partial: 'warn' }[s] || 'secondary';
}
async function onDeleteEvento() {
const ev = eventoSelecionado.value;
if (!ev?.id || eventoBusy.value) return;
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
const isVirtual = ev.is_occurrence || isVirtualId;
// ── Ocorrência virtual: cria recurrence_exception (cancel_session) ──
// Sem interação ainda — segura excluir. Mostra confirm simples.
if (isVirtual) {
const recId = ev.recurrence_id || ev.serie_id;
const origDate = ev.recurrence_date || ev.original_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
if (!recId || !origDate) {
toast.add({ severity: 'warn', summary: 'Não excluível', detail: 'Não foi possível identificar a regra de recorrência.', life: 4000 });
return;
}
confirm.require({
header: 'Cancelar ocorrência',
message: 'Esta ocorrência ainda não tem cobranças. Tem certeza que deseja cancelá-la? Ela some da agenda; as outras sessões da série continuam.',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sim, cancelar',
rejectLabel: 'Manter',
acceptClass: 'p-button-danger',
accept: async () => {
eventoBusy.value = true;
try {
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
const { error } = await supabase.from('recurrence_exceptions').insert({
recurrence_id: recId,
tenant_id: tenantId,
original_date: origDate,
type: 'cancel_session',
reason: 'Cancelado pelo terapeuta antes de qualquer interação'
});
if (error) throw error;
toast.add({ severity: 'success', summary: 'Ocorrência cancelada', life: 2500 });
M.refetch();
refetchEventosHoje();
fecharEvento();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao cancelar.', life: 4000 });
} finally {
eventoBusy.value = false;
}
}
});
return;
}
// ── Evento real: conta cobranças vinculadas ──
let recordsCount = 0;
let hasPaidRecord = false;
try {
const { data } = await supabase
.from('financial_records')
.select('id, status')
.eq('agenda_evento_id', ev.id)
.is('deleted_at', null);
recordsCount = (data || []).length;
hasPaidRecord = (data || []).some((r) => r.status === 'paid');
} catch (e) {
console.warn('[Excluir sessão] erro contando records:', e?.message);
}
// Cobrança PAGA bloqueia exclusão — precisa estornar pelo Financeiro
if (hasPaidRecord) {
toast.add({
severity: 'warn',
summary: 'Sessão com pagamento confirmado',
detail: 'Esta sessão tem cobrança paga. Estorne primeiro pelo Financeiro antes de excluir.',
life: 5500
});
return;
}
// Evento de série materializado (tem recurrence_id) → vira exception
// (cancel_session) também, mas removendo records pendentes junto.
const isMaterializedOccurrence = !!ev.recurrence_id || !!ev.serie_id;
const msgRecords = recordsCount > 0
? `Esta sessão tem ${recordsCount} cobrança(s) pendente(s) que também será(ão) removida(s).`
: 'A sessão não tem cobranças vinculadas.';
confirm.require({
header: isMaterializedOccurrence ? 'Cancelar ocorrência' : 'Excluir sessão',
message: `${msgRecords} A ação não pode ser desfeita. Confirmar?`,
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sim, confirmar',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-danger',
accept: async () => {
eventoBusy.value = true;
try {
// 1) Remove cobranças vinculadas (não-pagas)
if (recordsCount > 0) {
const { error: recErr } = await supabase.from('financial_records').delete().eq('agenda_evento_id', ev.id);
if (recErr) throw recErr;
}
if (isMaterializedOccurrence) {
// Cria exception cancel_session + DELETE da row (some da agenda)
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({
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);
}
}
const { error } = await supabase.from('agenda_eventos').delete().eq('id', ev.id);
if (error) throw error;
const detail = recordsCount > 0 ? `Sessão e ${recordsCount} cobrança(s) removida(s).` : 'Sessão removida.';
toast.add({ severity: 'success', summary: isMaterializedOccurrence ? 'Ocorrência cancelada' : 'Excluída', detail, life: 3000 });
M.refetch();
refetchEventosHoje();
fecharEvento();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir.', life: 4000 });
} finally {
eventoBusy.value = false;
}
}
});
}
async function onWhatsapp() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
@@ -1709,6 +2059,9 @@ function onKeydown(e) {
@cancelar="onCancelar"
@remarcar="onRemarcar"
@edit-sessao="onEditEvento"
@delete-sessao="onDeleteEvento"
@ver-lancamentos="onVerLancamentos"
@antecipar-pagamento="onAnteciparPagamento"
@edit-paciente="onEditPaciente"
@abrir-prontuario="onAbrirProntuario"
@whatsapp="onWhatsapp"
@@ -2139,6 +2492,8 @@ function onKeydown(e) {
:allEvents="agendaAllEvents"
:pausasSemanais="agendaSettings?.pausas_semanais || []"
:feriados="agendaFeriados"
:serieRefreshTick="agendaSerieRefreshTick"
:blockOverlapWarning="agendaDialogBlockOverlap"
newPatientRoute="/therapist/patients/cadastro"
@save="M.onDialogSave"
@delete="M.onDialogDelete"
@@ -2146,6 +2501,34 @@ function onKeydown(e) {
@editSeriesOccurrence="M.onEditSeriesOccurrence"
/>
<!-- 2º AgendaEventDialog empilhado por cima do principal pra editar
uma OCORRÊNCIA específica de série. Acionado pelo botão "Editar"
nas pills da lista "Recorrências Aplicadas". Reusa os mesmos
handlers de save/delete/update o composable distingue pelo
id/recurrence_date. PrimeVue empilha automaticamente, então
nenhum gerenciamento manual de z-index é necessário.
Adicionado 2026-05-11; pendente replicar em Rail/Clínica. -->
<AgendaEventDialog
v-model="agendaOccDialogOpen"
:eventRow="agendaOccDialogEventRow"
:initialStartISO="agendaOccDialogStartISO"
:initialEndISO="agendaOccDialogEndISO"
:ownerId="agendaOwnerId"
:tenantId="agendaClinicTenantId"
:commitmentOptions="agendaCommitmentOptions"
:workRules="agendaWorkRules"
:blockedDates="[]"
:agendaSettings="agendaSettings"
:allEvents="agendaAllEvents"
:pausasSemanais="agendaSettings?.pausas_semanais || []"
:feriados="agendaFeriados"
newPatientRoute="/therapist/patients/cadastro"
:occurrenceMode="true"
@save="M.onDialogSave"
@delete="M.onDialogDelete"
@updateSeriesEvent="M.onUpdateSeriesEvent"
/>
<!-- BloqueioDialog bloqueio de horário/período/dia/feriados.
Trigger é o menu na toolbar da MelissaAgenda. Após salvar,
refetcha pra refletir o bloqueio na agenda. -->
@@ -2159,6 +2542,118 @@ function onKeydown(e) {
@saved="M.refetch"
/>
<!-- Dialog "Lançamentos da sessão" (2026-05-14): lista todos os
financial_records vinculados ao evento atual. Abre via botão
"Lançamentos" na seção Financeiro do MelissaEventoPanel. -->
<Dialog
v-model:visible="lancamentosDialogOpen"
modal
:draggable="false"
:style="{ width: '640px', maxWidth: '96vw' }"
>
<template #header>
<div class="flex flex-col gap-0.5">
<span class="text-base font-bold">Lançamentos da sessão</span>
<span class="text-xs opacity-70">{{ lancamentosEventoTitulo }}</span>
</div>
</template>
<div v-if="lancamentosLoading" class="py-6 text-center text-sm opacity-70">
<i class="pi pi-spin pi-spinner mr-1" /> Carregando
</div>
<div v-else-if="!lancamentosList.length" class="py-6 text-center text-sm opacity-70">
<i class="pi pi-info-circle mr-1" /> Nenhum lançamento vinculado a esta sessão.
</div>
<div v-else class="flex flex-col gap-2.5">
<div
v-for="(r, idx) in lancamentosList"
:key="r.id"
class="ml-lanc-card"
:class="{ 'ml-lanc-card--child': idx > 0 }"
>
<div class="ml-lanc-card__head">
<i v-if="idx > 0" class="pi pi-arrow-right-and-arrow-left-up-down ml-lanc-card__indent" />
<span class="ml-lanc-card__desc">{{ r.description || (idx === 0 ? 'Sessão' : 'Cobrança extra') }}</span>
<Tag :value="_lancStatusLabels[r.status] || r.status" :severity="_lancStatusSeverity(r.status)" class="text-xs ml-auto" />
</div>
<div class="ml-lanc-card__body">
<div class="ml-lanc-card__row">
<i class="pi pi-money-bill" />
<span class="ml-lanc-card__amount">{{ _fmtLancBRL(r.final_amount || r.amount) }}</span>
</div>
<div v-if="r.payment_method" class="ml-lanc-card__row">
<i class="pi pi-credit-card" />
<span>{{ _lancMethodLabels[r.payment_method] || r.payment_method }}</span>
</div>
<div class="ml-lanc-card__row">
<i class="pi pi-calendar" />
<span>Vencimento: {{ _fmtLancDate(r.due_date) }}</span>
</div>
<div v-if="r.paid_at" class="ml-lanc-card__row">
<i class="pi pi-check-circle" />
<span>Pago em {{ _fmtLancDate(r.paid_at) }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined @click="lancamentosDialogOpen = false" />
</template>
</Dialog>
<!-- Dialog "Antecipar pagamento" (Fase 5, 2026-05-14): paciente
quer pagar antes da sessão. Materializa ocorrência se virtual
e cria/atualiza financial_record. Não decrementa saldo. -->
<Dialog
v-model:visible="anteciparDialogOpen"
modal
:draggable="false"
header="Antecipar pagamento"
:style="{ width: '480px', maxWidth: '96vw' }"
>
<div class="flex flex-col gap-3 pt-1">
<div class="text-sm">
Receba antecipadamente o valor desta sessão.
</div>
<div v-if="anteciparEventoRef" class="flex flex-col gap-1 px-3 py-2 rounded-md bg-[var(--surface-section)] border border-[var(--surface-border)]">
<div class="text-sm font-semibold">{{ anteciparEventoRef.pacienteNome || 'Sessão' }}</div>
<div class="text-xs opacity-70">Valor: R$ {{ Number(anteciparEventoRef.price || 0).toFixed(2).replace('.', ',') }}</div>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium">Como o paciente pagou?</label>
<Select
v-model="anteciparMethod"
:options="anteciparMethodOptions"
optionLabel="label"
optionValue="value"
size="small"
/>
</div>
<small class="text-xs opacity-60">
O saldo do pacote será decrementado quando você marcar a sessão como Realizada.
</small>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="anteciparBusy" @click="anteciparDialogOpen = false" />
<Button label="Confirmar" icon="pi pi-check" :loading="anteciparBusy" @click="confirmAnteciparPagamento" />
</template>
</Dialog>
<!-- AgendaStatusChangeConfirmDialog Fase 5 (2026-05-14): aparece
quando user muda status pra realizado/faltou/cancelado e
decisão a tomar (regra de exceção, pacote saldo, pending). -->
<AgendaStatusChangeConfirmDialog
v-model="agendaStatusDialogOpen"
:evento="agendaStatusDialogProps.evento"
:novoStatus="agendaStatusDialogProps.novoStatus"
:regraExcecao="agendaStatusDialogProps.regraExcecao"
:billingContract="agendaStatusDialogProps.billingContract"
:billingContractStyle="agendaStatusDialogProps.billingContractStyle"
:pendingRecord="agendaStatusDialogProps.pendingRecord"
:sessionPrice="agendaStatusDialogProps.sessionPrice"
@confirm="agendaOnStatusDialogConfirm"
@update:modelValue="(v) => !v && agendaOnStatusDialogCancel()"
/>
<!-- Toast: AppLayout não monta no Melissa (rota fullscreen),
então as pages embedadas (config, agendador online, etc.)
precisam de um Toast próprio aqui pra não silenciar o
@@ -2676,6 +3171,54 @@ function onKeydown(e) {
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Dialog "Lançamentos da sessão" (2026-05-14) ── */
.ml-lanc-card {
border: 1px solid var(--surface-border);
border-radius: 8px;
padding: 0.7rem 0.85rem;
background: var(--surface-card);
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.ml-lanc-card--child {
background: color-mix(in srgb, var(--p-primary-color) 4%, var(--surface-card));
margin-left: 1.5rem;
border-color: color-mix(in srgb, var(--p-primary-color) 25%, var(--surface-border));
}
.ml-lanc-card__head {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ml-lanc-card__indent {
color: var(--text-color-secondary);
font-size: 0.7rem;
transform: scaleY(-1);
}
.ml-lanc-card__desc {
font-size: 0.88rem;
font-weight: 600;
color: var(--text-color);
}
.ml-lanc-card__body {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.ml-lanc-card__row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: var(--text-color-secondary);
}
.ml-lanc-card__row i { font-size: 0.72rem; }
.ml-lanc-card__amount {
font-weight: 600;
color: var(--text-color);
}
</style>
<!--