agenda: Fase 5 (status change/edit cobrada) + indicadores visuais + UX convenio

DB
- drop agenda_excecoes (substituida por financial_exceptions + lock-edit
  baseado em financial_records)
- financial_records.payment_link (Asaas + link compartilhavel)
- financial_exceptions.consume_on_miss (rotular nao-show consome ou nao)
- billing_contracts.charging_style (upfront/saldo/per_session)

Payment refactor
- paymentSettlement -> paymentMethod (string) + markPaidNow (bool).
  Handler aplica payment_method sempre; status='paid'+paid_at apenas
  quando markPaidNow=true && method != 'link'. Asaas (link) sempre
  liquida via webhook, nunca nasce paid.
- create_financial_record_for_session com pos-RPC patch pra payment_method
  e (opcional) status='paid' quando user marca "ja recebi".

Indicadores visuais (3 canais distintos por estado)
- Paid: barra esquerda emerald-500 4px na agenda (MelissaAgenda),
  pi-check-circle no popover/Resumo.
- Pending: badge \$ amber canto direito (mantido); linha amber no popover/
  Resumo "A receber R\$ X (cobranca pendente)".
- Neutro: sem badge nem barra (compromisso pessoal, bloqueio, ou
  ocorrencia virtual de pacote upfront/saldo).
- Bulk-load de paymentState em _reloadRange etapa 4 (1 query unica em
  financial_records mapeada por agenda_evento_id).
- AgendaEventDialog Resumo lateral ganha linha entre pi-clock e
  pi-map-marker via novo sessionPaymentRecord (sem guard de
  occurrenceMode, contrario ao occFinancialRecord que continua so pra
  Rail/Clinica). 5 estados: paid+paid_at, overdue+venceu, pending+vence,
  sem cobranca c/ valor, sem cobranca s/ valor.

UX de convenio
- InsurancePlanServiceQuickCreateDialog novo: cadastra procedimento
  POR CIMA do AgendaEventDialog sem sair da agenda. Auto-seleciona
  novo procedimento so quando nada estava selecionado antes.
- Caixa cinza "Cadastrar procedimento" sempre visivel quando convenio
  selecionado, com copy variavel (0 procedimentos: chamada urgente;
  1+: "se quiser adicionar mais").
- "+ Novo convenio" toolbar em ConfiguracoesConveniosPage (botao
  estava faltando, empty state mandava clicar em botao inexistente).
- Hint contextual abaixo do card Sessao/Honorarios: convenio = "N da
  guia eh opcional", gratuito = "sem cobranca", particular = sem hint.
  Label "N da Guia" tambem ganhou "(opcional)" no service-picker dialog.

Bug fixes
- pickDbFields whitelist faltando 'modalidade' (useMelissaAgenda.js:74)
  — sessoes avulsas eram salvas como presencial independente da
  escolha visual. Adicionado.
