agenda: C8 OK + Usar/Revogar pacote saldo + UI de contract + ajustes UX
Cenário 8 (Pacote SALDO — Otávio Souza Ferreira 12 × R$ 50)
- Testado e passou. DB: 1 rule, 0 events, 1 contract (saldo), 0 records.
Visual: 12 virtuais limpas no calendário.
UI de pacote (saldo + upfront)
- _ruleContractMap em useMelissaAgenda: bulk-load popula contract info
(id, style, totalSessions, sessionsUsed, packagePrice) por
recurrence_id. Query recurrence_rules.patient_id como fonte
autoritativa — cobre saldo sem materializadas (sem isso, ruleToPatient
via records vinha vazio pra saldo)
- normalize injeta `contract` no evento via ruleContractMap
- MelissaEventoPanel: nova linha colorida (violeta saldo, verde upfront)
com "Pacote saldo · N/M usadas" ou "Pacote · N/M realizadas"
- AgendaEventDialog: info card mt-4 com header+body+hint explicando
modelo, gateado por occFinancialLoading (spinner durante carga
pra evitar piscar entre Usar/Revogar)
Handlers Usar/Revogar atômicos
- onUsarSessao em MelissaLayout: materializa virtual (preserva
determined_commitment_id da regra) → status=realizado +
billing_contract_id → create_financial_record_for_session →
sessions_used++ → (se atingiu total) contract.status=completed
- onRevogarSessao: cancela record + sessions_used-- + reativa contract
se estava completed + status=agendado. Bloqueia se record paid
(precisa estorno formal pelo Financeiro)
- Ambos aceitam payload {eventRow, contract} do dialog OU fallback
pra eventoSelecionado do popover
- Botão "Usar" verde no popover (paymentState=none) substituído por
"Revogar" vermelho (paymentState=pending). Equivalente "Usar agora"/
"Revogar uso" no info card do dialog
Fix enum status_evento_agenda
- 'realizada' não existe no enum — DB exige 'realizado' (masculino).
Corrigido em todas as ocorrências do handler
Fix campo "Título" indevido em sessão
- Sessão sem determined_commitment_id → selectedCommitment=null →
isSessionEvent=false → mostra campo Título (que é só pra não-sessão)
- Fix: materialize do Usar inclui determined_commitment_id (insert
path); update path backfilla via query da rule se NULL; Revogar
também backfilla pra consistência
Fix "Gerar fatura" não cabe em saldo
- Botão "Gerar fatura" do popover hide quando há contractInfo. Em
saldo, gerar fatura solta criaria cobrança duplicada sem incrementar
sessions_used. Fluxo correto: "Usar"
Recorrências Aplicadas — UI
- Header stats coloridos: total **azul**, realizadas **verde**,
faltaram **amber**, canceladas **cinza**, remarcadas **violeta**
- Pills com badge sólido por status (emerald-600 realizado, amber-600
faltou, stone-500 cancelado, violet-600 remarcado)
Race condition no dialog
- AgendaEventDialog mostrava botões Usar/Revogar baseado em
occFinancialRecord async; durante ~500ms de load, botão errado
podia piscar. Fix: spinner "Verificando estado…" enquanto
occFinancialLoading=true; botões só renderizam após
- Popover não fixado (race window pequena, fechar/reabrir resolve)
3 decisões UX confirmadas antes de codar
- Editar serviço pago → NÃO (cobrança fiscal imutável)
- Alternar Particular/Convênio/Gratuito em série cobrada → NÃO
- Gerar fatura individual em pacote upfront → NÃO (duplicação)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,9 @@ const emit = defineEmits([
|
||||
'delete-series', // botão "Excluir série inteira" — hard delete da regra + materializadas + records pendentes
|
||||
'ver-lancamentos', // botão "Lançamentos" — abre dialog com financial_records vinculados
|
||||
'antecipar-pagamento', // botão "Antecipar pagamento" — paciente quer pagar antes da sessão (pacote saldo)
|
||||
'gerar-cobranca' // botão inline "Gerar fatura" ao lado de "A cobrar R$ X" — atalho sem precisar abrir dialog
|
||||
'gerar-cobranca', // botão inline "Gerar fatura" ao lado de "A cobrar R$ X" — atalho sem precisar abrir dialog
|
||||
'usar-sessao', // botão "Usar" no card de pacote saldo — materializa+realizada+gera cobrança individual
|
||||
'revogar-sessao' // botão "Revogar" — desfaz Usar (cancela record + decrementa saldo). Bloqueado se já pago
|
||||
]);
|
||||
|
||||
// Regra atualizada (2026-05-14): botão Excluir aparece SEMPRE pra sessão.
|
||||
@@ -74,6 +76,31 @@ const seriesLabel = computed(() => {
|
||||
return 'Série recorrente';
|
||||
});
|
||||
|
||||
// Info de contrato (saldo/upfront) — vem injetada pelo bulk-load via
|
||||
// _ruleContractMap. Mostra linha no popover indicando o tipo de pacote
|
||||
// e progresso (usadas/total). Pra saldo é especialmente importante —
|
||||
// sem essa linha o user não sabe o que está acontecendo (nenhuma
|
||||
// cobrança no /financeiro até realizar).
|
||||
const contractInfo = computed(() => {
|
||||
const c = ev.value.contract;
|
||||
if (!c) return null;
|
||||
const total = c.totalSessions || 0;
|
||||
const used = c.sessionsUsed || 0;
|
||||
const remaining = Math.max(0, total - used);
|
||||
const perSession = total > 0 ? (c.packagePrice || 0) / total : 0;
|
||||
return {
|
||||
style: c.style || 'upfront',
|
||||
total,
|
||||
used,
|
||||
remaining,
|
||||
packagePrice: c.packagePrice || 0,
|
||||
perSession,
|
||||
label: c.style === 'saldo'
|
||||
? `Pacote saldo · ${used}/${total} usadas`
|
||||
: `Pacote · ${used}/${total} realizadas`
|
||||
};
|
||||
});
|
||||
|
||||
const ev = computed(() => props.evento || {});
|
||||
|
||||
const tipoLabel = computed(() => {
|
||||
@@ -250,8 +277,13 @@ function modalidadeIcon(mod) {
|
||||
com paymentState='none' (cobrança ainda não gerada).
|
||||
Pago/pendente já existe um record; nesses casos não
|
||||
cabe gerar de novo. -->
|
||||
<!-- "Gerar fatura" só faz sentido pra sessão SEM contrato
|
||||
de pacote OU sem saldo ativo. Em sessão de saldo o
|
||||
fluxo correto é "Usar" (que orquestra cobrança +
|
||||
sessions_used) — gerar fatura solta aqui criaria
|
||||
cobrança duplicada e dessincronizaria o saldo. -->
|
||||
<button
|
||||
v-if="paymentVariant === 'none' && !ev.is_occurrence"
|
||||
v-if="paymentVariant === 'none' && !ev.is_occurrence && !contractInfo"
|
||||
type="button"
|
||||
class="evento-row__pay-action"
|
||||
:disabled="busy"
|
||||
@@ -263,6 +295,44 @@ function modalidadeIcon(mod) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Linha de info do contrato (saldo/upfront) — quando a
|
||||
sessão pertence a uma série com billing_contract ativo.
|
||||
Pra saldo é a única forma do user entender que tem um
|
||||
pacote sem cobrança no /financeiro até realizar.
|
||||
Botão "Usar" só pra saldo com saldo disponível —
|
||||
materializa+realizada+gera cobrança em 1 click. -->
|
||||
<div v-if="contractInfo" class="evento-row evento-row--contract" :class="`evento-row--contract-${contractInfo.style}`">
|
||||
<i class="pi pi-box" />
|
||||
<span>{{ contractInfo.label }}</span>
|
||||
<!-- Revogar: aparece quando sessão já foi "usada" (record
|
||||
pending vinculado). Bloqueia pra paid (precisa estorno
|
||||
formal pelo Financeiro). -->
|
||||
<button
|
||||
v-if="contractInfo.style === 'saldo' && !ev.is_occurrence && ev.paymentState === 'pending'"
|
||||
type="button"
|
||||
class="evento-row__pay-action evento-row__pay-action--revogar"
|
||||
:disabled="busy"
|
||||
v-tooltip.top="'Desfaz o uso (cancela cobrança e devolve 1 ao saldo)'"
|
||||
@click="emit('revogar-sessao')"
|
||||
>
|
||||
<i class="pi pi-undo" />
|
||||
<span>Revogar</span>
|
||||
</button>
|
||||
<!-- Usar: aparece quando há saldo + sessão ainda não usada
|
||||
(virtual OU materializada sem cobrança ativa). -->
|
||||
<button
|
||||
v-else-if="contractInfo.style === 'saldo' && contractInfo.remaining > 0 && (ev.is_occurrence || ev.paymentState === 'none')"
|
||||
type="button"
|
||||
class="evento-row__pay-action evento-row__pay-action--contract"
|
||||
:disabled="busy"
|
||||
v-tooltip.top="'Marca sessão como realizada e gera cobrança (1 do saldo)'"
|
||||
@click="emit('usar-sessao')"
|
||||
>
|
||||
<i class="pi pi-check" />
|
||||
<span>Usar</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ev.modalidade" class="evento-row">
|
||||
<i :class="modalidadeIcon(ev.modalidade)" />
|
||||
<span class="capitalize">{{ ev.modalidade }}</span>
|
||||
@@ -607,6 +677,68 @@ html.app-dark .evento-row__pay-action {
|
||||
border-color: color-mix(in srgb, #fbbf24 30%, transparent);
|
||||
}
|
||||
|
||||
/* Linha de contrato — exibe "Pacote saldo · N/M usadas" ou "Pacote · N/M
|
||||
realizadas". Saldo em violeta (modelo Cliniko), upfront em verde
|
||||
(já cobrado). Acompanha as cores do info card do AgendaEventDialog. */
|
||||
.evento-row--contract {
|
||||
font-weight: 500;
|
||||
}
|
||||
.evento-row--contract-saldo {
|
||||
color: rgb(124, 58, 237); /* violet-600 */
|
||||
}
|
||||
.evento-row--contract-saldo > i {
|
||||
color: rgb(124, 58, 237);
|
||||
}
|
||||
.evento-row--contract-upfront {
|
||||
color: rgb(5, 150, 105); /* emerald-600 */
|
||||
}
|
||||
.evento-row--contract-upfront > i {
|
||||
color: rgb(5, 150, 105);
|
||||
}
|
||||
html.app-dark .evento-row--contract-saldo {
|
||||
color: #a78bfa; /* violet-400 */
|
||||
}
|
||||
html.app-dark .evento-row--contract-saldo > i {
|
||||
color: #a78bfa;
|
||||
}
|
||||
html.app-dark .evento-row--contract-upfront {
|
||||
color: #34d399; /* emerald-400 */
|
||||
}
|
||||
html.app-dark .evento-row--contract-upfront > i {
|
||||
color: #34d399;
|
||||
}
|
||||
/* Override do pay-action quando dentro da linha de contrato — usa
|
||||
violeta (saldo) pra combinar com a linha. */
|
||||
.evento-row__pay-action--contract {
|
||||
color: rgb(124, 58, 237) !important;
|
||||
background: color-mix(in srgb, rgb(124, 58, 237) 12%, transparent) !important;
|
||||
border-color: color-mix(in srgb, rgb(124, 58, 237) 36%, transparent) !important;
|
||||
}
|
||||
.evento-row__pay-action--contract:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, rgb(124, 58, 237) 22%, transparent) !important;
|
||||
border-color: color-mix(in srgb, rgb(124, 58, 237) 56%, transparent) !important;
|
||||
}
|
||||
html.app-dark .evento-row__pay-action--contract {
|
||||
color: #a78bfa !important;
|
||||
background: color-mix(in srgb, #a78bfa 14%, transparent) !important;
|
||||
border-color: color-mix(in srgb, #a78bfa 30%, transparent) !important;
|
||||
}
|
||||
/* Revogar — vermelho/amber pra sinalizar ação destrutiva. */
|
||||
.evento-row__pay-action--revogar {
|
||||
color: rgb(220, 38, 38) !important; /* red-600 */
|
||||
background: color-mix(in srgb, rgb(220, 38, 38) 10%, transparent) !important;
|
||||
border-color: color-mix(in srgb, rgb(220, 38, 38) 32%, transparent) !important;
|
||||
}
|
||||
.evento-row__pay-action--revogar:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, rgb(220, 38, 38) 18%, transparent) !important;
|
||||
border-color: color-mix(in srgb, rgb(220, 38, 38) 50%, transparent) !important;
|
||||
}
|
||||
html.app-dark .evento-row__pay-action--revogar {
|
||||
color: #f87171 !important; /* red-400 */
|
||||
background: color-mix(in srgb, #f87171 14%, transparent) !important;
|
||||
border-color: color-mix(in srgb, #f87171 30%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Stack de botões "Editar sessão" + "Excluir sessão" (Fase 5, 2026-05-14).
|
||||
Empilhados verticalmente à direita da linha das horas. */
|
||||
.evento-row__edit-stack {
|
||||
|
||||
Reference in New Issue
Block a user