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:
@@ -34,6 +34,7 @@ import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
||||
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices';
|
||||
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
|
||||
import { useFeriados } from '@/composables/useFeriados';
|
||||
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
|
||||
|
||||
import { mapAgendaEventosToCalendarEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
|
||||
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
|
||||
@@ -52,6 +53,50 @@ const _queryDate = route.query.date ? new Date(route.query.date + 'T12:00:00') :
|
||||
// -------------------- feriados --------------------
|
||||
const { fcEvents: feriadoFcEvents, load: loadFeriados } = useFeriados();
|
||||
|
||||
// -------------------- bloqueios (background events cinza) --------------------
|
||||
const { bloqueios: bloqueioRows, load: loadBloqueios, buildEventsForRange: buildBloqueioEvents } = useAgendaBloqueios();
|
||||
|
||||
// Detecta se um range [start, end] cai dentro de algum bloqueio carregado.
|
||||
// Cobre dia-inteiro, janela horária e recorrência semanal. Não veta criação
|
||||
// (só agendador público veta), mas sinaliza pro user via toast.
|
||||
function bloqueioCobrindo(start, end) {
|
||||
const arr = bloqueioRows?.value || [];
|
||||
if (!arr.length || !start) return null;
|
||||
const dStart = start instanceof Date ? start : new Date(start);
|
||||
const dEnd = end instanceof Date ? end : new Date(end || start);
|
||||
const isoDay = `${dStart.getFullYear()}-${String(dStart.getMonth() + 1).padStart(2, '0')}-${String(dStart.getDate()).padStart(2, '0')}`;
|
||||
const dow = dStart.getDay();
|
||||
const hhmmStart = dStart.getHours() * 60 + dStart.getMinutes();
|
||||
const hhmmEnd = dEnd.getHours() * 60 + dEnd.getMinutes();
|
||||
const parseHM = (s) => {
|
||||
if (!s) return null;
|
||||
const [h, m] = String(s).split(':').map(Number);
|
||||
return Number.isFinite(h) ? h * 60 + (m || 0) : null;
|
||||
};
|
||||
for (const b of arr) {
|
||||
if (!b) continue;
|
||||
if (b.recorrente && b.dia_semana != null) {
|
||||
if (Number(b.dia_semana) !== dow) continue;
|
||||
} else {
|
||||
const di = b.data_inicio;
|
||||
const df = b.data_fim || b.data_inicio;
|
||||
if (!di) continue;
|
||||
if (isoDay < di || isoDay > df) continue;
|
||||
}
|
||||
const bhi = parseHM(b.hora_inicio);
|
||||
const bhf = parseHM(b.hora_fim);
|
||||
if (bhi == null || bhf == null) return b;
|
||||
if (hhmmStart < bhf && hhmmEnd > bhi) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const bloqueioFcEvents = computed(() => {
|
||||
const s = currentRange.value.start;
|
||||
const e = currentRange.value.end;
|
||||
if (!s || !e) return [];
|
||||
return buildBloqueioEvents(s, e);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||||
if (tid) loadFeriados(tid);
|
||||
@@ -535,7 +580,7 @@ const allEvents = computed(() => {
|
||||
.filter(Boolean);
|
||||
const occEvents = mapAgendaEventosToCalendarEvents(occRows);
|
||||
|
||||
return [...base, ...occEvents, ...feriadoFcEvents.value];
|
||||
return [...base, ...occEvents, ...feriadoFcEvents.value, ...bloqueioFcEvents.value];
|
||||
});
|
||||
|
||||
// -------------------- eventos fora da jornada --------------------
|
||||
@@ -925,6 +970,15 @@ async function openDialogCreate({ ownerId, start, end }) {
|
||||
}
|
||||
|
||||
async function onSlotSelect({ ownerId, start, end }) {
|
||||
const bloqHit = bloqueioCobrindo(start, end);
|
||||
if (bloqHit) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Horário bloqueado',
|
||||
detail: `Este horário está dentro do bloqueio "${bloqHit.titulo || 'Bloqueio'}". A sessão será criada mesmo assim.`,
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
await openDialogCreate({ ownerId, start, end });
|
||||
}
|
||||
|
||||
@@ -937,6 +991,10 @@ async function onEventClick(info) {
|
||||
if (!ev) return;
|
||||
|
||||
const ep = ev.extendedProps || {};
|
||||
|
||||
// Bloqueios/pausas são background events — ignorar click.
|
||||
if (ep.kind === 'bloqueio' || ep.kind === 'break') return;
|
||||
|
||||
dialogEventRow.value = {
|
||||
id: ep.isOccurrence ? null : ev.id || null,
|
||||
owner_id: ep.owner_id,
|
||||
@@ -1686,6 +1744,10 @@ async function _reloadRange() {
|
||||
allMerged.push(...merged.filter((r) => r.is_occurrence));
|
||||
}
|
||||
_occurrenceRows.value = allMerged;
|
||||
|
||||
// Bloqueios (background events) — load assíncrono em paralelo. Recebe
|
||||
// o array de owners da clínica pra agregar todos numa query batch.
|
||||
loadBloqueios(ownerIds.value, start, end);
|
||||
}
|
||||
|
||||
// Ocorrências virtuais geradas pelo useRecurrence
|
||||
|
||||
Reference in New Issue
Block a user