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:
@@ -394,6 +394,18 @@ const fcEvents = computed(() => {
|
||||
// abaixo. Mantem a cor do commitment pra nao perder contexto.
|
||||
const pStatus = ev.paciente_status;
|
||||
const isInactivePatient = pStatus === 'Arquivado' || pStatus === 'Inativo';
|
||||
// Sessão paga → barra esquerda verde (override do border-left que o
|
||||
// FC pinta com a cor do commitment). Espelha as mesmas condições do
|
||||
// badge $ amber: sessão + paciente + não-virtual; aqui inverte pra
|
||||
// paymentState === 'paid'.
|
||||
const isPaidSession =
|
||||
String(ev.tipo || '').toLowerCase() === 'sessao' &&
|
||||
(ev.patient_id || ev.paciente_id) &&
|
||||
!ev.is_occurrence &&
|
||||
ev.paymentState === 'paid';
|
||||
const cls = [];
|
||||
if (isInactivePatient) cls.push('ma-evt--inactive-patient');
|
||||
if (isPaidSession) cls.push('ma-evt--paid');
|
||||
out.push({
|
||||
id: ev.id,
|
||||
title: ev.label,
|
||||
@@ -402,7 +414,7 @@ const fcEvents = computed(() => {
|
||||
backgroundColor: `${ev.color}26`, // ~15% opacity
|
||||
borderColor: ev.color,
|
||||
textColor: 'white',
|
||||
classNames: isInactivePatient ? ['ma-evt--inactive-patient'] : undefined,
|
||||
classNames: cls.length ? cls : undefined,
|
||||
extendedProps: ev
|
||||
});
|
||||
}
|
||||
@@ -411,6 +423,11 @@ const fcEvents = computed(() => {
|
||||
if (feriados.length) {
|
||||
for (const f of feriados) out.push(f);
|
||||
}
|
||||
// Bloqueios (background events cinza) — concat direto sem filtro.
|
||||
const blqs = bloqueioFcEvents.value;
|
||||
if (blqs.length) {
|
||||
for (const b of blqs) out.push(b);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -568,7 +585,11 @@ const fcOptions = computed(() => ({
|
||||
},
|
||||
eventClick: (info) => {
|
||||
const ev = info.event.extendedProps;
|
||||
if (ev) emit('select-evento', ev);
|
||||
if (!ev) return;
|
||||
// Bloqueios e pausas semanais são background events não-clicáveis —
|
||||
// o painel lateral é só pra sessões/compromissos reais.
|
||||
if (ev.kind === 'bloqueio' || ev.kind === 'break') return;
|
||||
emit('select-evento', ev);
|
||||
},
|
||||
// Drag → reagenda evento (mesmo dia, hora diferente OU outro dia)
|
||||
eventDrop: (info) => {
|
||||
@@ -621,6 +642,18 @@ const fcOptions = computed(() => ({
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Badge "$ a receber" — sessão com paciente, ainda não paga.
|
||||
// Cobre Cenário 2 (sem cobrança, sem record) e Cenário 3 (cobrança
|
||||
// pendente). Esconde quando paid ou quando não é sessão com paciente.
|
||||
// Ocorrências virtuais sempre 'none' até serem materializadas — pra
|
||||
// não poluir séries recorrentes com pacote upfront/saldo (cobertas
|
||||
// pelo contrato, não por record-por-sessão).
|
||||
let payBadgeHtml = '';
|
||||
if (isSessao && ext.patient_id && !ext.is_occurrence && ext.paymentState !== 'paid') {
|
||||
payBadgeHtml = `<span class="mc-fc-event__paybadge" title="Cobrança pendente"><i class="pi pi-dollar"></i></span>`;
|
||||
}
|
||||
|
||||
// Pra eventos não-sessão (compromisso, bloqueio etc.) mantém o
|
||||
// antigo `__meta` com modalidade ou título secundário.
|
||||
const metaFallback = !isSessao ? (ext.modalidade || ext.titulo || '') : '';
|
||||
@@ -628,6 +661,7 @@ const fcOptions = computed(() => ({
|
||||
return {
|
||||
html: `
|
||||
<div class="mc-fc-event">
|
||||
${payBadgeHtml}
|
||||
${titleLine}
|
||||
${badgesHtml}
|
||||
${metaFallback ? `<div class="mc-fc-event__meta">${escHtml(metaFallback)}</div>` : ''}
|
||||
@@ -729,15 +763,23 @@ function goNext() { fcApi()?.next(); }
|
||||
function goToday() { fcApi()?.today(); }
|
||||
|
||||
function setView(v) {
|
||||
// Detecta saida da view 'lista' antes de trocar — se o user veio de
|
||||
// lista, o refDate atual ta em (hoje - 1 ano) e ao mudar pra week/month
|
||||
// o FullCalendar mantem esse refDate, fazendo a agenda parecer estar
|
||||
// no ano passado. Snap pra hoje resolve. 2026-05-12.
|
||||
const leavingLista = calendarView.value === 'lista' && v !== 'lista';
|
||||
|
||||
calendarView.value = v;
|
||||
fcApi()?.changeView(VIEW_MAP[v]);
|
||||
// Lista cobre 2 anos — abrimos centrado: pula pra (hoje - 1 ano) pra
|
||||
// mostrar passado + presente + futuro de uma vez. Outras views mantém
|
||||
// o refDate atual (datesSet sincroniza viewStart/End normalmente).
|
||||
|
||||
if (v === 'lista') {
|
||||
// Lista cobre 2 anos — abrimos centrado: pula pra (hoje - 1 ano) pra
|
||||
// mostrar passado + presente + futuro de uma vez.
|
||||
const umAnoAtras = new Date();
|
||||
umAnoAtras.setFullYear(umAnoAtras.getFullYear() - 1);
|
||||
fcApi()?.gotoDate(umAnoAtras);
|
||||
} else if (leavingLista) {
|
||||
fcApi()?.today();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,10 +821,12 @@ const tenantStore = useTenantStore();
|
||||
// acessos diretos a M.x dispararam TypeError ao montar fora do layout.
|
||||
const _feriadosFallback = ref([]);
|
||||
const _feriadoFcEventsFallback = ref([]);
|
||||
const _bloqueioFcEventsFallback = ref([]);
|
||||
const _feriadosAnoFallback = ref(new Date().getFullYear());
|
||||
const _workRulesFallback = ref([]);
|
||||
const feriadosTodos = M?.feriados ?? _feriadosFallback;
|
||||
const feriadoFcEvents = M?.feriadoFcEvents ?? _feriadoFcEventsFallback;
|
||||
const bloqueioFcEvents = M?.bloqueioFcEvents ?? _bloqueioFcEventsFallback;
|
||||
const feriadosAno = M?.feriadosAno ?? _feriadosAnoFallback;
|
||||
const loadFeriados = M?.loadFeriadosBase ?? (async () => {});
|
||||
const workRules = M?.workRules ?? _workRulesFallback;
|
||||
@@ -2291,10 +2335,49 @@ defineExpose({
|
||||
.ma-cal__fc :deep(.fc-list-event.ma-evt--inactive-patient td) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Sessão paga — barra esquerda verde no lugar da cor do commitment.
|
||||
Espelho positivo do badge $ amber: pago = canal visual esquerdo,
|
||||
pendente = canal direito, sem cobrança = neutro. !important porque
|
||||
o FC seta border-color inline a partir do borderColor do evento. */
|
||||
.ma-cal__fc :deep(.fc-event.ma-evt--paid) {
|
||||
border-left-color: #10b981 !important; /* emerald-500 */
|
||||
border-left-width: 4px !important;
|
||||
}
|
||||
.ma-cal__fc :deep(.fc-list-event.ma-evt--paid .fc-list-event-dot) {
|
||||
border-color: #10b981 !important;
|
||||
}
|
||||
|
||||
.ma-cal__fc :deep(.mc-fc-event) {
|
||||
padding: 4px 6px;
|
||||
color: var(--m-text);
|
||||
font-family: inherit;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Badge "$ a receber" — canto superior direito do evento. Amarelo
|
||||
amber, pequeno, sinaliza cobrança pendente sem competir com os
|
||||
badges de status/modalidade. Renderizado só pra sessão+paciente
|
||||
com paymentState !== 'paid'. */
|
||||
.ma-cal__fc :deep(.mc-fc-event__paybadge) {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 3px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 9999px;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
z-index: 2;
|
||||
}
|
||||
.ma-cal__fc :deep(.mc-fc-event__paybadge .pi) {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ma-cal__fc :deep(.mc-fc-event__title) {
|
||||
/* Mesmo tamanho/peso da .ma-pat__name (lista de pacientes) pra
|
||||
|
||||
Reference in New Issue
Block a user