- goToConveniosConfig removida — fazia router.push("/therapist/
  configuracoes/convenios") mas /configuracoes/* eh rota raiz, nao
  filha. Substituida pelo quick-create inline (#1).
- bloqueioCobrindo + dialogBlockOverlap passados via deps em
  _buildHandlers (refs do useMelissaAgenda nao sao acessiveis no
  escopo de _buildHandlers).

Fase 5 (status change + edit cobrada)
- AgendaStatusChangeConfirmDialog: confirm dialog quando user muda
  status pra realizada/faltou/cancelado, com opcoes de markPaid ou
  gerar cobranca conforme o caso.
- useAgendaBloqueios novo composable: extrai logica de bloqueios
  cinza (background events) do MelissaAgenda.

Doc viva
- src/docs/agenda-compromisso-financeiro-cenarios.html: 13 cenarios
  de teste manual. C1-C4 ja validados. Cada teste validado vira parte
  da doc final pra area de ajuda (pos-Fase 9).

Wiki/handoff
- agenda-compromisso-fluxo e agenda-billing-pesquisa-mercado (decisoes
  arquiteturais sobre billing).
- HANDOFF.md atualizado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-19 08:31:18 -03:00
parent 41c44272a3
commit e95ed9b585
41 changed files with 8715 additions and 852 deletions
+197 -113
View File
@@ -1,147 +1,231 @@
# HANDOFF — 2026-05-08 (MelissaPaciente — port completo + iteração de UX) # HANDOFF — 2026-05-18 noite (C1-C4 OK, UX convênio refinado, C5 ainda não rodou save)
Documento de continuidade. **Quando voltar, comece lendo esta página.** Documento de continuidade. **Quando voltar, comece lendo esta página até o fim.**
> **🟢 PLANO DE 8 FASES COMPLETO** — `MelissaPaciente.vue` (~2400L) é a versão > **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** estamos na rodada de
> Melissa nativa do `PatientProntuario.vue` legacy (3593L). Todas as 7 abas > testes manuais dos 13 cenários do doc viva
> entregues, wire-up final feito (Dialog → route `/melissa/paciente?id=X`). > `src/docs/agenda-compromisso-financeiro-cenarios.html`. C1-C4 ✅, **C5
> 5 composables + utils compartilhados extraídos. > ainda não rodou save** — a tarde do dia 18 foi consumida refinando UX
> de convênio (3 bugs/melhorias) e preparando o C5. Próximo passo:
> **executar de fato o save do C5** (Sándor + Unimed Nacional + R$ 95).
> **🟢 ITERAÇÃO PÓS-FASE 8** — Várias rodadas de feedback do user com fixes: > **🟡 WORKING TREE BEM PESADO** — refactor de payment, indicadores
> full-width, sidebar "Voltar pra Pacientes" (no lugar de Configurações), > visuais (barra verde + popover + Resumo do dialog), inline quick-create
> editar inline, openWhatsapp fix, dialog Lançamento, dialog Nova Sessão > de procedimento, fix de rota convênios, botão "+ Novo convênio",
> com `AgendaEventDialog` real, recorrências do paciente. > hint contextual. Migrations da Fase 5 já rodadas em 14/05. **Considerar
> commitar antes de mais trabalho** — diff tá grande.
> **🟢 COMMITADO + PUSHED** — Working tree limpa.
--- ---
## 🚦 STATUS — Working tree LIMPA ## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 5 (Convênio)
``` Receita do doc HTML. Resumo:
On branch main
Your branch is up to date with 'origin/main'. | Campo | Valor |
nothing to commit, working tree clean |---|---|
``` | Paciente | **Sándor** |
| Convênio | **Unimed Nacional** (criar via `InsurancePlanQuickCreateDialog` se não existir) |
| Valor | **R$ 95** |
**Esperado:**
- Record com `insurance_plan_id` preenchido + pill "convênio" visível
- Na agenda: badge $ amber (record pendente até fechamento mensal do convênio)
- Popover: linha amber "A receber R$ 95,00 (cobrança pendente)"
Após o 5 passar: 6-9 (recorrentes) → 10-13 (status change + edit cobrada). Quando todos passarem, replicar em **Rail** (`AgendaTerapeutaPage.vue`) e **Clínica** (`AgendaClinicaPage.vue`).
--- ---
## 📦 Histórico de commits da sessão (mais recente → mais antigo) ## 📦 O que foi feito em 18/05
| # | Hash | Resumo | ### Cenário 4 (Joyce · "Já recebi") ✅
|---|------|--------| - Testado e passou: toast "Cobrança paga R$ 180,00 recebido via PIX", record nasceu `paid + payment_method=pix + paid_at=now()`.
| 24 | `6ad91e7` | passa preset-commitment-id pro AgendaEventDialog (fix botão Salvar sumido) |
| 23 | `cf1cd67` | pré-popula eventRow com commitment_id + paciente nome/avatar/status | ### Novo indicador: barra esquerda verde para sessão paga
| 22 | `73788c7` | AgendaEventDialog: lockType auto-seleciona commitment "Sessão" (fix jornada/billing/freq sumidos) | - Brainstorm de 6 opções; user escolheu #6 (3 canais visuais distintos por estado).
| 21 | `30d09eb` | AgendaEventDialog: props lockType + lockPatient + slot #headerLeft (aditivos) | - `MelissaAgenda.vue:395-419` — computa `isPaidSession` (sessão+paciente+não-virtual+`paymentState==='paid'`) e adiciona classe `ma-evt--paid` ao FC event (combina com `ma-evt--inactive-patient` se ambos).
| 20 | `88dff50` | (REVERTIDO em 30d09eb) usa AgendaEventDialog GLOBAL via inject | - `MelissaAgenda.vue:2325-2335` — CSS força `border-left-color: #10b981 !important` (emerald-500, 4px). `!important` necessário porque FC seta `borderColor` inline. Trata também list view (`.fc-list-event-dot`).
| 19 | `b040e15` | header custom do dialog Nova Sessão (ícone + título + nome) | - Doc HTML atualizado: legenda "Indicadores visuais" agora descreve **3 estados** (pendente / pago / neutro) com 3 mocks empilhados; estado-alvo do C4 reescrito mencionando a barra verde.
| 18 | `42a39ed` | dialog Nova Sessão usa "Frequência" estilo AgendaEventDialog | - Decisão salva em `memory/project_agenda_payment_indicators.md`.
| 17 | `9e76e4e` | bloco "Recorrências do paciente" na Tab Agenda |
| 16 | `f1d6fba` | dialog nova sessão integra useRecurrence (recorrência semanal) | ### Linha "Cobrança" no popover + Resumo do dialog
| 15 | `a8ab13b` | dialog inline nova sessão + createSession mutation | - **Popover `MelissaEventoPanel`** — antes só mostrava amber "A receber R$ X" pra pendente. Agora cobre os 3 estados, com cor + ícone por variante:
| 14 | `21c71f7` | addFinancial navega pra Financeiro + novo botão Agendar | - `paid``pi-check-circle` verde, label **"Pago · R$ X,XX"**
| 13 | `64005a5` | fix openWhatsapp + dialog inline novo lançamento financeiro | - `pending``pi-dollar` amber, label **"A receber R$ X (cobrança pendente)"** (mantido)
| 12 | `301a712` | editPatient abre PatientCadastroDialog INLINE (sem sair) | - `none``pi-dollar` amber, label **"A cobrar R$ X"** ou **"Cobrança ainda não gerada"** (mantido)
| 11 | `5d2c389` | fix sidebar cards encolhendo + gap das abas main | - CSS reescrito em 3 modificadores `.evento-row--pay-{paid|pending|none}` (com dark mode).
| 10 | `159b80d` | full-width + sidebar "Voltar pra Pacientes" no lugar de Configurações | - **Resumo lateral do `AgendaEventDialog`** — nova linha entre `pi-clock` e `pi-map-marker` em ambas as cópias (mobile inline + desktop floating).
| 9 | `71ee51d` | **Fase 8** wire-up final (Dialog → route /melissa/paciente?id=X) | - Novo ref `sessionPaymentRecord` em `useAgendaEventLifecycle.js:104+` (sem guard de `occurrenceMode`, contrário ao `occFinancialRecord` que continua só pra Rail/Clínica). Loader `loadSessionPaymentRecord` chamado no mesmo lifecycle.
| 8 | `167e864` | **Fase 7** Tabs Documentos + Conversas (KPIs + embed componentes existentes) | - Computed `paymentSummary` em `AgendaEventDialog.vue:951+` retorna `{icon, cls, label}` pra 5 casos: paid (verde + paid_at), overdue (vermelho + due_date), pending (amber + due_date), sem cobrança c/ valor (neutro), sem cobrança s/ valor (neutro).
| 7 | `e7c0f6c` | **Fase 6** Tab Financeiro + mark paid (mutation que legacy não tem) | - `@cobranca-atualizada` do `AgendaEventoFinanceiroPanel` agora também dispara `loadSessionPaymentRecord` pra a linha refrescar.
| 6 | `8a8d2e0` | **Fase 5** Tab Agenda (KPIs + filtros + grupos por mês + ações) | - **Importante:** `occFinancialRecord` (que aciona lock-edit) NÃO foi tocado de propósito — esse é território da Fase 6/C13 (Edit cobrada). Manter dois refs separados evita ativar lock prematuro em Melissa.
| 5 | `1278e93` | **Fase 4** Tab Prontuário MVP (evolução via session.observacoes) |
| 4 | `4fc0e3a` | **Fase 3** Tab Perfil (6 sections stacked + anchors) | ### Preparação do C5 (Sándor + Unimed Nacional) — UX de convênio refinado (3 issues)
| 3 | `ab7526b` | **Fase 2** Tab Visão Geral (4 KPIs + timeline + msgs + notas) |
| 2 | `df61cc4` | **Fase 1** Foundation (5 composables + skeleton 7 tabs + slug paciente) | User tentou rodar C5 e bateu em 3 problemas seguidos. Cada um virou um fix:
| 1 | `f3f0d83` | (pré-MelissaPaciente) preview teleport 3-way no Agendador/LinkExterno + chrome 6 páginas |
1. **Botão "Cadastrar" do procedimento navegava pra `/pages/notfound`**
- Root cause: `goToConveniosConfig` em `AgendaEventDialog.vue` prefixava com `/therapist` ou `/admin`, mas `/configuracoes/*` é rota **raiz** sob `AppLayout` (sibling, não filho). Em Melissa, convênios mora dentro do próprio layout via `secao: 'cfg-convenios'` (sem URL própria).
- Fix descartado: o user não queria sair da agenda. Em vez disso, criamos um quick-create inline (ver #2). `goToConveniosConfig` foi removida (dead code virou armadilha).
2. **Quick-create de procedimento inline (sem sair da agenda)**
- Novo componente `InsurancePlanServiceQuickCreateDialog.vue` (modelo do `InsurancePlanQuickCreateDialog`). 2 campos: nome do procedimento + valor que o convênio paga. Insere em `insurance_plan_services` pro `insurance_plan_id` ativo.
- Wiring em `useAgendaEventLifecycle.js`: novo `planServiceQuickDlgOpen` + `openPlanServiceQuickCreate()` + `onPlanServiceCreated(service)`. Após criar, recarrega `loadInsurancePlans` e **auto-seleciona** o novo procedimento **só quando nada estava selecionado antes** (preserva escolha quando user já tinha selecionado X e está só cadastrando Y pra próxima).
- UI refatorada (`AgendaEventDialog.vue:3110+`): a caixa cinza com botão "Cadastrar" agora aparece **sempre** que um convênio está selecionado. Quando 0 procedimentos: **"Este convênio ainda não tem procedimentos cadastrados."** Quando 1+: **"Se quiser adicionar mais procedimentos a este convênio:"**.
- `planServiceQuickDlgOpen` adicionado ao `anyChildDialogOpen` pra esconder o Resumo flutuante enquanto o quick-create está aberto.
3. **Botão "+ Novo convênio" faltando em `/melissa/cfg-convenios` (e na rota canônica também)**
- Root cause: `ConfiguracoesConveniosPage.vue` tinha o form de "Novo convênio" condicionado a `addingNew === true`, mas **nenhum botão setava esse flag**. Empty state mandava "Clique em 'Novo convênio'" sem botão pra clicar.
- Fix: toolbar simples no topo do template `<template v-else>` com `<Button label="Novo convênio" icon="pi pi-plus" @click="addingNew = true">`. Empty state corrigida pra apontar pro botão certo.
### Hint contextual abaixo do card Sessão / Honorários
- User pediu mensagem clarificando que "Nº da guia" é opcional em convênio.
- **Tentativa 1 (errou o lugar):** coloquei o hint em `AgendaEventDialog.vue:1826` dentro do bloco `v-if="occurrenceMode"` (só edita ocorrência em Rail/Clínica). User não viu.
- **Tentativa 2 (correta):** adicionado em `AgendaEventDialog.vue:2305+` (fluxo principal Melissa, fora do occurrenceMode). Mantive a tentativa 1 também — não atrapalha, só ativa em outro contexto.
- Texto: convênio = **"Nº da guia é opcional — você pode salvar a sessão e preencher depois, quando o convênio responder."** Gratuito = **"Sessão gratuita — nenhum lançamento será gerado no Financeiro."** Particular = sem hint (não há ambiguidade).
- Condição: `isSessionEvent && !occFinancialRecord && billingType === 'convenio'|'gratuito'`. Esconde quando há cobrança paga/pendente (lock-edit) — Message do panel já cobre.
- CSS: `.aed-billing-hint` em `AgendaEventDialog.vue:3558+` — barra esquerda primary, fundo neutro leve, fonte 0.78rem.
- Label do "Nº da Guia" no service-picker dialog também ganhou **(opcional)**.
--- ---
## 📂 Arquivos novos / modificados ## 📦 O que foi feito antes (16/05 noite/madrugada)
### NOVOS — composables + utils compartilhados ### Cenário 1 (Bloqueio) ✅
- `src/features/patients/composables/usePatientDetail.js` (~108L) — patient + groups + tags
- `src/features/patients/composables/usePatientSessions.js` (~155L) — agenda_eventos + computeds + `updateStatus`/`createSession` mutations
- `src/features/patients/composables/usePatientFinancial.js` (~155L) — financial_records + computeds + `markPaid`/`markUnpaid`/`createRecord` mutations
- `src/features/patients/composables/usePatientMessages.js` (~80L) — conversation_messages + computeds
- `src/features/patients/composables/usePatientDocuments.js` (~110L) — documents + computeds (topType, pendentes, sizeTotalFormatted)
- `src/features/patients/composables/usePatientRecurrences.js` (~110L) — recurrence_rules + cancel/reactivate + ativas/canceladas computeds
- `src/features/patients/utils/patientFormatters.js` (~280L) — fmt* helpers + STATUS_LABEL/SEVERITY + tagStyle (luminance) + DOC_TYPE_LABEL + chConvLabel + recordStatus + RECORD_STATUS_LABEL + WEEKDAY_LABEL + fmtRecurrenceLabel/Fim
### NOVO — página Melissa 1. **Fix `bloqueioCobrindo is not defined`** — função estava no escopo de `useMelissaAgenda` mas `onSelectTime` mora no `_buildHandlers` (outro escopo). Passada via `deps`. Mesmo padrão que `_openStatusDialog`.
- `src/layout/melissa/MelissaPaciente.vue` (~2400L) — 7 abas funcionais, header custom, sidebar com Voltar pra Pacientes, dialog Lançamento inline, dialog Nova Sessão usando AgendaEventDialog real 2. **Soft warn dentro do dialog** em vez de toast atrás do overlay — novo ref `dialogBlockOverlap` no composable + nova prop `blockOverlapWarning` no `AgendaEventDialog` + Message warn no topo do step 1. Reset nos outros openers (`onCreateEvento`, `onCreateEventoForPatient`, `onEditEvento`).
3. **Doc HTML Cenário 1 expandido** em 1a (criar bloqueio) + 1b (agendar sobre bloqueio), com mock visual da Message + comparação com agendador público (que veta).
### MODIFICADOS — wire-up ### Cenário 2 (Avulsa sem cobrança) ✅
- `src/layout/melissa/MelissaLayout.vue` — entry SECOES.paciente + render condicional `<MelissaPaciente :patient-id="String(route.query.id || '')" @close="fecharSecao" />`
- `src/layout/melissa/MelissaPacientes.vue``abrirProntuario` agora navega via `router.push('/melissa/paciente', query: { id })`. Removeu `<PatientProntuario>` Dialog. Watch em `route.query.edit` pra abrir cadastro full quando vem de `MelissaPaciente.editPatient`. 4. **Fonte da hint chargeMode** subiu de `0.72rem``0.8125rem` (acima de `text-xs`).
- `src/layout/melissa/MelissaAgenda.vue``abrirProntuarioPorId` igual. Removeu Dialog legacy. 5. **Card Frequência avulsa** refeito — antes era empty state convidando configurar; agora renderiza com `.aed-pay-summary` (mesma estrutura do estado configurado: "Tipo: Avulsa · Sessão única, sem repetição" + botão Editar).
- `src/layout/melissa/composables/useMelissaAgenda.js` — adicionou `onCreateEventoForPatient(patientId)` (não usado mais após reverter inject, mas mantido) 6. Doc HTML Cenário 2 atualizado.
- `src/features/agenda/components/AgendaEventDialog.vue`**3 props aditivas** (lockType, lockPatient, slot #headerLeft) + watch que auto-seleciona commitment "Sessão" quando lockType. **Zero regressão (301 specs passando)**.
### Cenário 3 (Avulsa cobrar ao salvar) ✅
7. **Refactor payment: `paymentSettlement` → `paymentMethod` + `markPaidNow`**
- UI antiga misturava método e status num único Select ("Já recebi — PIX").
- Agora 2 controles: Select forma (Enviar link / PIX / Dinheiro / Depósito / Cartão maquininha — SEM prefixo "Já recebi —") + SelectButton status (Cobrança pendente / Já recebi (dar baixa)).
- SelectButton só aparece quando método ≠ link (Asaas só liquida via webhook).
- Watcher força `markPaidNow=false` se voltar pra 'link'.
- Wire: AgendaEventDialog → useAgendaEventActions → useMelissaAgenda (handler avulsa + `_createPackageContract`).
8. **Indicadores visuais de pagamento** (novidade da sessão):
- Bulk-load de `financial_records` em `_reloadRange` etapa 4 (1 query única, mapa eventId → 'paid' | 'pending' | 'none').
- `normalizeForMelissa` agora injeta `paymentState` + `price` no evento.
- **Badge $ no canto** dos eventos da agenda — círculo amber 16px no canto superior direito. Só pra sessão + paciente + não-virtual + paymentState !== 'paid'.
- **Linha "A receber"** no popover (`MelissaEventoPanel`) — texto adaptativo: "A receber R$ X (cobrança pendente)" se pending, "A cobrar R$ X" se none, "Cobrança ainda não gerada" se sem valor.
9. **🐛 Bug fix `pickDbFields` faltando `modalidade`** — sessões avulsas eram salvas sem modalidade, DB caía no default 'presencial' independente da escolha. Adicionado ao whitelist em `useMelissaAgenda.js:74`. **TODAS as sessões avulsas criadas no Melissa antes desse fix estão como 'presencial' no DB** — pode precisar rodar UPDATE manual no banco se quiser corrigir histórico. Gotcha salvo em `memory/project_pickdbfields_whitelist.md`.
10. **Doc HTML atualizada amplamente**:
- Nova seção topo `★ Indicadores visuais de pagamento` com mocks (badge $ + linha popover) e link em violeta no TOC.
- Caixa violeta "Indicadores visuais" em cada cenário relevante (C2-C9).
- C4 ganhou caixa verde "estado-alvo" (sem badge, sem linha — pago).
- Receita do C3 e C4 atualizadas com os 3 controles (Cobrança ao salvar / Forma de pagamento / Status do pagamento) e opções limpas (sem prefixo "Já recebi —").
--- ---
## 🟡 PENDENTES PRA PRÓXIMA SESSÃO ## 🧭 Onde estamos no plano de 9 fases
User mencionou: **"tem alguns ajustes pra fazer nessa tela ainda"** após o último fix | Fase | Status |
do botão Salvar (commit `6ad91e7`). Ajustes específicos não foram detalhados ainda |---|---|
— próxima sessão começa pelo feedback do user no dialog Nova Sessão. | **1** Compromisso SEM paciente | ✅ |
| **2** Compromisso COM paciente | ✅ testado (C1-C3 done) |
### Pendentes conhecidos (não ditos pelo user, mas observados) | **3** Recorrência + replicar occurrenceMode Rail/Clínica | ⏳ |
- **PatientProntuario.vue legacy (3593L)** continua existindo intocado. Usado por: | **4** Modo disparo cobrança híbrido | ⚠️ parcial |
- `TherapistDashboard.vue` (homepage role therapist sem Melissa) | **5** Status change → confirm dialog | 🔄 Melissa codado + indicadores visuais done; falta testar (C10-C12) + replicar Rail/Clínica |
- `PatientsListPage.vue` (rota `/therapist/patients`) | **6** Edit cobrada | ✅ |
- Quando user troca pra Melissa em `/account/profile`, vê a versão nativa | **7** Pagamento separado | ⏳ |
- Pra deletar de vez precisa portar TherapistDashboard + PatientsListPage também | **8** Refund/credit note | ⏳ |
- **Tab Prontuário** é MVP usando `agenda_eventos.observacoes` como evolução. Quando schema clínico (`anamnese`, `clinical_notes`, `plano_terapeutico`) for adicionado, vira o real | **9** Plano Inicial | 📋 |
- **2 errors pré-existentes em MelissaLayout.vue** (duplicate key 'financeiro' L242, empty block L1130) — não foram tocados durante o port
--- ---
## 🧠 Conhecimento adicional acumulado nesta sessão ## 📋 Roteiro de testes restantes (`src/docs/agenda-compromisso-financeiro-cenarios.html`)
### Decisões arquiteturais | # | Cenário | Status |
1. **`MelissaPaciente` segue o padrão Melissa** (mesmo prefix `mpa-`, glass chrome, sidebar 320px à esquerda, drawer mobile). Diferente das outras Melissa Pages: **largura total** (sem `right: max(...)`) porque conteúdo (KPIs grid + tabelas + timeline) precisa de espaço. |---|---|---|
| 1 | Bloqueio (criar + agendar sobre) | ✅ |
2. **Reuso do `AgendaEventDialog`** via 2 props aditivas (`lockType` + `lockPatient`) + slot `#headerLeft` — caminho A escolhido após discussão honesta sobre drift risk de duplicação. | 2 | Avulsa sem cobrança | ✅ |
| 3 | Avulsa cobrar ao salvar | ✅ |
3. **Inject vs state local**`MelissaPaciente` injeta `MELISSA_AGENDA_KEY` SÓ pra ler dados pesados (commitmentOptions, workRules, etc) e mantém state LOCAL pro dialog (sessaoDialogOpen/EventRow/StartISO/EndISO). Não colide com dialog global da Agenda. | 4 | Avulsa "já recebi" no salvar | ✅ |
| **5** | **Avulsa convênio (Sándor + Unimed)** | 🔴 **PRÓXIMO** |
4. **Inicialização do dialog**: precisa passar **TANTO** `determined_commitment_id` no eventRow **QUANTO** prop `presetCommitmentId` separada. O resetForm lê o primeiro pra popular `form.commitment_id`; o lifecycle lê o segundo pra decidir step inicial. Sem ambos, cair em race condition (lifecycle reset desfaz selectCommitment). | 6 | Recorrente sem pacote (Anna Freud 4 sem) | ⏳ |
| 7 | Pacote upfront (Donald Winnicott 4× R$ 200) | ⏳ |
5. **Pré-popular paciente_nome/avatar/status no eventRow** é obrigatório pra não-edit — o composer só faz fetch async do nome quando isEdit=true. | 8 | Pacote saldo (Carl Jung 4× R$ 40) | ⏳ |
| 9 | 1 por sessão (Michael Balint 12 sem) | ⏳ |
### Hotspots de drift no `AgendaEventDialog` | 10 | Status change avulsa (realizado/faltou/cancelado) | ⏳ |
Arquivo tem 5 composables que fazem o trabalho pesado: `useAgendaEventComposer` (state + computeds), `useAgendaEventActions` (save/delete), `useAgendaEventLifecycle` (watchers + init), `useAgendaEventPickerBilling` (selectCommitment, paciente picker), `agendaEventHelpers` (utils). Mexer aqui é seguro pelo coverage de **301 specs**. | 11 | Status change pacote saldo | ⏳ |
| 12 | Antecipar pagamento (Carl Jung) | ⏳ |
### Slug `/melissa/paciente?id=<uuid>` | 13 | Edit cobrada | ⏳ |
Registrado em `MelissaLayout.vue` SECOES + adicionado a `MELISSA_NON_CONFIG_SLUGS`. ID vem via query param. Funciona pra deep-link.
--- ---
## 🛠️ Comandos úteis ## 📋 Como retomar amanhã (cego)
```bash 1. `git status` — confirmar working tree intacto
# Specs do agenda (regression check pro AgendaEventDialog) 2. **Ler HANDOFF até o fim**
npx vitest run src/features/agenda/composables/__tests__ 3. Abrir `src/docs/agenda-compromisso-financeiro-cenarios.html` no browser pra ver o estado atual do doc viva
4. **Começar pelo Cenário 4** (Joyce, "Já recebi (dar baixa)")
# Lint só dos arquivos do MelissaPaciente 5. Cada cenário que passar:
npx eslint src/layout/melissa/MelissaPaciente.vue src/features/patients/composables/usePatient*.js src/features/patients/utils/patientFormatters.js - Atualizar status pra ✅ aqui no HANDOFF
- Se descobrir bug ou texto divergente, corrigir código + doc na hora
# Testar visualmente 6. Quando todos os 13 passarem: replicar em **Rail** e **Clínica**
npm run dev 7. Adicionar `professional_cancellation` no `STATUS_TO_EXCEPTION`
# → http://localhost:5173/melissa/paciente?id=<uuid-real-de-paciente> 8. Marcar Fase 5 como ✅
``` 9. Decidir Fase 4 (modo disparo cobrança híbrido) OU Fase 3 (replicar occurrenceMode)
--- ---
## ▶️ Próxima sessão — onde retomar ## 🚨 Pendência IMPORTANTE — não esquecer
1. **Ler primeiro**: este HANDOFF.md (você já está nele) **Pós-Fase 9** (quando concluirmos TODAS as fases 1-9):
2. **Aguardar feedback do user** sobre ajustes específicos no dialog Nova Sessão (mencionou que tem mais alguma coisa) - User vai passar prompt específico pra criar **documentação completa da parte de ajuda** do sistema
3. **Possíveis frentes**: - Está em `memory/project_pendencia_doc_ajuda.md`
- Polish do dialog Nova Sessão pós-feedback - O doc `agenda-compromisso-financeiro-cenarios.html` já está sendo escrito de forma que vira a doc final pra usuário (cada teste validado vira parte da doc)
- Port do TherapistDashboard pra remover dependência do PatientProntuario legacy
- Schema clínico (anamnese/evolução) pra Tab Prontuário sair do MVP
4. **Antes de mexer em `AgendaEventDialog`**: rodar `npx vitest run src/features/agenda/composables/__tests__` (301 specs) pra confirmar baseline limpo
Boa sessão! **Histórico modalidade='presencial' no DB:**
- Bug do `pickDbFields` afetou TODAS as sessões avulsas criadas no Melissa até 16/05/2026
- Se quiser corrigir histórico, rodar UPDATE manual identificando sessões cuja modalidade visual era online (não há como saber retroativamente — perdido)
- Going forward o fix já cobre
---
## ⚠️ Gotchas duráveis (atualizados)
- **`MelissaBloqueios.vue` admin ≠ `BloqueioDialog` (4 modos)** — casos distintos
- **`agenda_excecoes` foi dropada** em 13/05
- **`financial_records.type` undefined sem `type` no BASE_SELECT** — fix 14/05 cedo
- **`financial_records.description` undefined sem `description` no BASE_SELECT** — fix 14/05 noite
- **`handleStatusChange` em `useAgendaFinanceiro.js` está ÓRFÃO** — não reativar
- **`_openStatusDialog` + `bloqueioCobrindo` + `dialogBlockOverlap`** declarados no `useMelissaAgenda` mas usados em `_buildHandlers` — passados via `deps`. **NÃO ESQUECER ao replicar em Rail/Clínica**
- **`billing_contracts.charging_style`** distingue upfront/saldo/per_session
- **Ocorrência virtual tem `id="rec::<rule>::<date>"`** — detectar via `typeof === 'string' && startsWith('rec::')` antes de query Supabase
- **`chargeMode` default dinâmico:** `'session'` em avulsa, `'none'` em recorrente
- **Toast atrás do overlay do dialog** — usar Message no topo do dialog em vez de toast quando contexto for dentro de dialog modal
- **Cuidado com `pickDbFields` whitelist** — `useMelissaAgenda.js:74` descarta campos não listados silenciosamente. Sintoma: campo escolhido na UI mas DB tem valor default. Memória: `memory/project_pickdbfields_whitelist.md`
- **`paymentSettlement` foi renomeado** em 16/05 — agora `paymentMethod` (string) + `markPaidNow` (bool). Handler aplica `payment_method` sempre, `status='paid'` só quando markPaidNow=true && method!='link'
- **Bulk-load de paymentState em `_reloadRange` etapa 4** — 1 query única em `financial_records` mapeada por `agenda_evento_id`. Anota `paymentState` no normalize. Badge na agenda + linha popover lêem daqui
---
## 🧠 Decisões persistidas (memory/)
**Indicadores visuais (16/05):**
- Badge $ no canto: só sessão + paciente + não-virtual + !paid
- Linha popover: 3 textos (a receber pending / a cobrar none / cobrança não gerada)
- Bulk-load 1x por _reloadRange, não query por evento
- Ocorrências virtuais sempre paymentState='none' (cobertas por contrato)
**Payment refactor (16/05):**
- Separar método (forma) de status (já pago?) — controles independentes na UI
- Método 'link' (Asaas) força markPaidNow=false (gateway externo)
- Wire format: `arg.paymentMethod` + `arg.markPaidNow` (no lugar de `arg.paymentSettlement`)
**Bugs evitar repetir:**
- Sempre adicionar campo novo ao `pickDbFields.allowed` quando adicionar coluna em agenda_eventos
- Sempre adicionar campo novo ao `BASE_SELECT` quando query custom
- Detectar `is_occurrence` ou `rec::` antes de query por UUID
- Refs/funções do composable principal NÃO ficam acessíveis em `_buildHandlers` — passar via `deps`
- Toast dentro de dialog modal fica atrás do overlay — usar Message
+259
View File
@@ -14,6 +14,142 @@ Chronological, append-only record of everything that's happened in this wiki.
--- ---
## [2026-05-18 23:30] session | UX de convenio refinado (3 fixes) + hint contextual
Touched: none (sem nova wiki page; tudo em codigo + HANDOFF)
Detalhes: tarde inteira consumida em refinar UX de convenio antes do
save real do C5. User bateu em 3 problemas seguidos:
1) Botao "Cadastrar" do procedimento navegava pra /pages/notfound.
Root cause: goToConveniosConfig prefixava com /therapist|/admin mas
/configuracoes/* eh rota raiz sob AppLayout (sibling, nao filho). Em
Melissa, convenios mora via secao=cfg-convenios sem URL propria.
Fix descartado: user nao queria sair da agenda. Criamos quick-create
inline (#2). Removi goToConveniosConfig (dead code).
2) InsurancePlanServiceQuickCreateDialog (novo componente). Mesmo pattern
do InsurancePlanQuickCreateDialog. 2 campos: nome + valor que o
convenio paga. Wiring em useAgendaEventLifecycle: planServiceQuickDlgOpen
+ openPlanServiceQuickCreate + onPlanServiceCreated. Apos criar:
loadInsurancePlans + auto-seleciona SO se nada estava selecionado
antes. UI refatorada: caixa cinza com botao "Cadastrar" SEMPRE
visivel quando convenio selecionado, copy varia por contagem
(0 procedimentos = chamada urgente; 1+ = "se quiser adicionar mais").
3) Botao "+ Novo convenio" faltando em ConfiguracoesConveniosPage.vue.
addingNew=true sem botao pra setar. Empty state mandava clicar em
botao inexistente. Fix: toolbar topo com Button label="Novo convenio"
@click="addingNew = true". Empty state corrigida.
Hint contextual abaixo do card Sessao/Honorarios. User: "Nº da guia
eh obrigatorio?" — consegui salvar sem. Tentativa 1: coloquei em
v-if=occurrenceMode (errado, so Rail/Clinica). Tentativa 2: fluxo
principal Melissa (linha 2305). Copy: convenio = "Nº da guia eh
opcional…", gratuito = "Sessão gratuita…", particular = sem hint.
Label "Nº da Guia" tambem ganhou "(opcional)".
PROXIMO: rodar de fato o save do C5 (Sandor + Unimed + R$ 95). Tudo
preparado, agora eh testar end-to-end.
## [2026-05-18 21:45] session | Linha de cobranca no popover (3 estados) + Resumo do dialog
Touched: none (sem nova wiki page)
Detalhes: extensao do trabalho da sessao anterior (barra verde na agenda).
User pediu pra popover mostrar tambem o estado pago, nao so pendente.
Lembrava que "tinha algo assim mas talvez seja pra outra coisa" —
verifiquei: occFinancialRecord existia mas com guard de occurrenceMode,
servindo Fase 6 (lock-edit) em Rail/Clinica apenas. Decidi NAO unguardar
pra nao ativar lock prematuro em Melissa (isso eh C13).
MelissaEventoPanel.vue: showPaymentRow agora cobre paid tambem; novo
paymentVariant + paymentIcon (pi-check-circle quando pago, pi-dollar
nos outros); paymentLabel inclui "Pago · R$ X,XX"; CSS com 3
modificadores .evento-row--pay-{paid|pending|none} + dark mode.
AgendaEventDialog.vue + useAgendaEventLifecycle.js: novo ref
sessionPaymentRecord (independente do occFinancialRecord). Loader sem
guard, chamado no mesmo lifecycle. Computed paymentSummary cobre 5
estados (paid + verde + paid_at, overdue + vermelho + venceu, pending
+ amber + vence, sem cobranca c/ valor, sem cobranca s/ valor). Nova
linha summary-row entre pi-clock e pi-map-marker em ambas as copias do
Resumo (mobile inline + desktop floating). CSS com 4 classes
.aed-pay-summary-row--{paid|pending|overdue|none}.
@cobranca-atualizada do AgendaEventoFinanceiroPanel agora tambem
dispara loadSessionPaymentRecord pra a linha refrescar quando user
marca pago pelo panel inline.
PROXIMO: cenario 5 (Sandor + Unimed Nacional, R$ 95, convenio).
## [2026-05-18 21:00] session | Cenario 4 OK + barra esquerda verde pra sessao paga
Touched: none (decisao salva em memory/, doc HTML atualizado, sem nova wiki page)
Detalhes: cenario 4 (Joyce R$180 PIX "Ja recebi") testado e passou — toast,
record paid+pix+paid_at, badge $ removido. User pediu sinalizacao visual
pra sessao paga "olhar e saber". Brainstorm de 6 opcoes (check verde
canto, barra esquerda, fundo green-50, $ riscado, so popover, 3 canais
combinados). User escolheu #6 — 3 canais visuais distintos:
- pago: barra verde 4px borda esquerda (emerald-500)
- pendente: badge $ amber canto direito (como antes)
- neutro: nem um nem outro (sem cobranca / virtual)
Implementado em MelissaAgenda.vue (classe ma-evt--paid via classNames,
CSS forca border-left-color !important porque FC seta borderColor inline).
Doc HTML legenda "Indicadores visuais" expandida pros 3 estados (3 mocks
empilhados). Estado-alvo do C4 reescrito. Memoria persistida em
memory/project_agenda_payment_indicators.md (decisao + onde aplicar quando
replicar em Rail/Clinica). HANDOFF atualizado pra apontar C5 como
proximo.
## [2026-05-17 02:30] session | Testes C1-C3 + payment refactor + indicadores visuais + fix modalidade
Touched: agenda-compromisso-fluxo (implicit via HANDOFF.md, sem nova pagina)
Detalhes: rodada de testes manuais dos cenarios do doc viva
src/docs/agenda-compromisso-financeiro-cenarios.html. Cenarios 1, 2 e 3 ok.
CENARIO 1 (bloqueio):
- Fix `bloqueioCobrindo is not defined` em onSelectTime — funcao mora no
escopo de useMelissaAgenda mas onSelectTime esta em _buildHandlers.
Passada via deps, mesmo padrao do _openStatusDialog.
- Soft warn de bloqueio sobre slot agora vai DENTRO do dialog (Message
warn no topo do step 1) em vez de toast atras do overlay. Novo ref
dialogBlockOverlap no composable + nova prop blockOverlapWarning no
AgendaEventDialog. Reset nos outros openers.
- Doc HTML cenario 1 expandido em 1a (criar) + 1b (agendar sobre
bloqueio), com mock visual da Message + comparacao agendador publico.
CENARIO 2 (avulsa sem cobranca):
- Hint chargeMode fonte 0.72rem -> 0.8125rem.
- Card Frequencia avulsa refeito: era empty state convidando configurar,
agora renderiza como "selecionado" com .aed-pay-summary (Tipo: Avulsa
/ Sessao unica, sem repeticao / botao Editar). Visual identico ao
estado configurado de pacote.
CENARIO 3 (avulsa cobrar ao salvar):
- Refactor paymentSettlement -> paymentMethod + markPaidNow. UI antiga
misturava metodo e status num Select unico ("Ja recebi - PIX"). Agora
2 controles: Select forma (sem prefixo "Ja recebi") + SelectButton
status (pendente / ja recebi). SelectButton oculto quando metodo='link'.
Wire em 3 camadas (Dialog -> useAgendaEventActions -> useMelissaAgenda
handler avulsa + _createPackageContract).
- Indicadores visuais de pagamento: bulk-load 1x em _reloadRange etapa 4
(financial_records mapeado por agenda_evento_id -> paid|pending|none).
normalizeForMelissa injeta paymentState + price. Badge $ amber 16px no
canto superior direito do evento da agenda (so sessao+paciente+nao-
virtual+!paid). Linha "A receber" amber abaixo do horario no popover
(MelissaEventoPanel), texto adaptativo.
- BUG FIX pickDbFields whitelist faltando 'modalidade' — todas as sessoes
avulsas criadas no Melissa ate hoje foram salvas como presencial no DB
independente da escolha visual. Adicionado ao allowed[]. Gotcha durador
salvo em memory/project_pickdbfields_whitelist.md.
DOC HTML AMPLAMENTE ATUALIZADO:
- Nova secao topo "★ Indicadores visuais de pagamento" com mocks (badge
$ + linha popover) e link em violeta no TOC.
- Caixa violeta "Indicadores visuais" em cada cenario relevante (C2-C9)
descrevendo o que aparece em cada caso.
- C4 ganhou caixa verde "estado-alvo" (sem badge, sem linha — pago).
- Receitas dos C2-C4 atualizadas pros 3 controles novos.
PROXIMO: cenario 4 (Joyce, "Ja recebi (dar baixa)"). Apos passar, seguir
ate C13. Quando todos passarem, replicar em AgendaTerapeutaPage (Rail) e
AgendaClinicaPage (Clinica). HANDOFF.md reescrito com tudo.
## [2026-05-05 23:45] session | Blueprint tabular Melissa + restore pacientes ## [2026-05-05 23:45] session | Blueprint tabular Melissa + restore pacientes
Touched: none (sem mudança de wiki — handoff em HANDOFF.md) Touched: none (sem mudança de wiki — handoff em HANDOFF.md)
Detalhes: criou `blueprints/melissa-table-page-blueprint.md` (~530L, 18 seções); Detalhes: criou `blueprints/melissa-table-page-blueprint.md` (~530L, 18 seções);
@@ -601,3 +737,126 @@ ComponentCadastroRapido + PatientCadastroDialog pra uso in-flow.
Database backup gerado: backups/2026-05-11/ (138 tabelas, 141 FKs). Database backup gerado: backups/2026-05-11/ (138 tabelas, 141 FKs).
Dashboard regenerado. Dashboard regenerado.
## [2026-05-11 17:00] session | AgendaEventDialog redesign completo + 2º dialog WIP
Touched: HANDOFF.md (reescrito do zero)
Detalhes: sessao longa de refator visual + UX do AgendaEventDialog.
Headers dos 4 cards (Paciente, Data/Horario, Sessao/Honorarios, Frequencia)
com altura fixa 40px, label so mobile, acoes a direita. Card Paciente
ganhou toggle Presencial/Online com pi-pencil substituindo SelectButton;
mini-links Editar/Limpar no lugar dos botoes redondos. Card Data e
Horario com body em 2 linhas (Data · Duracao / Inicio → Termino). Card
Sessao/Honorarios (renomeado de Pagamento) com Select dropdown default
particular + 3 estados (gratuito/empty/configurado-resumo). Card Extras
renomeado, com botao ? abrindo popover educativo. Layout 50/50 via
.aed-row-50 (Paciente|Data e Sessao|Frequencia).
DIALOGS NOVOS:
- serviceDialogOpen: cada servico vira card individual com preco unit,
total, botoes colapsaveis (Aplicar desconto, Alterar quantidade);
desconto mostra calculo em vermelho; footer fixo com Valor desta
sessao em pill tracejada primary; hint educativo Unidades vs
Recorrencia
- freqDialogOpen: empty/resumo, 4 sub-cards (.aed-freq-section: Tipo,
Dias, Quantidade, Proximas), chips renovados (.freq-tab borda solida
+ variant 2-line com 1 mes/4 sessoes), proximas ocorrencias com
separador por mes + referencia relativa (em 2 semanas/em 1 mes).
"Como interpretar o valor" REMOVIDO.
CONCEITO PACOTE (recorrencia >= 2):
- isPacote computed: criando >=2 OU editando hasSerie
- pacoteTotal = totalFromItems × totalOcorrencias
- Header dialog adapta: "Pacote · 4 Sessoes" / "Sessao do Pacote · Sessao"
/ "Editar Sessao" / "Nova Sessao"
- Resumo: modalidade vira "Presencial · Pacote"; wallet mostra total
pacote; linha extra "4× R$ 40 = R$ 160"
- Status da Sessao escondido em pacote
- pluralCommitment PT-BR (Sessao→Sessoes, Analise→Analises, ão→ões)
MODO EDIÇAO:
- Cadastro Rapido NAO aparece
- Cadastro Completo + toggle Modalidade + Ajustar Horario: disabled
com tooltip explicativo
- time-hero ganha --readonly (sem cursor pointer)
OUTROS: animacao de Dialog + backdrop blur REMOVIDOS (so nativo
PrimeVue); DataTable picker paciente coluna Acao agora frozen
alignFrozen=right; lista Recorrencias Aplicadas com numeracao 1/2/3
em badge primary; badge "atual" → "selecionado".
2º DIALOG EMPILHADO (WIP — BLOQUEIO PRA AMANHA):
- useMelissaAgenda.js: refs novos occDialogOpen/EventRow/StartISO/EndISO
- onEditSeriesOccurrence agora abre 2º dialog (em vez de mutar
dialogEventRow in-place silenciosamente)
- _buildHandlers recebe occDialog* via deps (sem isso dava
ReferenceError em runtime)
- MelissaLayout.vue: 2º AgendaEventDialog mountado paralelo, refs
destruturados como agendaOccDialog* (refs aninhados nao auto-
unwrappam no template — pattern conhecido do projeto)
- Status: ABRE, mas user reportou "nao é essa janela que tem de
abrir" — investigar amanha. Provavelmente precisa de prop pra
esconder Frequencia + Recorrencias Aplicadas no 2º dialog e
ajustar titulo pra "Editar Ocorrencia"
- Pendente replicar em Rail (AgendaTerapeutaPage L1630 + L3080) e
Clinica (AgendaClinicaPage L1119 + L2398) DEPOIS de estabilizar
no Melissa
Mudancas NAO commitadas: 5 arquivos (AgendaEventDialog.vue + dois
QuickCreateDialog + MelissaLayout + useMelissaAgenda). HANDOFF.md
reescrito do zero documentando tudo.
## [2026-05-12 17:55] session | occurrenceMode no 2º dialog da agenda
Touched: recorrencia-agenda
## [2026-05-13 14:00] session | pesquisa fluxo agenda Cliniko SimplePractice TherapyNotes
Touched: none
nTouched: agenda-billing-pesquisa-mercado, index
## [2026-05-13 21:00] session | Pesquisa mercado agenda billing
Touched: agenda-billing-pesquisa-mercado, index
## [2026-05-14 sessao continua] session | AgendaEventDialog testes manuais + correcoes
Touched: none (apenas logs detalhados; sem nova pagina wiki)
Detalhes: User testou fluxo de criacao avulsa (Henrique Lima Souza) e
recorrente (Donald Winnicott, 4×R$40) passo-a-passo. Achados/correcoes:
(1) Bug badge financeiro "Despesa" — campo `type` faltava em BASE_SELECT
de useFinancialRecords.js (caia em else → "Despesa"). Fix: adicionar type
no SELECT. (2) E5 toggle Presencial/Online: removido :disabled="isEdit"
(habilitado em ediçao tambem). (3) E6 Ajustar Horario: trocado por
v-if="!isEdit || !hasSerie" — visivel em criacao E em avulsa em edicao;
escondido em série recorrente (politica "esconder em vez de disabled"
salva na memoria, vale apenas pra agenda). (4) Botao cog + popover sobre
horarios online no time picker — padrao espelhado do Card Extras, linka
pra /melissa/agenda-config (ou /configuracoes/agenda fora do Melissa).
(5) Bug visual: pill --current + --online-cfg perdia destaque selected
porque online-cfg !important ganhava da cascade. Fix: regra combo com
mais especificidade. (6) Time picker vazava dia/hora/duracao pro card
principal quando fechado sem confirmar — snapshot ao abrir + revert no
@hide se _tpCommitted=false (added Cancelar button no footer). (7)
Opção C1 implementada — checkbox bool gerarCobrancaAoSalvar substituido
por SelectButton chargeMode com opcoes dinamicas:
- Avulsa: Não / Gerar cobrança
- Recorrente: Não / Pacote único / 1 por sessão
Handler em useMelissaAgenda agora suporta 3 modos: 'session' (1
financial_record, igual Fase 1 original), 'package' (1 billing_contract
inline em vez de confirm.require pós-save), 'per_session' (materializa N
agenda_eventos + cria N financial_records via RPC, respeitando
recurrence_exceptions). _offerBillingContract removido (codigo morto).
useAgendaEventActions.gerarCobrancaAoSalvar → chargeMode. ESLint: 0
erros novos. Vitest useAgendaEventComposer.spec: 76/76 passed.
## [2026-05-13 22:00] session | Fase 1 — drop agenda_excecoes + render bloqueios cinza
Touched: agenda-compromisso-fluxo
Detalhes: Fase 1 da auditoria fase-a-fase concluida. Auditoria revelou
agenda_excecoes orfa (0 refs em src/, embora policies+trigger+enums
existissem) e bloqueios nunca renderizados no FullCalendar (so impediam
criacao). Aplicado: (1) migration 20260513000001_drop_agenda_excecoes.sql
dropa tabela+enums+trigger; (2) agendaMappers.buildBloqueioBackgroundEvents
renderiza dia-cheio/com-hora/recorrente como background event #6b728033;
(3) composable useAgendaBloqueios reusavel (load aceita owner unico OU
array); (4) wire nos 3 layouts (Melissa via useMelissaAgenda+MelissaAgenda,
Rail via AgendaTerapeutaPage, Clinica via AgendaClinicaPage com multi-owner);
(5) docs/schema_map.md e db.config.json limpos. ESLint 0 novos errors;
agendaMappers.spec 40/40 passed. Pendente: rodar migration no banco local
+ validacao visual nos 3 layouts. Plano de 8 fases salvo em
[[agenda-compromisso-fluxo]]; pesquisa de mercado em [[agenda-billing-pesquisa-mercado]].
@@ -0,0 +1,228 @@
---
title: Pesquisa de mercado — fluxo de compromisso e cobrança
date: 2026-05-13
status: levantamento
players: Cliniko, SimplePractice, TherapyNotes
---
## Contexto do produto
SaaS BR pra clínicas de psicologia, multi-tenant. Agenda + paciente + recorrência já funcionando. Invariante "cobrança emitida é imutável pelo dialog da agenda" já implementada (padrão SimplePractice). Auditando fase-a-fase o fluxo antes de fechar gaps. Restrições fiscais BR: PIX, NFS-e, LGPD.
Cross-links: [[recorrencia-agenda]], [[index]]
---
## 1. Criação de compromisso SEM paciente
### Cliniko
- **Default:** existe entidade dedicada chamada **Unavailable block**. Não é appointment — não interfere em relatórios clínicos. Funciona como bloqueio puro de calendário (almoço, reunião, férias, manutenção).
- **Admin pode:** criar **Unavailable block types** customizados (nome, duração default, cor). Aceita arquivamento individual ("Archive" remove o bloco).
- **Fonte:** [Scheduling time off](https://help.cliniko.com/en/articles/1023892-scheduling-time-off), [Changing Your Calendar to Time Blocks](https://help.cliniko.com/en/articles/1024048-changing-your-calendar-to-time-blocks).
### SimplePractice
- **Default:** duas entidades distintas — **Calendar event** (cinza escuro, para reunião, supervisão, tempo pessoal) e **Out of office (OOO) block** (cinza claro, para indisponibilidade que deve bloquear request de agendamento). Calendar events também podem ser recorrentes.
- **Admin pode:** marcar evento como recorrente; OOO bloqueia automaticamente o widget de pedidos de horário online.
- **Fonte:** [Creating a calendar event](https://support.simplepractice.com/hc/en-us/articles/41930878513933-Creating-a-calendar-event), [Managing out of office blocks](https://support.simplepractice.com/hc/en-us/articles/41931023345165-Managing-out-of-office-blocks).
### TherapyNotes
- **Default:** dois tipos — **Scheduled Event** (atividade não-clínica: reunião, supervisão, treinamento; aparece no calendário do clínico) e **Unavailable** (vetar agendamento de pacientes em horários específicos: férias, almoço, compromisso pessoal). Ambos suportam descrição, duração e recorrência sem vincular paciente.
- **Admin pode:** decidir clínico-alvo, frequência (one-time ou recurring), texto livre.
- **Fonte:** [Schedule Non-Clinical Events](https://support.therapynotes.com/hc/en-us/articles/30661451456667-Schedule-Non-Clinical-Events), [Quick Start: Scheduling](https://support.therapynotes.com/hc/en-us/articles/30661279632539-Quick-Start-Scheduling).
**Convergência:** os 3 têm entidade não-clínica separada de "appointment" — nunca usam appointment-sem-paciente como hack.
---
## 2. Criação de compromisso COM paciente
### Cliniko
- **Default:** appointment exige paciente + appointment type + data/hora + practitioner. Paciente pode ser criado on-the-fly direto do dialog do appointment com apenas nome (descrição/categoria são opcionais).
- **Admin pode:** definir custom patient fields opcionais; appointment type carrega billable items default associados.
- **Fonte:** [Booking an appointment](https://help.cliniko.com/en/articles/1024061-booking-an-appointment), [Set up appointment types](https://help.cliniko.com/en/articles/1023911-set-up-appointment-types).
### SimplePractice
- **Default:** appointment exige cliente. Existe entidade intermediária chamada **Prospective client / Inquiry** — perfil parcial usado pra leads vindos de contact form ou pedido online. Pode-se enviar intake antes mesmo de aceitar o appointment (perfil definitivo só nasce ao aceitar).
- **Admin pode:** mandar link de agendamento; criar task de follow-up; enviar intake; rodar prescreener; converter inquiry em client.
- **Fonte:** [Managing prospective clients on the Inquiries page](https://support.simplepractice.com/hc/en-us/articles/33726366744589-Managing-prospective-clients-on-the-Inquiries-page), [Adding a new client](https://support.simplepractice.com/hc/en-us/articles/12416306860429-Adding-a-new-client-and-navigating-your-Clients-and-contacts-list).
### TherapyNotes
- **Default:** appointment clínico exige client + clinician + appointment type + date. Cliente novo precisa pelo menos de **last name**; demais campos (DOB, endereço, e-mail, sexo administrativo, HIPAA acknowledgment) só viram obrigatórios quando se vai submeter claim de plano ou ativar portal.
- **Admin pode:** liberar last-name-only para um "stub client" que recebe billable items mas não é submetível a plano até completar cadastro.
- **Fonte:** [Add a New Client](https://support.therapynotes.com/hc/en-us/articles/30661347776539-Add-a-New-Client), [Schedule a Clinical Appointment](https://support.therapynotes.com/hc/en-us/articles/30661407698203-Schedule-a-Clinical-Appointment).
**Convergência:** todos aceitam appointment com cadastro de paciente mínimo. SimplePractice é o único com camada formal de "lead" pré-prontuário.
---
## 3. Cobrança / fatura — quando é gerada?
### Cliniko
- **Default:** invoice é **explicitamente criada** pelo usuário a partir do appointment (botão "Create invoice" no card do compromisso). Não há geração automática no agendamento.
- **Admin pode:** vincular billable items / produtos a um appointment type, então o "Create invoice" já vem populado. Em fluxo de pagamento online, a invoice é gerada e marcada como paga automaticamente no momento do pagamento confirmando o appointment.
- **Fonte:** [Create an invoice](https://help.cliniko.com/en/articles/1023907-create-an-invoice), [Relate billable items and products to an appointment type](https://help.cliniko.com/en/articles/1023847-relate-billable-items-and-products-to-an-appointment-type).
### SimplePractice
- **Default:** geração **automática**, configurável globalmente entre Daily (overnight, à meia-noite do timezone da prática), Monthly ou Manual. Status do appointment determina se vira invoice: apenas appointments com status **Show**, **Late canceled** ou **No show** geram invoice automaticamente.
- **Admin pode:** escolher daily/monthly/manual em Settings → Client billing → Client billing documents. Recomendação oficial: Daily quando cobra na hora da sessão; Monthly quando fecha o mês.
- **Fonte:** [Setting up your billing and automations](https://support.simplepractice.com/hc/en-us/articles/207925643-Setting-up-your-billing-and-automations), [Managing appointment statuses and billing](https://support.simplepractice.com/hc/en-us/articles/360018410872-Managing-appointment-statuses-and-billing), [Best practices for time-of-session billing](https://support.simplepractice.com/hc/en-us/articles/115000837406-Best-practices-for-time-of-session-billing).
### TherapyNotes
- **Default:** billing line item é gerado **quando a nota da sessão é completada e assinada** pelo clínico. Cada appointment tem aba Billing acessível direto do dialog, mas o disparo de claim/invoice depende de note signed.
- **Admin pode:** configurar default billing method por payer; o To-Do list cria o lembrete pra submeter claim ou gerar CMS-1500 assim que a nota é assinada.
- **Fonte:** [Billing Overview](https://support.therapynotes.com/hc/en-us/articles/30661437130139-Billing-Overview), [Submit Electronic Claims](https://support.therapynotes.com/hc/en-us/articles/30661415430811-Submit-Electronic-Claims), [Quick Start: Billing](https://support.therapynotes.com/hc/en-us/articles/30661397280155-Quick-Start-Billing).
**Convergência:** ninguém cobra no momento de criar o appointment (futuro). Cliniko = manual sob demanda. SimplePractice = automático pós-sessão (status driven). TherapyNotes = automático pós-assinatura de nota (clinical-doc driven).
---
## 4. Recorrência (séries) — billing
### Cliniko
- **Default:** repeating appointment (daily/weekly/fortnightly/monthly). Cada ocorrência é **appointment independente**; invoice continua sendo manual por ocorrência. Pra pacotes, recomenda usar **patient cases + account credit**: cobra o pacote inteiro upfront, o crédito fica no perfil do paciente e é consumido por cada invoice subsequente.
- **Admin pode:** decidir entre invoice-por-sessão (manual ou via pagamento online) ou pacote upfront via account credit.
- **Fonte:** [Book repeating appointments](https://help.cliniko.com/en/articles/1777286-book-repeating-appointments), [Tracking packages with patient cases and account credit](https://help.cliniko.com/en/articles/6477363-tracking-packages-with-patient-cases-and-account-credit).
### SimplePractice
- **Default:** série de até 100 ocorrências, recorrência semanal/mensal/anual. Cada ocorrência é independente para billing — invoice é criada na ocorrência conforme regra global daily/monthly. Editar uma ocorrência pergunta "just this one" ou "all in series". Ao deletar série inteira incluindo passado, **passa por cima** de ocorrências sem nota ou invoice anexada; ocorrências com invoice/nota são preservadas.
- **Admin pode:** ajustar fee de ocorrência já faturada via **fee adjustment invoice** (novo doc que ajusta o saldo, não toca a invoice original — esse é exatamente o padrão "cobrança emitida imutável" já adotado no projeto).
- **Fonte:** [Managing recurring appointments](https://support.simplepractice.com/hc/en-us/articles/41930568779021-Managing-recurring-appointments), [Creating invoices](https://support.simplepractice.com/hc/en-us/articles/207925663-Creating-invoices).
### TherapyNotes
- **Default:** recurring appointments indefinidos ou com data-fim. Cada ocorrência tem nota e billing independentes — billing line item nasce com a assinatura de cada nota individualmente.
- **Admin pode:** cancelar "só esta" ou "todas futuras" da série; alertas podem ser anexados à série inteira.
- **Fonte:** [Quick Start: Scheduling](https://support.therapynotes.com/hc/en-us/articles/30661279632539-Quick-Start-Scheduling).
**Convergência:** os 3 tratam ocorrência como unidade de billing. Pacote upfront é exceção (Cliniko via account credit). Nenhum gera "fatura única da série".
---
## 5. No-show / cancelamento tardio
### Cliniko
- **Default:** plataforma não impõe fee; fornece ferramenta — terms of use no online booking + janela mínima de cancelamento (lock). Se paciente pagou full upfront online, ele **não consegue** cancelar pelo link; deposit parcial libera cancelamento.
- **Admin pode:** configurar minimum notice (várias opções entre "sem restrição" e "vários dias"); redigir política nos terms of use; aplicar fee manualmente via invoice.
- **Fonte:** [Restrict when a patient can cancel an appointment](https://help.cliniko.com/en/articles/1150562-restrict-when-a-patient-can-cancel-an-appointment), [Let patients cancel their appointments](https://help.cliniko.com/en/articles/1023945-let-patients-cancel-their-appointments).
### SimplePractice
- **Default:** statuses formais — **No show** e **Late canceled** (ambos billable, ambos geram invoice como qualquer Show quando auto-billing está ativo). Cancelamento dentro da janela permitida vira status não-billable.
- **Admin pode:** definir janela (24h ou 48h são presets) em Settings; statuses vão pra Client billing summary; appointments late-canceled aparecem em vermelho no calendário.
- **Fonte:** [Setting up your practice's cancellation policy](https://support.simplepractice.com/hc/en-us/articles/360046771271-Setting-up-your-practice-s-cancellation-policy), [Managing appointment statuses and billing](https://support.simplepractice.com/hc/en-us/articles/360018410872-Managing-appointment-statuses-and-billing).
### TherapyNotes
- **Default:** **Missed Appointment Note** dedicada — registra ausência e tem checkbox que automaticamente cria billing line item para fee de cancelamento. TherapyPortal mostra warning ao paciente quando ele tenta cancelar fora da janela.
- **Admin pode:** habilitar/desabilitar criação automática de fee; configurar valor; texto da política aparece no portal.
- **Fonte:** [Complete a Missed Appointment Note](https://support.therapynotes.com/hc/en-us/articles/30661183276315-Complete-a-Missed-Appointment-Note), [TherapyNotes 4.15 release notes](https://blog.therapynotes.com/version-4-15).
**Convergência:** todos têm conceito de "cobrar pelo no-show". SimplePractice é o mais automatizado (status billable triggera invoice junto com os outros). TherapyNotes é o mais explícito (note dedicada + checkbox). Cliniko é o mais manual.
---
## 6. Reembolso / cancelamento de cobrança emitida
### Cliniko
- **Default:** invoice criada por engano pode ser **arquivada** (Archive button). **Número fiscal não retorna** — invoice 000001 arquivada não pode ser reemitida com o mesmo número. Reembolso real usa botão **Reverse** que cria credit note com itens negativos; usuário escolhe **Create credit & refund** (devolve dinheiro) ou **Create credit** (vira account credit). Para desfazer um refund, arquiva-se a credit note.
- **Fonte:** [Archive an invoice](https://help.cliniko.com/en/articles/1359931-archive-an-invoice), [Recording refunds: an overview](https://help.cliniko.com/en/articles/4372587-recording-refunds-an-overview), [Undo a refund](https://help.cliniko.com/en/articles/4521200-undo-a-refund).
### SimplePractice
- **Default:** invoice paga **não deve ser deletada** (deletar quebra alocação de pagamento). Refund full ou parcial é fluxo separado. Pagamentos cash/check/external podem ser deletados se foram erro; pagamento online com cartão não pode ser deletado, só refunded. Para mudar fee de invoice já emitida, usa **fee adjustment invoice** (novo doc com diff).
- **Fonte:** [Navigating client payments](https://support.simplepractice.com/hc/en-us/articles/8497757602957-Navigating-client-payments), [Managing unallocated client payments](https://support.simplepractice.com/hc/en-us/articles/42078634883469-Managing-unallocated-client-payments).
### TherapyNotes
- **Default:** **deletar pagamento ≠ refund** — deletar só remove o registro, não devolve dinheiro. Refund usa botão **Enter Refund** no Patient Accounting do tab Billing. Refund de payer (plano) tem opção dedicada que marca valor negativo automaticamente.
- **Fonte:** [Edit, Delete and Refund Client Payments](https://support.therapynotes.com/hc/en-us/articles/30661497068443-Edit-Delete-and-Refund-Client-Payments).
**Convergência:** os 3 distinguem "anular registro" de "estornar dinheiro". Os 3 preservam histórico fiscal (Cliniko via número não-reaproveitável + credit note; SimplePractice via fee adjustment; TherapyNotes via refund line item). Padrão "cobrança imutável" do projeto está alinhado com o estado da arte.
---
## Tabela comparativa 3 × 6
| Etapa | Cliniko | SimplePractice | TherapyNotes |
|---|---|---|---|
| 1. Compromisso sem paciente | Unavailable block (tipos customizáveis) | Calendar event + OOO block (2 entidades) | Scheduled Event + Unavailable (2 tipos) |
| 2. Compromisso com paciente | Quick-create paciente (nome basta) | Lead (Inquiry) → cliente formal | Last name basta; demais campos só pra claim |
| 3. Quando gera cobrança | Manual via botão no appointment | Automático overnight (Daily/Monthly/Manual) condicionado a status billable | Quando nota da sessão é assinada |
| 4. Recorrência billing | Ocorrência individual ou pacote upfront (account credit) | Série até 100; ocorrência individual; fee adjustment para edit pós-fatura | Ocorrência individual; billing nasce na assinatura de cada nota |
| 5. No-show / late cancel | Política em terms of use; lock manual | Statuses billable (No show / Late canceled); janela 24h/48h | Missed Appointment Note com checkbox auto-fee |
| 6. Refund / cancel cobrança | Archive + Reverse → credit note | Não deletar invoice paga; fee adjustment + refund | Enter Refund (delete ≠ refund) |
---
## Consenso de mercado
1. **Bloqueio de tempo é entidade própria**, separada de appointment. Nunca um appointment "sem paciente".
2. **Cadastro mínimo de paciente** (1 campo) é aceito; campos pesados só ficam obrigatórios na hora de cobrar plano ou ativar portal.
3. **Recorrência cria ocorrências independentes** para billing; nenhum gera "fatura única da série".
4. **Edit de uma ocorrência pergunta "esta / todas / futuras"** — padrão consagrado.
5. **Cobrança nunca é gerada na criação do appointment futuro** — sempre depois (sessão, status, nota, ou trigger manual).
6. **Cobrança emitida é imutável**; ajustes vêm via documento novo (credit note, fee adjustment invoice, refund line item). Validação direta do invariante do projeto.
7. **Deletar pagamento ≠ reembolsar dinheiro** — distinção explícita nos 3.
8. **Janela de cancelamento configurável + política em texto livre** é o mínimo.
## Divergência
- **Quem aciona a cobrança:** Cliniko = humano clica. SimplePractice = job overnight via status. TherapyNotes = assinatura de nota clínica. Três paradigmas distintos.
- **Lead / prospect:** SimplePractice tem entidade formal (Inquiry). Cliniko e TherapyNotes esperam o paciente já ter perfil mínimo.
- **No-show fee:** SimplePractice = mais automatizado (status billable). TherapyNotes = mais auditável (note dedicada). Cliniko = mais manual.
- **Pacote upfront:** Cliniko documenta explicitamente via account credit. SimplePractice/TherapyNotes não têm pacote nativo — cobram ocorrência a ocorrência.
- **Reaproveitamento de número de invoice arquivada:** Cliniko proíbe (alinhado com fiscal BR via NFS-e). Outros não documentam regra equivalente.
---
## Perguntas-chave pro produto decidir
1. **O que dispara a cobrança no fluxo padrão?**
a) Manual (humano clica) — máxima auditabilidade, exige disciplina (Cliniko).
b) Job automático com base em status do appointment (SimplePractice) — pouco atrito, dependente de status estar correto.
c) Assinatura de nota da sessão (TherapyNotes) — vincula clínica e financeira, atrasa cobrança se nota demora.
**Trade-off:** quanto mais automático, menos atrito mas mais risco de cobrança errada; quanto mais manual, mais fricção mas auditoria perfeita.
2. **Devemos ter conceito formal de "lead/contato" antes de prontuário?**
a) Sim — entidade Inquiry separada com pipeline (modelo SimplePractice).
b) Não — paciente nasce na quick-create do agendamento com nome só (modelo Cliniko/TherapyNotes).
**Trade-off:** Inquiry casa com funil comercial mas duplica entidade; quick-create é simples mas dificulta funil de pré-vendas.
3. **Recorrência cobra cada ocorrência ou suporta pacote upfront?**
a) Só ocorrência individual (SimplePractice/TherapyNotes).
b) Suporta também pacote upfront com saldo (Cliniko via patient case + account credit).
**Trade-off:** pacote upfront atende prática que vende "10 sessões antecipado"; ocorrência-a-ocorrência casa direto com NFS-e brasileira (1 nota por serviço).
4. **No-show vira invoice automática ou exige ação manual?**
a) Automático — status "No show" / "Late canceled" entram no auto-billing como Show (SimplePractice).
b) Semi — note dedicada com checkbox que controla geração (TherapyNotes).
c) Manual — admin cria invoice de no-show à mão (Cliniko).
**Trade-off:** automático reduz perda mas pode constranger paciente sem revisão; manual exige rotina disciplinada.
5. **Edição de uma ocorrência de série recorrente: o que faz com cobrança já emitida?**
a) Bloqueia edição (invariante atual — alinhado com SimplePractice "fee adjustment invoice" preservando original).
b) Permite edição com nova cobrança suplementar (delta).
c) Permite edição e refaz a cobrança (cancela + recria).
**Trade-off:** opção a é a mais defensável fiscalmente (NFS-e já transmitida não pode ser silenciosamente mutada); b atende UX; c é perigoso mas familiar.
6. **Janela de cancelamento: presets ou livre?**
a) Presets (24h / 48h) com texto da política livre (SimplePractice).
b) Configuração granular por appointment type (Cliniko).
c) Cliente final só vê warning, sem lock (TherapyNotes).
**Trade-off:** presets cobrem 90% dos casos; granular casa com clínica que tem terapia de grupo + casal + individual com janelas diferentes.
7. **Reembolso preserva o documento fiscal original?**
a) Sim, sempre — credit note nova, número fiscal original nunca volta (Cliniko + alinhado com NFS-e brasileira: cancelamento ≠ deletar).
b) Sim, mas via fee adjustment que não toca a invoice (SimplePractice).
c) Sim, refund é line item separado (TherapyNotes).
**Trade-off:** modelo brasileiro de NFS-e exige (a) ou (c); SimplePractice (b) só funciona em mercados sem NF transmitida por API.
8. **Pagamento via PIX (e cartão online) confirma e marca invoice paga automaticamente?**
a) Sim — pagamento confirmado dispara appointment confirmado + invoice paga (Cliniko online payment).
b) Pagamento é entidade separada que pode ser alocada/desalocada (SimplePractice).
**Trade-off:** auto-confirm é UX premium mas exige tolerância a falhas de webhook do PSP; pagamento desalocado é seguro mas exige conciliação.
---
## Implicações imediatas pro projeto
- O invariante "cobrança emitida é imutável" já implementado é consenso de mercado — manter.
- "Compromisso sem paciente" precisa virar entidade própria (block/event), não um appointment com paciente null. Ver [[recorrencia-agenda]] para integração com expansão de série.
- Recorrência por ocorrência individual é o caminho seguro (cabe em NFS-e). Pacote upfront fica para fase 2.
- Disparo de cobrança: avaliar híbrido SimplePractice (status-driven) + TherapyNotes (note-signed), com fallback manual estilo Cliniko.
- Perguntas 1, 4, 5, 7, 8 são pré-requisito pra fechar o gap atual de billing antes de F1 de fiscal.
@@ -0,0 +1,216 @@
---
title: Plano de auditoria fase-a-fase — fluxo de compromisso da agenda
date: 2026-05-13
status: em-andamento
related: [[agenda-billing-pesquisa-mercado]], [[recorrencia-agenda]]
---
## Contexto
Auditoria do ciclo completo de compromisso da agenda, fase-a-fase, validando cada etapa contra a [[agenda-billing-pesquisa-mercado|pesquisa de mercado]] (Cliniko / SimplePractice / TherapyNotes). Cada fase tem 3 entregas: **auditar o que existe**, **decidir o gap**, **codar**.
## Decisões já tomadas (5 das 8 perguntas)
| # | Decisão |
|---|---|
| 1 | Disparo de cobrança: **híbrido configurável** (manual / status-driven / note-signed) |
| 4 | No-show: **semi-automático via dialog de confirmação** ao mudar status |
| 5 | Edit de cobrada: **bloqueia** (já implementado) |
| 7 | Refund: **credit note nova** (alinhado NFS-e) |
| 8 | Pagamento: **entidade separada** de financial_records |
Pendentes: #2 (lead/Inquiry), #3 (pacote upfront), #6 (janela de cancelamento — provavelmente já resolvido por `min_hours_notice` em `financial_exceptions`).
---
## Plano de 8 fases
Ordem por dependência ("o que destrava o quê") e por estado atual.
### ✅ Fase 1 — Compromisso SEM paciente (bloqueio/feriado/exceção) — **CONCLUÍDA 2026-05-13**
**Auditoria fez:**
-`agenda_excecoes` é tabela órfã (0 referências em src/) — apesar de schema, policies, trigger e enums existentes
-`agenda_bloqueios` é a entidade canônica usada pelos 3 layouts
-`BloqueioDialog` (4 modos: horário/período/dia/feriados) é compartilhado por Melissa Agenda (via `MelissaLayout.vue:2186`), Rail e Clínica
-`MelissaBloqueios.vue` tem form inline próprio pra **admin/edit** (caso de uso legítimo distinto do dialog de 4 modos)
- ✅ Bloqueios não eram renderizados no FullCalendar — apenas impediam criação. UX inconsistente vs pausas/feriados que aparecem como background events
- ⚠️ Tipos customizáveis de bloqueio: descartado no MVP (sem cliente real)
- ⚠️ Robustez de `marcarSessoesParaRemarcar`: adiado pra Fase 5 (status change)
**Aplicado:**
1. Migration `20260513000001_drop_agenda_excecoes.sql` — dropa tabela + 2 enums + trigger; policies caem com CASCADE
2. `agendaMappers.js`: nova função `buildBloqueioBackgroundEvents(bloqueios, rangeStart, rangeEnd)` — renderiza bloqueios como background events cinza (`#6b728033`), suporta dia-inteiro, com hora, e recorrente semanal
3. Novo composable `useAgendaBloqueios.js` — load por owner único OU array (multi-owner pra Clínica), `buildEventsForRange` reutilizável
4. Wire em `useMelissaAgenda` + `MelissaAgenda.vue` — bloqueios concatenados ao `fcEvents`
5. Wire em `AgendaTerapeutaPage` — bloqueios concatenados ao `calendarEvents`
6. Wire em `AgendaClinicaPage` — bloqueios consolidados de todos os ownerIds
7. Refs stale removidas de `database-novo/docs/schema_map.md` e `database-novo/db.config.json`
**Verificação:**
- ESLint nos arquivos modificados: 0 errors novos (11 pré-existentes em código não-tocado)
- Vitest `agendaMappers.spec.js`: 40/40 tests passed
- ⚠️ **Falta rodar a migration no banco local** (pendente de execução manual; arquivo SQL pronto)
- ⚠️ **Falta validar visualmente** nos 3 layouts (Melissa/Rail/Clínica) — verificar que bloqueios aparecem em cinza após criar pelo BloqueioDialog
---
### 🟢 Fase 2 — Compromisso COM paciente
**Estado:** dialog refatorado em 11/05 (cards 40px, picker DataTable, 50/50 layout, 3 estados Sessão/Honorários, conceito Pacote, resumo flutuante). Working tree.
**Auditar:**
- Fluxo de cadastro mínimo de paciente in-line (já existe via `PatientCadastroDialog` quick mode?)
- Decidir #2 (Inquiry/lead separado ou só quick-create)
- Modalidade presencial/online consistente
**Gap potencial:**
- Quick-create exige só nome ou mais campos? (Cliniko: só nome; TherapyNotes: só last name)
- Decisão #2 (Inquiry/lead) — adiar pra v2 provável
**Codar:** ajustes pequenos, principalmente UX. Provavelmente quase nada novo.
---
### 🟢 Fase 3 — Recorrência
**Estado:** modelo "1 real + N-1 virtual" + `occurrenceMode` no 2º dialog estabilizado em 12/05. Ver [[recorrencia-agenda]].
**Auditar:**
- `occurrenceMode` já replicado em Melissa; falta Rail (`AgendaTerapeutaPage` L1630 + L3080) e Clínica (`AgendaClinicaPage` L1119 + L2398)
- Decisão #3 (pacote upfront via account credit) — adiar provável
**Codar:** replicar `occurrenceMode` em Rail/Clínica. Talvez add de pacote upfront (Cliniko model) numa fase futura.
---
### 🟠 Fase 4 — Cobrança: modo de disparo configurável (DECISÃO #1)
**Estado:** Fase 1 atual ("Gerar cobrança ao salvar") existe como checkbox em criação avulsa+particular. Não tem setting de modo.
**Auditar:**
- Onde vive a config? Card novo em `/configuracoes/excecoes-financeiras` ou página irmã `/configuracoes/cobranca-defaults`?
- Granularidade: por tenant (clínica), por owner (terapeuta), ou ambos com herança?
**Gap:**
- Tabela/coluna nova pra `charge_trigger_mode` enum (`manual` / `status_driven` / `note_signed`)
- UI de config
- Job overnight pra modo `status_driven` (Supabase edge function + cron)
- Trigger no signature de nota pra `note_signed` (depende de modulo de notas; nao temos)
- Checkbox atual da agenda passa a fazer sentido **só em modo manual** (ou vira override universal?)
**Codar:**
1. Migration: setting de modo (tenant_billing_settings ou colunas em agenda_configuracoes)
2. UI de config
3. Job pra modo status_driven (avaliar se entra na v1 ou v2)
4. Refator do checkbox atual pra respeitar o modo
---
### 🟠 Fase 5 — Status change → cobrança com confirm dialog (DECISÃO #4)
**Estado:** lógica automática roda em `useAgendaFinanceiro.handleStatusChange`. Consulta regra em `financial_exceptions`, cria/ajusta/cancela `financial_record` SEM perguntar.
**Auditar:**
- Quais status disparam: hoje só `faltou` e `cancelado` (mapping `STATUS_TO_EXCEPTION`)
- `professional_cancellation` na tabela mas não no mapping
- Onde `handleStatusChange` é chamado (quais entradas de status change disparam)
**Gap:**
- Confirm dialog ao mudar status pra `faltou` / `cancelado`: *"Aplicar cobrança de R$X conforme regra? [Sim / Não / Editar valor]"*
- Adicionar `professional_cancellation` ao mapping (status atual da agenda inclui? checar)
- Decidir: dialog aparece **sempre** ou só quando `charge_mode !== 'none'`
**Codar:**
1. Dialog componente novo (`AgendaStatusChargeConfirmDialog.vue`)
2. Interceptar `handleStatusChange` antes da aplicação automática
3. Adicionar `professional_cancellation` no mapping
4. Toast diferenciado pra "aplicado/recusado/editado"
---
### 🟢 Fase 6 — Edit de cobrada (DECISÃO #5 — JÁ IMPLEMENTADO)
**Estado:** `propagateToSerie` filtra por `financial_records` em status imutável. UI lock em `AgendaEventDialog` via `occFinancialRecord`. Working tree.
**Auditar:** validar contra cenários reais (testar série com 4 sessões, 2 cobradas, 2 abertas; editar template; verificar que cobranças não mudam).
**Codar:** zero (talvez add de aviso UX se faltar clareza).
---
### 🔴 Fase 7 — Pagamento como entidade separada (DECISÃO #8)
**Estado:** hoje `financial_records.paid_at` marca pagamento (acoplado). Não tem entidade `payments` independente.
**Auditar:**
- Como financial_records.paid_at é usado hoje (queries de receita, dashboards, conciliação)
- Webhook PSP existente? (provável que PIX e cartão sejam manuais hoje)
**Gap:**
- Migration: tabela `payments` (id, amount, method, paid_at, source, allocated_to_record_id NULL-able)
- Alocação manual de pagamento "solto" a um financial_record
- Pagamento parcial (1 payment cobre N records ou 1 record recebe N payments?)
- Repo + composable + UI
**Codar:** fase pesada — provavelmente sub-dividir.
---
### 🔴 Fase 8 — Reembolso / credit note (DECISÃO #7)
**Estado:** hoje só tem `financial_records.status='cancelled'`. Não preserva original como doc fiscal.
**Auditar:** processo fiscal atual (já emite NFS-e? quando? como cancela?)
**Gap:**
- Migration: tabela `credit_notes` (id, original_record_id, amount, reason, issued_at)
- Constraint: credit note tem valor ≤ |original|
- UI no Financeiro pra "Reembolsar"
- Integração com NFS-e (pode ser separada)
**Codar:** fase pesada — provavelmente sub-dividir.
---
### 🟣 Fase 9 — Plano Inicial (entrevista + N sessões regulares)
**Estado:** apenas conceito; nada codado.
**Pedido do user (2026-05-14):** clínica cobra **1 entrevista inicial** (valor X) + **4 sessões regulares** (valor Y cada). É o "plano de entrada" pra novos pacientes. User faz isso manualmente hoje na clínica dele.
**Conceito:**
- Config nas settings da agenda do tenant:
- Toggle "Habilitar plano inicial"
- Valor entrevista (R$)
- Qtd de sessões regulares (default 4)
- Valor por sessão regular (R$)
- (Opcional) Texto/descrição que aparece no fluxo
- Quando user cria 1ª sessão de **paciente novo** (sem histórico):
- Sistema oferece: "Aplicar plano inicial? Entrevista R$ X + 4× R$ Y = total R$ Z"
- Ao aceitar, materializa 5 sessões com `price` diferenciado: 1ª = X, demais = Y
- Pode ser tratado como 1 série recorrente "especial" com 1ª ocorrência destacada
- OU como 2 entidades distintas (1 avulsa entrevista + 1 série de 4)
**Decisões pendentes:**
- Estrutura: série única com 1ª diferenciada OU avulsa + série separada?
- Onde fica a config: `agenda_configuracoes` (jsonb adicional?) ou tabela nova `intake_plans`?
- "Paciente novo" = sem sessões anteriores? Ou marcador manual no cadastro?
- Plano único do tenant ou múltiplos planos (avaliação clínica, avaliação neuropsi, etc)?
**Cabe na Fase 4 (cobrança)?** Não — Fase 4 é só modo de disparo; aqui é estrutura de pacote pré-configurado. Fica como Fase 9 separada.
---
## Ordem sugerida de execução
| Ordem | Fase | Razão |
|---|---|---|
| 1ª | **Fase 1** | Curta, validação, define se tem cleanup de tabelas necessário |
| 2ª | **Fase 5** | Destrava UX urgente (confirm dialog evita cobrar errado) |
| 3ª | **Fase 4** | Híbrido configurável — destrava racional do checkbox atual |
| 4ª | **Fase 2** | Quase 100% pronta, validar e finalizar |
| 5ª | **Fase 3** | Replicar `occurrenceMode` em Rail/Clínica |
| 6ª | **Fase 6** | Já feito; só testar |
| 7ª | **Fase 7** | Refator estrutural pesado — entra depois das fases UX |
| 8ª | **Fase 8** | Depende fiscal NFS-e — pode ir pra v2 |
| 9ª | **Fase 9** | Plano Inicial (entrevista + 4 sessões) — pedido do user, conceito pronto, codar pós-7 |
## Como cada fase termina
1. Página da fase na wiki é atualizada com o resultado
2. Commit dedicado com prefixo `agenda(fase-N): ...`
3. Update no [[index]] da wiki
4. Entrada no `log.md`
+3
View File
@@ -24,6 +24,9 @@ _(summaries of specific sources you've ingested)_
_(synthesized answers to questions you've asked, filed back as pages)_ _(synthesized answers to questions you've asked, filed back as pages)_
- [[agenda-billing-pesquisa-mercado]] — comparativo Cliniko / SimplePractice / TherapyNotes do ciclo compromisso→cobrança (6 etapas), consenso/divergência e 8 perguntas-chave pro produto
- [[agenda-compromisso-fluxo]] — plano de auditoria fase-a-fase (8 fases) do ciclo de compromisso da agenda; ordem de execução + decisões já tomadas
--- ---
*This index is maintained by Claude via `/wiki-brain`. Do not edit by hand unless you know what you're doing.* *This index is maintained by Claude via `/wiki-brain`. Do not edit by hand unless you know what you're doing.*
+56 -1
View File
@@ -81,11 +81,66 @@ WHERE patient_id IS NULL
Causa raiz já corrigida em 2026-05-11 (guard contra `rid` null em `onUpdateSeriesEvent`), mas o pattern de query é útil pra catch futuros. Causa raiz já corrigida em 2026-05-11 (guard contra `rid` null em `onUpdateSeriesEvent`), mas o pattern de query é útil pra catch futuros.
## Invariante de cobrança em séries — "cobrança emitida é imutável"
**Padrão adotado (SimplePractice / TherapyNotes / Cliniko):** `financial_records` em status `pending`/`paid`/`overdue` são **imutáveis pelo dialog da agenda**. Ajustes só via fluxo do Financeiro (cancelar + refaturar). Garante:
- Trilha fiscal estável.
- Paciente não vê valor "mágico" mudando.
- Dashboards de MRR e projeção consistentes.
### Como o sistema honra a invariante
**1. Lock no `occurrenceMode`** (`AgendaEventDialog.vue`):
- Card "Sessão / Honorários" detecta `occFinancialRecord` via query `financial_records` filtrada por `agenda_evento_id` + status `in ('pending','paid','overdue')`.
- Se record existe → renderiza apenas `AgendaEventoFinanceiroPanel` + mensagem de lock + Tag de status. Select de billingType e botão "Editar itens" desaparecem.
- Se record não existe (virtual ou materializado sem cobrança) → edição livre, marca `services_customized=true` ao salvar.
- Card "Aplicar alterações em" também é ocultado quando há cobrança (mudanças estruturais não se aplicam — usuário só pode mexer em status/horário).
**2. Filtro em `propagateToSerie`** (`useCommitmentServices.js`):
- Após filtrar eventos elegíveis (recurrence_id + opcionalmente fromDate + opcionalmente services_customized=false), faz 1 query batch em `financial_records` pra coletar `agenda_evento_id` lockados.
- Remove esses IDs da lista de elegíveis antes de fazer `delete + insert` de `commitment_services`.
- Resultado: editar template da regra **nunca toca** ocorrências cobradas, mesmo em escopo `todos`.
**3. Aviso fixo no dialog pai** (em `isEdit && hasSerie`):
- Mensagem inline abaixo do `AgendaEventoFinanceiroPanel`: "Alterações de tipo ou serviços afetam apenas sessões futuras ainda não cobradas. Cobranças já emitidas permanecem inalteradas — para ajustá-las, acesse o Financeiro."
### Opção `todos_sem_excecao` removida da UI
- O nome confundia (sugeria "ignorar cobranças") quando na verdade era "ignorar customização operacional" (`services_customized=true`).
- Backend mantém o caso pra compat, mas `editScopeOptions` agora só retorna 3 valores: `somente_este`, `este_e_seguintes`, `todos`.
- Mercado consolidado (SimplePractice etc) não expõe override de customizações — admin que precisa reseta sessão-a-sessão.
### Onde está cada peça
- `src/features/agenda/composables/useAgendaEventLifecycle.js``loadOccFinancialRecord` + `occFinancialRecord` ref
- `src/features/agenda/components/AgendaEventDialog.vue` — card lock/unlock + aviso pai
- `src/features/agenda/composables/useCommitmentServices.js:162``propagateToSerie` com filtro financial_records
- `src/features/agenda/composables/useAgendaEventComposer.js:91``editScopeOptions` com 3 valores
- `src/components/agenda/AgendaEventoFinanceiroPanel.vue` — UI do fluxo Financeiro embarcado
## 2º dialog empilhado — edição de ocorrência (occurrenceMode)
Quando o user clica "Editar" em uma pill da lista "Recorrências Aplicadas", abre um **segundo `AgendaEventDialog` empilhado** por cima do principal. Ele compartilha o mesmo componente, mas com a prop `occurrenceMode=true` que muda comportamento:
- **Título:** `Pacote · X de Y Sessões` (computa `occurrenceIndex` via `currentRecurrenceDate` + `serieEvents`) em vez do padrão `Sessão do Pacote · {nome}`.
- **Layout enxuto:** renderiza apenas 4 cards na ordem: (1) Dados da Recorrência (read-only summary), (2) Status, (3) Horário, (4) Aplicar alterações em. Tudo o resto (paciente-hero, fields-grid, serie-panel, sessão/honorários, frequência, extras, resumo mobile) fica oculto via `v-if="!occurrenceMode"`.
- **Escopo `Aplicar alterações em`:** migrou do `composer-right` do dialog pai pra dentro do dialog de ocorrência. O pai não mostra mais esse card — pra mudar escopo, o user obrigatoriamente vai pela pill.
- **Horário editável:** botão "Ajustar horário" não fica `:disabled="isEdit"` no occurrenceMode (no pai sim — data/horário do pacote inteiro é imutável após criação).
Stack relevante:
- `MelissaLayout.vue:2160` monta o 2º dialog passando `:occurrenceMode="true"` + `eventRow={ ...row, recurrence_date, _is_virtual }` via refs `agendaOccDialog*` (destructurados de `useMelissaAgenda` no setup — refs aninhados não auto-unwrap no template).
- `useMelissaAgenda.onEditSeriesOccurrence` popula `occDialogEventRow` + abre `occDialogOpen=true`. Substituiu o pattern antigo de mutar `dialogEventRow` in-place (que trocava silenciosamente os dados do dialog atual).
- `useAgendaEventLifecycle.onPillEditClick` emite `editSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_virtual })`.
**Pendente replicar:** Rail (`AgendaTerapeutaPage`) e Clínica (`AgendaClinicaPage`) ainda têm só o dialog principal — o 2º só existe no Melissa por enquanto.
## Referências de código ## Referências de código
- `src/features/agenda/composables/useRecurrence.js``loadAndExpand`, `expandRules`, `mergeWithStoredSessions`, `buildOccurrence` - `src/features/agenda/composables/useRecurrence.js``loadAndExpand`, `expandRules`, `mergeWithStoredSessions`, `buildOccurrence`
- `src/layout/melissa/composables/useMelissaAgenda.js:809``onUpdateSeriesEvent` - `src/layout/melissa/composables/useMelissaAgenda.js:817``onEditSeriesOccurrence`
- `src/layout/melissa/composables/useMelissaAgenda.js:837``onUpdateSeriesEvent`
- `src/features/agenda/composables/useAgendaEventActions.js:65` — watcher do form.status - `src/features/agenda/composables/useAgendaEventActions.js:65` — watcher do form.status
- `src/features/patients/composables/usePatientSessions.js:189``updateStatus` com materialização - `src/features/patients/composables/usePatientSessions.js:189``updateStatus` com materialização
- `src/features/agenda/components/AgendaEventDialog.vue` — props `occurrenceMode`, computeds `occurrenceIndex` / `occurrenceTotalSessions` / `headerMainLabel`
- `src/layout/melissa/MelissaLayout.vue:655``updateEventoStatus` do `MelissaEventoPanel` - `src/layout/melissa/MelissaLayout.vue:655``updateEventoStatus` do `MelissaEventoPanel`
- `src/layout/melissa/MelissaLayout.vue:2160` — 2º AgendaEventDialog empilhado
- `src/layout/melissa/MelissaAgenda.vue:244``VIEW_MAP.lista = 'listAll'` - `src/layout/melissa/MelissaAgenda.vue:244``VIEW_MAP.lista = 'listAll'`
+1 -1
View File
@@ -106,7 +106,7 @@
"contact_email_types", "contact_emails" "contact_email_types", "contact_emails"
], ],
"Agenda / Agendamento": [ "Agenda / Agendamento": [
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes", "agenda_excecoes", "agenda_eventos", "agenda_bloqueios", "agenda_configuracoes",
"agenda_online_slots", "agenda_regras_semanais", "agenda_online_slots", "agenda_regras_semanais",
"agenda_slots_bloqueados_semanais", "agenda_slots_regras", "agenda_slots_bloqueados_semanais", "agenda_slots_regras",
"agendador_configuracoes", "agendador_solicitacoes" "agendador_configuracoes", "agendador_solicitacoes"
+1 -2
View File
@@ -43,13 +43,12 @@ Mapa completo do banco de dados PostgreSQL 17, extraído de `schema.sql` (2026-0
| `module_features` | Features por módulo | | `module_features` | Features por módulo |
| `tenant_modules` | Módulos ativos por tenant | | `tenant_modules` | Módulos ativos por tenant |
### Agenda (11 tabelas) ### Agenda (10 tabelas)
| Tabela | Descrição | | Tabela | Descrição |
|--------|-----------| |--------|-----------|
| `agenda_bloqueios` | Bloqueios de horário | | `agenda_bloqueios` | Bloqueios de horário |
| `agenda_configuracoes` | Configurações da agenda por tenant_member | | `agenda_configuracoes` | Configurações da agenda por tenant_member |
| `agenda_eventos` | Eventos da agenda (sessões, bloqueios) | | `agenda_eventos` | Eventos da agenda (sessões, bloqueios) |
| `agenda_excecoes` | Exceções na agenda (horários extras, bloqueios pontuais) |
| `agenda_online_slots` | Slots de agendamento online | | `agenda_online_slots` | Slots de agendamento online |
| `agenda_regras_semanais` | Regras semanais de disponibilidade | | `agenda_regras_semanais` | Regras semanais de disponibilidade |
| `agenda_slots_bloqueados_semanais` | Slots bloqueados na semana | | `agenda_slots_bloqueados_semanais` | Slots bloqueados na semana |
@@ -0,0 +1,32 @@
-- ============================================================================
-- Drop agenda_excecoes (tabela órfã) + tipos relacionados
-- ----------------------------------------------------------------------------
-- A tabela `public.agenda_excecoes` foi criada num design anterior pra
-- representar "exceções no horário de trabalho" (almoço extra, atendimento
-- fora do padrão, etc) mas nunca foi integrada à UI. Auditoria em
-- 2026-05-13 confirmou 0 referências em src/. As funcionalidades equivalentes
-- vivem em:
-- - public.agenda_bloqueios — bloqueios (período, dia, horário, feriado)
-- - public.agenda_configuracoes.pausas_semanais (jsonb) — pausas semanais
-- - public.feriados — feriados nacionais/municipais
--
-- Esta migration:
-- 1) Dropa o trigger tg_agenda_excecoes_updated_at
-- 2) Dropa a tabela public.agenda_excecoes (CASCADE pra cair policies)
-- 3) Dropa os enums tipo_excecao_agenda e status_excecao_agenda
-- (verificados: usados APENAS por agenda_excecoes)
-- ============================================================================
BEGIN;
-- 1. Trigger (idempotente)
DROP TRIGGER IF EXISTS tg_agenda_excecoes_updated_at ON public.agenda_excecoes;
-- 2. Tabela (CASCADE leva policies junto)
DROP TABLE IF EXISTS public.agenda_excecoes CASCADE;
-- 3. Enums órfãos
DROP TYPE IF EXISTS public.tipo_excecao_agenda;
DROP TYPE IF EXISTS public.status_excecao_agenda;
COMMIT;
@@ -0,0 +1,24 @@
-- ============================================================================
-- Adiciona coluna payment_link em financial_records
-- ----------------------------------------------------------------------------
-- Quando a cobrança for paga via gateway externo (Asaas, Stripe, Mercado Pago)
-- e o terapeuta escolher "Enviar link de pagamento" no AgendaEventDialog, o
-- link de cobrança gerado pelo gateway é salvo aqui. UI da lista do Financeiro
-- usa esse campo pra exibir ícone clicável (external-link).
--
-- Campo nullable: registros sem integração de gateway (PIX manual, dinheiro,
-- depósito, cartão maquininha) ficam com payment_link = NULL.
--
-- Preparação pra Fase 7 (Pagamento como entidade separada) — quando a
-- integração Asaas estiver completa, o webhook vai preencher esse campo.
-- ============================================================================
BEGIN;
ALTER TABLE public.financial_records
ADD COLUMN IF NOT EXISTS payment_link text;
COMMENT ON COLUMN public.financial_records.payment_link IS
'URL externa de cobrança (Asaas/Stripe/etc) quando payment_method indica gateway. Null em pagamentos manuais.';
COMMIT;
@@ -0,0 +1,22 @@
-- ============================================================================
-- Adiciona coluna default_consume_on_miss em financial_exceptions
-- ----------------------------------------------------------------------------
-- Define o comportamento padrão pro saldo de pacote quando o status muda
-- pra "faltou" ou "cancelado":
-- true → desconta 1 sessão do pacote (sessions_used += 1) por padrão
-- false → não consome saldo (sessão fica disponível pra remarcar)
--
-- O dialog de confirmação que aparece ao mudar status sugere essa decisão
-- mas o terapeuta pode override caso a caso. Padrão começa false (mais
-- benevolente ao paciente).
-- ============================================================================
BEGIN;
ALTER TABLE public.financial_exceptions
ADD COLUMN IF NOT EXISTS default_consume_on_miss boolean DEFAULT false NOT NULL;
COMMENT ON COLUMN public.financial_exceptions.default_consume_on_miss IS
'Default pro toggle "Descontar do saldo" no dialog de status change. false = não consome (paciente pode remarcar); true = consome (sessão perdida).';
COMMIT;
@@ -0,0 +1,31 @@
-- ============================================================================
-- Adiciona coluna charging_style em billing_contracts
-- ----------------------------------------------------------------------------
-- Identifica como o pacote foi cobrado na criação:
-- 'upfront' → 1 financial_record total criado na hora; sessões só
-- consomem saldo, não geram nova cobrança
-- 'saldo' → sem financial_record na criação; cada sessão realizada
-- gera 1 cobrança individual e incrementa sessions_used
-- 'per_session'→ N financial_records já criados na materialização da série
-- (chargeMode='per_session' do AgendaEventDialog)
--
-- Sem esse campo, o handler de status change não saberia distinguir entre
-- "já tudo pago, só atualizar status" vs "criar cobrança nova".
-- ============================================================================
BEGIN;
ALTER TABLE public.billing_contracts
ADD COLUMN IF NOT EXISTS charging_style text DEFAULT 'saldo';
-- Constraint pra restringir aos 3 valores válidos
ALTER TABLE public.billing_contracts
DROP CONSTRAINT IF EXISTS billing_contracts_charging_style_chk;
ALTER TABLE public.billing_contracts
ADD CONSTRAINT billing_contracts_charging_style_chk
CHECK (charging_style = ANY (ARRAY['upfront'::text, 'saldo'::text, 'per_session'::text]));
COMMENT ON COLUMN public.billing_contracts.charging_style IS
'Estilo de cobrança: upfront (1 record total no início), saldo (cobra por sessão realizada), per_session (N records já criados).';
COMMIT;
+3 -3
View File
@@ -39,9 +39,9 @@ async function getUid() {
const BASE_SELECT = ` const BASE_SELECT = `
id, tenant_id, owner_id, patient_id, agenda_evento_id, id, tenant_id, owner_id, patient_id, agenda_evento_id,
amount, discount_amount, final_amount, type, amount, discount_amount, final_amount,
status, due_date, paid_at, payment_method, status, due_date, paid_at, payment_method, payment_link,
notes, created_at, updated_at, description, notes, created_at, updated_at,
patients!patient_id ( patients!patient_id (
id, nome_completo, identification_color id, nome_completo, identification_color
), ),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,474 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/AgendaStatusChangeConfirmDialog.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
/*
* AgendaStatusChangeConfirmDialog — Dialog que aparece ao mudar status
* de uma sessão (realizado/faltou/cancelado). Mostra defaults vindos da
* config (financial_exceptions, billing_contracts) e permite override
* caso a caso pelo terapeuta/clínica antes de aplicar.
*
* 5 variantes de renderização baseadas em (novoStatus, eventoContext):
* - faltou/cancelado + avulsa → bloco multa
* - faltou/cancelado + pacote saldo → bloco saldo + bloco multa
* - faltou/cancelado + pacote upfront → bloco multa (saldo n/a)
* - realizado + avulsa pendente → bloco "registrar pagamento"
* - realizado + pacote saldo → bloco "gerar cobrança no pacote"
*
* Emit 'confirm' com objeto descrevendo o que o handler deve fazer:
* {
* consumeSaldo: bool, // só relevante em pacote saldo + faltou/cancelado
* applyFine: bool, // se vai cobrar multa
* fineAmount: number|null, // valor da multa (editavel)
* markPaid: bool, // realizado avulsa → marcar paga
* paymentMethod: string, // método se markPaid ou gerar cobrança
* generatePackageCharge: bool // realizado em pacote saldo → criar record
* }
*/
import { ref, computed, watch } from 'vue';
const props = defineProps({
modelValue: { type: Boolean, default: false },
evento: { type: Object, default: null },
novoStatus: { type: String, default: '' }, // 'realizado' | 'faltou' | 'cancelado'
regraExcecao: { type: Object, default: null }, // row de financial_exceptions ou null
billingContract: { type: Object, default: null }, // row de billing_contracts ou null
billingContractStyle: { type: String, default: null }, // 'upfront' | 'saldo' | null
// Quando avulsa+pendente e novoStatus='realizado': financial_record relacionado
pendingRecord: { type: Object, default: null },
// Preço da sessão (pra calcular multa percentual e cobrança de pacote saldo)
sessionPrice: { type: Number, default: 0 }
});
const emit = defineEmits(['update:modelValue', 'confirm']);
// ─── State interno (refs editáveis) ─────────────────────────────────────
const consumeSaldo = ref(false);
const applyFine = ref(false);
const fineAmount = ref(0);
const markPaid = ref(true); // default em realizado: já recebeu
const paymentMethod = ref('pix'); // método em "como recebeu" ou "como cobrar"
const generatePackageCharge = ref(true); // realizado em pacote saldo: gera por padrão
// Reset/init ao abrir
watch(
() => props.modelValue,
(open) => {
if (!open) return;
// Defaults baseados em context
consumeSaldo.value = !!props.regraExcecao?.default_consume_on_miss;
applyFine.value = _calcInitialFineApply();
fineAmount.value = _calcInitialFineAmount();
markPaid.value = true;
paymentMethod.value = 'pix';
generatePackageCharge.value = true;
}
);
// ─── Computeds: o que renderizar ────────────────────────────────────────
const isFaltouOrCancelado = computed(() => props.novoStatus === 'faltou' || props.novoStatus === 'cancelado');
const isRealizado = computed(() => props.novoStatus === 'realizado');
const isAvulsa = computed(() => !props.billingContract);
const isPacoteSaldo = computed(() => props.billingContract?.type === 'package' && props.billingContractStyle === 'saldo');
const isPacoteUpfront = computed(() => props.billingContract?.type === 'package' && props.billingContractStyle === 'upfront');
// Mostrar bloco multa: faltou/cancelado + regra existe + charge_mode != 'none'
const showFineBlock = computed(() => isFaltouOrCancelado.value && props.regraExcecao && props.regraExcecao.charge_mode !== 'none');
// Mostrar bloco saldo: faltou/cancelado + pacote saldo
const showSaldoBlock = computed(() => isFaltouOrCancelado.value && isPacoteSaldo.value);
// Mostrar bloco "registrar pagamento": realizado + avulsa pendente
const showRegistrarPagto = computed(() => isRealizado.value && isAvulsa.value && props.pendingRecord && props.pendingRecord.status === 'pending');
// Mostrar bloco "cobrança no pacote": realizado + pacote saldo
const showCobrancaPacote = computed(() => isRealizado.value && isPacoteSaldo.value);
// ─── Header ────────────────────────────────────────────────────────────
const headerTitle = computed(() => {
const labels = { realizado: '✓ Marcar como Realizado', faltou: '⚠ Marcar como Faltou', cancelado: '✕ Marcar como Cancelado' };
return labels[props.novoStatus] || 'Atualizar status';
});
// ─── Sub-info do evento ────────────────────────────────────────────────
const pacienteNome = computed(() => props.evento?.paciente_nome || props.evento?.patients?.nome_completo || 'Paciente');
const dataHora = computed(() => {
const ini = props.evento?.inicio_em;
if (!ini) return '';
try {
const d = new Date(ini);
return d.toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
} catch {
return '';
}
});
const isSerieSemPacote = computed(() => {
// Série recorrente que não criou billing_contract (chargeMode='none' no save).
// Detecta via recurrence_id/serie_id na row sem contract carregado.
if (props.billingContract) return false;
const e = props.evento;
return !!(e?.recurrence_id || e?.serie_id || e?.is_occurrence);
});
const tipoTag = computed(() => {
if (isPacoteSaldo.value) return `Pacote saldo: ${props.billingContract.sessions_used ?? 0} de ${props.billingContract.total_sessions ?? '?'} usadas`;
if (isPacoteUpfront.value) return `Pacote upfront: R$ ${_fmtBRL(props.billingContract.package_price)} pago`;
if (isSerieSemPacote.value) return 'Série recorrente (sem pacote)';
if (props.pendingRecord) return `Avulsa: R$ ${_fmtBRL(props.pendingRecord.final_amount || props.pendingRecord.amount)} pendente`;
return 'Avulsa';
});
// ─── Labels e options ──────────────────────────────────────────────────
const paymentMethodOptions = [
{ value: 'pix', label: 'PIX' },
{ value: 'dinheiro', label: 'Dinheiro' },
{ value: 'deposito', label: 'Depósito' },
{ value: 'cartao_maquininha', label: 'Cartão (maquininha)' }
];
const paymentMethodOptionsCobranca = [
{ value: 'link', label: 'Enviar link de pagamento (Asaas)' },
{ value: 'pix', label: 'Já recebi — PIX' },
{ value: 'dinheiro', label: 'Já recebi — Dinheiro' },
{ value: 'deposito', label: 'Já recebi — Depósito' },
{ value: 'cartao_maquininha', label: 'Já recebi — Cartão (maquininha)' }
];
const regraResumo = computed(() => {
const r = props.regraExcecao;
if (!r) return '';
if (r.charge_mode === 'full') return `Sessão completa = R$ ${_fmtBRL(props.sessionPrice)}`;
if (r.charge_mode === 'fixed_fee') return `Taxa fixa: R$ ${_fmtBRL(r.charge_value)}`;
if (r.charge_mode === 'percentage') return `${r.charge_pct}% da sessão = R$ ${_fmtBRL((props.sessionPrice * (r.charge_pct ?? 0)) / 100)}`;
return 'Sem cobrança';
});
// ─── Helpers ───────────────────────────────────────────────────────────
function _fmtBRL(v) {
return Number(v ?? 0).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function _calcInitialFineAmount() {
const r = props.regraExcecao;
if (!r) return 0;
if (r.charge_mode === 'full') return Number(props.sessionPrice) || 0;
if (r.charge_mode === 'fixed_fee') return Number(r.charge_value) || 0;
if (r.charge_mode === 'percentage') return parseFloat((((Number(props.sessionPrice) || 0) * (Number(r.charge_pct) || 0)) / 100).toFixed(2));
return 0;
}
function _calcInitialFineApply() {
// Se regra existe e charge_mode != 'none': default true
// Exceção: cancelado fora da janela min_hours_notice → default false (paciente cancelou dentro do prazo)
const r = props.regraExcecao;
if (!r || r.charge_mode === 'none') return false;
if (props.novoStatus === 'cancelado' && r.min_hours_notice != null && props.evento?.inicio_em) {
const horasAteSessao = (new Date(props.evento.inicio_em).getTime() - Date.now()) / (1000 * 60 * 60);
// Se cancelou COM MAIS antecedência que min → sem multa por padrão
if (horasAteSessao >= Number(r.min_hours_notice)) return false;
}
return true;
}
// ─── Actions ───────────────────────────────────────────────────────────
function onConfirm() {
emit('confirm', {
consumeSaldo: showSaldoBlock.value ? consumeSaldo.value : false,
applyFine: showFineBlock.value ? applyFine.value : false,
fineAmount: showFineBlock.value && applyFine.value ? Number(fineAmount.value) || 0 : 0,
markPaid: showRegistrarPagto.value ? markPaid.value : false,
paymentMethod: paymentMethod.value,
generatePackageCharge: showCobrancaPacote.value ? generatePackageCharge.value : false
});
emit('update:modelValue', false);
}
function onCancel() {
emit('update:modelValue', false);
}
</script>
<template>
<Dialog
:visible="modelValue"
modal
:draggable="false"
:style="{ width: '480px', maxWidth: '96vw' }"
:breakpoints="{ '640px': '98vw' }"
@update:visible="emit('update:modelValue', $event)"
>
<template #header>
<div class="asccd-head">
<div class="asccd-head__title">{{ headerTitle }}</div>
</div>
</template>
<div class="flex flex-col gap-4">
<!-- Resumo do evento -->
<div class="asccd-summary">
<div class="asccd-summary__name">{{ pacienteNome }}</div>
<div class="asccd-summary__meta">
<span v-if="dataHora">{{ dataHora }}</span>
<span class="asccd-summary__tag">{{ tipoTag }}</span>
</div>
</div>
<!-- Bloco SALDO (pacote saldo + faltou/cancelado) -->
<div v-if="showSaldoBlock" class="asccd-block">
<div class="asccd-block__title">
<i class="pi pi-wallet" />
O que fazer com a vaga do pacote?
</div>
<div class="asccd-radio-group">
<label class="asccd-radio">
<input type="radio" :value="false" v-model="consumeSaldo" />
<span><b>Remarcar</b> não consome saldo (paciente pode remarcar)</span>
</label>
<label class="asccd-radio">
<input type="radio" :value="true" v-model="consumeSaldo" />
<span><b>Descontar</b> sessão perdida ({{ billingContract?.sessions_used ?? 0 }} {{ (billingContract?.sessions_used ?? 0) + 1 }})</span>
</label>
</div>
</div>
<!-- Bloco MULTA (faltou/cancelado com regra configurada) -->
<div v-if="showFineBlock" class="asccd-block">
<div class="asccd-block__title">
<i class="pi pi-money-bill" />
Aplicar multa?
</div>
<div class="asccd-fine-rule">Regra atual: {{ regraResumo }}</div>
<div class="asccd-fine-row">
<Checkbox v-model="applyFine" inputId="asccd-apply-fine" binary />
<label for="asccd-apply-fine" class="cursor-pointer">Aplicar multa</label>
<InputNumber
v-model="fineAmount"
:disabled="!applyFine"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
size="small"
class="asccd-fine-input"
/>
</div>
<small v-if="isPacoteUpfront" class="asccd-hint">
Pacote pago; multa entra como cobrança adicional avulsa.
</small>
</div>
<!-- Pacote upfront sem multa configurada: aviso -->
<div v-if="isFaltouOrCancelado && isPacoteUpfront && !showFineBlock" class="asccd-info">
<i class="pi pi-info-circle" />
Pacote foi pago. Nenhuma multa configurada atualiza o status.
</div>
<!-- Bloco REGISTRAR PAGAMENTO (realizado + avulsa pendente) -->
<div v-if="showRegistrarPagto" class="asccd-block">
<div class="asccd-block__title">
<i class="pi pi-check-circle" />
A sessão foi paga?
</div>
<div class="asccd-radio-group">
<label class="asccd-radio">
<input type="radio" :value="false" v-model="markPaid" />
<span>Não, manter cobrança pendente</span>
</label>
<label class="asccd-radio">
<input type="radio" :value="true" v-model="markPaid" />
<span>Sim, registrar pagamento</span>
</label>
</div>
<div v-if="markPaid" class="asccd-method-row">
<label class="asccd-method-label">Como recebeu?</label>
<Select
v-model="paymentMethod"
:options="paymentMethodOptions"
optionLabel="label"
optionValue="value"
size="small"
class="asccd-method-select"
/>
</div>
</div>
<!-- Bloco COBRANÇA NO PACOTE (realizado + pacote saldo) -->
<div v-if="showCobrancaPacote" class="asccd-block">
<div class="asccd-block__title">
<i class="pi pi-money-bill" />
Gerar cobrança no pacote?
</div>
<div class="asccd-fine-rule">Valor da sessão: R$ {{ _fmtBRL(sessionPrice) }}</div>
<div class="asccd-fine-row">
<Checkbox v-model="generatePackageCharge" inputId="asccd-gen-charge" binary />
<label for="asccd-gen-charge" class="cursor-pointer">Gerar cobrança e consumir 1 sessão</label>
</div>
<div v-if="generatePackageCharge" class="asccd-method-row">
<label class="asccd-method-label">Como cobrar?</label>
<Select
v-model="paymentMethod"
:options="paymentMethodOptionsCobranca"
optionLabel="label"
optionValue="value"
size="small"
class="asccd-method-select"
/>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined size="small" @click="onCancel" />
<Button label="Confirmar" icon="pi pi-check" size="small" @click="onConfirm" />
</template>
</Dialog>
</template>
<style scoped>
.asccd-head__title {
font-size: 1rem;
font-weight: 700;
color: var(--text-color);
}
.asccd-summary {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.65rem 0.85rem;
border-radius: 6px;
background: color-mix(in srgb, var(--p-primary-500) 4%, var(--surface-card));
border: 1px solid var(--surface-border);
}
.asccd-summary__name {
font-size: 0.92rem;
font-weight: 700;
color: var(--text-color);
}
.asccd-summary__meta {
font-size: 0.74rem;
color: var(--text-color-secondary);
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.asccd-summary__tag {
display: inline-block;
padding: 1px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--p-primary-500) 12%, transparent);
color: var(--p-primary-color);
font-weight: 600;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.asccd-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 0.85rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
}
.asccd-block__title {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color);
}
.asccd-block__title i {
color: var(--p-primary-color);
font-size: 0.85rem;
}
.asccd-radio-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.asccd-radio {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
color: var(--text-color);
cursor: pointer;
}
.asccd-radio input[type='radio'] {
accent-color: var(--p-primary-color);
cursor: pointer;
}
.asccd-fine-rule {
font-size: 0.74rem;
color: var(--text-color-secondary);
font-style: italic;
}
.asccd-fine-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.asccd-fine-input {
flex: 1;
min-width: 8rem;
}
.asccd-method-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
.asccd-method-label {
font-size: 0.78rem;
color: var(--text-color);
font-weight: 500;
flex-shrink: 0;
}
.asccd-method-select {
flex: 1;
min-width: 12rem;
}
.asccd-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.85rem;
border-radius: 6px;
background: color-mix(in srgb, var(--blue-400, #60a5fa) 8%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--blue-400, #60a5fa) 30%, transparent);
font-size: 0.78rem;
color: var(--text-color);
}
.asccd-info i {
color: var(--blue-600, #2563eb);
flex-shrink: 0;
}
.asccd-hint {
font-size: 0.7rem;
color: var(--text-color-secondary);
font-style: italic;
}
</style>
@@ -93,7 +93,6 @@ async function onSave() {
:closable="!saving" :closable="!saving"
header="Novo convênio" header="Novo convênio"
class="w-[94vw] max-w-md" class="w-[94vw] max-w-md"
pt:mask:class="backdrop-blur-sm"
> >
<div class="flex flex-col gap-3 pt-1"> <div class="flex flex-col gap-3 pt-1">
<FloatLabel variant="on"> <FloatLabel variant="on">
@@ -0,0 +1,130 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/components/InsurancePlanServiceQuickCreateDialog.vue
| Data: 2026-05-18
|
| Mini-dialog pra cadastrar procedimento dentro de um convênio SEM sair
| do AgendaEventDialog. Mesmo pattern do InsurancePlanQuickCreateDialog
| emite `created` com a row inserida; o parent recarrega os planos e
| (opcionalmente) auto-seleciona o novo procedimento.
|
| Campos:
| name * Ex: "Consulta psicológica", "Avaliação", "Sessão online"
| value * Valor que o convênio paga pra clínica nesse procedimento
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
const props = defineProps({
modelValue: { type: Boolean, default: false },
insurancePlanId: { type: String, default: '' },
insurancePlanName: { type: String, default: '' }
});
const emit = defineEmits(['update:modelValue', 'created']);
const toast = useToast();
const visible = ref(props.modelValue);
watch(() => props.modelValue, (v) => { visible.value = v; });
watch(visible, (v) => emit('update:modelValue', v));
const form = ref({
name: '',
value: null
});
const saving = ref(false);
watch(() => props.modelValue, (v) => {
if (v) {
form.value = { name: '', value: null };
}
});
const canSave = () => !!form.value.name?.trim() && form.value.value != null && form.value.value > 0;
async function onSave() {
if (!canSave()) return;
if (!props.insurancePlanId) {
toast.add({ severity: 'error', summary: 'Convênio ausente', detail: 'Selecione um convênio antes de cadastrar procedimento.', life: 3500 });
return;
}
saving.value = true;
try {
const payload = {
insurance_plan_id: props.insurancePlanId,
name: form.value.name.trim().slice(0, 120),
value: Number(form.value.value),
active: true
};
const { data, error } = await supabase.from('insurance_plan_services').insert(payload).select().single();
if (error) throw error;
toast.add({ severity: 'success', summary: 'Procedimento cadastrado', life: 2200 });
emit('created', data);
visible.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao criar procedimento', detail: e?.message || 'Erro inesperado', life: 4000 });
} finally {
saving.value = false;
}
}
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
class="w-[94vw] max-w-md"
>
<template #header>
<div class="flex flex-col">
<span class="text-base font-semibold">Novo procedimento</span>
<span v-if="insurancePlanName" class="text-xs text-color-secondary">{{ insurancePlanName }}</span>
</div>
</template>
<div class="flex flex-col gap-3 pt-1">
<FloatLabel variant="on">
<InputText id="ips-name" v-model="form.name" class="w-full" autofocus maxlength="120" />
<label for="ips-name">Nome do procedimento *</label>
</FloatLabel>
<FloatLabel variant="on">
<InputNumber
id="ips-value"
v-model="form.value"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:max="999999"
class="w-full"
/>
<label for="ips-value">Valor que o convênio paga *</label>
</FloatLabel>
<p class="text-xs text-color-secondary -mt-1">
<i class="pi pi-info-circle mr-1" />
Esse é o valor que a clínica recebe não o que o paciente paga.
</p>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="visible = false" />
<Button
label="Cadastrar"
icon="pi pi-check"
:loading="saving"
:disabled="!canSave()"
class="rounded-full"
@click="onSave"
/>
</template>
</Dialog>
</template>
@@ -110,7 +110,6 @@ async function onSave() {
:closable="!saving" :closable="!saving"
header="Novo serviço" header="Novo serviço"
class="w-[94vw] max-w-md" class="w-[94vw] max-w-md"
pt:mask:class="backdrop-blur-sm"
> >
<div class="flex flex-col gap-3 pt-1"> <div class="flex flex-col gap-3 pt-1">
<FloatLabel variant="on"> <FloatLabel variant="on">
@@ -159,9 +159,10 @@ describe('isFirstOccurrence', () => {
}); });
describe('editScopeOptions', () => { describe('editScopeOptions', () => {
it('retorna 4 opções', () => { it('retorna 3 opções (todos_sem_excecao removido da UI em 2026-05-12)', () => {
const { composer } = setup(); const { composer } = setup();
expect(composer.editScopeOptions.value).toHaveLength(4); expect(composer.editScopeOptions.value).toHaveLength(3);
expect(composer.editScopeOptions.value.map((o) => o.value)).toEqual(['somente_este', 'este_e_seguintes', 'todos']);
}); });
it('"este_e_seguintes" disabled quando isFirstOccurrence', () => { it('"este_e_seguintes" disabled quando isFirstOccurrence', () => {
const serieEvents = ref([{ recurrence_date: '2026-05-15' }, { recurrence_date: '2026-05-22' }]); const serieEvents = ref([{ recurrence_date: '2026-05-15' }, { recurrence_date: '2026-05-22' }]);
@@ -0,0 +1,98 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useAgendaBloqueios.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// useAgendaBloqueios
// Carrega e expõe rows de public.agenda_bloqueios + computed de events
// background pra renderizar no FullCalendar (cinza). Usado pelos 3 layouts
// da agenda (Melissa, Terapeuta/Rail, Clínica).
//
// Contrato:
// - load(ownerId, rangeStart, rangeEnd)
// carrega bloqueios cujo data_inicio esteja dentro do range OU que
// sejam recorrentes (data_inicio pode estar em qualquer ponto, mas
// o build de events filtra pra emitir só dentro do range visível)
// - bloqueios ref(Array)
// - loading ref(Bool)
// - error ref(String)
// - buildEventsForRange(rangeStart, rangeEnd)
// wrapper sobre agendaMappers.buildBloqueioBackgroundEvents
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { buildBloqueioBackgroundEvents } from '@/features/agenda/services/agendaMappers';
export function useAgendaBloqueios() {
const bloqueios = ref([]);
const loading = ref(false);
const error = ref('');
// ownerIdOrIds: string (1 owner) ou Array<string> (multi-owner, Clínica).
async function load(ownerIdOrIds, rangeStart, rangeEnd) {
if (!ownerIdOrIds) return;
const ids = Array.isArray(ownerIdOrIds)
? ownerIdOrIds.filter(Boolean)
: [ownerIdOrIds];
if (!ids.length) return;
loading.value = true;
error.value = '';
try {
const isoStart = _toISODate(rangeStart);
const isoEnd = _toISODate(rangeEnd);
// Query: recorrentes (qualquer data) OU não-recorrentes com
// data_inicio <= isoEnd e (data_fim ?? data_inicio) >= isoStart.
// 2 queries simples + merge pra evitar string-building frágil.
const baseNonRec = supabase
.from('agenda_bloqueios')
.select('*')
.eq('recorrente', false)
.lte('data_inicio', isoEnd)
.or(`data_fim.gte.${isoStart},and(data_fim.is.null,data_inicio.gte.${isoStart})`);
const baseRec = supabase
.from('agenda_bloqueios')
.select('*')
.eq('recorrente', true);
const [{ data: nonRec, error: e1 }, { data: rec, error: e2 }] = await Promise.all([
ids.length === 1 ? baseNonRec.eq('owner_id', ids[0]) : baseNonRec.in('owner_id', ids),
ids.length === 1 ? baseRec.eq('owner_id', ids[0]) : baseRec.in('owner_id', ids)
]);
if (e1) throw e1;
if (e2) throw e2;
bloqueios.value = [...(nonRec || []), ...(rec || [])];
} catch (e) {
error.value = e?.message || 'Falha ao carregar bloqueios.';
bloqueios.value = [];
} finally {
loading.value = false;
}
}
function buildEventsForRange(rangeStart, rangeEnd) {
return buildBloqueioBackgroundEvents(bloqueios.value, rangeStart, rangeEnd);
}
return { bloqueios, loading, error, load, buildEventsForRange };
}
function _toISODate(d) {
if (!d) return null;
const dt = d instanceof Date ? d : new Date(d);
const y = dt.getFullYear();
const m = String(dt.getMonth() + 1).padStart(2, '0');
const day = String(dt.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
@@ -48,6 +48,25 @@ export function useAgendaEventActions({
servicePickerSel, servicePickerSel,
selectedPlanService, selectedPlanService,
saveCommitmentItems, saveCommitmentItems,
// chargeMode (Opção C1, 2026-05-13): ref string com modo de cobrança.
// Valores: 'none' | 'session' (avulsa) | 'package' | 'per_session' (recorrente).
// O emit('save') leva chargeMode no payload; handler em useMelissaAgenda
// decide o que criar (financial_record | billing_contract | N events+records).
// Substituiu o boolean gerarCobrancaAoSalvar.
chargeMode,
// packageStyle (2026-05-14): só relevante em chargeMode='package'.
// Valores: 'upfront' (cria 1 financial_record total + materializa 1ª ocorrência)
// | 'saldo' (só billing_contract, sem financial_record imediato — Cliniko).
packageStyle,
// paymentMethod (refatorado 2026-05-16): forma de recebimento quando
// avulsa+session OU pacote+upfront. Valores: 'link' (Asaas, status pending)
// | 'pix' | 'dinheiro' | 'deposito' | 'cartao_maquininha'. Status do
// record é controlado pelo markPaidNow abaixo, não pela forma.
paymentMethod,
// markPaidNow (refatorado 2026-05-16): boolean. Quando true E método !== 'link',
// handler marca o financial_record como paid (paciente pagou na hora).
// Quando false, record nasce pending independente do método.
markPaidNow,
props, props,
emit emit
}) { }) {
@@ -62,88 +81,88 @@ export function useAgendaEventActions({
const samePatientConflict = ref(null); const samePatientConflict = ref(null);
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
// 1. Watcher do form.status — confirma cancelar/remarcar via dialog // 1. Watcher do form.status
// e persiste no banco IMEDIATAMENTE. Reverte se cancelar. // Fase 5 (2026-05-14): pra realizado/faltou/cancelado, emit
// Antes vivia no .vue; testado em isolamento agora. // `updateSeriesEvent` pro parent abrir o AgendaStatusChangeConfirmDialog
// (com regras de exceção, saldo de pacote, etc). Sem confirm.require
// aqui — o dialog do parent é a fonte canônica.
// Pra remarcado mantém path antigo (confirm.require simples).
// Se user cancelar o dialog: parent chama onReject pra reverter o form.
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
watch( watch(
() => composer.form.value?.status, () => composer.form.value?.status,
async (newVal, oldVal) => { async (newVal, oldVal) => {
if (_skipStatusWatch.value) return; if (_skipStatusWatch.value) return;
if (!composer.isEdit.value || !composer.form.value?.id) return; if (!composer.isEdit.value || !composer.form.value?.id) return;
if (newVal !== 'cancelado' && newVal !== 'remarcado') return;
const isStatusComDialog = ['realizado', 'faltou', 'cancelado'].includes(newVal);
const isRemarcado = newVal === 'remarcado';
if (!isStatusComDialog && !isRemarcado) return;
_prevStatus.value = oldVal; _prevStatus.value = oldVal;
const isCancelar = newVal === 'cancelado';
// Fase 5: emit pro parent abrir AgendaStatusChangeConfirmDialog.
// Parent decide o que fazer e chama onReject() se user cancelar.
if (isStatusComDialog) {
const formId = composer.form.value.id;
const isVirtual = !!composer.form.value.is_occurrence || (typeof formId === 'string' && formId.startsWith('rec::'));
emit('updateSeriesEvent', {
id: isVirtual ? null : formId,
status: newVal,
recurrence_date:
composer.form.value.recurrence_date ||
composer.form.value.original_date ||
String(composer.form.value.inicio_em || '').slice(0, 10),
inicio_em: composer.form.value.inicio_em,
fim_em: composer.form.value.fim_em,
is_virtual: isVirtual,
// Form completo — handler usa pra resolver recurrence_id, billing_contract_id, etc
row: { ...composer.form.value },
// Callback pra reverter status no form se user cancelar o dialog do parent.
// _skipStatusWatch evita loop recursivo no watcher.
onReject: () => {
_skipStatusWatch.value = true;
composer.form.value.status = _prevStatus.value;
Promise.resolve().then(() => {
_skipStatusWatch.value = false;
});
}
});
return;
}
// Path legacy pra 'remarcado': confirm.require simples + UPDATE direto.
confirm.require({ confirm.require({
header: isCancelar ? 'Cancelar sessão' : 'Remarcar sessão', header: 'Remarcar sessão',
message: isCancelar message: 'Tem certeza que deseja marcar esta sessão para remarcação? O status será salvo imediatamente.',
? 'Tem certeza que deseja cancelar esta sessão? O status será salvo imediatamente.' icon: 'pi pi-refresh',
: 'Tem certeza que deseja marcar esta sessão para remarcação? O status será salvo imediatamente.',
icon: isCancelar ? 'pi pi-times-circle' : 'pi pi-refresh',
acceptLabel: 'Sim, confirmar', acceptLabel: 'Sim, confirmar',
rejectLabel: 'Não', rejectLabel: 'Não',
acceptSeverity: isCancelar ? 'danger' : 'warn', acceptSeverity: 'warn',
accept: async () => { accept: async () => {
try { try {
// Se o evento é ocorrência VIRTUAL de recorrência
// (id "rec::..." sem row real em agenda_eventos),
// delega pro parent — useMelissaAgenda.onUpdateSeriesEvent
// e AgendaTerapeutaPage.onUpdateSeriesEvent materializam
// a linha antes de aplicar status. Sem essa delegação,
// UPDATE direto em id virtual quebra com PostgreSQL
// "invalid input syntax for type uuid".
const formId = composer.form.value.id; const formId = composer.form.value.id;
const isVirtual = const isVirtual = !!composer.form.value.is_occurrence || (typeof formId === 'string' && formId.startsWith('rec::'));
!!composer.form.value.is_occurrence ||
(typeof formId === 'string' && formId.startsWith('rec::'));
if (isVirtual) { if (isVirtual) {
emit('updateSeriesEvent', { emit('updateSeriesEvent', {
id: null, // sem row real id: null,
status: newVal, status: newVal,
recurrence_date: recurrence_date: composer.form.value.recurrence_date || composer.form.value.original_date || String(composer.form.value.inicio_em || '').slice(0, 10),
composer.form.value.recurrence_date ||
composer.form.value.original_date ||
String(composer.form.value.inicio_em || '').slice(0, 10),
inicio_em: composer.form.value.inicio_em, inicio_em: composer.form.value.inicio_em,
fim_em: composer.form.value.fim_em, fim_em: composer.form.value.fim_em,
is_virtual: true, is_virtual: true,
// Form completo do dialog — handler usa pra resolver
// recurrence_id/patient_id sem depender de dialogEventRow.
row: { ...composer.form.value } row: { ...composer.form.value }
}); });
toast.add({ toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 });
severity: 'success',
summary: 'Status atualizado',
detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`,
life: 3000
});
return; return;
} }
const { data, error } = await supabase.from('agenda_eventos').update({ status: newVal }).eq('id', formId).select().single();
const { data, error } = await supabase
.from('agenda_eventos')
.update({ status: newVal })
.eq('id', formId)
.select()
.single();
if (error) throw error; if (error) throw error;
toast.add({ toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 });
severity: 'success',
summary: 'Status atualizado',
detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`,
life: 3000
});
emit('updated', data); emit('updated', data);
} catch (e) { } catch (e) {
toast.add({ toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível atualizar o status.', life: 4000 });
severity: 'error',
summary: 'Erro',
detail: e?.message || 'Não foi possível atualizar o status.',
life: 4000
});
composer.form.value.status = _prevStatus.value; composer.form.value.status = _prevStatus.value;
} }
}, },
@@ -327,6 +346,24 @@ export function useAgendaEventActions({
editMode: emitEditMode, editMode: emitEditMode,
recurrence_id: emitRecurrenceId, recurrence_id: emitRecurrenceId,
original_date: emitOriginalDate, original_date: emitOriginalDate,
// _occurrenceMode: flag pra distinguir save do 2o dialog empilhado
// (editar UMA ocorrencia) do save do dialog pai. Handler decide qual
// dialog fechar — sem isso, fechava sempre o pai. 2026-05-12.
_occurrenceMode: !!props.occurrenceMode,
// chargeMode (Opção C1, 2026-05-13): handler decide entre criar
// financial_record (avulsa+session), billing_contract (recorrente+package)
// ou materializar N ocorrências + N records (recorrente+per_session).
// UI no .vue garante valores válidos por modo.
chargeMode: chargeMode?.value ?? 'none',
// packageStyle (2026-05-14): handler em useMelissaAgenda usa pra
// decidir entre upfront (1 record total + materializa 1ª) ou
// saldo (só contrato).
packageStyle: packageStyle?.value ?? 'upfront',
// paymentMethod + markPaidNow (refatorado 2026-05-16): substituem
// o antigo paymentSettlement. Handler aplica payment_method (sempre)
// e status=paid+paid_at apenas quando markPaidNow=true && method!='link'.
paymentMethod: paymentMethod?.value ?? 'link',
markPaidNow: markPaidNow?.value === true,
// legado — mantido para compatibilidade // legado — mantido para compatibilidade
serie_id: props.eventRow?.serie_id ?? null, serie_id: props.eventRow?.serie_id ?? null,
serviceItems: composer.isSessionEvent.value ? commitmentItems.value.slice() : null, serviceItems: composer.isSessionEvent.value ? commitmentItems.value.slice() : null,
@@ -88,11 +88,14 @@ export function useAgendaEventComposer(props, emit, extras = {}) {
return false; return false;
}); });
// 'todos_sem_excecao' removido da UI em 2026-05-12 — padrao SimplePractice
// nao expoe override de customizacoes (e destrutivo e raro). Backend ainda
// suporta caso outro fluxo precise, mas dialog so oferece os 3 escopos
// padrao do mercado.
const editScopeOptions = computed(() => [ const editScopeOptions = computed(() => [
{ value: 'somente_este', label: 'Somente esta sessão' }, { value: 'somente_este', label: 'Somente esta sessão' },
{ value: 'este_e_seguintes', label: 'Esta e as seguintes', disabled: isFirstOccurrence.value }, { value: 'este_e_seguintes', label: 'Esta e as seguintes', disabled: isFirstOccurrence.value },
{ value: 'todos', label: 'Todas da série' }, { value: 'todos', label: 'Todas da série' }
{ value: 'todos_sem_excecao', label: 'Todas sem exceção' }
]); ]);
// ── 4. Recorrência (criação) ─────────────────────────────────── // ── 4. Recorrência (criação) ───────────────────────────────────
@@ -96,6 +96,21 @@ export function useAgendaEventLifecycle({
const sendingReminder = ref(false); const sendingReminder = ref(false);
const serviceQuickDlgOpen = ref(false); const serviceQuickDlgOpen = ref(false);
const insuranceQuickDlgOpen = ref(false); const insuranceQuickDlgOpen = ref(false);
const planServiceQuickDlgOpen = ref(false);
// occurrenceMode: financial_record da ocorrencia atual (se existir).
// Usado pra travar edicao de tipo/servicos quando ja ha cobranca emitida
// (padrao SimplePractice — cobranca emitida e imutavel; ajustes via fluxo
// do Financeiro, nao via dialog). 2026-05-12.
const occFinancialRecord = ref(null);
const occFinancialLoading = ref(false);
// sessionPaymentRecord (2026-05-18): financial_record da sessão (mesmo
// shape do occFinancialRecord) mas SEM o guard de occurrenceMode.
// Carregado em qualquer edit de sessão pra alimentar a linha "Cobrança"
// do Resumo lateral do AgendaEventDialog. Não dispara lock — esse
// continua via occFinancialRecord (território da Fase 6/C13).
const sessionPaymentRecord = ref(null);
// ── computeds locais ─────────────────────────────────────── // ── computeds locais ───────────────────────────────────────
const serieCountByStatus = computed(() => { const serieCountByStatus = computed(() => {
@@ -192,6 +207,56 @@ export function useAgendaEventLifecycle({
} }
} }
// ── occurrence financial record loader ────────────────────
async function loadOccFinancialRecord() {
occFinancialRecord.value = null;
if (!props.occurrenceMode) return;
const evId = props.eventRow?.id;
if (!evId) return;
occFinancialLoading.value = true;
try {
const { data, error } = await supabase
.from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', evId)
.in('status', ['pending', 'paid', 'overdue'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
occFinancialRecord.value = data ?? null;
} catch (e) {
console.warn('[occurrence] erro ao carregar financial_record:', e?.message);
occFinancialRecord.value = null;
} finally {
occFinancialLoading.value = false;
}
}
// sessionPaymentRecord loader (2026-05-18): mesma query, sem guard
// de occurrenceMode. Alimenta a linha "Cobrança" do Resumo do dialog
// em qualquer edit de sessão (Melissa/Rail/Clínica) com eventRow.id.
async function loadSessionPaymentRecord() {
sessionPaymentRecord.value = null;
const evId = props.eventRow?.id;
if (!evId) return;
try {
const { data, error } = await supabase
.from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', evId)
.in('status', ['pending', 'paid', 'overdue'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
sessionPaymentRecord.value = data ?? null;
} catch (e) {
console.warn('[session-payment] erro ao carregar financial_record:', e?.message);
sessionPaymentRecord.value = null;
}
}
function onPillEditClick(ev) { function onPillEditClick(ev) {
emit('editSeriesOccurrence', { emit('editSeriesOccurrence', {
id: ev.id, id: ev.id,
@@ -278,6 +343,26 @@ export function useAgendaEventLifecycle({
} }
} }
// Quick-create de procedimento (insurance_plan_services) — inline,
// sem sair do dialog. Trigger no card Sessao/Honorarios quando o
// convenio selecionado nao tem procedimentos ou quando user quer
// adicionar mais. Apos criar, recarrega os planos pra refletir no
// computed planServices.
function openPlanServiceQuickCreate() {
if (!composer.form.value.insurance_plan_id) return;
planServiceQuickDlgOpen.value = true;
}
async function onPlanServiceCreated(service) {
await loadInsurancePlans(props.planOwnerId || props.ownerId);
// Auto-seleciona o procedimento recem-criado se o user nao
// tinha nenhum selecionado ainda (caso comum: convenio sem
// procedimentos -> cadastra o primeiro -> ja entra selecionado).
if (service?.id && !pickerBilling.selectedPlanService.value) {
pickerBilling.selectedPlanService.value = service.id;
pickerBilling.onProcedureSelect(service.id);
}
}
// ── lembrete WhatsApp manual (8.2) ───────────────────────── // ── lembrete WhatsApp manual (8.2) ─────────────────────────
async function onSendManualReminder() { async function onSendManualReminder() {
if (!composer.form.value?.id) return; if (!composer.form.value?.id) return;
@@ -349,7 +434,21 @@ export function useAgendaEventLifecycle({
if (composer.hasSerie.value) loadSerieEvents(); if (composer.hasSerie.value) loadSerieEvents();
else serieEvents.value = []; else serieEvents.value = [];
if (composer.isEdit.value) { // occurrenceMode: carrega financial_record desta ocorrencia
// pra decidir se o card Sessao/Honorarios fica locked (cobranca
// ja emitida) ou unlocked (sem cobranca, edicao livre).
loadOccFinancialRecord();
// sessionPaymentRecord: carrega em qualquer edit (Melissa
// tambem) pra alimentar a linha "Cobrança" do Resumo lateral.
loadSessionPaymentRecord();
// occurrenceMode: editando UMA ocorrencia de serie ja existente —
// tipo de compromisso ja foi escolhido (paciente + sessao). Pular
// step 1 incondicionalmente. Defesa em camadas: useMelissaAgenda
// ja seta is_occurrence=true na row (faz isEdit=true), mas se outro
// call site esquecer essa flag o guard aqui salva.
if (props.occurrenceMode || composer.isEdit.value) {
composer.step.value = 2; composer.step.value = 2;
} else { } else {
const preset = props.presetCommitmentId; const preset = props.presetCommitmentId;
@@ -452,11 +551,17 @@ export function useAgendaEventLifecycle({
sendingReminder, sendingReminder,
serviceQuickDlgOpen, serviceQuickDlgOpen,
insuranceQuickDlgOpen, insuranceQuickDlgOpen,
planServiceQuickDlgOpen,
occFinancialRecord,
occFinancialLoading,
sessionPaymentRecord,
// computeds // computeds
serieCountByStatus, serieCountByStatus,
pillDeleteMenuItems, pillDeleteMenuItems,
// series // series
loadSerieEvents, loadSerieEvents,
loadOccFinancialRecord,
loadSessionPaymentRecord,
onPillEditClick, onPillEditClick,
onPillStatusChange, onPillStatusChange,
onPillDeleteClick, onPillDeleteClick,
@@ -468,6 +573,8 @@ export function useAgendaEventLifecycle({
onServiceCreated, onServiceCreated,
openInsuranceQuickCreate, openInsuranceQuickCreate,
onInsuranceCreated, onInsuranceCreated,
openPlanServiceQuickCreate,
onPlanServiceCreated,
// reminder // reminder
onSendManualReminder onSendManualReminder
}; };
@@ -157,8 +157,16 @@ export function useCommitmentServices() {
// Atualiza commitment_services nos agenda_eventos com recurrence_id = ruleId // Atualiza commitment_services nos agenda_eventos com recurrence_id = ruleId
// onde services_customized = false (não foram editados individualmente). // onde services_customized = false (não foram editados individualmente).
// //
// Invariante adicional (2026-05-12, padrão SimplePractice): NUNCA propaga
// para eventos que já têm financial_record emitido (status pending/paid/
// overdue). Cobranças emitidas são imutáveis — ajustes só via fluxo do
// Financeiro. Sem isso, mudar o template da regra mudaria silenciosamente
// o valor referenciado por uma cobrança já entregue ao paciente.
//
// opts.fromDate: string ISO 'YYYY-MM-DD' — limita a ocorrências a partir // opts.fromDate: string ISO 'YYYY-MM-DD' — limita a ocorrências a partir
// dessa data inclusive (escopo 'este_e_seguintes'). null = todas da série. // dessa data inclusive (escopo 'este_e_seguintes'). null = todas da série.
// opts.ignoreCustomized: bypass do filtro services_customized=false
// (escopo 'todos_sem_excecao' operacional — NÃO afeta filtro financeiro).
async function propagateToSerie(ruleId, items, { fromDate = null, ignoreCustomized = false } = {}) { async function propagateToSerie(ruleId, items, { fromDate = null, ignoreCustomized = false } = {}) {
if (!ruleId) return; if (!ruleId) return;
@@ -177,8 +185,23 @@ export function useCommitmentServices() {
if (queryError) throw queryError; if (queryError) throw queryError;
if (!events?.length) return; if (!events?.length) return;
// Filtra OUT eventos que já têm financial_record emitido. Uma query
// em batch evita N round-trips. Status considerados imutáveis: pending,
// paid, overdue. cancelled é ok propagar (record foi descartado).
const eventIds = events.map((e) => e.id);
const { data: lockedEvents, error: frErr } = await supabase
.from('financial_records')
.select('agenda_evento_id')
.in('agenda_evento_id', eventIds)
.in('status', ['pending', 'paid', 'overdue']);
if (frErr) throw frErr;
const lockedSet = new Set((lockedEvents || []).map((r) => r.agenda_evento_id));
const eligibleEvents = events.filter((ev) => !lockedSet.has(ev.id));
if (!eligibleEvents.length) return;
// Para cada evento elegível: delete + insert (padrão idempotente) // Para cada evento elegível: delete + insert (padrão idempotente)
for (const ev of events) { for (const ev of eligibleEvents) {
const { error: delErr } = await supabase.from('commitment_services').delete().eq('commitment_id', ev.id); const { error: delErr } = await supabase.from('commitment_services').delete().eq('commitment_id', ev.id);
if (delErr) throw delErr; if (delErr) throw delErr;
@@ -66,7 +66,8 @@ export function useFinancialExceptions() {
charge_mode: payload.charge_mode, charge_mode: payload.charge_mode,
charge_value: payload.charge_value ?? null, charge_value: payload.charge_value ?? null,
charge_pct: payload.charge_pct ?? null, charge_pct: payload.charge_pct ?? null,
min_hours_notice: payload.min_hours_notice ?? null min_hours_notice: payload.min_hours_notice ?? null,
default_consume_on_miss: !!payload.default_consume_on_miss
}) })
.eq('id', payload.id); .eq('id', payload.id);
if (err) throw err; if (err) throw err;
@@ -78,7 +79,8 @@ export function useFinancialExceptions() {
charge_mode: payload.charge_mode, charge_mode: payload.charge_mode,
charge_value: payload.charge_value ?? null, charge_value: payload.charge_value ?? null,
charge_pct: payload.charge_pct ?? null, charge_pct: payload.charge_pct ?? null,
min_hours_notice: payload.min_hours_notice ?? null min_hours_notice: payload.min_hours_notice ?? null,
default_consume_on_miss: !!payload.default_consume_on_miss
}); });
if (err) throw err; if (err) throw err;
} }
@@ -34,6 +34,7 @@ import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices'; import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices';
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'; import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
import { useFeriados } from '@/composables/useFeriados'; import { useFeriados } from '@/composables/useFeriados';
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
import { mapAgendaEventosToCalendarEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers'; import { mapAgendaEventosToCalendarEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings'; import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
@@ -52,6 +53,50 @@ const _queryDate = route.query.date ? new Date(route.query.date + 'T12:00:00') :
// -------------------- feriados -------------------- // -------------------- feriados --------------------
const { fcEvents: feriadoFcEvents, load: loadFeriados } = useFeriados(); const { fcEvents: feriadoFcEvents, load: loadFeriados } = useFeriados();
// -------------------- bloqueios (background events cinza) --------------------
const { bloqueios: bloqueioRows, load: loadBloqueios, buildEventsForRange: buildBloqueioEvents } = useAgendaBloqueios();
// Detecta se um range [start, end] cai dentro de algum bloqueio carregado.
// Cobre dia-inteiro, janela horária e recorrência semanal. Não veta criação
// (só agendador público veta), mas sinaliza pro user via toast.
function bloqueioCobrindo(start, end) {
const arr = bloqueioRows?.value || [];
if (!arr.length || !start) return null;
const dStart = start instanceof Date ? start : new Date(start);
const dEnd = end instanceof Date ? end : new Date(end || start);
const isoDay = `${dStart.getFullYear()}-${String(dStart.getMonth() + 1).padStart(2, '0')}-${String(dStart.getDate()).padStart(2, '0')}`;
const dow = dStart.getDay();
const hhmmStart = dStart.getHours() * 60 + dStart.getMinutes();
const hhmmEnd = dEnd.getHours() * 60 + dEnd.getMinutes();
const parseHM = (s) => {
if (!s) return null;
const [h, m] = String(s).split(':').map(Number);
return Number.isFinite(h) ? h * 60 + (m || 0) : null;
};
for (const b of arr) {
if (!b) continue;
if (b.recorrente && b.dia_semana != null) {
if (Number(b.dia_semana) !== dow) continue;
} else {
const di = b.data_inicio;
const df = b.data_fim || b.data_inicio;
if (!di) continue;
if (isoDay < di || isoDay > df) continue;
}
const bhi = parseHM(b.hora_inicio);
const bhf = parseHM(b.hora_fim);
if (bhi == null || bhf == null) return b;
if (hhmmStart < bhf && hhmmEnd > bhi) return b;
}
return null;
}
const bloqueioFcEvents = computed(() => {
const s = currentRange.value.start;
const e = currentRange.value.end;
if (!s || !e) return [];
return buildBloqueioEvents(s, e);
});
onMounted(async () => { onMounted(async () => {
const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null; const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
if (tid) loadFeriados(tid); if (tid) loadFeriados(tid);
@@ -535,7 +580,7 @@ const allEvents = computed(() => {
.filter(Boolean); .filter(Boolean);
const occEvents = mapAgendaEventosToCalendarEvents(occRows); const occEvents = mapAgendaEventosToCalendarEvents(occRows);
return [...base, ...occEvents, ...feriadoFcEvents.value]; return [...base, ...occEvents, ...feriadoFcEvents.value, ...bloqueioFcEvents.value];
}); });
// -------------------- eventos fora da jornada -------------------- // -------------------- eventos fora da jornada --------------------
@@ -925,6 +970,15 @@ async function openDialogCreate({ ownerId, start, end }) {
} }
async function onSlotSelect({ ownerId, start, end }) { async function onSlotSelect({ ownerId, start, end }) {
const bloqHit = bloqueioCobrindo(start, end);
if (bloqHit) {
toast.add({
severity: 'warn',
summary: 'Horário bloqueado',
detail: `Este horário está dentro do bloqueio "${bloqHit.titulo || 'Bloqueio'}". A sessão será criada mesmo assim.`,
life: 5000
});
}
await openDialogCreate({ ownerId, start, end }); await openDialogCreate({ ownerId, start, end });
} }
@@ -937,6 +991,10 @@ async function onEventClick(info) {
if (!ev) return; if (!ev) return;
const ep = ev.extendedProps || {}; const ep = ev.extendedProps || {};
// Bloqueios/pausas são background events ignorar click.
if (ep.kind === 'bloqueio' || ep.kind === 'break') return;
dialogEventRow.value = { dialogEventRow.value = {
id: ep.isOccurrence ? null : ev.id || null, id: ep.isOccurrence ? null : ev.id || null,
owner_id: ep.owner_id, owner_id: ep.owner_id,
@@ -1686,6 +1744,10 @@ async function _reloadRange() {
allMerged.push(...merged.filter((r) => r.is_occurrence)); allMerged.push(...merged.filter((r) => r.is_occurrence));
} }
_occurrenceRows.value = allMerged; _occurrenceRows.value = allMerged;
// Bloqueios (background events) load assíncrono em paralelo. Recebe
// o array de owners da clínica pra agregar todos numa query batch.
loadBloqueios(ownerIds.value, start, end);
} }
// Ocorrências virtuais geradas pelo useRecurrence // Ocorrências virtuais geradas pelo useRecurrence
@@ -47,6 +47,7 @@ import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices'; import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices';
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'; import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
import { useFeriados } from '@/composables/useFeriados'; import { useFeriados } from '@/composables/useFeriados';
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
import { mapAgendaEventosToCalendarEvents, buildWeeklyBreakBackgroundEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers'; import { mapAgendaEventosToCalendarEvents, buildWeeklyBreakBackgroundEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
@@ -129,6 +130,57 @@ const ownerId = computed(() => settings.value?.owner_id || '');
// ----------------------------- // -----------------------------
const { fcEvents: feriadoFcEvents, load: loadFeriados } = useFeriados(); const { fcEvents: feriadoFcEvents, load: loadFeriados } = useFeriados();
// -----------------------------
// Bloqueios (background events cinza no FC)
// -----------------------------
const { bloqueios: bloqueioRows, load: loadBloqueios, buildEventsForRange: buildBloqueioEvents } = useAgendaBloqueios();
const bloqueioFcEvents = computed(() => {
const s = currentRange.value.start;
const e = currentRange.value.end;
if (!s || !e) return [];
return buildBloqueioEvents(s, e);
});
// Detecta se um range [start, end] cai dentro de algum bloqueio carregado.
// Cobre bloqueios dia-inteiro (sem hora) e bloqueios com janela horária.
// Recorrentes semanais cobertos via dia_semana.
function bloqueioCobrindo(start, end) {
const arr = bloqueioRows?.value || [];
if (!arr.length || !start) return null;
const dStart = start instanceof Date ? start : new Date(start);
const dEnd = end instanceof Date ? end : new Date(end || start);
const isoDay = `${dStart.getFullYear()}-${String(dStart.getMonth() + 1).padStart(2, '0')}-${String(dStart.getDate()).padStart(2, '0')}`;
const dow = dStart.getDay();
const hhmmStart = dStart.getHours() * 60 + dStart.getMinutes();
const hhmmEnd = dEnd.getHours() * 60 + dEnd.getMinutes();
const parseHM = (s) => {
if (!s) return null;
const [h, m] = String(s).split(':').map(Number);
return Number.isFinite(h) ? h * 60 + (m || 0) : null;
};
for (const b of arr) {
if (!b) continue;
// Recorrente semanal
if (b.recorrente && b.dia_semana != null) {
if (Number(b.dia_semana) !== dow) continue;
} else {
const di = b.data_inicio;
const df = b.data_fim || b.data_inicio;
if (!di) continue;
if (isoDay < di || isoDay > df) continue;
}
// Bloqueio sem hora = dia inteiro
const bhi = parseHM(b.hora_inicio);
const bhf = parseHM(b.hora_fim);
if (bhi == null || bhf == null) return b;
// Sobreposição de janela
if (hhmmStart < bhf && hhmmEnd > bhi) return b;
}
return null;
}
onMounted(async () => { onMounted(async () => {
const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null; const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
if (tid) loadFeriados(tid); if (tid) loadFeriados(tid);
@@ -599,7 +651,7 @@ const calendarEvents = computed(() => {
const breaks = settings.value && currentRange.value.start && currentRange.value.end ? buildWeeklyBreakBackgroundEvents(settings.value.pausas_semanais, currentRange.value.start, currentRange.value.end) : []; const breaks = settings.value && currentRange.value.start && currentRange.value.end ? buildWeeklyBreakBackgroundEvents(settings.value.pausas_semanais, currentRange.value.start, currentRange.value.end) : [];
return [...base, ...occEvents, ...breaks, ...feriadoFcEvents.value]; return [...base, ...occEvents, ...breaks, ...feriadoFcEvents.value, ...bloqueioFcEvents.value];
}); });
const visibleTitle = computed(() => { const visibleTitle = computed(() => {
@@ -1460,9 +1512,23 @@ function onCreateFromButton() {
function onSelectTime(selection) { function onSelectTime(selection) {
const durMin = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50; const durMin = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50;
const rawStart = selection.start instanceof Date ? selection.start : new Date(selection.start); const rawStart = selection.start instanceof Date ? selection.start : new Date(selection.start);
const rawEnd = selection.end instanceof Date ? selection.end : (selection.end ? new Date(selection.end) : new Date(rawStart.getTime() + durMin * 60000));
const startISO = rawStart.toISOString(); const startISO = rawStart.toISOString();
const endISO = new Date(rawStart.getTime() + durMin * 60000).toISOString(); const endISO = new Date(rawStart.getTime() + durMin * 60000).toISOString();
// Aviso de bloqueio não impede criação (regra: só agendador público
// veta), mas sinaliza pro terapeuta que ele tá agendando em cima de
// um bloqueio próprio.
const bloqHit = bloqueioCobrindo(rawStart, rawEnd);
if (bloqHit) {
toast.add({
severity: 'warn',
summary: 'Horário bloqueado',
detail: `Este horário está dentro do bloqueio "${bloqHit.titulo || 'Bloqueio'}". A sessão será criada mesmo assim.`,
life: 5000
});
}
dialogEventRow.value = { dialogEventRow.value = {
owner_id: ownerId.value, owner_id: ownerId.value,
terapeuta_id: null, terapeuta_id: null,
@@ -1485,6 +1551,10 @@ function onEventClick(info) {
if (!ev) return; if (!ev) return;
const ep = ev.extendedProps || {}; const ep = ev.extendedProps || {};
// Bloqueios/pausas são background events ignorar click pra não abrir
// dialog de compromisso em cima de bloqueio (visual cinza compromisso).
if (ep.kind === 'bloqueio' || ep.kind === 'break') return;
dialogEventRow.value = { dialogEventRow.value = {
id: ep.isOccurrence ? null : ev.id || null, id: ep.isOccurrence ? null : ev.id || null,
owner_id: ep.owner_id, owner_id: ep.owner_id,
@@ -2243,6 +2313,10 @@ async function _reloadRange() {
// 3. separa ocorrências virtuais (eventos reais já estão em rows.value) // 3. separa ocorrências virtuais (eventos reais já estão em rows.value)
_occurrenceRows.value = merged.filter((r) => r.is_occurrence); _occurrenceRows.value = merged.filter((r) => r.is_occurrence);
logEvent('AgendaTerapeutaPage', '_reloadRange: ocorrências virtuais', { count: _occurrenceRows.value.length }); logEvent('AgendaTerapeutaPage', '_reloadRange: ocorrências virtuais', { count: _occurrenceRows.value.length });
// 4. bloqueios (background events). Async em paralelo não bloqueia
// render do calendário; bloqueioFcEvents é computed e atualiza sozinho.
loadBloqueios(ownerId.value, start, end);
} }
// Ref auxiliar para ocorrências virtuais geradas pelo useRecurrence // Ref auxiliar para ocorrências virtuais geradas pelo useRecurrence
@@ -3383,19 +3457,13 @@ onBeforeUnmount(() => {
.fc-event.evt-session.evt-has-color { .fc-event.evt-session.evt-has-color {
color: #fff !important; color: #fff !important;
} }
.fc-event.evt-block { /* Bloqueios são background events (cinza ~20%, inline em _makeBloqueioEvent).
background-color: #ef4444 !important; A regra .fc-event.evt-block antiga pintava de vermelho removida.
border-color: #dc2626 !important; Deixar o backgroundColor inline (#6b728033) vencer. */
color: #fff !important;
opacity: 0.75;
}
/* dayGridMonth: o dot também precisa de cor */ /* dayGridMonth: o dot também precisa de cor */
.fc-daygrid-event.evt-session .fc-event-main { .fc-daygrid-event.evt-session .fc-event-main {
color: #fff; color: #fff;
} }
.fc-daygrid-event.evt-block .fc-event-main {
color: #fff;
}
/* Evento customizado — fora do scoped pois é HTML injetado pelo FullCalendar */ /* Evento customizado — fora do scoped pois é HTML injetado pelo FullCalendar */
.ev-custom { .ev-custom {
@@ -214,6 +214,109 @@ export function buildWeeklyBreakBackgroundEvents(pausas, rangeStart, rangeEnd) {
return out; return out;
} }
// ─────────────────────────────────────────────────────────────────────────────
// buildBloqueioBackgroundEvents
// Renderiza rows de public.agenda_bloqueios como background events cinza
// no FullCalendar. Suporta:
// - Bloqueio de dia inteiro (hora_inicio/fim NULL) → background do dia todo
// - Bloqueio com janela horária → background no intervalo
// - Bloqueio recorrente semanal (recorrente=true + dia_semana 0-6) →
// repetido em todas as ocorrências do dow no range
// ─────────────────────────────────────────────────────────────────────────────
export function buildBloqueioBackgroundEvents(bloqueios, rangeStart, rangeEnd) {
if (!Array.isArray(bloqueios) || bloqueios.length === 0) return [];
if (!rangeStart || !rangeEnd) return [];
const out = [];
const dayMs = 24 * 60 * 60 * 1000;
const startMs = startOfDay(rangeStart).getTime();
const endMs = rangeEnd.getTime();
for (const b of bloqueios) {
if (!b) continue;
const titulo = b.titulo || 'Bloqueio';
// Recorrente semanal: itera o range varrendo dias-da-semana iguais
if (b.recorrente && b.dia_semana != null) {
const dow = Number(b.dia_semana);
if (!Number.isFinite(dow) || dow < 0 || dow > 6) continue;
const hi = asTime(b.hora_inicio);
const hf = asTime(b.hora_fim);
for (let ts = startMs; ts < endMs; ts += dayMs) {
const d = new Date(ts);
if (d.getDay() !== dow) continue;
if (hi && hf) {
out.push(_makeBloqueioEvent(b.id ?? null, d, hi, hf, titulo));
} else {
out.push(_makeBloqueioDayEvent(b.id ?? null, d, titulo));
}
}
continue;
}
// Não recorrente: usa data_inicio e (opcional) data_fim
const di = _parseISODate(b.data_inicio);
if (!di) continue;
const df = _parseISODate(b.data_fim) ?? di;
// Range de dias do bloqueio (inclusive)
for (let cur = di.getTime(); cur <= df.getTime(); cur += dayMs) {
const d = new Date(cur);
// Filtra fora do range visível pra evitar lixo
if (d.getTime() + dayMs < startMs || d.getTime() > endMs) continue;
const hi = asTime(b.hora_inicio);
const hf = asTime(b.hora_fim);
if (hi && hf) {
out.push(_makeBloqueioEvent(b.id ?? null, d, hi, hf, titulo));
} else {
out.push(_makeBloqueioDayEvent(b.id ?? null, d, titulo));
}
}
}
return out;
}
function _makeBloqueioEvent(id, date, timeStart, timeEnd, titulo) {
const tag = id ?? `blq-${date.getTime()}-${timeStart}-${timeEnd}`;
return {
id: `blq-${tag}`,
title: titulo,
start: combineDateTimeISO(date, timeStart),
end: combineDateTimeISO(date, timeEnd),
display: 'background',
overlap: false,
backgroundColor: '#6b728033', // cinza ~20%
extendedProps: { kind: 'bloqueio', bloqueioId: id, label: titulo }
};
}
function _makeBloqueioDayEvent(id, date, titulo) {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const dateStr = `${yyyy}-${mm}-${dd}`;
const tag = id ?? `blq-${dateStr}`;
return {
id: `blq-day-${tag}`,
title: titulo,
start: dateStr,
allDay: true,
display: 'background',
overlap: false,
backgroundColor: '#6b728033',
extendedProps: { kind: 'bloqueio', bloqueioId: id, label: titulo }
};
}
function _parseISODate(s) {
if (!s) return null;
const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// minutesToDuration / tituloFallback // minutesToDuration / tituloFallback
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -34,7 +34,7 @@ const entitlements = useEntitlementsStore();
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa')); const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
const pagamentoPath = computed(() => (inMelissa.value ? '/melissa/pagamento' : '/configuracoes/pagamento')); const pagamentoPath = computed(() => (inMelissa.value ? '/melissa/pagamento' : '/configuracoes/pagamento'));
const hasAgendador = computed(() => entitlements.can('agendador.online')); const hasAgendador = computed(() => entitlements.can('online_scheduling.manage'));
const hasLinkPersonalizado = computed(() => entitlements.can('agendador.link_personalizado')); const hasLinkPersonalizado = computed(() => entitlements.can('agendador.link_personalizado'));
// Estado // Estado
@@ -268,6 +268,19 @@ onMounted(async () => {
</template> </template>
<template v-else> <template v-else>
<!-- Toolbar topo: botão "+ Novo convênio". aparece quando
não está no modo de cadastro inline (senão fica visualmente
confuso ter botão + form abertos juntos). -->
<div v-if="!addingNew" class="flex items-center justify-end gap-2">
<Button
label="Novo convênio"
icon="pi pi-plus"
size="small"
class="rounded-full"
@click="addingNew = true"
/>
</div>
<!-- Form novo convênio --> <!-- Form novo convênio -->
<div v-if="addingNew" class="cfg-wrap"> <div v-if="addingNew" class="cfg-wrap">
<div class="cfg-wrap__head"> <div class="cfg-wrap__head">
@@ -309,7 +322,7 @@ onMounted(async () => {
<div v-if="!plans.length && !addingNew" class="cfg-empty"> <div v-if="!plans.length && !addingNew" class="cfg-empty">
<i class="pi pi-id-card text-3xl opacity-25" /> <i class="pi pi-id-card text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum convênio cadastrado</div> <div class="text-sm font-medium">Nenhum convênio cadastrado</div>
<div class="text-xs opacity-70">Clique em "Novo convênio" para começar.</div> <div class="text-xs opacity-70">Use o botão <b>Novo convênio</b> acima pra começar.</div>
</div> </div>
<!-- Lista de convênios --> <!-- Lista de convênios -->
@@ -105,7 +105,8 @@ function startEdit(type) {
charge_mode: rec?.charge_mode ?? 'none', charge_mode: rec?.charge_mode ?? 'none',
charge_value: rec?.charge_value != null ? Number(rec.charge_value) : null, charge_value: rec?.charge_value != null ? Number(rec.charge_value) : null,
charge_pct: rec?.charge_pct != null ? Number(rec.charge_pct) : null, charge_pct: rec?.charge_pct != null ? Number(rec.charge_pct) : null,
min_hours_notice: rec?.min_hours_notice != null ? Number(rec.min_hours_notice) : null min_hours_notice: rec?.min_hours_notice != null ? Number(rec.min_hours_notice) : null,
default_consume_on_miss: !!rec?.default_consume_on_miss
}; };
} }
@@ -125,7 +126,8 @@ async function saveEdit() {
charge_mode: editForm.value.charge_mode, charge_mode: editForm.value.charge_mode,
charge_value: editForm.value.charge_mode === 'fixed_fee' ? (editForm.value.charge_value ?? null) : null, charge_value: editForm.value.charge_mode === 'fixed_fee' ? (editForm.value.charge_value ?? null) : null,
charge_pct: editForm.value.charge_mode === 'percentage' ? (editForm.value.charge_pct ?? null) : null, charge_pct: editForm.value.charge_mode === 'percentage' ? (editForm.value.charge_pct ?? null) : null,
min_hours_notice: editForm.value.exception_type === 'patient_cancellation' ? (editForm.value.min_hours_notice ?? null) : null min_hours_notice: editForm.value.exception_type === 'patient_cancellation' ? (editForm.value.min_hours_notice ?? null) : null,
default_consume_on_miss: !!editForm.value.default_consume_on_miss
}); });
await load(ownerId.value); await load(ownerId.value);
cancelEdit(); cancelEdit();
@@ -239,6 +241,18 @@ onMounted(async () => {
</div> </div>
</div> </div>
<!-- Default consume_on_miss (2026-05-14): toggle pra padrão do
"Descontar do saldo" no dialog de status change. -->
<div class="flex items-start gap-2.5 mt-1">
<Checkbox v-model="editForm.default_consume_on_miss" inputId="edit-consume-default" binary />
<label for="edit-consume-default" class="cursor-pointer flex flex-col gap-0.5">
<span class="text-sm font-medium">Descontar do saldo do pacote por padrão</span>
<small class="text-[var(--text-color-secondary)] opacity-70 text-xs">
Quando esta exceção ocorre em sessão de pacote saldo, o dialog vem com "Descontar" marcado. Terapeuta pode override caso a caso.
</small>
</label>
</div>
<!-- Botões na linha separada --> <!-- Botões na linha separada -->
<div class="flex gap-2 justify-end mt-1"> <div class="flex gap-2 justify-end mt-1">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" /> <Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
+88 -5
View File
@@ -394,6 +394,18 @@ const fcEvents = computed(() => {
// abaixo. Mantem a cor do commitment pra nao perder contexto. // abaixo. Mantem a cor do commitment pra nao perder contexto.
const pStatus = ev.paciente_status; const pStatus = ev.paciente_status;
const isInactivePatient = pStatus === 'Arquivado' || pStatus === 'Inativo'; const isInactivePatient = pStatus === 'Arquivado' || pStatus === 'Inativo';
// Sessão paga barra esquerda verde (override do border-left que o
// FC pinta com a cor do commitment). Espelha as mesmas condições do
// badge $ amber: sessão + paciente + não-virtual; aqui inverte pra
// paymentState === 'paid'.
const isPaidSession =
String(ev.tipo || '').toLowerCase() === 'sessao' &&
(ev.patient_id || ev.paciente_id) &&
!ev.is_occurrence &&
ev.paymentState === 'paid';
const cls = [];
if (isInactivePatient) cls.push('ma-evt--inactive-patient');
if (isPaidSession) cls.push('ma-evt--paid');
out.push({ out.push({
id: ev.id, id: ev.id,
title: ev.label, title: ev.label,
@@ -402,7 +414,7 @@ const fcEvents = computed(() => {
backgroundColor: `${ev.color}26`, // ~15% opacity backgroundColor: `${ev.color}26`, // ~15% opacity
borderColor: ev.color, borderColor: ev.color,
textColor: 'white', textColor: 'white',
classNames: isInactivePatient ? ['ma-evt--inactive-patient'] : undefined, classNames: cls.length ? cls : undefined,
extendedProps: ev extendedProps: ev
}); });
} }
@@ -411,6 +423,11 @@ const fcEvents = computed(() => {
if (feriados.length) { if (feriados.length) {
for (const f of feriados) out.push(f); for (const f of feriados) out.push(f);
} }
// Bloqueios (background events cinza) concat direto sem filtro.
const blqs = bloqueioFcEvents.value;
if (blqs.length) {
for (const b of blqs) out.push(b);
}
return out; return out;
}); });
@@ -568,7 +585,11 @@ const fcOptions = computed(() => ({
}, },
eventClick: (info) => { eventClick: (info) => {
const ev = info.event.extendedProps; const ev = info.event.extendedProps;
if (ev) emit('select-evento', ev); if (!ev) return;
// Bloqueios e pausas semanais são background events não-clicáveis
// o painel lateral é só pra sessões/compromissos reais.
if (ev.kind === 'bloqueio' || ev.kind === 'break') return;
emit('select-evento', ev);
}, },
// Drag reagenda evento (mesmo dia, hora diferente OU outro dia) // Drag reagenda evento (mesmo dia, hora diferente OU outro dia)
eventDrop: (info) => { eventDrop: (info) => {
@@ -621,6 +642,18 @@ const fcOptions = computed(() => ({
</div> </div>
`; `;
} }
// Badge "$ a receber" sessão com paciente, ainda não paga.
// Cobre Cenário 2 (sem cobrança, sem record) e Cenário 3 (cobrança
// pendente). Esconde quando paid ou quando não é sessão com paciente.
// Ocorrências virtuais sempre 'none' até serem materializadas pra
// não poluir séries recorrentes com pacote upfront/saldo (cobertas
// pelo contrato, não por record-por-sessão).
let payBadgeHtml = '';
if (isSessao && ext.patient_id && !ext.is_occurrence && ext.paymentState !== 'paid') {
payBadgeHtml = `<span class="mc-fc-event__paybadge" title="Cobrança pendente"><i class="pi pi-dollar"></i></span>`;
}
// Pra eventos não-sessão (compromisso, bloqueio etc.) mantém o // Pra eventos não-sessão (compromisso, bloqueio etc.) mantém o
// antigo `__meta` com modalidade ou título secundário. // antigo `__meta` com modalidade ou título secundário.
const metaFallback = !isSessao ? (ext.modalidade || ext.titulo || '') : ''; const metaFallback = !isSessao ? (ext.modalidade || ext.titulo || '') : '';
@@ -628,6 +661,7 @@ const fcOptions = computed(() => ({
return { return {
html: ` html: `
<div class="mc-fc-event"> <div class="mc-fc-event">
${payBadgeHtml}
${titleLine} ${titleLine}
${badgesHtml} ${badgesHtml}
${metaFallback ? `<div class="mc-fc-event__meta">${escHtml(metaFallback)}</div>` : ''} ${metaFallback ? `<div class="mc-fc-event__meta">${escHtml(metaFallback)}</div>` : ''}
@@ -729,15 +763,23 @@ function goNext() { fcApi()?.next(); }
function goToday() { fcApi()?.today(); } function goToday() { fcApi()?.today(); }
function setView(v) { function setView(v) {
// Detecta saida da view 'lista' antes de trocar se o user veio de
// lista, o refDate atual ta em (hoje - 1 ano) e ao mudar pra week/month
// o FullCalendar mantem esse refDate, fazendo a agenda parecer estar
// no ano passado. Snap pra hoje resolve. 2026-05-12.
const leavingLista = calendarView.value === 'lista' && v !== 'lista';
calendarView.value = v; calendarView.value = v;
fcApi()?.changeView(VIEW_MAP[v]); fcApi()?.changeView(VIEW_MAP[v]);
// Lista cobre 2 anos abrimos centrado: pula pra (hoje - 1 ano) pra
// mostrar passado + presente + futuro de uma vez. Outras views mantém
// o refDate atual (datesSet sincroniza viewStart/End normalmente).
if (v === 'lista') { if (v === 'lista') {
// Lista cobre 2 anos abrimos centrado: pula pra (hoje - 1 ano) pra
// mostrar passado + presente + futuro de uma vez.
const umAnoAtras = new Date(); const umAnoAtras = new Date();
umAnoAtras.setFullYear(umAnoAtras.getFullYear() - 1); umAnoAtras.setFullYear(umAnoAtras.getFullYear() - 1);
fcApi()?.gotoDate(umAnoAtras); fcApi()?.gotoDate(umAnoAtras);
} else if (leavingLista) {
fcApi()?.today();
} }
} }
@@ -779,10 +821,12 @@ const tenantStore = useTenantStore();
// acessos diretos a M.x dispararam TypeError ao montar fora do layout. // acessos diretos a M.x dispararam TypeError ao montar fora do layout.
const _feriadosFallback = ref([]); const _feriadosFallback = ref([]);
const _feriadoFcEventsFallback = ref([]); const _feriadoFcEventsFallback = ref([]);
const _bloqueioFcEventsFallback = ref([]);
const _feriadosAnoFallback = ref(new Date().getFullYear()); const _feriadosAnoFallback = ref(new Date().getFullYear());
const _workRulesFallback = ref([]); const _workRulesFallback = ref([]);
const feriadosTodos = M?.feriados ?? _feriadosFallback; const feriadosTodos = M?.feriados ?? _feriadosFallback;
const feriadoFcEvents = M?.feriadoFcEvents ?? _feriadoFcEventsFallback; const feriadoFcEvents = M?.feriadoFcEvents ?? _feriadoFcEventsFallback;
const bloqueioFcEvents = M?.bloqueioFcEvents ?? _bloqueioFcEventsFallback;
const feriadosAno = M?.feriadosAno ?? _feriadosAnoFallback; const feriadosAno = M?.feriadosAno ?? _feriadosAnoFallback;
const loadFeriados = M?.loadFeriadosBase ?? (async () => {}); const loadFeriados = M?.loadFeriadosBase ?? (async () => {});
const workRules = M?.workRules ?? _workRulesFallback; const workRules = M?.workRules ?? _workRulesFallback;
@@ -2291,10 +2335,49 @@ defineExpose({
.ma-cal__fc :deep(.fc-list-event.ma-evt--inactive-patient td) { .ma-cal__fc :deep(.fc-list-event.ma-evt--inactive-patient td) {
font-style: italic; font-style: italic;
} }
/* Sessão paga barra esquerda verde no lugar da cor do commitment.
Espelho positivo do badge $ amber: pago = canal visual esquerdo,
pendente = canal direito, sem cobrança = neutro. !important porque
o FC seta border-color inline a partir do borderColor do evento. */
.ma-cal__fc :deep(.fc-event.ma-evt--paid) {
border-left-color: #10b981 !important; /* emerald-500 */
border-left-width: 4px !important;
}
.ma-cal__fc :deep(.fc-list-event.ma-evt--paid .fc-list-event-dot) {
border-color: #10b981 !important;
}
.ma-cal__fc :deep(.mc-fc-event) { .ma-cal__fc :deep(.mc-fc-event) {
padding: 4px 6px; padding: 4px 6px;
color: var(--m-text); color: var(--m-text);
font-family: inherit; font-family: inherit;
position: relative;
}
/* Badge "$ a receber" canto superior direito do evento. Amarelo
amber, pequeno, sinaliza cobrança pendente sem competir com os
badges de status/modalidade. Renderizado pra sessão+paciente
com paymentState !== 'paid'. */
.ma-cal__fc :deep(.mc-fc-event__paybadge) {
position: absolute;
top: 2px;
right: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 9999px;
background: #f59e0b;
color: #fff;
font-size: 0.6rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
z-index: 2;
}
.ma-cal__fc :deep(.mc-fc-event__paybadge .pi) {
font-size: 0.62rem;
font-weight: 700;
} }
.ma-cal__fc :deep(.mc-fc-event__title) { .ma-cal__fc :deep(.mc-fc-event__title) {
/* Mesmo tamanho/peso da .ma-pat__name (lista de pacientes) pra /* Mesmo tamanho/peso da .ma-pat__name (lista de pacientes) pra
+2 -2
View File
@@ -34,7 +34,7 @@ const toast = useToast();
const tenantStore = useTenantStore(); const tenantStore = useTenantStore();
const entitlements = useEntitlementsStore(); const entitlements = useEntitlementsStore();
const hasAgendador = computed(() => entitlements.can('agendador.online')); const hasAgendador = computed(() => entitlements.can('online_scheduling.manage'));
const hasLinkPersonalizado = computed(() => entitlements.can('agendador.link_personalizado')); const hasLinkPersonalizado = computed(() => entitlements.can('agendador.link_personalizado'));
const AGENDADOR_BUCKET = 'agendador'; const AGENDADOR_BUCKET = 'agendador';
@@ -697,7 +697,7 @@ const summaryItems = computed(() => [
</div> </div>
<!-- Link público --> <!-- Link público -->
<template v-if="cfg.ativo"> <template v-if="cfg.ativo && hasAgendador">
<div v-if="!cfg.link_slug" class="mag-link-loading"> <div v-if="!cfg.link_slug" class="mag-link-loading">
<i class="pi pi-spin pi-spinner" /> <i class="pi pi-spin pi-spinner" />
<span>Gerando link</span> <span>Gerando link</span>
+176 -14
View File
@@ -33,9 +33,24 @@ const emit = defineEmits([
'edit-paciente', // botão "Editar" do grupo Outras opções PatientCadastroDialog 'edit-paciente', // botão "Editar" do grupo Outras opções PatientCadastroDialog
'abrir-prontuario', 'abrir-prontuario',
'whatsapp', 'whatsapp',
'historico' 'historico',
'delete-sessao', // botão "Excluir sessão" só pra sessões avulsas (sem recorrência)
'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)
]); ]);
// Regra atualizada (2026-05-14): botão Excluir aparece SEMPRE pra sessão.
// Handler no parent verifica:
// - virtual sem materialização cria recurrence_exception cancel_session
// - real sem records pagos DELETE (cobranças pendentes vão junto)
// - real com record PAGO bloqueia (estorno pelo Financeiro primeiro)
const canDelete = computed(() => {
const e = ev.value;
if (!e) return false;
// Pra MVP: oculta só em compromisso não-sessão sem id real.
return true;
});
const ev = computed(() => props.evento || {}); const ev = computed(() => props.evento || {});
const tipoLabel = computed(() => { const tipoLabel = computed(() => {
@@ -69,6 +84,41 @@ const isSessaoComPaciente = computed(
() => ev.value.tipo === 'sessao' && (ev.value.patient_id || ev.value.pacienteNome) () => ev.value.tipo === 'sessao' && (ev.value.patient_id || ev.value.pacienteNome)
); );
// Estado de pagamento vem anotado pelo useMelissaAgenda via bulk-query
// em financial_records. 'paid' | 'pending' | 'none'. Renderiza linha
// curta abaixo do horário pra sessão com paciente (espelha os 3 canais
// visuais da agenda). Ocorrências virtuais (sem id real) sempre 'none'
// não polui séries com pacote upfront.
const showPaymentRow = computed(() => {
if (!isSessaoComPaciente.value) return false;
if (ev.value.is_occurrence) return false;
return !!ev.value.paymentState;
});
const paymentVariant = computed(() => {
const s = ev.value.paymentState;
if (s === 'paid') return 'paid';
if (s === 'pending') return 'pending';
return 'none';
});
const paymentIcon = computed(() => {
return paymentVariant.value === 'paid' ? 'pi pi-check-circle' : 'pi pi-dollar';
});
const paymentLabel = computed(() => {
const state = ev.value.paymentState;
const valor = ev.value.price;
const valorFmt = (valor != null && !Number.isNaN(Number(valor)))
? Number(valor).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
: null;
if (state === 'paid') {
return valorFmt ? `Pago · ${valorFmt}` : 'Pago';
}
if (state === 'pending') {
return valorFmt ? `A receber ${valorFmt} (cobrança pendente)` : 'Cobrança pendente';
}
// 'none' sessão sem cobrança gerada ainda
return valorFmt ? `A cobrar ${valorFmt}` : 'Cobrança ainda não gerada';
});
function fmtHora(decimal) { function fmtHora(decimal) {
if (decimal === null || decimal === undefined || Number.isNaN(decimal)) return '—'; if (decimal === null || decimal === undefined || Number.isNaN(decimal)) return '—';
const h = Math.floor(decimal); const h = Math.floor(decimal);
@@ -121,16 +171,34 @@ function modalidadeIcon(mod) {
{{ fmtHora(ev.startH) }} {{ fmtHora(ev.endH) }} {{ fmtHora(ev.startH) }} {{ fmtHora(ev.endH) }}
<span v-if="duracaoMin() !== null" class="evento-row__sub">· {{ duracaoMin() }}min</span> <span v-if="duracaoMin() !== null" class="evento-row__sub">· {{ duracaoMin() }}min</span>
</span> </span>
<button <div class="evento-row__edit-stack">
type="button" <button
class="evento-row__edit" type="button"
:disabled="busy" class="evento-row__edit evento-row__edit--primary"
v-tooltip.top="'Editar sessão (data, hora, recorrência…)'" :disabled="busy"
@click="emit('edit-sessao')" v-tooltip.top="'Editar sessão (data, hora, recorrência…)'"
> @click="emit('edit-sessao')"
<i class="pi pi-pencil" /> >
<span>Editar sessão</span> <i class="pi pi-pencil" />
</button> <span>Editar sessão</span>
</button>
<button
v-if="canDelete"
type="button"
class="evento-row__edit evento-row__edit--danger"
:disabled="busy"
v-tooltip.top="'Excluir esta sessão (permanente)'"
@click="emit('delete-sessao')"
>
<i class="pi pi-trash" />
<span>Excluir sessão</span>
</button>
</div>
</div>
<div v-if="showPaymentRow" class="evento-row evento-row--pay" :class="`evento-row--pay-${paymentVariant}`">
<i :class="paymentIcon" />
<span>{{ paymentLabel }}</span>
</div> </div>
<div v-if="ev.modalidade" class="evento-row"> <div v-if="ev.modalidade" class="evento-row">
@@ -239,6 +307,34 @@ function modalidadeIcon(mod) {
</div> </div>
</section> </section>
<!-- Grupo Financeiro abre dialog com todos os lançamentos
vinculados a esta sessão (cobrança original + multas/taxas)
+ antecipar pagamento (paciente paga antes da sessão).
Adicionado 2026-05-14. pra sessão com paciente. -->
<section v-if="isSessaoComPaciente" class="evento-actions__section">
<div class="evento-actions__label">Financeiro:</div>
<div class="evento-actions__group">
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Ver lançamentos vinculados a esta sessão'"
@click="emit('ver-lancamentos')"
>
<i class="pi pi-list" />
<span class="evento-act__label">Lançamentos</span>
</button>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Paciente quer pagar antes da sessão (pacote saldo)'"
@click="emit('antecipar-pagamento')"
>
<i class="pi pi-money-bill" />
<span class="evento-act__label">Antecipar pagamento</span>
</button>
</div>
</section>
<!-- Grupo Geral (não-sessão: bloqueio/compromisso/etc). <!-- Grupo Geral (não-sessão: bloqueio/compromisso/etc).
Aqui "Editar" abre o evento em si (não tem paciente). --> Aqui "Editar" abre o evento em si (não tem paciente). -->
<section v-else class="evento-actions__section"> <section v-else class="evento-actions__section">
@@ -369,12 +465,56 @@ function modalidadeIcon(mod) {
margin-left: 4px; margin-left: 4px;
font-size: 0.82rem; font-size: 0.82rem;
} }
/* Botão "Editar sessão" inline na linha das horas. Discreto na largura
padrão, ganha destaque no hover. Margin-left auto pra alinhar à direita. */ /* Linha de cobrança espelha os 3 canais visuais da agenda:
.evento-row__edit { - paid: verde (estado-alvo, sessão quitada)
- pending: amber (cobrança gerada mas não paga)
- none: amber leve (sem cobrança gerada ainda) */
.evento-row--pay {
font-weight: 500;
}
.evento-row--pay-paid {
color: #047857; /* emerald-700 */
}
.evento-row--pay-paid > i {
color: #10b981; /* emerald-500 */
}
html.app-dark .evento-row--pay-paid {
color: #34d399; /* emerald-400 */
}
html.app-dark .evento-row--pay-paid > i {
color: #34d399;
}
.evento-row--pay-pending,
.evento-row--pay-none {
color: #b45309;
}
.evento-row--pay-pending > i,
.evento-row--pay-none > i {
color: #f59e0b;
}
html.app-dark .evento-row--pay-pending,
html.app-dark .evento-row--pay-none {
color: #fbbf24;
}
html.app-dark .evento-row--pay-pending > i,
html.app-dark .evento-row--pay-none > i {
color: #fbbf24;
}
/* 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 {
margin-left: auto; margin-left: auto;
display: flex;
flex-direction: column;
gap: 4px;
align-items: stretch;
flex-shrink: 0;
}
.evento-row__edit {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
gap: 5px; gap: 5px;
padding: 4px 10px; padding: 4px 10px;
background: var(--m-bg-soft); background: var(--m-bg-soft);
@@ -397,6 +537,28 @@ function modalidadeIcon(mod) {
cursor: not-allowed; cursor: not-allowed;
} }
.evento-row__edit i { font-size: 0.65rem; } .evento-row__edit i { font-size: 0.65rem; }
/* Variant primary (Editar sessão — ação principal). */
.evento-row__edit--primary {
background: var(--primary-color, #7c6af7);
border-color: var(--primary-color, #7c6af7);
color: var(--primary-color-text, #fff);
}
.evento-row__edit--primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--primary-color, #7c6af7) 88%, black);
border-color: color-mix(in srgb, var(--primary-color, #7c6af7) 88%, black);
color: var(--primary-color-text, #fff);
}
/* Variant danger (Excluir sessão — destrutivo, outlined). */
.evento-row__edit--danger {
background: transparent;
border-color: color-mix(in srgb, var(--red-500, #ef4444) 50%, var(--m-border));
color: var(--red-400, #f87171);
}
.evento-row__edit--danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--red-500, #ef4444) 12%, transparent);
border-color: var(--red-500, #ef4444);
color: var(--red-300, #fca5a5);
}
.evento-status { .evento-status {
padding: 2px 10px; padding: 2px 10px;
border-radius: 999px; border-radius: 999px;
@@ -85,17 +85,26 @@ const TYPE_FILTER_OPTIONS = [
]; ];
const PAYMENT_METHOD_OPTIONS = [ const PAYMENT_METHOD_OPTIONS = [
{ label: 'Pix', value: 'pix' }, { label: 'Pix', value: 'pix' },
{ label: 'Depósito', value: 'deposito' }, { label: 'Depósito', value: 'deposito' },
{ label: 'Dinheiro', value: 'dinheiro' }, { label: 'Dinheiro', value: 'dinheiro' },
{ label: 'Cartão', value: 'cartao' }, { label: 'Cartão', value: 'cartao' },
{ label: 'Convênio', value: 'convenio' } { label: 'Cartão (maquininha)', value: 'cartao_maquininha' },
{ label: 'Convênio', value: 'convenio' },
{ label: 'Asaas', value: 'asaas' }
]; ];
function paymentLabel(method) { function paymentLabel(method) {
return PAYMENT_METHOD_OPTIONS.find((o) => o.value === method)?.label ?? method ?? '—'; return PAYMENT_METHOD_OPTIONS.find((o) => o.value === method)?.label ?? method ?? '—';
} }
// Abre link de cobrança externa (Asaas/etc) em nova aba.
// noopener/noreferrer pra segurança (gateway não vira janela parent). 2026-05-14.
function openPaymentLink(url) {
if (!url) return;
window.open(url, '_blank', 'noopener,noreferrer');
}
// Filtros reativos // Filtros reativos
const filterStatus = ref(null); const filterStatus = ref(null);
const filterType = ref(null); const filterType = ref(null);
@@ -123,6 +132,38 @@ function clearAllFilters() {
filterDateRange.value = null; filterDateRange.value = null;
} }
// Aninhamento visual (2026-05-14): records com mesmo agenda_evento_id viram
// "pai + filho(s)" o mais antigo (created_at) é o pai (sessão); demais
// (multa, taxa de cancelamento, etc) aparecem indentados embaixo. Pai
// sempre antes dos filhos na lista. Records sem agenda_evento_id (avulso
// manual) ficam como itens soltos. Não reordena entre grupos só dentro
// de cada grupo, preservando ordem de chegada do servidor.
const recordsGrouped = computed(() => {
const list = records.value || [];
if (list.length === 0) return list;
const groupOrder = [];
const groups = new Map();
for (const r of list) {
const key = r.agenda_evento_id || `solo-${r.id}`;
if (!groups.has(key)) {
groups.set(key, []);
groupOrder.push(key);
}
groups.get(key).push(r);
}
const out = [];
for (const key of groupOrder) {
const group = groups
.get(key)
.slice()
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
group.forEach((r, idx) => {
out.push({ ...r, _isChild: idx > 0 && group.length > 1, _hasChildren: idx === 0 && group.length > 1 });
});
}
return out;
});
// Paginação server-side // Paginação server-side
const pageFirst = ref(0); const pageFirst = ref(0);
const pageRows = ref(20); const pageRows = ref(20);
@@ -531,7 +572,7 @@ onBeforeUnmount(() => {
</div> </div>
<DataTable <DataTable
:value="records" :value="recordsGrouped"
dataKey="id" dataKey="id"
:loading="loading" :loading="loading"
lazy lazy
@@ -545,13 +586,20 @@ onBeforeUnmount(() => {
scrollable scrollable
scrollHeight="flex" scrollHeight="flex"
tableStyle="min-width: 880px" tableStyle="min-width: 880px"
:rowClass="(r) => (r.status === 'overdue' ? 'mfl-row-overdue' : '')" :rowClass="(r) => [r.status === 'overdue' ? 'mfl-row-overdue' : '', r._isChild ? 'mfl-row-child' : '', r._hasChildren ? 'mfl-row-parent' : ''].filter(Boolean).join(' ')"
class="mfl-table" class="mfl-table"
@page="onPageChange" @page="onPageChange"
> >
<Column header="Paciente" style="min-width: 13rem"> <Column header="Paciente" style="min-width: 13rem">
<template #body="{ data }"> <template #body="{ data }">
<div class="mfl-row__patient"> <!-- Em records "filhos" (multa, taxa) do mesmo agenda_evento_id,
esconde avatar+nome e mostra "↳ {descrição}" indentado.
Mesmo paciente do pai logo acima reduz ruído visual. -->
<div v-if="data._isChild" class="mfl-row__child">
<i class="pi pi-arrow-right-and-arrow-left-up-down mfl-row__child-icon" />
<span class="mfl-row__child-label">{{ data.description || 'Cobrança extra' }}</span>
</div>
<div v-else class="mfl-row__patient">
<span <span
class="mfl-row__avatar" class="mfl-row__avatar"
:style="data.patients?.identification_color ? { background: data.patients.identification_color } : null" :style="data.patients?.identification_color ? { background: data.patients.identification_color } : null"
@@ -620,7 +668,26 @@ onBeforeUnmount(() => {
<Column header="Ações" style="width: 11rem; min-width: 11rem"> <Column header="Ações" style="width: 11rem; min-width: 11rem">
<template #body="{ data }"> <template #body="{ data }">
<div v-if="data.status === 'pending' || data.status === 'overdue'" class="flex items-center gap-1"> <div v-if="data.status === 'pending' || data.status === 'overdue'" class="flex items-center gap-2">
<!-- Info do método (Asaas/etc): ícone + texto em azul info
na linha de cima; "Ver boleto" como texto-link na linha
de baixo (disabled enquanto integração Asaas não preenche
payment_link, tooltip muda dinâmico). 2026-05-14. -->
<div v-if="data.payment_method === 'asaas'" class="mfl-row__pending-asaas">
<div class="mfl-row__pending-method">
<i class="pi pi-link" />
{{ paymentLabel(data.payment_method) }}
</div>
<button
type="button"
class="mfl-row__pending-link"
:disabled="!data.payment_link"
v-tooltip.top="data.payment_link ? 'Abrir link de pagamento' : 'Aguardando integração Asaas'"
@click="openPaymentLink(data.payment_link)"
>
Ver boleto
</button>
</div>
<Button <Button
label="Receber" label="Receber"
icon="pi pi-check" icon="pi pi-check"
@@ -1336,6 +1403,21 @@ onBeforeUnmount(() => {
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-overdue:hover) { .mfl-table :deep(.p-datatable-tbody > tr.mfl-row-overdue:hover) {
background: rgba(220, 38, 38, 0.08); background: rgba(220, 38, 38, 0.08);
} }
/* Aninhamento visual (2026-05-14): pai ganha border-bottom mais discreto,
filho herda fundo sutil + sem border-top parece "continuação" do pai. */
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-parent > td) {
border-bottom-style: dashed !important;
border-bottom-color: var(--m-border, rgba(255, 255, 255, 0.08)) !important;
}
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-child) {
background: color-mix(in srgb, var(--p-primary-color) 4%, transparent);
}
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-child > td) {
border-top: none !important;
}
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-child:hover) {
background: color-mix(in srgb, var(--p-primary-color) 8%, transparent);
}
.mfl-table :deep(.p-datatable-loading-overlay) { .mfl-table :deep(.p-datatable-loading-overlay) {
background: color-mix(in srgb, var(--m-bg-medium) 70%, transparent); background: color-mix(in srgb, var(--m-bg-medium) 70%, transparent);
@@ -1388,6 +1470,28 @@ onBeforeUnmount(() => {
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
} }
/* Bloco "filho" (multa/taxa do mesmo agenda_evento): indent + ícone setinha. */
.mfl-row__child {
display: flex;
align-items: center;
gap: 8px;
padding-left: 22px;
min-width: 0;
}
.mfl-row__child-icon {
color: var(--m-text-muted);
font-size: 0.65rem;
transform: scaleY(-1);
flex-shrink: 0;
}
.mfl-row__child-label {
font-size: 0.82rem;
font-style: italic;
color: var(--m-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mfl-row__avatar { .mfl-row__avatar {
width: 28px; height: 28px; width: 28px; height: 28px;
border-radius: 50%; border-radius: 50%;
@@ -1457,6 +1561,40 @@ onBeforeUnmount(() => {
font-size: 0.7rem; font-size: 0.7rem;
color: var(--m-text-muted); color: var(--m-text-muted);
} }
.mfl-row__pending-asaas {
display: flex;
flex-direction: column;
gap: 2px;
}
.mfl-row__pending-method {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.74rem;
color: rgb(37, 99, 235); /* azul info — cobrança aguardando, não paga */
font-weight: 500;
}
/* "Ver boleto" como texto-link (sem botão visual). Habilitado quando
payment_link existe vira underline + cursor pointer. Disabled hoje
enquanto integração Asaas não preenche tooltip explica. 2026-05-14. */
.mfl-row__pending-link {
background: none;
border: none;
padding: 0;
font-size: 0.7rem;
color: rgb(37, 99, 235);
cursor: pointer;
text-align: left;
font-family: inherit;
}
.mfl-row__pending-link:hover:not(:disabled) {
text-decoration: underline;
}
.mfl-row__pending-link:disabled {
color: var(--m-text-muted);
cursor: not-allowed;
opacity: 0.7;
}
.mfl-row__none { .mfl-row__none {
color: var(--m-text-faint); color: var(--m-text-faint);
font-style: italic; font-style: italic;
+573 -30
View File
@@ -18,6 +18,7 @@
import { ref, computed, watch, onMounted, onBeforeUnmount, provide, nextTick } from 'vue'; import { ref, computed, watch, onMounted, onBeforeUnmount, provide, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { useLayout } from '@/layout/composables/layout'; import { useLayout } from '@/layout/composables/layout';
import { applyThemeEngine, surfaces as THEME_SURFACES, presetOptions as THEME_PRESETS } from '@/theme/theme.options'; import { applyThemeEngine, surfaces as THEME_SURFACES, presetOptions as THEME_PRESETS } from '@/theme/theme.options';
import { MELISSA_THEME_NAMES, findMelissaTheme } from './melissaThemes'; import { MELISSA_THEME_NAMES, findMelissaTheme } from './melissaThemes';
@@ -95,6 +96,7 @@ import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue'; import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'; import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue'; import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue';
import AgendaStatusChangeConfirmDialog from '@/features/agenda/components/AgendaStatusChangeConfirmDialog.vue';
// Topbar system actions trazidos do AppTopbar pra Melissa: plan switcher // Topbar system actions trazidos do AppTopbar pra Melissa: plan switcher
// (DEV), notificações e ajuda. AppTopbar não monta na rota /melissa // (DEV), notificações e ajuda. AppTopbar não monta na rota /melissa
// (fullscreen), então duplicamos os triggers + drawers aqui. // (fullscreen), então duplicamos os triggers + drawers aqui.
@@ -609,6 +611,7 @@ const eventoSelecionado = ref(null);
const eventoBusy = ref(false); // bloqueia botões enquanto UPDATE roda const eventoBusy = ref(false); // bloqueia botões enquanto UPDATE roda
const melissaAgendaRef = ref(null); // pra chamar openProntuario + setView const melissaAgendaRef = ref(null); // pra chamar openProntuario + setView
const toast = useToast(); const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore(); const tenantStore = useTenantStore();
const conversationDrawerStore = useConversationDrawerStore(); const conversationDrawerStore = useConversationDrawerStore();
@@ -630,6 +633,12 @@ const {
dialogEventRow: agendaDialogEventRow, dialogEventRow: agendaDialogEventRow,
dialogStartISO: agendaDialogStartISO, dialogStartISO: agendaDialogStartISO,
dialogEndISO: agendaDialogEndISO, dialogEndISO: agendaDialogEndISO,
dialogBlockOverlap: agendaDialogBlockOverlap,
occDialogOpen: agendaOccDialogOpen,
occDialogEventRow: agendaOccDialogEventRow,
occDialogStartISO: agendaOccDialogStartISO,
occDialogEndISO: agendaOccDialogEndISO,
serieRefreshTick: agendaSerieRefreshTick,
ownerId: agendaOwnerId, ownerId: agendaOwnerId,
clinicTenantId: agendaClinicTenantId, clinicTenantId: agendaClinicTenantId,
commitmentOptions: agendaCommitmentOptions, commitmentOptions: agendaCommitmentOptions,
@@ -638,7 +647,12 @@ const {
allEventsForDialog: agendaAllEvents, allEventsForDialog: agendaAllEvents,
feriados: agendaFeriados, feriados: agendaFeriados,
bloqueioDialogOpen: agendaBloqueioOpen, bloqueioDialogOpen: agendaBloqueioOpen,
bloqueioMode: agendaBloqueioMode bloqueioMode: agendaBloqueioMode,
// Status change confirm dialog (Fase 5, 2026-05-14)
statusDialogOpen: agendaStatusDialogOpen,
statusDialogProps: agendaStatusDialogProps,
onStatusDialogConfirm: agendaOnStatusDialogConfirm,
onStatusDialogCancel: agendaOnStatusDialogCancel
} = M; } = M;
function abrirEvento(ev) { function abrirEvento(ev) {
@@ -650,12 +664,10 @@ function fecharEvento() {
} }
// Actions do MelissaEventoPanel // Actions do MelissaEventoPanel
// updateStatus: muda status no DB e refetcha agenda. Pattern espelha // Fase 5 (2026-05-14): TODOS os status (realizado/faltou/cancelado/etc)
// AgendaTerapeutaPage (sem optimistic update por simplicidade no MVP). // passam por M.onUpdateSeriesEvent que abre o AgendaStatusChangeConfirmDialog
// // quando há regra de exceção, pacote saldo ou pending record. Antes, eventos
// Quando `ev` é ocorrência VIRTUAL de recorrência (id `rec::...` sem row real), // reais faziam UPDATE direto sem passar pelo dialog (gap reportado pelo user).
// delega pro M.onUpdateSeriesEvent que materializa antes do UPDATE sem isso
// PostgreSQL recusa o UPDATE com "invalid input syntax for type uuid".
async function updateEventoStatus(novoStatus, msgSucesso) { async function updateEventoStatus(novoStatus, msgSucesso) {
const ev = eventoSelecionado.value; const ev = eventoSelecionado.value;
if (!ev?.id || eventoBusy.value) return; if (!ev?.id || eventoBusy.value) return;
@@ -665,29 +677,18 @@ async function updateEventoStatus(novoStatus, msgSucesso) {
!!ev.is_occurrence || !!ev.is_occurrence ||
(typeof ev.id === 'string' && ev.id.startsWith('rec::')); (typeof ev.id === 'string' && ev.id.startsWith('rec::'));
if (isVirtual) { await M.onUpdateSeriesEvent({
await M.onUpdateSeriesEvent({ id: isVirtual ? null : ev.id,
id: null, status: novoStatus,
status: novoStatus, recurrence_date:
recurrence_date: ev.recurrence_date ||
ev.recurrence_date || ev.original_date ||
ev.original_date || String(ev.inicio_em || '').slice(0, 10),
String(ev.inicio_em || '').slice(0, 10), inicio_em: ev.inicio_em,
inicio_em: ev.inicio_em, fim_em: ev.fim_em,
fim_em: ev.fim_em, is_virtual: isVirtual,
is_virtual: true, row: ev
// Passa o ev completo sem isso o handler depende de });
// dialogEventRow.value (que está vazio quando o user clica
// direto no evento do FC sem abrir o dialog antes).
row: ev
});
} else {
const { error } = await supabase
.from('agenda_eventos')
.update({ status: novoStatus })
.eq('id', ev.id);
if (error) throw error;
}
toast.add({ severity: 'success', summary: msgSucesso, life: 2200 }); toast.add({ severity: 'success', summary: msgSucesso, life: 2200 });
// Refetch: // Refetch:
// - M.refetch() alimenta a Agenda (FullCalendar + ocorrências virtuais) // - M.refetch() alimenta a Agenda (FullCalendar + ocorrências virtuais)
@@ -700,6 +701,7 @@ async function updateEventoStatus(novoStatus, msgSucesso) {
} catch (e) { } catch (e) {
const msg = e?.message || 'Erro ao atualizar evento'; const msg = e?.message || 'Erro ao atualizar evento';
toast.add({ severity: 'error', summary: 'Falha ao atualizar', detail: msg, life: 4000 }); toast.add({ severity: 'error', summary: 'Falha ao atualizar', detail: msg, life: 4000 });
} finally {
eventoBusy.value = false; eventoBusy.value = false;
} }
} }
@@ -708,6 +710,354 @@ function onConcluir() { updateEventoStatus('realizado', 'Sessão marcada como r
function onFaltou() { updateEventoStatus('faltou', 'Marcada como falta'); } function onFaltou() { updateEventoStatus('faltou', 'Marcada como falta'); }
function onCancelar() { updateEventoStatus('cancelado', 'Evento cancelado'); } function onCancelar() { updateEventoStatus('cancelado', 'Evento cancelado'); }
// Excluir evento via popover (Fase 5, 2026-05-14). Regra: não permitir
// exclusão direta de ocorrência de série recorrente usar fluxo do dialog
// pra encerrar série ou editar ocorrência. Pra avulsa: confirm + delete +
// remove cobranças vinculadas (com aviso explícito no confirm).
// Ver lançamentos da sessão abre dialog com financial_records vinculados.
// Reusa o mesmo padrão do dialog dentro do AgendaEventDialog. 2026-05-14.
const lancamentosDialogOpen = ref(false);
const lancamentosList = ref([]);
const lancamentosLoading = ref(false);
const lancamentosEventoTitulo = ref('');
// Antecipar pagamento (Fase 5, 2026-05-14): paciente quer pagar antes da
// sessão (caso típico em pacote saldo). Materializa a ocorrência (se virtual)
// + cria financial_record paid (PIX/etc) ou pending (Asaas). NÃO decrementa
// sessions_used só quando marcar Realizada depois.
const anteciparDialogOpen = ref(false);
const anteciparMethod = ref('pix');
const anteciparBusy = ref(false);
const anteciparEventoRef = ref(null); // snapshot do evento no momento do click
const anteciparMethodOptions = [
{ value: 'pix', label: 'Já recebi — PIX' },
{ value: 'dinheiro', label: 'Já recebi — Dinheiro' },
{ value: 'deposito', label: 'Já recebi — Depósito' },
{ value: 'cartao_maquininha', label: 'Já recebi — Cartão (maquininha)' },
{ value: 'link', label: 'Enviar link de pagamento (Asaas)' }
];
async function onAnteciparPagamento() {
const ev = eventoSelecionado.value;
if (!ev) return;
// Valida: precisa ter paciente, valor (price)
if (!ev.patient_id || !ev.price) {
toast.add({
severity: 'warn',
summary: 'Não é possível antecipar',
detail: 'Sessão precisa ter paciente e valor configurado.',
life: 4000
});
return;
}
anteciparEventoRef.value = ev;
anteciparMethod.value = 'pix';
anteciparDialogOpen.value = true;
}
async function confirmAnteciparPagamento() {
const ev = anteciparEventoRef.value;
if (!ev || anteciparBusy.value) return;
anteciparBusy.value = true;
try {
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
const ownerId = ev.owner_id || ev.terapeuta_id || null;
const settlement = anteciparMethod.value;
const amount = Number(ev.price) || 0;
const dueIso = ev.inicio_em ? new Date(ev.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
// 1) Materializa se virtual (cria agenda_evento real com status='agendado')
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
const isVirtual = ev.is_occurrence || isVirtualId;
let eventoId = ev.id;
if (isVirtual) {
const rid = ev.recurrence_id || ev.serie_id || null;
const rDate = ev.recurrence_date || ev.original_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
if (!rid || !rDate) throw new Error('Não foi possível identificar a regra de recorrência.');
// Confere se já não foi materializada
const { data: existing } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', rid)
.eq('recurrence_date', rDate)
.maybeSingle();
if (existing?.id) {
eventoId = existing.id;
} else {
const { data: created, error: cErr } = await supabase
.from('agenda_eventos')
.insert({
owner_id: ownerId,
tenant_id: tenantId,
recurrence_id: rid,
recurrence_date: rDate,
tipo: 'sessao',
status: 'agendado',
titulo: ev.titulo || 'Sessão',
inicio_em: ev.inicio_em,
fim_em: ev.fim_em,
patient_id: ev.patient_id,
determined_commitment_id: ev.determined_commitment_id || null,
modalidade: ev.modalidade || 'presencial',
price: amount,
visibility_scope: 'public'
})
.select('id')
.single();
if (cErr) throw cErr;
eventoId = created.id;
}
}
// 2) Verifica se já tem financial_record vinculado
const { data: existRec } = await supabase
.from('financial_records')
.select('id, status')
.eq('agenda_evento_id', eventoId)
.is('deleted_at', null)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (existRec?.status === 'paid') {
toast.add({ severity: 'info', summary: 'Já está pago', detail: 'Esta sessão já tem cobrança paga.', life: 3500 });
return;
}
// 3) Cria record via RPC (ou usa existente pending pra marcar paid)
let recordId = existRec?.id || null;
if (!recordId) {
const { error: rpcErr } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: ownerId,
p_patient_id: ev.patient_id,
p_agenda_evento_id: eventoId,
p_amount: amount,
p_due_date: dueIso
});
if (rpcErr) throw rpcErr;
const { data: newRec } = await supabase
.from('financial_records')
.select('id')
.eq('agenda_evento_id', eventoId)
.order('created_at', { ascending: false })
.limit(1)
.single();
recordId = newRec?.id;
}
// 4) Aplica status conforme settlement
if (recordId) {
const patch = { updated_at: new Date().toISOString() };
if (settlement === 'link') {
patch.payment_method = 'asaas';
// status fica pending
} else {
patch.status = 'paid';
patch.paid_at = new Date().toISOString();
patch.payment_method = settlement;
}
await supabase.from('financial_records').update(patch).eq('id', recordId);
}
const methodLabel = anteciparMethodOptions.find((o) => o.value === settlement)?.label || settlement;
toast.add({
severity: 'success',
summary: settlement === 'link' ? 'Cobrança gerada' : 'Pagamento registrado',
detail: `R$ ${amount.toFixed(2).replace('.', ',')}${methodLabel}`,
life: 4000
});
anteciparDialogOpen.value = false;
M.refetch();
refetchEventosHoje();
fecharEvento();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao antecipar pagamento.', life: 5000 });
} finally {
anteciparBusy.value = false;
}
}
async function onVerLancamentos() {
const ev = eventoSelecionado.value;
if (!ev?.id) return;
// Ocorrência virtual ainda não foi materializada id é sintético
// `rec::<rule>::<date>`, não bate com agenda_evento_id (uuid).
// Aborta sem query e avisa o user. 2026-05-14.
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
if (ev.is_occurrence || isVirtualId) {
toast.add({
severity: 'info',
summary: 'Sem lançamentos ainda',
detail: 'Esta ocorrência ainda não foi materializada. Lançamentos aparecem após a primeira ação na sessão (status, edição etc).',
life: 5000
});
return;
}
lancamentosEventoTitulo.value = ev.pacienteNome || ev.label || ev.titulo || 'Sessão';
lancamentosDialogOpen.value = true;
lancamentosLoading.value = true;
try {
const { data, error } = await supabase
.from('financial_records')
.select('id, description, amount, final_amount, status, due_date, paid_at, payment_method, created_at')
.eq('agenda_evento_id', ev.id)
.is('deleted_at', null)
.order('created_at', { ascending: true });
if (error) throw error;
lancamentosList.value = data || [];
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao carregar lançamentos.', life: 4000 });
lancamentosList.value = [];
} finally {
lancamentosLoading.value = false;
}
}
function _fmtLancBRL(v) {
return Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function _fmtLancDate(d) {
if (!d) return '—';
try { return new Date(d).toLocaleDateString('pt-BR'); } catch { return '—'; }
}
const _lancMethodLabels = {
pix: 'PIX', dinheiro: 'Dinheiro', deposito: 'Depósito', cartao: 'Cartão',
cartao_maquininha: 'Cartão (maquininha)', convenio: 'Convênio', asaas: 'Asaas'
};
const _lancStatusLabels = {
pending: 'Pendente', paid: 'Pago', overdue: 'Vencido',
cancelled: 'Cancelado', refunded: 'Reembolsado', partial: 'Parcial'
};
function _lancStatusSeverity(s) {
return { pending: 'info', paid: 'success', overdue: 'danger', cancelled: 'secondary', refunded: 'warn', partial: 'warn' }[s] || 'secondary';
}
async function onDeleteEvento() {
const ev = eventoSelecionado.value;
if (!ev?.id || eventoBusy.value) return;
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
const isVirtual = ev.is_occurrence || isVirtualId;
// Ocorrência virtual: cria recurrence_exception (cancel_session)
// Sem interação ainda segura excluir. Mostra confirm simples.
if (isVirtual) {
const recId = ev.recurrence_id || ev.serie_id;
const origDate = ev.recurrence_date || ev.original_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
if (!recId || !origDate) {
toast.add({ severity: 'warn', summary: 'Não excluível', detail: 'Não foi possível identificar a regra de recorrência.', life: 4000 });
return;
}
confirm.require({
header: 'Cancelar ocorrência',
message: 'Esta ocorrência ainda não tem cobranças. Tem certeza que deseja cancelá-la? Ela some da agenda; as outras sessões da série continuam.',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sim, cancelar',
rejectLabel: 'Manter',
acceptClass: 'p-button-danger',
accept: async () => {
eventoBusy.value = true;
try {
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
const { error } = await supabase.from('recurrence_exceptions').insert({
recurrence_id: recId,
tenant_id: tenantId,
original_date: origDate,
type: 'cancel_session',
reason: 'Cancelado pelo terapeuta antes de qualquer interação'
});
if (error) throw error;
toast.add({ severity: 'success', summary: 'Ocorrência cancelada', life: 2500 });
M.refetch();
refetchEventosHoje();
fecharEvento();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao cancelar.', life: 4000 });
} finally {
eventoBusy.value = false;
}
}
});
return;
}
// Evento real: conta cobranças vinculadas
let recordsCount = 0;
let hasPaidRecord = false;
try {
const { data } = await supabase
.from('financial_records')
.select('id, status')
.eq('agenda_evento_id', ev.id)
.is('deleted_at', null);
recordsCount = (data || []).length;
hasPaidRecord = (data || []).some((r) => r.status === 'paid');
} catch (e) {
console.warn('[Excluir sessão] erro contando records:', e?.message);
}
// Cobrança PAGA bloqueia exclusão precisa estornar pelo Financeiro
if (hasPaidRecord) {
toast.add({
severity: 'warn',
summary: 'Sessão com pagamento confirmado',
detail: 'Esta sessão tem cobrança paga. Estorne primeiro pelo Financeiro antes de excluir.',
life: 5500
});
return;
}
// Evento de série materializado (tem recurrence_id) vira exception
// (cancel_session) também, mas removendo records pendentes junto.
const isMaterializedOccurrence = !!ev.recurrence_id || !!ev.serie_id;
const msgRecords = recordsCount > 0
? `Esta sessão tem ${recordsCount} cobrança(s) pendente(s) que também será(ão) removida(s).`
: 'A sessão não tem cobranças vinculadas.';
confirm.require({
header: isMaterializedOccurrence ? 'Cancelar ocorrência' : 'Excluir sessão',
message: `${msgRecords} A ação não pode ser desfeita. Confirmar?`,
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sim, confirmar',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-danger',
accept: async () => {
eventoBusy.value = true;
try {
// 1) Remove cobranças vinculadas (não-pagas)
if (recordsCount > 0) {
const { error: recErr } = await supabase.from('financial_records').delete().eq('agenda_evento_id', ev.id);
if (recErr) throw recErr;
}
if (isMaterializedOccurrence) {
// Cria exception cancel_session + DELETE da row (some da agenda)
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
const origDate = ev.recurrence_date || ev.original_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
if (origDate) {
const { error: exErr } = await supabase.from('recurrence_exceptions').insert({
recurrence_id: ev.recurrence_id || ev.serie_id,
tenant_id: tenantId,
original_date: origDate,
type: 'cancel_session',
reason: 'Cancelado pelo terapeuta'
});
if (exErr) console.warn('[Excluir] exception insert falhou:', exErr?.message);
}
}
const { error } = await supabase.from('agenda_eventos').delete().eq('id', ev.id);
if (error) throw error;
const detail = recordsCount > 0 ? `Sessão e ${recordsCount} cobrança(s) removida(s).` : 'Sessão removida.';
toast.add({ severity: 'success', summary: isMaterializedOccurrence ? 'Ocorrência cancelada' : 'Excluída', detail, life: 3000 });
M.refetch();
refetchEventosHoje();
fecharEvento();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir.', life: 4000 });
} 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) {
@@ -1709,6 +2059,9 @@ function onKeydown(e) {
@cancelar="onCancelar" @cancelar="onCancelar"
@remarcar="onRemarcar" @remarcar="onRemarcar"
@edit-sessao="onEditEvento" @edit-sessao="onEditEvento"
@delete-sessao="onDeleteEvento"
@ver-lancamentos="onVerLancamentos"
@antecipar-pagamento="onAnteciparPagamento"
@edit-paciente="onEditPaciente" @edit-paciente="onEditPaciente"
@abrir-prontuario="onAbrirProntuario" @abrir-prontuario="onAbrirProntuario"
@whatsapp="onWhatsapp" @whatsapp="onWhatsapp"
@@ -2139,6 +2492,8 @@ function onKeydown(e) {
:allEvents="agendaAllEvents" :allEvents="agendaAllEvents"
:pausasSemanais="agendaSettings?.pausas_semanais || []" :pausasSemanais="agendaSettings?.pausas_semanais || []"
:feriados="agendaFeriados" :feriados="agendaFeriados"
:serieRefreshTick="agendaSerieRefreshTick"
:blockOverlapWarning="agendaDialogBlockOverlap"
newPatientRoute="/therapist/patients/cadastro" newPatientRoute="/therapist/patients/cadastro"
@save="M.onDialogSave" @save="M.onDialogSave"
@delete="M.onDialogDelete" @delete="M.onDialogDelete"
@@ -2146,6 +2501,34 @@ function onKeydown(e) {
@editSeriesOccurrence="M.onEditSeriesOccurrence" @editSeriesOccurrence="M.onEditSeriesOccurrence"
/> />
<!-- 2º AgendaEventDialog empilhado por cima do principal pra editar
uma OCORRÊNCIA específica de série. Acionado pelo botão "Editar"
nas pills da lista "Recorrências Aplicadas". Reusa os mesmos
handlers de save/delete/update o composable distingue pelo
id/recurrence_date. PrimeVue empilha automaticamente, então
nenhum gerenciamento manual de z-index é necessário.
Adicionado 2026-05-11; pendente replicar em Rail/Clínica. -->
<AgendaEventDialog
v-model="agendaOccDialogOpen"
:eventRow="agendaOccDialogEventRow"
:initialStartISO="agendaOccDialogStartISO"
:initialEndISO="agendaOccDialogEndISO"
:ownerId="agendaOwnerId"
:tenantId="agendaClinicTenantId"
:commitmentOptions="agendaCommitmentOptions"
:workRules="agendaWorkRules"
:blockedDates="[]"
:agendaSettings="agendaSettings"
:allEvents="agendaAllEvents"
:pausasSemanais="agendaSettings?.pausas_semanais || []"
:feriados="agendaFeriados"
newPatientRoute="/therapist/patients/cadastro"
:occurrenceMode="true"
@save="M.onDialogSave"
@delete="M.onDialogDelete"
@updateSeriesEvent="M.onUpdateSeriesEvent"
/>
<!-- BloqueioDialog bloqueio de horário/período/dia/feriados. <!-- BloqueioDialog bloqueio de horário/período/dia/feriados.
Trigger é o menu na toolbar da MelissaAgenda. Após salvar, Trigger é o menu na toolbar da MelissaAgenda. Após salvar,
refetcha pra refletir o bloqueio na agenda. --> refetcha pra refletir o bloqueio na agenda. -->
@@ -2159,6 +2542,118 @@ function onKeydown(e) {
@saved="M.refetch" @saved="M.refetch"
/> />
<!-- Dialog "Lançamentos da sessão" (2026-05-14): lista todos os
financial_records vinculados ao evento atual. Abre via botão
"Lançamentos" na seção Financeiro do MelissaEventoPanel. -->
<Dialog
v-model:visible="lancamentosDialogOpen"
modal
:draggable="false"
:style="{ width: '640px', maxWidth: '96vw' }"
>
<template #header>
<div class="flex flex-col gap-0.5">
<span class="text-base font-bold">Lançamentos da sessão</span>
<span class="text-xs opacity-70">{{ lancamentosEventoTitulo }}</span>
</div>
</template>
<div v-if="lancamentosLoading" class="py-6 text-center text-sm opacity-70">
<i class="pi pi-spin pi-spinner mr-1" /> Carregando
</div>
<div v-else-if="!lancamentosList.length" class="py-6 text-center text-sm opacity-70">
<i class="pi pi-info-circle mr-1" /> Nenhum lançamento vinculado a esta sessão.
</div>
<div v-else class="flex flex-col gap-2.5">
<div
v-for="(r, idx) in lancamentosList"
:key="r.id"
class="ml-lanc-card"
:class="{ 'ml-lanc-card--child': idx > 0 }"
>
<div class="ml-lanc-card__head">
<i v-if="idx > 0" class="pi pi-arrow-right-and-arrow-left-up-down ml-lanc-card__indent" />
<span class="ml-lanc-card__desc">{{ r.description || (idx === 0 ? 'Sessão' : 'Cobrança extra') }}</span>
<Tag :value="_lancStatusLabels[r.status] || r.status" :severity="_lancStatusSeverity(r.status)" class="text-xs ml-auto" />
</div>
<div class="ml-lanc-card__body">
<div class="ml-lanc-card__row">
<i class="pi pi-money-bill" />
<span class="ml-lanc-card__amount">{{ _fmtLancBRL(r.final_amount || r.amount) }}</span>
</div>
<div v-if="r.payment_method" class="ml-lanc-card__row">
<i class="pi pi-credit-card" />
<span>{{ _lancMethodLabels[r.payment_method] || r.payment_method }}</span>
</div>
<div class="ml-lanc-card__row">
<i class="pi pi-calendar" />
<span>Vencimento: {{ _fmtLancDate(r.due_date) }}</span>
</div>
<div v-if="r.paid_at" class="ml-lanc-card__row">
<i class="pi pi-check-circle" />
<span>Pago em {{ _fmtLancDate(r.paid_at) }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined @click="lancamentosDialogOpen = false" />
</template>
</Dialog>
<!-- Dialog "Antecipar pagamento" (Fase 5, 2026-05-14): paciente
quer pagar antes da sessão. Materializa ocorrência se virtual
e cria/atualiza financial_record. Não decrementa saldo. -->
<Dialog
v-model:visible="anteciparDialogOpen"
modal
:draggable="false"
header="Antecipar pagamento"
:style="{ width: '480px', maxWidth: '96vw' }"
>
<div class="flex flex-col gap-3 pt-1">
<div class="text-sm">
Receba antecipadamente o valor desta sessão.
</div>
<div v-if="anteciparEventoRef" class="flex flex-col gap-1 px-3 py-2 rounded-md bg-[var(--surface-section)] border border-[var(--surface-border)]">
<div class="text-sm font-semibold">{{ anteciparEventoRef.pacienteNome || 'Sessão' }}</div>
<div class="text-xs opacity-70">Valor: R$ {{ Number(anteciparEventoRef.price || 0).toFixed(2).replace('.', ',') }}</div>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium">Como o paciente pagou?</label>
<Select
v-model="anteciparMethod"
:options="anteciparMethodOptions"
optionLabel="label"
optionValue="value"
size="small"
/>
</div>
<small class="text-xs opacity-60">
O saldo do pacote será decrementado quando você marcar a sessão como Realizada.
</small>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="anteciparBusy" @click="anteciparDialogOpen = false" />
<Button label="Confirmar" icon="pi pi-check" :loading="anteciparBusy" @click="confirmAnteciparPagamento" />
</template>
</Dialog>
<!-- AgendaStatusChangeConfirmDialog Fase 5 (2026-05-14): aparece
quando user muda status pra realizado/faltou/cancelado e
decisão a tomar (regra de exceção, pacote saldo, pending). -->
<AgendaStatusChangeConfirmDialog
v-model="agendaStatusDialogOpen"
:evento="agendaStatusDialogProps.evento"
:novoStatus="agendaStatusDialogProps.novoStatus"
:regraExcecao="agendaStatusDialogProps.regraExcecao"
:billingContract="agendaStatusDialogProps.billingContract"
:billingContractStyle="agendaStatusDialogProps.billingContractStyle"
:pendingRecord="agendaStatusDialogProps.pendingRecord"
:sessionPrice="agendaStatusDialogProps.sessionPrice"
@confirm="agendaOnStatusDialogConfirm"
@update:modelValue="(v) => !v && agendaOnStatusDialogCancel()"
/>
<!-- Toast: AppLayout não monta no Melissa (rota fullscreen), <!-- Toast: AppLayout não monta no Melissa (rota fullscreen),
então as pages embedadas (config, agendador online, etc.) então as pages embedadas (config, agendador online, etc.)
precisam de um Toast próprio aqui pra não silenciar o precisam de um Toast próprio aqui pra não silenciar o
@@ -2676,6 +3171,54 @@ function onKeydown(e) {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
/* ── Dialog "Lançamentos da sessão" (2026-05-14) ── */
.ml-lanc-card {
border: 1px solid var(--surface-border);
border-radius: 8px;
padding: 0.7rem 0.85rem;
background: var(--surface-card);
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.ml-lanc-card--child {
background: color-mix(in srgb, var(--p-primary-color) 4%, var(--surface-card));
margin-left: 1.5rem;
border-color: color-mix(in srgb, var(--p-primary-color) 25%, var(--surface-border));
}
.ml-lanc-card__head {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ml-lanc-card__indent {
color: var(--text-color-secondary);
font-size: 0.7rem;
transform: scaleY(-1);
}
.ml-lanc-card__desc {
font-size: 0.88rem;
font-weight: 600;
color: var(--text-color);
}
.ml-lanc-card__body {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.ml-lanc-card__row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: var(--text-color-secondary);
}
.ml-lanc-card__row i { font-size: 0.72rem; }
.ml-lanc-card__amount {
font-weight: 600;
color: var(--text-color);
}
</style> </style>
<!-- <!--
+267
View File
@@ -21,6 +21,7 @@ import { useConfirm } from 'primevue/useconfirm';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { useLayout } from '@/layout/composables/layout';
import MelissaConfigList from './MelissaConfigList.vue'; import MelissaConfigList from './MelissaConfigList.vue';
// InputText/Select/Textarea/InputMask/Skeleton/Tag/Button: auto via PrimeVueResolver // InputText/Select/Textarea/InputMask/Skeleton/Tag/Button: auto via PrimeVueResolver
@@ -30,6 +31,55 @@ const toast = useToast();
const confirm = useConfirm(); const confirm = useConfirm();
const router = useRouter(); const router = useRouter();
const tenantStore = useTenantStore(); const tenantStore = useTenantStore();
const { layoutConfig, setVariant } = useLayout();
// Troca de layout variant (classic/rail/melissa). Confirma + persiste +
// hard reload sair do shell Melissa requer reload pq AppLayout não tem
// branch pra essa rota; quem renderiza Melissa é a rota /melissa separada.
const variantSwitchOpen = ref(false);
async function switchToVariant(v) {
if (!['classic', 'rail', 'melissa'].includes(v)) return;
if (layoutConfig.variant === v) return;
if (variantSwitchOpen.value) return;
variantSwitchOpen.value = true;
const labels = { classic: 'Clássico', rail: 'Rail', melissa: 'Melissa' };
confirm.require({
header: `Trocar para o layout ${labels[v]}`,
message: 'A página será recarregada para aplicar o novo layout. Confirma?',
icon: 'pi pi-th-large',
acceptLabel: 'Trocar e recarregar',
rejectLabel: 'Cancelar',
accept: async () => {
try {
setVariant(v);
if (userId.value) {
const { error } = await supabase
.from('user_settings')
.upsert(
{
user_id: userId.value,
layout_variant: v,
updated_at: new Date().toISOString()
},
{ onConflict: 'user_id' }
);
if (error) {
const msg = String(error.message || '');
const tolerant = /does not exist/i.test(msg) || /permission denied/i.test(msg) || /violates row-level security/i.test(msg);
if (!tolerant) throw error;
}
}
toast.add({ severity: 'info', summary: `Aplicando ${labels[v]}`, detail: 'Recarregando…', life: 1500 });
window.location.assign('/');
} catch (e) {
variantSwitchOpen.value = false;
toast.add({ severity: 'error', summary: 'Erro ao trocar layout', detail: e?.message || 'Tente novamente.', life: 4000 });
}
},
reject: () => { variantSwitchOpen.value = false; },
onHide: () => { variantSwitchOpen.value = false; }
});
}
const AVATAR_BUCKET = 'avatars'; const AVATAR_BUCKET = 'avatars';
@@ -933,6 +983,92 @@ onBeforeUnmount(() => {
</div> </div>
</div><!-- /.mpr-w__body --> </div><!-- /.mpr-w__body -->
</div> </div>
<!-- Layout (variante de navegação) -->
<div id="mpr-sec-layout" class="mpr-w">
<div class="mpr-w__head">
<div class="mpr-w__icon"><i class="pi pi-th-large" /></div>
<div class="mpr-w__title">
<div class="mpr-w__title-text">Layout</div>
<div class="mpr-w__sub">Estilo de navegação principal troca exige reload</div>
</div>
</div>
<div class="mpr-w__body">
<div class="mpr-lv-grid">
<button
class="mpr-lv-card"
:class="{ 'mpr-lv-card--current': layoutConfig.variant === 'classic' }"
:disabled="layoutConfig.variant === 'classic'"
@click="switchToVariant('classic')"
>
<div class="mpr-lv-preview mpr-lv-preview--classic">
<div class="mpr-lv-sidebar" />
<div class="mpr-lv-main">
<div class="mpr-lv-bar" />
<div class="mpr-lv-line" />
<div class="mpr-lv-line mpr-lv-line--sm" />
</div>
</div>
<div class="mpr-lv-foot">
<div class="mpr-lv-radio">
<div v-if="layoutConfig.variant === 'classic'" class="mpr-lv-dot" />
</div>
<div>
<div class="mpr-lv-name">Clássico</div>
<div class="mpr-lv-sub">Sidebar lateral com menu completo</div>
</div>
</div>
</button>
<button
class="mpr-lv-card"
:class="{ 'mpr-lv-card--current': layoutConfig.variant === 'rail' }"
:disabled="layoutConfig.variant === 'rail'"
@click="switchToVariant('rail')"
>
<div class="mpr-lv-preview mpr-lv-preview--rail">
<div class="mpr-lv-rail" />
<div class="mpr-lv-panel" />
<div class="mpr-lv-main">
<div class="mpr-lv-bar" />
<div class="mpr-lv-line" />
<div class="mpr-lv-line mpr-lv-line--sm" />
</div>
</div>
<div class="mpr-lv-foot">
<div class="mpr-lv-radio">
<div v-if="layoutConfig.variant === 'rail'" class="mpr-lv-dot" />
</div>
<div>
<div class="mpr-lv-name">Rail</div>
<div class="mpr-lv-sub">Mini rail + painel expansível, full-width</div>
</div>
</div>
</button>
<button
class="mpr-lv-card"
:class="{ 'mpr-lv-card--current': layoutConfig.variant === 'melissa' }"
:disabled="layoutConfig.variant === 'melissa'"
@click="switchToVariant('melissa')"
>
<div class="mpr-lv-preview mpr-lv-preview--melissa">
<div class="mpr-lv-melissa-bg" />
<div class="mpr-lv-melissa-dock" />
</div>
<div class="mpr-lv-foot">
<div class="mpr-lv-radio">
<div v-if="layoutConfig.variant === 'melissa'" class="mpr-lv-dot" />
</div>
<div>
<div class="mpr-lv-name">Melissa</div>
<div class="mpr-lv-sub">Lockscreen-style com dock central (atual)</div>
</div>
</div>
</button>
</div>
</div><!-- /.mpr-w__body -->
</div>
</template> </template>
</div> </div>
</div> </div>
@@ -1676,4 +1812,135 @@ onBeforeUnmount(() => {
.mpr-custom { flex-direction: column; gap: 8px; } .mpr-custom { flex-direction: column; gap: 8px; }
.mpr-custom .mpr-btn--icon { align-self: flex-end; } .mpr-custom .mpr-btn--icon { align-self: flex-end; }
} }
/* ═══════ Layout variant cards ═══════ */
.mpr-lv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 14px;
}
.mpr-lv-card {
background: var(--m-surface-2, var(--surface-card));
border: 1px solid var(--m-border, var(--surface-border));
border-radius: 10px;
padding: 12px;
text-align: left;
cursor: pointer;
transition: border-color .15s, background .15s, transform .12s;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--text-color);
}
.mpr-lv-card:hover:not(:disabled) {
border-color: var(--p-primary-500, #7c3aed);
background: var(--m-surface-hover, var(--surface-hover));
}
.mpr-lv-card:disabled {
cursor: default;
opacity: 0.9;
}
.mpr-lv-card--current {
border-color: var(--p-primary-500, #7c3aed);
box-shadow: 0 0 0 1px var(--p-primary-500, #7c3aed) inset;
}
.mpr-lv-preview {
height: 92px;
border-radius: 6px;
background: var(--m-surface-3, var(--surface-100));
border: 1px solid var(--m-border, var(--surface-border));
position: relative;
overflow: hidden;
display: flex;
}
.mpr-lv-preview--classic .mpr-lv-sidebar {
width: 30%;
background: var(--p-primary-500, #7c3aed);
opacity: 0.85;
}
.mpr-lv-preview--rail .mpr-lv-rail {
width: 12%;
background: var(--p-primary-500, #7c3aed);
opacity: 0.85;
}
.mpr-lv-preview--rail .mpr-lv-panel {
width: 22%;
background: var(--p-primary-500, #7c3aed);
opacity: 0.35;
}
.mpr-lv-main {
flex: 1;
padding: 8px;
display: flex;
flex-direction: column;
gap: 5px;
}
.mpr-lv-bar {
height: 8px;
background: var(--m-border-strong, var(--surface-300));
border-radius: 2px;
opacity: 0.6;
}
.mpr-lv-line {
height: 5px;
background: var(--m-border-strong, var(--surface-300));
border-radius: 2px;
opacity: 0.4;
}
.mpr-lv-line--sm { width: 65%; }
.mpr-lv-preview--melissa {
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%);
}
.mpr-lv-melissa-bg {
position: absolute;
inset: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
}
.mpr-lv-melissa-dock {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
width: 60%;
height: 14px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.25);
}
.mpr-lv-foot {
display: flex;
align-items: flex-start;
gap: 9px;
}
.mpr-lv-radio {
width: 14px;
height: 14px;
border-radius: 9999px;
border: 1.5px solid var(--m-border-strong, var(--surface-400));
display: grid;
place-items: center;
flex-shrink: 0;
margin-top: 3px;
}
.mpr-lv-card--current .mpr-lv-radio {
border-color: var(--p-primary-500, #7c3aed);
}
.mpr-lv-dot {
width: 7px;
height: 7px;
border-radius: 9999px;
background: var(--p-primary-500, #7c3aed);
}
.mpr-lv-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-color);
line-height: 1.2;
}
.mpr-lv-sub {
font-size: 0.75rem;
color: var(--text-color-secondary);
line-height: 1.3;
margin-top: 2px;
}
</style> </style>
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -437,9 +437,9 @@ export function applyGuards(router) {
if (shouldLoadEntitlements(_ent, _tid)) { if (shouldLoadEntitlements(_ent, _tid)) {
await loadEntitlementsSafe(_ent, _tid, true); await loadEntitlementsSafe(_ent, _tid, true);
} }
// Entitlements pessoais (therapist/supervisor têm assinatura própria) // Entitlements pessoais — subscription do user independe do role
const _roleNorm = normalizeRole(_role); // no tenant ativo (tenant_admin do próprio tenant pode ter therapist_pro)
if (['therapist', 'supervisor'].includes(_roleNorm) && _ent.loadedForUser !== uid) { if (_ent.loadedForUser !== uid) {
try { try {
await _ent.loadForUser(uid); await _ent.loadForUser(uid);
} catch { } } catch { }
@@ -738,11 +738,11 @@ export function applyGuards(router) {
await loadEntitlementsSafe(ent, tenantId, true); await loadEntitlementsSafe(ent, tenantId, true);
} }
// ✅ user entitlements: terapeuta pode ter assinatura pessoal (therapist_pro) // ✅ user entitlements: subscription pessoal (therapist_pro/free,
// que gera features em v_user_entitlements, não em v_tenant_entitlements. // supervisor_*, etc) independe do role no tenant ativo — um terapeuta
// user entitlements: therapist e supervisor têm assinatura pessoal (v_user_entitlements) // pode ser tenant_admin do próprio tenant E ter assinatura pessoal.
const activeRoleNormForEnt = normalizeRole(tenant.activeRole); // Sempre carrega pra qualquer user autenticado.
if (['therapist', 'supervisor'].includes(activeRoleNormForEnt) && uid && ent.loadedForUser !== uid) { if (uid && ent.loadedForUser !== uid) {
logGuard('ent.loadForUser'); logGuard('ent.loadForUser');
try { try {
await ent.loadForUser(uid); await ent.loadForUser(uid);
+2 -1
View File
@@ -34,7 +34,8 @@ export default {
{ {
path: 'melissa/:secao?', path: 'melissa/:secao?',
name: 'Melissa', name: 'Melissa',
component: () => import('@/layout/melissa/MelissaLayout.vue') component: () => import('@/layout/melissa/MelissaLayout.vue'),
meta: { requiresAuth: true }
}, },
// Preview do AgendaEventDialog V2 (A66 sub-sessão 2). Iteração // Preview do AgendaEventDialog V2 (A66 sub-sessão 2). Iteração