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:
+197
-113
@@ -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
|
||||
> Melissa nativa do `PatientProntuario.vue` legacy (3593L). Todas as 7 abas
|
||||
> entregues, wire-up final feito (Dialog → route `/melissa/paciente?id=X`).
|
||||
> 5 composables + utils compartilhados extraídos.
|
||||
> **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** estamos na rodada de
|
||||
> testes manuais dos 13 cenários do doc viva
|
||||
> `src/docs/agenda-compromisso-financeiro-cenarios.html`. C1-C4 ✅, **C5
|
||||
> 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:
|
||||
> full-width, sidebar "Voltar pra Pacientes" (no lugar de Configurações),
|
||||
> editar inline, openWhatsapp fix, dialog Lançamento, dialog Nova Sessão
|
||||
> com `AgendaEventDialog` real, recorrências do paciente.
|
||||
|
||||
> **🟢 COMMITADO + PUSHED** — Working tree limpa.
|
||||
> **🟡 WORKING TREE BEM PESADO** — refactor de payment, indicadores
|
||||
> visuais (barra verde + popover + Resumo do dialog), inline quick-create
|
||||
> de procedimento, fix de rota convênios, botão "+ Novo convênio",
|
||||
> hint contextual. Migrations da Fase 5 já rodadas em 14/05. **Considerar
|
||||
> commitar antes de mais trabalho** — diff tá grande.
|
||||
|
||||
---
|
||||
|
||||
## 🚦 STATUS — Working tree LIMPA
|
||||
## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 5 (Convênio)
|
||||
|
||||
```
|
||||
On branch main
|
||||
Your branch is up to date with 'origin/main'.
|
||||
nothing to commit, working tree clean
|
||||
```
|
||||
Receita do doc HTML. Resumo:
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| 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 |
|
||||
|---|------|--------|
|
||||
| 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 |
|
||||
| 22 | `73788c7` | AgendaEventDialog: lockType auto-seleciona commitment "Sessão" (fix jornada/billing/freq sumidos) |
|
||||
| 21 | `30d09eb` | AgendaEventDialog: props lockType + lockPatient + slot #headerLeft (aditivos) |
|
||||
| 20 | `88dff50` | (REVERTIDO em 30d09eb) usa AgendaEventDialog GLOBAL via inject |
|
||||
| 19 | `b040e15` | header custom do dialog Nova Sessão (ícone + título + nome) |
|
||||
| 18 | `42a39ed` | dialog Nova Sessão usa "Frequência" estilo AgendaEventDialog |
|
||||
| 17 | `9e76e4e` | bloco "Recorrências do paciente" na Tab Agenda |
|
||||
| 16 | `f1d6fba` | dialog nova sessão integra useRecurrence (recorrência semanal) |
|
||||
| 15 | `a8ab13b` | dialog inline nova sessão + createSession mutation |
|
||||
| 14 | `21c71f7` | addFinancial navega pra Financeiro + novo botão Agendar |
|
||||
| 13 | `64005a5` | fix openWhatsapp + dialog inline novo lançamento financeiro |
|
||||
| 12 | `301a712` | editPatient abre PatientCadastroDialog INLINE (sem sair) |
|
||||
| 11 | `5d2c389` | fix sidebar cards encolhendo + gap das abas main |
|
||||
| 10 | `159b80d` | full-width + sidebar "Voltar pra Pacientes" no lugar de Configurações |
|
||||
| 9 | `71ee51d` | **Fase 8** wire-up final (Dialog → route /melissa/paciente?id=X) |
|
||||
| 8 | `167e864` | **Fase 7** Tabs Documentos + Conversas (KPIs + embed componentes existentes) |
|
||||
| 7 | `e7c0f6c` | **Fase 6** Tab Financeiro + mark paid (mutation que legacy não tem) |
|
||||
| 6 | `8a8d2e0` | **Fase 5** Tab Agenda (KPIs + filtros + grupos por mês + ações) |
|
||||
| 5 | `1278e93` | **Fase 4** Tab Prontuário MVP (evolução via session.observacoes) |
|
||||
| 4 | `4fc0e3a` | **Fase 3** Tab Perfil (6 sections stacked + anchors) |
|
||||
| 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) |
|
||||
| 1 | `f3f0d83` | (pré-MelissaPaciente) preview teleport 3-way no Agendador/LinkExterno + chrome 6 páginas |
|
||||
### 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()`.
|
||||
|
||||
### Novo indicador: barra esquerda verde para sessão paga
|
||||
- Brainstorm de 6 opções; user escolheu #6 (3 canais visuais distintos por estado).
|
||||
- `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).
|
||||
- `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`).
|
||||
- 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.
|
||||
- Decisão salva em `memory/project_agenda_payment_indicators.md`.
|
||||
|
||||
### Linha "Cobrança" no popover + Resumo do dialog
|
||||
- **Popover `MelissaEventoPanel`** — antes só mostrava amber "A receber R$ X" pra pendente. Agora cobre os 3 estados, com cor + ícone por variante:
|
||||
- `paid` → `pi-check-circle` verde, label **"Pago · R$ X,XX"**
|
||||
- `pending` → `pi-dollar` amber, label **"A receber R$ X (cobrança pendente)"** (mantido)
|
||||
- `none` → `pi-dollar` amber, label **"A cobrar R$ X"** ou **"Cobrança ainda não gerada"** (mantido)
|
||||
- CSS reescrito em 3 modificadores `.evento-row--pay-{paid|pending|none}` (com dark mode).
|
||||
- **Resumo lateral do `AgendaEventDialog`** — nova linha entre `pi-clock` e `pi-map-marker` em ambas as cópias (mobile inline + desktop floating).
|
||||
- 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.
|
||||
- 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).
|
||||
- `@cobranca-atualizada` do `AgendaEventoFinanceiroPanel` agora também dispara `loadSessionPaymentRecord` pra a linha refrescar.
|
||||
- **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.
|
||||
|
||||
### Preparação do C5 (Sándor + Unimed Nacional) — UX de convênio refinado (3 issues)
|
||||
|
||||
User tentou rodar C5 e bateu em 3 problemas seguidos. Cada um virou um fix:
|
||||
|
||||
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
|
||||
- `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
|
||||
### Cenário 1 (Bloqueio) ✅
|
||||
|
||||
### NOVO — página Melissa
|
||||
- `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
|
||||
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`.
|
||||
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
|
||||
- `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`.
|
||||
- `src/layout/melissa/MelissaAgenda.vue` — `abrirProntuarioPorId` igual. Removeu Dialog legacy.
|
||||
- `src/layout/melissa/composables/useMelissaAgenda.js` — adicionou `onCreateEventoForPatient(patientId)` (não usado mais após reverter inject, mas mantido)
|
||||
- `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 2 (Avulsa sem cobrança) ✅
|
||||
|
||||
4. **Fonte da hint chargeMode** subiu de `0.72rem` → `0.8125rem` (acima de `text-xs`).
|
||||
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).
|
||||
6. Doc HTML Cenário 2 atualizado.
|
||||
|
||||
### 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
|
||||
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.
|
||||
|
||||
### Pendentes conhecidos (não ditos pelo user, mas observados)
|
||||
- **PatientProntuario.vue legacy (3593L)** continua existindo intocado. Usado por:
|
||||
- `TherapistDashboard.vue` (homepage role therapist sem Melissa)
|
||||
- `PatientsListPage.vue` (rota `/therapist/patients`)
|
||||
- Quando user troca pra Melissa em `/account/profile`, vê a versão nativa
|
||||
- Pra deletar de vez precisa portar TherapistDashboard + PatientsListPage também
|
||||
- **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
|
||||
- **2 errors pré-existentes em MelissaLayout.vue** (duplicate key 'financeiro' L242, empty block L1130) — não foram tocados durante o port
|
||||
| Fase | Status |
|
||||
|---|---|
|
||||
| **1** Compromisso SEM paciente | ✅ |
|
||||
| **2** Compromisso COM paciente | ✅ testado (C1-C3 done) |
|
||||
| **3** Recorrência + replicar occurrenceMode Rail/Clínica | ⏳ |
|
||||
| **4** Modo disparo cobrança híbrido | ⚠️ parcial |
|
||||
| **5** Status change → confirm dialog | 🔄 Melissa codado + indicadores visuais done; falta testar (C10-C12) + replicar Rail/Clínica |
|
||||
| **6** Edit cobrada | ✅ |
|
||||
| **7** Pagamento separado | ⏳ |
|
||||
| **8** Refund/credit note | ⏳ |
|
||||
| **9** Plano Inicial | 📋 |
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Conhecimento adicional acumulado nesta sessão
|
||||
## 📋 Roteiro de testes restantes (`src/docs/agenda-compromisso-financeiro-cenarios.html`)
|
||||
|
||||
### Decisões arquiteturais
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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. **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).
|
||||
|
||||
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.
|
||||
|
||||
### Hotspots de drift no `AgendaEventDialog`
|
||||
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**.
|
||||
|
||||
### Slug `/melissa/paciente?id=<uuid>`
|
||||
Registrado em `MelissaLayout.vue` SECOES + adicionado a `MELISSA_NON_CONFIG_SLUGS`. ID vem via query param. Funciona pra deep-link.
|
||||
| # | Cenário | Status |
|
||||
|---|---|---|
|
||||
| 1 | Bloqueio (criar + agendar sobre) | ✅ |
|
||||
| 2 | Avulsa sem cobrança | ✅ |
|
||||
| 3 | Avulsa cobrar ao salvar | ✅ |
|
||||
| 4 | Avulsa "já recebi" no salvar | ✅ |
|
||||
| **5** | **Avulsa convênio (Sándor + Unimed)** | 🔴 **PRÓXIMO** |
|
||||
| 6 | Recorrente sem pacote (Anna Freud 4 sem) | ⏳ |
|
||||
| 7 | Pacote upfront (Donald Winnicott 4× R$ 200) | ⏳ |
|
||||
| 8 | Pacote saldo (Carl Jung 4× R$ 40) | ⏳ |
|
||||
| 9 | 1 por sessão (Michael Balint 12 sem) | ⏳ |
|
||||
| 10 | Status change avulsa (realizado/faltou/cancelado) | ⏳ |
|
||||
| 11 | Status change pacote saldo | ⏳ |
|
||||
| 12 | Antecipar pagamento (Carl Jung) | ⏳ |
|
||||
| 13 | Edit cobrada | ⏳ |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Comandos úteis
|
||||
## 📋 Como retomar amanhã (cego)
|
||||
|
||||
```bash
|
||||
# Specs do agenda (regression check pro AgendaEventDialog)
|
||||
npx vitest run src/features/agenda/composables/__tests__
|
||||
|
||||
# Lint só dos arquivos do MelissaPaciente
|
||||
npx eslint src/layout/melissa/MelissaPaciente.vue src/features/patients/composables/usePatient*.js src/features/patients/utils/patientFormatters.js
|
||||
|
||||
# Testar visualmente
|
||||
npm run dev
|
||||
# → http://localhost:5173/melissa/paciente?id=<uuid-real-de-paciente>
|
||||
```
|
||||
1. `git status` — confirmar working tree intacto
|
||||
2. **Ler HANDOFF até o fim**
|
||||
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)")
|
||||
5. Cada cenário que passar:
|
||||
- Atualizar status pra ✅ aqui no HANDOFF
|
||||
- Se descobrir bug ou texto divergente, corrigir código + doc na hora
|
||||
6. Quando todos os 13 passarem: replicar em **Rail** e **Clínica**
|
||||
7. Adicionar `professional_cancellation` no `STATUS_TO_EXCEPTION`
|
||||
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)
|
||||
2. **Aguardar feedback do user** sobre ajustes específicos no dialog Nova Sessão (mencionou que tem mais alguma coisa)
|
||||
3. **Possíveis frentes**:
|
||||
- Polish do dialog Nova Sessão pós-feedback
|
||||
- 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
|
||||
**Pós-Fase 9** (quando concluirmos TODAS as fases 1-9):
|
||||
- User vai passar prompt específico pra criar **documentação completa da parte de ajuda** do sistema
|
||||
- Está em `memory/project_pendencia_doc_ajuda.md`
|
||||
- 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)
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
Touched: none (sem mudança de wiki — handoff em HANDOFF.md)
|
||||
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).
|
||||
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`
|
||||
@@ -24,6 +24,9 @@ _(summaries of specific sources you've ingested)_
|
||||
|
||||
_(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.*
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
- `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/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:2160` — 2º AgendaEventDialog empilhado
|
||||
- `src/layout/melissa/MelissaAgenda.vue:244` — `VIEW_MAP.lista = 'listAll'`
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
"contact_email_types", "contact_emails"
|
||||
],
|
||||
"Agenda / Agendamento": [
|
||||
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes", "agenda_excecoes",
|
||||
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes",
|
||||
"agenda_online_slots", "agenda_regras_semanais",
|
||||
"agenda_slots_bloqueados_semanais", "agenda_slots_regras",
|
||||
"agendador_configuracoes", "agendador_solicitacoes"
|
||||
|
||||
@@ -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 |
|
||||
| `tenant_modules` | Módulos ativos por tenant |
|
||||
|
||||
### Agenda (11 tabelas)
|
||||
### Agenda (10 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `agenda_bloqueios` | Bloqueios de horário |
|
||||
| `agenda_configuracoes` | Configurações da agenda por tenant_member |
|
||||
| `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_regras_semanais` | Regras semanais de disponibilidade |
|
||||
| `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;
|
||||
@@ -39,9 +39,9 @@ async function getUid() {
|
||||
|
||||
const BASE_SELECT = `
|
||||
id, tenant_id, owner_id, patient_id, agenda_evento_id,
|
||||
amount, discount_amount, final_amount,
|
||||
status, due_date, paid_at, payment_method,
|
||||
notes, created_at, updated_at,
|
||||
type, amount, discount_amount, final_amount,
|
||||
status, due_date, paid_at, payment_method, payment_link,
|
||||
description, notes, created_at, updated_at,
|
||||
patients!patient_id (
|
||||
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 já 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 já foi pago. Nenhuma multa configurada — só 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"
|
||||
header="Novo convênio"
|
||||
class="w-[94vw] max-w-md"
|
||||
pt:mask:class="backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex flex-col gap-3 pt-1">
|
||||
<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"
|
||||
header="Novo serviço"
|
||||
class="w-[94vw] max-w-md"
|
||||
pt:mask:class="backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex flex-col gap-3 pt-1">
|
||||
<FloatLabel variant="on">
|
||||
|
||||
@@ -159,9 +159,10 @@ describe('isFirstOccurrence', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
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', () => {
|
||||
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,
|
||||
selectedPlanService,
|
||||
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,
|
||||
emit
|
||||
}) {
|
||||
@@ -62,88 +81,88 @@ export function useAgendaEventActions({
|
||||
const samePatientConflict = ref(null);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// 1. Watcher do form.status — confirma cancelar/remarcar via dialog
|
||||
// e persiste no banco IMEDIATAMENTE. Reverte se cancelar.
|
||||
// Antes vivia no .vue; testado em isolamento agora.
|
||||
// 1. Watcher do form.status
|
||||
// Fase 5 (2026-05-14): pra realizado/faltou/cancelado, emit
|
||||
// `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(
|
||||
() => composer.form.value?.status,
|
||||
async (newVal, oldVal) => {
|
||||
if (_skipStatusWatch.value) 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;
|
||||
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({
|
||||
header: isCancelar ? 'Cancelar sessão' : 'Remarcar sessão',
|
||||
message: isCancelar
|
||||
? 'Tem certeza que deseja cancelar esta sessão? O status será salvo imediatamente.'
|
||||
: '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',
|
||||
header: 'Remarcar sessão',
|
||||
message: 'Tem certeza que deseja marcar esta sessão para remarcação? O status será salvo imediatamente.',
|
||||
icon: 'pi pi-refresh',
|
||||
acceptLabel: 'Sim, confirmar',
|
||||
rejectLabel: 'Não',
|
||||
acceptSeverity: isCancelar ? 'danger' : 'warn',
|
||||
acceptSeverity: 'warn',
|
||||
accept: async () => {
|
||||
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 isVirtual =
|
||||
!!composer.form.value.is_occurrence ||
|
||||
(typeof formId === 'string' && formId.startsWith('rec::'));
|
||||
const isVirtual = !!composer.form.value.is_occurrence || (typeof formId === 'string' && formId.startsWith('rec::'));
|
||||
|
||||
if (isVirtual) {
|
||||
emit('updateSeriesEvent', {
|
||||
id: null, // sem row real
|
||||
id: null,
|
||||
status: newVal,
|
||||
recurrence_date:
|
||||
composer.form.value.recurrence_date ||
|
||||
composer.form.value.original_date ||
|
||||
String(composer.form.value.inicio_em || '').slice(0, 10),
|
||||
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: true,
|
||||
// Form completo do dialog — handler usa pra resolver
|
||||
// recurrence_id/patient_id sem depender de dialogEventRow.
|
||||
row: { ...composer.form.value }
|
||||
});
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Status atualizado',
|
||||
detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`,
|
||||
life: 3000
|
||||
});
|
||||
toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 });
|
||||
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;
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Status atualizado',
|
||||
detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`,
|
||||
life: 3000
|
||||
});
|
||||
toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 });
|
||||
emit('updated', data);
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro',
|
||||
detail: e?.message || 'Não foi possível atualizar o status.',
|
||||
life: 4000
|
||||
});
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível atualizar o status.', life: 4000 });
|
||||
composer.form.value.status = _prevStatus.value;
|
||||
}
|
||||
},
|
||||
@@ -327,6 +346,24 @@ export function useAgendaEventActions({
|
||||
editMode: emitEditMode,
|
||||
recurrence_id: emitRecurrenceId,
|
||||
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
|
||||
serie_id: props.eventRow?.serie_id ?? null,
|
||||
serviceItems: composer.isSessionEvent.value ? commitmentItems.value.slice() : null,
|
||||
|
||||
@@ -88,11 +88,14 @@ export function useAgendaEventComposer(props, emit, extras = {}) {
|
||||
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(() => [
|
||||
{ value: 'somente_este', label: 'Somente esta sessão' },
|
||||
{ value: 'este_e_seguintes', label: 'Esta e as seguintes', disabled: isFirstOccurrence.value },
|
||||
{ value: 'todos', label: 'Todas da série' },
|
||||
{ value: 'todos_sem_excecao', label: 'Todas sem exceção' }
|
||||
{ value: 'todos', label: 'Todas da série' }
|
||||
]);
|
||||
|
||||
// ── 4. Recorrência (criação) ───────────────────────────────────
|
||||
|
||||
@@ -96,6 +96,21 @@ export function useAgendaEventLifecycle({
|
||||
const sendingReminder = ref(false);
|
||||
const serviceQuickDlgOpen = 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 ───────────────────────────────────────
|
||||
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) {
|
||||
emit('editSeriesOccurrence', {
|
||||
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) ─────────────────────────
|
||||
async function onSendManualReminder() {
|
||||
if (!composer.form.value?.id) return;
|
||||
@@ -349,7 +434,21 @@ export function useAgendaEventLifecycle({
|
||||
if (composer.hasSerie.value) loadSerieEvents();
|
||||
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;
|
||||
} else {
|
||||
const preset = props.presetCommitmentId;
|
||||
@@ -452,11 +551,17 @@ export function useAgendaEventLifecycle({
|
||||
sendingReminder,
|
||||
serviceQuickDlgOpen,
|
||||
insuranceQuickDlgOpen,
|
||||
planServiceQuickDlgOpen,
|
||||
occFinancialRecord,
|
||||
occFinancialLoading,
|
||||
sessionPaymentRecord,
|
||||
// computeds
|
||||
serieCountByStatus,
|
||||
pillDeleteMenuItems,
|
||||
// series
|
||||
loadSerieEvents,
|
||||
loadOccFinancialRecord,
|
||||
loadSessionPaymentRecord,
|
||||
onPillEditClick,
|
||||
onPillStatusChange,
|
||||
onPillDeleteClick,
|
||||
@@ -468,6 +573,8 @@ export function useAgendaEventLifecycle({
|
||||
onServiceCreated,
|
||||
openInsuranceQuickCreate,
|
||||
onInsuranceCreated,
|
||||
openPlanServiceQuickCreate,
|
||||
onPlanServiceCreated,
|
||||
// reminder
|
||||
onSendManualReminder
|
||||
};
|
||||
|
||||
@@ -157,8 +157,16 @@ export function useCommitmentServices() {
|
||||
// Atualiza commitment_services nos agenda_eventos com recurrence_id = ruleId
|
||||
// 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
|
||||
// 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 } = {}) {
|
||||
if (!ruleId) return;
|
||||
|
||||
@@ -177,8 +185,23 @@ export function useCommitmentServices() {
|
||||
if (queryError) throw queryError;
|
||||
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)
|
||||
for (const ev of events) {
|
||||
for (const ev of eligibleEvents) {
|
||||
const { error: delErr } = await supabase.from('commitment_services').delete().eq('commitment_id', ev.id);
|
||||
if (delErr) throw delErr;
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ export function useFinancialExceptions() {
|
||||
charge_mode: payload.charge_mode,
|
||||
charge_value: payload.charge_value ?? 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);
|
||||
if (err) throw err;
|
||||
@@ -78,7 +79,8 @@ export function useFinancialExceptions() {
|
||||
charge_mode: payload.charge_mode,
|
||||
charge_value: payload.charge_value ?? 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;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
||||
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices';
|
||||
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
|
||||
import { useFeriados } from '@/composables/useFeriados';
|
||||
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
|
||||
|
||||
import { mapAgendaEventosToCalendarEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
|
||||
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 --------------------
|
||||
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 () => {
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||||
if (tid) loadFeriados(tid);
|
||||
@@ -535,7 +580,7 @@ const allEvents = computed(() => {
|
||||
.filter(Boolean);
|
||||
const occEvents = mapAgendaEventosToCalendarEvents(occRows);
|
||||
|
||||
return [...base, ...occEvents, ...feriadoFcEvents.value];
|
||||
return [...base, ...occEvents, ...feriadoFcEvents.value, ...bloqueioFcEvents.value];
|
||||
});
|
||||
|
||||
// -------------------- eventos fora da jornada --------------------
|
||||
@@ -925,6 +970,15 @@ async function openDialogCreate({ 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 });
|
||||
}
|
||||
|
||||
@@ -937,6 +991,10 @@ async function onEventClick(info) {
|
||||
if (!ev) return;
|
||||
|
||||
const ep = ev.extendedProps || {};
|
||||
|
||||
// Bloqueios/pausas são background events — ignorar click.
|
||||
if (ep.kind === 'bloqueio' || ep.kind === 'break') return;
|
||||
|
||||
dialogEventRow.value = {
|
||||
id: ep.isOccurrence ? null : ev.id || null,
|
||||
owner_id: ep.owner_id,
|
||||
@@ -1686,6 +1744,10 @@ async function _reloadRange() {
|
||||
allMerged.push(...merged.filter((r) => r.is_occurrence));
|
||||
}
|
||||
_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
|
||||
|
||||
@@ -47,6 +47,7 @@ import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
||||
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices';
|
||||
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
|
||||
import { useFeriados } from '@/composables/useFeriados';
|
||||
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
|
||||
|
||||
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();
|
||||
|
||||
// -----------------------------
|
||||
// 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 () => {
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||||
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) : [];
|
||||
|
||||
return [...base, ...occEvents, ...breaks, ...feriadoFcEvents.value];
|
||||
return [...base, ...occEvents, ...breaks, ...feriadoFcEvents.value, ...bloqueioFcEvents.value];
|
||||
});
|
||||
|
||||
const visibleTitle = computed(() => {
|
||||
@@ -1460,9 +1512,23 @@ function onCreateFromButton() {
|
||||
function onSelectTime(selection) {
|
||||
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 rawEnd = selection.end instanceof Date ? selection.end : (selection.end ? new Date(selection.end) : new Date(rawStart.getTime() + durMin * 60000));
|
||||
const startISO = rawStart.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 = {
|
||||
owner_id: ownerId.value,
|
||||
terapeuta_id: null,
|
||||
@@ -1485,6 +1551,10 @@ function onEventClick(info) {
|
||||
if (!ev) return;
|
||||
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 = {
|
||||
id: ep.isOccurrence ? null : ev.id || null,
|
||||
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)
|
||||
_occurrenceRows.value = merged.filter((r) => r.is_occurrence);
|
||||
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
|
||||
@@ -3383,19 +3457,13 @@ onBeforeUnmount(() => {
|
||||
.fc-event.evt-session.evt-has-color {
|
||||
color: #fff !important;
|
||||
}
|
||||
.fc-event.evt-block {
|
||||
background-color: #ef4444 !important;
|
||||
border-color: #dc2626 !important;
|
||||
color: #fff !important;
|
||||
opacity: 0.75;
|
||||
}
|
||||
/* Bloqueios são background events (cinza ~20%, inline em _makeBloqueioEvent).
|
||||
A regra .fc-event.evt-block antiga pintava de vermelho — removida.
|
||||
Deixar o backgroundColor inline (#6b728033) vencer. */
|
||||
/* dayGridMonth: o dot também precisa de cor */
|
||||
.fc-daygrid-event.evt-session .fc-event-main {
|
||||
color: #fff;
|
||||
}
|
||||
.fc-daygrid-event.evt-block .fc-event-main {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Evento customizado — fora do scoped pois é HTML injetado pelo FullCalendar */
|
||||
.ev-custom {
|
||||
|
||||
@@ -214,6 +214,109 @@ export function buildWeeklyBreakBackgroundEvents(pausas, rangeStart, rangeEnd) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// buildBloqueioBackgroundEvents
|
||||
// Renderiza rows de public.agenda_bloqueios como background events cinza
|
||||
// no FullCalendar. Suporta:
|
||||
// - Bloqueio de dia inteiro (hora_inicio/fim NULL) → background do dia todo
|
||||
// - Bloqueio com janela horária → background no intervalo
|
||||
// - Bloqueio recorrente semanal (recorrente=true + dia_semana 0-6) →
|
||||
// repetido em todas as ocorrências do dow no range
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildBloqueioBackgroundEvents(bloqueios, rangeStart, rangeEnd) {
|
||||
if (!Array.isArray(bloqueios) || bloqueios.length === 0) return [];
|
||||
if (!rangeStart || !rangeEnd) return [];
|
||||
|
||||
const out = [];
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const startMs = startOfDay(rangeStart).getTime();
|
||||
const endMs = rangeEnd.getTime();
|
||||
|
||||
for (const b of bloqueios) {
|
||||
if (!b) continue;
|
||||
const titulo = b.titulo || 'Bloqueio';
|
||||
|
||||
// Recorrente semanal: itera o range varrendo dias-da-semana iguais
|
||||
if (b.recorrente && b.dia_semana != null) {
|
||||
const dow = Number(b.dia_semana);
|
||||
if (!Number.isFinite(dow) || dow < 0 || dow > 6) continue;
|
||||
const hi = asTime(b.hora_inicio);
|
||||
const hf = asTime(b.hora_fim);
|
||||
for (let ts = startMs; ts < endMs; ts += dayMs) {
|
||||
const d = new Date(ts);
|
||||
if (d.getDay() !== dow) continue;
|
||||
if (hi && hf) {
|
||||
out.push(_makeBloqueioEvent(b.id ?? null, d, hi, hf, titulo));
|
||||
} else {
|
||||
out.push(_makeBloqueioDayEvent(b.id ?? null, d, titulo));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Não recorrente: usa data_inicio e (opcional) data_fim
|
||||
const di = _parseISODate(b.data_inicio);
|
||||
if (!di) continue;
|
||||
const df = _parseISODate(b.data_fim) ?? di;
|
||||
|
||||
// Range de dias do bloqueio (inclusive)
|
||||
for (let cur = di.getTime(); cur <= df.getTime(); cur += dayMs) {
|
||||
const d = new Date(cur);
|
||||
// Filtra fora do range visível pra evitar lixo
|
||||
if (d.getTime() + dayMs < startMs || d.getTime() > endMs) continue;
|
||||
const hi = asTime(b.hora_inicio);
|
||||
const hf = asTime(b.hora_fim);
|
||||
if (hi && hf) {
|
||||
out.push(_makeBloqueioEvent(b.id ?? null, d, hi, hf, titulo));
|
||||
} else {
|
||||
out.push(_makeBloqueioDayEvent(b.id ?? null, d, titulo));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function _makeBloqueioEvent(id, date, timeStart, timeEnd, titulo) {
|
||||
const tag = id ?? `blq-${date.getTime()}-${timeStart}-${timeEnd}`;
|
||||
return {
|
||||
id: `blq-${tag}`,
|
||||
title: titulo,
|
||||
start: combineDateTimeISO(date, timeStart),
|
||||
end: combineDateTimeISO(date, timeEnd),
|
||||
display: 'background',
|
||||
overlap: false,
|
||||
backgroundColor: '#6b728033', // cinza ~20%
|
||||
extendedProps: { kind: 'bloqueio', bloqueioId: id, label: titulo }
|
||||
};
|
||||
}
|
||||
|
||||
function _makeBloqueioDayEvent(id, date, titulo) {
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
const dateStr = `${yyyy}-${mm}-${dd}`;
|
||||
const tag = id ?? `blq-${dateStr}`;
|
||||
return {
|
||||
id: `blq-day-${tag}`,
|
||||
title: titulo,
|
||||
start: dateStr,
|
||||
allDay: true,
|
||||
display: 'background',
|
||||
overlap: false,
|
||||
backgroundColor: '#6b728033',
|
||||
extendedProps: { kind: 'bloqueio', bloqueioId: id, label: titulo }
|
||||
};
|
||||
}
|
||||
|
||||
function _parseISODate(s) {
|
||||
if (!s) return null;
|
||||
const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!m) return null;
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// minutesToDuration / tituloFallback
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,7 +34,7 @@ const entitlements = useEntitlementsStore();
|
||||
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
|
||||
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'));
|
||||
|
||||
// ── Estado ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -268,6 +268,19 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Toolbar topo: botão "+ Novo convênio". Só 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 -->
|
||||
<div v-if="addingNew" class="cfg-wrap">
|
||||
<div class="cfg-wrap__head">
|
||||
@@ -309,7 +322,7 @@ onMounted(async () => {
|
||||
<div v-if="!plans.length && !addingNew" class="cfg-empty">
|
||||
<i class="pi pi-id-card text-3xl opacity-25" />
|
||||
<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>
|
||||
|
||||
<!-- Lista de convênios -->
|
||||
|
||||
@@ -105,7 +105,8 @@ function startEdit(type) {
|
||||
charge_mode: rec?.charge_mode ?? 'none',
|
||||
charge_value: rec?.charge_value != null ? Number(rec.charge_value) : 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_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,
|
||||
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);
|
||||
cancelEdit();
|
||||
@@ -239,6 +241,18 @@ onMounted(async () => {
|
||||
</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 -->
|
||||
<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" />
|
||||
|
||||
@@ -394,6 +394,18 @@ const fcEvents = computed(() => {
|
||||
// abaixo. Mantem a cor do commitment pra nao perder contexto.
|
||||
const pStatus = ev.paciente_status;
|
||||
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({
|
||||
id: ev.id,
|
||||
title: ev.label,
|
||||
@@ -402,7 +414,7 @@ const fcEvents = computed(() => {
|
||||
backgroundColor: `${ev.color}26`, // ~15% opacity
|
||||
borderColor: ev.color,
|
||||
textColor: 'white',
|
||||
classNames: isInactivePatient ? ['ma-evt--inactive-patient'] : undefined,
|
||||
classNames: cls.length ? cls : undefined,
|
||||
extendedProps: ev
|
||||
});
|
||||
}
|
||||
@@ -411,6 +423,11 @@ const fcEvents = computed(() => {
|
||||
if (feriados.length) {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -568,7 +585,11 @@ const fcOptions = computed(() => ({
|
||||
},
|
||||
eventClick: (info) => {
|
||||
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)
|
||||
eventDrop: (info) => {
|
||||
@@ -621,6 +642,18 @@ const fcOptions = computed(() => ({
|
||||
</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
|
||||
// antigo `__meta` com modalidade ou título secundário.
|
||||
const metaFallback = !isSessao ? (ext.modalidade || ext.titulo || '') : '';
|
||||
@@ -628,6 +661,7 @@ const fcOptions = computed(() => ({
|
||||
return {
|
||||
html: `
|
||||
<div class="mc-fc-event">
|
||||
${payBadgeHtml}
|
||||
${titleLine}
|
||||
${badgesHtml}
|
||||
${metaFallback ? `<div class="mc-fc-event__meta">${escHtml(metaFallback)}</div>` : ''}
|
||||
@@ -729,15 +763,23 @@ function goNext() { fcApi()?.next(); }
|
||||
function goToday() { fcApi()?.today(); }
|
||||
|
||||
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;
|
||||
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') {
|
||||
// Lista cobre 2 anos — abrimos centrado: pula pra (hoje - 1 ano) pra
|
||||
// mostrar passado + presente + futuro de uma vez.
|
||||
const umAnoAtras = new Date();
|
||||
umAnoAtras.setFullYear(umAnoAtras.getFullYear() - 1);
|
||||
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.
|
||||
const _feriadosFallback = ref([]);
|
||||
const _feriadoFcEventsFallback = ref([]);
|
||||
const _bloqueioFcEventsFallback = ref([]);
|
||||
const _feriadosAnoFallback = ref(new Date().getFullYear());
|
||||
const _workRulesFallback = ref([]);
|
||||
const feriadosTodos = M?.feriados ?? _feriadosFallback;
|
||||
const feriadoFcEvents = M?.feriadoFcEvents ?? _feriadoFcEventsFallback;
|
||||
const bloqueioFcEvents = M?.bloqueioFcEvents ?? _bloqueioFcEventsFallback;
|
||||
const feriadosAno = M?.feriadosAno ?? _feriadosAnoFallback;
|
||||
const loadFeriados = M?.loadFeriadosBase ?? (async () => {});
|
||||
const workRules = M?.workRules ?? _workRulesFallback;
|
||||
@@ -2291,10 +2335,49 @@ defineExpose({
|
||||
.ma-cal__fc :deep(.fc-list-event.ma-evt--inactive-patient td) {
|
||||
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) {
|
||||
padding: 4px 6px;
|
||||
color: var(--m-text);
|
||||
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 só 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) {
|
||||
/* Mesmo tamanho/peso da .ma-pat__name (lista de pacientes) pra
|
||||
|
||||
@@ -34,7 +34,7 @@ const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
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 AGENDADOR_BUCKET = 'agendador';
|
||||
@@ -697,7 +697,7 @@ const summaryItems = computed(() => [
|
||||
</div>
|
||||
|
||||
<!-- Link público -->
|
||||
<template v-if="cfg.ativo">
|
||||
<template v-if="cfg.ativo && hasAgendador">
|
||||
<div v-if="!cfg.link_slug" class="mag-link-loading">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
<span>Gerando link…</span>
|
||||
|
||||
@@ -33,9 +33,24 @@ const emit = defineEmits([
|
||||
'edit-paciente', // botão "Editar" do grupo Outras opções → PatientCadastroDialog
|
||||
'abrir-prontuario',
|
||||
'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 tipoLabel = computed(() => {
|
||||
@@ -69,6 +84,41 @@ const isSessaoComPaciente = computed(
|
||||
() => 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) {
|
||||
if (decimal === null || decimal === undefined || Number.isNaN(decimal)) return '—';
|
||||
const h = Math.floor(decimal);
|
||||
@@ -121,16 +171,34 @@ function modalidadeIcon(mod) {
|
||||
{{ fmtHora(ev.startH) }} – {{ fmtHora(ev.endH) }}
|
||||
<span v-if="duracaoMin() !== null" class="evento-row__sub">· {{ duracaoMin() }}min</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="evento-row__edit"
|
||||
:disabled="busy"
|
||||
v-tooltip.top="'Editar sessão (data, hora, recorrência…)'"
|
||||
@click="emit('edit-sessao')"
|
||||
>
|
||||
<i class="pi pi-pencil" />
|
||||
<span>Editar sessão</span>
|
||||
</button>
|
||||
<div class="evento-row__edit-stack">
|
||||
<button
|
||||
type="button"
|
||||
class="evento-row__edit evento-row__edit--primary"
|
||||
:disabled="busy"
|
||||
v-tooltip.top="'Editar sessão (data, hora, recorrência…)'"
|
||||
@click="emit('edit-sessao')"
|
||||
>
|
||||
<i class="pi pi-pencil" />
|
||||
<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 v-if="ev.modalidade" class="evento-row">
|
||||
@@ -239,6 +307,34 @@ function modalidadeIcon(mod) {
|
||||
</div>
|
||||
</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. Só 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).
|
||||
Aqui "Editar" abre o evento em si (não tem paciente). -->
|
||||
<section v-else class="evento-actions__section">
|
||||
@@ -369,12 +465,56 @@ function modalidadeIcon(mod) {
|
||||
margin-left: 4px;
|
||||
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. */
|
||||
.evento-row__edit {
|
||||
|
||||
/* Linha de cobrança — espelha os 3 canais visuais da agenda:
|
||||
- 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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.evento-row__edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
background: var(--m-bg-soft);
|
||||
@@ -397,6 +537,28 @@ function modalidadeIcon(mod) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.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 {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -85,17 +85,26 @@ const TYPE_FILTER_OPTIONS = [
|
||||
];
|
||||
|
||||
const PAYMENT_METHOD_OPTIONS = [
|
||||
{ label: 'Pix', value: 'pix' },
|
||||
{ label: 'Depósito', value: 'deposito' },
|
||||
{ label: 'Dinheiro', value: 'dinheiro' },
|
||||
{ label: 'Cartão', value: 'cartao' },
|
||||
{ label: 'Convênio', value: 'convenio' }
|
||||
{ label: 'Pix', value: 'pix' },
|
||||
{ label: 'Depósito', value: 'deposito' },
|
||||
{ label: 'Dinheiro', value: 'dinheiro' },
|
||||
{ label: 'Cartão', value: 'cartao' },
|
||||
{ label: 'Cartão (maquininha)', value: 'cartao_maquininha' },
|
||||
{ label: 'Convênio', value: 'convenio' },
|
||||
{ label: 'Asaas', value: 'asaas' }
|
||||
];
|
||||
|
||||
function paymentLabel(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 ──────────────────────────────────
|
||||
const filterStatus = ref(null);
|
||||
const filterType = ref(null);
|
||||
@@ -123,6 +132,38 @@ function clearAllFilters() {
|
||||
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 ─────────────────────────────
|
||||
const pageFirst = ref(0);
|
||||
const pageRows = ref(20);
|
||||
@@ -531,7 +572,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="records"
|
||||
:value="recordsGrouped"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
lazy
|
||||
@@ -545,13 +586,20 @@ onBeforeUnmount(() => {
|
||||
scrollable
|
||||
scrollHeight="flex"
|
||||
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"
|
||||
@page="onPageChange"
|
||||
>
|
||||
<Column header="Paciente" style="min-width: 13rem">
|
||||
<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
|
||||
class="mfl-row__avatar"
|
||||
: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">
|
||||
<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
|
||||
label="Receber"
|
||||
icon="pi pi-check"
|
||||
@@ -1336,6 +1403,21 @@ onBeforeUnmount(() => {
|
||||
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-overdue:hover) {
|
||||
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) {
|
||||
background: color-mix(in srgb, var(--m-bg-medium) 70%, transparent);
|
||||
@@ -1388,6 +1470,28 @@ onBeforeUnmount(() => {
|
||||
gap: 8px;
|
||||
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 {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
@@ -1457,6 +1561,40 @@ onBeforeUnmount(() => {
|
||||
font-size: 0.7rem;
|
||||
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 {
|
||||
color: var(--m-text-faint);
|
||||
font-style: italic;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, provide, nextTick } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { applyThemeEngine, surfaces as THEME_SURFACES, presetOptions as THEME_PRESETS } from '@/theme/theme.options';
|
||||
import { MELISSA_THEME_NAMES, findMelissaTheme } from './melissaThemes';
|
||||
@@ -95,6 +96,7 @@ import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
|
||||
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.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
|
||||
// (DEV), notificações e ajuda. AppTopbar não monta na rota /melissa
|
||||
// (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 melissaAgendaRef = ref(null); // pra chamar openProntuario + setView
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const tenantStore = useTenantStore();
|
||||
const conversationDrawerStore = useConversationDrawerStore();
|
||||
|
||||
@@ -630,6 +633,12 @@ const {
|
||||
dialogEventRow: agendaDialogEventRow,
|
||||
dialogStartISO: agendaDialogStartISO,
|
||||
dialogEndISO: agendaDialogEndISO,
|
||||
dialogBlockOverlap: agendaDialogBlockOverlap,
|
||||
occDialogOpen: agendaOccDialogOpen,
|
||||
occDialogEventRow: agendaOccDialogEventRow,
|
||||
occDialogStartISO: agendaOccDialogStartISO,
|
||||
occDialogEndISO: agendaOccDialogEndISO,
|
||||
serieRefreshTick: agendaSerieRefreshTick,
|
||||
ownerId: agendaOwnerId,
|
||||
clinicTenantId: agendaClinicTenantId,
|
||||
commitmentOptions: agendaCommitmentOptions,
|
||||
@@ -638,7 +647,12 @@ const {
|
||||
allEventsForDialog: agendaAllEvents,
|
||||
feriados: agendaFeriados,
|
||||
bloqueioDialogOpen: agendaBloqueioOpen,
|
||||
bloqueioMode: agendaBloqueioMode
|
||||
bloqueioMode: agendaBloqueioMode,
|
||||
// Status change confirm dialog (Fase 5, 2026-05-14)
|
||||
statusDialogOpen: agendaStatusDialogOpen,
|
||||
statusDialogProps: agendaStatusDialogProps,
|
||||
onStatusDialogConfirm: agendaOnStatusDialogConfirm,
|
||||
onStatusDialogCancel: agendaOnStatusDialogCancel
|
||||
} = M;
|
||||
|
||||
function abrirEvento(ev) {
|
||||
@@ -650,12 +664,10 @@ function fecharEvento() {
|
||||
}
|
||||
|
||||
// ── Actions do MelissaEventoPanel ──────────────────────────────
|
||||
// updateStatus: muda status no DB e refetcha agenda. Pattern espelha
|
||||
// AgendaTerapeutaPage (sem optimistic update por simplicidade no MVP).
|
||||
//
|
||||
// Quando `ev` é ocorrência VIRTUAL de recorrência (id `rec::...` sem row real),
|
||||
// delega pro M.onUpdateSeriesEvent que materializa antes do UPDATE — sem isso
|
||||
// PostgreSQL recusa o UPDATE com "invalid input syntax for type uuid".
|
||||
// Fase 5 (2026-05-14): TODOS os status (realizado/faltou/cancelado/etc)
|
||||
// passam por M.onUpdateSeriesEvent — que abre o AgendaStatusChangeConfirmDialog
|
||||
// quando há regra de exceção, pacote saldo ou pending record. Antes, eventos
|
||||
// reais faziam UPDATE direto sem passar pelo dialog (gap reportado pelo user).
|
||||
async function updateEventoStatus(novoStatus, msgSucesso) {
|
||||
const ev = eventoSelecionado.value;
|
||||
if (!ev?.id || eventoBusy.value) return;
|
||||
@@ -665,29 +677,18 @@ async function updateEventoStatus(novoStatus, msgSucesso) {
|
||||
!!ev.is_occurrence ||
|
||||
(typeof ev.id === 'string' && ev.id.startsWith('rec::'));
|
||||
|
||||
if (isVirtual) {
|
||||
await M.onUpdateSeriesEvent({
|
||||
id: null,
|
||||
status: novoStatus,
|
||||
recurrence_date:
|
||||
ev.recurrence_date ||
|
||||
ev.original_date ||
|
||||
String(ev.inicio_em || '').slice(0, 10),
|
||||
inicio_em: ev.inicio_em,
|
||||
fim_em: ev.fim_em,
|
||||
is_virtual: true,
|
||||
// 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;
|
||||
}
|
||||
await M.onUpdateSeriesEvent({
|
||||
id: isVirtual ? null : ev.id,
|
||||
status: novoStatus,
|
||||
recurrence_date:
|
||||
ev.recurrence_date ||
|
||||
ev.original_date ||
|
||||
String(ev.inicio_em || '').slice(0, 10),
|
||||
inicio_em: ev.inicio_em,
|
||||
fim_em: ev.fim_em,
|
||||
is_virtual: isVirtual,
|
||||
row: ev
|
||||
});
|
||||
toast.add({ severity: 'success', summary: msgSucesso, life: 2200 });
|
||||
// Refetch:
|
||||
// - M.refetch() alimenta a Agenda (FullCalendar + ocorrências virtuais)
|
||||
@@ -700,6 +701,7 @@ async function updateEventoStatus(novoStatus, msgSucesso) {
|
||||
} catch (e) {
|
||||
const msg = e?.message || 'Erro ao atualizar evento';
|
||||
toast.add({ severity: 'error', summary: 'Falha ao atualizar', detail: msg, life: 4000 });
|
||||
} finally {
|
||||
eventoBusy.value = false;
|
||||
}
|
||||
}
|
||||
@@ -708,6 +710,354 @@ function onConcluir() { updateEventoStatus('realizado', 'Sessão marcada como r
|
||||
function onFaltou() { updateEventoStatus('faltou', 'Marcada como falta'); }
|
||||
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() {
|
||||
const ev = eventoSelecionado.value;
|
||||
if (!ev?.patient_id) {
|
||||
@@ -1709,6 +2059,9 @@ function onKeydown(e) {
|
||||
@cancelar="onCancelar"
|
||||
@remarcar="onRemarcar"
|
||||
@edit-sessao="onEditEvento"
|
||||
@delete-sessao="onDeleteEvento"
|
||||
@ver-lancamentos="onVerLancamentos"
|
||||
@antecipar-pagamento="onAnteciparPagamento"
|
||||
@edit-paciente="onEditPaciente"
|
||||
@abrir-prontuario="onAbrirProntuario"
|
||||
@whatsapp="onWhatsapp"
|
||||
@@ -2139,6 +2492,8 @@ function onKeydown(e) {
|
||||
:allEvents="agendaAllEvents"
|
||||
:pausasSemanais="agendaSettings?.pausas_semanais || []"
|
||||
:feriados="agendaFeriados"
|
||||
:serieRefreshTick="agendaSerieRefreshTick"
|
||||
:blockOverlapWarning="agendaDialogBlockOverlap"
|
||||
newPatientRoute="/therapist/patients/cadastro"
|
||||
@save="M.onDialogSave"
|
||||
@delete="M.onDialogDelete"
|
||||
@@ -2146,6 +2501,34 @@ function onKeydown(e) {
|
||||
@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.
|
||||
Trigger é o menu na toolbar da MelissaAgenda. Após salvar,
|
||||
refetcha pra refletir o bloqueio na agenda. -->
|
||||
@@ -2159,6 +2542,118 @@ function onKeydown(e) {
|
||||
@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 há
|
||||
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),
|
||||
então as pages embedadas (config, agendador online, etc.)
|
||||
precisam de um Toast próprio aqui pra não silenciar o
|
||||
@@ -2676,6 +3171,54 @@ function onKeydown(e) {
|
||||
-webkit-box-orient: vertical;
|
||||
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>
|
||||
|
||||
<!-- ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import MelissaConfigList from './MelissaConfigList.vue';
|
||||
// InputText/Select/Textarea/InputMask/Skeleton/Tag/Button: auto via PrimeVueResolver
|
||||
|
||||
@@ -30,6 +31,55 @@ const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const router = useRouter();
|
||||
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';
|
||||
|
||||
@@ -933,6 +983,92 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div><!-- /.mpr-w__body -->
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1676,4 +1812,135 @@ onBeforeUnmount(() => {
|
||||
.mpr-custom { flex-direction: column; gap: 8px; }
|
||||
.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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -437,9 +437,9 @@ export function applyGuards(router) {
|
||||
if (shouldLoadEntitlements(_ent, _tid)) {
|
||||
await loadEntitlementsSafe(_ent, _tid, true);
|
||||
}
|
||||
// Entitlements pessoais (therapist/supervisor têm assinatura própria)
|
||||
const _roleNorm = normalizeRole(_role);
|
||||
if (['therapist', 'supervisor'].includes(_roleNorm) && _ent.loadedForUser !== uid) {
|
||||
// Entitlements pessoais — subscription do user independe do role
|
||||
// no tenant ativo (tenant_admin do próprio tenant pode ter therapist_pro)
|
||||
if (_ent.loadedForUser !== uid) {
|
||||
try {
|
||||
await _ent.loadForUser(uid);
|
||||
} catch { }
|
||||
@@ -738,11 +738,11 @@ export function applyGuards(router) {
|
||||
await loadEntitlementsSafe(ent, tenantId, true);
|
||||
}
|
||||
|
||||
// ✅ user entitlements: terapeuta pode ter assinatura pessoal (therapist_pro)
|
||||
// que gera features em v_user_entitlements, não em v_tenant_entitlements.
|
||||
// user entitlements: therapist e supervisor têm assinatura pessoal (v_user_entitlements)
|
||||
const activeRoleNormForEnt = normalizeRole(tenant.activeRole);
|
||||
if (['therapist', 'supervisor'].includes(activeRoleNormForEnt) && uid && ent.loadedForUser !== uid) {
|
||||
// ✅ user entitlements: subscription pessoal (therapist_pro/free,
|
||||
// supervisor_*, etc) independe do role no tenant ativo — um terapeuta
|
||||
// pode ser tenant_admin do próprio tenant E ter assinatura pessoal.
|
||||
// Sempre carrega pra qualquer user autenticado.
|
||||
if (uid && ent.loadedForUser !== uid) {
|
||||
logGuard('ent.loadForUser');
|
||||
try {
|
||||
await ent.loadForUser(uid);
|
||||
|
||||
@@ -34,7 +34,8 @@ export default {
|
||||
{
|
||||
path: 'melissa/:secao?',
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user