diff --git a/HANDOFF.md b/HANDOFF.md index 8a6f757..3c59951 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,47 +1,104 @@ -# HANDOFF — 2026-05-19 noite (C1-C7 ✅, próximo C8 — pacote saldo) +# HANDOFF — 2026-05-19 madrugada (C1-C8 ✅ + UI saldo + Usar/Revogar, próximo C9) Documento de continuidade. **Quando voltar, comece lendo esta página até o fim.** > **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** estamos na rodada de > testes manuais dos 13 cenários do doc viva -> `src/docs/agenda-compromisso-financeiro-cenarios.html`. **C1-C7 ✅**. -> Próximo: **Cenário 8** (Carl Jung · pacote SALDO · 4 × R$ 40 — modelo -> Cliniko: contrato sem cobrança imediata, cada sessão gera record ao -> ser realizada). +> `src/docs/agenda-compromisso-financeiro-cenarios.html`. **C1-C8 ✅**. +> Próximo: **Cenário 9** (Michael Balint · per_session · 12 × R$ 150 — +> materializa todas 12 sessões + cria 12 records pendentes upfront). -> **🟢 WORKING TREE LIMPO** após commit/push de 19/05 noite. Fase 6 -> (lock-edit cobrada) ativada em Melissa também. Lock + popover atalho -> "Gerar fatura" + propagação cross-week de pacote upfront tudo -> funcionando. +> **🟢 WORKING TREE LIMPO** após commit/push de 19/05 madrugada. UI de +> pacote saldo completa (info card violeta + botão Usar/Revogar atômico +> que materializa+realiza+cobra ou desfaz). Backfill de +> determined_commitment_id em revogar+usar (evita campo "Título" indevido +> em sessões legadas). --- -## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 8 (Pacote SALDO) +## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 9 (Per-session) | Campo | Valor | |---|---| -| Paciente | **Carl Jung** | -| Frequência | **Semanal · 4 ocorrências** | -| Serviço | Sessão (R$ 40 cada) | -| Cobrança ao salvar | **Pacote único** + estilo **"Saldo (Cliniko)"** | -| Total contrato | **R$ 160** (4 × 40) | +| Paciente | **Michael Balint** | +| Frequência | **Semanal · 12 ocorrências** | +| Serviço | Sessão R$ 150 | +| Cobrança ao salvar | **1 por sessão** (chargeMode=per_session) | **Esperado:** - 1 row em `recurrence_rules` -- **0 rows em `agenda_eventos`** materializadas inicialmente (saldo NÃO materializa 1ª) -- 1 row em `billing_contracts` (type=package, charging_style=**saldo**, total_sessions=4, package_price=160) -- **0 rows em `financial_records`** (sem cobrança imediata — modelo Cliniko) -- Agenda: 4 ocorrências virtuais **TODAS LIMPAS** (sem badge $, sem barra verde) — saldo intencionalmente não propaga estado, cada sessão gera cobrança individual quando vira realidade +- **12 rows em `agenda_eventos`** (TODAS materializadas — diferença chave vs C6-C8) +- 0 rows em `billing_contracts` (per_session NÃO usa contrato) +- **12 rows em `financial_records`** (1 por sessão, todos pending, amount=150 cada) +- Agenda: 12 sessões com **badge $ amber** em todas (cada uma tem seu record próprio) +- Popover de cada uma: linha amber "A receber R$ 150,00 (cobrança pendente)" -Diferença chave vs upfront: -- Upfront: 1 cobrança única (paga ou pendente) cobre todas as 4 sessões -- Saldo: contrato sem cobrança; cada sessão materializada GERA sua própria cobrança ao virar realidade (status realizado/faltou via flow C10-C12) +Diferença chave vs cenários anteriores: +- C6 (none): 1 row materializada + 0 records +- C7 (upfront): 1 row + 1 record (R$ totalPacote) +- C8 (saldo): 0 rows + 0 records (modelo Cliniko, geram on-the-fly via Usar) +- **C9 (per_session): N rows + N records pré-criados** -Após C8: C9 (per_session) → C10-C13 (status change + edit cobrada). +Após C9: C10-C13 (status change + edit cobrada). Quando todos passarem, replicar em **Rail** (`AgendaTerapeutaPage.vue`) e **Clínica** (`AgendaClinicaPage.vue`). --- +## 📦 O que foi feito em 19/05 madrugada (C8 + Usar/Revogar saldo + UI de pacote) + +### Cenário 8 ✅ (Pacote SALDO — Otávio Souza Ferreira 12 × R$ 50) +Testado e validado. Contract criado com `charging_style='saldo'`, 0 events materializadas, 0 records. Modelo Cliniko: sessões materializam on-demand via Usar. + +### UI do pacote (saldo + upfront) +- **`_ruleContractMap`** em useMelissaAgenda: bulk-load agora popula contract info (id, style, totalSessions, sessionsUsed, packagePrice) por `recurrence_id`. Query usa `recurrence_rules.patient_id` como fonte autoritativa (cobre saldo sem materializadas). +- **Normalize** injeta `contract` no evento → popover acessa via `ev.contract`. +- **Popover** (`MelissaEventoPanel`): nova linha colorida abaixo do payment: + - Saldo: violeta `"Pacote saldo · N/M usadas"` + botão verde **"Usar"** (paymentState=none) OU vermelho **"Revogar"** (paymentState=pending) + - Upfront: verde `"Pacote · N/M realizadas"` (sem botão; cobrança já tratada) +- **AgendaEventDialog**: info card mt-4 (saldo violeta / upfront emerald) com header (pacote+contador), body (total/per-session/restam), botão "Usar agora" ou "Revogar uso", hint explicando o modelo. Gateado por `occFinancialLoading` (spinner durante carga) pra evitar piscar entre estados. + +### Handlers Usar/Revogar atômicos +**`onUsarSessao`** em MelissaLayout (aceita payload do popover OU do dialog): +1. Materializa virtual se necessário (preserva `determined_commitment_id` da regra) +2. Status='realizado' + link `billing_contract_id` +3. `create_financial_record_for_session` RPC com per-session amount +4. Incrementa `billing_contracts.sessions_used` +5. Se atingiu total → contract `status='completed'` +6. Toast verde + fecha popover/dialog + +**`onRevogarSessao`** desfaz tudo: +1. Cancela financial_record (status='cancelled') +2. Decrementa sessions_used (não fica negativo) +3. Reativa contract se estava completed +4. Status volta pra 'agendado' +5. Bloqueia se record já está `paid` (precisa estorno formal pelo Financeiro) +6. **Backfill** de `determined_commitment_id` se NULL (fix de legado) + +### Fix: enum status_evento_agenda +Era `'realizada'` no insert/update, DB exige `'realizado'` (masculino). Corrigido em todas as ocorrências. + +### Fix: campo "Título" indevido no dialog +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` da regra +- Update path do Usar (sessão real após revogar) backfilla via query da rule +- Revogar também backfilla — garante consistência mesmo sem novo Usar +- SQL massivo de backfill disponível no HANDOFF pra limpar rows legadas + +### Fix: "Gerar fatura" não cabe em sessão de saldo +Hide do botão "Gerar fatura" no popover quando há `contractInfo`. Geraria cobrança solta sem incrementar saldo → duplicação. Fluxo correto: usar "Usar". + +### Recorrências Aplicadas: cores + badges +- Header stats: total **azul**, realizadas **verde**, faltaram **amber**, canceladas **cinza**, remarcadas **violeta** +- Pills: badge sólido por status (Realizado=emerald-600, Faltou=amber-600, Cancelado=stone-500, Remarcado=violet-600) + +### Race condition no dialog +- AgendaEventDialog mostrava botões "Usar"/"Revogar" baseado em `occFinancialRecord` que carrega async +- Durante load (~500ms), botão errado podia aparecer → snap pro correto depois +- Fix: spinner "Verificando estado…" enquanto `occFinancialLoading=true`; botões só renderizam após +- Popover decidiu manter como está (race window pequena, fechar/reabrir resolve) + +--- + ## 📦 O que foi feito em 19/05 noite (C7 + lock-edit + propagação cross-week) ### Cenário 7 ✅ (Pacote UPFRONT — Ana Souza Ferreira) @@ -224,8 +281,8 @@ User tentou rodar C5 e bateu em 3 problemas seguidos. Cada um virou um fix: | 5 | Avulsa convênio (Sándor + Unimed) | ✅ | | 6 | Recorrente sem pacote (Maria Magali / Anna Freud) | ✅ | | 7 | Pacote upfront (Ana Souza Ferreira 4 × R$ 200) | ✅ | -| **8** | **Pacote saldo (Carl Jung 4 × R$ 40)** | 🔴 **PRÓXIMO** | -| 9 | 1 por sessão (Michael Balint 12 sem) | ⏳ | +| 8 | Pacote saldo (Otávio 12 × R$ 50) | ✅ | +| **9** | **1 por sessão (Michael Balint 12 × R$ 150)** | 🔴 **PRÓXIMO** | | 10 | Status change avulsa (realizado/faltou/cancelado) | ⏳ | | 11 | Status change pacote saldo | ⏳ | | 12 | Antecipar pagamento (Carl Jung) | ⏳ | diff --git a/Obsidian/Brain/log.md b/Obsidian/Brain/log.md index a86c16d..0a2eb4d 100644 --- a/Obsidian/Brain/log.md +++ b/Obsidian/Brain/log.md @@ -14,6 +14,63 @@ Chronological, append-only record of everything that's happened in this wiki. --- +## [2026-05-20 03:00] session | C8 OK + Usar/Revogar saldo + UI pacote + ajustes UX +Touched: none (sem nova wiki page; mudancas em codigo + HANDOFF) +Detalhes: noite longa cobrindo C8 (pacote saldo) e principalmente +construindo a UI/UX de pacote saldo do zero — antes nao tinha +indicacao alguma no dialog/popover de que era saldo. + +CENARIO 8 (Pacote SALDO): +- Criou Otavio 12 sessoes R$50 saldo +- DB conforme esperado: 1 rule, 0 events, 1 contract (saldo), 0 records +- Visual conforme esperado: 12 virtuais limpas + +UI DE PACOTE (saldo + upfront): +- _ruleContractMap em useMelissaAgenda: bulk-load popula contract info + por recurrence_id (query recurrence_rules.patient_id como fonte + autoritativa, cobre saldo sem materializadas) +- Normalize injeta `contract` no evento +- Popover MelissaEventoPanel: linha colorida (violeta saldo, verde + upfront) com "Pacote X · N/M usadas|realizadas" + botão Usar + (verde, paymentState=none) OU Revogar (vermelho, paymentState=pending) +- AgendaEventDialog: info card mt-4 com header+body+hint explicando + modelo, botão "Usar agora"/"Revogar uso" gateado por + occFinancialLoading (spinner durante carga) + +HANDLERS USAR/REVOGAR ATOMICOS: +- onUsarSessao: materializa virtual + status=realizado + record per-session + + sessions_used++ + (se total) contract status=completed +- onRevogarSessao: cancela record + sessions_used-- + reativa contract + + status=agendado. Bloqueia se record paid (estorno formal precisa) +- Ambos aceitam payload do popover OU do dialog + +FIXES NA RODADA: +- Enum status_evento_agenda usa 'realizado' (masculino), nao 'realizada' +- determined_commitment_id backfill no materialize+revogar+update path + (sem isso, dialog mostrava campo "Titulo" indevidamente) +- "Gerar fatura" do popover esconde quando ha contract (evita + duplicacao em saldo) +- Race condition no dialog: spinner enquanto occFinancialLoading, + botoes so renderizam apos carga + +RECORRENCIAS APLICADAS — UI: +- Header stats coloridos por status (azul total, verde realizadas, + amber faltaram, cinza canceladas, violeta remarcadas) +- Pills com badge solido por status (em vez de texto cinza) + +DECISAO UX antes de codar (3 perguntas): +- Editar servico pago? NAO (cobranca fiscal imutavel) +- Alternar Particular/Convenio/Gratuito em serie cobrada? NAO +- Gerar fatura individual em pacote upfront? NAO (duplicacao) +Tudo isso o lock-edit (Fase 6) cobre. + +DECISAO UX "Usar" vs "Confirmacao": +- Recomendei Revogar (undo) em vez de confirmacao toda vez +- Friction baixo no fluxo principal (usuario "Usa" 12x ao longo de 3 meses) +- Reversivel enquanto record pending; paid bloqueia + +PROXIMO: Cenario 9 (Michael Balint per_session 12 x R$150). + ## [2026-05-19 23:00] session | C7 OK + Fase 6 lock em Melissa + cross-week propagation Touched: project_cross_week_propagation (nova) Detalhes: rodada longa cobrindo C7 (pacote upfront, Ana Souza Ferreira 4xR$200=R$800), diff --git a/src/features/agenda/components/AgendaEventDialog.vue b/src/features/agenda/components/AgendaEventDialog.vue index 5459cbf..4057f5e 100644 --- a/src/features/agenda/components/AgendaEventDialog.vue +++ b/src/features/agenda/components/AgendaEventDialog.vue @@ -147,7 +147,7 @@ const props = defineProps({ blockOverlapWarning: { type: Object, default: null } }); -const emit = defineEmits(['update:modelValue', 'save', 'delete', 'updateSeriesEvent', 'editSeriesOccurrence', 'updated']); +const emit = defineEmits(['update:modelValue', 'save', 'delete', 'updateSeriesEvent', 'editSeriesOccurrence', 'updated', 'usar-sessao', 'revogar-sessao']); const confirm = useConfirm(); const toast = useToast(); const router = useRouter(); @@ -596,6 +596,7 @@ const { occFinancialRecord, occFinancialLoading, sessionPaymentRecord, + sessionContract, serieCountByStatus, pillDeleteMenuItems, loadSerieEvents, @@ -1009,6 +1010,40 @@ const paymentSummary = computed(() => { return null; }); +// Info card do pacote (saldo/upfront) — quando a sessão pertence a uma +// série com billing_contract ativo. Pra saldo é a única forma do user +// entender o que tá acontecendo (nada aparece em /financeiro até +// realizar). Pra upfront é redundante com o lock-edit mas ainda útil. +const sessionContractInfo = computed(() => { + const c = sessionContract.value; + if (!c || c.type !== 'package') return null; + const total = c.total_sessions || 0; + const used = c.sessions_used || 0; + const remaining = Math.max(0, total - used); + const totalPrice = Number(c.package_price || 0); + const perSession = total > 0 ? totalPrice / total : 0; + const remainingValue = perSession * remaining; + return { + id: c.id, + style: c.charging_style || 'upfront', + total, + used, + remaining, + totalPrice, + perSession, + remainingValue, + // Forma "popover-shape" pra reusar no emit usar-sessao (handler em + // MelissaLayout aceita ambos os fluxos: popover ou dialog) + _normalized: { + id: c.id, + style: c.charging_style || 'upfront', + totalSessions: total, + sessionsUsed: used, + packagePrice: totalPrice + } + }; +}); + // ── Preview "antes e depois" das ocorrencias afetadas ───────────────── // So vale no occurrenceMode quando o user escolhe escopo > somente_este. // Mostra abaixo do card "Aplicar alteracoes em" duas colunas: as datas @@ -2153,11 +2188,11 @@ onBeforeUnmount(() => { Recorrências Aplicadas
- {{ serieEvents.length }} sessões - · {{ serieCountByStatus.realizado }} realizadas - · {{ serieCountByStatus.faltou }} faltaram - · {{ serieCountByStatus.cancelado }} canceladas - · {{ serieCountByStatus.remarcado }} para remarcar + {{ serieEvents.length }} sessões + · {{ serieCountByStatus.realizado }} realizadas + · {{ serieCountByStatus.faltou }} faltaram + · {{ serieCountByStatus.cancelado }} canceladas + · {{ serieCountByStatus.remarcado }} para remarcar
Carregando… @@ -2197,6 +2232,70 @@ onBeforeUnmount(() => { 2026-05-12 — só aparece no 2º dialog empilhado de ocorrência. -->
+ +
+
+ + + Pacote {{ sessionContractInfo.style === 'saldo' ? 'saldo' : 'fechado (upfront)' }} + + {{ sessionContractInfo.used }} / {{ sessionContractInfo.total }} usadas +
+
+
+ Total contratado: + {{ fmtBRL(sessionContractInfo.totalPrice) }} +
+
+ Por sessão: + {{ fmtBRL(sessionContractInfo.perSession) }} +
+
+ Restam: + {{ sessionContractInfo.remaining }} sessão(ões) · {{ fmtBRL(sessionContractInfo.remainingValue) }} +
+ +
+ + Verificando estado… +
+ +
+
+ @@ -3635,6 +3734,104 @@ onBeforeUnmount(() => { color: var(--text-color); } +/* Info card de pacote (saldo/upfront) — 2026-05-19 noite. Mostra status + do contrato quando sessão pertence a série com billing_contract ativo. + Saldo: tom violeta (contexto, modelo Cliniko). Upfront: tom verde + (já cobrado, status positivo). */ +.aed-contract-card { + border-radius: 10px; + border: 1px solid; + overflow: hidden; + font-size: 0.82rem; +} +.aed-contract-card--saldo { + background: color-mix(in srgb, rgb(124, 58, 237) 6%, var(--surface-card)); + border-color: color-mix(in srgb, rgb(124, 58, 237) 30%, transparent); +} +.aed-contract-card--upfront { + background: color-mix(in srgb, rgb(16, 185, 129) 6%, var(--surface-card)); + border-color: color-mix(in srgb, rgb(16, 185, 129) 30%, transparent); +} +.aed-contract-card__head { + display: flex; + align-items: center; + gap: 8px; + padding: 0.55rem 0.85rem; + border-bottom: 1px solid color-mix(in srgb, var(--surface-border), transparent 30%); + font-weight: 600; +} +.aed-contract-card--saldo .aed-contract-card__head > i { + color: rgb(124, 58, 237); +} +.aed-contract-card--upfront .aed-contract-card__head > i { + color: rgb(16, 185, 129); +} +.aed-contract-card__title { + flex: 1; +} +.aed-contract-card__count { + font-size: 0.74rem; + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, currentColor 10%, transparent); + font-weight: 600; + letter-spacing: 0.02em; +} +.aed-contract-card__body { + padding: 0.7rem 0.85rem 0.85rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.aed-contract-card__row { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 0.78rem; +} +.aed-contract-card__label { + color: var(--text-color-secondary); +} +.aed-contract-card__value { + font-weight: 600; + color: var(--text-color); +} +.aed-contract-card__hint { + display: flex; + align-items: flex-start; + gap: 6px; + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px dashed color-mix(in srgb, var(--surface-border), transparent 30%); + font-size: 0.74rem; + color: var(--text-color-secondary); + line-height: 1.4; +} +.aed-contract-card__hint > i { + margin-top: 1px; + flex-shrink: 0; +} +.aed-contract-card--saldo .aed-contract-card__hint > i { + color: rgb(124, 58, 237); +} +.aed-contract-card--upfront .aed-contract-card__hint > i { + color: rgb(16, 185, 129); +} +.aed-contract-card__hint b { + color: var(--text-color); +} +.aed-contract-card__loading { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 0.5rem; + font-size: 0.74rem; + color: var(--text-color-secondary); +} +.aed-contract-card__loading > i { + color: var(--p-primary-color); +} + /* Padding interno do card "Campos Extras (compromisso)" — mesmo tratamento do aed-pay-body. Sem isso os inputs ficam grudados nas bordas. */ @@ -5195,12 +5392,42 @@ onBeforeUnmount(() => { display: flex; gap: 0.3rem; font-size: 0.7rem; - font-weight: 400; + font-weight: 500; text-transform: none; letter-spacing: 0; - opacity: 0.75; margin-left: 0.25rem; } +/* Contadores coloridos por estado no header de Recorrências Aplicadas */ +.serie-stat--total { + color: rgb(37, 99, 235); /* blue-600 */ +} +.serie-stat--realizado { + color: rgb(5, 150, 105); /* emerald-600 */ +} +.serie-stat--faltou { + color: rgb(217, 119, 6); /* amber-600 */ +} +.serie-stat--cancelado { + color: rgb(120, 113, 108); /* stone-500 */ +} +.serie-stat--remarcado { + color: rgb(124, 58, 237); /* violet-600 */ +} +html.app-dark .serie-stat--total { + color: rgb(96, 165, 250); /* blue-400 */ +} +html.app-dark .serie-stat--realizado { + color: rgb(52, 211, 153); /* emerald-400 */ +} +html.app-dark .serie-stat--faltou { + color: rgb(251, 191, 36); /* amber-400 */ +} +html.app-dark .serie-stat--cancelado { + color: rgb(168, 162, 158); /* stone-400 */ +} +html.app-dark .serie-stat--remarcado { + color: rgb(167, 139, 250); /* violet-400 */ +} .serie-panel__empty { padding: 0.85rem; font-size: 0.82rem; @@ -5310,6 +5537,47 @@ onBeforeUnmount(() => { overflow: hidden; text-overflow: ellipsis; } +/* Badge sólido por status quando relevante (realizado/faltou/cancelado/ + remarcado). Agendado fica neutro (default acima). */ +.serie-pill--realizado .serie-pill__status-badge, +.serie-pill--realizada .serie-pill__status-badge { + flex: 0 0 auto; + color: white; + background: rgb(5, 150, 105); /* emerald-600 */ + padding: 0.15rem 0.6rem; + border-radius: 999px; + font-size: 0.66rem; + letter-spacing: 0.02em; +} +.serie-pill--faltou .serie-pill__status-badge { + flex: 0 0 auto; + color: white; + background: rgb(217, 119, 6); /* amber-600 */ + padding: 0.15rem 0.6rem; + border-radius: 999px; + font-size: 0.66rem; + letter-spacing: 0.02em; +} +.serie-pill--cancelado .serie-pill__status-badge, +.serie-pill--cancelada .serie-pill__status-badge { + flex: 0 0 auto; + color: white; + background: rgb(120, 113, 108); /* stone-500 */ + padding: 0.15rem 0.6rem; + border-radius: 999px; + font-size: 0.66rem; + letter-spacing: 0.02em; +} +.serie-pill--remarcado .serie-pill__status-badge, +.serie-pill--remarcada .serie-pill__status-badge { + flex: 0 0 auto; + color: white; + background: rgb(124, 58, 237); /* violet-600 */ + padding: 0.15rem 0.6rem; + border-radius: 999px; + font-size: 0.66rem; + letter-spacing: 0.02em; +} .serie-pill__cur-badge { font-size: 0.58rem; font-weight: 700; diff --git a/src/features/agenda/composables/useAgendaEventLifecycle.js b/src/features/agenda/composables/useAgendaEventLifecycle.js index 3214bac..3f8a7d9 100644 --- a/src/features/agenda/composables/useAgendaEventLifecycle.js +++ b/src/features/agenda/composables/useAgendaEventLifecycle.js @@ -112,6 +112,14 @@ export function useAgendaEventLifecycle({ // continua via occFinancialRecord (território da Fase 6/C13). const sessionPaymentRecord = ref(null); + // sessionContract (2026-05-19 noite): billing_contract ativo do paciente + // quando a sessão pertence a uma série com pacote (upfront ou saldo). + // Usado pra exibir info card no dialog explicando o pacote (qtd + // sessões usadas/restantes, valor, comportamento). Pra saldo, é a + // única forma do user entender o que tá acontecendo (nada aparece + // no /financeiro até realizar sessão). + const sessionContract = ref(null); + // ── computeds locais ─────────────────────────────────────── const serieCountByStatus = computed(() => { const counts = {}; @@ -323,6 +331,33 @@ export function useAgendaEventLifecycle({ } } + // loadSessionContract (2026-05-19 noite): busca billing_contract ativo + // do paciente quando o evento pertence a uma série com pacote. Usado + // pra info card no dialog explicando o pacote saldo/upfront. + async function loadSessionContract() { + sessionContract.value = null; + const patientId = props.eventRow?.paciente_id || props.eventRow?.patient_id; + const ruleId = props.eventRow?.recurrence_id; + // Só faz sentido pra sessão de série + if (!patientId || !ruleId) return; + try { + const { data, error } = await supabase + .from('billing_contracts') + .select('id, type, total_sessions, sessions_used, package_price, charging_style, status, active_from') + .eq('patient_id', patientId) + .eq('type', 'package') + .eq('status', 'active') + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); + if (error) throw error; + sessionContract.value = data ?? null; + } catch (e) { + console.warn('[session-contract] erro ao carregar:', e?.message); + sessionContract.value = null; + } + } + function onPillEditClick(ev) { emit('editSeriesOccurrence', { id: ev.id, @@ -508,6 +543,7 @@ export function useAgendaEventLifecycle({ // sessionPaymentRecord: carrega em qualquer edit (Melissa // tambem) pra alimentar a linha "Cobrança" do Resumo lateral. loadSessionPaymentRecord(); + loadSessionContract(); // occurrenceMode: editando UMA ocorrencia de serie ja existente — // tipo de compromisso ja foi escolhido (paciente + sessao). Pular @@ -628,6 +664,8 @@ export function useAgendaEventLifecycle({ loadSerieEvents, loadOccFinancialRecord, loadSessionPaymentRecord, + sessionContract, + loadSessionContract, onPillEditClick, onPillStatusChange, onPillDeleteClick, diff --git a/src/layout/melissa/MelissaEventoPanel.vue b/src/layout/melissa/MelissaEventoPanel.vue index c4eaa66..4fa00c8 100644 --- a/src/layout/melissa/MelissaEventoPanel.vue +++ b/src/layout/melissa/MelissaEventoPanel.vue @@ -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. --> +
+ +
+ + {{ contractInfo.label }} + + + + +
+
{{ ev.modalidade }} @@ -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 { diff --git a/src/layout/melissa/MelissaLayout.vue b/src/layout/melissa/MelissaLayout.vue index e2eb50a..a46b67b 100644 --- a/src/layout/melissa/MelissaLayout.vue +++ b/src/layout/melissa/MelissaLayout.vue @@ -1245,6 +1245,246 @@ async function onGerarCobrancaQuick() { } } +// "Usar" sessão de pacote saldo — combina em uma ação: +// 1. Materializa virtual (se ainda virtual) +// 2. Status='realizada' +// 3. Cria financial_record per_session via RPC (status='pending', dueDate=hoje) +// 4. Incrementa billing_contracts.sessions_used +// 5. Se sessions_used == total → contract.status='completed' +// Reflete o modelo Cliniko: paciente comparece, sessão é "usada" do saldo, +// nasce a cobrança individual. +async function onUsarSessao(payload = null) { + // Aceita payload do dialog ({ eventRow, contract }) OU usa fallback + // do estado do popover (eventoSelecionado + ev.contract injetado pelo + // bulk-load). Permite reuse do handler em ambos os fluxos. + const ev = payload?.eventRow || eventoSelecionado.value; + if (!ev || eventoBusy.value) return; + const contract = payload?.contract || ev?.contract; + if (!contract?.id) { + toast.add({ severity: 'warn', summary: 'Sem contrato', detail: 'Esta sessão não pertence a um pacote ativo.', life: 3000 }); + return; + } + if (contract.style !== 'saldo') { + // Pra upfront, "Usar" não cabe (pacote já foi pago de uma vez) + toast.add({ severity: 'info', summary: 'Pacote upfront', detail: 'Este pacote já foi cobrado integralmente. Use "Realizada" pra marcar status.', life: 4000 }); + return; + } + + eventoBusy.value = true; + try { + const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null; + const ownerId = M?.ownerId?.value || ev.owner_id; + const perSession = contract.totalSessions > 0 ? (contract.packagePrice || 0) / contract.totalSessions : 0; + const dueDate = ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : new Date().toISOString().slice(0, 10); + + // 1) Materializa virtual se necessário + let eventoId = ev.id; + const isVirtualId = typeof eventoId === 'string' && eventoId.startsWith('rec::'); + if (ev.is_occurrence || isVirtualId) { + const recurrenceDate = ev.recurrence_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null); + // Preserva determined_commitment_id da regra (necessário pra + // isSessionEvent=true no AgendaEventDialog; sem ele o campo + // "Título" aparece indevidamente porque o dialog acha que não + // é sessão). + const raw = ev._raw || {}; + const { data: created, error: matErr } = await supabase + .from('agenda_eventos') + .insert({ + owner_id: ownerId, + tenant_id: tenantId, + patient_id: ev.patient_id || ev.paciente_id, + recurrence_id: ev.recurrence_id, + recurrence_date: recurrenceDate, + tipo: 'sessao', + status: 'realizado', + titulo: ev.label || ev.titulo || raw.titulo || 'Sessão', + inicio_em: ev.inicio_em, + fim_em: ev.fim_em, + modalidade: ev.modalidade || raw.modalidade || 'presencial', + determined_commitment_id: raw.determined_commitment_id || null, + price: perSession, + billing_contract_id: contract.id, + visibility_scope: 'public' + }) + .select('id') + .single(); + if (matErr) throw matErr; + eventoId = created.id; + } else { + // Sessão real: atualiza status + garante link com contrato. + // Tambem backfilla determined_commitment_id se estiver NULL + // (legado de uses antigos antes do fix). Sem isso o dialog + // mostra campo "Título" indevidamente (isSessionEvent=false). + const raw = ev._raw || {}; + const patch = { status: 'realizado', billing_contract_id: contract.id }; + const commitmentId = raw.determined_commitment_id || null; + if (commitmentId) { + patch.determined_commitment_id = commitmentId; + } else { + // Busca da rule (fonte autoritativa) se ev não tem + const { data: rule } = await supabase + .from('recurrence_rules') + .select('determined_commitment_id') + .eq('id', ev.recurrence_id) + .maybeSingle(); + if (rule?.determined_commitment_id) { + patch.determined_commitment_id = rule.determined_commitment_id; + } + } + const { error: upErr } = await supabase + .from('agenda_eventos') + .update(patch) + .eq('id', eventoId); + if (upErr) throw upErr; + } + + // 2) Cria financial_record per_session (RPC ignora cancelled na idempotência) + const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', { + p_tenant_id: tenantId, + p_owner_id: ownerId, + p_patient_id: ev.patient_id || ev.paciente_id, + p_agenda_evento_id: eventoId, + p_amount: perSession, + p_due_date: dueDate + }); + if (cobErr) throw cobErr; + + // 3) Incrementa sessions_used + completa contract se necessário + const newUsed = (contract.sessionsUsed || 0) + 1; + const patchContract = { sessions_used: newUsed }; + if (newUsed >= contract.totalSessions) { + patchContract.status = 'completed'; + } + const { error: ctErr } = await supabase + .from('billing_contracts') + .update(patchContract) + .eq('id', contract.id); + if (ctErr) throw ctErr; + + toast.add({ + severity: 'success', + summary: 'Sessão usada do pacote', + detail: `Cobrança de R$ ${perSession.toFixed(2).replace('.', ',')} gerada. Saldo: ${newUsed}/${contract.totalSessions}.`, + life: 4000 + }); + M.refetch(); + refetchEventosHoje(); + // Fecha popover + dialogs eventuais (chamada pode vir do popover OU + // de qualquer um dos AgendaEventDialog empilhados) + fecharEvento(); + if (M.dialogOpen) M.dialogOpen.value = false; + if (M.occDialogOpen) M.occDialogOpen.value = false; + } catch (e) { + toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao usar sessão.', life: 5000 }); + } finally { + eventoBusy.value = false; + } +} + +// "Revogar" sessão usada do pacote saldo — desfaz onUsarSessao: +// 1. Cancela financial_record (status='cancelled') +// 2. Decrementa billing_contracts.sessions_used (se > 0) +// 3. Se contract estava completed, volta pra active +// 4. Status do evento volta pra 'agendado' +// Constraint: só funciona se record ainda pending. Pago = bloqueado (precisa +// estorno formal pelo Financeiro). +async function onRevogarSessao(payload = null) { + // Aceita payload do dialog ({ eventRow, contract }) OU usa fallback + // do popover (eventoSelecionado + ev.contract). + const ev = payload?.eventRow || eventoSelecionado.value; + if (!ev || eventoBusy.value) return; + const contract = payload?.contract || ev?.contract; + if (!contract?.id) { + toast.add({ severity: 'warn', summary: 'Sem contrato', detail: 'Esta sessão não pertence a um pacote ativo.', life: 3000 }); + return; + } + if (!ev.id || ev.is_occurrence) { + toast.add({ severity: 'warn', summary: 'Não disponível', detail: 'Sessão ainda não foi usada.', life: 3000 }); + return; + } + + eventoBusy.value = true; + try { + // Acha record vinculado a essa sessão e ao contrato + const { data: recs } = await supabase + .from('financial_records') + .select('id, status') + .eq('agenda_evento_id', ev.id) + .neq('status', 'cancelled') + .order('created_at', { ascending: false }); + const activeRec = (recs || []).find((r) => r.status !== 'cancelled'); + if (activeRec && activeRec.status === 'paid') { + toast.add({ + severity: 'warn', + summary: 'Cobrança já paga', + detail: 'Esta sessão já foi paga. Estorne primeiro pelo Financeiro antes de revogar.', + life: 5000 + }); + return; + } + + // 1) Cancela record (se ainda pending) + if (activeRec) { + const { error: cancelErr } = await supabase + .from('financial_records') + .update({ status: 'cancelled', updated_at: new Date().toISOString() }) + .eq('id', activeRec.id); + if (cancelErr) throw cancelErr; + } + + // 2) Decrementa sessions_used + reativa contract se estava completed + const newUsed = Math.max(0, (contract.sessionsUsed || 0) - 1); + const patchContract = { sessions_used: newUsed }; + // Se contrato estava completed (sessionsUsed == total) e agora < total, reativa + if ((contract.sessionsUsed || 0) >= contract.totalSessions) { + patchContract.status = 'active'; + } + const { error: ctErr } = await supabase + .from('billing_contracts') + .update(patchContract) + .eq('id', contract.id); + if (ctErr) throw ctErr; + + // 3) Status do evento volta pra agendado. + // Backfill determined_commitment_id se estiver NULL (legado de uses + // antigos antes do fix). Garante que próxima abertura do dialog + // não exiba campo "Título" indevidamente. + const raw = ev._raw || {}; + const patch = { status: 'agendado' }; + if (!raw.determined_commitment_id && ev.recurrence_id) { + const { data: rule } = await supabase + .from('recurrence_rules') + .select('determined_commitment_id') + .eq('id', ev.recurrence_id) + .maybeSingle(); + if (rule?.determined_commitment_id) { + patch.determined_commitment_id = rule.determined_commitment_id; + } + } + const { error: upErr } = await supabase + .from('agenda_eventos') + .update(patch) + .eq('id', ev.id); + if (upErr) throw upErr; + + toast.add({ + severity: 'success', + summary: 'Sessão revogada', + detail: `Cobrança cancelada. Saldo: ${newUsed}/${contract.totalSessions}.`, + life: 4000 + }); + M.refetch(); + refetchEventosHoje(); + fecharEvento(); + if (M.dialogOpen) M.dialogOpen.value = false; + if (M.occDialogOpen) M.occDialogOpen.value = false; + } catch (e) { + toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao revogar sessão.', life: 5000 }); + } finally { + eventoBusy.value = false; + } +} + async function onWhatsapp() { const ev = eventoSelecionado.value; if (!ev?.patient_id) { @@ -2249,6 +2489,8 @@ function onKeydown(e) { @delete-sessao="onDeleteEvento" @delete-series="onDeleteSeries" @gerar-cobranca="onGerarCobrancaQuick" + @usar-sessao="onUsarSessao" + @revogar-sessao="onRevogarSessao" @ver-lancamentos="onVerLancamentos" @antecipar-pagamento="onAnteciparPagamento" @edit-paciente="onEditPaciente" @@ -2688,6 +2930,8 @@ function onKeydown(e) { @delete="M.onDialogDelete" @updateSeriesEvent="M.onUpdateSeriesEvent" @editSeriesOccurrence="M.onEditSeriesOccurrence" + @usar-sessao="onUsarSessao" + @revogar-sessao="onRevogarSessao" />