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:
+82
-25
@@ -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.**
|
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
|
> **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** estamos na rodada de
|
||||||
> testes manuais dos 13 cenários do doc viva
|
> testes manuais dos 13 cenários do doc viva
|
||||||
> `src/docs/agenda-compromisso-financeiro-cenarios.html`. **C1-C7 ✅**.
|
> `src/docs/agenda-compromisso-financeiro-cenarios.html`. **C1-C8 ✅**.
|
||||||
> Próximo: **Cenário 8** (Carl Jung · pacote SALDO · 4 × R$ 40 — modelo
|
> Próximo: **Cenário 9** (Michael Balint · per_session · 12 × R$ 150 —
|
||||||
> Cliniko: contrato sem cobrança imediata, cada sessão gera record ao
|
> materializa todas 12 sessões + cria 12 records pendentes upfront).
|
||||||
> ser realizada).
|
|
||||||
|
|
||||||
> **🟢 WORKING TREE LIMPO** após commit/push de 19/05 noite. Fase 6
|
> **🟢 WORKING TREE LIMPO** após commit/push de 19/05 madrugada. UI de
|
||||||
> (lock-edit cobrada) ativada em Melissa também. Lock + popover atalho
|
> pacote saldo completa (info card violeta + botão Usar/Revogar atômico
|
||||||
> "Gerar fatura" + propagação cross-week de pacote upfront tudo
|
> que materializa+realiza+cobra ou desfaz). Backfill de
|
||||||
> funcionando.
|
> 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 |
|
| Campo | Valor |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Paciente | **Carl Jung** |
|
| Paciente | **Michael Balint** |
|
||||||
| Frequência | **Semanal · 4 ocorrências** |
|
| Frequência | **Semanal · 12 ocorrências** |
|
||||||
| Serviço | Sessão (R$ 40 cada) |
|
| Serviço | Sessão R$ 150 |
|
||||||
| Cobrança ao salvar | **Pacote único** + estilo **"Saldo (Cliniko)"** |
|
| Cobrança ao salvar | **1 por sessão** (chargeMode=per_session) |
|
||||||
| Total contrato | **R$ 160** (4 × 40) |
|
|
||||||
|
|
||||||
**Esperado:**
|
**Esperado:**
|
||||||
- 1 row em `recurrence_rules`
|
- 1 row em `recurrence_rules`
|
||||||
- **0 rows em `agenda_eventos`** materializadas inicialmente (saldo NÃO materializa 1ª)
|
- **12 rows em `agenda_eventos`** (TODAS materializadas — diferença chave vs C6-C8)
|
||||||
- 1 row em `billing_contracts` (type=package, charging_style=**saldo**, total_sessions=4, package_price=160)
|
- 0 rows em `billing_contracts` (per_session NÃO usa contrato)
|
||||||
- **0 rows em `financial_records`** (sem cobrança imediata — modelo Cliniko)
|
- **12 rows em `financial_records`** (1 por sessão, todos pending, amount=150 cada)
|
||||||
- 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
|
- 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:
|
Diferença chave vs cenários anteriores:
|
||||||
- Upfront: 1 cobrança única (paga ou pendente) cobre todas as 4 sessões
|
- C6 (none): 1 row materializada + 0 records
|
||||||
- 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)
|
- 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`).
|
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)
|
## 📦 O que foi feito em 19/05 noite (C7 + lock-edit + propagação cross-week)
|
||||||
|
|
||||||
### Cenário 7 ✅ (Pacote UPFRONT — Ana Souza Ferreira)
|
### 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) | ✅ |
|
| 5 | Avulsa convênio (Sándor + Unimed) | ✅ |
|
||||||
| 6 | Recorrente sem pacote (Maria Magali / Anna Freud) | ✅ |
|
| 6 | Recorrente sem pacote (Maria Magali / Anna Freud) | ✅ |
|
||||||
| 7 | Pacote upfront (Ana Souza Ferreira 4 × R$ 200) | ✅ |
|
| 7 | Pacote upfront (Ana Souza Ferreira 4 × R$ 200) | ✅ |
|
||||||
| **8** | **Pacote saldo (Carl Jung 4 × R$ 40)** | 🔴 **PRÓXIMO** |
|
| 8 | Pacote saldo (Otávio 12 × R$ 50) | ✅ |
|
||||||
| 9 | 1 por sessão (Michael Balint 12 sem) | ⏳ |
|
| **9** | **1 por sessão (Michael Balint 12 × R$ 150)** | 🔴 **PRÓXIMO** |
|
||||||
| 10 | Status change avulsa (realizado/faltou/cancelado) | ⏳ |
|
| 10 | Status change avulsa (realizado/faltou/cancelado) | ⏳ |
|
||||||
| 11 | Status change pacote saldo | ⏳ |
|
| 11 | Status change pacote saldo | ⏳ |
|
||||||
| 12 | Antecipar pagamento (Carl Jung) | ⏳ |
|
| 12 | Antecipar pagamento (Carl Jung) | ⏳ |
|
||||||
|
|||||||
@@ -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
|
## [2026-05-19 23:00] session | C7 OK + Fase 6 lock em Melissa + cross-week propagation
|
||||||
Touched: project_cross_week_propagation (nova)
|
Touched: project_cross_week_propagation (nova)
|
||||||
Detalhes: rodada longa cobrindo C7 (pacote upfront, Ana Souza Ferreira 4xR$200=R$800),
|
Detalhes: rodada longa cobrindo C7 (pacote upfront, Ana Souza Ferreira 4xR$200=R$800),
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ const props = defineProps({
|
|||||||
blockOverlapWarning: { type: Object, default: null }
|
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 confirm = useConfirm();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -596,6 +596,7 @@ const {
|
|||||||
occFinancialRecord,
|
occFinancialRecord,
|
||||||
occFinancialLoading,
|
occFinancialLoading,
|
||||||
sessionPaymentRecord,
|
sessionPaymentRecord,
|
||||||
|
sessionContract,
|
||||||
serieCountByStatus,
|
serieCountByStatus,
|
||||||
pillDeleteMenuItems,
|
pillDeleteMenuItems,
|
||||||
loadSerieEvents,
|
loadSerieEvents,
|
||||||
@@ -1009,6 +1010,40 @@ const paymentSummary = computed(() => {
|
|||||||
return null;
|
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 ─────────────────
|
// ── Preview "antes e depois" das ocorrencias afetadas ─────────────────
|
||||||
// So vale no occurrenceMode quando o user escolhe escopo > somente_este.
|
// So vale no occurrenceMode quando o user escolhe escopo > somente_este.
|
||||||
// Mostra abaixo do card "Aplicar alteracoes em" duas colunas: as datas
|
// Mostra abaixo do card "Aplicar alteracoes em" duas colunas: as datas
|
||||||
@@ -2153,11 +2188,11 @@ onBeforeUnmount(() => {
|
|||||||
<i class="pi pi-refresh shrink-0" />
|
<i class="pi pi-refresh shrink-0" />
|
||||||
<span>Recorrências Aplicadas</span>
|
<span>Recorrências Aplicadas</span>
|
||||||
<div v-if="!serieLoading && serieEvents.length" class="serie-panel__stats">
|
<div v-if="!serieLoading && serieEvents.length" class="serie-panel__stats">
|
||||||
<span>{{ serieEvents.length }} sessões</span>
|
<span class="serie-stat serie-stat--total">{{ serieEvents.length }} sessões</span>
|
||||||
<span v-if="serieCountByStatus.realizado"> · {{ serieCountByStatus.realizado }} realizadas</span>
|
<span v-if="serieCountByStatus.realizado" class="serie-stat serie-stat--realizado"> · {{ serieCountByStatus.realizado }} realizadas</span>
|
||||||
<span v-if="serieCountByStatus.faltou"> · {{ serieCountByStatus.faltou }} faltaram</span>
|
<span v-if="serieCountByStatus.faltou" class="serie-stat serie-stat--faltou"> · {{ serieCountByStatus.faltou }} faltaram</span>
|
||||||
<span v-if="serieCountByStatus.cancelado"> · {{ serieCountByStatus.cancelado }} canceladas</span>
|
<span v-if="serieCountByStatus.cancelado" class="serie-stat serie-stat--cancelado"> · {{ serieCountByStatus.cancelado }} canceladas</span>
|
||||||
<span v-if="serieCountByStatus.remarcado"> · {{ serieCountByStatus.remarcado }} para remarcar</span>
|
<span v-if="serieCountByStatus.remarcado" class="serie-stat serie-stat--remarcado"> · {{ serieCountByStatus.remarcado }} para remarcar</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="serieLoading" class="ml-auto text-xs opacity-50">Carregando…</span>
|
<span v-if="serieLoading" class="ml-auto text-xs opacity-50">Carregando…</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2197,6 +2232,70 @@ onBeforeUnmount(() => {
|
|||||||
2026-05-12 — só aparece no 2º dialog empilhado de ocorrência. -->
|
2026-05-12 — só aparece no 2º dialog empilhado de ocorrência. -->
|
||||||
<div v-if="!occurrenceMode" class="composer-right">
|
<div v-if="!occurrenceMode" class="composer-right">
|
||||||
|
|
||||||
|
<!-- Info card do pacote (saldo/upfront) — quando a sessão
|
||||||
|
pertence a série com billing_contract ativo. Pra saldo
|
||||||
|
é a única forma do user entender que existe um saldo
|
||||||
|
(sem record em /financeiro até realizar). 2026-05-19 noite. -->
|
||||||
|
<div v-if="sessionContractInfo && isSessionEvent" class="aed-contract-card mt-4" :class="`aed-contract-card--${sessionContractInfo.style}`">
|
||||||
|
<div class="aed-contract-card__head">
|
||||||
|
<i class="pi pi-box" />
|
||||||
|
<span class="aed-contract-card__title">
|
||||||
|
Pacote {{ sessionContractInfo.style === 'saldo' ? 'saldo' : 'fechado (upfront)' }}
|
||||||
|
</span>
|
||||||
|
<span class="aed-contract-card__count">{{ sessionContractInfo.used }} / {{ sessionContractInfo.total }} usadas</span>
|
||||||
|
</div>
|
||||||
|
<div class="aed-contract-card__body">
|
||||||
|
<div class="aed-contract-card__row">
|
||||||
|
<span class="aed-contract-card__label">Total contratado:</span>
|
||||||
|
<span class="aed-contract-card__value">{{ fmtBRL(sessionContractInfo.totalPrice) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="aed-contract-card__row">
|
||||||
|
<span class="aed-contract-card__label">Por sessão:</span>
|
||||||
|
<span class="aed-contract-card__value">{{ fmtBRL(sessionContractInfo.perSession) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="sessionContractInfo.remaining > 0" class="aed-contract-card__row">
|
||||||
|
<span class="aed-contract-card__label">Restam:</span>
|
||||||
|
<span class="aed-contract-card__value">{{ sessionContractInfo.remaining }} sessão(ões) · {{ fmtBRL(sessionContractInfo.remainingValue) }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Spinner enquanto carrega occFinancialRecord pra
|
||||||
|
evitar piscar entre "Usar" e "Revogar". -->
|
||||||
|
<div v-if="occFinancialLoading" class="aed-contract-card__loading">
|
||||||
|
<i class="pi pi-spinner pi-spin" />
|
||||||
|
<span>Verificando estado…</span>
|
||||||
|
</div>
|
||||||
|
<!-- Revogar: sessão já consumida (record pending vinculado),
|
||||||
|
paciente ainda não pagou. Atalho pra desfazer. -->
|
||||||
|
<Button
|
||||||
|
v-else-if="sessionContractInfo.style === 'saldo' && occFinancialRecord && occFinancialRecord.status === 'pending'"
|
||||||
|
label="Revogar uso"
|
||||||
|
icon="pi pi-undo"
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
class="rounded-full mt-2 aed-contract-card__usar"
|
||||||
|
@click="emit('revogar-sessao', { eventRow, contract: sessionContractInfo._normalized })"
|
||||||
|
/>
|
||||||
|
<!-- Usar agora: sessão ainda não usada + tem saldo -->
|
||||||
|
<Button
|
||||||
|
v-else-if="sessionContractInfo.style === 'saldo' && sessionContractInfo.remaining > 0 && !occFinancialRecord"
|
||||||
|
label="Usar agora"
|
||||||
|
icon="pi pi-check"
|
||||||
|
size="small"
|
||||||
|
class="rounded-full mt-2 aed-contract-card__usar"
|
||||||
|
@click="emit('usar-sessao', { eventRow, contract: sessionContractInfo._normalized })"
|
||||||
|
/>
|
||||||
|
<div class="aed-contract-card__hint">
|
||||||
|
<i class="pi pi-info-circle" />
|
||||||
|
<template v-if="sessionContractInfo.style === 'saldo'">
|
||||||
|
Modelo <b>saldo</b>: cada sessão gera cobrança ao ser marcada como <b>realizada</b>. Use <b>"Usar agora"</b> pra consumir 1 do saldo (marca realizada + gera fatura de {{ fmtBRL(sessionContractInfo.perSession) }}).
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Modelo <b>upfront</b>: todas as sessões foram cobradas de uma vez no início do pacote.
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── SESSÃO/HONORÁRIOS + FREQUÊNCIA (lado a lado em desktop) ──
|
<!-- ── SESSÃO/HONORÁRIOS + FREQUÊNCIA (lado a lado em desktop) ──
|
||||||
Quando ha serie (edicao de evento ja vinculado a regra),
|
Quando ha serie (edicao de evento ja vinculado a regra),
|
||||||
Frequencia some e Sessao/Honorarios ocupa a linha sozinho. -->
|
Frequencia some e Sessao/Honorarios ocupa a linha sozinho. -->
|
||||||
@@ -3635,6 +3734,104 @@ onBeforeUnmount(() => {
|
|||||||
color: var(--text-color);
|
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
|
/* Padding interno do card "Campos Extras (compromisso)" — mesmo
|
||||||
tratamento do aed-pay-body. Sem isso os inputs ficam grudados nas
|
tratamento do aed-pay-body. Sem isso os inputs ficam grudados nas
|
||||||
bordas. */
|
bordas. */
|
||||||
@@ -5195,12 +5392,42 @@ onBeforeUnmount(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
opacity: 0.75;
|
|
||||||
margin-left: 0.25rem;
|
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 {
|
.serie-panel__empty {
|
||||||
padding: 0.85rem;
|
padding: 0.85rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
@@ -5310,6 +5537,47 @@ onBeforeUnmount(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.serie-pill__cur-badge {
|
||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ export function useAgendaEventLifecycle({
|
|||||||
// continua via occFinancialRecord (território da Fase 6/C13).
|
// continua via occFinancialRecord (território da Fase 6/C13).
|
||||||
const sessionPaymentRecord = ref(null);
|
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 ───────────────────────────────────────
|
// ── computeds locais ───────────────────────────────────────
|
||||||
const serieCountByStatus = computed(() => {
|
const serieCountByStatus = computed(() => {
|
||||||
const counts = {};
|
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) {
|
function onPillEditClick(ev) {
|
||||||
emit('editSeriesOccurrence', {
|
emit('editSeriesOccurrence', {
|
||||||
id: ev.id,
|
id: ev.id,
|
||||||
@@ -508,6 +543,7 @@ export function useAgendaEventLifecycle({
|
|||||||
// sessionPaymentRecord: carrega em qualquer edit (Melissa
|
// sessionPaymentRecord: carrega em qualquer edit (Melissa
|
||||||
// tambem) pra alimentar a linha "Cobrança" do Resumo lateral.
|
// tambem) pra alimentar a linha "Cobrança" do Resumo lateral.
|
||||||
loadSessionPaymentRecord();
|
loadSessionPaymentRecord();
|
||||||
|
loadSessionContract();
|
||||||
|
|
||||||
// occurrenceMode: editando UMA ocorrencia de serie ja existente —
|
// occurrenceMode: editando UMA ocorrencia de serie ja existente —
|
||||||
// tipo de compromisso ja foi escolhido (paciente + sessao). Pular
|
// tipo de compromisso ja foi escolhido (paciente + sessao). Pular
|
||||||
@@ -628,6 +664,8 @@ export function useAgendaEventLifecycle({
|
|||||||
loadSerieEvents,
|
loadSerieEvents,
|
||||||
loadOccFinancialRecord,
|
loadOccFinancialRecord,
|
||||||
loadSessionPaymentRecord,
|
loadSessionPaymentRecord,
|
||||||
|
sessionContract,
|
||||||
|
loadSessionContract,
|
||||||
onPillEditClick,
|
onPillEditClick,
|
||||||
onPillStatusChange,
|
onPillStatusChange,
|
||||||
onPillDeleteClick,
|
onPillDeleteClick,
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ const emit = defineEmits([
|
|||||||
'delete-series', // botão "Excluir série inteira" — hard delete da regra + materializadas + records pendentes
|
'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
|
'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)
|
'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.
|
// Regra atualizada (2026-05-14): botão Excluir aparece SEMPRE pra sessão.
|
||||||
@@ -74,6 +76,31 @@ const seriesLabel = computed(() => {
|
|||||||
return 'Série recorrente';
|
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 ev = computed(() => props.evento || {});
|
||||||
|
|
||||||
const tipoLabel = computed(() => {
|
const tipoLabel = computed(() => {
|
||||||
@@ -250,8 +277,13 @@ function modalidadeIcon(mod) {
|
|||||||
com paymentState='none' (cobrança ainda não gerada).
|
com paymentState='none' (cobrança ainda não gerada).
|
||||||
Pago/pendente já existe um record; nesses casos não
|
Pago/pendente já existe um record; nesses casos não
|
||||||
cabe gerar de novo. -->
|
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
|
<button
|
||||||
v-if="paymentVariant === 'none' && !ev.is_occurrence"
|
v-if="paymentVariant === 'none' && !ev.is_occurrence && !contractInfo"
|
||||||
type="button"
|
type="button"
|
||||||
class="evento-row__pay-action"
|
class="evento-row__pay-action"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@@ -263,6 +295,44 @@ function modalidadeIcon(mod) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div v-if="ev.modalidade" class="evento-row">
|
||||||
<i :class="modalidadeIcon(ev.modalidade)" />
|
<i :class="modalidadeIcon(ev.modalidade)" />
|
||||||
<span class="capitalize">{{ ev.modalidade }}</span>
|
<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);
|
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).
|
/* Stack de botões "Editar sessão" + "Excluir sessão" (Fase 5, 2026-05-14).
|
||||||
Empilhados verticalmente à direita da linha das horas. */
|
Empilhados verticalmente à direita da linha das horas. */
|
||||||
.evento-row__edit-stack {
|
.evento-row__edit-stack {
|
||||||
|
|||||||
@@ -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() {
|
async function onWhatsapp() {
|
||||||
const ev = eventoSelecionado.value;
|
const ev = eventoSelecionado.value;
|
||||||
if (!ev?.patient_id) {
|
if (!ev?.patient_id) {
|
||||||
@@ -2249,6 +2489,8 @@ function onKeydown(e) {
|
|||||||
@delete-sessao="onDeleteEvento"
|
@delete-sessao="onDeleteEvento"
|
||||||
@delete-series="onDeleteSeries"
|
@delete-series="onDeleteSeries"
|
||||||
@gerar-cobranca="onGerarCobrancaQuick"
|
@gerar-cobranca="onGerarCobrancaQuick"
|
||||||
|
@usar-sessao="onUsarSessao"
|
||||||
|
@revogar-sessao="onRevogarSessao"
|
||||||
@ver-lancamentos="onVerLancamentos"
|
@ver-lancamentos="onVerLancamentos"
|
||||||
@antecipar-pagamento="onAnteciparPagamento"
|
@antecipar-pagamento="onAnteciparPagamento"
|
||||||
@edit-paciente="onEditPaciente"
|
@edit-paciente="onEditPaciente"
|
||||||
@@ -2688,6 +2930,8 @@ function onKeydown(e) {
|
|||||||
@delete="M.onDialogDelete"
|
@delete="M.onDialogDelete"
|
||||||
@updateSeriesEvent="M.onUpdateSeriesEvent"
|
@updateSeriesEvent="M.onUpdateSeriesEvent"
|
||||||
@editSeriesOccurrence="M.onEditSeriesOccurrence"
|
@editSeriesOccurrence="M.onEditSeriesOccurrence"
|
||||||
|
@usar-sessao="onUsarSessao"
|
||||||
|
@revogar-sessao="onRevogarSessao"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 2º AgendaEventDialog — empilhado por cima do principal pra editar
|
<!-- 2º AgendaEventDialog — empilhado por cima do principal pra editar
|
||||||
@@ -2716,6 +2960,8 @@ function onKeydown(e) {
|
|||||||
@save="M.onDialogSave"
|
@save="M.onDialogSave"
|
||||||
@delete="M.onDialogDelete"
|
@delete="M.onDialogDelete"
|
||||||
@updateSeriesEvent="M.onUpdateSeriesEvent"
|
@updateSeriesEvent="M.onUpdateSeriesEvent"
|
||||||
|
@usar-sessao="onUsarSessao"
|
||||||
|
@revogar-sessao="onRevogarSessao"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- BloqueioDialog — bloqueio de horário/período/dia/feriados.
|
<!-- BloqueioDialog — bloqueio de horário/período/dia/feriados.
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ function isoToDecimalHour(iso) {
|
|||||||
return d.getHours() + d.getMinutes() / 60;
|
return d.getHours() + d.getMinutes() / 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null) {
|
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) {
|
||||||
// r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand
|
// r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand
|
||||||
// (ocorrência virtual com is_occurrence=true e id "rec::uuid::date").
|
// (ocorrência virtual com is_occurrence=true e id "rec::uuid::date").
|
||||||
const isOccurrence = !!r.is_occurrence;
|
const isOccurrence = !!r.is_occurrence;
|
||||||
@@ -161,6 +161,9 @@ function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null,
|
|||||||
recurrence_date: r.recurrence_date ?? r.original_date ?? null,
|
recurrence_date: r.recurrence_date ?? r.original_date ?? null,
|
||||||
paymentState,
|
paymentState,
|
||||||
paymentAmount,
|
paymentAmount,
|
||||||
|
// Info do contrato (saldo/upfront) — injetado quando a série tem
|
||||||
|
// billing_contract ativo. Popover usa pra mostrar "Pacote X · N/M".
|
||||||
|
contract: r.recurrence_id && ruleContractMap ? (ruleContractMap[r.recurrence_id] ?? null) : null,
|
||||||
price: r.price != null ? Number(r.price) : null,
|
price: r.price != null ? Number(r.price) : null,
|
||||||
// insurance_value: pra convênio, o valor cobrado mora aqui (não em
|
// insurance_value: pra convênio, o valor cobrado mora aqui (não em
|
||||||
// price). Popover e Resumo usam fallback `price ?? insurance_value`
|
// price). Popover e Resumo usam fallback `price ?? insurance_value`
|
||||||
@@ -351,9 +354,12 @@ export function useMelissaAgenda() {
|
|||||||
// têm id real e portanto não entram em _paymentStateMap), normalize lê
|
// têm id real e portanto não entram em _paymentStateMap), normalize lê
|
||||||
// daqui o estado herdado do contrato upfront pago da série.
|
// daqui o estado herdado do contrato upfront pago da série.
|
||||||
const _rulePaymentMap = ref({});
|
const _rulePaymentMap = ref({});
|
||||||
|
// Map recurrence_id → {style, totalSessions, sessionsUsed, packagePrice}
|
||||||
|
// — info do billing_contract da série pra exibir no popover.
|
||||||
|
const _ruleContractMap = ref({});
|
||||||
|
|
||||||
// ── Eventos Melissa-normalizados (consumidos pelo MelissaAgenda FC) ──
|
// ── Eventos Melissa-normalizados (consumidos pelo MelissaAgenda FC) ──
|
||||||
const eventos = computed(() => allRows.value.map((r) => normalizeForMelissa(r, _paymentStateMap.value, _paymentAmountMap.value, _rulePaymentMap.value)));
|
const eventos = computed(() => allRows.value.map((r) => normalizeForMelissa(r, _paymentStateMap.value, _paymentAmountMap.value, _rulePaymentMap.value, _ruleContractMap.value)));
|
||||||
|
|
||||||
// ── Eventos do FC original (se precisar — AgendaEventDialog quer
|
// ── Eventos do FC original (se precisar — AgendaEventDialog quer
|
||||||
// `allEvents` no shape FC pra checar conflitos) ─────────
|
// `allEvents` no shape FC pra checar conflitos) ─────────
|
||||||
@@ -486,6 +492,7 @@ export function useMelissaAgenda() {
|
|||||||
const map = {};
|
const map = {};
|
||||||
const amountMap = {};
|
const amountMap = {};
|
||||||
const ruleMap = {};
|
const ruleMap = {};
|
||||||
|
const ruleContractMap = {};
|
||||||
// Filtra cancelados: cobrança cancelada não deve manter
|
// Filtra cancelados: cobrança cancelada não deve manter
|
||||||
// paymentState='pending' (badge $ residual). Tratamos cancelled
|
// paymentState='pending' (badge $ residual). Tratamos cancelled
|
||||||
// como "sem cobrança ativa" → cai pro default 'none'.
|
// como "sem cobrança ativa" → cai pro default 'none'.
|
||||||
@@ -542,9 +549,20 @@ export function useMelissaAgenda() {
|
|||||||
if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id);
|
if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id);
|
||||||
}
|
}
|
||||||
if (ruleIdsInView.size) {
|
if (ruleIdsInView.size) {
|
||||||
// 2) Acha QUALQUER evento (em qualquer semana) das rules
|
// 2) Acha patient_id direto das rules (fonte autoritativa,
|
||||||
// em view + seus records paid/pending pra detectar o
|
// funciona até quando rule não tem nenhum evento
|
||||||
// estado do contrato.
|
// materializado — caso pacote saldo recém-criado).
|
||||||
|
const { data: rulesData } = await supabase
|
||||||
|
.from('recurrence_rules')
|
||||||
|
.select('id, patient_id')
|
||||||
|
.in('id', [...ruleIdsInView]);
|
||||||
|
const rulePatientFromRule = new Map();
|
||||||
|
for (const r of rulesData || []) {
|
||||||
|
if (r.patient_id) rulePatientFromRule.set(r.id, r.patient_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Acha eventos (em qualquer semana) das rules em view +
|
||||||
|
// seus records paid/pending pra detectar estado.
|
||||||
const { data: allRuleEvents } = await supabase
|
const { data: allRuleEvents } = await supabase
|
||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.select('id, recurrence_id, patient_id')
|
.select('id, recurrence_id, patient_id')
|
||||||
@@ -577,14 +595,38 @@ export function useMelissaAgenda() {
|
|||||||
const existing = ruleToState.get(rid);
|
const existing = ruleToState.get(rid);
|
||||||
if (existing !== 'paid') ruleToState.set(rid, newState);
|
if (existing !== 'paid') ruleToState.set(rid, newState);
|
||||||
}
|
}
|
||||||
if (ruleToPatient.size) {
|
// Busca contratos ativos pra TODOS pacientes envolvidos
|
||||||
// Confirma contratos upfront pros pacientes envolvidos
|
// (saldo OU upfront — ambos exibem info no popover).
|
||||||
const patientIds = [...new Set(ruleToPatient.values())];
|
// Query única com todos campos necessários. Usa
|
||||||
|
// rulePatientFromRule (fonte autoritativa) pra cobrir
|
||||||
|
// saldo sem records (não passa por ruleToPatient).
|
||||||
|
const allPatientIds = [...new Set(rulePatientFromRule.values())];
|
||||||
|
let activePackages = [];
|
||||||
|
if (allPatientIds.length) {
|
||||||
const { data: contracts } = await supabase
|
const { data: contracts } = await supabase
|
||||||
.from('billing_contracts')
|
.from('billing_contracts')
|
||||||
.select('patient_id, charging_style, status, type')
|
.select('id, patient_id, charging_style, status, type, total_sessions, sessions_used, package_price')
|
||||||
.in('patient_id', patientIds);
|
.in('patient_id', allPatientIds);
|
||||||
const activePackages = (contracts || []).filter((c) => c.type === 'package' && c.status === 'active');
|
activePackages = (contracts || []).filter((c) => c.type === 'package' && c.status === 'active');
|
||||||
|
}
|
||||||
|
// Index por patient_id pra lookup rápido
|
||||||
|
const contractByPatient = new Map();
|
||||||
|
for (const c of activePackages) contractByPatient.set(c.patient_id, c);
|
||||||
|
// Popula ruleContractMap pra TODAS as rules em view com
|
||||||
|
// contrato ativo (saldo + upfront, com OU sem records).
|
||||||
|
for (const [rid, pid] of rulePatientFromRule.entries()) {
|
||||||
|
const c = contractByPatient.get(pid);
|
||||||
|
if (c) {
|
||||||
|
ruleContractMap[rid] = {
|
||||||
|
id: c.id,
|
||||||
|
style: c.charging_style || 'upfront',
|
||||||
|
totalSessions: c.total_sessions || 0,
|
||||||
|
sessionsUsed: c.sessions_used || 0,
|
||||||
|
packagePrice: Number(c.package_price || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ruleToPatient.size) {
|
||||||
// NULL charging_style → assume upfront (default histórico
|
// NULL charging_style → assume upfront (default histórico
|
||||||
// antes da migration 20260514000003). Pra dados antigos
|
// antes da migration 20260514000003). Pra dados antigos
|
||||||
// sem a coluna preenchida, evita virtuais ficarem sem
|
// sem a coluna preenchida, evita virtuais ficarem sem
|
||||||
@@ -602,18 +644,13 @@ export function useMelissaAgenda() {
|
|||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.select('id, recurrence_id')
|
.select('id, recurrence_id')
|
||||||
.in('recurrence_id', ruleIdsToPropagate);
|
.in('recurrence_id', ruleIdsToPropagate);
|
||||||
// Captura o package_price do contrato pra propagar
|
// Reusa contractByPatient da query unificada acima
|
||||||
// valor real pra siblings (não o per-session que
|
// (antes havia uma 2ª query redundante pro mesmo dado).
|
||||||
// pode ter sido editado depois)
|
|
||||||
const contractPriceByPatient = new Map();
|
const contractPriceByPatient = new Map();
|
||||||
const { data: contractsDetail } = await supabase
|
for (const c of activePackages) {
|
||||||
.from('billing_contracts')
|
if (c.charging_style === 'upfront' || c.charging_style == null) {
|
||||||
.select('patient_id, package_price')
|
contractPriceByPatient.set(c.patient_id, c.package_price);
|
||||||
.in('patient_id', [...upfrontPatients])
|
}
|
||||||
.eq('charging_style', 'upfront')
|
|
||||||
.eq('status', 'active');
|
|
||||||
for (const c of contractsDetail || []) {
|
|
||||||
contractPriceByPatient.set(c.patient_id, c.package_price);
|
|
||||||
}
|
}
|
||||||
for (const s of siblings || []) {
|
for (const s of siblings || []) {
|
||||||
if (map[s.id] !== undefined) {
|
if (map[s.id] !== undefined) {
|
||||||
@@ -645,6 +682,7 @@ export function useMelissaAgenda() {
|
|||||||
_paymentStateMap.value = map;
|
_paymentStateMap.value = map;
|
||||||
_paymentAmountMap.value = amountMap;
|
_paymentAmountMap.value = amountMap;
|
||||||
_rulePaymentMap.value = ruleMap;
|
_rulePaymentMap.value = ruleMap;
|
||||||
|
_ruleContractMap.value = ruleContractMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refetch() {
|
async function refetch() {
|
||||||
|
|||||||
Reference in New Issue
Block a user