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:
@@ -214,6 +214,109 @@ export function buildWeeklyBreakBackgroundEvents(pausas, rangeStart, rangeEnd) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// buildBloqueioBackgroundEvents
|
||||
// Renderiza rows de public.agenda_bloqueios como background events cinza
|
||||
// no FullCalendar. Suporta:
|
||||
// - Bloqueio de dia inteiro (hora_inicio/fim NULL) → background do dia todo
|
||||
// - Bloqueio com janela horária → background no intervalo
|
||||
// - Bloqueio recorrente semanal (recorrente=true + dia_semana 0-6) →
|
||||
// repetido em todas as ocorrências do dow no range
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildBloqueioBackgroundEvents(bloqueios, rangeStart, rangeEnd) {
|
||||
if (!Array.isArray(bloqueios) || bloqueios.length === 0) return [];
|
||||
if (!rangeStart || !rangeEnd) return [];
|
||||
|
||||
const out = [];
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const startMs = startOfDay(rangeStart).getTime();
|
||||
const endMs = rangeEnd.getTime();
|
||||
|
||||
for (const b of bloqueios) {
|
||||
if (!b) continue;
|
||||
const titulo = b.titulo || 'Bloqueio';
|
||||
|
||||
// Recorrente semanal: itera o range varrendo dias-da-semana iguais
|
||||
if (b.recorrente && b.dia_semana != null) {
|
||||
const dow = Number(b.dia_semana);
|
||||
if (!Number.isFinite(dow) || dow < 0 || dow > 6) continue;
|
||||
const hi = asTime(b.hora_inicio);
|
||||
const hf = asTime(b.hora_fim);
|
||||
for (let ts = startMs; ts < endMs; ts += dayMs) {
|
||||
const d = new Date(ts);
|
||||
if (d.getDay() !== dow) continue;
|
||||
if (hi && hf) {
|
||||
out.push(_makeBloqueioEvent(b.id ?? null, d, hi, hf, titulo));
|
||||
} else {
|
||||
out.push(_makeBloqueioDayEvent(b.id ?? null, d, titulo));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Não recorrente: usa data_inicio e (opcional) data_fim
|
||||
const di = _parseISODate(b.data_inicio);
|
||||
if (!di) continue;
|
||||
const df = _parseISODate(b.data_fim) ?? di;
|
||||
|
||||
// Range de dias do bloqueio (inclusive)
|
||||
for (let cur = di.getTime(); cur <= df.getTime(); cur += dayMs) {
|
||||
const d = new Date(cur);
|
||||
// Filtra fora do range visível pra evitar lixo
|
||||
if (d.getTime() + dayMs < startMs || d.getTime() > endMs) continue;
|
||||
const hi = asTime(b.hora_inicio);
|
||||
const hf = asTime(b.hora_fim);
|
||||
if (hi && hf) {
|
||||
out.push(_makeBloqueioEvent(b.id ?? null, d, hi, hf, titulo));
|
||||
} else {
|
||||
out.push(_makeBloqueioDayEvent(b.id ?? null, d, titulo));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function _makeBloqueioEvent(id, date, timeStart, timeEnd, titulo) {
|
||||
const tag = id ?? `blq-${date.getTime()}-${timeStart}-${timeEnd}`;
|
||||
return {
|
||||
id: `blq-${tag}`,
|
||||
title: titulo,
|
||||
start: combineDateTimeISO(date, timeStart),
|
||||
end: combineDateTimeISO(date, timeEnd),
|
||||
display: 'background',
|
||||
overlap: false,
|
||||
backgroundColor: '#6b728033', // cinza ~20%
|
||||
extendedProps: { kind: 'bloqueio', bloqueioId: id, label: titulo }
|
||||
};
|
||||
}
|
||||
|
||||
function _makeBloqueioDayEvent(id, date, titulo) {
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
const dateStr = `${yyyy}-${mm}-${dd}`;
|
||||
const tag = id ?? `blq-${dateStr}`;
|
||||
return {
|
||||
id: `blq-day-${tag}`,
|
||||
title: titulo,
|
||||
start: dateStr,
|
||||
allDay: true,
|
||||
display: 'background',
|
||||
overlap: false,
|
||||
backgroundColor: '#6b728033',
|
||||
extendedProps: { kind: 'bloqueio', bloqueioId: id, label: titulo }
|
||||
};
|
||||
}
|
||||
|
||||
function _parseISODate(s) {
|
||||
if (!s) return null;
|
||||
const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!m) return null;
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// minutesToDuration / tituloFallback
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user