6 Commits

Author SHA1 Message Date
Leonardo 97b0ec1ec5 HANDOFF + log atualizados pra sessao 2026-05-06
- HANDOFF.md reescrito refletindo estado atual: working tree limpa,
  5 commits criados na sessao, resumo do que foi feito (6 Melissa Pages
  blueprint + dialogs harmonizados + ConversationDrawer WhatsApp +
  bug fix de cores no MelissaPacientes), e o que continua pendente
  (A66 V2 design aguardando feedback + restore na PatientsListPage)
- Obsidian/Brain/log.md: entrada da sessao 05-06 anexada com detalhes
  e referencias dos 5 commits

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:18:57 -03:00
Leonardo 15103eded5 Cleanup: backups antigos removidos + dashboard config + HANDOFF/log
- Remove database-novo/backups/2026-03-27 e 2026-03-29 (deveriam estar
  no gitignore, mas haviam sido tracked antes)
- Atualiza db.config.json + generate-dashboard.cjs + dashboard.html
- HANDOFF.md atualizado com estado de 05-05 (sprint blueprint tabular +
  arquivamento de pacientes)
- Obsidian/Brain/log.md: entrada da sessao 05-05 adicionada

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:15:22 -03:00
Leonardo 98f7252dcd Melissa: 6 Pages aplicando blueprint + dialogs unificados + Conversa estilo WhatsApp
Sprint F (05-06). Blueprint tabular aplicado nas 6 paginas restantes;
dialogs harmonizados (FloatLabel + IconField + variant=filled + section
dividers, espelhando PatientsCadastroPage Identidade); ConversationDrawer
repaginado pra visual estilo WhatsApp.

Pages refatoradas (cada uma com subheader, sidebar __scroll + __footer
fixo "Limpar filtros", Xs inline pra zerar filtro individual, mobile
drawer com sticky footer):

- MelissaCompromissos: blueprint mantendo row design original (color
  stripe + name + badges + descricao + meta inline). Filtros Status
  (Ativos/Inativos) + Tipo (Nativos/Meus). Coluna Acoes frozen 140px
  com toggle+pencil+trash.

- MelissaGrupos / MelissaTags: pattern completo + dialog "Pacientes
  do grupo/tag" com lista vinculada via patient_group_patient /
  patient_patient_tag. Avatar primary nos pacientes, header colorido
  com cor da entidade, X de fechar igual .mc-close. Dialog de
  criar/editar com FloatLabel + section dividers.

- MelissaMedicos: blueprint + dialog "Pacientes encaminhados" usando
  cor primary do tema (medicos nao tem cor propria); dialog de
  criar/editar com 4 secoes (Identificacao/Contato/Localizacao/Obs)
  espelhando PatientsCadastroPage. Service ja tinha
  fetchPatientsByMedicoNome (ILIKE em encaminhado_por).

- MelissaConversas: subheader, sidebar com bg-soft + border-right e
  cards com sombra (mw-w--side), Limpar filtros global no footer fixo
  (fix bug: filters era ref({...}) e eu lia filters.search direto, agora
  usa .value), alerta de unlinked movido pro topo, kanban mobile com
  min-height nas colunas pra mostrar mensagens.

- MelissaRecorrencias: subheader, button list de status (Ativas verde/
  Encerradas vermelho/Todas) substitui SelectButton, busca por nome do
  paciente, footer Limpar filtros, X inline no filtro Status.

ConversationDrawer redesign (WhatsApp-style):
- Header com avatar circular primary + iniciais + numero formatado
- Container de mensagens com bg "papel de parede" (color-mix com bege
  esverdeado WA + radial-gradient pattern)
- Bolhas com cantos certos (top-left ou top-right zerado simulando
  tail), sombra sutil, cores autenticas (#d9fdd3 light/#005c4b dark
  outbound; #fff/#202c33 inbound), detecao dark via :global
- Time HH:MM + status overlay no canto inferior direito DENTRO do
  balao; checks azuis quando lida (#53bdeb)
- Compose pill rounded-full + botao Send circular verde #00a884
- Removido fmtDateTime obsoleto (substituido por fmtTimeOnly)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:14:35 -03:00
Leonardo 269b531158 Melissa: blueprint tabular + Cadastros/Agendamentos/Pacientes + restore
Sprint E (05-05). Blueprint tabular oficial pras paginas Melissa de
listagem (DataTable + sidebar com stats e filtros coloridos, view
toggle list/grade, subheader explicativo, mobile pencil+popover).

Novo arquivo:
- blueprints/melissa-table-page-blueprint.md (~530L, 18 secoes) —
  referencia canonica MelissaCadastrosRecebidos

Paginas refatoradas/criadas:
- MelissaCadastrosRecebidos: refator pra blueprint (DataTable + frozen
  action + view toggle + subheader)
- MelissaAgendamentosRecebidos (NOVO): substitui o embed via
  MelissaEmbed; 4 status coloridos (Pendente/Autorizado/Convertido/
  Recusado), 3 acoes condicionais (Recusar/Autorizar/Converter em
  sessao), wired com AgendaEventDialog
- MelissaPacientes: refator parcial (subheader, sombras, status pills
  coloridas, email/phone colunas proprias, mobile pencil+popover, fix
  scroll mobile com min-height:0 na .mp-list, view toggle persistido,
  tags/grupos color fix g.cor->g.color, restore de arquivados)
- MelissaEmbed: agendamentos-recebidos removido do EMBED_MAP
- MelissaLayout: wire-up MelissaAgendamentosRecebidos nativo
- composables/useMelissaPacientes + useMelissaPacientesAside ajustes

Restore de pacientes arquivados:
- patientsRepository: novo restorePatient(id, { tenantId })
- PatientsCadastroPage statusOpts: +Arquivado (fecha gap de
  inconsistencia ao editar paciente arquivado)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:13:53 -03:00
Leonardo 6d9b36d592 A66 WIP: AgendaEventDialog quebrado em 5 composables + 265 specs + V2 esqueleto
Sub-sessao 1 entregue (composables):
- agendaEventHelpers (262L) — utilitarios puros (date, format, parse)
- useAgendaEventComposer (485L) — montagem do form + validacao
- useAgendaEventActions (387L) — save/delete/cancel/move actions
- useAgendaEventPickerBilling (378L) — pickers (terapeuta, servico,
  convenio) + calculo de billing
- useAgendaEventLifecycle (474L) — open/close/dirty state + autosave
- 5 specs em __tests__/ (75+76+28+43+43 = 265 testes), 495/495 passing

AgendaEventDialog: 3522 -> 2632 linhas (-25%) consumindo os composables.
Backup byte-identico em AgendaEventDialog.vue.bak pra rollback.

Sub-sessao 2 entregue (esqueleto, NAO TESTADO):
- AgendaEventDialogV2 (~1100L, 3 zonas: PACIENTE/QUANDO/O QUE)
- Preview em /preview/agenda-dialog-v2 com 5 cenarios
- Rota em routes.misc.js
- User testou e nao gostou do design — aguarda feedback especifico
  pra iteracao na sub-sessao 3 (migracao nos 9 consumers).

Dialogs auxiliares novos pro AgendaEventDialog:
- InsurancePlanQuickCreateDialog (criar convenio inline)
- ServiceQuickCreateDialog (criar tipo de sessao inline)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:13:22 -03:00
Leonardo 957e912a7f Melissa polish + Prontuario Visao Geral + agenda historico
Sprints B (05-03) e C (05-04) acumulados:

- NotificationDrawer/Item redesign (visual mais limpo, ações inline)
- Dock pins compose (useMelissaDockPins) + cache store global (melissaCacheStore)
- MelissaAgenda: timeline FullCalendar parity + cards resumo, histórico
  card com useMelissaAgendaHistorico, MelissaEventoPanel ajustado
- useFeriados: cache opt-in pra evitar fetch redundante de feriados
- PatientProntuario: aba Visão Geral nova; PatientConversationsTab polish
- AgendaClinicMosaic / AgendaTerapeutaPage / useAgendaSettings: ajustes
  de paridade com Melissa
- DocumentsListPage: pequenos ajustes
- DB migration 20260504000001: fix do trigger pra status 'excluido' nas
  cancel_notifications

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:11:55 -03:00
64 changed files with 29086 additions and 90494 deletions
+170 -585
View File
@@ -1,644 +1,229 @@
# HANDOFF — 2026-04-30 (quinta, sprint MelissaConfiguracoes + MelissaEmbed + dialog blueprint dark)
# HANDOFF — 2026-05-06 (Melissa Pages aplicando blueprint + ConversationDrawer WhatsApp redesign + commits)
Documento de continuidade. **Quando voltar, comece lendo esta página.**
Sessão grande — criação do hub de configs Melissa (com tudo embedado),
wrapper genérico pra pages tradicionais, polimento do cadastro de paciente,
migração de 22 ocorrências de `bg-gray-100` em dialogs pra tema-aware.
Working tree **NÃO commitado** ainda.
A sessão anterior (2026-04-29) deixou 7 Melissa Pages novas também não
commitadas — estão acumuladas com esta. Decidir junto: 1 commit grande
ou chunks lógicos.
> **🟢 ENTREGUE HOJE** — Blueprint tabular aplicado nas **6 Melissa Pages restantes**
> (Compromissos, Grupos, Tags, Médicos, Conversas, Recorrências) + dialogs
> harmonizados com `FloatLabel + IconField + section dividers` + dialogs
> "Pacientes do grupo/tag/médico" com cor primary nos avatares + redesign
> completo do `ConversationDrawer` pra estilo WhatsApp (avatar circular, bg
> "papel de parede", bolhas com tail simulada, time/status overlay no canto,
> compose pill + send circular verde) + fix de cor de tags/grupos no
> MelissaPacientes (`g.cor → g.color` em 20 lugares).
> **🟢 COMMITADO** — Working tree estava com 4 sprints acumulados (~50 arquivos).
> Foram criados **5 commits** lógicos antes do push, do mais antigo pro mais
> recente. Ver seção "Histórico de commits" abaixo.
> **🟡 AINDA PENDENTE** — Sub-sessão 2 do A66 (V2 dialog): user não gostou
> do design do esqueleto entregue em 2026-05-05. Aguarda feedback específico
> antes de iterar. Detalhes na seção "Sessões dedicadas pendentes".
---
## 🚦 STATUS — Working tree
## 🚦 STATUS — Working tree LIMPA
**Modificados (acumulado das 2 sprints):**
```
M HANDOFF.md
M blueprints/dialog-blueprint.md
M src/assets/layout/_menu.scss
M src/components/CadastroRapidoConvenio.vue
M src/components/CadastroRapidoMedico.vue
M src/components/agenda/AgendaQuickAddDialog.vue
M src/components/ui/PatientCadastroDialog.vue
M src/features/patients/cadastro/PatientsCadastroPage.vue
M src/features/patients/medicos/MedicosPage.vue
M src/layout/AppMenu.vue
M src/layout/AppRail.vue
M src/layout/AppRailPanel.vue
M src/layout/AppRailSidebar.vue
M src/layout/composables/layout.js
M src/layout/configuracoes/ConfiguracoesAgendaPage.vue
M src/layout/configuracoes/ConfiguracoesRecursosExtrasPage.vue
M src/layout/configuracoes/ConfiguracoesWhatsappPage.vue
M src/layout/melissa/MelissaAgenda.vue
M src/layout/melissa/MelissaLayout.vue
M src/layout/melissa/MelissaMenu.vue
M src/layout/melissa/MelissaPacientes.vue
M src/layout/melissa/composables/useMelissaAgenda.js
M src/router/routes.clinic.js
M src/router/routes.therapist.js
M src/views/pages/account/ProfilePage.vue
M src/views/pages/saas/SaasAddonsPage.vue
M src/views/pages/saas/SaasNotificationTemplatesPage.vue
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
```
**Novos (acumulado):**
```
?? blueprints/melissa-page-blueprint.md
?? src/layout/melissa/MelissaCadastrosRecebidos.vue
?? src/layout/melissa/MelissaCompromissos.vue
?? src/layout/melissa/MelissaConfiguracoes.vue
?? src/layout/melissa/MelissaConversas.vue
?? src/layout/melissa/MelissaEmbed.vue
?? src/layout/melissa/MelissaGrupos.vue
?? src/layout/melissa/MelissaMedicos.vue
?? src/layout/melissa/MelissaRecorrencias.vue
?? src/layout/melissa/MelissaTags.vue
?? src/layout/melissa/composables/useMelissaPacientesAside.js
```
(após `git push`. Antes do push: 5 commits ahead.)
---
## ✅ FEITO HOJE (2026-04-30)
## 📦 Histórico de commits criados hoje
### PatientsCadastroPage — polimento
Em ordem cronológica de criação (mais antigo → mais novo):
1. **Bug fix dropdown Grupo vazio**`optionLabel="nome"``"name"` (`PatientsCadastroPage.vue:1542`). Repo `listGroups` retorna `{name,color}` mas template buscava `nome`. Tags já estavam OK.
2. **Toggle Vertical/Abas** — botão segmented na sidebar (entre avatar e nav). Em "Abas", esconde nav vertical da sidebar + mostra tab list em cima do main + esconde headers do Accordion (via `.pcd-horizontal :deep(.p-accordionheader)`). Persiste em `localStorage` (chave `pcd.viewMode.v1`).
3. **Sticky + margin-top fantasma** — adicionado `xl:items-start` no grid container (era stretch por default). Sticky da esquerda mantido como estava. Margin-top fantasma resolveu.
1. **`957e912`** — `Melissa polish + Prontuario Visao Geral + agenda historico`
- Sprints B (05-03) + C (05-04) acumulados:
- NotificationDrawer/Item redesign
- Dock pins compose (`useMelissaDockPins`) + cache store global (`melissaCacheStore`)
- MelissaAgenda timeline FullCalendar parity + cards resumo + histórico card
- `useFeriados` cache opt-in
- PatientProntuario aba Visão Geral nova
- DB migration `20260504000001_fix_cancel_notifications_excluido.sql`
- 19 files, +5203 285
### Dialog blueprint — dark/light aware
2. **`6d9b36d`** — `A66 WIP: AgendaEventDialog quebrado em 5 composables + 265 specs + V2 esqueleto`
- 5 composables (1986L total): `agendaEventHelpers`, `useAgendaEventComposer`, `useAgendaEventActions`, `useAgendaEventPickerBilling`, `useAgendaEventLifecycle`
- 5 specs em `__tests__/` (75+76+28+43+43 = **265 testes**, 495/495 passando)
- AgendaEventDialog 3522 → 2632 linhas (-25%)
- `AgendaEventDialogV2.vue` esqueleto (~1100L, 3 zonas) + preview em `/preview/agenda-dialog-v2`
- Backup byte-idêntico em `AgendaEventDialog.vue.bak`
- Dialogs auxiliares: `InsurancePlanQuickCreateDialog`, `ServiceQuickCreateDialog`
- 17 files, +10966 1298
4. **`blueprints/dialog-blueprint.md`** atualizado: trocado `bg-gray-100` (hardcoded light) por `bg-[var(--surface-ground)]` (tema-aware: `--p-surface-100` light / `--p-surface-950` dark). Removido o hack `shadow-[0_1px_0_0_rgba(255,255,255,0.06)]`. Adicionada seção Anti-pattern explícita.
5. **22 ocorrências em 9 arquivos migradas** pro novo padrão:
- `MedicosPage.vue` (1 dialog)
- `SaasNotificationTemplatesPage.vue` (1)
- `SaasAddonsPage.vue` (3 dialogs)
- `PatientsCadastroPage.vue` (2 dialogs internos: criar grupo, criar tag)
- `ConfiguracoesWhatsappPage.vue` (1)
- `CadastroRapidoMedico.vue` (1)
- `CadastroRapidoConvenio.vue` (1)
- `ConfiguracoesRecursosExtrasPage.vue` (1)
- `AgendaQuickAddDialog.vue` (1)
3. **`269b531`** — `Melissa: blueprint tabular + Cadastros/Agendamentos/Pacientes + restore`
- Sprint E (05-05): Blueprint canônico em `blueprints/melissa-table-page-blueprint.md` (~530L, 18 seções)
- MelissaCadastrosRecebidos refator pro blueprint
- **MelissaAgendamentosRecebidos** novo (substitui o embed)
- MelissaPacientes refator parcial (subheader, sombras, status pills coloridas, email/phone colunas próprias, mobile pencil+popover, fix scroll com `min-height: 0`, restore de arquivados)
- `restorePatient` no `patientsRepository`
- 10 files, +4824 301
### Surface picker no popover do canto superior direito
4. **`98f7252`** — `Melissa: 6 Pages aplicando blueprint + dialogs unificados + Conversa estilo WhatsApp`
- Sprint F (05-06, esta sessão):
- **MelissaCompromissos**: blueprint mantendo row design original (color stripe + name + badges + descrição + meta inline)
- **MelissaGrupos** + **MelissaTags**: blueprint completo + dialog "Pacientes do grupo/tag" com lista vinculada via `patient_group_patient` / `patient_patient_tag`
- **MelissaMedicos**: blueprint + dialog "Pacientes encaminhados" usando cor primary; dialog editar com 4 seções (Identificação/Contato/Localização/Obs) espelhando PatientsCadastroPage
- **MelissaConversas**: subheader, sidebar reestruturada, alerta unlinked no topo, kanban mobile com `min-height` nas colunas, fix bug `filters` é `ref({})` então no script precisa `.value`
- **MelissaRecorrencias**: button list de status, busca por nome do paciente, footer Limpar filtros
- **ConversationDrawer**: redesign WhatsApp (avatar primary, bg "papel de parede", bolhas com tail, time/status overlay, compose pill + send circular #00a884)
- 7 files, +7879 1467
6. **`MelissaLayout.vue`** — importado `surfaces` de `theme.options`, criado `setSurface()` análogo a `setPrimary()`, computed `activeSurface` com fallback (`zinc` no dark, `slate` no light). Popover do canto sup. dir. agora mostra **Surface** (8 swatches) abaixo de **Cor primária**.
### MelissaConfiguracoes — hub de configurações
7. **Novo arquivo: `src/layout/melissa/MelissaConfiguracoes.vue`**
- Layout 2-col (estilo MelissaAgenda): aside ~320px com Accordion de grupos + main com conteúdo da seção ativa
- **6 grupos** (espelham `/configuracoes/` + extensões):
- **Layout Melissa**: Aparência, Plano de fundo, Relógio, Cronômetro (4 inlines com controles definidos no próprio arquivo)
- **Conta** (NOVO): Meu Perfil, Meu Plano, Meu Negócio, Segurança
- **Agenda**: 3 sub-itens
- **Financeiro**: 5 sub-itens
- **WhatsApp & Conversas**: 9 sub-itens
- **Comunicação**: 2 sub-itens
- **Empresa & Plataforma**: 3 sub-itens
- **Total: 30 sub-itens** — 4 inlines + 26 embedados via `defineAsyncComponent` + `<Suspense>`
- **`COMPONENT_MAP`** mapeia keys pra `defineAsyncComponent(() => import(...))`
- **`mcfg-embed-hero`** sticky no topo (`z-index: 11`) com ícone+label+desc+slot `#cfg-page-actions` pros Teleports das pages tradicionais
- **`mcfg-embed-wrap`** dá padding equivalente ao `px-3 md:px-4 pb-5` da `ConfiguracoesPage`
- Aceita prop `secaoRota` que mapeia rota → `secaoAtiva` (`ROUTE_TO_SECAO`): permite deep-link tipo `/melissa/perfil` abrir direto na seção certa
8. **`MelissaLayout.vue`** — adicionou ao `SECOES`:
- `aparencia` (default do MelissaConfiguracoes)
- `perfil`, `plano`, `negocio`, `seguranca` (atalhos de Conta — montam MelissaConfiguracoes pré-selecionado)
- **`provide('melissaSettings', {...})`** após `testarToque` (ordem importa pra ter todas as refs definidas) — expõe pro MelissaConfiguracoes
### MelissaEmbed — wrapper genérico (Onda 1)
9. **Novo arquivo: `src/layout/melissa/MelissaEmbed.vue`**
- 1-coluna (sem aside) — mais leve que MelissaConfiguracoes
- Hero glass sticky (z-index 11) + `<Suspense>` + `<component :is>` lazy
- **9 pages embedadas** (`EMBED_MAP`):
- Financeiro Visão geral + Lançamentos
- Documents List + Templates
- Agendamentos Recebidos
- Online Scheduling
- Relatórios
- Notifications History
- Patients External Link
- Reusa Teleport target `#cfg-page-actions` (compartilhado com ConfiguracoesPage tradicional)
10. **`MelissaLayout.vue`** — `MELISSA_EMBED_KEYS` array + render condicional + filtro do placeholder atualizado
### MelissaMenu — handlers internos + Onda 1
11. **`MelissaMenu.vue`** — várias mudanças:
- **Footer**: `goPerfil/goPlano/goSeguranca` trocados de `navAndClose(rota_externa)` pra `emit('select', '...')` interno. Adicionado `goNegocio` + botão "Meu Negócio".
- **Backdrop blur** no `.mm-layer`: `background: rgba(0,0,0,0.25)` + `backdrop-filter: blur(2px)` (equivalente ao `backdrop-blur-xs` do PrimeVue Dialog)
- **Categoria Configurações > Layout Melissa** > "Aparência e cronômetro"
- **Onda 1 entries**:
- Agenda e Pacientes > Principais: + Agendamentos recebidos
- Agenda e Pacientes > Outros: + Agendador online, Link externo de cadastro
- Prontuários: novo grupo "Documentos" (Documentos, Templates)
- Financeiro: convertido `fin-overview/fin-lancamentos` (route externo) → `financeiro/financeiro-lancamentos` (internos), + grupo "Análise" com Relatórios
- WhatsApp > Atendimento: + Notificações enviadas
5. **`15103ed`** — `Cleanup: backups antigos removidos + dashboard config + HANDOFF/log`
- Backups `database-novo/backups/2026-03-27` e `2026-03-29` removidos
- `db.config.json` + `generate-dashboard.cjs` + `dashboard.html` atualizados
- HANDOFF.md (estado 05-05) + log.md
- 11 files, +435 87172
---
## 📊 Estado da migração Melissa
## 📋 RESUMO da sessão 2026-05-06
**Promovidas (10 nativas)**: Agenda, Pacientes, Compromissos, Recorrências, Conversas, Tags, Grupos, CadastrosRecebidos, Médicos, Configurações
**Embedadas via MelissaConfiguracoes (26)**: 22 configs + 4 conta
**Embedadas via MelissaEmbed (9)**: Financeiro+Lançamentos, Documents+Templates, Agendamentos Recebidos, Online Scheduling, Relatórios, Notificações, Link Externo
### Padrões consolidados nas 6 páginas Melissa restantes
**Faltam (🟢 baixa prioridade)**: Dashboards (Therapist/Clinic — paradigma diferente, resumo já cobre), Setup Wizard, Upgrade pages, Clinic admin (Features/Professionals)
Cada página agora segue o blueprint:
🚫 **Fora de escopo**: routes.saas.js (admin SaaS), Auth pages
- **Subheader explicativo** logo abaixo do header (1-2 frases descrevendo a página + ações principais com `<strong>`)
- **Sidebar reestruturada** em 2 zonas:
- `.xx-side` com `bg: var(--m-bg-soft)` + `border-right` (visual de coluna lateral)
- `.xx-side__scroll` (flex 1, overflow auto) com cards `xx-w--side` (margin lateral 12px + sombra)
- `.xx-side__footer` (flex-shrink 0, padding 12px, bg-soft, border-top) com botão **"Limpar filtros"** global
- **Xs inline** ao lado do título de cada filter card (vermelho 18×18, aparece só quando filtro ativo)
- **Transition `xx-clear`** no footer (fade + collapse 240ms)
- **Body sem padding/gap** (sidebar fica colada à esquerda; main column tem padding interno próprio)
- **Mobile drawer** com sidebar teleportada perde bg/border-right (drawer já tem chrome) + footer vira `position: sticky; bottom: 0` com bg blur
### Dialogs harmonizados (Tags / Grupos / Médicos)
Espelhando o pattern do **PatientsCadastroPage > Identidade**:
- **Section dividers**: `<span class="text-[0.7rem] font-bold uppercase tracking-widest text-[var(--p-primary-color)]">` + linha `h-px` primary-tinted
- **Cada campo**: `FloatLabel variant="on"` + `IconField` + `InputIcon` + InputText/Select com `variant="filled"`
- **Grid**: `grid grid-cols-1 gap-6 xl:grid-cols-2 mb-7`
- **Erro inline**: `<small class="text-red-500">` + `pi-exclamation-circle`
- **Footer**: Button PrimeVue padrão (Cancelar secondary text + Salvar com `pi-check`)
- **Bordas dos inputs**: padrão do PrimeVue (sem CSS scoped sobreescrevendo)
### Dialog "Pacientes vinculados" (Tags / Grupos / Médicos)
Pattern unificado:
- **Borda 2px na cor da entidade** (cor da tag/grupo via `:pt root style`); médicos usam `var(--p-primary-color)` (sem cor própria)
- **Header**: avatar quadrado/circular colorido + título com cor da entidade + sub com count
- **Toolbar**: search + count pill colorido
- **Estados**: loading (cor da entidade), erro (vermelho), empty (icon tinted), sem-resultado-de-busca
- **DataTable interna**: Paciente (avatar com iniciais primary-tinted + nome + email) / Telefone / Botão "Abrir" outlined
- **Click "Abrir"** → reusa `PatientCadastroDialog` com `:patient-id`
- **Sem footer "Fechar"** — o X do header é o único botão de fechar
- **X do header** estilizado como `.xx-close` (32×32, bg --m-bg-soft, border, hover bg-soft-hover) via `:pt="{ pcCloseButton: { root: { class: 'xx-pdlg-close-btn' } } }"` + CSS `:global()` (Dialog é teleportado pra body)
### ConversationDrawer redesign (estilo WhatsApp)
- **Header**: avatar circular 40×40 com iniciais + nome em destaque + sub (canal icon + número formatado mono)
- **Container de mensagens**: bg "papel de parede" (`color-mix` bege esverdeado WA + radial-gradient pattern de pontos)
- **Bolhas**:
- Inbound light `#ffffff` / dark `#202c33` — top-left zerado simulando tail
- Outbound light `#d9fdd3` / dark `#005c4b` — top-right zerado simulando tail
- Padding `6/10/18/10` (extra bottom pra meta)
- Border-radius 8px + sombra `0 1px 0.5px rgba(0,0,0,0.13)`
- Detecção dark via `:global(.p-dark) / html.dark / [data-theme="dark"]`
- **Meta** (HH:MM + status checks): `position: absolute` no canto inferior direito DENTRO do balão
- ✓ enviada / ✓✓ entregue / ✓✓ azul `#53bdeb` lida / ✗ vermelho falhou
- **Compose**:
- Botões emoji + templates à esquerda do input
- Textarea com `border-radius: 22px` (pill)
- Botão **Send circular 40×40** verde `#00a884` (cor send WA), translate-up no hover
### Bug fix: cores de tags/grupos no MelissaPacientes
`patientsRepository.listGroups()` e `listTags()` mapeiam `cor → color` (camelCase frontend-friendly). O template do MelissaPacientes lia `g.cor` / `t.cor` (PT-BR) em **20 lugares** — sempre `undefined` → fallback caía no cinza/hex hardcoded. Trocado pra `g.color` / `t.color` via `replace_all`. Outros consumers (PatientsCadastroPage) já usavam `.color` correto, não foram afetados.
---
## 🧪 ROTEIRO DE TESTE — passo a passo (7 fases, ~35 min)
## 🛠️ Sessões dedicadas pendentes
### Setup (1 min)
### A66 — Refactor `AgendaEventDialog` V2 (3 sub-sessões)
```powershell
Remove-Item -Recurse -Force node_modules\.vite -ErrorAction SilentlyContinue
npm run dev
```
**Estado**:
- ✅ Sub-sessão 1 (composables) — 5 composables + 265 testes, 495/495 suite passando, AgendaEventDialog 3522→2632 linhas (-25%)
- 🟡 Sub-sessão 2 (template V2) — esqueleto entregue 2026-05-05, **user não gostou do design**, aguarda feedback específico
- ⏳ Sub-sessão 3 (migração nos 9 consumers) — depende do V2 estabilizar
- Abre `http://localhost:5173` → faz login
- `/account/profile` → terceiro card **"Layout"** → confirma `Melissa` selecionado
- **DevTools console aberto** durante TODOS os testes (F12)
**Próxima ação**: user dá feedback design → eu itero V2.
❌ Se ver `Cannot read properties of null` → mata vite, limpa cache (`Remove-Item -Recurse -Force node_modules\.vite`), reinicia.
Perguntas em aberto:
- Estrutura: 3 zonas (PACIENTE/QUANDO/O QUÊ) tá errado? Prefere 2 zonas? 1 coluna scroll? Tabs?
- Hierarquia: hero PACIENTE muito grande/pequeno?
- Densidade: airy demais ou apertado demais?
- Chips de duração/scope/status: muito visuais?
- Mobile: já testou viewport pequeno?
- Referência visual: Win11? Cleaner? Mais como V1? Algum app?
---
### Fase 1 — Smoke (3 min)
## ⏭️ PRÓXIMOS PASSOS (sugestão)
| # | Passo | Esperado |
|---|---|---|
| 1.1 | Acessa `/melissa` | Resumo carrega: relógio gigante + saudação + cards. Console limpo. |
| 1.2 | Click no **ψ** (canto inf. esq.) | Menu Win11 abre. **Fundo escurece com blur sutil** (mudança nova) |
| 1.3 | Click fora do menu | Menu fecha |
### 1. Restore arquivados na `PatientsListPage.vue` (layout Rail)
---
A `PatientsListPage.vue` tem KPI "Arquivados" mas SEM botão Restaurar. Replicar o pattern da MelissaPacientes:
- Helper `isArquivado(p)` (case-insensitive)
- Botão condicional ↶ "Restaurar" baseado em `p.status === 'Arquivado'`
- Click → confirm → `restorePatient(id, { tenantId })` do mesmo repository → toast + refetch
- Toggle visual: ↶ undo primary quando arquivado / 🗑 trash vermelho quando ativo
### Fase 2 — Cadastro de paciente (5 min)
### 2. Decidir A66 V2 design
1. `/melissa/pacientes` → click **"+ Novo paciente"**
2. **Toggle Vertical/Abas** (topo da sidebar do dialog):
- Click **"Abas"** → tabs aparecem em cima do form (6 com cores) + nav vertical da sidebar some
- Painel ativo continua sendo mostrado
- Click **"Vertical"** → volta ao Accordion
3. **Painel "Clínico & origem"** (seção 2):
- Dropdown **Grupo** mostra nomes (era vazio antes)
- Multiselect **Tags** funciona
- Botão **+** ao lado de Grupo → abre dialog "Novo grupo"
- Botão **+** ao lado de Tag → abre dialog "Nova tag"
4. **Sticky / margin-top fantasma**:
- Expande painel grande, scroll pra baixo
- Coluna esquerda acompanha sticky
- **Sem espaço fantasma** entre topo e sidebar
5. **Refresh hard** (Ctrl+Shift+R) → reabre dialog → toggle Vertical/Abas **persiste**
Aguarda feedback. Sem feedback, posso:
- Tentar uma direção alternativa (ex: 1 coluna scroll mais minimalista)
- Comparar com referências externas (Outlook, Cal.com, Linear)
- Voltar pro V1 polido em vez de redesenhar
---
### 3. Outras Melissa Pages?
### Fase 3 — Dialogs dark/light (3 min)
Todas as 9 páginas tabulares Melissa já estão alinhadas ao blueprint:
- ✅ Cadastros Recebidos, Agendamentos Recebidos, Pacientes (Sprint E)
- ✅ Compromissos, Grupos, Tags, Médicos, Conversas, Recorrências (Sprint F)
1. `/account/profile` → tema **claro**
2. `/melissa/pacientes` → abre dialog (criar tag pelo + na sidebar)
- Header e footer com **cor levemente cinza** (var `--surface-ground`)
3. Troca pra **escuro** → reabre dialog
- Header e footer com **cor levemente preta** (mais escuro que body)
4. Repete em outros dialogs:
- Cadastro paciente
- Médicos (`/melissa/medicos` → editar)
- Convênio (cadastro rápido)
- Agenda quick add (`/melissa/agenda` → click numa célula vazia)
---
### Fase 4 — MelissaConfiguracoes (10 min) — **mais densa**
Acessa `/melissa/aparencia`
**Sidebar — confirma 6 grupos no Accordion:**
- Layout Melissa (aberto por default)
- Conta, Agenda, Financeiro, WhatsApp & Conversas, Comunicação, Empresa & Plataforma
**Grupo "Layout Melissa" — 4 inlines:**
| Sub-item | O que confere |
|---|---|
| Aparência | Segmented Tema, swatches Primary, swatches Surface, botão "Padrão" |
| Plano de fundo | Preview, upload de imagem, sliders |
| Relógio | Segmented 24h/12h |
| Cronômetro | Select de toque + botão "Testar" |
**Grupo "Conta" — 4 embedados:**
- Meu Perfil → ProfilePage com hero "Meu Perfil" no topo
- Meu Plano → TherapistMeuPlanoPage
- Meu Negócio → NegocioPage
- Segurança → SecurityPage
**Grupo "Agenda" — 3 embedados:** Agenda, Bloqueios, Agendador Online
**Grupo "Financeiro" — 5 embedados:** Pagamento, Precificação, Descontos, Exceções, Convênios
**Grupo "WhatsApp" — 9 embedados:** testa pelo menos 2-3 (ex: Templates, Créditos, Histórico)
**Grupo "Plataforma" — 3 embedados:** Empresa, Recursos Extras, Auditoria
**Em TODAS as embedadas, conferir:**
- **Hero contextual** sticky no topo do main, z-index acima de qq sticky interno
- Ícone + título + descrição corretos por seção
- **Padding adequado** — conteúdo não cola na borda
---
### Fase 5 — Atalhos MelissaMenu (5 min)
Click no **ψ** pra abrir menu:
**Footer (5 botões):**
| Botão | Esperado |
|---|---|
| Meu Perfil | Fecha menu, abre `/melissa/perfil` (embedado, **não** navega externo) |
| Meus Planos | `/melissa/plano` |
| **Meu Negócio** | `/melissa/negocio` (botão NOVO desta sprint) |
| Segurança | `/melissa/seguranca` |
| Modo escuro | Toggle dark/light |
**Categoria Configurações:**
- Configurações → grupo **"Layout Melissa"** → "Aparência e cronômetro" → vai pra `/melissa/aparencia`
**Backdrop:**
- Ao abrir menu, fundo escurece (rgba 0.25) + blur 2px
---
### Fase 6 — Onda 1 embedadas (8 min)
Pra cada URL: **hero contextual + conteúdo renderiza + botão X fecha + console limpo**
| URL | Como chegar pelo menu |
|---|---|
| `/melissa/financeiro` | ψ → Financeiro → Visão geral |
| `/melissa/financeiro-lancamentos` | ψ → Financeiro → Lançamentos |
| `/melissa/documentos` | ψ → Prontuários → Documentos |
| `/melissa/documentos-templates` | ψ → Prontuários → Templates de documentos |
| `/melissa/agendamentos-recebidos` | ψ → Agenda e Pacientes → Agendamentos recebidos |
| `/melissa/online-scheduling` | ψ → Agenda e Pacientes → Agendador online |
| `/melissa/relatorios` | ψ → Financeiro → Relatórios |
| `/melissa/notificacoes` | ψ → WhatsApp → Notificações enviadas |
| `/melissa/link-externo` | ψ → Agenda e Pacientes → Link externo de cadastro |
---
### Fase 7 — Surface picker (1 min)
1. `/melissa` → click **engrenagem** (canto sup. dir.)
2. Aparece **Cor primária** + **Surface** abaixo (8 swatches)
3. Click em diferentes surfaces → fundo de cards/dialogs muda
---
### Como reportar bugs (pra cada problema)
- **Fase + step** (ex: "Fase 4, Conta > Meu Plano")
- **O que aconteceu** (frase curta)
- **Console error** (stack se tiver)
- **Screenshot** se for visual
**Prioridade pra arrumar:**
1. 🔴 Crashes (página em branco, erro vermelho)
2. 🟠 Funcionalidade quebrada
3. 🟡 Polish visual
---
## ⏭️ PRÓXIMOS PASSOS (após testes)
**1. Bugs encontrados → arrumar pontual**
**2. Bug aberto pendente — A#37**
- "Erros ao salvar paciente + dialog não fecha"
- Botão maximizar já corrigido (sessão anterior)
- Falta erro específico do Leonardo (console + toast + Network)
- Hipóteses: RLS, NOT NULL constraint, validação silenciosa
**3. Commits**
- Decidir: 1 mega-commit ou chunks lógicos. Acumulado = 27 modificados + 11 novos.
- Sugestão de chunks (em ordem):
1. Sprint anterior (HANDOFF 2026-04-29) — 7 Melissa Pages + bugfixes Teleport
2. PatientsCadastroPage polish (toggle + bug Grupo + sticky)
3. Dialog blueprint dark/light (blueprint + 9 arquivos migrados)
4. Surface picker + provide melissaSettings
5. MelissaConfiguracoes (hub completo)
6. MelissaEmbed (Onda 1)
7. MelissaMenu (handlers internos + Onda 1 entries + blur)
**4. Onda 2/3 — decisão pendente**
- Onda 2: MelissaDocumentos nativa (~700 linhas glass card 2-col)
- Onda 3: MelissaFinanceiro nativa (~1000 linhas, dashboard cards Win11)
- Critério: se usar muito → vale nativa. Se ocasional → embedado tá bom.
- Decidir DEPOIS de testar a Onda 1 atual.
**5. Itens 🟢 baixa prioridade (talvez nunca)**
- Dashboards tradicionais (resumo já cobre)
- Setup Wizard (fluxo pontual)
- Upgrade pages
- Clinic admin specific (Features/Professionals — admin só)
Não há mais páginas pendentes do plano original.
---
## 📚 Tracking persistente
- **A#32** — Fase 5 router wire-up (ainda pendente, sessão dedicada)
- **A#33-A#36** — bugs resolvidos sessões anteriores (memória project_layout_melissa.md)
- **A#37** — Cadastro paciente erro ao salvar: ⚠️ INVESTIGAR (preciso erro específico)
- Memória atualizada: `project_layout_melissa.md` (sprint 2026-04-29 completa)
- Blueprints: `dialog-blueprint.md` (atualizado dark/light), `melissa-page-blueprint.md` (referência)
- **A66** — sub-sessão 2 (V2 design) aguardando feedback do user
- **Blueprint tabular Melissa** — referência canônica: `MelissaCadastrosRecebidos.vue`. Todas as 9 páginas alinhadas.
- **Restore pacientes** — implementado no Melissa; replicar no Rail (`PatientsListPage.vue`)
- **Migration aplicada local**: `20260504000001_fix_cancel_notifications_excluido.sql`. Já aplicada no DB local.
---
## 📦 Setup pra retomar
```bash
# Terminal 1 — Functions
supabase functions serve --no-verify-jwt --env-file supabase/functions/.env
```powershell
# Limpa cache do Vite (recomendado depois de muita mudança em styles)
Remove-Item -Recurse -Force node_modules\.vite -ErrorAction SilentlyContinue
# Terminal 2 — Vite
rm -rf node_modules/.vite # se cache estiver bagunçado
# Sobe dev
npm run dev
# Browser
http://localhost:5173/melissa
# Build sanity check (opcional, mas roda em ~25s)
npm run build
```
**Suite de testes** (495 testes incluindo o A66):
```powershell
npm run test
```
**Login**: user com `layout_variant=melissa` no profile pra testar
direto em `/melissa/...`. Pra testar Rail (regressão), troca em
`/account/profile` → terceiro card "Layout".
---
# HANDOFF — 2026-05-03 (sábado, sprint Melissa polishing + topbar parity + notif redesign)
Sessão longa. Working tree continua não-commitado (acumula com as duas anteriores
de 04-29 e 04-30). Dados de demo no DB (9 eventos hoje com price/billed) — limpar
antes de qualquer push pra outro ambiente.
## ✅ FEITO HOJE
### Melissa: timeline do resumo com paridade FullCalendar
- **Range derivado de `agenda_regras_semanais`** (regra do dia atual) com fallback
pra `agenda_configuracoes` (`agenda_custom_start/end`) → 0818h hardcoded.
Substitui as constantes `HORA_INICIO=8` / `HORA_FIM=20`.
- **Range expande pra fora do expediente** — sessões agendadas fora da jornada
(ex: 22h num dia 0818) ainda aparecem; a timeline cresce pra acomodar.
- **Badge "Folga" / "Feriado: <nome>"** no header — `isFolga` checa ausência de
rule pro `dia_semana`; `todayFeriado` busca em `agendaFeriados` (já exposto via
`useMelissaAgenda`). Não bloqueia, só sinaliza.
- **Scroll horizontal** com `min-width` por slot via CSS var `--m-tl-slot-w`
(default 80px); `--m-tl-cols` setada inline. Wrapper `.tl-h-scroll` com
scrollbar fina theme-aware.
- **Auto-scroll inicial pra "agora"** registrado dentro de `onMounted` (TDZ-safe);
watch `[HORA_INICIO, HORA_FIM]` com `_tlAutoScrolled` flag pra disparar uma
vez só. ResizeObserver mantém viewW/innerW frescos.
- **Eco lateral** — minimap pulsante de cores nas bordas. Faixa 8px com tracinhos
coloridos (cor do status) posicionados por tempo dentro da janela invisível.
Click anima scroll até o evento. Pulse 2.4s só quando há off-screen. Hover
expande tracinho `scaleX(2.2)` pra fora da faixa.
### Cards do resumo Melissa (todos funcionais)
- **Próximo paciente** — computed de `eventosHojeReais`, filtra sessao pendente,
ordem por startH, primeiro. "Em curso" pulsa avatar quando `hNow ∈ [start, end]`.
- **Recebíveis hoje** — `price + billed` do agenda_eventos. 3 estados: com valores
(barra de progresso), sem preço, sem sessões. `billed=true` é proxy pra "pago"
(fonte real seria `financial_records.status='paid'` — refinamento futuro).
- **WhatsApp** — novo composable `useMelissaWhatsapp` lendo `conversation_threads`
filtrando channel=whatsapp + unread_count>0. Limit 50, agregação local.
Fallback pra contact_number quando patient_name vazio.
- **Copilot** — virou `implementado: false` no catálogo; cai no placeholder
"Em breve" honesto (sem mock). Backend de IA não existe.
### Dock pinned (Agenda + WhatsApp)
- Adicionados ao `.melissa-dock` (que estava vazio, esperando uso). Ícones
rounded-square 44px (vs ψ full-circle 56px) — hierarquia auto-resolve.
- Active state primary-tinted quando seção correspondente aberta. Badge vermelho
no WhatsApp com `whatsappPendente.count` (99+ se passar).
- Hover: lift `-3px` + sombra crescendo (Win11 dock peek).
### Topbar Melissa (paridade com Rail)
- 3 botões trazidos do AppTopbar pro Melissa (canto sup. dir., flex row): plan
switcher DEV (DEV-only), notificações (com badge), ajuda. Cog continua o
rightmost.
- **Composable extraído**: `src/composables/useTopbarPlanMenu.js` encapsula
~250 linhas de plan-menu logic (resolveActiveSubscriptionContext, listPlans,
changePlanTo, etc). Reusável. AppTopbar inline NÃO foi refatorado (evita
risco de regressão — refactor opcional).
- `<NotificationDrawer />` montado em MelissaLayout (idem `<Toast />`).
- `<AjudaDrawer />` já é global em App.vue, só importei o toggle.
### Toast funcionando no Melissa
- Bug: rota `/melissa/:secao?` está em `routes.misc.js` fora do `AppLayout`
(que monta `<Toast />`). Resultado: toast.add() era silenciado em qualquer
page embedada.
- Fix: `<Toast />` adicionado no fim do template do MelissaLayout (PrimeVue
auto-import via PrimeVueResolver).
### Refresh da timeline ao salvar jornada
- Bug: `useMelissaAgenda.loadSettings()` rodava só no mount. Salvar jornada em
`/melissa/configuracoes/agenda` atualizava DB mas Melissa ficava com workRules
antigas até reload.
- Fix: `ConfiguracoesAgendaPage.saveJornada()` dispara
`window.dispatchEvent(new CustomEvent('agenda:settings-saved'))` após sucesso.
`useMelissaAgenda` registra listener em window, chama `loadSettings()` quando
ouve. Cleanup em `onBeforeUnmount`. Pattern espelha `app:session-refreshed`
já em uso (`main.js:134`).
### Profile: redirect ao trocar Melissa→Rail/Classic
- Bug: `saveAll()` em ProfilePage persistia `layout_variant` mas não navegava.
Usuário ficava preso em `/melissa` mesmo com flag mudado.
- Fix: depois do save bem-sucedido, se estamos em `/melissa` E variant ≠ melissa
→ toast "Aplicando layout" + `setTimeout(700ms, window.location.assign('/'))`.
Hard reload pra remontar a árvore com AppLayout. Caminho inverso (qualquer→melissa)
já era tratado em selectMelissa.
### Tratamento visual por status nas pílulas (Melissa)
- Helpers no MelissaLayout: `statusKey(ev)`, `statusIcon(ev)`, `isEvEmCurso(ev)`,
`pillStatusClass(ev)`. Aplicados na pílula horizontal e no `.vt-event` vertical.
- Tratamentos:
- **realizado** ✓ → glow verde sutil
- **faltou** ✗ → opacidade 0.78 + label tachado
- **cancelado** ⊘ → opacidade 0.6 + diagonal hatching 135° + label tachado
- **remarcar** 🔄 → ring âmbar 2px + glow âmbar
- **em-curso** → pulse 2.2s usando `--ev-color` (color-mix) — herda hue do bg
- `eventStyle()` agora também injeta `--ev-color` inline pra o pulse usar.
### Status updates: refetch fix
- Bug: status mudava no DB mas timeline visual não atualizava. `M.refetch()`
só refresh a Agenda (composable separado).
- Fix: capturei `refetch` do `useMelissaEventosHoje` no destructure e chamo
junto com `M.refetch()` em `updateEventoStatus`.
### Dialog do evento (MelissaEventoPanel) — botões
- **Blur XS** — `.evento-layer` baixou `blur(20px)` pra `blur(4px)` + saturate
110%. Overlay opacidade 0.5→0.32 (dark) / 0.18 (light). Resumo atrás continua
legível, só com leve "tilt-shift".
- **Editar** funciona — antes precisava de `_raw` que `useMelissaEventosHoje`
não inclui (select limitado). Agora busca `select('*')` do row antes de chamar
`M.onEditEvento(data)`. Toast de erro com detalhe se falhar.
- **Prontuário e Histórico** funcionam mesmo sem Agenda montada — novo helper
`_callOnAgenda(action)`: se `melissaAgendaRef.value` existe executa imediato,
senão enfileira em `_pendingAgendaAction` ref e abre seção Agenda. Watch em
`melissaAgendaRef` drena a queue quando o ref aparece.
### Notification drawer redesign
- **NotificationItem.vue** — repensado como card-item:
- `--type-rgb` injetado inline; uma var pinta ícone, spine, chips e actions
via color-mix em várias intensidades.
- Avatar 40px circular (iniciais OU ícone do tipo) com pulse dot 9px no
canto sup-direito quando não-lida.
- Spine vertical 3px à esquerda na cor do tipo (cresce no hover de 80% pra 100%).
- Card border-radius 10px + margin lateral; hover lifta 1px com shadow tinted.
- Header com type label uppercase + horário relativo. Title 2-line clamp,
detail 2-line clamp.
- Quick actions: chips type-colored, primária filled (deeplink), outline
(Conversa). Actions secundárias revelam no hover deslizando da direita.
- **NotificationDrawer.vue** — toolbar limpa + grupos por data:
- Width 380→420px. Header: título + count-pill ("3 não lidas").
- Browser-notif vira icon-btn com active state primary-tinted.
- Body com bg ground sutil. Toolbar sticky. Tabs em segmented control.
- Mark-all virou ghost button compacto.
- **Grupos por data**: Hoje / Ontem / Esta semana / Mais antigas (date-fns
`isToday`/`isYesterday`/`differenceInDays`). Cada grupo tem header
label-uppercase + linha + count.
- Empty state: círculo 72px primary-tinted com check-circle, copy refinada,
link "Ver tudo" se filtro=unread mas há itens.
- Footer: pílula com border (em vez de link plain), arrow scoot 2px no hover.
### TherapistDashboard (rail layout) — timeline parity
- Mesmas features do Melissa aplicadas inline no `TherapistDashboard.vue`
(cópia consciente, não extração — risco de regressão).
- `useAgendaSettings` + `useFeriados` importados; `loadFeriadosBase(tid)`
chamado em `load()` após resolver tid.
- `TL_START`/`TL_END` viraram computeds (eram constantes 7/20). Default
fallback 0720h (preserva range visual original do Dashboard, em vez
do 0818 do Melissa).
- Novo `timelineEventsRaw` evita dep circular: TL_START depende de
`timelineEventsRaw` (que NÃO depende de TL_START). `timelineEvents`
então adiciona positioning baseado em TL_START.
- Folga/feriado badge inline no header.
- Wrapper `.dash-tl-frame > .dash-tl-scroll > .dash-tl-inner`. Eco lateral
com `.dash-tl-eco`. CSS scoped ao componente — duplicação consciente do
Melissa até eventual extração pra `<DailyTimelineStrip />` compartilhado.
### Plan menu popover — ancoragem
- Bug: ao clicar no botão Plan-DEV no Melissa, popover abria no top-left
(sem âncora). Causas:
1. `planBtn.value?.$el` é a forma correta pra PrimeVue Button mas Melissa
usa `<button>` HTML cru, onde planBtn.value já É o DOM (sem `$el`).
2. `event.currentTarget` é null DEPOIS do `await loadPlanMenuData()` — DOM
event spec normal: após a microtask, ciclo de propagação acabou.
- Fix em `useTopbarPlanMenu.js`: captura âncora ANTES do await, com fallback
ampliado: `planBtn.value?.$el || planBtn.value || event?.currentTarget || event?.target`.
Suporta os dois tipos de trigger sem branch.
### Migration aplicada: status_evento_agenda + remarcado + confirmado
- Enum tinha só `{agendado, realizado, faltou, cancelado, remarcar}` mas o
código (`AgendaTerapeutaPage.vue:1339` — bloqueio por feriado) e o trigger
`fn_notify_agenda_status_change` (migration `20260423000009`) referenciam
`'remarcado'` e `'confirmado'`. Tentativas de save falhavam com
`invalid input value for enum status_evento_agenda`.
- Migration: `database-novo/migrations/20260503000001_status_evento_agenda_remarcado_confirmado.sql`.
Idempotente (ADD VALUE IF NOT EXISTS). Aplicada local.
- `NOTIFY pgrst, 'reload schema'` + restart do container `supabase_rest_*`
pra dropar cache do PostgREST.
- Distinção semântica preservada: `remarcar` (verbo, action label) ≠ `remarcado`
(state final, sessão remarcada efetivamente). `confirmado` = paciente
confirmou presença antes da sessão.
## 🗄️ DEMO DATA NO DB (LOCAL)
9 atendimentos populados em `agenda_eventos` pra hoje (2026-05-03), owner
`aaaaaaaa-...02`, tenant `bbbbbbbb-...02`. Mix de status (realizado/faltou/
cancelado/agendado), price R$ 200-280, billed em 3 (R$ 780 / R$ 1.680 = 46%).
Sessão demo "em curso" às 00:12 (John Bowlby) realinhada pra `NOW()±window`
pra preservar pulse.
**Ao virar o dia**, shift +1d com:
```sql
UPDATE agenda_eventos
SET inicio_em = inicio_em + interval '1 day',
fim_em = fim_em + interval '1 day'
WHERE owner_id = 'aaaaaaaa-0002-0002-0002-000000000002'
AND inicio_em >= (CURRENT_DATE - 1)::timestamp AT TIME ZONE 'America/Sao_Paulo'
AND inicio_em < (CURRENT_DATE)::timestamp AT TIME ZONE 'America/Sao_Paulo';
```
**Limpar tudo (antes de qualquer push)**:
```sql
DELETE FROM agenda_eventos
WHERE owner_id = 'aaaaaaaa-0002-0002-0002-000000000002'
AND id IN (
'6b3ed093-fe1c-48de-a0ad-9bb4ea4d4c00',
'136c2dcc-9da6-4fe8-bf24-3ca72d36a9e7',
'8a6386b1-53d6-4043-a80d-90b4d146446b',
'7002e899-f97e-4ca8-b3f2-826fc71c6e84',
'3c7f5a11-8684-4adf-9fa0-76906dd5cc3c',
'8f9f9ee1-3227-408c-9a97-91635a62078a',
'71623e8b-38a9-4b62-8743-1e66eea2ab73',
'b673d2db-335a-4c38-a939-9db1f9e400c6',
'5142ad19-0224-456f-9c41-18c19fafd067'
);
```
## 📂 ARQUIVOS MEXIDOS (SESSÃO 05-03)
**Modificados:**
```
M HANDOFF.md
M src/layout/melissa/MelissaLayout.vue ← massivo
M src/layout/melissa/MelissaEventoPanel.vue (blur XS)
M src/layout/melissa/composables/useMelissaEventos.js (price/billed/select)
M src/layout/melissa/composables/useMelissaAgenda.js (listener settings-saved)
M src/components/notifications/NotificationDrawer.vue (redesign)
M src/components/notifications/NotificationItem.vue (redesign)
M src/views/pages/therapist/TherapistDashboard.vue (timeline parity)
M src/layout/configuracoes/ConfiguracoesAgendaPage.vue (dispatch event)
M src/views/pages/account/ProfilePage.vue (post-save redirect)
```
**Novos:**
```
?? src/layout/melissa/composables/useMelissaWhatsapp.js
?? src/composables/useTopbarPlanMenu.js
?? database-novo/migrations/20260503000001_status_evento_agenda_remarcado_confirmado.sql
```
## ⚠️ EM ABERTO
- **Erro de enum persistente** — usuário relatou que erro de enum persiste
depois da migration + reload do schema. Não recebi o **valor literal**
rejeitado (devtools → network → resposta da API). Suspeitos: `tipo_evento_agenda`
(só tem `sessao`/`bloqueio`, mas código fala em `supervisao`/`reuniao`),
ou outro enum. **Próximo passo amanhã**: capturar o erro literal e decidir
fix (migration nova ou correção de código).
- **WhatsApp realtime no card** — MVP só faz fetch on mount. Pluggar
`useConversations` realtime channel (`conv_msg_tenant_<tid>` em
`conversation_messages` INSERT) pra atualizar count instantâneo.
- **Recebíveis usa proxy `billed`** — ideal seria query em `financial_records`
com `status='paid'` filtrado por reference_date hoje. Refinamento futuro.
- **`<DailyTimelineStrip />` extração** — Dashboard duplicou inline em vez
de extrair componente compartilhado. Quando aparecer 3º caller (admin
dashboard?), vale a refatoração.
- **AppTopbar plan logic ainda inline** — composable `useTopbarPlanMenu`
existe mas AppTopbar não foi refatorado (evita risco). Quando tocar nele
por outro motivo, dedupe.
- **Cleanup do demo data** — 9 eventos populados local. Não comitar nem
pushar antes de DELETE.
## 🎯 PRÓXIMOS PASSOS (sugestão)
1. Capturar erro literal de enum + corrigir.
2. Decidir commits acumulados (3 sprints sem commitar).
3. WhatsApp realtime (rápido — só pluggar o channel).
4. Avaliar extração `<DailyTimelineStrip />` se for tocar Dashboard de novo.
**Estado limpo, push pendente. Quando voltar, próximo passo natural é o feedback do A66 V2 ou o restore na PatientsListPage. Sua escolha.**
+30
View File
@@ -13,3 +13,33 @@ Chronological, append-only record of everything that's happened in this wiki.
**Quick access:** `grep "^## \[" log.md | tail -5` gives you the last 5 entries.
---
## [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);
refatorou MelissaCadastrosRecebidos pro padrão (DataTable + frozen action +
view toggle list/grade); criou MelissaAgendamentosRecebidos nativa (substituindo
embed); MelissaPacientes ganhou subheader, sombras, status pills coloridas,
email/phone colunas próprias, mobile pencil+popover, view toggle, fix scroll
mobile (`min-height: 0` em `.mp-list`), botão Restaurar pra arquivados.
Repository: `restorePatient` novo. PatientsCadastroPage statusOpts: +Arquivado.
A66 V2 — user não gostou design, aguarda feedback específico.
## [2026-05-06 12:00] session | MelissaCompromissos refator blueprint
Touched: none (aplicacao direta do blueprint existente - sem mudanca de wiki)
Detalhes: refator de MelissaCompromissos pro melissa-table-page-blueprint preservando o design do row (color stripe + name+badges + descricao + meta inline). DataTable com 3 colunas (Compromisso flex / Atividade 220px / Acoes frozen 140px com toggle+pencil+trash). Sidebar com 2 grupos de filtros: Status (Ativos verde / Inativos amber) e Tipo (Nativos blue / Meus accent), cada um com Limpar filtro proprio. Grid view com cards (color stripe 28px + toggle topo + footer com edit/trash). Subheader explicativo. View toggle persistido em mc.viewMode.v1. Removeu Popover de actions (drawer mobile cobre). Stats: Total/Ativos/Inativos/Tempo total. ESLint 0 errors. UI nao testada em browser ainda.
## [2026-05-06 14:00] session | Melissa 6 Pages blueprint + WhatsApp drawer + commits
Touched: none (sem nova pagina de wiki - aplicacao do blueprint existente)
Detalhes: Sprint F entregue. Blueprint tabular aplicado em MelissaCompromissos
(row design preservado), MelissaGrupos, MelissaTags, MelissaMedicos,
MelissaConversas, MelissaRecorrencias. Dialogs de criar/editar harmonizados
(FloatLabel + IconField + section dividers espelhando PatientsCadastroPage
Identidade). Dialogs "Pacientes do grupo/tag/medico" com cor primary nos
avatares de letras + X de fechar igual .xx-close. ConversationDrawer redesign
estilo WhatsApp (avatar primary, bg papel de parede, bolhas com tail simulada,
time/status overlay no canto inferior direito, compose pill + send circular
verde #00a884). Bug fix em MelissaPacientes: g.cor->g.color em 20 lugares
(repository devolve camelCase, template lia PT-BR e cores nao apareciam).
5 commits criados: 957e912, 6d9b36d, 269b531, 98f7252, 15103ed. Working tree
limpa. HANDOFF.md atualizado.
+812
View File
@@ -0,0 +1,812 @@
# Blueprint — Melissa Table Page
Padrão de página Melissa que apresenta uma **coleção tabular** (intake
requests, médicos, recorrências, compromissos, etc.) com 2 modos de
visualização (lista/grade), filtros laterais coloridos, busca, e
DataTable com paginação + coluna de ação fixa.
Validado em `src/layout/melissa/MelissaCadastrosRecebidos.vue`
(referência canônica). Estende o
[`melissa-page-blueprint.md`](./melissa-page-blueprint.md) — leia aquele
primeiro pra entender a estrutura macro (`.xx-page` / `.xx-body` /
`.xx-side` / `.xx-main`, drawer mobile, header).
---
## 1. Princípio
Página de coleção = **sidebar de filtros + coluna principal com
toolbar + visualização tabular**. O user controla:
- **Busca** (texto livre — nome / email / telefone / etc.)
- **Filtro de status** (mutualmente exclusivo, com botão "Limpar")
- **Modo de visualização** (lista densa via DataTable ou grade de cards)
- **Paginação** (10/20/50/100 por página)
A linha tem 1 ação primária visível (botão pencil) que abre um Dialog
com detalhes + ações secundárias (rejeitar, converter, etc.).
---
## 2. Estrutura do template
Segue a macro do `melissa-page-blueprint.md` (drawer + backdrop + page
+ header + body com aside Teleportada). Sobre essa base, esta blueprint
adiciona um **subheader explicativo** (logo abaixo do header, antes do
body) e a estrutura tabular dentro da `.xx-main`:
```vue
<section class="xx-page">
<header class="xx-page__head"></header>
<!-- Subheader explicativo 1 frase de contexto sobre o que essa
página faz, com palavras-chave em <strong>. Diferencia páginas
que têm layout idêntico (ex: Cadastros Recebidos vs.
Agendamentos Recebidos). -->
<div class="xx-subheader">
<i class="pi pi-info-circle xx-subheader__icon" />
<span class="xx-subheader__text">
Texto descritivo da página em 1-2 frases. Use
<strong>palavras-chave</strong> em negrito pra destacar as
ações disponíveis (autorize, recuse, converta, etc.).
</span>
</div>
<div class="xx-body">sidebar + main</div>
</section>
```
A diferença dentro da `.xx-main`:
```vue
<div class="xx-main">
<!-- A) Toolbar busca + view toggle -->
<div class="xx-toolbar">
<div class="xx-search">
<i class="pi pi-search xx-search__icon" />
<input v-model="busca" class="xx-search__input" placeholder="…" />
<button v-if="busca" class="xx-search__clear" @click="busca = ''">
<i class="pi pi-times" />
</button>
</div>
<div class="xx-view-toggle" role="group" aria-label="Visualização">
<button :class="{ 'is-active': viewMode === 'list' }" @click="setViewMode('list')">
<i class="pi pi-list" />
</button>
<button :class="{ 'is-active': viewMode === 'grid' }" @click="setViewMode('grid')">
<i class="pi pi-th-large" />
</button>
</div>
</div>
<!-- B) View Lista (DataTable) -->
<DataTable v-if="viewMode === 'list'" />
<!-- C) View Grade (cards em CSS grid + Paginator standalone) -->
<div v-else-if="viewMode === 'grid'" class="xx-grid-wrap">
<div class="xx-grid">
<div v-for="r in pagedItems" class="xx-grid__card" role="button" tabindex="0" @click="openDetails(r)"></div>
</div>
<Paginator class="xx-paginator" :rows="rowsXX" :first="firstXX" />
</div>
</div>
```
E na sidebar (`.xx-side`), ao invés de Hoje/Pacientes/Mini-cal, tem:
```vue
<aside class="xx-side">
<!-- Stats (4 contadores em grid 2x2) -->
<div class="xx-w xx-w--side">
<div class="xx-w__head">
<span class="xx-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
</div>
<div class="xx-stats">
<div v-for="s in stats" class="xx-stat" :class="`is-${s.cls}`">
<div class="xx-stat__val">{{ s.value }}</div>
<div class="xx-stat__lbl">{{ s.label }}</div>
</div>
</div>
</div>
<!-- Filtros (botões coloridos por status + Limpar filtro) -->
<div class="xx-w xx-w--side">
<div class="xx-w__head">
<span class="xx-w__title"><i class="pi pi-filter" /> Status</span>
<span v-if="statusFilter" class="xx-w__count">1</span>
</div>
<div class="xx-side__list">
<button
v-for="o in STATUS_FILTER_OPTIONS"
class="xx-side__item"
:class="[`is-${o.key}`, { 'is-active': statusFilter === o.key }]"
@click="toggleStatusFilter(o.key)"
>
<i :class="o.icon" /><span>{{ o.label }}</span>
</button>
<Transition name="xx-clear">
<button v-if="statusFilter" class="xx-side__item is-clear" @click="statusFilter = ''">
<i class="pi pi-filter-slash" /><span>Limpar filtro</span>
</button>
</Transition>
</div>
</div>
</aside>
```
---
## 3. Estado JS (script setup)
```js
// ── Filtros + busca ──
const busca = ref('');
const statusFilter = ref('');
function toggleStatusFilter(s) {
statusFilter.value = statusFilter.value === s ? '' : s;
}
// ── Computeds derivados ──
const stats = computed(() => {/* contadores por status */});
const filtered = computed(() => {/* aplica busca + statusFilter sobre rows */});
// ── Paginação compartilhada (DataTable + grid) ──
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const rowsXX = ref(10);
const firstXX = ref(0);
function onPage(event) {
firstXX.value = event.first;
rowsXX.value = event.rows;
}
watch([busca, statusFilter], () => { firstXX.value = 0; }); // reset à pg 1
// ── View mode persistido ──
const VIEW_MODE_KEY = 'xx.viewMode.v1';
const viewMode = ref('list');
try {
const saved = localStorage.getItem(VIEW_MODE_KEY);
if (saved === 'list' || saved === 'grid') viewMode.value = saved;
} catch (_) {}
function setViewMode(m) {
if (m !== 'list' && m !== 'grid') return;
viewMode.value = m;
try { localStorage.setItem(VIEW_MODE_KEY, m); } catch (_) {}
}
// ── Slice da grid (DataTable pagina internamente) ──
const pagedItems = computed(() =>
filtered.value.slice(firstXX.value, firstXX.value + rowsXX.value)
);
// ── Row click + ação ──
function onRowClick(event) { if (event?.data) openDetails(event.data); }
function rowStatusClass(data) { return statusClass(data?.status); }
```
---
## 4. DataTable (view Lista) — props canônicas
```vue
<DataTable
v-if="viewMode === 'list'"
:value="filtered"
:loading="loading"
dataKey="id"
paginator
:rows="rowsXX"
:first="firstXX"
:rowsPerPageOptions="PAGE_SIZE_OPTIONS"
paginatorTemplate="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
currentPageReportTemplate="{first}{last} de {totalRecords}"
:rowClass="rowStatusClass"
selectionMode="single"
scrollable
scrollHeight="flex"
tableStyle="min-width: 640px"
class="xx-table"
@row-click="onRowClick"
@page="onPage"
>
<Column header="Paciente" style="min-width: 220px">
<template #body="{ data }">avatar + nome + badge</template>
</Column>
<Column header="Contato" style="min-width: 220px">
<template #body="{ data }">email + tel</template>
</Column>
<Column header="Recebido" style="width: 130px">
<template #body="{ data }">tempo relativo</template>
</Column>
<!-- Coluna de ação fixa (frozen à direita) -->
<Column
header=""
:style="{ width: '60px', maxWidth: '60px', minWidth: '60px' }"
frozen
alignFrozen="right"
>
<template #body="{ data }">
<button class="xx-row__action" @click.stop="openDetails(data)">
<i class="pi pi-pencil" />
</button>
</template>
</Column>
<template #empty>empty state contextual</template>
<template #loading>spinner inline</template>
</DataTable>
```
### Props críticas explicadas
| Prop | Por quê |
|---|---|
| `:loading="loading"` | Overlay nativo do PrimeVue + slot `#loading` custom — substitui skeleton manual. |
| `paginator + :rows + :first + @page` | Paginator embutido controlado; `firstXX` permite resetar à página 1 quando filtros mudam. |
| `paginatorTemplate="RowsPerPageDropdown First… Last…"` | Ordem do exemplo PrimeVue 4: dropdown ANTES dos navegadores; CurrentPageReport no meio. |
| `currentPageReportTemplate="{first}{last} de {totalRecords}"` | i18n PT-BR. |
| `:rowClass="rowStatusClass"` | Aplica `is-new` / `is-done` / `is-rejected` no `<tr>` → border-left colorido via CSS deep. |
| `selectionMode="single"` | Marca visualmente a row selecionada; `@row-click` abre o dialog. |
| `scrollable + scrollHeight="flex"` | Tabela preenche o flex restante da `.xx-main` e scrolla internamente (vertical). |
| `tableStyle="min-width: 640px"` | Força scroll horizontal em mobile pra ativar a coluna frozen. |
| `dataKey="id"` | Identificação estável de rows pra seleção + reactive updates. |
### Coluna frozen — regras
- **Última `<Column>`** do template
- `frozen alignFrozen="right"` — fixa à direita
- `width: 60px, maxWidth: 60px, minWidth: 60px` — todas três pra evitar reflow durante scroll
- **`header=""`** vazio (icon do botão é auto-explicativo; tooltip cobre o resto)
- Botão interno usa **`@click.stop`** — sem isso, o row-click do DataTable também dispararia
---
## 5. View Grade (cards em CSS grid)
Quando `viewMode === 'grid'`, renderiza cards num grid responsivo com
Paginator standalone abaixo (compartilha state com a list view):
```vue
<div v-else-if="viewMode === 'grid'" class="xx-grid-wrap">
<div v-if="loading && filtered.length === 0" class="xx-grid__loading"></div>
<div v-else-if="filtered.length === 0" class="xx-empty"></div>
<div v-else class="xx-grid">
<div
v-for="r in pagedItems"
class="xx-grid__card"
:class="statusClass(r.status)"
role="button"
tabindex="0"
@click="openDetails(r)"
@keydown.enter.prevent="openDetails(r)"
@keydown.space.prevent="openDetails(r)"
>
<div class="xx-grid__top">
<span class="xx-card__avatar"></span>
<div class="xx-grid__top-right">
<span class="xx-card__badge" :class="statusClass(r.status)"></span>
<button class="xx-row__action" @click.stop="openDetails(r)">
<i class="pi pi-pencil" />
</button>
</div>
</div>
<div class="xx-grid__name"></div>
<div class="xx-grid__meta"></div>
<div class="xx-grid__time"></div>
</div>
</div>
<Paginator
v-if="filtered.length > 0"
class="xx-paginator"
:rows="rowsXX"
:totalRecords="filtered.length"
:first="firstXX"
:rowsPerPageOptions="PAGE_SIZE_OPTIONS"
template="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
currentPageReportTemplate="{first}{last} de {totalRecords}"
@page="onPage"
/>
</div>
```
### Por que `<div role="button">` em vez de `<button>`?
HTML não permite aninhar `<button>` em `<button>`. A grid card tem o
botão pencil interno, então o card precisa ser um `<div>` com
`role="button"`, `tabindex="0"` e handlers de teclado (`@keydown.enter`
+ `@keydown.space`) pra manter acessibilidade.
---
## 6. Tokens de surface (light/dark)
A consistência visual entre **header da tabela**, **coluna frozen**, e
**cards da sidebar** depende de usar o token certo:
| Elemento | Token | Light | Dark |
|---|---|---|---|
| `.xx-page` (background da página) | `var(--m-bg-medium)` | branco opaco | 88% opaco (glass) |
| `.xx-side` (sidebar) | `var(--m-bg-soft)` | surface-100 | 50% opaco |
| `.xx-w` (cards) | `var(--m-bg-medium)` | branco opaco | 88% opaco |
| `.xx-card` / `.xx-grid__card` (cards de linha) | `var(--m-bg-soft)` | surface-100 | 50% opaco |
| **Header da tabela** (`.p-datatable-thead > tr > th`) | **`var(--p-content-background)`** | **branco opaco** | **surface dark configurado** |
| **Coluna frozen** (header + body) | **`var(--p-content-background)`** | **branco opaco** | **surface dark configurado** |
| **Botão pencil** (bg) | **`var(--p-content-background)`** | **branco opaco** | **surface dark configurado** |
**Por que `--p-content-background` e não `--m-bg-medium`** pro frozen?
No dark mode `--m-bg-medium` tem 12% de transparência (efeito glass),
o que faz a coluna frozen vazar conteúdo de outras colunas durante
scroll horizontal. `--p-content-background` é 100% opaco em ambos os
modos e segue a config de surface do tema PrimeVue (token canônico de
"superfície de card").
---
## 7. Cores de status (semântica + paleta)
Tailwind 600 — fortes o bastante pra ler em ambos os modos:
| Status | Cor | RGB | Uso |
|---|---|---|---|
| Novo / Pendente | 🔵 azul | `rgb(37, 99, 235)` | item recém-chegado, ação requerida |
| Convertido / Concluído | 🟢 verde | `rgb(22, 163, 74)` | sucesso, finalizado |
| Rejeitado / Cancelado | 🔴 vermelho | `rgb(220, 38, 38)` | descartado, falha |
**Aplicação consistente** em 4 lugares por status:
1. **Stat value** (`.xx-stat.is-info / is-ok / is-danger`) — número colorido
2. **Filtro lateral** (`.xx-side__item.is-X`) — bg/border/ícone tinted (3 níveis: default 5% / hover 10% / active 16% + ring)
3. **Border-left da row** (`.xx-table tr.is-X`) — 3px sólido na cor
4. **Badge** (`.xx-card__badge.is-X`) — pill colorido no card/row
Variável `cls` no objeto stats:
```js
{ key: 'new', label: 'Novos', value: n, cls: n > 0 ? 'info' : 'neutral' },
{ key: 'converted', label: 'Convertidos', value: c, cls: 'ok' },
{ key: 'rejected', label: 'Rejeitados', value: r, cls: r > 0 ? 'danger' : 'neutral' },
```
**Não usar `is-warn`** (amarelo) pra "Novo" — semanticamente novo é
informativo, não alerta.
---
## 8. Filtro de status — botões + "Limpar filtro"
3 botões coloridos (Novo / Convertido / Rejeitado) + 4º botão
**"Limpar filtro"** que aparece com `<Transition name="xx-clear">`
quando algum filtro está ativo:
```css
.xx-side__item.is-clear {
margin-top: 4px;
background: var(--m-bg-soft);
border-color: var(--m-border);
color: var(--m-text-muted);
font-style: italic;
}
/* Fade + slide vertical + collapse de altura */
.xx-clear-enter-active,
.xx-clear-leave-active {
transition: opacity 220ms ease, transform 220ms ease,
max-height 220ms ease, margin-top 220ms ease;
overflow: hidden;
}
.xx-clear-enter-from,
.xx-clear-leave-to {
opacity: 0;
transform: translateY(-4px);
max-height: 0;
margin-top: 0;
}
.xx-clear-enter-to,
.xx-clear-leave-from {
opacity: 1;
transform: translateY(0);
max-height: 40px;
}
```
**Estilo neutro/itálico** (não colorido) pra distinguir dos 3 botões
de filtro coloridos. Ícone `pi pi-filter-slash`.
---
## 9. Subheader explicativo
Faixa estreita abaixo do `xx-page__head`, antes do `xx-body`. Tem 2
papéis:
1. **Diferenciar páginas** que têm o mesmo layout (Cadastros Recebidos
vs. Agendamentos Recebidos parecem visualmente idênticos sem isso)
2. **Resumir as ações** disponíveis pra reduzir cliques exploratórios
do user
```css
.xx-subheader {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 18px;
border-bottom: 1px solid var(--m-border);
background: var(--m-bg-soft);
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.45;
flex-shrink: 0;
}
.xx-subheader__icon {
color: var(--p-primary-color);
font-size: 0.92rem;
flex-shrink: 0;
margin-top: 1px;
}
.xx-subheader__text {
flex: 1;
min-width: 0;
}
.xx-subheader__text strong {
color: var(--m-text);
font-weight: 600;
}
```
### Convenção do texto
- 1-2 frases curtas (~20-30 palavras max)
- Inicia descrevendo a fonte/origem dos dados ("Solicitações vindas
de...", "Cadastros enviados por...")
- Termina enumerando as ações principais com `<strong>`
(`autorize`, `recuse`, `converta`)
- Tom direto, sem formalidade excessiva ("a gente cria o paciente
automaticamente" ✓ vs. "o sistema procederá com a criação" ✗)
- Ícone fixo: `pi pi-info-circle` em primary
### Exemplos validados
**Cadastros Recebidos:**
> Cadastros completos enviados por pacientes via formulário externo
> (link público). Revise os dados, **converta em paciente ativo** com
> 1 clique ou **rejeite** com motivo opcional.
**Agendamentos Recebidos:**
> Solicitações de horário vindas do agendador online à espera de ação.
> **Autorize** pra reservar o slot, **recuse** com motivo, ou
> **converta direto em sessão** — a gente cria o paciente
> automaticamente se ainda não existir.
---
## 10. Toolbar — busca + view toggle (no main column)
```css
.xx-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
}
.xx-search {
position: relative;
flex: 1;
min-width: 0;
}
.xx-search__input {
width: 100%;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
padding: 9px 36px 9px 34px; /* espaço pro ícone esq + clear dir */
border-radius: 10px;
}
.xx-search__icon { position: absolute; left: 12px; }
.xx-search__clear { position: absolute; right: 8px; }
/* Segmented control list/grid */
.xx-view-toggle {
flex-shrink: 0;
display: inline-flex;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 2px;
gap: 2px;
}
.xx-view-toggle__btn {
width: 32px; height: 32px;
background: transparent;
border: none;
border-radius: 8px;
}
.xx-view-toggle__btn.is-active {
background: var(--m-accent-soft);
color: var(--m-accent);
}
```
A **busca está no main column** (não na sidebar). Esta é a regra do
blueprint — sidebar só tem stats + filtros; busca + toggle ficam acima
da tabela.
---
## 11. DataTable — estilos de header, rows, paginator
```css
/* Wrapper que faz a DataTable ocupar o flex restante */
.xx-table {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.xx-table :deep(.p-datatable) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: transparent;
border: 1px solid var(--m-border);
border-radius: 10px;
overflow: hidden;
}
.xx-table :deep(.p-datatable-table-container) {
flex: 1;
min-height: 0;
background: transparent;
}
/* Header — totalmente transparente nos níveis externos, surface no <th> */
.xx-table :deep(.p-datatable-thead),
.xx-table :deep(.p-datatable-thead > tr) {
background: transparent !important;
}
.xx-table :deep(.p-datatable-thead > tr > th) {
background: var(--p-content-background) !important; /* canônico */
color: var(--m-text);
font-size: 0.78rem;
font-weight: 700; /* negrito */
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
}
/* Rows */
.xx-table :deep(.p-datatable-tbody > tr) {
background: transparent;
cursor: pointer;
border-left: 3px solid var(--m-border); /* default neutro */
transition: background-color 140ms ease;
}
.xx-table :deep(.p-datatable-tbody > tr > td) {
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
background: transparent;
vertical-align: middle;
}
.xx-table :deep(.p-datatable-tbody > tr:hover) { background: var(--m-bg-soft-hover); }
.xx-table :deep(.p-datatable-tbody > tr.p-datatable-row-selected) {
background: var(--m-accent-soft);
}
/* Border-left colorido por status — espelha .ma-sess do MelissaAgenda */
.xx-table :deep(tr.is-new) { border-left-color: rgb(37, 99, 235); }
.xx-table :deep(tr.is-done) { border-left-color: rgb(22, 163, 74); }
.xx-table :deep(tr.is-rejected) { border-left-color: rgb(220, 38, 38); opacity: 0.85; }
/* Coluna frozen — mesma surface do header */
.xx-table :deep(td.p-datatable-frozen-column),
.xx-table :deep(th.p-datatable-frozen-column) {
background: var(--p-content-background) !important;
box-shadow: -3px 0 6px -3px rgba(0, 0, 0, 0.18);
z-index: 1;
}
.xx-table :deep(.p-datatable-tbody > tr:hover td.p-datatable-frozen-column) {
background: var(--m-bg-soft-hover);
}
.xx-table :deep(.p-datatable-tbody > tr.p-datatable-row-selected td.p-datatable-frozen-column) {
background: var(--m-accent-soft);
}
/* Paginator integrado — centralizado, sem refresh à esquerda */
.xx-table :deep(.p-paginator) {
background: var(--m-bg-medium);
border: none;
border-top: 1px solid var(--m-border);
padding: 8px 12px;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
}
.xx-table :deep(.p-paginator-current) {
color: var(--m-text-muted);
font-size: 0.78rem;
background: transparent;
border: none;
}
.xx-table :deep(.p-paginator-page.p-paginator-page-selected) {
background: var(--m-accent-soft);
border-color: var(--m-accent-strong);
color: var(--m-accent);
}
/* Select de "rows per page" — bg transparente + label centralizado */
.xx-table :deep(.p-select) {
background: transparent;
border: 1px solid var(--m-border);
border-radius: 8px;
height: 30px;
display: inline-flex;
align-items: center;
}
.xx-table :deep(.p-select-label) {
padding: 0 8px;
color: var(--m-text);
font-size: 0.78rem;
display: flex;
align-items: center;
line-height: 1;
height: 100%;
background: transparent;
}
```
---
## 12. Botão de ação (pencil) — coluna fixa
```css
.xx-row__action {
width: 30px; height: 30px;
display: grid;
place-items: center;
background: var(--p-content-background); /* opaco — não vaza no scroll */
border: 1px solid color-mix(in srgb, var(--p-primary-color) 30%, var(--m-border));
color: var(--p-primary-color); /* primary do tema */
border-radius: 8px;
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.xx-row__action:hover {
background: color-mix(in srgb, var(--p-primary-color) 12%, var(--p-content-background));
border-color: var(--p-primary-color);
}
```
Reutilizável **na list view (dentro da coluna frozen)** e **na grid
view (dentro do `.xx-grid__top-right`)** — mesma classe, mesmo visual.
---
## 13. Empty / loading
Ambos via slot do DataTable + replicados na grid view:
```vue
<template #empty>
<div class="xx-empty">
<i class="pi pi-inbox xx-empty__icon" />
<div class="xx-empty__title">Nenhum cadastro encontrado</div>
<div class="xx-empty__hint">
<template v-if="busca || statusFilter">Ajuste os filtros pra ver mais.</template>
<template v-else> mensagem default contextual </template>
</div>
</div>
</template>
<template #loading>
<div class="xx-table__loading">
<i class="pi pi-spin pi-spinner" />
<span>Carregando</span>
</div>
</template>
```
```css
.xx-empty {
margin: 24px 0;
padding: 56px 28px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
color: var(--m-text-muted);
border: 2px dashed var(--m-border-strong);
border-radius: 12px;
background: color-mix(in srgb, var(--m-bg-soft) 40%, transparent);
gap: 8px;
}
.xx-empty__icon { font-size: 2rem; opacity: 0.6; }
.xx-empty__title { font-size: 0.92rem; font-weight: 600; }
.xx-empty__hint { font-size: 0.78rem; }
```
---
## 14. Mobile (<1024px)
A sidebar é Teleportada pro drawer (já documentado em
`melissa-page-blueprint.md`). Específico desta página:
```css
@media (max-width: 1023px) {
.xx-body { flex-direction: column; padding: 0; }
.xx-main { width: 100%; padding: 8px; }
.xx-page__title > span:first-of-type { display: none; }
.xx-menu-btn--mobile-only { display: inline-flex; }
/* IMPORTANTE: NÃO esconder colunas em mobile.
O scroll horizontal (via tableStyle min-width:640px) cuida
do overflow, e a coluna frozen "Ação" continua visível na
borda direita enquanto o user scrolla as outras. */
/* (sem display: none em qualquer th/td) */
/* Reset do bg/border-right da sidebar quando teleportada */
.xx-mobile-drawer__scroll .xx-side {
background: transparent;
border-right: none;
}
}
```
---
## 15. Acessibilidade
- `role="button" tabindex="0"` no card grid + `@keydown.enter.prevent` + `@keydown.space.prevent`
- `:focus-visible { outline: 2px solid var(--p-primary-color); outline-offset: 2px; }` nos cards
- `aria-label` em todos os icon-only buttons (pencil, view toggle, search clear)
- `v-tooltip` complementa visualmente (não substitui aria-label)
---
## 16. Checklist de adoção
Ao criar uma nova página tabular Melissa (ex: MelissaCompromissos):
- [ ] Renomeia `xx` → prefixo da página (`mco`, `mmd`, `mcv` etc.)
- [ ] Define `STATUS_FILTER_OPTIONS` com 3 keys/labels/icons
- [ ] Define `stats` computed retornando 4 itens (total + 3 status) com `cls` correto
- [ ] Implementa `filtered` computed (busca + statusFilter)
- [ ] Adiciona `rowsXX/firstXX/onPage` + watch reset
- [ ] Adiciona `viewMode` com persistência (`xx.viewMode.v1`)
- [ ] Adiciona `pagedItems` computed (slice pra grid)
- [ ] Adiciona `onRowClick + rowStatusClass`
- [ ] Adiciona `openDetails(r)` que abre o Dialog
- [ ] **Subheader explicativo** abaixo do `xx-page__head` (1-2 frases, fonte/origem + ações com `<strong>`, ícone `pi pi-info-circle`)
- [ ] Template: drawer + backdrop + page + header + **subheader** + body + sidebar (stats + filtros + clear) + main (toolbar + DataTable + grid)
- [ ] DataTable: `:loading + paginator + scrollable + scrollHeight="flex" + tableStyle="min-width: 640px"`
- [ ] Coluna frozen Ação: `width 60px + frozen alignFrozen="right"` + button pencil com `@click.stop`
- [ ] Grid card: `<div role="button" tabindex="0">` + handlers de teclado
- [ ] CSS: tokens `--p-content-background` em header, frozen, e botão pencil
- [ ] Mobile: NÃO esconder colunas; scroll horizontal via `tableStyle min-width`
---
## 17. Anti-patterns (NÃO fazer)
-**Busca na sidebar** — sempre no topo do main, ao lado do view toggle
-**`display: none` em colunas no mobile** — usar scroll horizontal + frozen
-**`<button>` envolvendo card no grid** — quebra HTML quando tem pencil interno; usar `<div role="button">`
-**`var(--m-bg-medium)` na coluna frozen no dark** — tem 12% transparência, vaza scroll. Usar `var(--p-content-background)`
-**`text-amber-300` Tailwind hardcoded** no ícone do header da página — usar `color: var(--p-primary-color)` via classe
-**`cls: 'warn'` pra "Novo"** — semanticamente errado (warn = aviso amarelo, novo = info azul)
-**Paginator com `#paginatorstart` slot duplicando refresh** — refresh já vive no header da página; centralizar o paginator (sem paginatorstart)
-**Skeleton manual + `carregandoInicial` na lista** — DataTable tem `:loading` nativo
-**`pageMCR + filteredPaginated` manual** — DataTable pagina internamente; só usa `firstXX/rowsXX` compartilhado
-**Border-left só em `is-new`** — todos os 3 status devem ter border-left colorido (consistência visual)
-**Misturar opacidade pesada (0.55, 0.75) com border colorido** — escolher uma estratégia; preferir border + opacidade leve (0.85 max)
---
## 18. Referência canônica
`src/layout/melissa/MelissaCadastrosRecebidos.vue` — implementação 1:1
deste blueprint. Quando dúvida, abrir esse arquivo lado-a-lado e
copiar o padrão exato (variáveis, ordem dos templates, tokens CSS).
Próximas adoções planejadas: `MelissaCompromissos`, `MelissaMedicos`,
`MelissaConversas`, `MelissaRecorrencias`, `MelissaTags`,
`MelissaGrupos` — todas seguem este blueprint.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22 -5
View File
@@ -132,7 +132,7 @@
"notification_templates", "notification_channels", "notification_preferences",
"notification_logs", "notification_schedules", "notification_queue",
"notifications", "notice_dismissals", "global_notices", "login_carousel_slides",
"twilio_subaccount_usage"
"twilio_subaccount_usage", "saas_twilio_config"
],
"CRM Conversas (WhatsApp)": [
"conversation_messages", "conversation_threads",
@@ -140,14 +140,30 @@
"conversation_tags", "conversation_thread_tags",
"conversation_optouts", "conversation_optout_keywords",
"conversation_autoreply_settings", "conversation_autoreply_log",
"session_reminder_settings", "session_reminder_logs"
"session_reminder_settings", "session_reminder_logs",
"conversation_assignments",
"conversation_bots", "conversation_bot_sessions",
"conversation_sla_rules", "conversation_sla_breaches",
"whatsapp_connection_incidents"
],
"Segurança / Rate limiting": [
"submission_rate_limits"
"Segurança / Auditoria": [
"submission_rate_limits",
"audit_logs",
"saas_security_config",
"math_challenges",
"patient_invite_attempts",
"public_submission_attempts"
],
"Central SaaS (docs/FAQ)": [
"saas_docs", "saas_doc_votos", "saas_faq", "saas_faq_itens"
],
"Dev / Tracking": [
"dev_auditoria_items", "dev_verificacoes_items", "dev_test_items",
"dev_roadmap_phases", "dev_roadmap_items",
"dev_competitors", "dev_competitor_features",
"dev_comparison_matrix", "dev_comparison_competitor_status",
"dev_generation_log"
],
"Estrutura / Calendário": [
"feriados"
]
@@ -163,8 +179,9 @@
"Documentos": "#0ea5e9",
"Comunicação / Notificações": "#fbbf24",
"CRM Conversas (WhatsApp)": "#25d366",
"Segurança / Rate limiting": "#ef4444",
"Segurança / Auditoria": "#ef4444",
"Central SaaS (docs/FAQ)": "#c084fc",
"Dev / Tracking": "#94a3b8",
"Estrutura / Calendário": "#fb923c"
},
"infrastructure": {
+4 -4
View File
@@ -305,7 +305,7 @@ function buildSB(){
</div>
<div class="sb-h" style="margin-top:8px">Domínios</div>\`;
for(const[d,ts]of Object.entries(D.domains)){
h+=\`<div class="sb-i \${dom===d?'active':''}" onclick="scrollToDomain(\`+JSON.stringify(d)+\`)">
h+=\`<div class="sb-i \${dom===d?'active':''}" onclick="scrollToDomain('\${D.slugs[d]}')">
<div class="sb-dot" style="background:\${gc(d)}"></div>\${escapeHtml(d)}
<span class="sb-c">\${ts.length}</span>
</div>\`;
@@ -349,7 +349,7 @@ function buildMN(){
<div class="dgrid">\`;
for(const[d,ts]of Object.entries(D.domains)){
const fks=ts.reduce((a,t)=>a+(D.tables[t]?.fks?.length||0),0);
h+=\`<div class="dc" style="--c:\${gc(d)}" onclick="scrollToDomain(\`+JSON.stringify(d)+\`)">
h+=\`<div class="dc" style="--c:\${gc(d)}" onclick="scrollToDomain('\${D.slugs[d]}')">
<div class="dc-n">\${escapeHtml(d)}</div>
<div class="dc-m"><span style="color:\${gc(d)}">\${ts.length}</span> tabelas · \${fks} FKs</div>
</div>\`;
@@ -420,7 +420,7 @@ function sel(d){
dom=d;view='overview';q='';document.getElementById('si').value='';
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
}
function scrollToDomain(d){
function scrollToDomain(slug){
// Sempre ir pra overview (com todos os domínios visíveis) antes de scrollar
const needRebuild=view!=='overview'||dom!==null||q;
if(needRebuild){
@@ -429,7 +429,7 @@ function scrollToDomain(d){
buildSB();buildMN();
}
setTimeout(()=>{
const el=document.getElementById('dom-'+(D.slugs[d]||''));
const el=document.getElementById('dom-'+slug);
if(el) el.scrollIntoView({behavior:'smooth',block:'start'});
}, needRebuild?80:0);
}
@@ -0,0 +1,34 @@
-- ==========================================================================
-- Agencia PSI — Fix: cancel_notifications_on_session_cancel referencia 'excluido'
-- ==========================================================================
-- A funcao trigger comparava NEW.status IN ('cancelado', 'excluido'), mas o
-- enum status_evento_agenda nunca teve o valor 'excluido'. Postgres precisa
-- fazer cast do literal pro tipo do enum, e o cast falha com:
--
-- invalid input value for enum status_evento_agenda: "excluido"
--
-- Isso quebrava QUALQUER UPDATE que mudasse status pra um valor != atual,
-- pois o IF tinha que avaliar a expressao com 'excluido'.
--
-- O front-end nunca usou 'excluido' (statusOptions em AgendaEventDialog.vue
-- so tem agendado/realizado/faltou/cancelado/remarcado). Delete e hard delete
-- via DELETE — nao tem soft-delete em agenda_eventos. Logo, 'excluido' eh
-- codigo morto e pode ser removido.
--
-- Refs:
-- - src/features/agenda/components/AgendaEventDialog.vue:1071 (statusOptions)
-- - schema/03_functions/_all.sql:1056 (funcao original)
-- ==========================================================================
CREATE OR REPLACE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
PERFORM public.cancel_patient_pending_notifications(
NEW.patient_id, NULL, NEW.id
);
END IF;
RETURN NEW;
END;
$$;
@@ -336,12 +336,19 @@ const KANBAN_COLUMNS = [
];
// Formatters
function fmtDateTime(iso) {
// HH:MM curto pra exibir dentro da bolha (estilo WhatsApp).
function fmtTimeOnly(iso) {
if (!iso) return '';
return new Date(iso).toLocaleString('pt-BR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
// Iniciais pro avatar circular do header.
function iniciaisChat(name) {
if (!name) return '?';
const parts = String(name).trim().split(/\s+/).filter(Boolean);
if (!parts.length) return '?';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function channelIcon(ch) {
@@ -699,11 +706,16 @@ function insertEmoji(emoji) {
<ConfirmDialog group="conversation-drawer" />
<Drawer v-model:visible="isOpen" position="right" class="!w-full md:!w-[520px]">
<template #header>
<div v-if="store.thread" class="flex items-center gap-2">
<i :class="['pi', channelIcon(store.thread.channel)]" />
<div class="flex flex-col">
<span class="font-semibold">{{ contactLabel() }}</span>
<span v-if="store.thread.contact_number" class="text-xs text-[var(--text-color-secondary)]">{{ store.thread.contact_number }}</span>
<div v-if="store.thread" class="flex items-center gap-3 min-w-0">
<span class="cd-avatar">
{{ iniciaisChat(contactLabel()) }}
</span>
<div class="flex flex-col min-w-0">
<span class="font-semibold truncate text-[0.95rem]">{{ contactLabel() }}</span>
<span v-if="store.thread.contact_number" class="text-[0.72rem] text-[var(--text-color-secondary)] flex items-center gap-1.5">
<i :class="['pi', channelIcon(store.thread.channel), 'text-[0.65rem]']" />
<span class="font-mono">{{ formatPhoneShort(store.thread.contact_number) }}</span>
</span>
</div>
</div>
</template>
@@ -1030,8 +1042,8 @@ function insertEmoji(emoji) {
</div>
</div>
<!-- Mensagens -->
<div ref="messagesContainerRef" class="flex-1 overflow-y-auto flex flex-col gap-2 p-1">
<!-- Mensagens container com bg WhatsApp-like -->
<div ref="messagesContainerRef" class="cd-msgs flex-1 overflow-y-auto flex flex-col gap-1.5 p-3">
<div v-if="store.loading" class="text-xs text-[var(--text-color-secondary)] text-center py-6">
<i class="pi pi-spin pi-spinner mr-2" />Carregando mensagens...
</div>
@@ -1042,12 +1054,12 @@ function insertEmoji(emoji) {
<div
v-for="m in store.messages"
:key="m.id"
class="flex flex-col gap-0.5"
:class="m.direction === 'inbound' ? 'items-start' : 'items-end'"
class="cd-bubble-wrap"
:class="m.direction === 'inbound' ? 'cd-bubble-wrap--in' : 'cd-bubble-wrap--out'"
>
<div
class="max-w-[85%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap break-words"
:class="m.direction === 'inbound' ? 'bg-[var(--surface-ground)] rounded-tl-none' : 'bg-emerald-500/10 text-emerald-900 dark:text-emerald-100 rounded-tr-none'"
class="cd-bubble"
:class="m.direction === 'inbound' ? 'cd-bubble--in' : 'cd-bubble--out'"
>
<template v-if="m.media_url">
<!-- Loading enquanto resolve signed URL -->
@@ -1061,7 +1073,7 @@ function insertEmoji(emoji) {
:preview="true"
alt="imagem"
imageClass="max-w-full rounded-md cursor-zoom-in"
class="mb-1 block"
class="mb-1 block cd-bubble__media"
/>
<audio
v-else-if="isAudio(m.media_mime)"
@@ -1074,7 +1086,7 @@ function insertEmoji(emoji) {
v-else-if="isVideo(m.media_mime)"
:src="mediaUrls[m.id]"
controls
class="max-w-full rounded-md mb-1"
class="max-w-full rounded-md mb-1 cd-bubble__media"
/>
<a
v-else
@@ -1088,18 +1100,19 @@ function insertEmoji(emoji) {
</a>
</template>
</template>
<div v-if="m.body">{{ m.body }}</div>
</div>
<div class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-75 px-1 flex items-center gap-1">
<span>{{ fmtDateTime(m.created_at) }}</span>
<span v-if="m.direction === 'outbound'" class="flex items-center">
<i v-if="m.delivery_status === 'read'" class="pi pi-check-double text-sky-500" v-tooltip.top="'Lida'" />
<i v-else-if="m.delivery_status === 'delivered'" class="pi pi-check-double" v-tooltip.top="'Entregue'" />
<i v-else-if="m.delivery_status === 'sent'" class="pi pi-check" v-tooltip.top="'Enviada'" />
<i v-else-if="m.delivery_status === 'failed'" class="pi pi-exclamation-circle text-red-500" v-tooltip.top="'Falhou'" />
</span>
<span v-if="m.direction === 'inbound' && !m.patient_id" class="italic">· número não vinculado</span>
<div v-if="m.body" class="cd-bubble__body">{{ m.body }}</div>
<!-- Time + status overlay no canto inferior direito (estilo WhatsApp) -->
<div class="cd-bubble__meta">
<span>{{ fmtTimeOnly(m.created_at) }}</span>
<span v-if="m.direction === 'outbound'" class="cd-bubble__status">
<i v-if="m.delivery_status === 'read'" class="pi pi-check-double cd-bubble__status--read" v-tooltip.top="'Lida'" />
<i v-else-if="m.delivery_status === 'delivered'" class="pi pi-check-double" v-tooltip.top="'Entregue'" />
<i v-else-if="m.delivery_status === 'sent'" class="pi pi-check" v-tooltip.top="'Enviada'" />
<i v-else-if="m.delivery_status === 'failed'" class="pi pi-exclamation-circle text-red-500" v-tooltip.top="'Falhou'" />
</span>
</div>
</div>
<span v-if="m.direction === 'inbound' && !m.patient_id" class="cd-bubble__unlinked">não vinculado</span>
</div>
</div>
@@ -1142,34 +1155,48 @@ function insertEmoji(emoji) {
</button>
</div>
<!-- Compose -->
<div v-if="store.thread.channel === 'whatsapp' && store.thread.contact_number" class="border-t border-[var(--surface-border)] pt-2 flex flex-col gap-2">
<div class="flex items-end gap-2">
<!-- Compose barra estilo WhatsApp (input pill + send circular) -->
<div v-if="store.thread.channel === 'whatsapp' && store.thread.contact_number" class="cd-compose">
<div class="cd-compose__row">
<button
class="cd-compose__icon-btn"
v-tooltip.top="'Emoji'"
@click="toggleEmojiPopover"
>
<i class="pi pi-face-smile" />
</button>
<button
class="cd-compose__icon-btn"
v-tooltip.top="'Templates'"
@click="openTemplatesPopover"
>
<i class="pi pi-bookmark" />
</button>
<Textarea
ref="composeTextareaRef"
v-model="composeText"
autoResize
rows="1"
placeholder="Digite sua mensagem... (Enter envia, Shift+Enter quebra linha)"
class="flex-1 !text-sm !resize-none"
placeholder="Digite uma mensagem"
class="cd-compose__input flex-1"
:disabled="store.sending"
:maxlength="4000"
@keydown="onComposeKeydown"
/>
<Button
icon="pi pi-send"
severity="success"
class="!w-10 !h-10 shrink-0"
:loading="store.sending"
<button
class="cd-compose__send"
:class="{ 'is-disabled': !composeText.trim() || store.sending }"
:disabled="!composeText.trim() || store.sending"
v-tooltip.top="'Enviar (Enter)'"
@click="sendMessage"
/>
>
<i v-if="store.sending" class="pi pi-spin pi-spinner" />
<i v-else class="pi pi-send" />
</button>
</div>
<div class="flex items-center gap-1 text-[var(--text-color-secondary)]">
<Button icon="pi pi-bookmark" text rounded size="small" class="!w-8 !h-8" v-tooltip.top="'Templates'" @click="openTemplatesPopover" />
<Button icon="pi pi-face-smile" text rounded size="small" class="!w-8 !h-8" v-tooltip.top="'Emoji'" @click="toggleEmojiPopover" />
<span class="ml-auto text-[0.65rem] opacity-60">{{ composeText.length }}/4000</span>
<div class="cd-compose__hint">
<span>Enter envia · Shift+Enter quebra linha</span>
<span class="ml-auto">{{ composeText.length }}/4000</span>
</div>
</div>
<div v-else class="text-[0.7rem] text-[var(--text-color-secondary)] italic text-center py-2 border-t border-[var(--surface-border)]">
@@ -1214,3 +1241,221 @@ function insertEmoji(emoji) {
</Popover>
</Drawer>
</template>
<style scoped>
/* ─── Avatar do header (iniciais sobre primary) ─── */
.cd-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--p-primary-color);
color: var(--p-primary-contrast-color, white);
font-weight: 700;
font-size: 0.85rem;
flex-shrink: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
/* ─── Container de mensagens (bg estilo "papel de parede" WhatsApp) ─── */
/* Light: bege esverdeado clássico do WA. Dark: cinza profundo tipo
wallpaper de modo escuro. Adapta via CSS variable `--p-content-background`
pra harmonizar com o tema do app. */
.cd-msgs {
background-color: color-mix(in srgb, var(--p-content-background) 85%, #efeae2);
background-image:
radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--p-text-color) 4%, transparent) 1px, transparent 0);
background-size: 18px 18px;
border-radius: 8px;
margin: 0 -2px;
}
/* ─── Bolha (wrapper + content + meta) ─── */
.cd-bubble-wrap {
display: flex;
flex-direction: column;
gap: 2px;
max-width: 80%;
}
.cd-bubble-wrap--in { align-self: flex-start; }
.cd-bubble-wrap--out { align-self: flex-end; align-items: flex-end; }
.cd-bubble {
position: relative;
padding: 6px 10px 18px 10px;
border-radius: 8px;
font-size: 0.88rem;
line-height: 1.42;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
box-shadow: 0 1px 0.5px rgba(0, 0, 0, 0.13);
min-width: 80px;
}
/* Inbound: surface-card adapta light/dark; canto top-left "achatado" vira o tail */
.cd-bubble--in {
background: var(--p-surface-0, #ffffff);
color: var(--p-text-color);
border-top-left-radius: 0;
}
:global(.p-dark) .cd-bubble--in,
:global(html.dark) .cd-bubble--in,
:global([data-theme="dark"]) .cd-bubble--in,
.cd-bubble--in:where(.p-dark *, html.dark *, [data-theme="dark"] *) {
background: #202c33;
color: rgb(233, 237, 239);
}
/* Outbound: verde claro WA (light) / verde escuro WA (dark) */
.cd-bubble--out {
background: #d9fdd3;
color: rgb(17, 27, 33);
border-top-right-radius: 0;
}
:global(.p-dark) .cd-bubble--out,
:global(html.dark) .cd-bubble--out,
:global([data-theme="dark"]) .cd-bubble--out,
.cd-bubble--out:where(.p-dark *, html.dark *, [data-theme="dark"] *) {
background: #005c4b;
color: rgb(233, 237, 239);
}
.cd-bubble__body {
margin-bottom: 2px;
}
.cd-bubble__media {
border-radius: 6px;
margin: -2px -4px 6px -4px;
max-width: calc(100% + 8px);
}
/* Meta (HH:MM + status checks) overlay no canto inferior direito */
.cd-bubble__meta {
position: absolute;
right: 8px;
bottom: 4px;
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.62rem;
font-weight: 500;
line-height: 1;
color: rgba(0, 0, 0, 0.45);
pointer-events: auto;
user-select: none;
}
.cd-bubble--in .cd-bubble__meta { color: rgba(0, 0, 0, 0.45); }
.cd-bubble--out .cd-bubble__meta { color: rgba(0, 0, 0, 0.55); }
:global(.p-dark) .cd-bubble--in .cd-bubble__meta,
:global(html.dark) .cd-bubble--in .cd-bubble__meta,
:global([data-theme="dark"]) .cd-bubble--in .cd-bubble__meta,
.cd-bubble--in:where(.p-dark *, html.dark *, [data-theme="dark"] *) .cd-bubble__meta {
color: rgba(255, 255, 255, 0.55);
}
:global(.p-dark) .cd-bubble--out .cd-bubble__meta,
:global(html.dark) .cd-bubble--out .cd-bubble__meta,
:global([data-theme="dark"]) .cd-bubble--out .cd-bubble__meta,
.cd-bubble--out:where(.p-dark *, html.dark *, [data-theme="dark"] *) .cd-bubble__meta {
color: rgba(233, 237, 239, 0.6);
}
.cd-bubble__status > i {
font-size: 0.78rem;
}
.cd-bubble__status--read {
color: rgb(83, 189, 235) !important; /* azul WA quando lido */
}
.cd-bubble__unlinked {
font-size: 0.62rem;
color: var(--p-text-muted-color, var(--text-color-secondary));
font-style: italic;
padding-left: 2px;
}
/* ─── Compose bar estilo WhatsApp ─── */
.cd-compose {
border-top: 1px solid var(--p-content-border-color, var(--surface-border));
padding: 8px 4px 6px;
background: color-mix(in srgb, var(--p-content-background) 80%, transparent);
}
.cd-compose__row {
display: flex;
align-items: flex-end;
gap: 6px;
}
.cd-compose__icon-btn {
width: 36px;
height: 36px;
display: grid;
place-items: center;
background: transparent;
border: none;
color: var(--p-text-muted-color);
border-radius: 50%;
cursor: pointer;
flex-shrink: 0;
transition: background-color 140ms ease, color 140ms ease;
}
.cd-compose__icon-btn:hover {
background: var(--p-content-hover-background, color-mix(in srgb, var(--p-text-color) 8%, transparent));
color: var(--p-text-color);
}
.cd-compose__icon-btn > i { font-size: 1rem; }
.cd-compose__input :deep(textarea),
.cd-compose__input.p-textarea {
border-radius: 22px !important;
background: var(--p-surface-0) !important;
border: 1px solid var(--p-content-border-color) !important;
padding: 9px 16px !important;
font-size: 0.88rem !important;
line-height: 1.4 !important;
resize: none !important;
min-height: 40px;
max-height: 120px;
transition: border-color 140ms ease, box-shadow 140ms ease;
}
.cd-compose__input :deep(textarea:focus) {
border-color: var(--p-primary-color) !important;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--p-primary-color) 22%, transparent) !important;
}
.cd-compose__send {
width: 40px;
height: 40px;
display: grid;
place-items: center;
background: #00a884; /* verde send do WA */
border: none;
color: white;
border-radius: 50%;
cursor: pointer;
flex-shrink: 0;
transition: background-color 140ms ease, transform 140ms ease, opacity 140ms ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
.cd-compose__send:hover:not(.is-disabled) {
background: #06cf9c;
transform: translateY(-1px);
}
.cd-compose__send.is-disabled,
.cd-compose__send:disabled {
opacity: 0.5;
cursor: not-allowed;
background: var(--p-content-hover-background);
color: var(--p-text-muted-color);
}
.cd-compose__send > i { font-size: 1rem; }
.cd-compose__hint {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px 0;
font-size: 0.62rem;
color: var(--p-text-muted-color);
opacity: 0.7;
}
</style>
@@ -16,7 +16,7 @@
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { isToday, isYesterday, differenceInDays } from 'date-fns';
import {
@@ -29,6 +29,7 @@ import NotificationItem from './NotificationItem.vue';
const store = useNotificationStore();
const router = useRouter();
const route = useRoute();
const toast = useToast();
const filter = ref('unread'); // 'unread' | 'all'
@@ -101,7 +102,10 @@ function handleArchive(id) {
}
function goToHistory() {
router.push('/therapist/notificacoes');
// Se o user está em /melissa/*, navega pra equivalente Melissa
// (preserva o overlay) senão cai na rota do role.
const target = route.path?.startsWith('/melissa') ? '/melissa/notificacoes' : '/therapist/notificacoes';
router.push(target);
store.drawerOpen = false;
}
</script>
@@ -16,7 +16,7 @@
-->
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { formatDistanceToNow } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { useNotificationStore } from '@/stores/notificationStore';
@@ -30,6 +30,7 @@ const props = defineProps({
const emit = defineEmits(['read', 'archive']);
const router = useRouter();
const route = useRoute();
const store = useNotificationStore();
const conversationDrawer = useConversationDrawerStore();
const tenantStore = useTenantStore();
@@ -52,6 +53,32 @@ const DEEPLINK_ALIASES = {
'/conversas': { therapist: '/therapist/conversas', clinic_admin: '/admin/conversas' }
};
// Mapeamento de slug "última parte da rota" seção Melissa equivalente.
// Usado quando o user está no layout Melissa (/melissa/*) pra evitar que
// uma notificação o jogue pro layout rail (rota do role) e perca o overlay.
// Slugs cobertos: páginas dedicadas + embeds (financeiro, documentos, etc).
const MELISSA_SLUG_MAP = {
agenda: 'agenda',
conversas: 'conversas',
patients: 'pacientes',
pacientes: 'pacientes',
medicos: 'medicos',
recorrencias: 'recorrencias',
compromissos: 'compromissos',
grupos: 'grupos',
tags: 'tags',
'cadastros-recebidos': 'cadastros-recebidos',
financeiro: 'financeiro',
'financeiro-lancamentos': 'financeiro-lancamentos',
documentos: 'documentos',
'documentos-templates': 'documentos-templates',
'agendamentos-recebidos': 'agendamentos-recebidos',
'online-scheduling': 'online-scheduling',
relatorios: 'relatorios',
notificacoes: 'notificacoes',
'link-externo': 'link-externo'
};
function resolveDeeplink(link) {
if (!link || typeof link !== 'string') return link;
const alias = DEEPLINK_ALIASES[link];
@@ -60,6 +87,22 @@ function resolveDeeplink(link) {
return alias[role] || alias.therapist;
}
// Se o user está em /melissa/*, traduz o destino pra equivalente Melissa
// quando possível. Pra rotas sem mapping conhecido, retorna o path original
// (vai navegar pro layout rail caso raro, geralmente é uma rota muito
// específica que não tem versão Melissa ainda).
function applyMelissaContext(path) {
if (!path || typeof path !== 'string') return path;
if (path.startsWith('/melissa')) return path; // já é Melissa
if (!route.path?.startsWith('/melissa')) return path; // user não está em Melissa
// Strip prefixos de role e pega o último segmento significativo
const cleaned = path.replace(/^\/(therapist|admin|clinic|crm)/, '');
const parts = cleaned.split('/').filter(Boolean);
if (!parts.length) return path;
const slug = MELISSA_SLUG_MAP[parts[parts.length - 1]] || MELISSA_SLUG_MAP[parts[0]];
return slug ? `/melissa/${slug}` : path;
}
const meta = computed(() => typeMap[props.item.type] || DEFAULT_TYPE);
const isUnread = computed(() => !props.item.read_at);
@@ -128,8 +171,9 @@ async function handleRowClick() {
}
}
// Fallback: segue deeplink resolvido por alias
const deeplink = resolveDeeplink(payload.deeplink);
// Fallback: segue deeplink resolvido por alias e (se user está em
// Melissa) traduzido pra equivalente do layout Melissa.
const deeplink = applyMelissaContext(resolveDeeplink(payload.deeplink));
if (deeplink) {
router.push(deeplink);
store.drawerOpen = false;
@@ -150,7 +194,7 @@ async function handleOpenConversation(e) {
function handleOpenDeeplink(e) {
e.stopPropagation();
const payload = props.item.payload || {};
const deeplink = resolveDeeplink(payload.deeplink);
const deeplink = applyMelissaContext(resolveDeeplink(payload.deeplink));
if (deeplink) {
router.push(deeplink);
store.drawerOpen = false;
+51 -3
View File
@@ -18,8 +18,15 @@
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { getFeriadosNacionais } from '@/utils/feriadosBR';
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
// opts.cache (opt-in): habilita stale-while-revalidate via melissaCacheStore.
// Default false pra preservar comportamento em páginas SaaS/admin que
// editam feriados (esperam invalidação imediata após criar/remover).
export function useFeriados(opts = {}) {
const useCache = !!opts.cache;
const cache = useCache ? useMelissaCacheStore() : null;
export function useFeriados() {
const ano = ref(new Date().getFullYear());
const loading = ref(false);
const municipais = ref([]); // linhas da tabela `feriados`
@@ -51,14 +58,53 @@ export function useFeriados() {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
async function _doFetch(tenantId, cacheKey) {
const { data, error } = await supabase
.from('feriados')
.select('*')
.or(`tenant_id.eq.${tenantId},tenant_id.is.null`)
.gte('data', `${ano.value}-01-01`)
.lte('data', `${ano.value}-12-31`)
.order('data');
if (error) throw error;
const rows = data || [];
if (cache && cacheKey) cache.set('feriados', rows, cacheKey);
municipais.value = rows;
return rows;
}
// ── Load municipais do Supabase ───────────────────────────
async function load(tenantId, year) {
if (year) ano.value = year;
if (!tenantId) return;
if (cache) {
const cacheKey = `${tenantId}:${ano.value}`;
const cached = cache.get('feriados', cacheKey, MELISSA_CACHE_TTL.feriados);
if (cached) {
municipais.value = cached;
_doFetch(tenantId, cacheKey).catch((e) => {
// eslint-disable-next-line no-console
console.warn('[useFeriados] revalidate', e);
});
return;
}
loading.value = true;
try { await _doFetch(tenantId, cacheKey); }
finally { loading.value = false; }
return;
}
// Comportamento legado (sem cache) — páginas de admin que editam.
loading.value = true;
try {
// Busca feriados do tenant + feriados globais (tenant_id null, cadastrados pelo SAAS)
const { data, error } = await supabase.from('feriados').select('*').or(`tenant_id.eq.${tenantId},tenant_id.is.null`).gte('data', `${ano.value}-01-01`).lte('data', `${ano.value}-12-31`).order('data');
const { data, error } = await supabase
.from('feriados')
.select('*')
.or(`tenant_id.eq.${tenantId},tenant_id.is.null`)
.gte('data', `${ano.value}-01-01`)
.lte('data', `${ano.value}-12-31`)
.order('data');
if (error) throw error;
municipais.value = data || [];
} finally {
@@ -71,6 +117,7 @@ export function useFeriados() {
const { data, error } = await supabase.from('feriados').insert(payload).select().single();
if (error) throw error;
municipais.value = [...municipais.value, data].sort((a, b) => a.data.localeCompare(b.data));
if (cache) cache.invalidate('feriados');
return data;
}
@@ -79,6 +126,7 @@ export function useFeriados() {
const { error } = await supabase.from('feriados').delete().eq('id', id);
if (error) throw error;
municipais.value = municipais.value.filter((f) => f.id !== id);
if (cache) cache.invalidate('feriados');
}
// ── Verificar duplicata ───────────────────────────────────
@@ -309,7 +309,6 @@ function buildFcOptions(ownerId) {
const nome = ext.paciente_nome || '';
const obs = ext.observacoes || '';
const title = arg.event.title || '';
const timeText = arg.timeText || '';
const pacienteStatus = ext.paciente_status || '';
const esc = (s) =>
@@ -326,21 +325,28 @@ function buildFcOptions(ownerId) {
return (p[0][0] + p[p.length - 1][0]).toUpperCase();
};
const fmtHour = (d) => {
if (!d) return '';
const h = d.getHours();
const m = d.getMinutes();
return m === 0 ? `${h}h` : `${h}:${String(m).padStart(2, '0')}h`;
};
const range = arg.event.start && arg.event.end ? `${fmtHour(arg.event.start)}-${fmtHour(arg.event.end)}` : arg.timeText || '';
const avatarHtml = avatarUrl ? `<img src="${esc(avatarUrl)}" class="ev-avatar ev-avatar-img" />` : nome ? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>` : '';
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : '';
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : '';
const titleLine = `<div class="ev-title"><span class="ev-name">${esc(title)}</span>${range ? ` <span class="ev-hour">(${esc(range)})</span>` : ''}</div>`;
const statusBadge =
pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado'
? `<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.4;margin-top:2px;">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
? `<span class="ev-badge">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
: '';
return {
html: `<div class="ev-custom">
${avatarHtml}
<div class="ev-body">
${timeHtml}
<div class="ev-title">${esc(title)}</div>
${titleLine}
${statusBadge}
${obsHtml}
</div>
@@ -475,20 +481,34 @@ function buildFcOptions(ownerId) {
flex: 1;
overflow: hidden;
}
.ev-time {
font-size: 10px;
opacity: 0.8;
.ev-title {
font-size: 11px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ev-title {
font-size: 11px;
.ev-name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.ev-hour {
font-weight: 400;
font-size: 10px;
opacity: 0.75;
margin-left: 2px;
}
.ev-badge {
display: inline-block;
background: #f97316;
color: #fff;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 1px 5px;
border-radius: 3px;
line-height: 1.4;
margin-top: 2px;
}
.ev-obs {
font-size: 10px;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/components/InsurancePlanQuickCreateDialog.vue
| Data: 2026-05-04
|
| Mini-dialog pra cadastrar convênio SEM sair do AgendaEventDialog.
| Mesmo pattern do ServiceQuickCreateDialog emite `created` com a row
| inserida, parent pré-seleciona.
|
| Campos mínimos (obrigatórios no schema):
| name, owner_id, tenant_id
| Opcionais:
| default_value (valor base sugerido), notes
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const props = defineProps({
modelValue: { type: Boolean, default: false },
ownerId: { type: String, default: '' },
initialName: { type: String, default: '' }
});
const emit = defineEmits(['update:modelValue', 'created']);
const toast = useToast();
const tenantStore = useTenantStore();
const visible = ref(props.modelValue);
watch(() => props.modelValue, (v) => { visible.value = v; });
watch(visible, (v) => emit('update:modelValue', v));
const form = ref({
name: '',
default_value: null,
notes: ''
});
const saving = ref(false);
watch(() => props.modelValue, (v) => {
if (v) {
form.value = {
name: props.initialName || '',
default_value: null,
notes: ''
};
}
});
const canSave = () => !!form.value.name?.trim();
async function onSave() {
if (!canSave()) return;
const ownerId = props.ownerId || (await supabase.auth.getUser()).data?.user?.id;
const tid = tenantStore.activeTenantId || tenantStore.tenantId;
if (!ownerId || !tid) {
toast.add({ severity: 'error', summary: 'Sem contexto', detail: 'Owner ou tenant ausentes.', life: 3500 });
return;
}
saving.value = true;
try {
const payload = {
owner_id: ownerId,
tenant_id: tid,
name: form.value.name.trim().slice(0, 120),
default_value: form.value.default_value != null ? Number(form.value.default_value) : null,
notes: form.value.notes?.trim().slice(0, 500) || null,
active: true
};
const { data, error } = await supabase.from('insurance_plans').insert(payload).select().single();
if (error) throw error;
toast.add({ severity: 'success', summary: 'Convênio criado', life: 2200 });
emit('created', data);
visible.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao criar convênio', detail: e?.message || 'Erro inesperado', life: 4000 });
} finally {
saving.value = false;
}
}
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
: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">
<InputText id="ins-name" v-model="form.name" class="w-full" autofocus maxlength="120" />
<label for="ins-name">Nome do convênio *</label>
</FloatLabel>
<FloatLabel variant="on">
<InputNumber
id="ins-value"
v-model="form.default_value"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:max="999999"
class="w-full"
/>
<label for="ins-value">Valor base sugerido (opcional)</label>
</FloatLabel>
<FloatLabel variant="on">
<Textarea id="ins-notes" v-model="form.notes" class="w-full" rows="2" autoResize maxlength="500" />
<label for="ins-notes">Observações (opcional)</label>
</FloatLabel>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="visible = false" />
<Button
label="Criar convênio"
icon="pi pi-check"
:loading="saving"
:disabled="!canSave()"
class="rounded-full"
@click="onSave"
/>
</template>
</Dialog>
</template>
@@ -0,0 +1,154 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/components/ServiceQuickCreateDialog.vue
| Data: 2026-05-04
|
| Mini-dialog pra cadastrar um serviço SEM sair do AgendaEventDialog.
| Pattern: usuário agendando, percebe que falta o serviço no catálogo,
| clica "+", preenche nome/duração/valor, salva o emit `created` retorna
| o id pra que o parent pré-selecione no select de serviços.
|
| Campos mínimos (obrigatórios no schema):
| name, price, owner_id, tenant_id
| Opcionais úteis:
| duration_min, description
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const props = defineProps({
modelValue: { type: Boolean, default: false },
ownerId: { type: String, default: '' },
initialName: { type: String, default: '' }
});
const emit = defineEmits(['update:modelValue', 'created']);
const toast = useToast();
const tenantStore = useTenantStore();
const visible = ref(props.modelValue);
watch(() => props.modelValue, (v) => { visible.value = v; });
watch(visible, (v) => emit('update:modelValue', v));
const form = ref({
name: '',
price: null,
duration_min: 50,
description: ''
});
const saving = ref(false);
watch(() => props.modelValue, (v) => {
if (v) {
form.value = {
name: props.initialName || '',
price: null,
duration_min: 50,
description: ''
};
}
});
const canSave = () => !!form.value.name?.trim() && form.value.price != null && Number(form.value.price) >= 0;
async function onSave() {
if (!canSave()) return;
const ownerId = props.ownerId || (await supabase.auth.getUser()).data?.user?.id;
const tid = tenantStore.activeTenantId || tenantStore.tenantId;
if (!ownerId || !tid) {
toast.add({ severity: 'error', summary: 'Sem contexto', detail: 'Owner ou tenant ausentes.', life: 3500 });
return;
}
saving.value = true;
try {
const payload = {
owner_id: ownerId,
tenant_id: tid,
name: form.value.name.trim().slice(0, 120),
price: Number(form.value.price),
duration_min: form.value.duration_min ? Number(form.value.duration_min) : null,
description: form.value.description?.trim().slice(0, 500) || null,
active: true
};
const { data, error } = await supabase.from('services').insert(payload).select().single();
if (error) throw error;
toast.add({ severity: 'success', summary: 'Serviço criado', life: 2200 });
emit('created', data);
visible.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao criar serviço', detail: e?.message || 'Erro inesperado', life: 4000 });
} finally {
saving.value = false;
}
}
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
: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">
<InputText id="svc-name" v-model="form.name" class="w-full" autofocus maxlength="120" />
<label for="svc-name">Nome do serviço *</label>
</FloatLabel>
<div class="grid grid-cols-2 gap-3">
<FloatLabel variant="on">
<InputNumber
id="svc-price"
v-model="form.price"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:max="999999"
class="w-full"
/>
<label for="svc-price">Valor *</label>
</FloatLabel>
<FloatLabel variant="on">
<InputNumber
id="svc-duration"
v-model="form.duration_min"
:min="0"
:max="600"
:step="5"
suffix=" min"
class="w-full"
/>
<label for="svc-duration">Duração</label>
</FloatLabel>
</div>
<FloatLabel variant="on">
<Textarea id="svc-desc" v-model="form.description" class="w-full" rows="2" autoResize maxlength="500" />
<label for="svc-desc">Descrição (opcional)</label>
</FloatLabel>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="visible = false" />
<Button
label="Criar serviço"
icon="pi pi-check"
:loading="saving"
:disabled="!canSave()"
class="rounded-full"
@click="onSave"
/>
</template>
</Dialog>
</template>
@@ -0,0 +1,399 @@
/**
* agendaEventHelpers.spec.js A66 sub-sessão 1A
*
* Cobertura dos helpers PUROS extraídos do AgendaEventDialog. Cada função
* é determinística (sem refs reativos, sem I/O) bateria foca em:
* - happy path (entrada típica)
* - edge cases (null/undefined/'')
* - boundaries (0min, 24h wrapping, descontos > subtotal)
* - matriz de inputs nos mappers de status
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
patientInitials,
fmtBRL,
fmtJornadaHora,
fmtDateBR,
fmtDateBRLong,
fmtTime,
fmtDuracao,
fmtSerieHora,
nomeDiaSemana,
fmtWeekdayShort,
fmtDayNum,
fmtMonthShort,
hhmmToMin,
minToHHMM,
isoToHHMM,
isPast,
isNativeSession,
isForaDoPlano,
addMinutesDate,
calcMinutes,
calcFinalPrice,
labelStatusSessao,
statusSeverity,
statusExtraClass
} from '../agendaEventHelpers';
describe('patientInitials', () => {
it('extrai 2 iniciais de nome composto', () => {
expect(patientInitials('Ana Souza Ferreira')).toBe('AF');
expect(patientInitials('joão silva')).toBe('JS');
});
it('faz slice 2 do único nome quando não há sobrenome', () => {
expect(patientInitials('Maria')).toBe('MA');
});
it('retorna ? quando vazio/null', () => {
expect(patientInitials('')).toBe('?');
expect(patientInitials(null)).toBe('?');
expect(patientInitials(undefined)).toBe('?');
expect(patientInitials(' ')).toBe('?');
});
it('lida com múltiplos espaços', () => {
expect(patientInitials(' ana maria ')).toBe('AM');
});
});
describe('fmtBRL', () => {
it('formata número como BRL', () => {
expect(fmtBRL(1234.56)).toMatch(/R\$\s?1\.234,56/);
expect(fmtBRL(0)).toMatch(/R\$\s?0,00/);
});
it('null/undefined → "—"', () => {
expect(fmtBRL(null)).toBe('—');
expect(fmtBRL(undefined)).toBe('—');
});
it('0 NÃO retorna "—" (é valor válido)', () => {
expect(fmtBRL(0)).not.toBe('—');
});
});
describe('fmtJornadaHora', () => {
it('hora cheia sem minutos', () => {
expect(fmtJornadaHora('09:00')).toBe('9h');
expect(fmtJornadaHora('14:00')).toBe('14h');
});
it('hora com minutos formata "Xh{MM}"', () => {
expect(fmtJornadaHora('14:30')).toBe('14h30');
expect(fmtJornadaHora('09:05')).toBe('9h05');
});
it('lida com null/string vazia (default 00:00)', () => {
expect(fmtJornadaHora(null)).toBe('0h');
expect(fmtJornadaHora('')).toBe('0h');
});
it('aceita "HH:MM:SS" truncando segundos', () => {
expect(fmtJornadaHora('14:30:00')).toBe('14h30');
});
});
describe('fmtDuracao', () => {
it('só horas', () => {
expect(fmtDuracao(60)).toBe('1h');
expect(fmtDuracao(120)).toBe('2h');
});
it('horas + minutos', () => {
expect(fmtDuracao(90)).toBe('1h 30min');
expect(fmtDuracao(135)).toBe('2h 15min');
});
it('só minutos', () => {
expect(fmtDuracao(45)).toBe('45min');
expect(fmtDuracao(15)).toBe('15min');
});
it('0/null/undefined → "—"', () => {
expect(fmtDuracao(0)).toBe('—');
expect(fmtDuracao(null)).toBe('—');
expect(fmtDuracao(undefined)).toBe('—');
});
});
describe('fmtSerieHora', () => {
it('trunca segundos da string TIME', () => {
expect(fmtSerieHora('14:30:00')).toBe('14:30');
expect(fmtSerieHora('09:00:45')).toBe('09:00');
});
it('mantém HH:MM se sem segundos', () => {
expect(fmtSerieHora('14:30')).toBe('14:30');
});
it('null/undefined → "—"', () => {
expect(fmtSerieHora(null)).toBe('—');
expect(fmtSerieHora('')).toBe('—');
});
});
describe('nomeDiaSemana', () => {
it('mapeia 0-6 corretamente', () => {
expect(nomeDiaSemana(0)).toBe('domingo');
expect(nomeDiaSemana(1)).toBe('segunda');
expect(nomeDiaSemana(6)).toBe('sábado');
});
it('aceita string numérica', () => {
expect(nomeDiaSemana('3')).toBe('quarta');
});
it('null/undefined → "domingo" (fallback 0)', () => {
expect(nomeDiaSemana(null)).toBe('domingo');
expect(nomeDiaSemana(undefined)).toBe('domingo');
});
});
describe('fmtTime', () => {
it('formata HH:MM de Date', () => {
const d = new Date('2026-05-15T14:30:00');
expect(fmtTime(d)).toMatch(/14:30/);
});
it('formata HH:MM de string ISO', () => {
expect(fmtTime('2026-05-15T09:05:00')).toMatch(/09:05/);
});
it('null/undefined → "—"', () => {
expect(fmtTime(null)).toBe('—');
expect(fmtTime(undefined)).toBe('—');
});
});
describe('hhmmToMin', () => {
it('converte HH:MM em minutos do dia', () => {
expect(hhmmToMin('00:00')).toBe(0);
expect(hhmmToMin('01:30')).toBe(90);
expect(hhmmToMin('14:00')).toBe(840);
expect(hhmmToMin('23:59')).toBe(1439);
});
it('aceita HH:MM:SS truncando', () => {
expect(hhmmToMin('14:30:00')).toBe(870);
});
it('null/string vazia → 0', () => {
expect(hhmmToMin(null)).toBe(0);
expect(hhmmToMin('')).toBe(0);
});
});
describe('minToHHMM', () => {
it('converte minutos em HH:MM zero-padded', () => {
expect(minToHHMM(0)).toBe('00:00');
expect(minToHHMM(90)).toBe('01:30');
expect(minToHHMM(840)).toBe('14:00');
});
it('faz wrapping em 24h', () => {
expect(minToHHMM(1440)).toBe('00:00');
expect(minToHHMM(1500)).toBe('01:00');
});
// Round-trip pra cobrir simetria das duas funções
it('round-trip hhmmToMin → minToHHMM preserva valor', () => {
const inputs = ['00:00', '08:15', '14:30', '23:59'];
for (const h of inputs) {
expect(minToHHMM(hhmmToMin(h))).toBe(h);
}
});
});
describe('isoToHHMM', () => {
it('lê dígitos diretos de ISO sem timezone', () => {
expect(isoToHHMM('2026-05-15T14:30:00')).toBe('14:30');
expect(isoToHHMM('2026-05-15T09:05:30')).toBe('09:05');
});
it('null/undefined → null', () => {
expect(isoToHHMM(null)).toBe(null);
expect(isoToHHMM('')).toBe(null);
});
// ISO com Z/offset depende do timezone do sistema executando o teste,
// então só verificamos que volta uma string HH:MM válida.
it('ISO com Z retorna formato HH:MM', () => {
const result = isoToHHMM('2026-05-15T14:30:00Z');
expect(result).toMatch(/^\d{2}:\d{2}$/);
});
});
describe('isPast', () => {
let mockNow;
beforeEach(() => {
mockNow = new Date('2026-05-15T12:00:00').getTime();
vi.useFakeTimers();
vi.setSystemTime(mockNow);
});
afterEach(() => vi.useRealTimers());
it('passado → true', () => {
expect(isPast('2026-05-14T12:00:00')).toBe(true);
expect(isPast('2025-01-01')).toBe(true);
});
it('futuro → false', () => {
expect(isPast('2026-05-16T12:00:00')).toBe(false);
expect(isPast('2030-01-01')).toBe(false);
});
it('null/undefined → false', () => {
expect(isPast(null)).toBe(false);
expect(isPast(undefined)).toBe(false);
expect(isPast('')).toBe(false);
});
});
describe('isNativeSession', () => {
it('native_key="session" → true (qualquer case)', () => {
expect(isNativeSession({ native_key: 'session' })).toBe(true);
expect(isNativeSession({ native_key: 'SESSION' })).toBe(true);
expect(isNativeSession({ native_key: 'Session' })).toBe(true);
});
it('outro native_key → false', () => {
expect(isNativeSession({ native_key: 'meeting' })).toBe(false);
expect(isNativeSession({ native_key: '' })).toBe(false);
expect(isNativeSession({})).toBe(false);
});
it('null/undefined → false', () => {
expect(isNativeSession(null)).toBe(false);
expect(isNativeSession(undefined)).toBe(false);
});
});
describe('isForaDoPlano', () => {
it('limite null → tudo dentro do plano', () => {
expect(isForaDoPlano('2030-01-01', null)).toBe(false);
expect(isForaDoPlano('2030-01-01', undefined)).toBe(false);
});
it('data > limite → fora', () => {
expect(isForaDoPlano('2026-12-31', '2026-06-30')).toBe(true);
});
it('data < limite → dentro', () => {
expect(isForaDoPlano('2026-01-15', '2026-06-30')).toBe(false);
});
it('data == limite → dentro (operador é > e não >=)', () => {
expect(isForaDoPlano('2026-06-30', '2026-06-30')).toBe(false);
});
});
describe('addMinutesDate', () => {
it('soma minutos retornando NOVA data', () => {
const base = new Date('2026-05-15T10:00:00');
const result = addMinutesDate(base, 90);
expect(result.getHours()).toBe(11);
expect(result.getMinutes()).toBe(30);
// Não muta o original
expect(base.getHours()).toBe(10);
});
it('subtrai com negativos', () => {
const base = new Date('2026-05-15T10:00:00');
const result = addMinutesDate(base, -30);
expect(result.getHours()).toBe(9);
expect(result.getMinutes()).toBe(30);
});
it('aceita string ISO', () => {
const result = addMinutesDate('2026-05-15T10:00:00', 30);
expect(result.getMinutes()).toBe(30);
});
it('null minutos → mesma data (0)', () => {
const base = new Date('2026-05-15T10:00:00');
const result = addMinutesDate(base, null);
expect(result.getTime()).toBe(base.getTime());
});
});
describe('calcMinutes', () => {
it('diff positivo em minutos', () => {
expect(calcMinutes('2026-05-15T10:00:00', '2026-05-15T11:30:00')).toBe(90);
expect(calcMinutes('2026-05-15T10:00:00', '2026-05-15T10:50:00')).toBe(50);
});
it('diff negativo (b < a) → 0 (clamp)', () => {
expect(calcMinutes('2026-05-15T11:00:00', '2026-05-15T10:00:00')).toBe(0);
});
it('null/undefined → null', () => {
expect(calcMinutes(null, '2026-05-15T10:00:00')).toBe(null);
expect(calcMinutes('2026-05-15T10:00:00', null)).toBe(null);
expect(calcMinutes(null, null)).toBe(null);
});
it('mesma data → 0', () => {
expect(calcMinutes('2026-05-15T10:00:00', '2026-05-15T10:00:00')).toBe(0);
});
});
describe('calcFinalPrice', () => {
it('sem descontos = subtotal', () => {
expect(calcFinalPrice(100, 1, 0, 0)).toBe(100);
expect(calcFinalPrice(50, 3, 0, 0)).toBe(150);
expect(calcFinalPrice(100, 1, null, null)).toBe(100);
});
it('aplica desconto percentual', () => {
expect(calcFinalPrice(100, 1, 10, 0)).toBe(90);
expect(calcFinalPrice(200, 2, 25, 0)).toBe(300); // 400 - 25%
});
it('aplica desconto flat', () => {
expect(calcFinalPrice(100, 1, 0, 20)).toBe(80);
});
it('combina percentual + flat', () => {
// 100 - 10% - 5 = 100 - 10 - 5 = 85
expect(calcFinalPrice(100, 1, 10, 5)).toBe(85);
});
it('descontos > subtotal → 0 (não negativo)', () => {
expect(calcFinalPrice(100, 1, 0, 200)).toBe(0);
expect(calcFinalPrice(100, 1, 110, 0)).toBe(0);
});
});
describe('labelStatusSessao', () => {
const cases = [
['agendado', 'Agendado'],
['realizado', 'Realizado'],
['faltou', 'Faltou'],
['cancelado', 'Cancelado'],
['remarcado', 'Remarcado']
];
it.each(cases)('"%s" → "%s"', (status, expected) => {
expect(labelStatusSessao(status)).toBe(expected);
});
it('desconhecido → "—"', () => {
expect(labelStatusSessao('blablabla')).toBe('—');
expect(labelStatusSessao(null)).toBe('—');
expect(labelStatusSessao('')).toBe('—');
});
});
describe('statusSeverity', () => {
const cases = [
['agendado', 'info'],
['realizado', 'success'],
['faltou', 'warn'],
['cancelado', 'danger'],
['remarcado', 'secondary']
];
it.each(cases)('"%s" → "%s"', (status, expected) => {
expect(statusSeverity(status)).toBe(expected);
});
it('desconhecido → "secondary" (fallback)', () => {
expect(statusSeverity('blablabla')).toBe('secondary');
expect(statusSeverity(null)).toBe('secondary');
});
});
describe('statusExtraClass', () => {
it('remarcado → "tag-remarcado"', () => {
expect(statusExtraClass('remarcado')).toBe('tag-remarcado');
});
it('outros → ""', () => {
expect(statusExtraClass('agendado')).toBe('');
expect(statusExtraClass('realizado')).toBe('');
expect(statusExtraClass(null)).toBe('');
});
});
describe('fmt date helpers (Date input)', () => {
const d = new Date('2026-05-15T14:30:00');
it('fmtDateBR retorna formato dd-mes-aaaa pt-BR', () => {
const result = fmtDateBR(d);
expect(result).toMatch(/15.*mai.*2026/i);
});
it('fmtDateBRLong inclui weekday', () => {
const result = fmtDateBRLong(d);
expect(result).toMatch(/15.*mai/i);
// Pelo menos contém algum dia da semana
expect(result).toMatch(/dom|seg|ter|qua|qui|sex|s[áa]b/i);
});
it('fmtWeekdayShort retorna 3 chars', () => {
const r = fmtWeekdayShort('2026-05-15T14:30:00');
expect(r.length).toBeLessThanOrEqual(3);
});
it('fmtDayNum extrai dia do mês', () => {
expect(fmtDayNum('2026-05-15T14:30:00')).toBe(15);
});
it('fmtMonthShort sem ponto final', () => {
const r = fmtMonthShort('2026-05-15T14:30:00');
expect(r).not.toContain('.');
});
});
@@ -0,0 +1,548 @@
/**
* useAgendaEventActions.spec.js A66 sub-sessão 1C-i
*
* Foco: handlers de save/delete e helpers puros de payload.
* Os watchers (status confirm, billingType, samePatientConflict) são
* testados indiretamente via setup do composable + mutações no form.
*
* Mock estratégia:
* - useToast/useConfirm: capturamos as chamadas em arrays/callbacks
* - supabase: mock o builder chain pra retornar { data, error }
* - composer: criamos manualmente um objeto com refs computeds que
* espelham o contrato esperado (não rodamos o composer real pra
* isolar o teste nas actions)
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ref, computed } from 'vue';
// ── Mocks de dependências ───────────────────────────────────────────
const toastAdd = vi.fn();
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: toastAdd })
}));
let _confirmAccept = null;
let _confirmReject = null;
let _confirmCalls = [];
const confirmRequire = vi.fn((opts) => {
_confirmCalls.push(opts);
_confirmAccept = opts.accept;
_confirmReject = opts.reject;
});
vi.mock('primevue/useconfirm', () => ({
useConfirm: () => ({ require: confirmRequire })
}));
const supabaseUpdateMock = vi.fn();
const supabaseSelectMock = vi.fn();
vi.mock('@/lib/supabase/client', () => ({
supabase: {
from: vi.fn(() => ({
update: (...args) => {
supabaseUpdateMock(...args);
return {
eq: () => ({
select: () => ({
single: () => Promise.resolve({ data: { id: 'evt-1', status: 'cancelado' }, error: null })
})
})
};
},
select: (...args) => {
supabaseSelectMock(...args);
return {
eq: () => ({
gte: () => ({
lt: () => ({
limit: () => ({
neq: () => ({ maybeSingle: () => Promise.resolve({ data: null }) }),
maybeSingle: () => Promise.resolve({ data: null })
})
})
})
})
};
}
}))
}
}));
const { useAgendaEventActions } = await import('../useAgendaEventActions.js');
// ── Helper: composer fake com o contrato mínimo ─────────────────────
function makeComposer(overrides = {}) {
const form = ref({
id: null,
owner_id: 'owner-1',
terapeuta_id: null,
paciente_id: null,
paciente_nome: '',
paciente_status: '',
commitment_id: 'c-1',
titulo_custom: '',
status: 'agendado',
observacoes: '',
dia: new Date('2026-05-15T00:00:00'),
startTime: '14:00',
duracaoMin: 50,
modalidade: 'presencial',
extra_fields: {},
price: null,
insurance_plan_id: null,
insurance_guide_number: null,
insurance_value: null,
insurance_plan_service_id: null,
...(overrides.formExtra || {})
});
const inicioDateTime = computed(() => {
if (!form.value.dia || !form.value.startTime) return null;
const d = new Date(form.value.dia);
const [h, m] = form.value.startTime.split(':').map(Number);
d.setHours(h, m, 0, 0);
return d;
});
const fimDateTime = computed(() => {
if (!inicioDateTime.value) return null;
const d = new Date(inicioDateTime.value);
d.setMinutes(d.getMinutes() + (form.value.duracaoMin || 50));
return d;
});
return {
form,
canSave: ref(true),
timeConflict: ref(null),
isEdit: ref(false),
isSessionEvent: ref(true),
requiresPatient: ref(false),
hasSerie: ref(false),
billingType: ref('particular'),
recorrenciaType: ref('avulsa'),
diaSemanaRecorrencia: ref(0),
diasSelecionados: ref([]),
dataFimCalculada: ref(null),
qtdSessoesEfetiva: ref(4),
ocorrenciasComConflito: ref([]),
editScope: ref('somente_este'),
editScopeOptions: ref([
{ value: 'somente_este', label: 'Somente esta' },
{ value: 'todos', label: 'Todas' }
]),
visible: ref(true),
inicioDateTime,
fimDateTime,
computedTitulo: ref('Sessão'),
...overrides
};
}
function setup(composerOverrides = {}, propsOverrides = {}) {
toastAdd.mockClear();
confirmRequire.mockClear();
_confirmCalls = [];
_confirmAccept = null;
_confirmReject = null;
const composer = makeComposer(composerOverrides);
const commitmentItems = ref([]);
const servicePickerSel = ref(null);
const selectedPlanService = ref(null);
const saveCommitmentItems = vi.fn().mockResolvedValue();
const props = { eventRow: null, ...propsOverrides };
const emitted = [];
const emit = (...args) => emitted.push(args);
const actions = useAgendaEventActions({
composer,
commitmentItems,
servicePickerSel,
selectedPlanService,
saveCommitmentItems,
props,
emit
});
return { composer, commitmentItems, servicePickerSel, selectedPlanService, props, emit, emitted, actions, saveCommitmentItems };
}
// ════════════════════════════════════════════════════════════════════
describe('buildSavePayload (helper puro)', () => {
it('monta payload com session', () => {
const { actions } = setup();
const payload = actions.buildSavePayload({
form: {
owner_id: 'o-1',
terapeuta_id: 't-1',
paciente_id: 'p-1',
status: 'agendado',
modalidade: 'presencial',
observacoes: 'note',
commitment_id: 'c-1',
titulo_custom: '',
extra_fields: {},
price: 100,
insurance_plan_id: null,
insurance_guide_number: null,
insurance_value: null,
insurance_plan_service_id: null
},
requiresPatient: true,
isSessionEvent: true,
computedTitulo: 'Ana [Sessão]',
inicioISO: '2026-05-15T14:00:00.000Z',
fimISO: '2026-05-15T14:50:00.000Z'
});
expect(payload).toMatchObject({
owner_id: 'o-1',
paciente_id: 'p-1',
patient_id: 'p-1',
tipo: 'sessao',
status: 'agendado',
titulo: 'Ana [Sessão]',
inicio_em: '2026-05-15T14:00:00.000Z',
determined_commitment_id: 'c-1',
price: 100
});
});
it('paciente_id fica null quando NÃO é session', () => {
const { actions } = setup();
const payload = actions.buildSavePayload({
form: { paciente_id: 'p-1', commitment_id: 'c-meeting' },
requiresPatient: false,
isSessionEvent: false,
computedTitulo: 'Reunião',
inicioISO: 'X',
fimISO: 'Y'
});
expect(payload.paciente_id).toBe(null);
expect(payload.patient_id).toBe(null);
expect(payload.price).toBe(null); // só session salva price
});
it('extra_fields=null quando objeto vazio', () => {
const { actions } = setup();
const payload = actions.buildSavePayload({
form: { extra_fields: {} },
requiresPatient: false,
isSessionEvent: false,
computedTitulo: '',
inicioISO: 'X',
fimISO: 'Y'
});
expect(payload.extra_fields).toBe(null);
});
it('extra_fields preservado quando tem chaves', () => {
const { actions } = setup();
const payload = actions.buildSavePayload({
form: { extra_fields: { custom: 'v' } },
requiresPatient: false,
isSessionEvent: false,
computedTitulo: '',
inicioISO: 'X',
fimISO: 'Y'
});
expect(payload.extra_fields).toEqual({ custom: 'v' });
});
});
describe('buildRecorrenciaPayload (helper puro)', () => {
it('avulsa → null', () => {
const { actions } = setup();
expect(
actions.buildRecorrenciaPayload({
recorrenciaType: 'avulsa',
diaSemanaRecorrencia: 0,
diasSelecionados: [],
startTime: '14:00',
duracaoMin: 50,
dataFimCalculada: null,
qtdSessoesEfetiva: 0,
serieValorMode: 'multiplicar',
commitmentItemsList: [],
ocorrenciasComConflito: []
})
).toBe(null);
});
it('semanal monta payload completo', () => {
const { actions } = setup();
const dataFim = new Date('2026-06-05T00:00:00.000Z');
const result = actions.buildRecorrenciaPayload({
recorrenciaType: 'semanal',
diaSemanaRecorrencia: 5,
diasSelecionados: [],
startTime: '14:00',
duracaoMin: 50,
dataFimCalculada: dataFim,
qtdSessoesEfetiva: 4,
serieValorMode: 'multiplicar',
commitmentItemsList: [{ id: 'i-1' }],
ocorrenciasComConflito: []
});
expect(result).toMatchObject({
tipo: 'recorrente',
tipoFreq: 'semanal',
diaSemana: 5,
horaInicio: '14:00:00',
duracaoMin: 50,
qtdSessoes: 4,
serieValorMode: 'multiplicar',
commitmentItems: [{ id: 'i-1' }]
});
expect(result.dataFim).toBe(dataFim.toISOString());
});
it('inclui só ocorrências COM conflict no array conflitos', () => {
const { actions } = setup();
const result = actions.buildRecorrenciaPayload({
recorrenciaType: 'semanal',
diaSemanaRecorrencia: 5,
diasSelecionados: [],
startTime: '14:00',
duracaoMin: 50,
dataFimCalculada: new Date(),
qtdSessoesEfetiva: 4,
serieValorMode: 'multiplicar',
commitmentItemsList: [],
ocorrenciasComConflito: [
{ date: new Date('2026-05-15'), conflict: { type: 'feriado', label: 'X' } },
{ date: new Date('2026-05-22'), conflict: null },
{ date: new Date('2026-05-29'), conflict: { type: 'pausa', label: 'Y' } }
]
});
expect(result.conflitos).toHaveLength(2);
expect(result.conflitos[0].conflict.type).toBe('feriado');
expect(result.conflitos[1].conflict.type).toBe('pausa');
});
});
describe('onSave', () => {
it('aborta se !canSave (não emite)', () => {
const { actions, emitted } = setup({ canSave: ref(false) });
actions.onSave();
expect(emitted).toHaveLength(0);
});
it('aborta com toast se timeConflict presente', () => {
const { actions, emitted } = setup({ timeConflict: ref('Conflito com Maria às 14:30') });
actions.onSave();
expect(emitted).toHaveLength(0);
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'warn',
summary: 'Conflito de horário',
detail: expect.stringContaining('Conflito com Maria')
})
);
});
it('emite save com payload e recorrencia=null em avulsa', () => {
const { actions, emitted } = setup();
actions.onSave();
expect(emitted).toHaveLength(1);
const [name, body] = emitted[0];
expect(name).toBe('save');
expect(body.recorrencia).toBe(null);
expect(body.payload.tipo).toBe('sessao');
});
it('emite save com recorrencia preenchida em recorrência', () => {
const { actions, emitted } = setup({
recorrenciaType: ref('semanal'),
qtdSessoesEfetiva: ref(4),
dataFimCalculada: ref(new Date('2026-06-05'))
});
actions.onSave();
const [, body] = emitted[0];
expect(body.recorrencia).not.toBe(null);
expect(body.recorrencia.tipoFreq).toBe('semanal');
expect(body.recorrencia.qtdSessoes).toBe(4);
});
it('inclui editMode/recurrence_id/original_date quando editando série', () => {
const props = {
eventRow: { recurrence_id: 'r-1', original_date: '2026-05-15' }
};
const { actions, emitted } = setup({ hasSerie: ref(true), editScope: ref('todos') }, props);
actions.onSave();
const [, body] = emitted[0];
expect(body.editMode).toBe('todos');
expect(body.recurrence_id).toBe('r-1');
expect(body.original_date).toBe('2026-05-15');
});
it('serviceItems e onSaved presentes só em sessão', () => {
const { actions, emitted, commitmentItems, saveCommitmentItems } = setup();
commitmentItems.value = [{ service_id: 's-1', quantity: 2 }];
actions.onSave();
const [, body] = emitted[0];
expect(body.serviceItems).toEqual([{ service_id: 's-1', quantity: 2 }]);
expect(typeof body.onSaved).toBe('function');
// Simula chamada do onSaved
body.onSaved('evt-1', { markCustomized: true });
expect(saveCommitmentItems).toHaveBeenCalledWith('evt-1', expect.any(Array), { markCustomized: true });
});
it('serviceItems e onSaved são null em não-session', () => {
const { actions, emitted } = setup({ isSessionEvent: ref(false) });
actions.onSave();
const [, body] = emitted[0];
expect(body.serviceItems).toBe(null);
expect(body.onSaved).toBe(null);
});
});
describe('onDelete', () => {
it('no-op sem form.id', () => {
const { actions, emitted } = setup();
actions.onDelete();
expect(emitted).toHaveLength(0);
expect(confirmRequire).not.toHaveBeenCalled();
});
it('avulsa: confirm + emit(id)', () => {
const { composer, actions, emitted } = setup();
composer.form.value.id = 'evt-1';
actions.onDelete();
expect(confirmRequire).toHaveBeenCalled();
// Aceitar dispara emit
_confirmAccept();
expect(emitted).toContainEqual(['delete', 'evt-1']);
});
it('série: confirm com escopo + emit({id, editMode, ...})', () => {
const props = { eventRow: { recurrence_id: 'r-1', original_date: '2026-05-15' } };
const { composer, actions, emitted } = setup({ hasSerie: ref(true), editScope: ref('este_e_seguintes') }, props);
composer.form.value.id = 'evt-1';
actions.onDelete();
_confirmAccept();
expect(emitted).toHaveLength(1);
const [name, body] = emitted[0];
expect(name).toBe('delete');
expect(body).toMatchObject({
id: 'evt-1',
editMode: 'este_e_seguintes',
recurrence_id: 'r-1',
original_date: '2026-05-15'
});
});
it('série + editScope=todos: usa header "Encerrar toda a série"', () => {
const { composer, actions } = setup({ hasSerie: ref(true), editScope: ref('todos') });
composer.form.value.id = 'evt-1';
actions.onDelete();
const opts = _confirmCalls[0];
expect(opts.header).toMatch(/Encerrar toda/);
});
});
describe('onEncerrarSerie', () => {
it('confirma encerramento e emite com editMode=todos', () => {
const props = { eventRow: { recurrence_id: 'r-2', original_date: '2026-05-22' } };
const { composer, actions, emitted } = setup({}, props);
composer.form.value.id = 'evt-2';
actions.onEncerrarSerie();
expect(confirmRequire).toHaveBeenCalled();
_confirmAccept();
const [name, body] = emitted[0];
expect(name).toBe('delete');
expect(body.editMode).toBe('todos');
expect(body.recurrence_id).toBe('r-2');
});
});
describe('Watcher: billingType', () => {
it('gratuito limpa items, price=0 e campos de convênio', async () => {
const { composer, commitmentItems } = setup({
billingType: ref('particular')
});
commitmentItems.value = [{ id: 'i-1' }];
composer.form.value.price = 100;
composer.form.value.insurance_plan_id = 'plan-1';
composer.billingType.value = 'gratuito';
await new Promise((r) => setTimeout(r, 0)); // flush watcher
expect(commitmentItems.value).toEqual([]);
expect(composer.form.value.price).toBe(0);
expect(composer.form.value.insurance_plan_id).toBe(null);
});
it('particular limpa só campos de convênio', async () => {
const { composer, commitmentItems } = setup({ billingType: ref('convenio') });
commitmentItems.value = [{ id: 'i-1' }];
composer.form.value.insurance_plan_id = 'plan-1';
composer.billingType.value = 'particular';
await new Promise((r) => setTimeout(r, 0));
// particular preserva itens (não limpa); só limpa convenio
expect(composer.form.value.insurance_plan_id).toBe(null);
});
it('convenio limpa items e servicePickerSel', async () => {
const { composer, commitmentItems, servicePickerSel } = setup({ billingType: ref('particular') });
commitmentItems.value = [{ id: 'i-1' }];
servicePickerSel.value = 'svc-x';
composer.billingType.value = 'convenio';
await new Promise((r) => setTimeout(r, 0));
expect(commitmentItems.value).toEqual([]);
expect(servicePickerSel.value).toBe(null);
});
});
describe('Watcher: form.status (cancelado/remarcado)', () => {
it('NÃO dispara confirm em status comuns (agendado, realizado)', async () => {
const { composer } = setup({ isEdit: ref(true) });
composer.form.value.id = 'evt-1';
composer.form.value.status = 'realizado';
await new Promise((r) => setTimeout(r, 0));
expect(confirmRequire).not.toHaveBeenCalled();
});
it('dispara confirm em "cancelado"', async () => {
const { composer } = setup({ isEdit: ref(true) });
composer.form.value.id = 'evt-1';
composer.form.value.status = 'cancelado';
await new Promise((r) => setTimeout(r, 0));
expect(confirmRequire).toHaveBeenCalled();
const opts = _confirmCalls[0];
expect(opts.header).toMatch(/Cancelar/);
});
it('dispara confirm em "remarcado"', async () => {
const { composer } = setup({ isEdit: ref(true) });
composer.form.value.id = 'evt-1';
composer.form.value.status = 'remarcado';
await new Promise((r) => setTimeout(r, 0));
expect(confirmRequire).toHaveBeenCalled();
expect(_confirmCalls[0].header).toMatch(/Remarcar/);
});
it('reverte status no reject', async () => {
const { composer } = setup({ isEdit: ref(true) });
composer.form.value.id = 'evt-1';
composer.form.value.status = 'agendado';
await new Promise((r) => setTimeout(r, 0));
composer.form.value.status = 'cancelado';
await new Promise((r) => setTimeout(r, 0));
_confirmReject();
expect(composer.form.value.status).toBe('agendado');
});
it('NÃO dispara se !isEdit', async () => {
const { composer } = setup({ isEdit: ref(false) });
composer.form.value.status = 'cancelado';
await new Promise((r) => setTimeout(r, 0));
expect(confirmRequire).not.toHaveBeenCalled();
});
it('NÃO dispara se _skipStatusWatch ativo', async () => {
const { composer, actions } = setup({ isEdit: ref(true) });
composer.form.value.id = 'evt-1';
actions._skipStatusWatch.value = true;
composer.form.value.status = 'cancelado';
await new Promise((r) => setTimeout(r, 0));
expect(confirmRequire).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,701 @@
/**
* useAgendaEventComposer.spec.js A66 sub-sessão 1B
*
* Cobre o composable factory extraído do AgendaEventDialog.
* Foco do contrato: refs reativos + computeds derivados são consistentes
* com o comportamento original do .vue (matriz de inputs, edge cases).
*
* Não cobre: watchers e handlers (1C fica no .vue ainda).
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ref, reactive } from 'vue';
import { useAgendaEventComposer } from '../useAgendaEventComposer';
// Mock de getPatientAgendaPermissions — o composable importa direto
vi.mock('@/composables/usePatientLifecycle', () => ({
getPatientAgendaPermissions: (status) => {
const norm = String(status || '').toLowerCase();
if (norm === 'inativo' || norm === 'arquivado') {
return { canCreateSession: false, canCreateRecurrence: false };
}
return { canCreateSession: true, canCreateRecurrence: true };
}
}));
// Helper: monta props default + emit fake
function setup(overrides = {}, extras = {}) {
const props = reactive({
modelValue: false,
eventRow: null,
initialStartISO: '',
initialEndISO: '',
ownerId: 'owner-1',
planOwnerId: '',
allowOwnerEdit: false,
ownerOptions: [],
tenantId: 'tenant-1',
commitmentOptions: [],
presetCommitmentId: null,
lockCommitment: false,
restrictPatientsToOwner: false,
patientScopeOwnerId: null,
workRules: [],
blockedDates: [],
agendaSettings: { session_duration_min: 50, session_break_min: 0 },
allEvents: [],
pausasSemanais: [],
feriados: [],
newPatientRoute: '',
...overrides
});
const emitted = [];
const emit = (...args) => emitted.push(args);
const composer = useAgendaEventComposer(props, emit, extras);
return { props, emit, emitted, composer };
}
const SESSION_COMMITMENT = { id: 'c-session', native_key: 'session', name: 'Sessão' };
const MEETING_COMMITMENT = { id: 'c-meeting', native_key: 'meeting', name: 'Reunião' };
// ════════════════════════════════════════════════════════════════════════
describe('visible (v-model)', () => {
it('lê de props.modelValue', () => {
const { composer, props } = setup({ modelValue: true });
expect(composer.visible.value).toBe(true);
props.modelValue = false;
expect(composer.visible.value).toBe(false);
});
it('escrever emite update:modelValue', () => {
const { composer, emitted } = setup();
composer.visible.value = true;
expect(emitted).toContainEqual(['update:modelValue', true]);
});
});
describe('isEdit', () => {
it('false sem eventRow', () => {
const { composer } = setup({ eventRow: null });
expect(composer.isEdit.value).toBe(false);
});
it('true com id', () => {
const { composer } = setup({ eventRow: { id: 'evt-1' } });
expect(composer.isEdit.value).toBe(true);
});
it('true com is_occurrence (sem id)', () => {
const { composer } = setup({ eventRow: { is_occurrence: true } });
expect(composer.isEdit.value).toBe(true);
});
});
describe('allowBack', () => {
it('true por default', () => {
const { composer } = setup();
expect(composer.allowBack.value).toBe(true);
});
it('false quando lockCommitment', () => {
const { composer } = setup({ lockCommitment: true });
expect(composer.allowBack.value).toBe(false);
});
it('false quando presetCommitmentId', () => {
const { composer } = setup({ presetCommitmentId: 'c-1' });
expect(composer.allowBack.value).toBe(false);
});
});
describe('hasSerie', () => {
it('false sem indicadores de série', () => {
const { composer } = setup({ eventRow: { id: 'evt-1' } });
expect(composer.hasSerie.value).toBe(false);
});
it('true com recurrence_id', () => {
const { composer } = setup({ eventRow: { id: 'evt-1', recurrence_id: 'r-1' } });
expect(composer.hasSerie.value).toBe(true);
});
it('true com serie_id (legado)', () => {
const { composer } = setup({ eventRow: { id: 'evt-1', serie_id: 's-1' } });
expect(composer.hasSerie.value).toBe(true);
});
it('true com is_occurrence', () => {
const { composer } = setup({ eventRow: { is_occurrence: true } });
expect(composer.hasSerie.value).toBe(true);
});
});
describe('isFirstOccurrence', () => {
it('false quando não é série', () => {
const { composer } = setup();
expect(composer.isFirstOccurrence.value).toBe(false);
});
it('true quando recurrence_date é a menor data da série', () => {
const serieEvents = ref([
{ recurrence_date: '2026-05-15' },
{ recurrence_date: '2026-05-22' },
{ recurrence_date: '2026-05-29' }
]);
const { composer } = setup(
{ eventRow: { id: 'evt-1', recurrence_id: 'r-1', recurrence_date: '2026-05-15' } },
{ serieEvents }
);
expect(composer.isFirstOccurrence.value).toBe(true);
});
it('false quando recurrence_date NÃO é a menor', () => {
const serieEvents = ref([
{ recurrence_date: '2026-05-15' },
{ recurrence_date: '2026-05-22' }
]);
const { composer } = setup(
{ eventRow: { id: 'evt-1', recurrence_id: 'r-1', recurrence_date: '2026-05-22' } },
{ serieEvents }
);
expect(composer.isFirstOccurrence.value).toBe(false);
});
it('false quando serieEvents vazio', () => {
const { composer } = setup(
{ eventRow: { id: 'evt-1', recurrence_id: 'r-1', recurrence_date: '2026-05-15' } },
{ serieEvents: ref([]) }
);
expect(composer.isFirstOccurrence.value).toBe(false);
});
});
describe('editScopeOptions', () => {
it('retorna 4 opções', () => {
const { composer } = setup();
expect(composer.editScopeOptions.value).toHaveLength(4);
});
it('"este_e_seguintes" disabled quando isFirstOccurrence', () => {
const serieEvents = ref([{ recurrence_date: '2026-05-15' }, { recurrence_date: '2026-05-22' }]);
const { composer } = setup(
{ eventRow: { id: 'evt-1', recurrence_id: 'r-1', recurrence_date: '2026-05-15' } },
{ serieEvents }
);
const opt = composer.editScopeOptions.value.find((o) => o.value === 'este_e_seguintes');
expect(opt.disabled).toBe(true);
});
});
describe('qtdSessoesEfetiva', () => {
it('valores fixos pelo mode', () => {
const { composer } = setup();
composer.qtdSessoesMode.value = '4';
expect(composer.qtdSessoesEfetiva.value).toBe(4);
composer.qtdSessoesMode.value = '8';
expect(composer.qtdSessoesEfetiva.value).toBe(8);
composer.qtdSessoesMode.value = '12';
expect(composer.qtdSessoesEfetiva.value).toBe(12);
});
it('"personalizar" usa qtdSessoesCustom', () => {
const { composer } = setup();
composer.qtdSessoesMode.value = 'personalizar';
composer.qtdSessoesCustom.value = 24;
expect(composer.qtdSessoesEfetiva.value).toBe(24);
});
it('clampa em 1 quando custom é 0/null', () => {
const { composer } = setup();
composer.qtdSessoesMode.value = 'personalizar';
composer.qtdSessoesCustom.value = 0;
expect(composer.qtdSessoesEfetiva.value).toBe(1);
composer.qtdSessoesCustom.value = null;
expect(composer.qtdSessoesEfetiva.value).toBe(1);
});
});
describe('proximasOcorrencias', () => {
function asISODate(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
it('retorna [] quando avulsa', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'avulsa';
composer.form.value.dia = new Date('2026-05-15');
expect(composer.proximasOcorrencias.value).toEqual([]);
});
it('semanal: 4 datas separadas por 7 dias', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'semanal';
composer.qtdSessoesMode.value = '4';
composer.form.value.dia = new Date('2026-05-15T10:00:00');
const list = composer.proximasOcorrencias.value;
expect(list).toHaveLength(4);
expect(asISODate(list[0])).toBe('2026-05-15');
expect(asISODate(list[1])).toBe('2026-05-22');
expect(asISODate(list[2])).toBe('2026-05-29');
expect(asISODate(list[3])).toBe('2026-06-05');
});
it('quinzenal: separação 14 dias', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'quinzenal';
composer.qtdSessoesMode.value = '4';
composer.form.value.dia = new Date('2026-05-15T10:00:00');
const list = composer.proximasOcorrencias.value;
expect(list).toHaveLength(4);
expect(asISODate(list[1])).toBe('2026-05-29');
expect(asISODate(list[2])).toBe('2026-06-12');
});
it('diasEspecificos: respeita dias selecionados', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'diasEspecificos';
composer.qtdSessoesMode.value = '4';
composer.diasSelecionados.value = [1, 3]; // segunda + quarta
composer.form.value.dia = new Date('2026-05-18T10:00:00'); // segunda
const list = composer.proximasOcorrencias.value;
expect(list).toHaveLength(4);
// 18 (seg) - 20 (qua) - 25 (seg) - 27 (qua)
expect(list[0].getDay()).toBe(1);
expect(list[1].getDay()).toBe(3);
expect(list[2].getDay()).toBe(1);
expect(list[3].getDay()).toBe(3);
});
it('diasEspecificos com array vazio retorna []', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'diasEspecificos';
composer.diasSelecionados.value = [];
composer.form.value.dia = new Date('2026-05-15');
expect(composer.proximasOcorrencias.value).toEqual([]);
});
});
describe('toggleDiaSelecionado', () => {
it('adiciona se não existe, remove se existe', () => {
const { composer } = setup();
composer.toggleDiaSelecionado(1);
expect(composer.diasSelecionados.value).toEqual([1]);
composer.toggleDiaSelecionado(3);
expect(composer.diasSelecionados.value).toEqual([1, 3]);
composer.toggleDiaSelecionado(1);
expect(composer.diasSelecionados.value).toEqual([3]);
});
});
describe('isForaDoPlano (com dataLimiteManual)', () => {
it('false quando dataLimiteManual null', () => {
const { composer } = setup();
expect(composer.isForaDoPlano(new Date('2026-12-31'))).toBe(false);
});
it('true quando data > limite', () => {
const { composer } = setup();
composer.dataLimiteManual.value = '2026-06-30';
expect(composer.isForaDoPlano(new Date('2026-12-31'))).toBe(true);
expect(composer.isForaDoPlano(new Date('2026-06-29'))).toBe(false);
});
});
describe('commitmentCards', () => {
it('coloca native_key="session" primeiro', () => {
const { composer } = setup({
commitmentOptions: [MEETING_COMMITMENT, SESSION_COMMITMENT, { id: 'c-3', name: 'Avaliação' }]
});
const cards = composer.commitmentCards.value;
// commitmentOptions vem via reactive(props) — itens viram proxies,
// por isso comparamos id (toBe) em vez de identidade do objeto.
expect(cards[0].id).toBe('c-session');
});
it('ordem alfabética entre não-session', () => {
const { composer } = setup({
commitmentOptions: [
{ id: 'c-1', name: 'Zeta' },
{ id: 'c-2', name: 'Alpha' },
{ id: 'c-3', name: 'Beta' }
]
});
const cards = composer.commitmentCards.value;
expect(cards.map((c) => c.name)).toEqual(['Alpha', 'Beta', 'Zeta']);
});
});
describe('selectedCommitment + relacionados', () => {
it('selectedCommitment encontra pelo form.commitment_id', () => {
const { composer } = setup({
commitmentOptions: [SESSION_COMMITMENT, MEETING_COMMITMENT]
});
composer.form.value.commitment_id = 'c-meeting';
expect(composer.selectedCommitment.value?.id).toBe('c-meeting');
});
it('selectedCommitmentName fallback "—" quando null', () => {
const { composer } = setup();
expect(composer.selectedCommitmentName.value).toBe('—');
});
it('requiresPatient true quando native_key=session', () => {
const { composer } = setup({ commitmentOptions: [SESSION_COMMITMENT] });
composer.form.value.commitment_id = 'c-session';
expect(composer.requiresPatient.value).toBe(true);
expect(composer.isSessionEvent.value).toBe(true);
});
it('requiresPatient false pra outros commitments', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
composer.form.value.commitment_id = 'c-meeting';
expect(composer.requiresPatient.value).toBe(false);
});
it('patientLocked true só na edição de sessão com paciente', () => {
const { composer } = setup({
commitmentOptions: [SESSION_COMMITMENT],
eventRow: { id: 'evt-1', paciente_id: 'p-1' }
});
composer.form.value.commitment_id = 'c-session';
expect(composer.patientLocked.value).toBe(true);
});
it('hasInsurance reage a form.insurance_plan_id', () => {
const { composer } = setup();
expect(composer.hasInsurance.value).toBe(false);
composer.form.value.insurance_plan_id = 'plan-1';
expect(composer.hasInsurance.value).toBe(true);
});
});
describe('agendaPerms', () => {
it('Inativo bloqueia create', () => {
const { composer } = setup();
composer.form.value.paciente_status = 'Inativo';
expect(composer.agendaPerms.value.canCreateSession).toBe(false);
});
it('Ativo permite tudo', () => {
const { composer } = setup();
composer.form.value.paciente_status = 'Ativo';
expect(composer.agendaPerms.value.canCreateSession).toBe(true);
expect(composer.agendaPerms.value.canCreateRecurrence).toBe(true);
});
});
describe('isSessionFuture / isArchivedPastEdit / isInativoFutureEdit', () => {
let mockNow;
beforeEach(() => {
mockNow = new Date('2026-05-15T12:00:00').getTime();
vi.useFakeTimers();
vi.setSystemTime(mockNow);
});
it('isSessionFuture true quando criando (não-edit)', () => {
const { composer } = setup({ eventRow: null });
expect(composer.isSessionFuture.value).toBe(true);
});
it('isSessionFuture true quando edit + sessão futura', () => {
const { composer } = setup({
eventRow: { id: 'evt-1', inicio_em: '2026-05-20T10:00:00' }
});
expect(composer.isSessionFuture.value).toBe(true);
});
it('isSessionFuture false quando edit + sessão passada', () => {
const { composer } = setup({
eventRow: { id: 'evt-1', inicio_em: '2026-05-10T10:00:00' }
});
expect(composer.isSessionFuture.value).toBe(false);
});
it('isArchivedPastEdit true: edit + Arquivado + passada', () => {
const { composer } = setup({
eventRow: { id: 'evt-1', inicio_em: '2026-05-10T10:00:00' }
});
composer.form.value.paciente_status = 'Arquivado';
expect(composer.isArchivedPastEdit.value).toBe(true);
});
it('isInativoFutureEdit true: edit + Inativo + futura', () => {
const { composer } = setup({
eventRow: { id: 'evt-1', inicio_em: '2026-05-20T10:00:00' }
});
composer.form.value.paciente_status = 'Inativo';
expect(composer.isInativoFutureEdit.value).toBe(true);
});
});
describe('inicioDateTime / fimDateTime', () => {
it('null quando dia ou startTime ausentes', () => {
const { composer } = setup();
composer.form.value.dia = null;
expect(composer.inicioDateTime.value).toBe(null);
});
it('combina dia + startTime corretamente', () => {
const { composer } = setup();
composer.form.value.dia = new Date('2026-05-15T00:00:00');
composer.form.value.startTime = '14:30';
const ini = composer.inicioDateTime.value;
expect(ini.getHours()).toBe(14);
expect(ini.getMinutes()).toBe(30);
});
it('fimDateTime adiciona duracaoMin', () => {
const { composer } = setup();
composer.form.value.dia = new Date('2026-05-15T00:00:00');
composer.form.value.startTime = '14:00';
composer.form.value.duracaoMin = 50;
const fim = composer.fimDateTime.value;
expect(fim.getHours()).toBe(14);
expect(fim.getMinutes()).toBe(50);
});
});
describe('startTimeDate (computed get/set)', () => {
it('getter null quando startTime null', () => {
const { composer } = setup();
composer.form.value.startTime = null;
expect(composer.startTimeDate.value).toBe(null);
});
it('getter retorna Date com hora setada', () => {
const { composer } = setup();
composer.form.value.startTime = '09:30';
const d = composer.startTimeDate.value;
expect(d.getHours()).toBe(9);
expect(d.getMinutes()).toBe(30);
});
it('setter atualiza form.startTime no formato HH:MM', () => {
const { composer } = setup();
const d = new Date();
d.setHours(15, 5, 0, 0);
composer.startTimeDate.value = d;
expect(composer.form.value.startTime).toBe('15:05');
});
it('setter null limpa form.startTime', () => {
const { composer } = setup();
composer.form.value.startTime = '14:00';
composer.startTimeDate.value = null;
expect(composer.form.value.startTime).toBe(null);
});
});
describe('canSave (matriz de validação — núcleo)', () => {
function ready(composer) {
composer.form.value.owner_id = 'owner-1';
composer.form.value.dia = new Date('2026-05-15');
composer.form.value.startTime = '14:00';
composer.form.value.commitment_id = 'c-meeting';
composer.form.value.duracaoMin = 50;
}
it('false sem owner_id', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
ready(composer);
composer.form.value.owner_id = '';
expect(composer.canSave.value).toBe(false);
});
it('false sem dia', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
ready(composer);
composer.form.value.dia = null;
expect(composer.canSave.value).toBe(false);
});
it('false sem startTime', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
ready(composer);
composer.form.value.startTime = null;
expect(composer.canSave.value).toBe(false);
});
it('false ao criar sem commitment_id', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
ready(composer);
composer.form.value.commitment_id = null;
expect(composer.canSave.value).toBe(false);
});
it('true ao EDITAR sem commitment_id (sessões antigas)', () => {
const { composer } = setup({
commitmentOptions: [MEETING_COMMITMENT],
eventRow: { id: 'evt-1', inicio_em: '2026-05-15T14:00:00' }
});
ready(composer);
composer.form.value.commitment_id = null; // legacy null
expect(composer.canSave.value).toBe(true);
});
it('false em sessão sem paciente_id quando requiresPatient', () => {
const { composer } = setup({ commitmentOptions: [SESSION_COMMITMENT] });
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = null;
expect(composer.canSave.value).toBe(false);
});
it('false em sessão particular sem itens de billing', () => {
const items = ref([]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.billingType.value = 'particular';
expect(composer.canSave.value).toBe(false);
});
it('true em sessão particular COM itens', () => {
const items = ref([{ id: 'i-1' }]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.billingType.value = 'particular';
expect(composer.canSave.value).toBe(true);
});
it('false ao criar sessão pra paciente Inativo', () => {
const items = ref([{ id: 'i-1' }]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.form.value.paciente_status = 'Inativo';
composer.billingType.value = 'particular';
expect(composer.canSave.value).toBe(false);
});
it('false ao criar recorrência pra paciente Arquivado', () => {
const items = ref([{ id: 'i-1' }]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.form.value.paciente_status = 'Arquivado';
composer.billingType.value = 'particular';
composer.recorrenciaType.value = 'semanal';
expect(composer.canSave.value).toBe(false);
});
it('true em billing convenio sem precisar de itens', () => {
const items = ref([]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.billingType.value = 'convenio';
expect(composer.canSave.value).toBe(true);
});
});
describe('timeConflict (pré-check antes do PATCH)', () => {
function readyForm(composer) {
composer.form.value.dia = new Date('2026-05-15T00:00:00');
composer.form.value.startTime = '14:00';
composer.form.value.duracaoMin = 50;
}
it('null quando form ainda incompleto', () => {
const { composer } = setup();
composer.form.value.dia = null;
expect(composer.timeConflict.value).toBe(null);
});
it('null sem allEvents', () => {
const { composer } = setup({ allEvents: [] });
readyForm(composer);
expect(composer.timeConflict.value).toBe(null);
});
it('detecta overlap com evento existente', () => {
const { composer } = setup({
allEvents: [{
id: 'evt-x',
inicio_em: '2026-05-15T14:30:00',
fim_em: '2026-05-15T15:20:00',
paciente_nome: 'Maria'
}]
});
readyForm(composer);
expect(composer.timeConflict.value).toMatch(/Maria/);
});
it('NÃO detecta overlap com o próprio evento (form.id === evt.id)', () => {
const { composer } = setup({
allEvents: [{
id: 'self',
inicio_em: '2026-05-15T14:00:00',
fim_em: '2026-05-15T14:50:00'
}]
});
readyForm(composer);
composer.form.value.id = 'self';
expect(composer.timeConflict.value).toBe(null);
});
it('null quando evento adjacente (sem overlap real)', () => {
const { composer } = setup({
allEvents: [{
id: 'evt-x',
inicio_em: '2026-05-15T15:00:00',
fim_em: '2026-05-15T15:50:00'
}]
});
readyForm(composer);
composer.form.value.duracaoMin = 50; // 14:0014:50
expect(composer.timeConflict.value).toBe(null);
});
it('detecta sobreposição com pausa do dia', () => {
const { composer } = setup({
pausasSemanais: [{
dia_semana: 5, // sexta (2026-05-15)
hora_inicio: '14:30',
hora_fim: '15:00'
}]
});
readyForm(composer);
expect(composer.timeConflict.value).toMatch(/pausa/i);
});
});
describe('totalConflitos / sessoesForaDoPlano', () => {
it('totalConflitos conta ocorrências com folga/feriado/bloqueado/pausa', () => {
const { composer } = setup({
workRules: [
{ dia_semana: 1 }, { dia_semana: 2 }, { dia_semana: 3 },
{ dia_semana: 4 }, { dia_semana: 5 } // seg-sex
],
blockedDates: ['2026-05-22']
});
composer.recorrenciaType.value = 'semanal';
composer.qtdSessoesMode.value = '4';
composer.form.value.dia = new Date('2026-05-15T10:00:00'); // sexta
// 15 (sex ok) - 22 (sex bloqueada) - 29 (sex ok) - 5/jun (sex ok)
expect(composer.totalConflitos.value).toBe(1);
});
it('sessoesForaDoPlano com dataLimiteManual', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'semanal';
composer.qtdSessoesMode.value = '4';
composer.form.value.dia = new Date('2026-05-15T10:00:00');
composer.dataLimiteManual.value = '2026-05-22';
// ocorrências carregam hora 10:00 (de form.dia); limite "2026-05-22"
// vira meia-noite local. Logo 22/5 10:00 > 22/5 00:00 → fora.
// Resultado: 15 (dentro), 22 (fora), 29 (fora), 5/jun (fora) = 3
expect(composer.sessoesForaDoPlano.value).toBe(3);
});
});
describe('headerTitle', () => {
it('"Editar compromisso" quando isEdit', () => {
const { composer } = setup({ eventRow: { id: 'evt-1' } });
expect(composer.headerTitle.value).toBe('Editar compromisso');
});
it('"Novo compromisso — escolha o tipo" no step 1', () => {
const { composer } = setup();
composer.step.value = 1;
expect(composer.headerTitle.value).toMatch(/escolha o tipo/);
});
it('"Novo compromisso" no step 2', () => {
const { composer } = setup();
composer.step.value = 2;
expect(composer.headerTitle.value).toBe('Novo compromisso');
});
});
describe('computedTitulo', () => {
it('usa titulo_custom quando preenchido', () => {
const { composer } = setup();
composer.form.value.titulo_custom = 'Minha custom';
expect(composer.computedTitulo.value).toBe('Minha custom');
});
it('"—" quando não há commitment selecionado (selectedCommitmentName fallback)', () => {
// selectedCommitmentName retorna "—" quando sem commitment, então
// computedTitulo herda esse "—" porque "—" || "Compromisso" → "—".
// Comportamento original do .vue preservado.
const { composer } = setup();
expect(composer.computedTitulo.value).toBe('—');
});
it('combina nome paciente + commitment quando session', () => {
const { composer } = setup({ commitmentOptions: [SESSION_COMMITMENT] });
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_nome = 'Ana';
expect(composer.computedTitulo.value).toBe('Ana [Sessão]');
});
});
@@ -0,0 +1,687 @@
/**
* useAgendaEventLifecycle.spec.js A66 sub-sessão 1C-ii-b
*
* Cobre: generateRuleDates (pura), loadSerieEvents, 4 onPill handlers,
* selectSlot, quick-creates wiring, onSendManualReminder, e os 4 watchers
* (modelValue init, tenant scope, solicitação pendente, online slots).
*
* Mock supabase: dispatcher por tabela. Cada `.from(table)` retorna um
* builder que registra a chain e retorna `_responses[table]` quando
* awaited / .maybeSingle() / .single().
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ref, computed, nextTick } from 'vue';
// ── Mocks supabase ────────────────────────────────────────────
const _responses = {}; // { 'patients': { data, error }, ... }
const _calls = []; // log das chamadas .from()
const _functionsInvoke = vi.fn(); // pra functions.invoke
function setResponse(table, payload) {
_responses[table] = payload;
}
function clearMocks() {
for (const k of Object.keys(_responses)) delete _responses[k];
_calls.length = 0;
_functionsInvoke.mockReset();
}
function makeBuilder(table) {
const log = { table, ops: [] };
_calls.push(log);
const result = () => _responses[table] ?? { data: null, error: null };
const b = {
select: (...a) => { log.ops.push(['select', ...a]); return b; },
eq: (...a) => { log.ops.push(['eq', ...a]); return b; },
is: (...a) => { log.ops.push(['is', ...a]); return b; },
order: (...a) => { log.ops.push(['order', ...a]); return b; },
limit: (...a) => { log.ops.push(['limit', ...a]); return b; },
maybeSingle: () => Promise.resolve(result()),
single: () => Promise.resolve(result()),
then: (resolve) => resolve(result())
};
return b;
}
vi.mock('@/lib/supabase/client', () => ({
supabase: {
from: (table) => makeBuilder(table),
functions: { invoke: (...args) => _functionsInvoke(...args) }
}
}));
const lifecycleModule = await import('../useAgendaEventLifecycle.js');
const { useAgendaEventLifecycle, generateRuleDates } = lifecycleModule;
// ── Helpers de fixture ────────────────────────────────────────
function makeComposer(overrides = {}) {
const form = ref({
id: null,
commitment_id: null,
paciente_id: null,
paciente_nome: '',
paciente_avatar: '',
insurance_plan_id: null,
insurance_plan_service_id: null,
insurance_value: null,
insurance_guide_number: null,
dia: null,
startTime: '',
modalidade: 'presencial',
...(overrides.formExtra || {})
});
return {
form,
visible: ref(true),
isEdit: ref(false),
hasSerie: ref(false),
isSessionEvent: ref(true),
requiresPatient: ref(true),
billingType: ref('particular'),
recorrenciaType: ref('avulsa'),
diasSelecionados: ref([]),
dataLimiteManual: ref(null),
qtdSessoesMode: ref('4'),
qtdSessoesCustom: ref(12),
editScope: ref('somente_este'),
step: ref(1),
startTimeDate: ref(null),
resetForm: vi.fn(() => ({
id: null,
commitment_id: null,
paciente_id: null,
paciente_nome: '',
paciente_avatar: '',
insurance_plan_id: null,
insurance_plan_service_id: null,
insurance_value: null,
insurance_guide_number: null,
dia: null,
startTime: '',
modalidade: 'presencial'
})),
...overrides
};
}
function makeActions() {
return {
_skipStatusWatch: ref(false),
_restoringConvenio: ref(false),
samePatientConflict: ref(null)
};
}
function makePickerBilling() {
return {
ensureServicesLoaded: vi.fn().mockResolvedValue(),
_loadCommitmentItemsForEvent: vi.fn().mockResolvedValue(),
clearPatientsCache: vi.fn(),
loadPatients: vi.fn().mockResolvedValue(),
addItem: vi.fn().mockResolvedValue()
};
}
function makeConfirm() {
return {
require: vi.fn((opts) => {
// por padrão, dispara accept imediatamente
if (typeof opts.accept === 'function') return opts.accept();
})
};
}
function makeToast() {
return { add: vi.fn() };
}
function setup(overrides = {}, propsOverrides = {}) {
const composer = overrides.composer ?? makeComposer();
const actions = overrides.actions ?? makeActions();
const pickerBilling = overrides.pickerBilling ?? makePickerBilling();
const commitmentItems = overrides.commitmentItems ?? ref([]);
const serieEvents = overrides.serieEvents ?? ref([]);
const servicePickerSel = overrides.servicePickerSel ?? ref(null);
const selectedPlanService = overrides.selectedPlanService ?? ref(null);
const serieValorMode = overrides.serieValorMode ?? ref('multiplicar');
const services = overrides.services ?? ref([]);
const loadServices = overrides.loadServices ?? vi.fn().mockResolvedValue();
const loadInsurancePlans = overrides.loadInsurancePlans ?? vi.fn().mockResolvedValue();
const props = ref({
modelValue: false,
eventRow: null,
ownerId: 'owner-1',
tenantId: 'tenant-1',
planOwnerId: '',
presetCommitmentId: null,
restrictPatientsToOwner: false,
patientScopeOwnerId: null,
...propsOverrides
});
const confirm = overrides.confirm ?? makeConfirm();
const toast = overrides.toast ?? makeToast();
const emit = vi.fn();
const result = useAgendaEventLifecycle({
composer,
actions,
pickerBilling,
commitmentItems,
serieEvents,
servicePickerSel,
selectedPlanService,
serieValorMode,
services,
loadServices,
loadInsurancePlans,
// Vue não aceita `props` reativo aqui — passa o objeto direto.
// Pra simular mudança, modificamos `props.value.X` antes do watcher rodar:
// o `propsRefProxy` abaixo faz o lifecycle ver props como objeto plano,
// mas testes que precisam reatividade no watcher de modelValue usam
// props.value diretamente.
props: new Proxy({}, {
get(_, k) { return props.value[k]; },
set(_, k, v) { props.value[k] = v; return true; }
}),
emit,
confirm,
toast
});
return {
composer, actions, pickerBilling, commitmentItems, serieEvents,
servicePickerSel, selectedPlanService, serieValorMode, services,
loadServices, loadInsurancePlans, propsRef: props, confirm, toast, emit,
...result
};
}
beforeEach(() => {
clearMocks();
});
// ════════════════════════════════════════════════════════════════════
describe('generateRuleDates', () => {
it('sem start_date → []', () => {
expect(generateRuleDates({ weekdays: [1] })).toEqual([]);
});
it('sem weekdays → []', () => {
expect(generateRuleDates({ start_date: '2026-05-04' })).toEqual([]);
});
it('regra null/undefined → []', () => {
expect(generateRuleDates(null)).toEqual([]);
expect(generateRuleDates(undefined)).toEqual([]);
});
it('weekly interval=1 com max_occurrences=3', () => {
// 2026-05-04 é uma segunda-feira (weekday=1)
const dates = generateRuleDates({
type: 'weekly',
interval: 1,
weekdays: [1],
start_date: '2026-05-04',
max_occurrences: 3
});
expect(dates).toEqual(['2026-05-04', '2026-05-11', '2026-05-18']);
});
it('quinzenal interval=2 (gap de 14 dias)', () => {
const dates = generateRuleDates({
type: 'biweekly',
interval: 2,
weekdays: [1],
start_date: '2026-05-04',
max_occurrences: 3
});
expect(dates).toEqual(['2026-05-04', '2026-05-18', '2026-06-01']);
});
it('custom_weekdays — só inclui dias que casam', () => {
// Ter (2) e Qui (4), 4 ocorrências
const dates = generateRuleDates({
type: 'custom_weekdays',
weekdays: [2, 4],
start_date: '2026-05-04',
max_occurrences: 4
});
// Ter 5/5, Qui 7/5, Ter 12/5, Qui 14/5
expect(dates).toEqual(['2026-05-05', '2026-05-07', '2026-05-12', '2026-05-14']);
});
it('respeita end_date', () => {
const dates = generateRuleDates({
type: 'weekly',
interval: 1,
weekdays: [1],
start_date: '2026-05-04',
end_date: '2026-05-15',
max_occurrences: 365
});
expect(dates).toEqual(['2026-05-04', '2026-05-11']);
});
it('clamp max_occurrences a 365', () => {
const dates = generateRuleDates({
type: 'weekly',
interval: 1,
weekdays: [1],
start_date: '2026-01-05',
max_occurrences: 9999
});
expect(dates.length).toBeLessThanOrEqual(365);
});
});
// ════════════════════════════════════════════════════════════════════
describe('loadSerieEvents', () => {
it('sem recurrence_id/serie_id → zera serieEvents', async () => {
const { loadSerieEvents, serieEvents } = setup();
serieEvents.value = [{ id: 'old' }];
await loadSerieEvents();
expect(serieEvents.value).toEqual([]);
});
it('com recurrence_id: gera lista de virtuals + materializa reais', async () => {
const { loadSerieEvents, serieEvents } = setup({}, { eventRow: { recurrence_id: 'rec-1' } });
setResponse('recurrence_rules', {
data: {
id: 'rec-1', type: 'weekly', interval: 1, weekdays: [1],
start_date: '2026-05-04', max_occurrences: 2,
start_time: '14:00:00', duration_min: 50
},
error: null
});
setResponse('recurrence_exceptions', { data: [], error: null });
setResponse('agenda_eventos', {
data: [{ id: 'real-1', inicio_em: '2026-05-04T14:00:00', fim_em: '2026-05-04T14:50:00', status: 'realizado', recurrence_date: '2026-05-04' }],
error: null
});
await loadSerieEvents();
expect(serieEvents.value).toHaveLength(2);
// primeira é a real (status='realizado', _is_virtual=false)
const first = serieEvents.value.find((e) => e.recurrence_date === '2026-05-04');
expect(first.id).toBe('real-1');
expect(first._is_virtual).toBe(false);
expect(first._status).toBe('realizado');
// segunda é virtual
const second = serieEvents.value.find((e) => e.recurrence_date === '2026-05-11');
expect(second.id).toBeNull();
expect(second._is_virtual).toBe(true);
expect(second._status).toBe('agendado');
});
it('exception cancel_session vira _cancelled=true', async () => {
const { loadSerieEvents, serieEvents } = setup({}, { eventRow: { recurrence_id: 'rec-1' } });
setResponse('recurrence_rules', {
data: { id: 'rec-1', type: 'weekly', interval: 1, weekdays: [1], start_date: '2026-05-04', max_occurrences: 1, start_time: '10:00:00', duration_min: 50 },
error: null
});
setResponse('recurrence_exceptions', {
data: [{ original_date: '2026-05-04', type: 'cancel_session', reason: 'paciente desmarcou' }],
error: null
});
setResponse('agenda_eventos', { data: [], error: null });
await loadSerieEvents();
expect(serieEvents.value[0]._cancelled).toBe(true);
expect(serieEvents.value[0]._status).toBe('cancelado');
expect(serieEvents.value[0]._reason).toBe('paciente desmarcou');
});
it('engole erro do supabase e zera lista', async () => {
const { loadSerieEvents, serieEvents } = setup({}, { eventRow: { recurrence_id: 'rec-1' } });
setResponse('recurrence_rules', { data: null, error: new Error('boom') });
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await loadSerieEvents();
expect(serieEvents.value).toEqual([]);
errSpy.mockRestore();
});
});
// ════════════════════════════════════════════════════════════════════
describe('onPill handlers', () => {
it('onPillEditClick: emit editSeriesOccurrence com payload', () => {
const { onPillEditClick, emit } = setup();
onPillEditClick({ id: 'e-1', recurrence_date: '2026-05-04', inicio_em: 'X', fim_em: 'Y', _is_virtual: true });
expect(emit).toHaveBeenCalledWith('editSeriesOccurrence', {
id: 'e-1',
recurrence_date: '2026-05-04',
inicio_em: 'X',
fim_em: 'Y',
is_virtual: true
});
});
it('onPillStatusChange: emit updateSeriesEvent', () => {
const { onPillStatusChange, emit } = setup();
onPillStatusChange({ id: 'e-1', _status: 'realizado', recurrence_date: '2026-05-04', inicio_em: 'X', fim_em: 'Y', _is_virtual: false });
expect(emit).toHaveBeenCalledWith('updateSeriesEvent', expect.objectContaining({
id: 'e-1', status: 'realizado', is_virtual: false
}));
});
it('onPillStatusChange virtual agenda recarregamento (setTimeout)', () => {
vi.useFakeTimers();
const { onPillStatusChange, serieEvents } = setup({}, { eventRow: { recurrence_id: 'rec-1' } });
setResponse('recurrence_rules', { data: null, error: null });
setResponse('recurrence_exceptions', { data: [], error: null });
setResponse('agenda_eventos', { data: [], error: null });
onPillStatusChange({ id: null, _status: 'agendado', _is_virtual: true });
vi.advanceTimersByTime(700);
// Triggered loadSerieEvents — não vamos esperar await aqui;
// basta saber que não quebrou.
vi.useRealTimers();
expect(serieEvents.value).toBeDefined();
});
it('onPillDelete (somente_este): confirm + emit delete', () => {
const confirm = makeConfirm();
const { onPillDelete, emit } = setup({ confirm }, { eventRow: { recurrence_id: 'rec-1', serie_id: 'ser-1' } });
onPillDelete({ id: 'e-1', recurrence_date: '2026-05-04', inicio_em: '2026-05-04T14:00:00' }, 'somente_este');
expect(confirm.require).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('delete', expect.objectContaining({
id: 'e-1',
editMode: 'somente_este',
recurrence_id: 'rec-1',
original_date: '2026-05-04',
serie_id: 'ser-1'
}));
});
it('onPillDelete (todos): label header diferente', () => {
const confirm = makeConfirm();
const { onPillDelete } = setup({ confirm });
onPillDelete({ id: 'e-1', inicio_em: '2026-05-04T14:00:00' }, 'todos');
const callArgs = confirm.require.mock.calls[0][0];
expect(callArgs.header).toBe('Encerrar toda a série');
expect(callArgs.icon).toBe('pi pi-trash');
expect(callArgs.acceptLabel).toBe('Sim, encerrar série');
});
});
// ════════════════════════════════════════════════════════════════════
describe('selectSlot', () => {
it('atualiza startTimeDate com a hora', () => {
const { selectSlot, composer } = setup();
selectSlot('14:30');
const d = composer.startTimeDate.value;
expect(d).toBeInstanceOf(Date);
expect(d.getHours()).toBe(14);
expect(d.getMinutes()).toBe(30);
});
});
// ════════════════════════════════════════════════════════════════════
describe('quick-creates', () => {
it('openServiceQuickCreate seta serviceQuickDlgOpen=true', () => {
const { openServiceQuickCreate, serviceQuickDlgOpen } = setup();
expect(serviceQuickDlgOpen.value).toBe(false);
openServiceQuickCreate();
expect(serviceQuickDlgOpen.value).toBe(true);
});
it('onServiceCreated chama loadServices e addItem com o svc fresco se encontrado', async () => {
const services = ref([{ id: 's-1', name: 'Sessão Atualizada', price: 200 }]);
const pickerBilling = makePickerBilling();
const { onServiceCreated, loadServices } = setup({ services, pickerBilling });
await onServiceCreated({ id: 's-1', name: 'Sessão', price: 100 });
expect(loadServices).toHaveBeenCalledWith('owner-1');
expect(pickerBilling.addItem).toHaveBeenCalledWith({ id: 's-1', name: 'Sessão Atualizada', price: 200 });
});
it('onServiceCreated com svc não na lista usa o param', async () => {
const services = ref([]);
const pickerBilling = makePickerBilling();
const { onServiceCreated } = setup({ services, pickerBilling });
await onServiceCreated({ id: 's-1', name: 'Param', price: 50 });
expect(pickerBilling.addItem).toHaveBeenCalledWith({ id: 's-1', name: 'Param', price: 50 });
});
it('onServiceCreated sem svc.id → não chama addItem', async () => {
const pickerBilling = makePickerBilling();
const { onServiceCreated } = setup({ pickerBilling });
await onServiceCreated(null);
expect(pickerBilling.addItem).not.toHaveBeenCalled();
});
it('openInsuranceQuickCreate seta flag', () => {
const { openInsuranceQuickCreate, insuranceQuickDlgOpen } = setup();
expect(insuranceQuickDlgOpen.value).toBe(false);
openInsuranceQuickCreate();
expect(insuranceQuickDlgOpen.value).toBe(true);
});
it('onInsuranceCreated chama loadInsurancePlans e seta plan_id', async () => {
const { onInsuranceCreated, loadInsurancePlans, composer } = setup({}, { planOwnerId: 'planowner-1' });
await onInsuranceCreated({ id: 'plan-99' });
expect(loadInsurancePlans).toHaveBeenCalledWith('planowner-1');
expect(composer.form.value.insurance_plan_id).toBe('plan-99');
});
it('onInsuranceCreated fallback ownerId quando não há planOwnerId', async () => {
const { onInsuranceCreated, loadInsurancePlans } = setup();
await onInsuranceCreated({ id: 'plan-1' });
expect(loadInsurancePlans).toHaveBeenCalledWith('owner-1');
});
});
// ════════════════════════════════════════════════════════════════════
describe('onSendManualReminder', () => {
it('no-op se form.id é null', async () => {
const { onSendManualReminder, confirm } = setup();
await onSendManualReminder();
expect(confirm.require).not.toHaveBeenCalled();
});
it('sucesso: chama functions.invoke + toast success', async () => {
const composer = makeComposer({ formExtra: { id: 'evt-1', paciente_nome: 'Marina' } });
_functionsInvoke.mockResolvedValueOnce({ data: { ok: true, to: '+5516988887777' }, error: null });
const { onSendManualReminder, toast, sendingReminder } = setup({ composer });
await onSendManualReminder();
expect(_functionsInvoke).toHaveBeenCalledWith('send-session-reminder-manual', { body: { event_id: 'evt-1' } });
expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ severity: 'success' }));
expect(sendingReminder.value).toBe(false);
});
it('error no_phone vira "Paciente sem telefone cadastrado."', async () => {
const composer = makeComposer({ formExtra: { id: 'evt-1' } });
_functionsInvoke.mockResolvedValueOnce({ data: { ok: false, error: 'no_phone' }, error: null });
const { onSendManualReminder, toast } = setup({ composer });
await onSendManualReminder();
expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({
severity: 'error',
detail: 'Paciente sem telefone cadastrado.'
}));
});
it('error template_not_found tem mensagem amigável', async () => {
const composer = makeComposer({ formExtra: { id: 'evt-1' } });
_functionsInvoke.mockResolvedValueOnce({ data: { ok: false, error: 'template_not_found' }, error: null });
const { onSendManualReminder, toast } = setup({ composer });
await onSendManualReminder();
expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({
detail: expect.stringContaining('lembrete_sessao')
}));
});
it('error send_failed_X também é amigável', async () => {
const composer = makeComposer({ formExtra: { id: 'evt-1' } });
_functionsInvoke.mockResolvedValueOnce({ data: { ok: false, error: 'send_failed_timeout' }, error: null });
const { onSendManualReminder, toast } = setup({ composer });
await onSendManualReminder();
expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({
detail: 'Não conseguimos enviar. Verifique a conexão do WhatsApp.'
}));
});
});
// ════════════════════════════════════════════════════════════════════
describe('watchers — tenant scope', () => {
it('quando tenantId muda e dialog visível, recarrega patients', async () => {
const composer = makeComposer();
const pickerBilling = makePickerBilling();
const { propsRef } = setup({ composer, pickerBilling });
pickerBilling.clearPatientsCache.mockClear();
pickerBilling.loadPatients.mockClear();
propsRef.value.tenantId = 'tenant-2';
await nextTick();
expect(pickerBilling.clearPatientsCache).toHaveBeenCalled();
expect(pickerBilling.loadPatients).toHaveBeenCalledWith(true);
});
it('não recarrega se dialog não está visível', async () => {
const composer = makeComposer();
composer.visible.value = false;
const pickerBilling = makePickerBilling();
const { propsRef } = setup({ composer, pickerBilling });
pickerBilling.clearPatientsCache.mockClear();
propsRef.value.tenantId = 'tenant-2';
await nextTick();
expect(pickerBilling.clearPatientsCache).not.toHaveBeenCalled();
});
});
describe('watchers — solicitação pendente', () => {
it('busca solicitação quando dia + startTime estão setados (não-edit)', async () => {
const composer = makeComposer({ formExtra: { dia: new Date('2026-05-04T12:00:00'), startTime: '14:00' } });
setResponse('agendador_solicitacoes', { data: { id: 'sol-1', paciente_nome: 'João' }, error: null });
const { solicitacaoPendente } = setup({ composer });
// dispara watcher: já estava setado mas não imediato — mudamos pra forçar
composer.form.value.startTime = '15:00';
await nextTick();
await new Promise((r) => setTimeout(r, 0));
expect(solicitacaoPendente.value).toEqual({ id: 'sol-1', paciente_nome: 'João' });
});
it('em modo edit não busca', async () => {
const composer = makeComposer({ formExtra: { dia: new Date('2026-05-04T12:00:00'), startTime: '14:00' } });
composer.isEdit.value = true;
const { solicitacaoPendente } = setup({ composer });
composer.form.value.startTime = '15:00';
await nextTick();
await new Promise((r) => setTimeout(r, 0));
expect(solicitacaoPendente.value).toBeNull();
});
it('sem ownerId não busca', async () => {
const composer = makeComposer({ formExtra: { dia: new Date('2026-05-04T12:00:00'), startTime: '14:00' } });
const { solicitacaoPendente } = setup({ composer }, { ownerId: '' });
composer.form.value.startTime = '15:00';
await nextTick();
await new Promise((r) => setTimeout(r, 0));
expect(solicitacaoPendente.value).toBeNull();
});
});
describe('watchers — online slots', () => {
it('modalidade=online + dia + ownerId → carrega slots', async () => {
const composer = makeComposer({ formExtra: { dia: new Date('2026-05-04T12:00:00'), modalidade: 'online' } });
setResponse('agenda_online_slots', {
data: [{ time: '14:00:00' }, { time: '15:00:00' }],
error: null
});
const { onlineSlots } = setup({ composer });
// immediate=true já disparou
await new Promise((r) => setTimeout(r, 0));
expect(onlineSlots.value).toEqual([{ hhmm: '14:00' }, { hhmm: '15:00' }]);
});
it('modalidade=presencial → zera slots', async () => {
const composer = makeComposer({ formExtra: { dia: new Date('2026-05-04T12:00:00'), modalidade: 'presencial' } });
const { onlineSlots } = setup({ composer });
await new Promise((r) => setTimeout(r, 0));
expect(onlineSlots.value).toEqual([]);
});
it('engole erro do supabase e zera lista', async () => {
const composer = makeComposer({ formExtra: { dia: new Date('2026-05-04T12:00:00'), modalidade: 'online' } });
// Não setamos response, mas vamos mockar o erro substituindo o builder
// — usa simply o caminho try/catch via reject do `then`
// Para esse caso, deixamos sem setResponse e ele retorna { data: null, error: null }
// (sem dispara erro). Vou setar erro explicitamente:
setResponse('agenda_online_slots', { data: null, error: new Error('boom') });
const { onlineSlots } = setup({ composer });
await new Promise((r) => setTimeout(r, 0));
// Como erro vem em `error` mas o código não checa explicitamente — só faz
// map sobre data || []. Logo, vai ser [] mesmo.
expect(onlineSlots.value).toEqual([]);
});
});
describe('watcher — modelValue init', () => {
it('ao abrir, reseta refs e chama orchestration', async () => {
const composer = makeComposer();
const actions = makeActions();
const pickerBilling = makePickerBilling();
const loadInsurancePlans = vi.fn().mockResolvedValue();
const serieValorMode = ref('dividir');
const { propsRef } = setup({ composer, actions, pickerBilling, serieValorMode, loadInsurancePlans });
propsRef.value.modelValue = true;
// deixa watcher async correr
await nextTick();
await new Promise((r) => setTimeout(r, 0));
await nextTick();
// resets aplicados
expect(composer.recorrenciaType.value).toBe('avulsa');
expect(composer.diasSelecionados.value).toEqual([]);
expect(composer.qtdSessoesMode.value).toBe('4');
expect(composer.qtdSessoesCustom.value).toBe(12);
expect(composer.editScope.value).toBe('somente_este');
expect(serieValorMode.value).toBe('multiplicar');
// step 1 (não-edit, sem preset)
expect(composer.step.value).toBe(1);
// ensureServicesLoaded chamado
expect(pickerBilling.ensureServicesLoaded).toHaveBeenCalled();
// billingType default novo evento
expect(composer.billingType.value).toBe('particular');
// _restoringConvenio resetado
expect(actions._restoringConvenio.value).toBe(false);
});
it('com presetCommitmentId vai pra step=2 + commitment_id setado', async () => {
const composer = makeComposer();
const { propsRef } = setup({ composer });
propsRef.value.presetCommitmentId = 'commit-99';
propsRef.value.modelValue = true;
await nextTick();
await new Promise((r) => setTimeout(r, 0));
await nextTick();
expect(composer.form.value.commitment_id).toBe('commit-99');
expect(composer.step.value).toBe(2);
});
it('em modo edit vai pra step=2 e chama _loadCommitmentItemsForEvent quando há id', async () => {
const composer = makeComposer({ formExtra: { id: 'evt-7' } });
composer.isEdit.value = true;
composer.resetForm = vi.fn(() => ({ ...composer.form.value })); // mantém id
const pickerBilling = makePickerBilling();
const { propsRef } = setup({ composer, pickerBilling });
propsRef.value.modelValue = true;
await nextTick();
await new Promise((r) => setTimeout(r, 0));
await nextTick();
expect(composer.step.value).toBe(2);
expect(pickerBilling._loadCommitmentItemsForEvent).toHaveBeenCalledWith('evt-7');
});
it('hasSerie=true dispara loadSerieEvents (sem rid → fica []) ', async () => {
const composer = makeComposer();
composer.hasSerie.value = true;
const { propsRef, serieEvents } = setup({ composer });
propsRef.value.modelValue = true;
await nextTick();
await new Promise((r) => setTimeout(r, 0));
// sem eventRow.recurrence_id, loadSerieEvents zera
expect(serieEvents.value).toEqual([]);
});
it('quando close (modelValue=false) não faz nada', async () => {
const composer = makeComposer();
composer.recorrenciaType.value = 'semanal';
const { propsRef } = setup({ composer });
propsRef.value.modelValue = false;
await nextTick();
// recorrenciaType permanece como estava
expect(composer.recorrenciaType.value).toBe('semanal');
});
});
@@ -0,0 +1,611 @@
/**
* useAgendaEventPickerBilling.spec.js A66 sub-sessão 1C-ii-a
*
* Cobre handlers de patient picker + billing items + 2 watchers
* (form.commitment_id, form.insurance_plan_id).
*
* Mock estratégia: monta um composer fake + actions fake com refs/computeds
* mínimos. Mock supabase.from('patients') pra loadPatients.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ref, computed } from 'vue';
// ── Mocks ─────────────────────────────────────────────────────
let _supabaseSelectArgs = null;
const _patientsResult = { data: [], error: null };
function makeQ() {
// O loadPatients faz: from().select().order().limit() e DEPOIS adiciona
// .eq() condicionalmente, finalizando com `await q`. Pra suportar isso,
// o mock retorna o mesmo `q` em todos os métodos da chain e implementa
// `then` (thenable) pra ser awaitable.
const q = {
select: (...args) => {
_supabaseSelectArgs = args;
return q;
},
eq: () => q,
order: () => q,
limit: () => q,
then: (resolve) => resolve(_patientsResult)
};
return q;
}
vi.mock('@/lib/supabase/client', () => ({
supabase: {
from: () => makeQ()
}
}));
function resetPatientsResult({ data = [], error = null } = {}) {
_patientsResult.data = data;
_patientsResult.error = error;
}
const { useAgendaEventPickerBilling } = await import('../useAgendaEventPickerBilling.js');
// ── Helpers ───────────────────────────────────────────────────
function makeComposer(overrides = {}) {
const form = ref({
commitment_id: null,
paciente_id: null,
paciente_nome: '',
paciente_avatar: '',
extra_fields: {},
price: null,
insurance_plan_id: null,
insurance_plan_service_id: null,
insurance_value: null,
insurance_guide_number: null,
duracaoMin: 50,
...(overrides.formExtra || {})
});
return {
form,
billingType: ref('particular'),
isEdit: ref(false),
visible: ref(true),
step: ref(1),
requiresPatient: ref(true),
allowBack: ref(true),
...overrides
};
}
function makeActions() {
return {
_restoringConvenio: ref(false),
samePatientConflict: ref(null)
};
}
function setup(overrides = {}, propsOverrides = {}) {
_supabaseSelectArgs = null;
// NÃO resetamos _patientsResult aqui pra permitir que o teste pré-popule.
// Cada `it` chama resetPatientsResult() explicitamente quando precisa.
const composer = overrides.composer ?? makeComposer();
const actions = overrides.actions ?? makeActions();
const commitmentItems = overrides.commitmentItems ?? ref([]);
const servicePickerSel = overrides.servicePickerSel ?? ref(null);
const selectedPlanService = overrides.selectedPlanService ?? ref(null);
const services = overrides.services ?? ref([]);
const loadServices = vi.fn().mockResolvedValue();
const getDefaultPrice = overrides.getDefaultPrice ?? vi.fn(() => null);
const planServices = overrides.planServices ?? computed(() => []);
const loadActiveDiscount = overrides.loadActiveDiscount ?? vi.fn().mockResolvedValue(null);
const _csLoadItems = overrides._csLoadItems ?? vi.fn().mockResolvedValue([]);
const _csLoadItemsOrTemplate = overrides._csLoadItemsOrTemplate ?? vi.fn().mockResolvedValue([]);
const isDynamic = overrides.isDynamic ?? computed(() => false);
const props = {
ownerId: 'owner-1',
tenantId: 'tenant-1',
eventRow: null,
agendaSettings: { session_duration_min: 50 },
restrictPatientsToOwner: false,
patientScopeOwnerId: null,
newPatientRoute: '',
...propsOverrides
};
const result = useAgendaEventPickerBilling({
composer,
actions,
commitmentItems,
servicePickerSel,
selectedPlanService,
services,
loadServices,
getDefaultPrice,
planServices,
loadActiveDiscount,
_csLoadItems,
_csLoadItemsOrTemplate,
isDynamic,
props
});
return {
composer,
actions,
commitmentItems,
servicePickerSel,
selectedPlanService,
services,
loadServices,
getDefaultPrice,
planServices,
loadActiveDiscount,
_csLoadItems,
_csLoadItemsOrTemplate,
isDynamic,
props,
...result
};
}
// ════════════════════════════════════════════════════════════════════
describe('addItem', () => {
it('no-op sem service.id', async () => {
const { commitmentItems, addItem } = setup();
await addItem(null);
await addItem({});
expect(commitmentItems.value).toEqual([]);
});
it('adiciona item novo com final_price calculado', async () => {
const { commitmentItems, addItem } = setup();
await addItem({ id: 's-1', name: 'Sessão', price: 100 });
expect(commitmentItems.value).toHaveLength(1);
expect(commitmentItems.value[0]).toMatchObject({
service_id: 's-1',
service_name: 'Sessão',
quantity: 1,
unit_price: 100,
discount_pct: 0,
discount_flat: 0,
final_price: 100
});
});
it('incrementa quantity quando service_id já existe', async () => {
const { commitmentItems, addItem } = setup();
await addItem({ id: 's-1', name: 'Sessão', price: 100 });
await addItem({ id: 's-1', name: 'Sessão', price: 100 });
await addItem({ id: 's-1', name: 'Sessão', price: 100 });
expect(commitmentItems.value).toHaveLength(1);
expect(commitmentItems.value[0].quantity).toBe(3);
expect(commitmentItems.value[0].final_price).toBe(300);
});
it('aplica desconto ativo do paciente', async () => {
const composer = makeComposer({ formExtra: { paciente_id: 'p-1' } });
const loadActiveDiscount = vi.fn().mockResolvedValue({ discount_pct: 10, discount_flat: 5 });
const { commitmentItems, addItem } = setup({ composer, loadActiveDiscount });
await addItem({ id: 's-1', name: 'X', price: 100 });
expect(loadActiveDiscount).toHaveBeenCalledWith('owner-1', 'p-1');
// 100 - 10% = 90 - 5 = 85
expect(commitmentItems.value[0].final_price).toBe(85);
});
it('sem patient_id NÃO chama loadActiveDiscount', async () => {
const { addItem, loadActiveDiscount } = setup();
await addItem({ id: 's-1', name: 'X', price: 100 });
expect(loadActiveDiscount).not.toHaveBeenCalled();
});
});
describe('removeItem', () => {
it('remove item por índice', () => {
const items = ref([
{ service_id: 'a', quantity: 1, final_price: 100 },
{ service_id: 'b', quantity: 1, final_price: 200 }
]);
const { removeItem, commitmentItems } = setup({ commitmentItems: items });
removeItem(0);
expect(commitmentItems.value).toHaveLength(1);
expect(commitmentItems.value[0].service_id).toBe('b');
});
it('lista vazia em modo dynamic restaura duração padrão', () => {
const composer = makeComposer({ formExtra: { duracaoMin: 30 } });
const isDynamic = computed(() => true);
const items = ref([{ service_id: 'a', quantity: 1, final_price: 100 }]);
const { removeItem } = setup({ composer, isDynamic, commitmentItems: items });
removeItem(0);
expect(composer.form.value.duracaoMin).toBe(50); // session_duration_min default
});
it('NÃO restaura duração se !isDynamic', () => {
const composer = makeComposer({ formExtra: { duracaoMin: 30 } });
const items = ref([{ service_id: 'a', quantity: 1, final_price: 100 }]);
const { removeItem } = setup({ composer, commitmentItems: items });
removeItem(0);
expect(composer.form.value.duracaoMin).toBe(30);
});
});
describe('onItemChange', () => {
it('recalcula final_price baseado em quantity/discounts', () => {
const { onItemChange } = setup();
const item = { unit_price: 100, quantity: 2, discount_pct: 10, discount_flat: 0, final_price: 0 };
onItemChange(item);
expect(item.final_price).toBe(180); // 200 - 10% = 180
});
});
describe('onProcedureSelect', () => {
it('seta plan_service_id e atualiza insurance_value', () => {
const planServices = computed(() => [{ id: 'ps-1', value: 250.5 }]);
const composer = makeComposer();
const { onProcedureSelect } = setup({ composer, planServices });
onProcedureSelect('ps-1');
expect(composer.form.value.insurance_plan_service_id).toBe('ps-1');
expect(composer.form.value.insurance_value).toBe(250.5);
});
it('null limpa insurance_value', () => {
const composer = makeComposer({ formExtra: { insurance_value: 100 } });
const { onProcedureSelect } = setup({ composer });
onProcedureSelect(null);
expect(composer.form.value.insurance_plan_service_id).toBe(null);
expect(composer.form.value.insurance_value).toBe(null);
});
it('id desconhecido limpa insurance_value', () => {
const planServices = computed(() => [{ id: 'ps-1', value: 250 }]);
const composer = makeComposer({ formExtra: { insurance_value: 100 } });
const { onProcedureSelect } = setup({ composer, planServices });
onProcedureSelect('ps-x');
expect(composer.form.value.insurance_plan_service_id).toBe('ps-x');
expect(composer.form.value.insurance_value).toBe(null);
});
});
describe('selectCommitment', () => {
it('seta commitment_id, reseta extra_fields, vai pra step 2', () => {
const composer = makeComposer();
const { selectCommitment } = setup({ composer });
selectCommitment({ id: 'c-1', name: 'X', fields: [{ key: 'idade' }, { key: 'peso' }] });
expect(composer.form.value.commitment_id).toBe('c-1');
expect(composer.form.value.extra_fields).toEqual({ idade: '', peso: '' });
expect(composer.step.value).toBe(2);
});
it('no-op sem id', () => {
const composer = makeComposer();
const { selectCommitment } = setup({ composer });
composer.step.value = 1;
selectCommitment(null);
selectCommitment({});
expect(composer.step.value).toBe(1);
});
});
describe('goBack', () => {
it('volta pra step 1 e limpa commitment + paciente', () => {
const composer = makeComposer({
formExtra: { commitment_id: 'c-1', paciente_id: 'p-1', paciente_nome: 'Ana' }
});
composer.step.value = 2;
const { goBack } = setup({ composer });
goBack();
expect(composer.step.value).toBe(1);
expect(composer.form.value.commitment_id).toBe(null);
expect(composer.form.value.paciente_id).toBe(null);
expect(composer.form.value.paciente_nome).toBe('');
});
it('no-op em edição', () => {
const composer = makeComposer();
composer.isEdit.value = true;
composer.step.value = 2;
const { goBack } = setup({ composer });
goBack();
expect(composer.step.value).toBe(2);
});
it('no-op com !allowBack', () => {
const composer = makeComposer();
composer.allowBack.value = false;
composer.step.value = 2;
const { goBack } = setup({ composer });
goBack();
expect(composer.step.value).toBe(2);
});
});
describe('selectPaciente / clearPaciente', () => {
it('selectPaciente preenche form e fecha picker', () => {
const composer = makeComposer();
const { selectPaciente, pacientePickerOpen } = setup({ composer });
pacientePickerOpen.value = true;
selectPaciente({ id: 'p-1', nome: 'Ana', avatar_url: 'url' });
expect(composer.form.value.paciente_id).toBe('p-1');
expect(composer.form.value.paciente_nome).toBe('Ana');
expect(composer.form.value.paciente_avatar).toBe('url');
expect(pacientePickerOpen.value).toBe(false);
});
it('selectPaciente no-op sem id', () => {
const composer = makeComposer();
const { selectPaciente } = setup({ composer });
selectPaciente(null);
selectPaciente({});
expect(composer.form.value.paciente_id).toBe(null);
});
it('clearPaciente limpa form + samePatientConflict', () => {
const composer = makeComposer({
formExtra: { paciente_id: 'p-1', paciente_nome: 'Ana', paciente_avatar: 'url' }
});
const actions = makeActions();
actions.samePatientConflict.value = { id: 'evt-x' };
const { clearPaciente } = setup({ composer, actions });
clearPaciente();
expect(composer.form.value.paciente_id).toBe(null);
expect(composer.form.value.paciente_nome).toBe('');
expect(actions.samePatientConflict.value).toBe(null);
});
});
describe('openPacientePicker', () => {
it('abre picker e dispara loadPatients', () => {
const composer = makeComposer();
composer.requiresPatient.value = true;
const { openPacientePicker, pacientePickerOpen } = setup({ composer });
openPacientePicker();
expect(pacientePickerOpen.value).toBe(true);
});
it('no-op quando NÃO requiresPatient', () => {
const composer = makeComposer();
composer.requiresPatient.value = false;
const { openPacientePicker, pacientePickerOpen } = setup({ composer });
openPacientePicker();
expect(pacientePickerOpen.value).toBe(false);
});
});
describe('clearPatientsCache', () => {
it('zera patients, search e error', () => {
const { clearPatientsCache, patients, pacienteSearch, pacientesError } = setup();
patients.value = [{ id: 'p-1' }];
pacienteSearch.value = 'foo';
pacientesError.value = 'err';
clearPatientsCache();
expect(patients.value).toEqual([]);
expect(pacienteSearch.value).toBe('');
expect(pacientesError.value).toBe('');
});
});
describe('loadPatients', () => {
it('faz fetch e mapeia resultado', async () => {
const { loadPatients, patients } = setup();
resetPatientsResult({
data: [{ id: 'p-1', nome_completo: 'Ana', email_principal: 'a@x', telefone: '11', status: 'Ativo', avatar_url: 'u' }]
});
await loadPatients(true);
expect(patients.value).toHaveLength(1);
expect(patients.value[0]).toMatchObject({
id: 'p-1',
nome: 'Ana',
email: 'a@x',
telefone: '11'
});
});
it('skip quando já tem cache e !force', async () => {
const { loadPatients, patients } = setup();
patients.value = [{ id: 'cached' }];
await loadPatients(false);
expect(patients.value).toEqual([{ id: 'cached' }]);
});
it('error → setta pacientesError e zera lista', async () => {
const { loadPatients, patients, pacientesError } = setup();
resetPatientsResult({ data: null, error: new Error('boom') });
await loadPatients(true);
expect(patients.value).toEqual([]);
expect(pacientesError.value).toBe('boom');
});
});
describe('applyDefaultPrice', () => {
it('skip em billingType=particular', () => {
const composer = makeComposer();
composer.billingType.value = 'particular';
const getDefaultPrice = vi.fn(() => 100);
const { applyDefaultPrice } = setup({ composer, getDefaultPrice });
applyDefaultPrice();
expect(composer.form.value.price).toBe(null);
});
it('skip em edição', () => {
const composer = makeComposer();
composer.billingType.value = 'gratuito';
composer.isEdit.value = true;
const getDefaultPrice = vi.fn(() => 100);
const { applyDefaultPrice } = setup({ composer, getDefaultPrice });
applyDefaultPrice();
expect(composer.form.value.price).toBe(null);
});
it('aplica em criação + billingType != particular', () => {
const composer = makeComposer();
composer.billingType.value = 'gratuito';
const getDefaultPrice = vi.fn(() => 75);
const { applyDefaultPrice } = setup({ composer, getDefaultPrice });
applyDefaultPrice();
expect(composer.form.value.price).toBe(75);
});
});
describe('Watcher: form.commitment_id (auto-fill price)', () => {
it('dispara em criação + visível: ensureServices + applyDefaultPrice', async () => {
const composer = makeComposer();
// applyDefaultPrice skip se billingType=particular (default); usar 'gratuito'
composer.billingType.value = 'gratuito';
const getDefaultPrice = vi.fn(() => 80);
const { loadServices, composer: c } = setup({ composer, getDefaultPrice });
c.form.value.commitment_id = 'c-1';
await new Promise((r) => setTimeout(r, 0));
expect(loadServices).toHaveBeenCalled();
expect(c.form.value.price).toBe(80);
});
it('NÃO dispara em edição', async () => {
const composer = makeComposer();
composer.isEdit.value = true;
const { loadServices } = setup({ composer });
composer.form.value.commitment_id = 'c-1';
await new Promise((r) => setTimeout(r, 0));
expect(loadServices).not.toHaveBeenCalled();
});
it('NÃO dispara quando dialog fechado (!visible)', async () => {
const composer = makeComposer();
composer.visible.value = false;
const { loadServices } = setup({ composer });
composer.form.value.commitment_id = 'c-1';
await new Promise((r) => setTimeout(r, 0));
expect(loadServices).not.toHaveBeenCalled();
});
});
describe('Watcher: form.insurance_plan_id', () => {
it('seleciona convênio: limpa items + servicePickerSel', async () => {
const composer = makeComposer();
const items = ref([{ service_id: 'a' }]);
const sps = ref('svc-x');
const { commitmentItems, servicePickerSel } = setup({
composer,
commitmentItems: items,
servicePickerSel: sps
});
composer.form.value.insurance_plan_id = 'plan-1';
await new Promise((r) => setTimeout(r, 0));
expect(commitmentItems.value).toEqual([]);
expect(servicePickerSel.value).toBe(null);
});
it('desmarca convênio: limpa insurance_value e guide', async () => {
const composer = makeComposer({
formExtra: { insurance_value: 100, insurance_guide_number: 'X' }
});
const { composer: c } = setup({ composer });
c.form.value.insurance_plan_id = 'plan-1';
await new Promise((r) => setTimeout(r, 0));
c.form.value.insurance_plan_id = null;
await new Promise((r) => setTimeout(r, 0));
expect(c.form.value.insurance_value).toBe(null);
expect(c.form.value.insurance_guide_number).toBe(null);
});
it('ignorado quando _restoringConvenio', async () => {
const composer = makeComposer();
const actions = makeActions();
actions._restoringConvenio.value = true;
const items = ref([{ service_id: 'a' }]);
const { commitmentItems } = setup({
composer,
actions,
commitmentItems: items
});
composer.form.value.insurance_plan_id = 'plan-1';
await new Promise((r) => setTimeout(r, 0));
// Restoração ativa: não toca em commitmentItems
expect(commitmentItems.value).toHaveLength(1);
});
});
describe('_loadCommitmentItemsForEvent', () => {
it('sem eventId nem ruleId: limpa items, billingType=particular', async () => {
const composer = makeComposer();
const items = ref([{ service_id: 'a' }]);
const { _loadCommitmentItemsForEvent, commitmentItems } = setup({
composer,
commitmentItems: items
});
await _loadCommitmentItemsForEvent(null);
expect(commitmentItems.value).toEqual([]);
expect(composer.billingType.value).toBe('particular');
});
it('eventRow com insurance_plan_id: aplica convenio', async () => {
const composer = makeComposer();
const props = {
ownerId: 'owner-1',
tenantId: 't-1',
agendaSettings: { session_duration_min: 50 },
eventRow: {
insurance_plan_id: 'plan-1',
insurance_guide_number: 'G-1',
insurance_value: 200,
insurance_plan_service_id: 'ps-1'
}
};
const { _loadCommitmentItemsForEvent } = setup({ composer }, props);
await _loadCommitmentItemsForEvent('evt-1');
// Espera nextTick interno
await new Promise((r) => setTimeout(r, 10));
expect(composer.billingType.value).toBe('convenio');
expect(composer.form.value.insurance_plan_id).toBe('plan-1');
expect(composer.form.value.insurance_value).toBe(200);
});
it('com items carregados: billingType=particular', async () => {
const composer = makeComposer();
const _csLoadItems = vi.fn().mockResolvedValue([{ service_id: 's-1', final_price: 100 }]);
const { _loadCommitmentItemsForEvent, commitmentItems } = setup({
composer,
_csLoadItems
});
await _loadCommitmentItemsForEvent('evt-1');
expect(commitmentItems.value).toHaveLength(1);
expect(composer.billingType.value).toBe('particular');
});
it('sem items: billingType=gratuito', async () => {
const composer = makeComposer();
const _csLoadItems = vi.fn().mockResolvedValue([]);
const { _loadCommitmentItemsForEvent } = setup({ composer, _csLoadItems });
await _loadCommitmentItemsForEvent('evt-1');
expect(composer.billingType.value).toBe('gratuito');
});
it('error: items=[], billingType=gratuito', async () => {
const composer = makeComposer();
const _csLoadItems = vi.fn().mockRejectedValue(new Error('boom'));
const { _loadCommitmentItemsForEvent, commitmentItems } = setup({
composer,
_csLoadItems
});
await _loadCommitmentItemsForEvent('evt-1');
expect(commitmentItems.value).toEqual([]);
expect(composer.billingType.value).toBe('gratuito');
});
});
describe('ensureServicesLoaded', () => {
it('carrega só uma vez (gate)', async () => {
const { ensureServicesLoaded, loadServices } = setup();
await ensureServicesLoaded();
await ensureServicesLoaded();
await ensureServicesLoaded();
expect(loadServices).toHaveBeenCalledTimes(1);
});
it('skip sem ownerId', async () => {
const { ensureServicesLoaded, loadServices } = setup({}, { ownerId: '' });
await ensureServicesLoaded();
expect(loadServices).not.toHaveBeenCalled();
});
it('resetServicesGate permite re-load', async () => {
const { ensureServicesLoaded, resetServicesGate, loadServices } = setup();
await ensureServicesLoaded();
resetServicesGate();
await ensureServicesLoaded();
expect(loadServices).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,262 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/composables/agendaEventHelpers.js
| Data: 2026-05-04
|
| Helpers PUROS extraídos do AgendaEventDialog.vue (sub-sessão 1A do
| refator A66 vide HANDOFF.md). Sem dependência de Vue ou de refs
| reativos. Recebem entrada retornam saída. Testáveis isoladamente.
|
| O módulo cobre 4 categorias:
| 1. Formatters de data/hora/duração/moeda (fmt*)
| 2. Parsers/conversores (hhmmToMin, minToHHMM, isoToHHMM, ...)
| 3. Predicados (isPast, isNativeSession, isForaDoPlano)
| 4. Cálculos (calcFinalPrice, calcMinutes, addMinutesDate)
| 5. Mapeamentos de status (labelStatusSessao, statusSeverity,
| statusExtraClass) fixos por design.
|
| Próximas etapas (1B/1C) extraem state + computeds + handlers reativos
| num composable factory que dependerá deste módulo.
|--------------------------------------------------------------------------
*/
// ────────────────────────────────────────────────────────────────────────
// Identidade / texto
// ────────────────────────────────────────────────────────────────────────
/**
* Iniciais do paciente pra avatar fallback (ex. "Ana Souza" "AS").
* Trata "" / null retornando '?'.
*/
export function patientInitials(nome) {
const parts = String(nome || '')
.trim()
.split(/\s+/)
.filter(Boolean);
if (!parts.length) return '?';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
// ────────────────────────────────────────────────────────────────────────
// Formatters — moeda, hora, data, duração
// ────────────────────────────────────────────────────────────────────────
/** Formata número como BRL (R$ 1.234,56). null/undefined → '—'. */
export function fmtBRL(v) {
if (v == null) return '—';
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
/**
* Hora compacta pra labels de jornada/duração (ex. "9h", "14h30").
* Suporta entrada "HH:MM" ou "HH:MM:SS".
*/
export function fmtJornadaHora(hhmm) {
const [h, m] = String(hhmm || '00:00')
.slice(0, 5)
.split(':')
.map(Number);
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`;
}
/** Data BR curta (15 mai 2026). Aceita Date ou string ISO. */
export function fmtDateBR(d) {
const dt = d instanceof Date ? d : new Date(d);
return dt.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' });
}
/** Data BR longa (sex, 15 mai). Aceita Date ou string ISO. */
export function fmtDateBRLong(d) {
const dt = d instanceof Date ? d : new Date(d);
return dt.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: 'short' });
}
/** Hora HH:MM. null → '—'. */
export function fmtTime(d) {
if (!d) return '—';
return new Date(d).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
/** Duração legível (90 → "1h 30min", 45 → "45min", 0/null → "—"). */
export function fmtDuracao(min) {
const m = Number(min || 0);
if (!m) return '—';
const h = Math.floor(m / 60);
const r = m % 60;
if (h && r) return `${h}h ${r}min`;
if (h) return `${h}h`;
return `${r}min`;
}
/** Hora da série truncada em HH:MM (descarta segundos do TIME). */
export function fmtSerieHora(hora) {
if (!hora) return '—';
return String(hora).slice(0, 5);
}
/** Dia da semana 0-6 → nome lowercase ('domingo'..'sábado'). */
export function nomeDiaSemana(dow) {
const nomes = ['domingo', 'segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado'];
return nomes[Number(dow ?? 0)] ?? '—';
}
/** Weekday curto a partir de ISO ('seg', 'ter', ...). */
export function fmtWeekdayShort(iso) {
return new Date(iso).toLocaleDateString('pt-BR', { weekday: 'short' }).replace('.', '').slice(0, 3);
}
/** Dia do mês a partir de ISO. */
export function fmtDayNum(iso) {
return new Date(iso).getDate();
}
/** Mês curto a partir de ISO ('mai', 'jun', ...). */
export function fmtMonthShort(iso) {
return new Date(iso).toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '');
}
// ────────────────────────────────────────────────────────────────────────
// Parsers / conversores
// ────────────────────────────────────────────────────────────────────────
/** "HH:MM" → minutos do dia. Trata null/inválido como 0. */
export function hhmmToMin(hhmm) {
const [h, m] = String(hhmm || '00:00')
.slice(0, 5)
.split(':')
.map(Number);
return (h || 0) * 60 + (m || 0);
}
/** Minutos do dia → "HH:MM" zero-padded. Wrapping em 24h. */
export function minToHHMM(min) {
const h = Math.floor(min / 60) % 24;
const m = min % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
/**
* Extrai HH:MM de um ISO timestamp respeitando timezone:
* - Se traz Z ou ±HH:MM no final converte pra timezone local
* - Se for ISO sem timezone (ex: "2026-05-15T14:30:00") os
* dígitos diretamente (evita drift quando o backend mandou
* hora "como deveria aparecer").
*/
export function isoToHHMM(iso) {
if (!iso) return null;
const s = String(iso);
if (s.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(s)) {
const d = new Date(s);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
const match = s.match(/T(\d{2}):(\d{2})/);
if (match) return `${match[1]}:${match[2]}`;
const d = new Date(s);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
// ────────────────────────────────────────────────────────────────────────
// Predicados
// ────────────────────────────────────────────────────────────────────────
/** Data já passou (em relação ao now). null/falsy → false. */
export function isPast(iso) {
return iso ? new Date(iso) < new Date() : false;
}
/**
* Retorna true se o commitment é uma "sessão nativa" (categoria especial
* que requer paciente vinculado e habilita financeiro/recorrência).
* Schema: commitments.native_key = 'session' (case-insensitive).
*/
export function isNativeSession(c) {
return String(c?.native_key || '').toLowerCase() === 'session';
}
/**
* Verifica se uma data está fora do plano (após dataLimiteManual).
* dataLimiteManual=null tudo dentro do plano (false).
* Antes a função era impura (lia dataLimiteManual.value de ref); agora
* recebe o valor explicitamente pra ser testável.
*/
export function isForaDoPlano(d, dataLimiteManual) {
if (!dataLimiteManual) return false;
return new Date(d) > new Date(dataLimiteManual);
}
// ────────────────────────────────────────────────────────────────────────
// Cálculos
// ────────────────────────────────────────────────────────────────────────
/** Adiciona N minutos a uma data, retorna NOVA Date (não muta entrada). */
export function addMinutesDate(date, min) {
const d = new Date(date);
d.setMinutes(d.getMinutes() + Number(min || 0));
return d;
}
/**
* Diferença em minutos (b - a) entre duas datas/strings ISO.
* Negativos viram 0 (proteção contra range invertido).
* Erros (datas inválidas) null.
*/
export function calcMinutes(a, b) {
try {
if (!a || !b) return null;
const ms = new Date(b).getTime() - new Date(a).getTime();
return Math.max(0, Math.round(ms / 60000));
} catch {
return null;
}
}
/**
* Preço final de um item de billing aplicando desconto percentual e flat.
* subtotal = unit_price * quantity
* final = max(0, subtotal - subtotal*pct% - flat)
* Garante não-negativo (descontos > subtotal viram zero).
*/
export function calcFinalPrice(unit_price, quantity, discount_pct, discount_flat) {
const subtotal = Number(unit_price) * Number(quantity);
const discPct = subtotal * (Number(discount_pct ?? 0) / 100);
const discFlat = Number(discount_flat ?? 0);
return Math.max(0, subtotal - discPct - discFlat);
}
// ────────────────────────────────────────────────────────────────────────
// Mapeamentos de status da sessão
// ────────────────────────────────────────────────────────────────────────
const STATUS_LABEL_MAP = Object.freeze({
agendado: 'Agendado',
realizado: 'Realizado',
faltou: 'Faltou',
cancelado: 'Cancelado',
remarcado: 'Remarcado'
});
/** Status enum → label legível. Desconhecido → '—'. */
export function labelStatusSessao(v) {
return STATUS_LABEL_MAP[v] || '—';
}
/** Status → severity do PrimeVue Tag (info/success/warn/danger/secondary). */
export function statusSeverity(v) {
if (v === 'agendado') return 'info';
if (v === 'realizado') return 'success';
if (v === 'faltou') return 'warn';
if (v === 'cancelado') return 'danger';
if (v === 'remarcado') return 'secondary'; // cor real via classe CSS
return 'secondary';
}
/**
* Classe CSS extra pra status que precisam de cor custom (PrimeVue
* severity não tem roxo nativo).
*/
export function statusExtraClass(v) {
return v === 'remarcado' ? 'tag-remarcado' : '';
}
@@ -0,0 +1,387 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/composables/useAgendaEventActions.js
| Data: 2026-05-04
|
| Watchers + handlers de save/delete extraídos do AgendaEventDialog.vue
| (A66 sub-sessão 1C-i). Contém SIDE-EFFECTS (supabase, confirm, emit, toast)
| diferente do composer (1B) que é state + computeds derivados.
|
| Escopo da 1C-i:
| - Watcher do form.status (confirm dialog cancelado/remarcado + supabase update)
| - Watcher do billingType (limpa campos por tipo)
| - Watcher [paciente_id, dia] (detecta conflito do mesmo paciente no dia)
| - onSave (monta payload + emit)
| - onDelete (avulsa OU série com confirm)
| - onEncerrarSerie (confirm de encerramento série inteira)
|
| Não inclui (vai pra 1C-ii):
| - Watcher do props.modelValue (init form ao abrir depende de loadPatients,
| ensureServicesLoaded, loadInsurancePlans, _loadCommitmentItemsForEvent)
| - Patient picker handlers (loadPatients, selectPaciente, ...)
| - Billing/items handlers (addItem, removeItem, ...)
| - Series pills handlers
| - Slot selection
|
| Recebe via argumento:
| composer resultado de useAgendaEventComposer (form, canSave, etc)
| commitmentItems ref<Item[]> dos serviços/billing
| servicePickerSel ref do select picker
| selectedPlanService ref do procedure de convênio
| saveCommitmentItems function de useCommitmentServices (callback do save)
| props, emit do componente parent
|--------------------------------------------------------------------------
*/
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { labelStatusSessao } from './agendaEventHelpers';
const EVENTO_TIPO_SESSAO = 'sessao';
export function useAgendaEventActions({
composer,
commitmentItems,
servicePickerSel,
selectedPlanService,
saveCommitmentItems,
props,
emit
}) {
const toast = useToast();
const confirm = useConfirm();
// Refs internos compartilhados com o .vue (que ainda tem watchers
// próprios em 1C-ii). Expostos no return pra leitura/escrita externa.
const _skipStatusWatch = ref(false);
const _prevStatus = ref(null);
const _restoringConvenio = ref(false);
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.
// ────────────────────────────────────────────────────────────────────
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;
_prevStatus.value = oldVal;
const isCancelar = newVal === 'cancelado';
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',
acceptLabel: 'Sim, confirmar',
rejectLabel: 'Não',
acceptSeverity: isCancelar ? 'danger' : 'warn',
accept: async () => {
try {
const { data, error } = await supabase
.from('agenda_eventos')
.update({ status: newVal })
.eq('id', composer.form.value.id)
.select()
.single();
if (error) throw error;
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
});
composer.form.value.status = _prevStatus.value;
}
},
reject: () => {
composer.form.value.status = _prevStatus.value;
}
});
}
);
// ────────────────────────────────────────────────────────────────────
// 2. Watcher do billingType — quando troca tipo (gratuito/particular/
// convenio), limpa campos dos outros tipos pra não vazar valores.
// ────────────────────────────────────────────────────────────────────
watch(composer.billingType, (val) => {
if (val === 'gratuito') {
commitmentItems.value = [];
composer.form.value.price = 0;
composer.form.value.insurance_plan_id = null;
composer.form.value.insurance_guide_number = null;
composer.form.value.insurance_value = null;
if (selectedPlanService) selectedPlanService.value = null;
}
if (val === 'particular') {
composer.form.value.insurance_plan_id = null;
composer.form.value.insurance_guide_number = null;
composer.form.value.insurance_value = null;
if (selectedPlanService) selectedPlanService.value = null;
}
if (val === 'convenio') {
commitmentItems.value = [];
if (servicePickerSel) servicePickerSel.value = null;
}
});
// ────────────────────────────────────────────────────────────────────
// 3. Watcher [paciente_id, dia] — detecta se o paciente já tem outra
// sessão no mesmo dia. Não bloqueia o save (só informa via UI).
// ────────────────────────────────────────────────────────────────────
watch(
() => [composer.form.value.paciente_id, composer.form.value.dia?.toString()],
async () => {
const pid = composer.form.value.paciente_id;
samePatientConflict.value = null;
if (!pid || !composer.isSessionEvent.value || !composer.visible.value) return;
const d = composer.form.value.dia ? new Date(composer.form.value.dia) : new Date();
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
const dayEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).toISOString();
let q = supabase
.from('agenda_eventos')
.select('id, inicio_em, fim_em, titulo')
.eq('patient_id', pid)
.gte('inicio_em', dayStart)
.lt('inicio_em', dayEnd)
.limit(1);
if (composer.form.value.id) q = q.neq('id', composer.form.value.id);
const { data } = await q.maybeSingle();
samePatientConflict.value = data || null;
}
);
// ────────────────────────────────────────────────────────────────────
// Helpers internos (puros) pra montar payload — extraídos pra serem
// testáveis e reutilizáveis. Não dependem de refs reativos diretos,
// recebem o form como argumento.
// ────────────────────────────────────────────────────────────────────
function buildSavePayload({ form, requiresPatient, isSessionEvent, computedTitulo, inicioISO, fimISO }) {
return {
owner_id: form.owner_id,
terapeuta_id: form.terapeuta_id,
paciente_id: requiresPatient ? form.paciente_id : null,
patient_id: requiresPatient ? form.paciente_id : null,
tipo: EVENTO_TIPO_SESSAO,
status: form.status || 'agendado',
titulo: computedTitulo || null,
modalidade: form.modalidade || null,
observacoes: form.observacoes || null,
inicio_em: inicioISO,
fim_em: fimISO,
determined_commitment_id: form.commitment_id || null,
titulo_custom: form.titulo_custom || null,
extra_fields: Object.keys(form.extra_fields || {}).length ? form.extra_fields : null,
price: isSessionEvent ? (form.price ?? null) : null,
insurance_plan_id: isSessionEvent ? (form.insurance_plan_id ?? null) : null,
insurance_guide_number: isSessionEvent ? (form.insurance_guide_number ?? null) : null,
insurance_value: isSessionEvent ? (form.insurance_value ?? null) : null,
insurance_plan_service_id: isSessionEvent ? (form.insurance_plan_service_id ?? null) : null
};
}
function buildRecorrenciaPayload({
recorrenciaType,
diaSemanaRecorrencia,
diasSelecionados,
startTime,
duracaoMin,
dataFimCalculada,
qtdSessoesEfetiva,
serieValorMode,
commitmentItemsList,
ocorrenciasComConflito
}) {
if (recorrenciaType === 'avulsa') return null;
return {
tipo: 'recorrente',
tipoFreq: recorrenciaType,
diaSemana: diaSemanaRecorrencia,
diasSemana: diasSelecionados,
horaInicio: startTime ? `${startTime}:00` : null,
duracaoMin,
dataFim: dataFimCalculada ? dataFimCalculada.toISOString() : null,
qtdSessoes: qtdSessoesEfetiva,
serieValorMode,
commitmentItems: commitmentItemsList.slice(),
conflitos: ocorrenciasComConflito
.filter((o) => o.conflict)
.map((o) => ({ date: o.date.toISOString().slice(0, 10), conflict: o.conflict }))
};
}
// ────────────────────────────────────────────────────────────────────
// 4. onSave — valida (canSave + timeConflict), monta payload e emite.
// ────────────────────────────────────────────────────────────────────
function onSave() {
if (!composer.canSave.value) return;
if (composer.timeConflict.value) {
toast.add({
severity: 'warn',
summary: 'Conflito de horário',
detail: `${composer.timeConflict.value}. Ajuste o horário ou a duração.`,
life: 4500
});
return;
}
const inicioISO = composer.inicioDateTime.value?.toISOString() || null;
const fimISO = composer.fimDateTime.value?.toISOString() || null;
const payload = buildSavePayload({
form: composer.form.value,
requiresPatient: composer.requiresPatient.value,
isSessionEvent: composer.isSessionEvent.value,
computedTitulo: composer.computedTitulo.value,
inicioISO,
fimISO
});
// serieValorMode e similars não estão no composer (1B); são lidos
// do .vue via props.eventActionsExtras se passados, ou null como
// default. 1C-i: assumimos null se não fornecido pra simplificar.
const recorrencia = composer.isSessionEvent.value
? buildRecorrenciaPayload({
recorrenciaType: composer.recorrenciaType.value,
diaSemanaRecorrencia: composer.diaSemanaRecorrencia.value,
diasSelecionados: composer.diasSelecionados.value,
startTime: composer.form.value.startTime,
duracaoMin: composer.form.value.duracaoMin,
dataFimCalculada: composer.dataFimCalculada.value,
qtdSessoesEfetiva: composer.qtdSessoesEfetiva.value,
serieValorMode: 'multiplicar', // default; .vue pode passar outro via _serieValorMode
commitmentItemsList: commitmentItems.value,
ocorrenciasComConflito: composer.ocorrenciasComConflito.value
})
: null;
// Escopo de edição — só quando edita série existente
const emitEditMode = composer.hasSerie.value ? composer.editScope.value : null;
const emitRecurrenceId = composer.hasSerie.value
? props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null
: null;
const emitOriginalDate = composer.hasSerie.value ? props.eventRow?.original_date ?? null : null;
emit('save', {
id: composer.form.value.id,
payload,
recorrencia,
editMode: emitEditMode,
recurrence_id: emitRecurrenceId,
original_date: emitOriginalDate,
// legado — mantido para compatibilidade
serie_id: props.eventRow?.serie_id ?? null,
serviceItems: composer.isSessionEvent.value ? commitmentItems.value.slice() : null,
onSaved: composer.isSessionEvent.value
? async (eventId, { markCustomized = false } = {}) => {
await saveCommitmentItems(eventId, commitmentItems.value, { markCustomized });
}
: null
});
}
// ────────────────────────────────────────────────────────────────────
// 5. onDelete — avulsa: confirm simples + emit(id).
// Série: confirm com escopo (somente_este/seguintes/todos) + emit({id, editMode}).
// ────────────────────────────────────────────────────────────────────
function onDelete() {
if (!composer.form.value.id) return;
if (composer.hasSerie.value) {
const isTodos = composer.editScope.value === 'todos';
confirm.require({
header: isTodos ? 'Encerrar toda a série' : 'Excluir sessão recorrente',
message: isTodos
? 'Todos os agendamentos futuros da série serão removidos permanentemente. Esta sessão será mantida como avulsa. Esta ação é irreversível.'
: 'Esta sessão faz parte de uma série. O que deseja remover?',
icon: isTodos ? 'pi pi-trash' : 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: isTodos
? 'Sim, encerrar série'
: composer.editScopeOptions.value.find((o) => o.value === composer.editScope.value)?.label || 'Excluir',
rejectLabel: 'Cancelar',
accept: () =>
emit('delete', {
id: composer.form.value.id,
editMode: composer.editScope.value,
recurrence_id: props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null,
original_date: props.eventRow?.original_date ?? null,
serie_id: props.eventRow?.serie_id ?? null
})
});
return;
}
confirm.require({
header: 'Excluir compromisso',
message: 'Tem certeza? Essa ação não pode ser desfeita.',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: () => emit('delete', composer.form.value.id)
});
}
// ────────────────────────────────────────────────────────────────────
// 6. onEncerrarSerie — confirm explícito de encerramento total da série.
// Diferente do onDelete em 'todos' porque pode ser chamado direto
// de um botão dedicado, sem depender de editScope.
// ────────────────────────────────────────────────────────────────────
function onEncerrarSerie() {
confirm.require({
header: 'Encerrar toda a série',
message:
'Todos os agendamentos da série serão removidos permanentemente, incluindo exceções e recorrências. Esta sessão será mantida como avulsa. Esta ação é irreversível.',
icon: 'pi pi-trash',
acceptClass: 'p-button-danger',
acceptLabel: 'Sim, encerrar série',
rejectLabel: 'Cancelar',
accept: () =>
emit('delete', {
id: composer.form.value.id,
editMode: 'todos',
recurrence_id: props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null,
original_date: props.eventRow?.original_date ?? null,
serie_id: props.eventRow?.serie_id ?? null
})
});
}
return {
// refs internos (expostos pra .vue ler/escrever em watchers próprios)
_skipStatusWatch,
_prevStatus,
_restoringConvenio,
samePatientConflict,
// helpers de payload (públicos pra teste isolado)
buildSavePayload,
buildRecorrenciaPayload,
// handlers
onSave,
onDelete,
onEncerrarSerie
};
}
@@ -0,0 +1,485 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/composables/useAgendaEventComposer.js
| Data: 2026-05-04
|
| Composable factory do AgendaEventDialog A66 sub-sessão 1B.
|
| Responsabilidade: state + computeds derivados que NÃO dependem de
| efeitos colaterais (sem watchers, sem I/O, sem confirm dialogs). Tudo
| reativo, mas sem side-effects. Watchers e handlers ficam no .vue (1C).
|
| Estrutura:
| 1. Visibility (v-model do Dialog)
| 2. Step + edit scope state
| 3. Recurrence state (avulsa/semanal/quinzenal/diasEspecificos)
| 4. Form factory (resetForm) + form ref
| 5. Computeds derivados de props/state:
| - série (hasSerie, isFirstOccurrence, editScopeOptions, ...)
| - recorrência (proximasOcorrencias, ocorrenciasComConflito, ...)
| - commitment (commitmentCards, selectedCommitment, requiresPatient, ...)
| - permissions (agendaPerms, isArchivedPastEdit, ...)
| - datetime (inicioDateTime, fimDateTime, previewRange, ...)
| - validação (canSave, timeConflict)
|
| Não recebe refs externos (services, insurancePlans, etc) esses ficam
| no .vue até 1C onde os watchers consomem-os.
|
| commitmentItems é passado via `extras` porque é um array reativo do
| .vue usado em canSave (validação de billing particular).
|
| serieEvents idem usado em isFirstOccurrence.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { getPatientAgendaPermissions } from '@/composables/usePatientLifecycle';
import {
isNativeSession,
isForaDoPlano as _isForaDoPlanoPure,
isoToHHMM,
addMinutesDate,
fmtTime,
fmtDateBR,
calcMinutes
} from './agendaEventHelpers';
export function useAgendaEventComposer(props, emit, extras = {}) {
// Refs externos consumidos pelo composer.
// Default = ref([]) pra que o composable seja testável sem que
// o caller precise sempre passar — em produção, .vue passa os
// refs reais (`commitmentItems`, `serieEvents`).
const commitmentItems = extras.commitmentItems ?? ref([]);
const serieEvents = extras.serieEvents ?? ref([]);
// ── 1. Visibilidade (v-model:visible) ──────────────────────────
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
});
// ── 2. Step + edit ─────────────────────────────────────────────
const step = ref(1);
const isEdit = computed(() => !!props.eventRow?.id || !!props.eventRow?.is_occurrence);
const allowBack = computed(() => !props.lockCommitment && !props.presetCommitmentId);
// ── 3. Série (eventRow já carrega; isFirstOccurrence usa serieEvents)
const hasSerie = computed(() =>
!!(props.eventRow?.recurrence_id || props.eventRow?.serie_id || props.eventRow?.is_occurrence)
);
const currentRecurrenceDate = computed(() =>
props.eventRow?.recurrence_date || props.eventRow?.inicio_em?.slice(0, 10) || null
);
const editScope = ref('somente_este');
const isFirstOccurrence = computed(() => {
if (!hasSerie.value) return false;
const rDate = props.eventRow?.recurrence_date || props.eventRow?.original_date;
if (!rDate) return false;
const list = serieEvents.value;
if (list?.length) {
const dates = list
.map((e) => e.recurrence_date || e.original_date)
.filter(Boolean)
.sort();
return dates[0] === rDate;
}
return false;
});
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' }
]);
// ── 4. Recorrência (criação) ───────────────────────────────────
const recorrenciaType = ref('avulsa');
const diasSelecionados = ref([]);
const qtdSessoesMode = ref('4');
const qtdSessoesCustom = ref(12);
const dataLimiteManual = ref(null);
function toggleDiaSelecionado(dow) {
const idx = diasSelecionados.value.indexOf(dow);
if (idx === -1) diasSelecionados.value.push(dow);
else diasSelecionados.value.splice(idx, 1);
}
const qtdSessoesEfetiva = computed(() => {
if (qtdSessoesMode.value === '4') return 4;
if (qtdSessoesMode.value === '8') return 8;
if (qtdSessoesMode.value === '12') return 12;
return Math.max(1, Number(qtdSessoesCustom.value || 1));
});
// ── 5. Form factory + form ref ─────────────────────────────────
function resetForm() {
const r = props.eventRow;
const startISO = r?.inicio_em || props.initialStartISO || '';
const endISO = r?.fim_em || props.initialEndISO || '';
const duracaoMin = calcMinutes(startISO, endISO) || props.agendaSettings?.session_duration_min || 50;
return {
id: r?.id || null,
owner_id: r?.owner_id || props.ownerId || '',
terapeuta_id: r?.terapeuta_id ?? null,
paciente_id: r?.paciente_id ?? null,
paciente_nome: r?.paciente_nome ?? r?.patient_name ?? '',
paciente_avatar: r?.paciente_avatar ?? '',
paciente_status: r?.paciente_status ?? '',
commitment_id: r?.determined_commitment_id ?? null,
titulo_custom: r?.titulo_custom || '',
status: r?.status || 'agendado',
observacoes: r?.observacoes || '',
dia: startISO ? new Date(startISO) : new Date(),
startTime: startISO ? isoToHHMM(startISO) : null,
duracaoMin,
modalidade: r?.modalidade || 'presencial',
conflito: null,
extra_fields: r?.extra_fields && typeof r.extra_fields === 'object' ? { ...r.extra_fields } : {},
price: r?.price != null ? Number(r.price) : null,
insurance_plan_id: r?.insurance_plan_id ?? null,
insurance_guide_number: r?.insurance_guide_number ?? null,
insurance_value: r?.insurance_value != null ? Number(r.insurance_value) : null,
insurance_plan_service_id: r?.insurance_plan_service_id ?? null
};
}
const form = ref(resetForm());
// ── 6. Recorrência computeds (usam form.dia + state) ──────────
const diaSemanaRecorrencia = computed(() => {
const d = form.value.dia ? new Date(form.value.dia) : new Date();
return d.getDay();
});
const proximasOcorrencias = computed(() => {
if (recorrenciaType.value === 'avulsa' || !form.value.dia) return [];
const result = [];
const total = qtdSessoesEfetiva.value;
if (recorrenciaType.value === 'semanal' || recorrenciaType.value === 'quinzenal') {
const stepDays = recorrenciaType.value === 'quinzenal' ? 14 : 7;
const cursor = new Date(form.value.dia);
while (result.length < total) {
result.push(new Date(cursor));
cursor.setDate(cursor.getDate() + stepDays);
}
} else if (recorrenciaType.value === 'diasEspecificos') {
if (!diasSelecionados.value.length) return [];
const sorted = [...diasSelecionados.value].sort((a, b) => a - b);
const start = new Date(form.value.dia);
const cur = new Date(start);
let safety = 0;
while (result.length < total && safety < 1000) {
if (sorted.includes(cur.getDay()) && cur >= start) result.push(new Date(cur));
cur.setDate(cur.getDate() + 1);
safety++;
}
}
return result;
});
const dataFimCalculada = computed(() => {
const oc = proximasOcorrencias.value;
return oc.length ? oc[oc.length - 1] : null;
});
const totalOcorrencias = computed(() => proximasOcorrencias.value.length);
function isForaDoPlano(d) {
return _isForaDoPlanoPure(d, dataLimiteManual.value);
}
const sessoesForaDoPlano = computed(() =>
proximasOcorrencias.value.filter((d) => isForaDoPlano(d)).length
);
// Conflito por data: folga | feriado | bloqueado | pausa
function conflictForDate(date) {
if (!date) return null;
const dow = date.getDay();
const iso = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
if (props.workRules?.length && !props.workRules.some((r) => Number(r.dia_semana) === dow)) {
return { type: 'folga', label: 'dia de folga' };
}
const feriado = (props.feriados || []).find((f) => {
const fiso = f.date || f.data || f.iso || '';
return String(fiso).slice(0, 10) === iso;
});
if (feriado) return { type: 'feriado', label: `feriado: ${feriado.name || feriado.nome || ''}` };
if ((props.blockedDates || []).includes(iso)) {
return { type: 'bloqueado', label: 'dia bloqueado' };
}
if (form.value.startTime) {
const [sh, sm] = form.value.startTime.split(':').map(Number);
const slotS = sh * 60 + sm;
const slotE = slotS + (form.value.duracaoMin || 50);
const pausas = (props.pausasSemanais || []).filter((p) => p.dia_semana == null || Number(p.dia_semana) === dow);
for (const p of pausas) {
const [ph, pm] = String(p.hora_inicio || '00:00').split(':').map(Number);
const [eh, em] = String(p.hora_fim || '00:00').split(':').map(Number);
if (slotS < eh * 60 + em && slotE > ph * 60 + pm) {
return { type: 'pausa', label: 'horário de pausa' };
}
}
}
return null;
}
const ocorrenciasComConflito = computed(() =>
proximasOcorrencias.value.map((d) => ({
date: d,
conflict: conflictForDate(d)
}))
);
const totalConflitos = computed(() => ocorrenciasComConflito.value.filter((o) => o.conflict).length);
// ── 7. Billing state ───────────────────────────────────────────
const billingType = ref('particular');
// ── 8. Commitment computeds ────────────────────────────────────
const commitmentCards = computed(() => {
const list = Array.isArray(props.commitmentOptions) ? props.commitmentOptions : [];
const prio = new Map([['session', 0]]);
return [...list].sort((a, b) => {
const pa = prio.has(a.native_key) ? prio.get(a.native_key) : 99;
const pb = prio.has(b.native_key) ? prio.get(b.native_key) : 99;
if (pa !== pb) return pa - pb;
return String(a.name || '').localeCompare(String(b.name || ''), 'pt-BR');
});
});
const selectedCommitment = computed(() => {
const id = form.value.commitment_id;
if (!id) return null;
return commitmentCards.value.find((x) => x.id === id) || null;
});
const selectedCommitmentName = computed(() => selectedCommitment.value?.name || '—');
const selectedCommitmentFields = computed(() => {
const fields = selectedCommitment.value?.fields;
return Array.isArray(fields) ? fields : [];
});
const requiresPatient = computed(() => isNativeSession(selectedCommitment.value));
const isSessionEvent = computed(() => requiresPatient.value);
const patientLocked = computed(() => isEdit.value && isSessionEvent.value && !!props.eventRow?.paciente_id);
const hasInsurance = computed(() => !!form.value.insurance_plan_id);
// ── 9. Permissions (status do paciente) ────────────────────────
const agendaPerms = computed(() => getPatientAgendaPermissions(form.value.paciente_status || ''));
const isSessionFuture = computed(() => {
if (!isEdit.value) return true;
const iso = props.eventRow?.inicio_em;
return iso ? new Date(iso) > new Date() : true;
});
const isArchivedPastEdit = computed(
() => isEdit.value && form.value.paciente_status === 'Arquivado' && !isSessionFuture.value
);
const isInativoFutureEdit = computed(
() => isEdit.value && form.value.paciente_status === 'Inativo' && isSessionFuture.value
);
const statusOptionsFiltered = computed(() => [
{ label: 'Agendado', value: 'agendado' },
{ label: 'Realizado', value: 'realizado' },
{ label: 'Faltou', value: 'faltou' },
{ label: 'Cancelado', value: 'cancelado' },
{ label: 'Remarcar', value: 'remarcado', disabled: isInativoFutureEdit.value }
]);
// ── 10. Datetime ───────────────────────────────────────────────
const startTimeDate = computed({
get() {
const t = form.value.startTime;
if (!t) return null;
const [h, m] = String(t).split(':').map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return d;
},
set(v) {
if (!v) {
form.value.startTime = null;
return;
}
form.value.startTime = `${String(v.getHours()).padStart(2, '0')}:${String(v.getMinutes()).padStart(2, '0')}`;
}
});
const inicioDateTime = computed(() => {
if (!form.value.dia || !form.value.startTime) return null;
const d = new Date(form.value.dia);
const [hh, mm] = String(form.value.startTime).split(':').map(Number);
d.setHours(hh, mm, 0, 0);
return d;
});
const fimDateTime = computed(() => {
if (!inicioDateTime.value) return null;
return addMinutesDate(inicioDateTime.value, Number(form.value.duracaoMin || 50));
});
const dataHoraDisplay = computed(() => {
const parts = [];
if (form.value.dia) parts.push(fmtDateBR(form.value.dia));
if (form.value.startTime) parts.push(form.value.startTime);
return parts.join(' • ');
});
const previewRange = computed(() => {
if (!inicioDateTime.value || !fimDateTime.value) return '—';
return `${fmtDateBR(inicioDateTime.value)}${fmtTime(inicioDateTime.value)}${fmtTime(fimDateTime.value)}`;
});
// ── 11. Título ─────────────────────────────────────────────────
const computedTitulo = computed(() => {
const forced = String(form.value.titulo_custom || '').trim();
if (forced) return forced;
const comp = selectedCommitmentName.value || 'Compromisso';
if (requiresPatient.value) {
const nome = String(form.value.paciente_nome || '').trim();
return nome ? `${nome} [${comp}]` : comp;
}
return comp;
});
const headerTitle = computed(() => {
if (isEdit.value) return 'Editar compromisso';
return step.value === 1 ? 'Novo compromisso — escolha o tipo' : 'Novo compromisso';
});
// ── 12. Validação (canSave) ────────────────────────────────────
// Núcleo do business logic: cobre 6 categorias de check pra
// habilitar o botão Salvar. Testado isoladamente em 1B.
const canSave = computed(() => {
if (!form.value.owner_id) return false;
if (!form.value.dia) return false;
if (!form.value.startTime) return false;
// commitment_id obrigatório só na criação (sessões antigas no DB
// podem ter null — não bloquear edição por isso)
if (!isEdit.value && !form.value.commitment_id) return false;
if (requiresPatient.value && !form.value.paciente_id) return false;
if (isSessionEvent.value && billingType.value === 'particular' && commitmentItems.value.length === 0) return false;
// Restrições por status do paciente
if (isSessionEvent.value && form.value.paciente_status) {
const perms = agendaPerms.value;
if (!isEdit.value && !perms.canCreateSession) return false;
if (!isEdit.value && recorrenciaType.value !== 'avulsa' && !perms.canCreateRecurrence) return false;
if (isArchivedPastEdit.value) return false;
}
return true;
});
// ── 13. Conflito de horário (timeConflict) ─────────────────────
// Pre-check client-side ANTES do PATCH ir pro DB. Sem isso, o constraint
// EXCLUDE do Postgres dispara 400 no console toda vez que o user salva
// num horário ocupado.
const timeConflict = computed(() => {
if (!form.value.dia || !form.value.startTime || !inicioDateTime.value) return null;
const dur = form.value.duracaoMin || 50;
const breakMin = props.agendaSettings?.session_break_min || 0;
const slotS = inicioDateTime.value.getTime();
const slotE = slotS + dur * 60000;
const d = new Date(form.value.dia);
const dayISO = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const currentRow = props.eventRow;
const dayEvts = (props.allEvents || []).filter((e) => {
if (!e.inicio_em) return false;
// Exclui evento real pelo id
if (form.value.id && e.id === form.value.id) return false;
// Exclui ocorrência virtual pelo recurrence_id + original_date
if (currentRow?.is_occurrence && e.is_occurrence && e.recurrence_id && e.recurrence_id === currentRow.recurrence_id && String(e.original_date || '').slice(0, 10) === String(currentRow.original_date || '').slice(0, 10)) return false;
const es = new Date(e.inicio_em);
return `${es.getFullYear()}-${String(es.getMonth() + 1).padStart(2, '0')}-${String(es.getDate()).padStart(2, '0')}` === dayISO;
});
for (const evt of dayEvts) {
const evtS = new Date(evt.inicio_em).getTime();
const evtE = new Date(evt.fim_em || evt.inicio_em).getTime() + breakMin * 60000;
if (slotS < evtE && slotE > evtS) {
const nome = evt.paciente_nome || 'outro compromisso';
return `Conflito com "${nome}" às ${fmtTime(new Date(evt.inicio_em))}`;
}
}
// Pausas do dia
const dow = d.getDay();
const pausas = (props.pausasSemanais || []).filter((p) => p.hora_inicio && p.hora_fim && (p.dia_semana == null || Number(p.dia_semana) === dow));
if (pausas.length && form.value.startTime) {
const [sh, sm] = form.value.startTime.split(':').map(Number);
const sS = sh * 60 + sm;
const sE = sS + dur;
for (const p of pausas) {
const [ph, pm] = String(p.hora_inicio).split(':').map(Number);
const [eh, em] = String(p.hora_fim).split(':').map(Number);
const pS = ph * 60 + pm;
const pE = eh * 60 + em;
if (sS < pE && sE > pS) return `Horário coincide com uma pausa (${p.hora_inicio}${p.hora_fim})`;
}
}
return null;
});
return {
// refs
visible,
step,
editScope,
recorrenciaType,
diasSelecionados,
qtdSessoesMode,
qtdSessoesCustom,
dataLimiteManual,
billingType,
form,
// edit/série computeds
isEdit,
allowBack,
hasSerie,
currentRecurrenceDate,
isFirstOccurrence,
editScopeOptions,
// recorrência computeds
qtdSessoesEfetiva,
diaSemanaRecorrencia,
proximasOcorrencias,
dataFimCalculada,
totalOcorrencias,
sessoesForaDoPlano,
ocorrenciasComConflito,
totalConflitos,
// commitment computeds
commitmentCards,
selectedCommitment,
selectedCommitmentName,
selectedCommitmentFields,
requiresPatient,
isSessionEvent,
patientLocked,
hasInsurance,
// permissions computeds
agendaPerms,
isSessionFuture,
isArchivedPastEdit,
isInativoFutureEdit,
statusOptionsFiltered,
// datetime computeds
startTimeDate,
inicioDateTime,
fimDateTime,
dataHoraDisplay,
previewRange,
// título computeds
computedTitulo,
headerTitle,
// validação
canSave,
timeConflict,
// métodos
toggleDiaSelecionado,
isForaDoPlano,
conflictForDate,
resetForm
};
}
@@ -0,0 +1,474 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/composables/useAgendaEventLifecycle.js
| Data: 2026-05-04
|
| A66 sub-sessão 1C-ii-b lifecycle do AgendaEventDialog:
| - Watcher props.modelValue (init form ao abrir orquestra
| loadPatients/ensureServicesLoaded/loadInsurancePlans/
| _loadCommitmentItemsForEvent + reset de refs)
| - Watcher [tenantId, restrictPatients, patientScopeOwnerId]
| - Watcher [dia, startTime] (solicitação pendente do agendador público)
| - Watcher [dia, modalidade] (online slots loader)
| - Series pills (loadSerieEvents + 4 handlers + generateRuleDates)
| - selectSlot
| - Quick-creates wiring (service + insurance)
| - onSendManualReminder (lembrete WhatsApp)
|
| Recebe via argumento:
| composer composer (1B)
| actions actions (1C-i): _skipStatusWatch, _restoringConvenio,
| samePatientConflict
| pickerBilling picker/billing (1C-ii-a): ensureServicesLoaded,
| _loadCommitmentItemsForEvent, clearPatientsCache,
| loadPatients, addItem
| commitmentItems ref<Item[]>
| serieEvents ref<SerieEvent[]>
| servicePickerSel ref do picker
| selectedPlanService ref do procedure de convênio
| serieValorMode ref<'multiplicar' | 'dividir'>
| services ref<Service[]> (de useServices)
| loadServices fn(ownerId)
| loadInsurancePlans fn(ownerId)
| props props do dialog
| emit emitter ('updateSeriesEvent', 'editSeriesOccurrence', 'delete')
| confirm useConfirm()
| toast useToast()
|--------------------------------------------------------------------------
*/
import { ref, computed, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function generateRuleDates(rule) {
const { type, interval = 1, weekdays = [], start_date, end_date, max_occurrences } = rule || {};
if (!start_date || !weekdays?.length) return [];
const maxOcc = Math.min(max_occurrences || 365, 365);
const endLimit = end_date ? new Date(end_date + 'T23:59:59') : null;
const dates = [];
if (type === 'custom_weekdays') {
const cursor = new Date(start_date + 'T12:00:00');
let safety = 0;
while (dates.length < maxOcc && safety < 2000) {
safety++;
if (endLimit && cursor > endLimit) break;
if (weekdays.includes(cursor.getDay())) dates.push(cursor.toISOString().slice(0, 10));
cursor.setDate(cursor.getDate() + 1);
}
} else {
// weekly (interval=1) ou quinzenal (interval=2)
const cursor = new Date(start_date + 'T12:00:00');
while (dates.length < maxOcc) {
if (endLimit && cursor > endLimit) break;
dates.push(cursor.toISOString().slice(0, 10));
cursor.setDate(cursor.getDate() + 7 * (interval || 1));
}
}
return dates;
}
export function useAgendaEventLifecycle({
composer,
actions,
pickerBilling,
commitmentItems,
serieEvents,
servicePickerSel,
selectedPlanService,
serieValorMode,
services,
loadServices,
loadInsurancePlans,
props,
emit,
confirm,
toast
}) {
// ── refs locais ────────────────────────────────────────────
const solicitacaoPendente = ref(null);
const onlineSlots = ref([]);
const loadingOnlineSlots = ref(false);
const serieLoading = ref(false);
const pillDeleteMenuRef = ref(null);
const pillDeleteTarget = ref(null);
const sendingReminder = ref(false);
const serviceQuickDlgOpen = ref(false);
const insuranceQuickDlgOpen = ref(false);
// ── computeds locais ───────────────────────────────────────
const serieCountByStatus = computed(() => {
const counts = {};
for (const ev of serieEvents.value) {
const s = ev._status || 'agendado';
counts[s] = (counts[s] || 0) + 1;
}
return counts;
});
const pillDeleteMenuItems = computed(() => {
if (!pillDeleteTarget.value) return [];
const ev = pillDeleteTarget.value;
return [
{ label: 'Remover apenas esta', icon: 'pi pi-minus-circle', command: () => onPillDelete(ev, 'somente_este') },
{ label: 'Remover esta e as seguintes', icon: 'pi pi-forward', command: () => onPillDelete(ev, 'este_e_seguintes') },
{ separator: true },
{ label: 'Remover todas as futuras', icon: 'pi pi-trash', command: () => onPillDelete(ev, 'todos') }
];
});
// ── series pills ───────────────────────────────────────────
async function loadSerieEvents() {
const rid = props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null;
if (!rid) {
serieEvents.value = [];
return;
}
serieLoading.value = true;
try {
const { data: rule, error: ruleErr } = await supabase.from('recurrence_rules').select('*').eq('id', rid).maybeSingle();
if (ruleErr) throw ruleErr;
const { data: excData } = await supabase.from('recurrence_exceptions').select('original_date, type, reason').eq('recurrence_id', rid);
const exMap = new Map((excData || []).map((e) => [e.original_date, e]));
const { data: realData } = await supabase
.from('agenda_eventos')
.select('id, inicio_em, fim_em, status, recurrence_date')
.eq('recurrence_id', rid)
.is('mirror_of_event_id', null)
.order('inicio_em', { ascending: true });
const realMap = new Map((realData || []).map((e) => [e.recurrence_date || e.inicio_em?.slice(0, 10), e]));
const dates = rule ? generateRuleDates(rule) : [];
const startTime = rule?.start_time || '00:00:00';
const durMin = rule?.duration_min || 50;
const list = dates.map((dateISO) => {
const real = realMap.get(dateISO);
const exc = exMap.get(dateISO);
const isCancelled = exc?.type === 'cancel_session' || exc?.type === 'holiday_block';
const inicioStr = real?.inicio_em || `${dateISO}T${startTime}`;
const fimDate = new Date(`${dateISO}T${startTime}`);
fimDate.setMinutes(fimDate.getMinutes() + durMin);
const fimStr = real?.fim_em || fimDate.toISOString();
return {
id: real?.id || null,
inicio_em: inicioStr,
fim_em: fimStr,
status: real?.status || (isCancelled ? 'cancelado' : 'agendado'),
recurrence_date: dateISO,
_status: real?.status || (isCancelled ? 'cancelado' : 'agendado'),
_is_virtual: !real?.id,
_cancelled: isCancelled,
_reason: exc?.reason || null
};
});
for (const [dateISO, real] of realMap) {
if (!dates.includes(dateISO)) {
list.push({
id: real.id,
inicio_em: real.inicio_em,
fim_em: real.fim_em,
status: real.status || 'agendado',
recurrence_date: dateISO,
_status: real.status || 'agendado',
_is_virtual: false,
_cancelled: false,
_reason: null
});
}
}
list.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em));
serieEvents.value = list;
} catch (e) {
console.error('[serie] erro ao carregar:', e);
serieEvents.value = [];
} finally {
serieLoading.value = false;
}
}
function onPillEditClick(ev) {
emit('editSeriesOccurrence', {
id: ev.id,
recurrence_date: ev.recurrence_date,
inicio_em: ev.inicio_em,
fim_em: ev.fim_em,
is_virtual: ev._is_virtual
});
}
function onPillStatusChange(ev) {
emit('updateSeriesEvent', {
id: ev.id,
status: ev._status,
recurrence_date: ev.recurrence_date,
inicio_em: ev.inicio_em,
fim_em: ev.fim_em,
is_virtual: ev._is_virtual
});
if (ev._is_virtual) {
setTimeout(() => loadSerieEvents(), 700);
}
}
function onPillDeleteClick(ev, event) {
pillDeleteTarget.value = ev;
nextTick(() => pillDeleteMenuRef.value?.toggle(event));
}
function onPillDelete(ev, mode) {
const isTodos = mode === 'todos';
confirm.require({
header: isTodos ? 'Encerrar toda a série' : 'Excluir sessão recorrente',
message: isTodos
? 'Todos os agendamentos futuros da série serão removidos permanentemente. Esta sessão será mantida como avulsa. Esta ação é irreversível.'
: mode === 'este_e_seguintes'
? 'Esta sessão e todas as seguintes serão removidas. Tem certeza?'
: 'Esta sessão será cancelada. Tem certeza?',
icon: isTodos ? 'pi pi-trash' : 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: isTodos ? 'Sim, encerrar série' : 'Confirmar',
rejectLabel: 'Cancelar',
accept: () =>
emit('delete', {
id: ev.id,
editMode: mode,
recurrence_id: props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null,
original_date: ev.recurrence_date || (ev.inicio_em ? ev.inicio_em.slice(0, 10) : null),
serie_id: props.eventRow?.serie_id ?? null
})
});
}
// ── slot selection ─────────────────────────────────────────
function selectSlot(hhmm) {
const [h, m] = String(hhmm).split(':').map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
composer.startTimeDate.value = d;
}
// ── quick-creates ──────────────────────────────────────────
function openServiceQuickCreate() {
serviceQuickDlgOpen.value = true;
}
async function onServiceCreated(svc) {
await loadServices(props.ownerId);
if (svc?.id) {
const list = services?.value;
const fresh = (Array.isArray(list) ? list.find((s) => s.id === svc.id) : null) || svc;
if (typeof pickerBilling.addItem === 'function') {
pickerBilling.addItem(fresh);
}
}
}
function openInsuranceQuickCreate() {
insuranceQuickDlgOpen.value = true;
}
async function onInsuranceCreated(plan) {
await loadInsurancePlans(props.planOwnerId || props.ownerId);
if (plan?.id) {
composer.form.value.insurance_plan_id = plan.id;
}
}
// ── lembrete WhatsApp manual (8.2) ─────────────────────────
async function onSendManualReminder() {
if (!composer.form.value?.id) return;
confirm.require({
header: 'Enviar lembrete WhatsApp?',
message: `Vou mandar o template "lembrete_sessao" pra ${composer.form.value.paciente_nome || 'o paciente'} agora. Pode disparar?`,
icon: 'pi pi-whatsapp',
acceptLabel: 'Enviar',
rejectLabel: 'Cancelar',
accept: async () => {
sendingReminder.value = true;
try {
const { data, error } = await supabase.functions.invoke('send-session-reminder-manual', {
body: { event_id: composer.form.value.id }
});
if (error || !data?.ok) {
const err = data?.error || error?.message || 'unknown_error';
let friendly = err;
if (err === 'no_phone') friendly = 'Paciente sem telefone cadastrado.';
else if (err === 'invalid_phone') friendly = 'Telefone do paciente inválido.';
else if (err === 'no_active_channel') friendly = 'Nenhum canal WhatsApp ativo. Configure em Configurações → WhatsApp.';
else if (err === 'template_not_found') friendly = 'Template "lembrete_sessao" não encontrado. Configure em Configurações → WhatsApp.';
else if (err === 'forbidden') friendly = 'Você não tem permissão pra enviar por este canal.';
else if (String(err).startsWith('send_failed')) friendly = 'Não conseguimos enviar. Verifique a conexão do WhatsApp.';
throw new Error(friendly);
}
toast.add({ severity: 'success', summary: 'Lembrete enviado', detail: data.to ? `Para ${data.to}` : undefined, life: 3500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao enviar lembrete', detail: e.message, life: 5000 });
} finally {
sendingReminder.value = false;
}
}
});
}
// ── watchers ───────────────────────────────────────────────
// Init form ao abrir o dialog (orquestra tudo)
watch(
() => props.modelValue,
async (open) => {
if (!open) return;
await nextTick();
actions._skipStatusWatch.value = true;
composer.form.value = composer.resetForm();
await nextTick();
actions._skipStatusWatch.value = false;
actions.samePatientConflict.value = null;
composer.recorrenciaType.value = 'avulsa';
composer.diasSelecionados.value = [];
composer.dataLimiteManual.value = null;
composer.qtdSessoesMode.value = '4';
composer.qtdSessoesCustom.value = 12;
composer.editScope.value = 'somente_este';
if (serieValorMode) serieValorMode.value = 'multiplicar';
if (composer.isEdit.value && composer.form.value.paciente_id && !composer.form.value.paciente_nome) {
supabase
.from('patients')
.select('id, nome_completo')
.eq('id', composer.form.value.paciente_id)
.maybeSingle()
.then(({ data }) => {
if (data?.nome_completo) composer.form.value.paciente_nome = data.nome_completo;
});
}
if (composer.hasSerie.value) loadSerieEvents();
else serieEvents.value = [];
if (composer.isEdit.value) {
composer.step.value = 2;
} else {
const preset = props.presetCommitmentId;
if (preset) {
composer.form.value.commitment_id = preset;
composer.step.value = 2;
} else composer.step.value = 1;
}
pickerBilling.clearPatientsCache();
if (composer.requiresPatient.value) pickerBilling.loadPatients(true);
pickerBilling.ensureServicesLoaded();
const insuranceOwner = props.planOwnerId || props.ownerId;
if (insuranceOwner) {
await loadInsurancePlans(insuranceOwner);
}
selectedPlanService.value = null;
actions._restoringConvenio.value = false;
commitmentItems.value = [];
servicePickerSel.value = null;
if (composer.isEdit.value && (composer.form.value.id || props.eventRow?.recurrence_id)) {
pickerBilling._loadCommitmentItemsForEvent(composer.form.value.id);
} else {
composer.billingType.value = 'particular';
}
}
);
// Tenant/scope mudou — recarrega lista de pacientes
watch(
() => [props.tenantId, props.restrictPatientsToOwner, props.patientScopeOwnerId],
() => {
if (!composer.visible.value) return;
pickerBilling.clearPatientsCache();
if (composer.requiresPatient.value) pickerBilling.loadPatients(true);
}
);
// Solicitação pendente do agendador público no horário escolhido
watch(
() => [composer.form.value.dia?.toString(), composer.form.value.startTime],
async ([dia, startTime]) => {
solicitacaoPendente.value = null;
if (!composer.isSessionEvent.value || !composer.visible.value || composer.isEdit.value) return;
if (!props.ownerId || !dia || !startTime) return;
const d = new Date(composer.form.value.dia);
const isoDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const { data } = await supabase
.from('agendador_solicitacoes')
.select('id, paciente_nome, paciente_sobrenome, paciente_email')
.eq('owner_id', props.ownerId)
.eq('status', 'pendente')
.eq('data_solicitada', isoDate)
.eq('hora_solicitada', startTime)
.maybeSingle();
solicitacaoPendente.value = data || null;
}
);
// Online slots: depende de [dia, modalidade]
watch(
[() => composer.form.value.dia, () => composer.form.value.modalidade],
async ([dia, mod]) => {
if (mod !== 'online' || !dia || !props.ownerId) {
onlineSlots.value = [];
return;
}
const dow = new Date(dia).getDay();
loadingOnlineSlots.value = true;
try {
const { data } = await supabase
.from('agenda_online_slots')
.select('time')
.eq('owner_id', props.ownerId)
.eq('weekday', dow)
.eq('enabled', true)
.order('time');
onlineSlots.value = (data || []).map((s) => ({ hhmm: String(s.time || '').slice(0, 5) }));
} catch {
onlineSlots.value = [];
} finally {
loadingOnlineSlots.value = false;
}
},
{ immediate: true }
);
return {
// refs
solicitacaoPendente,
onlineSlots,
loadingOnlineSlots,
serieLoading,
pillDeleteMenuRef,
pillDeleteTarget,
sendingReminder,
serviceQuickDlgOpen,
insuranceQuickDlgOpen,
// computeds
serieCountByStatus,
pillDeleteMenuItems,
// series
loadSerieEvents,
onPillEditClick,
onPillStatusChange,
onPillDeleteClick,
onPillDelete,
// slot
selectSlot,
// quick-creates
openServiceQuickCreate,
onServiceCreated,
openInsuranceQuickCreate,
onInsuranceCreated,
// reminder
onSendManualReminder
};
}
@@ -0,0 +1,378 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/composables/useAgendaEventPickerBilling.js
| Data: 2026-05-04
|
| A66 sub-sessão 1C-ii-a handlers de patient picker + billing items +
| 2 watchers (commitment_id auto-fill price, insurance_plan_id limpa
| items + reset campos).
|
| Não inclui (vai pra 1C-ii-b):
| - Watcher props.modelValue (init form ao abrir tem dependências
| em loadPatients/ensureServicesLoaded/loadInsurancePlans/
| _loadCommitmentItemsForEvent + supabase pra buscar nome do paciente)
| - Watchers de online slots, solicitações pendentes
| - Series pills handlers
| - Slot selection
| - Quick-creates wiring
| - onSendManualReminder
|
| Recebe via argumento:
| composer refs + computeds do composer (1B)
| _actions refs internos do actions (1C-i): _restoringConvenio
| commitmentItems ref<Item[]>
| servicePickerSel ref do select picker (limpado ao trocar convenio)
| selectedPlanService ref do procedure de convênio
| services ref<Service[]> do useServices
| loadServices fn(ownerId) do useServices
| getDefaultPrice fn() do useServices (preço default sugerido)
| planServices computed<PlanService[]> (de useInsurancePlans + form)
| loadActiveDiscount fn(ownerId, patientId) do usePatientDiscounts
| _csLoadItems fn(eventId) do useCommitmentServices
| _csLoadItemsOrTemplate fn(eventId, ruleId, opts) do useCommitmentServices
| isDynamic computed<boolean>
| props props do componente parent
|--------------------------------------------------------------------------
*/
import { ref, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { calcFinalPrice } from './agendaEventHelpers';
export function useAgendaEventPickerBilling({
composer,
actions,
commitmentItems,
servicePickerSel,
selectedPlanService,
services,
loadServices,
getDefaultPrice,
planServices,
loadActiveDiscount,
_csLoadItems,
_csLoadItemsOrTemplate,
isDynamic,
props
}) {
// ── Patient picker state ───────────────────────────────────────
const pacientePickerOpen = ref(false);
const pacienteSearch = ref('');
const pacientesLoading = ref(false);
const pacientesError = ref('');
const patients = ref([]);
// ── Cadastro rápido ────────────────────────────────────────────
const cadRapidoOpen = ref(false);
// ── Services lazy-load gate ────────────────────────────────────
let _servicesLoaded = false;
// ────────────────────────────────────────────────────────────────
// Services pré-carga (lazy: só uma vez por sessão do dialog)
// ────────────────────────────────────────────────────────────────
async function ensureServicesLoaded() {
if (_servicesLoaded || !props.ownerId) return;
_servicesLoaded = true;
await loadServices(props.ownerId);
}
// Reseta o gate (chamado pelo watcher de open na 1C-ii-b)
function resetServicesGate() {
_servicesLoaded = false;
}
function applyDefaultPrice() {
// Skip particular: preço vem dos commitmentItems
if (composer.billingType.value === 'particular') return;
// Só auto-preenche em criação (edição preserva o valor salvo)
if (!composer.isEdit.value) {
const suggested = getDefaultPrice();
if (suggested != null) composer.form.value.price = suggested;
}
}
// ────────────────────────────────────────────────────────────────
// Billing items (commitment_services)
// ────────────────────────────────────────────────────────────────
/**
* Adiciona um serviço ao billing. Regras:
* - Não duplica: se existe item do mesmo service_id, incrementa quantity
* - Aplica desconto ativo do paciente (se houver)
* - Recalcula final_price via helper puro
*/
async function addItem(svc) {
if (!svc?.id) return;
const existing = commitmentItems.value.find((i) => i.service_id === svc.id);
if (existing) {
existing.quantity++;
existing.final_price = calcFinalPrice(existing.unit_price, existing.quantity, existing.discount_pct, existing.discount_flat);
return;
}
const unit_price = Number(svc.price);
const patientId = composer.form.value.patient_id ?? composer.form.value.paciente_id ?? null;
let discount_pct = 0;
let discount_flat = 0;
if (patientId && props.ownerId) {
const discount = await loadActiveDiscount(props.ownerId, patientId);
if (discount) {
discount_pct = Number(discount.discount_pct ?? 0);
discount_flat = Number(discount.discount_flat ?? 0);
}
}
commitmentItems.value.push({
service_id: svc.id,
service_name: svc.name,
quantity: 1,
unit_price,
discount_pct,
discount_flat,
final_price: calcFinalPrice(unit_price, 1, discount_pct, discount_flat)
});
}
function removeItem(index) {
commitmentItems.value.splice(index, 1);
// Lista vazia em modo dinâmico → restaura duração padrão
if (commitmentItems.value.length === 0 && isDynamic.value) {
composer.form.value.duracaoMin = props.agendaSettings?.session_duration_min ?? 50;
}
}
function onItemChange(item) {
item.final_price = calcFinalPrice(item.unit_price, item.quantity, item.discount_pct, item.discount_flat);
}
/**
* Carrega items do evento (ou template da série) e detecta billingType.
* Heurística: se eventRow tem insurance_plan_id 'convenio'; senão,
* presença de items 'particular', vazio 'gratuito'.
*/
async function _loadCommitmentItemsForEvent(eventId) {
const ruleId = props.eventRow?.recurrence_id ?? null;
const isCustomized = props.eventRow?.services_customized ?? false;
const origPlanId = props.eventRow?.insurance_plan_id ?? null;
const origGuide = props.eventRow?.insurance_guide_number ?? null;
const origInsValue = props.eventRow?.insurance_value != null ? Number(props.eventRow.insurance_value) : null;
function applyConvenio() {
const origPsId = props.eventRow?.insurance_plan_service_id ?? null;
actions._restoringConvenio.value = true;
composer.form.value.insurance_plan_id = origPlanId;
composer.form.value.insurance_guide_number = origGuide;
composer.form.value.insurance_value = origInsValue;
composer.form.value.insurance_plan_service_id = origPsId;
composer.billingType.value = 'convenio';
nextTick(() => {
if (origPsId && planServices.value.find((s) => s.id === origPsId)) {
selectedPlanService.value = origPsId;
} else {
selectedPlanService.value = null;
}
actions._restoringConvenio.value = false;
});
}
if (!eventId && !ruleId) {
commitmentItems.value = [];
if (origPlanId) applyConvenio();
else composer.billingType.value = 'particular';
return;
}
try {
commitmentItems.value = ruleId
? await _csLoadItemsOrTemplate(eventId, ruleId, { allowEmpty: isCustomized })
: await _csLoadItems(eventId);
if (origPlanId) applyConvenio();
else composer.billingType.value = commitmentItems.value.length > 0 ? 'particular' : 'gratuito';
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[useAgendaEventPickerBilling] commitment_services load error:', e?.message);
commitmentItems.value = [];
if (origPlanId) applyConvenio();
else composer.billingType.value = 'gratuito';
}
}
/**
* Setter do procedimento de convênio (form.insurance_plan_service_id).
* Quando muda, atualiza form.insurance_value baseado no value do procedimento.
*/
function onProcedureSelect(psId) {
composer.form.value.insurance_plan_service_id = psId ?? null;
if (!psId) {
composer.form.value.insurance_value = null;
return;
}
const ps = planServices.value.find((s) => s.id === psId);
composer.form.value.insurance_value = ps?.value != null ? Number(ps.value) : null;
}
// ────────────────────────────────────────────────────────────────
// Patient picker
// ────────────────────────────────────────────────────────────────
function selectCommitment(c) {
if (!c?.id) return;
composer.form.value.commitment_id = c.id;
composer.form.value.extra_fields = {};
if (Array.isArray(c.fields)) {
for (const f of c.fields) composer.form.value.extra_fields[f.key] = '';
}
composer.step.value = 2;
if (composer.requiresPatient.value) loadPatients(true);
}
function goBack() {
if (composer.isEdit.value || !composer.allowBack.value) return;
composer.step.value = 1;
composer.form.value.commitment_id = null;
composer.form.value.paciente_id = null;
composer.form.value.paciente_nome = '';
}
function openPacientePicker() {
if (!composer.requiresPatient.value) return;
pacientePickerOpen.value = true;
loadPatients(false);
}
function clearPatientsCache() {
patients.value = [];
pacientesError.value = '';
pacienteSearch.value = '';
}
async function loadPatients(force = false) {
try {
if (pacientesLoading.value) return;
if (!force && patients.value?.length) return;
pacientesError.value = '';
pacientesLoading.value = true;
let q = supabase
.from('patients')
.select('id,nome_completo,email_principal,telefone,status,avatar_url,tenant_id,responsible_member_id,created_at')
.order('created_at', { ascending: false })
.limit(500);
if (props.tenantId) q = q.eq('tenant_id', props.tenantId);
if (props.restrictPatientsToOwner && props.patientScopeOwnerId) {
q = q.eq('responsible_member_id', props.patientScopeOwnerId);
}
const { data, error } = await q;
if (error) throw error;
patients.value = (data || []).map((r) => ({
id: r.id,
nome: r.nome_completo ?? '',
email: r.email_principal ?? '',
telefone: r.telefone ?? '',
status: r.status ?? '',
avatar_url: r.avatar_url ?? ''
}));
} catch (e) {
pacientesError.value = e?.message || 'Falha ao carregar pacientes.';
patients.value = [];
} finally {
pacientesLoading.value = false;
}
}
function selectPaciente(p) {
if (!p?.id) return;
composer.form.value.paciente_id = p.id;
composer.form.value.paciente_nome = p.nome || '';
composer.form.value.paciente_avatar = p.avatar_url || '';
pacientePickerOpen.value = false;
}
function clearPaciente() {
composer.form.value.paciente_id = null;
composer.form.value.paciente_nome = '';
composer.form.value.paciente_avatar = '';
actions.samePatientConflict.value = null;
}
function openCadastroRapido() {
cadRapidoOpen.value = true;
}
function abrirCadastroCompleto() {
if (!props.newPatientRoute) return;
// Abre em nova aba pra o user voltar pro dialog depois
window.open(props.newPatientRoute, '_blank', 'noopener');
}
// ────────────────────────────────────────────────────────────────
// Watchers
// ────────────────────────────────────────────────────────────────
/**
* Auto-fill de price quando o user troca commitment em criação.
* Ignorado em edição pra preservar o valor salvo.
*/
watch(
() => composer.form.value.commitment_id,
async (newId) => {
if (!newId || composer.isEdit.value || !composer.visible.value) return;
await ensureServicesLoaded();
applyDefaultPrice();
}
);
/**
* Limpa procedure + campos de convênio quando muda plano.
* Quando seleciona convênio: zera items (exclusividade convênio vs serviços).
* `_restoringConvenio` ignora o watch durante restauração de eventRow editado.
*/
watch(
() => composer.form.value.insurance_plan_id,
(planId) => {
if (actions._restoringConvenio.value) return;
selectedPlanService.value = null;
composer.form.value.insurance_plan_service_id = null;
if (!planId) {
composer.form.value.insurance_value = null;
composer.form.value.insurance_guide_number = null;
return;
}
commitmentItems.value = [];
servicePickerSel.value = null;
}
);
return {
// refs do picker
pacientePickerOpen,
pacienteSearch,
pacientesLoading,
pacientesError,
patients,
cadRapidoOpen,
// billing/services
ensureServicesLoaded,
resetServicesGate,
applyDefaultPrice,
addItem,
removeItem,
onItemChange,
_loadCommitmentItemsForEvent,
onProcedureSelect,
// picker
selectCommitment,
goBack,
openPacientePicker,
clearPatientsCache,
loadPatients,
selectPaciente,
clearPaciente,
openCadastroRapido,
abrirCadastroCompleto
};
}
@@ -16,20 +16,53 @@
*/
import { ref } from 'vue';
import { getMyAgendaSettings, getMyWorkSchedule } from '../services/agendaRepository';
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
// opts.cache (opt-in): habilita stale-while-revalidate via melissaCacheStore.
// Default false pra preservar comportamento de páginas de configuração que
// editam settings/workRules (esperam ver mudança imediata após salvar).
export function useAgendaSettings(opts = {}) {
const useCache = !!opts.cache;
const cache = useCache ? useMelissaCacheStore() : null;
export function useAgendaSettings() {
const loading = ref(false);
const error = ref('');
const settings = ref(null);
const workRules = ref([]); // [{ dia_semana, hora_inicio, hora_fim }]
async function _doFetch() {
const [cfg, rules] = await Promise.all([getMyAgendaSettings(), getMyWorkSchedule()]);
settings.value = cfg;
workRules.value = rules;
if (cache) {
// Cache key inclui owner_id da config — invalida automaticamente
// se o usuário trocar (multi-tenant ou alternar entre staff).
const key = cfg?.owner_id || 'anon';
cache.set('agendaSettings', { settings: cfg, workRules: rules }, key);
}
return { settings: cfg, workRules: rules };
}
async function load() {
if (cache) {
// Sem owner_id ainda, key vira 'anon' — pega qualquer cache
// do mesmo escopo (que normalmente é o user logado).
const cached = cache.get('agendaSettings', undefined, MELISSA_CACHE_TTL.agendaSettings);
if (cached) {
settings.value = cached.settings;
workRules.value = cached.workRules;
_doFetch().catch((e) => {
// eslint-disable-next-line no-console
console.warn('[useAgendaSettings] revalidate', e);
});
return;
}
}
loading.value = true;
error.value = '';
try {
const [cfg, rules] = await Promise.all([getMyAgendaSettings(), getMyWorkSchedule()]);
settings.value = cfg;
workRules.value = rules;
await _doFetch();
} catch (e) {
error.value = e?.message || 'Falha ao carregar configurações da agenda.';
settings.value = null;
@@ -742,7 +742,6 @@ const fcOptions = computed(() => ({
const nome = ext.paciente_nome || '';
const obs = ext.observacoes || '';
const title = arg.event.title || '';
const timeText = arg.timeText || '';
const pacienteStatus = ext.paciente_status || '';
const esc = (s) =>
@@ -759,21 +758,28 @@ const fcOptions = computed(() => ({
return (p[0][0] + p[p.length - 1][0]).toUpperCase();
};
const fmtHour = (d) => {
if (!d) return '';
const h = d.getHours();
const m = d.getMinutes();
return m === 0 ? `${h}h` : `${h}:${String(m).padStart(2, '0')}h`;
};
const range = arg.event.start && arg.event.end ? `${fmtHour(arg.event.start)}-${fmtHour(arg.event.end)}` : arg.timeText || '';
const avatarHtml = avatarUrl ? `<img src="${esc(avatarUrl)}" class="ev-avatar ev-avatar-img" />` : nome ? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>` : '';
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : '';
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : '';
const titleLine = `<div class="ev-title"><span class="ev-name">${esc(title)}</span>${range ? ` <span class="ev-hour">(${esc(range)})</span>` : ''}</div>`;
const inativoBadge =
pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado'
? `<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.4;margin-top:2px;">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
? `<span class="ev-badge">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
: '';
return {
html: `<div class="ev-custom">
${avatarHtml}
<div class="ev-body">
${timeHtml}
<div class="ev-title">${esc(title)}</div>
${titleLine}
${inativoBadge}
${obsHtml}
</div>
@@ -3424,20 +3430,34 @@ onBeforeUnmount(() => {
flex: 1;
overflow: hidden;
}
.ev-time {
font-size: 10px;
opacity: 0.8;
.ev-title {
font-size: 11px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ev-title {
font-size: 11px;
.ev-name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.ev-hour {
font-weight: 400;
font-size: 10px;
opacity: 0.75;
margin-left: 2px;
}
.ev-badge {
display: inline-block;
background: #f97316;
color: #fff;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 1px 5px;
border-radius: 3px;
line-height: 1.4;
margin-top: 2px;
}
.ev-obs {
font-size: 10px;
+64 -18
View File
@@ -180,13 +180,10 @@ watch(filters, () => fetchDocuments(), { deep: true })
EMBEDDED MODE dentro do prontuário (sem hero, layout compacto)
-->
<div v-if="embedded">
<!-- Header compacto -->
<div class="flex items-center justify-between gap-2 mb-4">
<span class="text-sm font-semibold text-[var(--text-color-secondary)] uppercase tracking-wider">Documentos</span>
<div class="flex gap-1.5">
<Button icon="pi pi-file-pdf" text rounded size="small" v-tooltip.top="'Gerar documento'" @click="generateDlg = true" />
<Button icon="pi pi-upload" text rounded size="small" v-tooltip.top="'Upload'" @click="uploadDlg = true" />
</div>
<!-- Header compacto: ações alinhadas à direita, sem label -->
<div class="flex items-center justify-end gap-2 mb-4">
<Button label="Upload" icon="pi pi-upload" size="small" outlined class="rounded-full" @click="uploadDlg = true" />
<Button label="Template" icon="pi pi-file-pdf" size="small" class="rounded-full" @click="generateDlg = true" />
</div>
<!-- Loading -->
@@ -195,11 +192,11 @@ watch(filters, () => fetchDocuments(), { deep: true })
</div>
<!-- Empty -->
<div v-else-if="!documents.length" class="py-10 px-6 text-center">
<i class="pi pi-inbox text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-sm">Nenhum documento ainda</div>
<div class="text-xs opacity-60 mt-1">Faça upload do primeiro documento deste paciente</div>
<Button v-if="resolvedPatientId" label="Enviar documento" icon="pi pi-upload" severity="secondary" outlined size="small" class="rounded-full mt-3" @click="uploadDlg = true" />
<div v-else-if="!documents.length" class="empty-rich">
<div class="empty-rich__icon"><i class="pi pi-folder-open" /></div>
<div class="empty-rich__title">Nenhum documento ainda</div>
<div class="empty-rich__sub">Faça upload do primeiro laudo, receita, exame ou termo assinado deste paciente.</div>
<Button v-if="resolvedPatientId" label="Enviar documento" icon="pi pi-upload" class="empty-rich__cta rounded-full" @click="uploadDlg = true" />
</div>
<!-- Lista -->
@@ -331,15 +328,17 @@ watch(filters, () => fetchDocuments(), { deep: true })
</div>
<!-- Empty -->
<div v-else-if="!documents.length" class="py-10 px-6 text-center">
<i class="pi pi-inbox text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-sm">
<div v-else-if="!documents.length" class="empty-rich m-4">
<div class="empty-rich__icon">
<i :class="hasActiveFilter ? 'pi pi-filter-slash' : 'pi pi-folder-open'" />
</div>
<div class="empty-rich__title">
{{ hasActiveFilter ? 'Nenhum documento encontrado' : 'Nenhum documento ainda' }}
</div>
<div class="text-xs opacity-60 mt-1">
{{ hasActiveFilter ? 'Limpe os filtros ou ajuste a busca' : resolvedPatientId ? 'Faça upload do primeiro documento' : 'Selecione um paciente para adicionar documentos' }}
<div class="empty-rich__sub">
{{ hasActiveFilter ? 'Limpe os filtros ou ajuste a busca pra ver outros resultados.' : resolvedPatientId ? 'Faça upload do primeiro laudo, receita, exame ou termo assinado deste paciente.' : 'Selecione um paciente para adicionar documentos.' }}
</div>
<Button v-if="resolvedPatientId && !hasActiveFilter" label="Enviar primeiro documento" icon="pi pi-upload" severity="secondary" outlined size="small" class="rounded-full mt-3" @click="uploadDlg = true" />
<Button v-if="resolvedPatientId && !hasActiveFilter" label="Enviar primeiro documento" icon="pi pi-upload" class="empty-rich__cta rounded-full" @click="uploadDlg = true" />
</div>
<!-- Lista -->
@@ -377,3 +376,50 @@ watch(filters, () => fetchDocuments(), { deep: true })
<DocumentShareDialog :visible="shareDlg" @update:visible="shareDlg = $event" :doc="selectedDoc" />
<ConfirmDialog />
</template>
<style scoped>
/* Empty state rico espelha .pp-empty--rich do PatientProntuario.vue.
Padroniza visual em ambos os modos (embedded e standalone). */
.empty-rich {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 14px;
padding: 48px 24px;
border: 2px dashed color-mix(in srgb, var(--primary-color) 35%, var(--surface-border));
border-radius: 16px;
background:
radial-gradient(ellipse at top, color-mix(in srgb, var(--primary-color) 5%, transparent), transparent 70%),
var(--surface-card);
color: var(--text-color-secondary);
text-align: center;
}
.empty-rich__icon {
width: 72px;
height: 72px;
display: grid;
place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
color: var(--primary-color);
margin-bottom: 4px;
}
.empty-rich__icon .pi { font-size: 2rem; }
.empty-rich__title {
font-size: 1rem;
font-weight: 700;
color: var(--text-color);
letter-spacing: -0.02em;
}
.empty-rich__sub {
font-size: 0.82rem;
color: var(--text-color-secondary);
max-width: 340px;
line-height: 1.5;
}
.empty-rich__cta {
margin-top: 6px;
}
</style>
@@ -746,7 +746,7 @@ const escolaridadeOpts = [
{ label:'Prefere não informar', value:'Prefere não informar' },
]
const canalOpts = [{ label:'WhatsApp',value:'WhatsApp' },{ label:'Telefone',value:'Telefone' },{ label:'E-mail',value:'E-mail' },{ label:'SMS',value:'SMS' }]
const statusOpts = [{ label:'Ativo',value:'Ativo' },{ label:'Em espera',value:'Em espera' },{ label:'Inativo',value:'Inativo' },{ label:'Alta',value:'Alta' }]
const statusOpts = [{ label:'Ativo',value:'Ativo' },{ label:'Em espera',value:'Em espera' },{ label:'Inativo',value:'Inativo' },{ label:'Alta',value:'Alta' },{ label:'Arquivado',value:'Arquivado' }]
const scopeOpts = [{ label:'Clínica',value:'Clínica' },{ label:'Particular',value:'Particular' },{ label:'Online',value:'Online' },{ label:'Híbrido',value:'Híbrido' }]
const tipoContatoOpts = [{ label:'Emergência',value:'emergencia' },{ label:'Familiar',value:'familiar' },{ label:'Profissional de saúde',value:'profissional_saude' },{ label:'Amigo(a)',value:'amigo' },{ label:'Outro',value:'outro' }]
@@ -156,10 +156,10 @@ watch(() => props.patientId, () => { load(); });
</div>
<!-- Empty -->
<div v-else-if="!messages.length" class="flex flex-col items-center justify-center gap-2 py-12 text-center text-[var(--text-color-secondary)]">
<i class="pi pi-comments text-4xl opacity-30" />
<div class="text-sm">Nenhuma conversa registrada com este paciente ainda.</div>
<div class="text-xs opacity-70">
<div v-else-if="!messages.length" class="empty-rich">
<div class="empty-rich__icon"><i class="pi pi-comments" /></div>
<div class="empty-rich__title">Nenhuma conversa registrada</div>
<div class="empty-rich__sub">
Quando {{ props.patientName || 'o paciente' }} enviar uma mensagem pelo WhatsApp (ou você enviar uma), vai aparecer aqui.
</div>
</div>
@@ -219,3 +219,48 @@ watch(() => props.patientId, () => { load(); });
</div>
</div>
</template>
<style scoped>
/* Empty state rico espelha .pp-empty--rich do PatientProntuario.vue.
Replicado aqui pra que a aparência seja idêntica em qualquer contexto
(ficha embedded ou página standalone). */
.empty-rich {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 14px;
padding: 48px 24px;
border: 2px dashed color-mix(in srgb, var(--primary-color) 35%, var(--surface-border));
border-radius: 16px;
background:
radial-gradient(ellipse at top, color-mix(in srgb, var(--primary-color) 5%, transparent), transparent 70%),
var(--surface-card);
color: var(--text-color-secondary);
text-align: center;
}
.empty-rich__icon {
width: 72px;
height: 72px;
display: grid;
place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
color: var(--primary-color);
margin-bottom: 4px;
}
.empty-rich__icon .pi { font-size: 2rem; }
.empty-rich__title {
font-size: 1rem;
font-weight: 700;
color: var(--text-color);
letter-spacing: -0.02em;
}
.empty-rich__sub {
font-size: 0.82rem;
color: var(--text-color-secondary);
max-width: 340px;
line-height: 1.5;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -95,6 +95,22 @@ export async function softDeletePatient(id, { tenantId } = {}) {
if (error) throw error;
}
/**
* Restaura um paciente arquivado volta status pra 'Ativo'.
* Inverso explícito do softDeletePatient. Uso: botão "Restaurar"
* que aparece nas ações quando p.status === 'Arquivado'.
*/
export async function restorePatient(id, { tenantId } = {}) {
if (!id) throw new Error('id obrigatório');
assertTenantId(tenantId);
const { error } = await supabase
.from('patients')
.update({ status: 'Ativo' })
.eq('id', id)
.eq('tenant_id', tenantId);
if (error) throw error;
}
// ─────────────────────────────────────────────────────────────────────────
// Groups
// -----------------------------------------------------------------------------
+118 -28
View File
@@ -25,10 +25,9 @@ import ptBrLocale from '@fullcalendar/core/locales/pt-br';
import { useMelissaEventosRange, useMelissaTodasSessoesPaciente, searchEventosByText } from './composables/useMelissaEventos';
import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
import { useMelissaPacientesAside } from './composables/useMelissaPacientesAside';
import { useFeriados } from '@/composables/useFeriados';
import { useTenantStore } from '@/stores/tenantStore';
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosCard.vue';
import MelissaAgendaHistoricoCard from './MelissaAgendaHistoricoCard.vue';
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
import Popover from 'primevue/popover';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
@@ -531,11 +530,15 @@ const fcOptions = computed(() => ({
eventDrop: (info) => {
if (!M) { info.revert?.(); return; }
M.persistMoveOrResize(info, 'Sessão movida');
// audit_logs grava no AFTER trigger; pequeno delay garante que
// a query do histórico já pegue a entrada nova.
setTimeout(() => historicoCardRef.value?.refetch(), 700);
},
// Resize muda duração da sessão
eventResize: (info) => {
if (!M) { info.revert?.(); return; }
M.persistMoveOrResize(info, 'Duração alterada');
setTimeout(() => historicoCardRef.value?.refetch(), 700);
},
// Click-drag em área vazia abre dialog pra criar evento novo, com
// start/end pré-preenchidos. AgendaEventDialog cuida do resto (seleção
@@ -547,8 +550,18 @@ const fcOptions = computed(() => ({
eventContent: (arg) => {
const ext = arg.event.extendedProps || {};
const titulo = arg.event.title || '—';
const time = arg.timeText || '';
const isSessao = String(ext.tipo || '').toLowerCase() === 'sessao';
const fmtHour = (d) => {
if (!d) return '';
const h = d.getHours();
const m = d.getMinutes();
return m === 0 ? `${h}h` : `${h}:${String(m).padStart(2, '0')}h`;
};
const range = arg.event.start && arg.event.end
? `${fmtHour(arg.event.start)}-${fmtHour(arg.event.end)}`
: (arg.timeText || '');
// Badges só pra sessões compromissos pessoais/bloqueios/feriados
// não têm status nem modalidade relevantes pra exibir.
let badgesHtml = '';
@@ -567,11 +580,11 @@ const fcOptions = computed(() => ({
// 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 || '') : '';
const titleLine = `<div class="mc-fc-event__title"><span class="mc-fc-event__name">${escHtml(titulo)}</span>${range ? ` <span class="mc-fc-event__hour">(${escHtml(range)})</span>` : ''}</div>`;
return {
html: `
<div class="mc-fc-event">
<div class="mc-fc-event__time">${escHtml(time)}</div>
<div class="mc-fc-event__title">${escHtml(titulo)}</div>
${titleLine}
${badgesHtml}
${metaFallback ? `<div class="mc-fc-event__meta">${escHtml(metaFallback)}</div>` : ''}
</div>
@@ -684,6 +697,43 @@ function irParaData(date) {
searchDateMatch.value = null;
}
// Card de histórico (audit_logs) ref pra disparar refetch após
// mutações; handler que abre o evento clicado pelo id.
const historicoCardRef = ref(null);
async function onHistoricoOpen({ id }) {
if (!id) return;
try {
const { data, error } = await supabase
.from('agenda_eventos')
.select('*, patients!agenda_eventos_patient_id_fkey(nome_completo, status, avatar_url)')
.eq('id', id)
.maybeSingle();
if (error || !data) {
// Evento pode ter sido deletado depois da entrada fail-soft.
return;
}
// Foca no dia + emite seleção pro MelissaLayout abrir o panel.
const ev = {
...data,
patient_id: data.patient_id,
paciente_nome: data.patients?.nome_completo || '',
paciente_status: data.patients?.status || '',
paciente_avatar: data.patients?.avatar_url || '',
startH: new Date(data.inicio_em).getHours() + new Date(data.inicio_em).getMinutes() / 60,
endH: new Date(data.fim_em).getHours() + new Date(data.fim_em).getMinutes() / 60,
label: data.patients?.nome_completo || data.titulo || data.titulo_custom || '—'
};
if (data.inicio_em) {
fcApi()?.gotoDate(data.inicio_em);
refDate.value = new Date(data.inicio_em);
}
emit('select-evento', ev);
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[onHistoricoOpen]', e);
}
}
function onSelecionarResultado(ev) {
if (!ev?.inicio_em) return;
fcApi()?.gotoDate(ev.inicio_em);
@@ -764,12 +814,15 @@ const miniRefDate = ref(new Date());
// Feriados (nacionais via algoritmo + municipais/personalizados via DB)
// + Jornada semanal (workRules) pra marcar dias fechados em cinza no mini-cal.
// Pattern espelha AgendaTerapeutaPage:113,129-134,1143.
// IMPORTANTE: declarado APÓS miniRefDate porque o watch abaixo lê
// miniRefDate.value durante o setup (rastreio de dependências).
// Reusa as refs do composable injetado M antes essa página instanciava
// novamente useFeriados() e useAgendaSettings(), gerando duplicação de
// queries (feriados municipais + agenda_configuracoes + agenda_regras).
const tenantStore = useTenantStore();
const { todos: feriadosTodos, load: loadFeriados, ano: feriadosAno, fcEvents: feriadoFcEvents } = useFeriados();
const { workRules, load: loadAgendaSettings } = useAgendaSettings();
const feriadosTodos = M.feriados;
const feriadoFcEvents = M.feriadoFcEvents;
const feriadosAno = M.feriadosAno;
const loadFeriados = M.loadFeriadosBase;
const workRules = M.workRules;
// Set de dias da semana ativos (0=dom..6=sáb). Fallback seg-sex se sem regras
// mesmo default do AgendaTerapeutaPage:370.
@@ -779,15 +832,10 @@ const workDowSet = computed(() => {
return new Set([1, 2, 3, 4, 5]);
});
onMounted(() => {
const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
if (tid) loadFeriados(tid, new Date().getFullYear());
// workRules é por owner_id (RLS), não precisa do tenant
loadAgendaSettings();
});
// Recarrega feriados quando mini-cal navega pra outro ano (municipais variam).
// Nacionais são puro algoritmo recomputam automático no useFeriados via `ano`.
// Carga inicial de feriados/settings já é feita pelo useMelissaAgenda no
// mount (watch immediate em clinicTenantId + loadSettings paralelo). Não
// duplicamos aqui só mantemos o reload de feriados quando o mini-cal
// navega pra outro ano (municipais variam por ano).
watch(
() => miniRefDate.value.getFullYear(),
(novoAno) => {
@@ -1080,6 +1128,21 @@ function abrirSessoesPaciente() {
verTodasSessoes.value = true;
fetchTodasSessoes(pacienteSelecionadoId.value);
}
// API pública pro MelissaLayout (botão "Sessões" do MelissaEventoPanel):
// seleciona o paciente e abre o overlay "Todas as sessões" no mesmo
// fluxo do .ma-dock-actions. Importante: setar pacienteSelecionadoId
// ANTES de verTodasSessoes o watch logo abaixo reseta verTodasSessoes
// quando pacienteSelecionadoId muda, então fazemos a ordem inversa.
function openSessoesPaciente(patientId) {
if (!patientId) return;
const id = String(patientId);
if (pacienteSelecionadoId.value !== id) {
pacienteSelecionadoId.value = id;
}
verTodasSessoes.value = true;
fetchTodasSessoes(id);
}
function voltarParaPeriodo() {
verTodasSessoes.value = false;
resetTodasSessoes();
@@ -1125,6 +1188,14 @@ function abrirProntuarioPaciente() {
prontuarioPatient.value = { ...p };
prontuarioOpen.value = true;
}
// API pública pra MelissaLayout chamar via ref (botão "Editar paciente"
// do MelissaEventoPanel). Abre o PatientCadastroDialog já no modo edição.
function openEditPatient(patientId) {
if (!patientId) return;
editPatientId.value = String(patientId);
cadastroFullDialog.value = true;
}
function editarPacienteSelecionado() {
if (!pacienteSelecionadoId.value) return;
editPatientId.value = String(pacienteSelecionadoId.value);
@@ -1171,7 +1242,9 @@ function openProntuario(patient) {
defineExpose({
refetch: refetchEventosFc,
openProntuario,
setView
setView,
openSessoesPaciente,
openEditPatient
});
</script>
@@ -1887,6 +1960,14 @@ defineExpose({
@bloqueado="onFeriadoBloqueado"
/>
<!-- Histórico de ações na agenda (audit_logs) útil pra
rastrear movimentações recentes. Click na entrada
abre o evento (se ainda existe). -->
<MelissaAgendaHistoricoCard
ref="historicoCardRef"
@open-evento="onHistoricoOpen"
/>
</aside>
</Teleport>
</div>
@@ -3210,22 +3291,26 @@ html.app-dark .ma-tsearch__result--date .ma-tsearch__result-sub {
color: var(--m-text);
font-family: inherit;
}
.ma-cal__fc :deep(.mc-fc-event__time) {
font-size: 0.6rem;
color: var(--m-text-muted);
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.ma-cal__fc :deep(.mc-fc-event__title) {
/* Mesmo tamanho/peso da .ma-pat__name (lista de pacientes) pra
alinhar a hierarquia visual entre aside e calendário. */
alinhar a hierarquia visual entre aside e calendário.
Nome + hora em linha única; ellipsis corta o nome antes da hora. */
font-size: 0.85rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
margin-top: 1px;
}
.ma-cal__fc :deep(.mc-fc-event__name) {
font-weight: 500;
}
.ma-cal__fc :deep(.mc-fc-event__hour) {
font-size: 0.7rem;
font-weight: 400;
color: var(--m-text-muted);
font-variant-numeric: tabular-nums;
margin-left: 2px;
}
.ma-cal__fc :deep(.mc-fc-event__meta) {
font-size: 0.6rem;
@@ -3495,6 +3580,11 @@ html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--modal.is-presencial)
background: var(--m-bg-medium) !important;
border-color: var(--m-border) !important;
border-radius: 12px !important;
min-height: 158px;
/* flex: 0 0 auto toma o tamanho natural do conteúdo (incluindo
expansão da confirmação inline) e NÃO encolhe quando o histórico
crescer. O histórico (flex: 1 abaixo) absorve o restante. */
flex: 0 0 auto;
}
:deep(.ma-w-feriados .border-b) {
border-color: var(--m-border);
@@ -0,0 +1,335 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/layout/melissa/MelissaAgendaHistoricoCard.vue
| Data: 2026-05-04
|
| Card de histórico de ações na agenda mostra movimentações, criações,
| status e edições recentes do owner_id logado. Útil quando o user move
| várias sessões e quer revisar o que fez.
|
| de audit_logs via useMelissaAgendaHistorico ( agrega + classifica).
| Agrupa por dia (Hoje, Ontem, X dias atrás) pra leitura cronológica.
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useMelissaAgendaHistorico } from './composables/useMelissaAgendaHistorico';
const emit = defineEmits(['open-evento']);
// days=1 já cobre "hoje" no servidor (last 24h); o filtro abaixo refina
// pra início do dia local entradas das últimas 24h que cruzam meia-noite
// não devem aparecer no card "de hoje".
const { entries, loading, refetch } = useMelissaAgendaHistorico({ limit: 30, days: 1 });
const router = useRouter();
const route = useRoute();
onMounted(refetch);
// Filtra estritamente pro dia atual (00:00 agora). Audit logs vêm em UTC,
// new Date(iso) já normaliza pra timezone local comparar com início do
// dia local (setHours 0,0,0,0) garante consistência.
const todaysEntries = computed(() => {
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const startMs = startOfDay.getTime();
return entries.value.filter((e) => new Date(e.when).getTime() >= startMs);
});
// Sem agrupamento por dia só exibe entradas de hoje (sem header de grupo).
const items = computed(() => todaysEntries.value);
function goToHistoricoCompleto() {
const target = route.path?.startsWith('/melissa') ? '/melissa/cfg-auditoria' : '/configuracoes/auditoria';
router.push(target);
}
const KIND_META = {
create: { icon: 'pi pi-plus-circle', color: '#4ade80', label: 'Criou' },
move: { icon: 'pi pi-arrows-alt', color: '#60a5fa', label: 'Moveu' },
status: { icon: 'pi pi-tag', color: '#f59e0b', label: 'Status' },
edit: { icon: 'pi pi-pencil', color: '#a78bfa', label: 'Editou' },
delete: { icon: 'pi pi-trash', color: '#f87171', label: 'Removeu' }
};
function fmtRelative(iso) {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const min = Math.round(diff / 60000);
if (min < 1) return 'agora';
if (min < 60) return `${min} min`;
const h = Math.round(min / 60);
if (h < 24) return `${h}h`;
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function onClickEntry(entry) {
if (entry.kind === 'delete') return; // evento não existe mais
if (entry.evento_id) emit('open-evento', { id: entry.evento_id });
}
defineExpose({ refetch });
</script>
<template>
<section class="hist-card">
<header class="hist-card__head">
<div class="hist-card__title">
<i class="pi pi-history" />
<span>Histórico</span>
</div>
<button
type="button"
class="hist-card__refresh"
:disabled="loading"
v-tooltip.top="'Atualizar'"
@click="refetch"
>
<i class="pi pi-refresh" :class="{ 'pi-spin': loading }" />
</button>
</header>
<div v-if="loading && !items.length" class="hist-card__empty">
<i class="pi pi-spin pi-spinner" /> Carregando
</div>
<div v-else-if="!items.length" class="hist-card__empty">
<i class="pi pi-inbox" />
<span>Sem ações hoje.</span>
</div>
<ol v-else class="hist-card__list">
<li
v-for="e in items" :key="e.id"
class="hist-card__item"
:class="{ 'is-clickable': e.kind !== 'delete' }"
:data-kind="e.kind"
@click="onClickEntry(e)"
>
<span class="hist-card__icon" :style="{ color: KIND_META[e.kind]?.color, background: `${KIND_META[e.kind]?.color}1f` }">
<i :class="KIND_META[e.kind]?.icon" />
</span>
<div class="hist-card__body">
<div class="hist-card__row1">
<span class="hist-card__paciente" v-if="e.paciente">{{ e.paciente }}</span>
<span class="hist-card__paciente hist-card__paciente--anon" v-else></span>
<span class="hist-card__when">{{ fmtRelative(e.when) }}</span>
</div>
<div class="hist-card__label">{{ e.label }}</div>
</div>
</li>
</ol>
<!-- Atalho pra página de Auditoria completa (todas as entidades,
não agenda documentos, pacientes, financeiro, etc). -->
<button
type="button"
class="hist-card__more"
v-tooltip.top="'Ver auditoria completa'"
@click="goToHistoricoCompleto"
>
<i class="pi pi-external-link" />
<span>Ver histórico completo</span>
</button>
</section>
</template>
<style scoped>
.hist-card {
background: var(--m-bg-soft, rgba(255, 255, 255, 0.04));
border: 1px solid var(--m-border, rgba(255, 255, 255, 0.08));
border-radius: 14px;
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
padding: 12px 14px;
color: var(--m-text, white);
font-family: 'Segoe UI', system-ui, sans-serif;
/* Toma o espaço restante do aside (.ma-widgets é flex column).
min-height: 0 é necessário pra flex calcular shrink corretamente
quando feriados expande (confirmação inline) e empurra o histórico. */
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.hist-card__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.hist-card__title {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--m-text-muted, rgba(255, 255, 255, 0.7));
}
.hist-card__title i { font-size: 0.78rem; opacity: 0.8; }
.hist-card__refresh {
width: 24px;
height: 24px;
display: grid;
place-items: center;
background: transparent;
border: none;
border-radius: 6px;
color: var(--m-text-muted, rgba(255, 255, 255, 0.55));
cursor: pointer;
font-size: 0.7rem;
transition: background-color 140ms ease, color 140ms ease;
}
.hist-card__refresh:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.06);
color: var(--m-text, white);
}
.hist-card__refresh:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hist-card__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 18px 8px;
color: var(--m-text-muted, rgba(255, 255, 255, 0.5));
font-size: 0.78rem;
text-align: center;
}
.hist-card__empty .pi { font-size: 1.2rem; opacity: 0.5; }
.hist-card__list {
list-style: none;
margin: 0;
padding: 0;
/* Toma todo o restante do card. min-height:0 destrava shrink dentro
de flex column. Sem max-height fixo: a lista cresce/encolhe junto
com o card (que por sua vez balanceia com o feriados acima). */
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
}
.hist-card__list::-webkit-scrollbar { width: 4px; }
.hist-card__list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
.hist-card__item {
display: flex;
gap: 10px;
padding: 8px 4px;
border-radius: 8px;
transition: background-color 140ms ease, transform 140ms ease;
overflow: hidden;
min-width: 0;
}
.hist-card__item.is-clickable {
cursor: pointer;
}
.hist-card__item.is-clickable:hover {
background: rgba(255, 255, 255, 0.05);
transform: translateX(1px);
}
.hist-card__item[data-kind="delete"] {
opacity: 0.7;
}
.hist-card__icon {
flex-shrink: 0;
width: 26px;
height: 26px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.72rem;
}
.hist-card__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.hist-card__row1 {
display: flex;
align-items: baseline;
gap: 6px;
}
.hist-card__paciente {
font-size: 0.78rem;
font-weight: 600;
color: var(--m-text, white);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.hist-card__paciente--anon { font-style: italic; opacity: 0.55; font-weight: 400; }
.hist-card__when {
font-size: 0.68rem;
color: var(--m-text-muted, rgba(255, 255, 255, 0.5));
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.hist-card__label {
font-size: 0.7rem;
color: var(--m-text-muted, rgba(255, 255, 255, 0.7));
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Botão "Ver histórico completo" vai pra AuditoriaPage central
(mesmo destino em /melissa/cfg-auditoria ou /configuracoes/auditoria). */
.hist-card__more {
margin-top: 10px;
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 10px;
background: transparent;
border: 1px dashed var(--m-border, rgba(255, 255, 255, 0.18));
border-radius: 10px;
color: var(--m-text-muted, rgba(255, 255, 255, 0.7));
font-family: inherit;
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.01em;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
}
.hist-card__more:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--m-text, white);
border-color: var(--m-border-strong, rgba(255, 255, 255, 0.25));
border-style: solid;
}
.hist-card__more i { font-size: 0.7rem; opacity: 0.85; }
/* Light mode adjusts contrast */
html:not(.app-dark) .hist-card {
background: rgba(255, 255, 255, 0.7);
border-color: rgba(15, 23, 42, 0.08);
}
html:not(.app-dark) .hist-card__group-label {
background: rgba(248, 250, 252, 0.95);
}
html:not(.app-dark) .hist-card__item.is-clickable:hover {
background: rgba(15, 23, 42, 0.04);
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+363 -118
View File
@@ -148,6 +148,19 @@ const carregandoInicial = computed(
const unlinkedCount = computed(() => filteredThreads.value.filter((t) => !t.patient_id).length);
// "Limpar filtros" global (footer fixo da sidebar)
// `filters` é um ref({...}) (vide useConversations.js). No script
// preciso acessar via .value; no template o auto-unwrap cuida.
const hasActiveFilters = computed(() =>
!!(filters.value.search || filters.value.unreadOnly || filters.value.assigned || filters.value.channel)
);
function clearAllFilters() {
filters.value.search = '';
filters.value.unreadOnly = false;
filters.value.assigned = null;
filters.value.channel = null;
}
// Popover de Ações (compact)
const actionsPopRef = ref(null);
function openActions(e) { actionsPopRef.value?.toggle(e); }
@@ -266,119 +279,172 @@ watch(() => tenantStore.activeTenantId, async () => {
</div>
</Popover>
<!-- Subheader explicativo (blueprint §9) diferencia de
outras páginas Melissa que mostram listas tabulares. -->
<div class="mw-subheader">
<i class="pi pi-info-circle mw-subheader__icon" />
<span class="mw-subheader__text">
CRM de mensagens organizado em <strong>kanban por urgência</strong>:
Urgente / Aguardando resposta / Aguardando paciente / Resolvido.
Click num card abre a conversa no painel lateral.
</span>
</div>
<div class="mw-body">
<!-- COL 1: Filtros + atribuição + canais + status -->
<Teleport to="#mw-mobile-drawer-target" :disabled="!isMobile">
<aside class="mw-side">
<!-- Filtros rápidos -->
<div class="mw-w">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-filter" /> Filtros rápidos</span>
</div>
<div class="mw-side__list">
<button
class="mw-side__item"
:class="{ 'is-active': !filters.unreadOnly && !filters.channel }"
@click="filters.unreadOnly = false; filters.channel = null; filters.search = ''"
>
<i class="pi pi-list" />
<span>Todas</span>
<span class="mw-side__count">{{ summary.total }}</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.unreadOnly, 'is-warn': summary.unreadTotal > 0 }"
@click="filters.unreadOnly = !filters.unreadOnly"
>
<i class="pi pi-bell" />
<span>Não lidas</span>
<span class="mw-side__count" :class="{ 'is-danger': summary.unreadTotal > 0 }">{{ summary.unreadTotal }}</span>
</button>
</div>
</div>
<!-- Atribuição -->
<div class="mw-w">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-user" /> Atribuição</span>
</div>
<div class="mw-side__list">
<button
class="mw-side__item"
:class="{ 'is-active': !filters.assigned }"
@click="filters.assigned = null"
>
<i class="pi pi-list" />
<span>Todas</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.assigned === 'me' }"
@click="filters.assigned = 'me'"
>
<i class="pi pi-user" />
<span>Minhas</span>
<span class="mw-side__count">{{ mineCount }}</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.assigned === 'unassigned' }"
@click="filters.assigned = 'unassigned'"
>
<i class="pi pi-user-minus" />
<span>Não atribuídas</span>
<span class="mw-side__count" :class="{ 'is-warn': unassignedCount > 0 }">{{ unassignedCount }}</span>
</button>
</div>
</div>
<!-- Por status (kanban resumo) -->
<div class="mw-w">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-chart-bar" /> Por status</span>
</div>
<div class="mw-side__list">
<div
v-for="col in KANBAN_COLUMNS"
:key="col.key"
class="mw-side__row"
:class="`is-${col.color}`"
>
<i :class="col.icon" />
<span>{{ col.label }}</span>
<span class="mw-side__count">{{ summary[col.key] || 0 }}</span>
<div class="mw-side__scroll">
<!-- Alerta unlinked no topo pra ficar bem visível
(números de telefone sem paciente vinculado). -->
<div v-if="unlinkedCount > 0" class="mw-alert">
<i class="pi pi-exclamation-circle" />
<div>
<div class="mw-alert__title">{{ unlinkedCount }} sem paciente vinculado</div>
<div class="mw-alert__hint">Números de telefone que não batem com pacientes cadastrados.</div>
</div>
</div>
<!-- Filtros rápidos -->
<div class="mw-w mw-w--side">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-filter" /> Filtros rápidos</span>
<button
v-if="filters.unreadOnly"
class="mw-side__clear-inline"
v-tooltip.top="'Limpar filtro de não lidas'"
aria-label="Limpar filtro de não lidas"
@click="filters.unreadOnly = false"
>
<i class="pi pi-times" />
</button>
</div>
<div class="mw-side__list">
<button
class="mw-side__item"
:class="{ 'is-active': !filters.unreadOnly }"
@click="filters.unreadOnly = false"
>
<i class="pi pi-list" />
<span>Todas</span>
<span class="mw-side__count">{{ summary.total }}</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.unreadOnly, 'is-warn': summary.unreadTotal > 0 }"
@click="filters.unreadOnly = !filters.unreadOnly"
>
<i class="pi pi-bell" />
<span>Não lidas</span>
<span class="mw-side__count" :class="{ 'is-danger': summary.unreadTotal > 0 }">{{ summary.unreadTotal }}</span>
</button>
</div>
</div>
<!-- Atribuição -->
<div class="mw-w mw-w--side">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-user" /> Atribuição</span>
<button
v-if="filters.assigned"
class="mw-side__clear-inline"
v-tooltip.top="'Limpar filtro de atribuição'"
aria-label="Limpar filtro de atribuição"
@click="filters.assigned = null"
>
<i class="pi pi-times" />
</button>
</div>
<div class="mw-side__list">
<button
class="mw-side__item"
:class="{ 'is-active': !filters.assigned }"
@click="filters.assigned = null"
>
<i class="pi pi-list" />
<span>Todas</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.assigned === 'me' }"
@click="filters.assigned = 'me'"
>
<i class="pi pi-user" />
<span>Minhas</span>
<span class="mw-side__count">{{ mineCount }}</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.assigned === 'unassigned' }"
@click="filters.assigned = 'unassigned'"
>
<i class="pi pi-user-minus" />
<span>Não atribuídas</span>
<span class="mw-side__count" :class="{ 'is-warn': unassignedCount > 0 }">{{ unassignedCount }}</span>
</button>
</div>
</div>
<!-- Por status (kanban resumo display-only, sem X) -->
<div class="mw-w mw-w--side">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-chart-bar" /> Por status</span>
</div>
<div class="mw-side__list">
<div
v-for="col in KANBAN_COLUMNS"
:key="col.key"
class="mw-side__row"
:class="`is-${col.color}`"
>
<i :class="col.icon" />
<span>{{ col.label }}</span>
<span class="mw-side__count">{{ summary[col.key] || 0 }}</span>
</div>
</div>
</div>
<!-- Canais -->
<div class="mw-w mw-w--side">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-send" /> Canais</span>
<button
v-if="filters.channel"
class="mw-side__clear-inline"
v-tooltip.top="'Limpar filtro de canal'"
aria-label="Limpar filtro de canal"
@click="filters.channel = null"
>
<i class="pi pi-times" />
</button>
</div>
<div class="mw-side__list">
<button
v-for="opt in CHANNEL_OPTIONS"
:key="String(opt.value)"
class="mw-side__item"
:class="{ 'is-active': filters.channel === opt.value }"
@click="filters.channel = opt.value"
>
<i v-if="opt.value" :class="['pi', channelIcon(opt.value)]" />
<i v-else class="pi pi-list" />
<span>{{ opt.label }}</span>
</button>
</div>
</div>
</div>
<!-- Canais -->
<div class="mw-w">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-send" /> Canais</span>
</div>
<div class="mw-side__list">
<button
v-for="opt in CHANNEL_OPTIONS"
:key="String(opt.value)"
class="mw-side__item"
:class="{ 'is-active': filters.channel === opt.value }"
@click="filters.channel = opt.value"
>
<i v-if="opt.value" :class="['pi', channelIcon(opt.value)]" />
<i v-else class="pi pi-list" />
<span>{{ opt.label }}</span>
<!-- Footer fixo: "Limpar filtros" global (zera busca,
unread, atribuição e canal de uma vez). -->
<Transition name="mw-clear">
<div v-if="hasActiveFilters" class="mw-side__footer">
<button class="mw-side__clear-all" @click="clearAllFilters">
<i class="pi pi-filter-slash" />
<span>Limpar filtros</span>
</button>
</div>
</div>
<!-- Alerta unlinked -->
<div v-if="unlinkedCount > 0" class="mw-alert">
<i class="pi pi-exclamation-circle" />
<div>
<div class="mw-alert__title">{{ unlinkedCount }} sem paciente vinculado</div>
<div class="mw-alert__hint">Números de telefone que não batem com pacientes cadastrados.</div>
</div>
</div>
</Transition>
</aside>
</Teleport>
@@ -611,42 +677,166 @@ watch(() => tenantStore.activeTenantId, async () => {
}
.mw-menu-btn > i { font-size: 0.85rem; }
/* Body */
/* Subheader explicativo (blueprint §9) */
.mw-subheader {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 18px;
border-bottom: 1px solid var(--m-border);
background: var(--m-bg-soft);
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.45;
flex-shrink: 0;
}
.mw-subheader__icon {
color: var(--p-primary-color);
font-size: 0.92rem;
flex-shrink: 0;
margin-top: 1px;
}
.mw-subheader__text { flex: 1; min-width: 0; }
.mw-subheader__text strong { color: var(--m-text); font-weight: 600; }
/* Body sem padding/gap; a sidebar tem bg+border-right próprios e o
main column controla seu padding interno. Espelha o pattern usado
em MelissaGrupos / MelissaTags / MelissaMedicos. */
.mw-body {
flex: 1;
display: flex;
min-height: 0;
position: relative;
gap: 12px;
padding: 12px;
gap: 0;
padding: 0;
}
/* Aside */
/* Aside 2 zonas: __scroll (cards) + __footer (Limpar filtros fixo).
bg colorido próprio (--m-bg-soft) + border-right pra separar
visualmente da coluna principal. */
.mw-side {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
overflow: hidden;
}
.mw-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mw-side::-webkit-scrollbar { width: 5px; }
.mw-side::-webkit-scrollbar-thumb {
.mw-side__scroll::-webkit-scrollbar { width: 5px; }
.mw-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Footer fixo no bottom da sidebar (fora do scroll dos filter cards).
Aparece com fade+collapse quando algum filtro está ativo. */
.mw-side__footer {
flex-shrink: 0;
padding: 12px;
background: var(--m-bg-soft);
border-top: 1px solid var(--m-border);
}
.mw-side__clear-all {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
color: var(--m-text);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.mw-side__clear-all:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
color: var(--m-text);
}
.mw-side__clear-all > i {
font-size: 0.78rem;
color: var(--m-text-muted);
transition: color 140ms ease;
}
.mw-side__clear-all:hover > i { color: var(--m-text); }
/* X inline ao lado do título de cada filter card limpa o filtro
individual. Espelha o pattern do MelissaPacientes/Grupos/Tags. */
.mw-side__clear-inline {
width: 18px;
height: 18px;
display: grid;
place-items: center;
background: transparent;
border: 1px solid color-mix(in srgb, rgb(220, 38, 38) 30%, var(--m-border));
color: rgb(220, 38, 38);
border-radius: 4px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mw-side__clear-inline:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.55);
}
.mw-side__clear-inline > i { font-size: 0.6rem; }
/* Transition do footer "Limpar filtros" */
.mw-clear-enter-active,
.mw-clear-leave-active {
transition: opacity 220ms ease, transform 220ms ease, max-height 240ms ease;
overflow: hidden;
}
.mw-clear-enter-from,
.mw-clear-leave-to {
opacity: 0;
transform: translateY(6px);
max-height: 0;
}
.mw-clear-enter-to,
.mw-clear-leave-from {
opacity: 1;
transform: translateY(0);
max-height: 80px;
}
/* Card-base — alinhado com .ma-w / .mp-w / .mcr-w: surface --m-bg-medium
pra destacar do bg da página/dialog (ambos --m-bg-soft). */
pra destacar do bg da sidebar (--m-bg-soft). */
.mw-w {
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 12px;
}
.mw-w__head { margin-bottom: 10px; }
/* Modifier pros cards dentro da .mw-side margem lateral + sombra
sutil pra elevar sobre o bg da sidebar. Espelha .mc-w--side, .mt-w--side. */
.mw-w--side {
margin: 12px 12px 0;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.mw-w--side:last-of-type { margin-bottom: 12px; }
.mw-w__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.mw-w__title {
display: inline-flex;
align-items: center;
@@ -734,12 +924,16 @@ watch(() => tenantStore.activeTenantId, async () => {
.mw-alert {
display: flex;
gap: 10px;
margin: 12px 12px 0;
padding: 12px;
border-radius: 10px;
border: 1px solid rgba(251, 191, 36, 0.3);
background: rgba(251, 191, 36, 0.05);
color: rgb(251, 191, 36);
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.mw-alert:last-child { margin-bottom: 12px; }
.mw-alert > i { font-size: 0.85rem; margin-top: 2px; }
.mw-alert__title {
font-size: 0.78rem;
@@ -751,13 +945,15 @@ watch(() => tenantStore.activeTenantId, async () => {
margin-top: 2px;
}
/* Main */
/* Main recebe padding interno (o body não tem mais padding/gap;
sidebar fica colada à esquerda com border-right). */
.mw-main {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 12px;
}
/* Kanban */
@@ -1009,11 +1205,40 @@ watch(() => tenantStore.activeTenantId, async () => {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Sidebar teleportada pro drawer perde bg/border-right (o drawer
tem chrome próprio) + cards perdem margem lateral (drawer
tem padding). Footer vira sticky no bottom do drawer. */
.mw-mobile-drawer__scroll .mw-side {
width: 100%;
height: auto;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
}
.mw-mobile-drawer__scroll .mw-side__scroll {
flex: none;
min-height: 0;
overflow: visible;
display: flex;
flex-direction: column;
gap: 12px;
}
.mw-mobile-drawer__scroll .mw-w--side {
margin: 0;
}
.mw-mobile-drawer__scroll .mw-w--side:last-of-type { margin-bottom: 0; }
.mw-mobile-drawer__scroll .mw-alert { margin: 0; }
.mw-mobile-drawer__scroll .mw-side__footer {
position: sticky;
bottom: 0;
margin: 8px -12px -24px;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
z-index: 5;
}
.mw-mobile-drawer__backdrop {
position: fixed;
@@ -1035,12 +1260,32 @@ watch(() => tenantStore.activeTenantId, async () => {
.mw-kanban { grid-template-columns: repeat(2, 1fr); }
}
/* ═══ Mobile (<lg) — drawer + kanban 1-col ═══ */
/* Mobile (<lg) drawer + kanban 1-col stacked
Em mobile o kanban vira flex column (stacked) e o scroll passa a ser
global no .mw-main (não interno por coluna). Cada .mw-col cresce com
o conteúdo + min-height pra empty state ter altura visível. */
@media (max-width: 1023px) {
.mw-body { flex-direction: column; padding: 8px; }
.mw-main { width: 100%; }
.mw-kanban { grid-template-columns: 1fr; }
.mw-col { min-height: auto; }
.mw-body { flex-direction: column; padding: 0; }
.mw-main {
width: 100%;
padding: 8px;
overflow-y: auto;
}
.mw-kanban {
display: flex;
flex-direction: column;
flex: none;
gap: 8px;
}
.mw-col {
flex: none;
min-height: 200px;
}
.mw-col__body {
flex: none;
overflow: visible;
min-height: 80px;
}
.mw-page__title > span:first-of-type { display: none; }
.mw-menu-btn--mobile-only { display: inline-flex; }
}
+3 -6
View File
@@ -53,12 +53,9 @@ const EMBED_MAP = {
icon: 'pi pi-file-edit',
comp: defineAsyncComponent(() => import('@/features/documents/DocumentTemplatesPage.vue'))
},
'agendamentos-recebidos': {
label: 'Agendamentos recebidos',
desc: 'Solicitações vindas do agendador online à espera de confirmação.',
icon: 'pi pi-inbox',
comp: defineAsyncComponent(() => import('@/features/agenda/pages/AgendamentosRecebidosPage.vue'))
},
// 'agendamentos-recebidos' migrou pra Melissa Page nativa
// (MelissaAgendamentosRecebidos.vue) segue o blueprint
// melissa-table-page-blueprint.md. Removido do embed map.
'online-scheduling': {
label: 'Agendador online',
desc: 'Configure o link público pra pacientes solicitarem horários.',
+196 -86
View File
@@ -29,7 +29,8 @@ const emit = defineEmits([
'faltou',
'cancelar',
'remarcar',
'edit',
'edit-sessao', // botão dedicado ao lado das horas AgendaEventDialog
'edit-paciente', // botão "Editar" do grupo Outras opções PatientCadastroDialog
'abrir-prontuario',
'whatsapp',
'historico'
@@ -68,9 +69,6 @@ const isSessaoComPaciente = computed(
() => ev.value.tipo === 'sessao' && (ev.value.patient_id || ev.value.pacienteNome)
);
// Status finais não permitem mudar pra outro status (UI mais clara)
const statusEhFinal = computed(() => /realizad|faltou|cancelad/i.test(ev.value.status || ''));
function fmtHora(decimal) {
if (decimal === null || decimal === undefined || Number.isNaN(decimal)) return '—';
const h = Math.floor(decimal);
@@ -123,6 +121,16 @@ 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>
<div v-if="ev.modalidade" class="evento-row">
@@ -140,83 +148,111 @@ function modalidadeIcon(mod) {
</div>
</div>
<!-- Action bar agrupada por contexto -->
<!-- Action bar agrupada por contexto.
Cada botão tem ícone + label textual empilhados pra reduzir
ambiguidade (tooltip sozinho não é descobrível em touch). -->
<footer class="evento-actions">
<!-- Grupo Status pra sessão e quando ainda não é status final -->
<div v-if="isSessaoComPaciente && !statusEhFinal" class="evento-actions__group">
<button
class="evento-act evento-act--ok"
:disabled="busy"
v-tooltip.top="'Marcar como realizada'"
@click="emit('concluir')"
>
<i class="pi pi-check-circle" />
</button>
<button
class="evento-act evento-act--warn"
:disabled="busy"
v-tooltip.top="'Marcar como falta'"
@click="emit('faltou')"
>
<i class="pi pi-user-minus" />
</button>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Remarcar'"
@click="emit('remarcar')"
>
<i class="pi pi-calendar-clock" />
</button>
<button
class="evento-act evento-act--danger"
:disabled="busy"
v-tooltip.top="'Cancelar'"
@click="emit('cancelar')"
>
<i class="pi pi-ban" />
</button>
</div>
<!-- Grupo Status sempre visível pra sessão (permite trocar
de status mesmo após marcar realizado/faltou/cancelado).
Status atual fica destacado via .is-current. -->
<section v-if="isSessaoComPaciente" class="evento-actions__section">
<div class="evento-actions__label">Marcar sessão como:</div>
<div class="evento-actions__group">
<button
class="evento-act evento-act--ok"
:class="{ 'is-current': statusSlug === 'realizado' }"
:disabled="busy"
@click="emit('concluir')"
>
<i class="pi pi-check-circle" />
<span class="evento-act__label">Realizada</span>
</button>
<button
class="evento-act evento-act--warn"
:class="{ 'is-current': statusSlug === 'faltou' }"
:disabled="busy"
@click="emit('faltou')"
>
<i class="pi pi-user-minus" />
<span class="evento-act__label">Falta</span>
</button>
<button
class="evento-act"
:class="{ 'is-current': statusSlug === 'remarcar' || statusSlug === 'remarcado' }"
:disabled="busy"
@click="emit('remarcar')"
>
<i class="pi pi-calendar-clock" />
<span class="evento-act__label">Reagendar</span>
</button>
<button
class="evento-act evento-act--danger"
:class="{ 'is-current': statusSlug === 'cancelado' }"
:disabled="busy"
@click="emit('cancelar')"
>
<i class="pi pi-ban" />
<span class="evento-act__label">Cancelar</span>
</button>
</div>
</section>
<!-- Grupo Paciente pra sessão com paciente vinculado -->
<div v-if="isSessaoComPaciente" class="evento-actions__group">
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Abrir prontuário'"
@click="emit('abrir-prontuario')"
>
<i class="pi pi-file" />
</button>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Conversar (WhatsApp)'"
@click="emit('whatsapp')"
>
<i class="pi pi-whatsapp" />
</button>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Histórico de sessões'"
@click="emit('historico')"
>
<i class="pi pi-history" />
</button>
</div>
<!-- Grupo Outras opções pra sessão com paciente.
"Editar" abre o cadastro do paciente (não a sessão);
pra editar a sessão, usar o botão ao lado das horas. -->
<section v-if="isSessaoComPaciente" class="evento-actions__section">
<div class="evento-actions__label">Outras opções:</div>
<div class="evento-actions__group">
<button
class="evento-act"
:disabled="busy"
@click="emit('abrir-prontuario')"
>
<i class="pi pi-file" />
<span class="evento-act__label">Prontuário</span>
</button>
<button
class="evento-act"
:disabled="busy"
@click="emit('historico')"
>
<i class="pi pi-history" />
<span class="evento-act__label">Sessões</span>
</button>
<button
class="evento-act"
:disabled="busy"
@click="emit('whatsapp')"
>
<i class="pi pi-whatsapp" />
<span class="evento-act__label">Conversar</span>
</button>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Editar cadastro do paciente'"
@click="emit('edit-paciente')"
>
<i class="pi pi-user-edit" />
<span class="evento-act__label">Editar</span>
</button>
</div>
</section>
<!-- Grupo Geral Editar sempre disponível -->
<div class="evento-actions__group">
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Editar evento'"
@click="emit('edit')"
>
<i class="pi pi-pencil" />
</button>
</div>
<!-- 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">
<div class="evento-actions__group">
<button
class="evento-act"
:disabled="busy"
@click="emit('edit-sessao')"
>
<i class="pi pi-pencil" />
<span class="evento-act__label">Editar</span>
</button>
</div>
</section>
</footer>
</div>
</div>
@@ -333,6 +369,34 @@ 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 {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 100px;
color: var(--m-text-muted);
font-size: 0.7rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
}
.evento-row__edit:hover:not(:disabled) {
background: var(--m-bg-soft-hover);
color: var(--m-text);
border-color: var(--m-accent, var(--primary-color, #7c6af7));
}
.evento-row__edit:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.evento-row__edit i { font-size: 0.65rem; }
.evento-status {
padding: 2px 10px;
border-radius: 999px;
@@ -378,25 +442,41 @@ function modalidadeIcon(mod) {
/* ─── Action bar ────────────────────────────────── */
.evento-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
flex-direction: column;
gap: 12px;
padding-top: 14px;
border-top: 1px solid var(--m-border);
justify-content: space-between;
}
.evento-actions__section {
display: flex;
flex-direction: column;
gap: 6px;
}
.evento-actions__label {
font-size: 0.7rem;
color: var(--m-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
padding-left: 2px;
}
.evento-actions__group {
display: flex;
gap: 6px;
gap: 4px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 4px;
}
.evento-act {
width: 38px;
height: 38px;
display: grid;
place-items: center;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 4px;
background: transparent;
border: none;
color: var(--m-text);
@@ -406,6 +486,13 @@ function modalidadeIcon(mod) {
font-family: inherit;
transition: background-color 140ms ease, color 140ms ease, transform 140ms ease;
}
.evento-act__label {
font-size: 0.7rem;
line-height: 1.1;
font-weight: 500;
letter-spacing: 0.01em;
white-space: nowrap;
}
.evento-act:hover:not(:disabled) {
background: var(--m-bg-soft-hover);
transform: translateY(-1px);
@@ -431,6 +518,29 @@ function modalidadeIcon(mod) {
background: rgba(239, 68, 68, 0.15);
}
/* Estado .is-current sinaliza o status atual da sessão dentro do
grupo de actions. Permite que o usuário troque o status mesmo após
marcar realizado/faltou/cancelado, vendo qual está ativo. */
.evento-act.is-current {
background: rgba(255, 255, 255, 0.12);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25);
}
.evento-act--ok.is-current {
color: rgb(16, 185, 129);
background: rgba(16, 185, 129, 0.18);
box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.55);
}
.evento-act--warn.is-current {
color: rgb(245, 158, 11);
background: rgba(245, 158, 11, 0.18);
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.55);
}
.evento-act--danger.is-current {
color: rgb(239, 68, 68);
background: rgba(239, 68, 68, 0.18);
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.55);
}
/* Light mode — overlay ainda mais discreto */
html:not(.app-dark) .evento-layer {
background: rgba(15, 23, 42, 0.18);
File diff suppressed because it is too large Load Diff
+262 -12
View File
@@ -35,6 +35,7 @@ import MelissaGrupos from './MelissaGrupos.vue';
import MelissaConfiguracoes from './MelissaConfiguracoes.vue';
import MelissaEmbed from './MelissaEmbed.vue';
import MelissaCadastrosRecebidos from './MelissaCadastrosRecebidos.vue';
import MelissaAgendamentosRecebidos from './MelissaAgendamentosRecebidos.vue';
import MelissaMedicos from './MelissaMedicos.vue';
import MelissaEventoPanel from './MelissaEventoPanel.vue';
import { TOQUES, playToque } from './melissaToques';
@@ -42,6 +43,7 @@ import { useMelissaPacientes } from './composables/useMelissaPacientes';
import { useMelissaEventosHoje } from './composables/useMelissaEventos';
import { useMelissaWhatsapp } from './composables/useMelissaWhatsapp';
import { useMelissaAgenda, MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
import { useMelissaDockPins } from './composables/useMelissaDockPins';
import { supabase } from '@/lib/supabase/client';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
@@ -56,10 +58,53 @@ import { useNotificationStore } from '@/stores/notificationStore';
import { useAjuda } from '@/composables/useAjuda';
import { useTopbarPlanMenu } from '@/composables/useTopbarPlanMenu';
// Pacientes ativos do tenant (real, via Supabase)
const { pacientes: pacientesReais, loading: pacientesLoading, refetch: refetchPacientes } = useMelissaPacientes();
// Eventos reais de hoje alimenta timeline + cards + busca + "Hoje há"
const { eventos: eventosHojeReais, refetch: refetchEventosHoje } = useMelissaEventosHoje();
// Pacientes + eventos do dia.
//
// PERF: quando o usuário entra direto numa seção (`/melissa/agenda`,
// `/melissa/pacientes`...), o resumo fica tapado pelo conteúdo da seção.
// Carregar os dois loaders na hora competiria com as queries da seção
// (que é o que o user efetivamente vai ver). Adiamos os loaders do
// resumo pra rodarem em background via `requestIdleCallback` quando a
// rota inicial já tem seção. Quando o user fecha a seção (volta pro
// resumo), o cache provavelmente já tá quente se não estiver, o
// loading aparece naturalmente.
//
// CACHE: composables usam stale-while-revalidate via melissaCacheStore.
// Reabertura do Melissa na mesma sessão SPA é instantânea.
// Snapshot da rota no setup pra detectar deep-link com seção já no boot.
// (`route` reativo é declarado mais abaixo, mas só precisamos do params
// inicial aqui `setup` roda 1× por mount, params do router já estão
// resolvidos nesse ponto.)
const _hasInitialSecao = !!useRoute().params?.secao;
const {
pacientes: pacientesReais,
loading: pacientesLoading,
refetch: refetchPacientes,
fetchCached: fetchPacientesCached
} = useMelissaPacientes({ autoFetch: !_hasInitialSecao });
const {
eventos: eventosHojeReais,
refetch: refetchEventosHoje,
fetchCached: fetchEventosHojeCached
} = useMelissaEventosHoje({ autoFetch: !_hasInitialSecao });
// Defer manual quando a rota inicial é uma seção: agenda os fetches do
// resumo pra rodarem após a seção montar (idle callback) fetchCached
// usa stale-while-revalidate, então não tomba o cache. setTimeout 200
// como fallback pra navegadores sem requestIdleCallback (Safari < 16).
if (_hasInitialSecao) {
const _idleFetch = () => {
fetchPacientesCached();
fetchEventosHojeCached();
};
if (typeof window !== 'undefined') {
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(_idleFetch, { timeout: 1500 });
} else {
setTimeout(_idleFetch, 200);
}
}
}
//
// Catálogo de cards do resumo (extensível novos cards entram aqui)
@@ -129,14 +174,14 @@ const SECOES = {
};
// Set de keys que renderizam via MelissaEmbed (Onda 1 pages 1-coluna)
const MELISSA_EMBED_KEYS = ['financeiro', 'financeiro-lancamentos', 'documentos', 'documentos-templates', 'agendamentos-recebidos', 'online-scheduling', 'relatorios', 'notificacoes', 'link-externo'];
const MELISSA_EMBED_KEYS = ['financeiro', 'financeiro-lancamentos', 'documentos', 'documentos-templates', 'online-scheduling', 'relatorios', 'notificacoes', 'link-externo'];
// Slugs reservados pra páginas dedicadas (não-config) agenda, pacientes,
// conversas etc. + as MELISSA_EMBED_KEYS. Usado no isConfigRoute pra
// evitar colisão (ex: /melissa/agenda MelissaAgenda, não config).
const MELISSA_NON_CONFIG_SLUGS = new Set([
'agenda', 'pacientes', 'compromissos', 'recorrencias', 'conversas',
'tags', 'grupos', 'cadastros-recebidos', 'medicos',
'tags', 'grupos', 'cadastros-recebidos', 'medicos', 'agendamentos-recebidos',
...MELISSA_EMBED_KEYS
]);
// Aliases "bonitos" + INLINE_KEYS reconhecidos pelo MelissaConfiguracoes.
@@ -171,6 +216,17 @@ const secaoAberta = computed(() => {
return null;
});
// Quando o usuário fecha a seção e volta pro resumo, garante que os
// dados estão prontos (caso o idle callback ainda não tenha disparado
// no fluxo deep-link). fetchCached é idempotente: cache hit instant,
// cache miss fetch real. Sem cache, não dispara nada estranho.
watch(secaoAberta, (newVal, oldVal) => {
if (oldVal && !newVal) {
fetchPacientesCached();
fetchEventosHojeCached();
}
});
function abrirSecao(key) {
// Fecha overlays paralelos pra evitar empilhamento
workspaceOpen.value = false;
@@ -183,6 +239,74 @@ function fecharSecao() {
router.push({ name: 'Melissa', params: {} });
}
// Pins dinâmicos do dock (híbrido: 4 fixos + 3 MRU)
const dockPins = useMelissaDockPins();
const pinContextMenu = ref(null);
const pinContextSlug = ref('');
// Toda vez que a seção muda, registra como "recente" no dock (se não
// for builtin nem pinned). Slugs de configuração (cfg-*) também viram
// recent útil quando o user fica navegando entre páginas de config.
watch(secaoAberta, (slug) => {
if (slug) dockPins.pushRecent(slug);
});
function openPinContextMenu(event, slug) {
event.preventDefault();
event.stopPropagation();
pinContextSlug.value = slug;
pinContextMenu.value?.show(event);
}
const pinContextMenuItems = computed(() => {
const slug = pinContextSlug.value;
if (!slug) return [];
const isPinned = dockPins.isPinned(slug);
const items = [];
if (isPinned) {
items.push({
label: 'Desafixar',
icon: 'pi pi-bookmark',
command: () => dockPins.unpin(slug)
});
} else {
items.push({
label: 'Fixar no dock',
icon: 'pi pi-bookmark-fill',
command: () => {
const result = dockPins.pin(slug);
if (!result.ok && result.reason === 'full') {
toast.add({
severity: 'warn',
summary: 'Limite atingido',
detail: `Você pode fixar até ${dockPins.MAX_PINNED} atalhos. Desafixe um pra liberar espaço.`,
life: 3500
});
}
}
});
}
items.push({ separator: true });
items.push({
label: 'Remover do dock',
icon: 'pi pi-times',
command: () => dockPins.remove(slug)
});
return items;
});
// Resolve label/ícone a partir do slug pra renderizar no pin.
// Usa o catálogo SECOES (já existente) fallback genérico se slug
// for de uma rota cfg-* (configurações embeds).
function pinMeta(slug) {
const fromSecoes = SECOES[slug];
if (fromSecoes) return { label: fromSecoes.label, icon: fromSecoes.icon };
if (slug?.startsWith('cfg-')) {
return { label: 'Configuração', icon: 'pi pi-cog' };
}
return { label: slug, icon: 'pi pi-bookmark' };
}
// Prefs de layout/UI (toque, fundo, opacidade, formato hora)
// TODO: migrar pra configs do tenant hoje só localStorage pra survive refresh
const LAYOUT_STORAGE_KEY = 'melissa.layout.v1';
@@ -619,14 +743,23 @@ function onConcluir() { updateEventoStatus('realizado', 'Sessão marcada como r
function onFaltou() { updateEventoStatus('faltou', 'Marcada como falta'); }
function onCancelar() { updateEventoStatus('cancelado', 'Evento cancelado'); }
function onWhatsapp() {
async function onWhatsapp() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
toast.add({ severity: 'warn', summary: 'Paciente sem id', life: 2200 });
return;
}
conversationDrawerStore.openForPatient(String(ev.patient_id));
const patientId = String(ev.patient_id);
fecharEvento();
// openForPatient é async busca thread existente ou cria stub.
// Se paciente não tem telefone (ou outro erro), o store seta `error`
// e mantém `isOpen=false` silenciosamente. Aguardamos pra dar feedback.
await conversationDrawerStore.openForPatient(patientId);
if (!conversationDrawerStore.isOpen) {
const detail = conversationDrawerStore.error?.message || 'Não foi possível abrir a conversa.';
toast.add({ severity: 'warn', summary: 'WhatsApp', detail, life: 3500 });
conversationDrawerStore.error = null;
}
}
// Pending agenda actions
@@ -666,8 +799,31 @@ function onAbrirProntuario() {
}
function onHistoricoSessoes() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
toast.add({ severity: 'warn', summary: 'Paciente não vinculado', life: 2200 });
return;
}
const patientId = ev.patient_id;
fecharEvento();
_callOnAgenda((agenda) => agenda.setView?.('lista'));
// Abre a Agenda (se não estiver) e dispara o overlay "Todas as sessões"
// filtrado pelo paciente mesmo fluxo do botão Sessões em .ma-dock-actions.
_callOnAgenda((agenda) => agenda.openSessoesPaciente?.(patientId));
}
// Editar cadastro do paciente vinculado à sessão. Difere de onEditEvento
// (que abre o AgendaEventDialog pra mexer na sessão em si). Reusa o
// PatientCadastroDialog já montado dentro do MelissaAgenda via método
// exposto openEditPatient.
function onEditPaciente() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
toast.add({ severity: 'warn', summary: 'Paciente não vinculado', life: 2200 });
return;
}
const patientId = ev.patient_id;
fecharEvento();
_callOnAgenda((agenda) => agenda.openEditPatient?.(patientId));
}
async function onEditEvento() {
@@ -1178,10 +1334,20 @@ function onKeydown(e) {
}
if (e.key !== 'Escape') return;
// Bail-out: se há um overlay PrimeVue (Dialog/Drawer) aberto, deixa
// o componente cuidar do ESC pelo seu próprio closeOnEscape. Sem
// este guard, o ESC fechava o overlay E uma camada do cascade
// o usuário via "duas janelas fechando" (drawer WhatsApp + agenda,
// AgendaEventDialog + evento panel, ConfirmDialog + workspace, etc).
if (document.querySelector('.p-dialog-mask, .p-drawer-mask')) return;
// Cascata top-down do z-order ESC fecha SOMENTE a camada do topo.
// Ordem (mais sobreposto menos): central modal > evento panel >
// cronômetro > seção (agenda/pacientes/etc) > workspace > settings.
if (centralOpen.value) centralOpen.value = false;
else if (secaoAberta.value) fecharSecao();
else if (eventoSelecionado.value) fecharEvento();
else if (cronoVisible.value) fecharCronometro();
else if (secaoAberta.value) fecharSecao();
else if (workspaceOpen.value) closeWorkspace();
else if (settingsOpen.value) settingsOpen.value = false;
}
@@ -1774,7 +1940,7 @@ function onKeydown(e) {
type="button"
class="dock-pin"
v-tooltip.top="'WhatsApp'"
:class="{ 'dock-pin--active': secaoAtual === 'conversas' }"
:class="{ 'dock-pin--active': secaoAberta === 'conversas' }"
@click="abrirSecao('conversas')"
>
<i class="pi pi-whatsapp" />
@@ -1784,6 +1950,46 @@ function onKeydown(e) {
:title="`${whatsappPendente.count} mensagens não lidas`"
>{{ whatsappPendente.count > 99 ? '99+' : whatsappPendente.count }}</span>
</button>
<!-- Divisor entre builtins e pins dinâmicos. aparece se
o user tem pelo menos 1 pin (fixo ou recente). -->
<div
v-if="dockPins.pinned.value.length || dockPins.recent.value.length"
class="dock-divider"
aria-hidden="true"
/>
<!-- Pins fixados pelo user (max 4). Click direito menu
desafixar/remover. Hover mostra subtle ring. -->
<button
v-for="slug in dockPins.pinned.value" :key="`p-${slug}`"
type="button"
class="dock-pin dock-pin--user"
:class="{ 'dock-pin--active': secaoAberta === slug }"
v-tooltip.top="pinMeta(slug).label + ' (fixado)'"
@click="abrirSecao(slug)"
@contextmenu="openPinContextMenu($event, slug)"
>
<i :class="pinMeta(slug).icon" />
<span class="dock-pin__pinned-mark" aria-hidden="true" />
</button>
<!-- Pins MRU (max 3) empurrados pelas últimas seções abertas.
Visual mais leve (opacity menor) pra destacar dos fixos. -->
<button
v-for="slug in dockPins.recent.value" :key="`r-${slug}`"
type="button"
class="dock-pin dock-pin--recent"
:class="{ 'dock-pin--active': secaoAberta === slug }"
v-tooltip.top="pinMeta(slug).label + ' (recente — clique direito pra fixar)'"
@click="abrirSecao(slug)"
@contextmenu="openPinContextMenu($event, slug)"
>
<i :class="pinMeta(slug).icon" />
</button>
<!-- Menu de contexto dos pins dinâmicos (popup global) -->
<Menu ref="pinContextMenu" :model="pinContextMenuItems" :popup="true" />
</div>
@@ -1812,7 +2018,8 @@ function onKeydown(e) {
@faltou="onFaltou"
@cancelar="onCancelar"
@remarcar="onRemarcar"
@edit="onEditEvento"
@edit-sessao="onEditEvento"
@edit-paciente="onEditPaciente"
@abrir-prontuario="onAbrirProntuario"
@whatsapp="onWhatsapp"
@historico="onHistoricoSessoes"
@@ -1910,6 +2117,8 @@ function onKeydown(e) {
@close="fecharSecao"
@patient-created="refetchPacientes"
@goto-agenda="abrirSecao('agenda')"
@goto-grupos="abrirSecao('grupos')"
@goto-tags="abrirSecao('tags')"
/>
<MelissaCompromissos
@@ -1942,6 +2151,11 @@ function onKeydown(e) {
@close="fecharSecao"
/>
<MelissaAgendamentosRecebidos
v-if="layoutReady && secaoAberta === 'agendamentos-recebidos'"
@close="fecharSecao"
/>
<MelissaMedicos
v-if="layoutReady && secaoAberta === 'medicos'"
@close="fecharSecao"
@@ -3177,6 +3391,42 @@ html:not(.app-dark) .melissa-dock .dock-pin:hover {
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.16);
}
/* ─── Pins dinâmicos do dock (híbrido fixo + recente) ───────── */
/* Divisor entre builtins e dinâmicos. Fininho, atravessa o gap. */
.dock-divider {
width: 1px;
height: 24px;
align-self: center;
background: var(--m-border, rgba(255, 255, 255, 0.18));
opacity: 0.7;
}
html:not(.app-dark) .dock-divider {
background: rgba(15, 23, 42, 0.18);
}
/* Pin fixado pelo user — pequena marca no canto pra diferenciar do recente. */
.dock-pin--user .dock-pin__pinned-mark {
position: absolute;
top: 4px;
right: 4px;
width: 6px;
height: 6px;
border-radius: 50%;
background: color-mix(in srgb, var(--p-primary-color) 70%, white);
box-shadow: 0 0 6px color-mix(in srgb, var(--p-primary-color) 50%, transparent);
pointer-events: none;
}
/* Pin recente (MRU) visualmente mais leve pra denotar transitoriedade.
Fica entre opacity total (active/hover) e ~70% no estado normal. */
.dock-pin--recent {
opacity: 0.78;
}
.dock-pin--recent:hover,
.dock-pin--recent.dock-pin--active {
opacity: 1;
}
/* Skeleton loading utilitário (global pra atravessar scoped CSS dos
componentes filhos). Use a classe .melissa-skeleton em qualquer
container vira um placeholder com shimmer suave.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+424 -122
View File
@@ -14,7 +14,6 @@
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import Popover from 'primevue/popover';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// Dialog/SelectButton/Button/Tag/ProgressBar/Avatar/Select: auto-import via PrimeVueResolver
@@ -49,16 +48,46 @@ const sessionsMap = ref({}); // ruleId → AgendaEvento[]
const expandedId = ref(null);
const filterStatus = ref('ativo');
const statusOptions = [
{ label: 'Ativas', value: 'ativo' },
{ label: 'Encerradas', value: 'cancelado' },
{ label: 'Todas', value: 'all' }
// Button list de status (icons + cores). Ativa = verde / Encerrada = vermelho.
// Todas = neutral. O blueprint pede botões coloridos por status (§7).
const STATUS_FILTER_OPTIONS = [
{ key: 'ativo', label: 'Ativas', icon: 'pi pi-check-circle' },
{ key: 'cancelado', label: 'Encerradas', icon: 'pi pi-times-circle' },
{ key: 'all', label: 'Todas', icon: 'pi pi-list' }
];
const busca = ref('');
function setStatusFilter(s) {
filterStatus.value = s;
load();
}
const hasActiveFilters = computed(() =>
!!(busca.value || filterStatus.value !== 'ativo')
);
function clearAllFilters() {
busca.value = '';
if (filterStatus.value !== 'ativo') {
filterStatus.value = 'ativo';
load();
}
}
const carregandoInicial = computed(
() => loading.value && rules.value.length === 0
);
// Filtragem client-side por nome do paciente (status é server-side via load).
const filteredRules = computed(() => {
const q = String(busca.value || '').trim().toLowerCase();
if (!q) return rules.value;
return rules.value.filter((r) =>
String(r._patient?.nome_completo || '').toLowerCase().includes(q)
);
});
// Data load
async function init() {
const { data } = await supabase.auth.getUser();
@@ -325,10 +354,6 @@ function toggleExpand(ruleId) {
expandedId.value = expandedId.value === ruleId ? null : ruleId;
}
// Popover de Ações (mobile compact)
const actionsPopRef = ref(null);
function openActions(e) { actionsPopRef.value?.toggle(e); }
onMounted(async () => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
@@ -378,18 +403,11 @@ onBeforeUnmount(() => {
<span>Menu Recorrências</span>
</button>
<div class="mr-page__title">
<i class="pi pi-sync text-indigo-300" />
<i class="pi pi-sync mr-page__title-icon" />
<span>Recorrências</span>
<span class="mr-page__count">{{ rules.length }}</span>
<span class="mr-page__count">{{ filteredRules.length }}</span>
</div>
<div class="mr-page__actions">
<button
class="mr-head-btn mr-head-btn--compact-only"
v-tooltip.bottom="'Filtros'"
@click="openActions"
>
<i class="pi pi-sliders-h" />
</button>
<button
class="mr-head-btn"
v-tooltip.bottom="'Recarregar'"
@@ -404,77 +422,110 @@ onBeforeUnmount(() => {
</div>
</header>
<Popover ref="actionsPopRef" class="mr-actions-pop">
<div class="mr-actions">
<div class="mr-actions__group">
<div class="mr-actions__label">Status</div>
<SelectButton
v-model="filterStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
size="small"
class="w-full"
@change="load"
/>
</div>
</div>
</Popover>
<!-- Subheader explicativo (blueprint §9) -->
<div class="mr-subheader">
<i class="pi pi-info-circle mr-subheader__icon" />
<span class="mr-subheader__text">
Séries de sessões que se repetem (semanal, quinzenal, dias específicos).
Acompanhe o <strong>progresso</strong> de cada uma e marque como
<strong>encerrada</strong> quando o paciente terminar o tratamento.
</span>
</div>
<div class="mr-body">
<!-- COL 1: Stats + filtros -->
<Teleport to="#mr-mobile-drawer-target" :disabled="!isMobile">
<aside class="mr-side">
<div class="mr-w">
<div class="mr-w__head">
<span class="mr-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
</div>
<div class="mr-stats">
<template v-if="carregandoInicial">
<div v-for="i in 4" :key="`stsk-${i}`" class="mr-stat mr-stat--skeleton" aria-busy="true">
<div class="mr-stat__val melissa-skeleton melissa-skeleton--number" />
<div class="mr-stat__lbl melissa-skeleton melissa-skeleton--text" style="width: 60%; margin-top: 6px;" />
<div class="mr-side__scroll">
<!-- Stats -->
<div class="mr-w mr-w--side">
<div class="mr-w__head">
<span class="mr-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
</div>
<div class="mr-stats">
<template v-if="carregandoInicial">
<div v-for="i in 4" :key="`stsk-${i}`" class="mr-stat mr-stat--skeleton" aria-busy="true">
<div class="mr-stat__val melissa-skeleton melissa-skeleton--number" />
<div class="mr-stat__lbl melissa-skeleton melissa-skeleton--text" style="width: 60%; margin-top: 6px;" />
</div>
</template>
<div
v-for="s in aggregateStats"
v-else
:key="s.key"
class="mr-stat"
:class="`is-${s.cls}`"
>
<div class="mr-stat__val">{{ s.value }}</div>
<div class="mr-stat__lbl">{{ s.label }}</div>
</div>
</template>
<div
v-for="s in aggregateStats"
v-else
:key="s.key"
class="mr-stat"
:class="`is-${s.cls}`"
>
<div class="mr-stat__val">{{ s.value }}</div>
<div class="mr-stat__lbl">{{ s.label }}</div>
</div>
</div>
<!-- Filtro de status (button list colorido) -->
<div class="mr-w mr-w--side">
<div class="mr-w__head">
<span class="mr-w__title"><i class="pi pi-filter" /> Status</span>
<button
v-if="filterStatus !== 'ativo'"
class="mr-side__clear-inline"
v-tooltip.top="'Voltar pro filtro padrão (Ativas)'"
aria-label="Voltar pro filtro padrão"
@click="setStatusFilter('ativo')"
>
<i class="pi pi-times" />
</button>
</div>
<div class="mr-side__list">
<button
v-for="o in STATUS_FILTER_OPTIONS"
:key="o.key"
class="mr-side__item"
:class="[`is-status-${o.key}`, { 'is-active': filterStatus === o.key }]"
@click="setStatusFilter(o.key)"
>
<i :class="o.icon" />
<span>{{ o.label }}</span>
</button>
</div>
</div>
</div>
<div class="mr-w mr-w--side-only">
<div class="mr-w__head">
<span class="mr-w__title"><i class="pi pi-filter" /> Filtros</span>
<!-- Footer fixo: Limpar filtros (Transition fade+collapse) -->
<Transition name="mr-clear">
<div v-if="hasActiveFilters" class="mr-side__footer">
<button class="mr-side__clear-all" @click="clearAllFilters">
<i class="pi pi-filter-slash" />
<span>Limpar filtros</span>
</button>
</div>
<div class="mr-side__filters">
<div>
<div class="mr-side__label">Status</div>
<SelectButton
v-model="filterStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
size="small"
class="w-full"
@change="load"
/>
</div>
</div>
</div>
</Transition>
</aside>
</Teleport>
<!-- COL 2: Lista de regras -->
<div class="mr-main">
<!-- Toolbar: busca por nome do paciente -->
<div class="mr-toolbar">
<div class="mr-search">
<i class="pi pi-search mr-search__icon" />
<input
v-model="busca"
type="text"
placeholder="Buscar por nome do paciente…"
class="mr-search__input"
/>
<button
v-if="busca"
class="mr-search__clear"
v-tooltip.bottom="'Limpar busca'"
@click="busca = ''"
>
<i class="pi pi-times" />
</button>
</div>
</div>
<div class="mr-list">
<!-- Skeletons (blueprint §9) -->
<template v-if="carregandoInicial">
@@ -490,11 +541,14 @@ onBeforeUnmount(() => {
</div>
</template>
<div v-else-if="!rules.length" class="mr-empty">
<div v-else-if="!filteredRules.length" class="mr-empty">
<i class="pi pi-calendar-times mr-empty__icon" />
<div class="mr-empty__title">Nenhuma série encontrada</div>
<div class="mr-empty__hint">
<template v-if="filterStatus === 'ativo'">
<template v-if="busca">
Nenhum paciente corresponde à busca. Ajuste ou limpe os filtros.
</template>
<template v-else-if="filterStatus === 'ativo'">
Crie sessões recorrentes na agenda pra -las aqui.
</template>
<template v-else>
@@ -503,7 +557,7 @@ onBeforeUnmount(() => {
</div>
</div>
<div v-else v-for="rule in rules" :key="rule.id" class="mr-card">
<div v-else v-for="rule in filteredRules" :key="rule.id" class="mr-card">
<!-- Head: paciente + descrição + período -->
<div class="mr-card__head">
<span class="mr-card__avatar">
@@ -637,6 +691,10 @@ onBeforeUnmount(() => {
font-size: 1rem;
font-weight: 500;
}
.mr-page__title-icon {
color: var(--p-primary-color);
font-size: 1.05rem;
}
.mr-page__title > span:not(.mr-page__count) {
overflow: hidden;
text-overflow: ellipsis;
@@ -699,40 +757,159 @@ onBeforeUnmount(() => {
}
.mr-menu-btn > i { font-size: 0.85rem; }
/* Body */
/* Subheader (blueprint §9) */
.mr-subheader {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 18px;
border-bottom: 1px solid var(--m-border);
background: var(--m-bg-soft);
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.45;
flex-shrink: 0;
}
.mr-subheader__icon {
color: var(--p-primary-color);
font-size: 0.92rem;
flex-shrink: 0;
margin-top: 1px;
}
.mr-subheader__text { flex: 1; min-width: 0; }
.mr-subheader__text strong { color: var(--m-text); font-weight: 600; }
/* Body sidebar fica colada à esquerda com border-right; main com
padding interno. Espelha Grupos/Tags/Médicos/Conversas. */
.mr-body {
flex: 1;
display: flex;
min-height: 0;
position: relative;
gap: 12px;
padding: 12px;
gap: 0;
padding: 0;
}
/* Aside */
/* Aside — 2 zonas: __scroll (cards) + __footer (Limpar filtros fixo) */
.mr-side {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
overflow: hidden;
}
.mr-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mr-side::-webkit-scrollbar { width: 5px; }
.mr-side::-webkit-scrollbar-thumb {
.mr-side__scroll::-webkit-scrollbar { width: 5px; }
.mr-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mr-w {
/* Footer fixo: Limpar filtros (espelha Grupos/Tags/Médicos/Conversas) */
.mr-side__footer {
flex-shrink: 0;
padding: 12px;
background: var(--m-bg-soft);
border-top: 1px solid var(--m-border);
}
.mr-side__clear-all {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
color: var(--m-text);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.mr-side__clear-all:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
color: var(--m-text);
}
.mr-side__clear-all > i {
font-size: 0.78rem;
color: var(--m-text-muted);
transition: color 140ms ease;
}
.mr-side__clear-all:hover > i { color: var(--m-text); }
/* X inline ao lado do título do filter card */
.mr-side__clear-inline {
width: 18px;
height: 18px;
display: grid;
place-items: center;
background: transparent;
border: 1px solid color-mix(in srgb, rgb(220, 38, 38) 30%, var(--m-border));
color: rgb(220, 38, 38);
border-radius: 4px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mr-side__clear-inline:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.55);
}
.mr-side__clear-inline > i { font-size: 0.6rem; }
/* Transition do footer */
.mr-clear-enter-active,
.mr-clear-leave-active {
transition: opacity 220ms ease, transform 220ms ease, max-height 240ms ease;
overflow: hidden;
}
.mr-clear-enter-from,
.mr-clear-leave-to {
opacity: 0;
transform: translateY(6px);
max-height: 0;
}
.mr-clear-enter-to,
.mr-clear-leave-from {
opacity: 1;
transform: translateY(0);
max-height: 80px;
}
.mr-w {
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 12px;
}
.mr-w__head { margin-bottom: 10px; }
/* Modifier pros cards dentro da sidebar — margem lateral + sombra */
.mr-w--side {
margin: 12px 12px 0;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.mr-w--side:last-of-type { margin-bottom: 12px; }
.mr-w__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.mr-w__title {
display: inline-flex;
align-items: center;
@@ -748,7 +925,7 @@ onBeforeUnmount(() => {
gap: 6px;
}
.mr-stat {
background: var(--m-bg-medium);
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 8px 10px;
@@ -765,15 +942,80 @@ onBeforeUnmount(() => {
text-transform: uppercase;
letter-spacing: 0.06em;
}
.mr-stat.is-ok .mr-stat__val { color: rgb(74, 222, 128); }
.mr-stat.is-ok .mr-stat__val { color: rgb(22, 163, 74); }
.mr-side__label {
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--m-text-muted);
font-size: 0.62rem;
font-weight: 600;
margin-bottom: 6px;
/* Filter button list (blueprint §8) */
.mr-side__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.mr-side__item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: transparent;
border: 1px solid transparent;
color: var(--m-text);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 0.82rem;
text-align: left;
transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
}
.mr-side__item > i {
font-size: 0.78rem;
width: 14px;
text-align: center;
}
/* Status: Ativas verde / Encerradas vermelho / Todas neutral */
.mr-side__item.is-status-ativo {
background: rgba(22, 163, 74, 0.05);
border-color: rgba(22, 163, 74, 0.18);
}
.mr-side__item.is-status-ativo > i { color: rgb(22, 163, 74); }
.mr-side__item.is-status-ativo:hover {
background: rgba(22, 163, 74, 0.10);
border-color: rgba(22, 163, 74, 0.30);
}
.mr-side__item.is-active.is-status-ativo {
background: rgba(22, 163, 74, 0.16);
border-color: rgba(22, 163, 74, 0.55);
box-shadow: 0 0 0 1px rgba(22, 163, 74, 0.35);
}
.mr-side__item.is-status-cancelado {
background: rgba(220, 38, 38, 0.05);
border-color: rgba(220, 38, 38, 0.18);
}
.mr-side__item.is-status-cancelado > i { color: rgb(220, 38, 38); }
.mr-side__item.is-status-cancelado:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.30);
}
.mr-side__item.is-active.is-status-cancelado {
background: rgba(220, 38, 38, 0.16);
border-color: rgba(220, 38, 38, 0.55);
box-shadow: 0 0 0 1px rgba(220, 38, 38, 0.35);
}
.mr-side__item.is-status-all {
background: var(--m-bg-soft);
border-color: var(--m-border);
}
.mr-side__item.is-status-all > i { color: var(--m-text-muted); }
.mr-side__item.is-status-all:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mr-side__item.is-active.is-status-all {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
box-shadow: 0 0 0 1px var(--m-border-strong);
}
/* Main */
@@ -782,9 +1024,70 @@ onBeforeUnmount(() => {
min-width: 0;
display: flex;
flex-direction: column;
padding: 12px;
gap: 10px;
}
/* Toolbar com busca */
.mr-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
}
.mr-search {
position: relative;
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.mr-search__icon {
position: absolute;
left: 12px;
color: var(--m-text-muted);
font-size: 0.78rem;
pointer-events: none;
}
.mr-search__input {
width: 100%;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
color: var(--m-text);
padding: 9px 36px 9px 34px;
border-radius: 10px;
font-size: 0.85rem;
font-family: inherit;
outline: none;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mr-search__input::placeholder { color: var(--m-text-faint); }
.mr-search__input:focus {
border-color: var(--m-border-strong);
}
.mr-search__clear {
position: absolute;
right: 8px;
width: 22px;
height: 22px;
display: grid;
place-items: center;
background: transparent;
border: none;
color: var(--m-text-muted);
border-radius: 6px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, color 140ms ease;
}
.mr-search__clear:hover {
background: var(--m-bg-soft-hover);
color: var(--m-text);
}
.mr-search__clear > i { font-size: 0.7rem; }
.mr-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 4px 4px;
display: flex;
@@ -1049,27 +1352,6 @@ onBeforeUnmount(() => {
color: var(--m-text-muted);
}
/* Popover de Ações */
.mr-actions {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 240px;
padding: 4px;
}
.mr-actions__group {
display: flex;
flex-direction: column;
gap: 6px;
}
.mr-actions__label {
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--text-color-secondary, var(--m-text-faint));
font-size: 0.62rem;
font-weight: 600;
}
/* Drawer mobile (blueprint §6) */
.mr-mobile-drawer {
position: fixed;
@@ -1110,6 +1392,31 @@ onBeforeUnmount(() => {
height: auto;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
}
.mr-mobile-drawer__scroll .mr-side__scroll {
flex: none;
min-height: 0;
overflow: visible;
display: flex;
flex-direction: column;
gap: 12px;
}
.mr-mobile-drawer__scroll .mr-w--side {
margin: 0;
}
.mr-mobile-drawer__scroll .mr-w--side:last-of-type { margin-bottom: 0; }
.mr-mobile-drawer__scroll .mr-side__footer {
position: sticky;
bottom: 0;
margin: 8px -12px -24px;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
z-index: 5;
}
.mr-mobile-drawer__backdrop {
position: fixed;
@@ -1124,15 +1431,10 @@ onBeforeUnmount(() => {
.mr-drawer-fade-enter-from,
.mr-drawer-fade-leave-to { opacity: 0; }
/* Compact (<xl) */
@media (max-width: 1279px) {
.mr-head-btn--compact-only { display: grid; }
}
/* Mobile (<lg) */
@media (max-width: 1023px) {
.mr-body { flex-direction: column; padding: 8px; }
.mr-main { width: 100%; }
.mr-body { flex-direction: column; padding: 0; }
.mr-main { width: 100%; padding: 8px; }
.mr-page__title > span:first-of-type { display: none; }
.mr-menu-btn--mobile-only { display: inline-flex; }
.mr-card__foot { gap: 4px; }
File diff suppressed because it is too large Load Diff
@@ -181,8 +181,15 @@ export function useMelissaAgenda() {
);
// ── Settings + workRules ────────────────────────────────────
const { settings, workRules, load: loadSettings } = useAgendaSettings();
const ownerId = computed(() => settings.value?.owner_id || '');
// cache: stale-while-revalidate via melissaCacheStore — abertura
// subsequente da Agenda na mesma sessão usa cache instantâneo.
const { settings, workRules, load: loadSettings } = useAgendaSettings({ cache: true });
// _bootUid: pegado em paralelo no mount via supabase.auth.getUser().
// Sem isso, ownerId ficava null até loadSettings completar (~300ms),
// bloqueando o primeiro fetch dos eventos. Como owner_id da agenda
// é literalmente o uid do user logado, podemos resolver imediato.
const _bootUid = ref('');
const ownerId = computed(() => settings.value?.owner_id || _bootUid.value || '');
// ── Eventos reais (CRUD) ────────────────────────────────────
const {
@@ -245,7 +252,16 @@ export function useMelissaAgenda() {
});
// ── Feriados + commitment services ──────────────────────────
const { todos: feriados, fcEvents: feriadoFcEvents, load: loadFeriadosBase } = useFeriados();
// Instância única de useFeriados — antes MelissaAgenda.vue criava
// sua própria também, fazendo dupla requisição de feriados municipais
// toda vez que a agenda abria. Agora MelissaAgenda lê esses refs do
// composable injetado (M.feriadosAno, M.loadFeriadosBase, etc).
const {
todos: feriados,
fcEvents: feriadoFcEvents,
load: loadFeriadosBase,
ano: feriadosAno
} = useFeriados({ cache: true });
const { saveRuleItems, propagateToSerie } = useCommitmentServices();
// ── Linhas combinadas (real + virtual) ──────────────────────
@@ -294,13 +310,18 @@ export function useMelissaAgenda() {
const e = viewEnd.value;
if (!s || !e) return;
// Aguarda ownerId — settings é async
if (!ownerId.value) {
const unwatch = watch(ownerId, async (v) => {
if (!v) return;
unwatch();
await _reloadRange();
});
// Espera ownerId E tenant — qualquer um faltando significa boot
// ainda em curso (auth/tenantStore/settings async). Watcher one-shot
// re-dispara assim que o último ficar disponível, sem polling.
if (!ownerId.value || !clinicTenantId.value) {
const unwatch = watch(
() => [ownerId.value, clinicTenantId.value],
([uid, tid]) => {
if (!uid || !tid) return;
unwatch();
_reloadRange();
}
);
return;
}
@@ -308,9 +329,14 @@ export function useMelissaAgenda() {
const end = new Date(e);
const tid = clinicTenantId.value;
// Etapa 1: eventos reais — `rows` é reativo, FullCalendar re-renderiza
// assim que esse await resolve (o user já vê as sessões agendadas).
await loadMyRange(start.toISOString(), end.toISOString(), ownerId.value);
// Expande regras + merge com sessões reais
// Etapa 2: ocorrências virtuais (regras de recorrência expandidas).
// Continuamos awaitando porque saveRule/cancel dependem do estado
// final estar pronto pra UI consistente, mas a janela visual onde
// o usuário vê só eventos reais é a metade do tempo de antes.
const merged = await loadAndExpand(ownerId.value, start, end, rows.value, tid);
_occurrenceRows.value = merged.filter((r) => r.is_occurrence);
}
@@ -320,8 +346,37 @@ export function useMelissaAgenda() {
}
// ── Inicialização ───────────────────────────────────────────
onMounted(async () => {
await loadSettings();
// Boot paralelo: auth uid + tenant + settings todos disparam ao mesmo
// tempo. Antes era serial (loadSettings precisava terminar pra ownerId
// ficar disponível e o watch disparar _reloadRange) — adicionava ~300ms
// de waterfall antes da primeira query de eventos sair.
onMounted(() => {
// 1) Resolve o uid o quanto antes — destrava _reloadRange.
// getSession() lê do storage local (fast path, <10ms);
// getUser() faria round-trip pro auth server. Fallback pro
// getUser só se a sessão ainda não estiver no storage.
supabase.auth.getSession()
.then(({ data }) => {
const uid = data?.session?.user?.id;
if (uid) {
_bootUid.value = uid;
} else {
// Cold start sem sessão hidratada — fallback pro round-trip.
return supabase.auth.getUser().then(({ data: u }) => {
if (u?.user?.id) _bootUid.value = u.user.id;
});
}
})
.catch(() => { /* noop — settings ainda pode resolver */ });
// 2) Garante que o tenant está hidratado (idempotente — se já
// estiver carregado, retorna imediato).
if (typeof tenantStore.ensureLoaded === 'function') {
tenantStore.ensureLoaded().catch(() => {});
}
// 3) Settings em paralelo (não bloqueia mais nada)
loadSettings();
});
// Refetch settings + workRules quando o user salva jornada/ritmo/online
@@ -354,11 +409,10 @@ export function useMelissaAgenda() {
{ immediate: true }
);
// Reload quando view muda OU quando settings/ownerId aparece
// Reload quando o range visível muda. _reloadRange já tem guard
// interno pra esperar uid+tenant (one-shot watcher) — sem necessidade
// de outro watch global em ownerId, que disparava _reloadRange duplicado.
watch([viewStart, viewEnd], _reloadRange);
watch(ownerId, (v) => {
if (v) _reloadRange();
});
// ──────────────────────────────────────────────────────────
// Handlers — populados na Stage 2
@@ -405,6 +459,8 @@ export function useMelissaAgenda() {
commitmentOptions,
feriados,
feriadoFcEvents,
feriadosAno,
loadFeriadosBase,
allEventsForDialog,
// Handlers
@@ -0,0 +1,193 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/layout/melissa/composables/useMelissaAgendaHistorico.js
| Data: 2026-05-04
|
| Histórico recente de ações na agenda do terapeuta logado.
|
| de `audit_logs` (populado automaticamente pela trigger
| `trg_audit_agenda_eventos`). Não precisa criar nada todas as ações
| INSERT/UPDATE/DELETE em agenda_eventos viram linhas auditadas.
|
| Filtros aplicados:
| - entity_type = 'agenda_eventos'
| - user_id = uid do user logado (mostra ações dele)
| - created_at >= 7 dias atrás
| - tenant_id = tenant ativo
| - LIMIT 20 (mais recentes primeiro)
|
| Pra exibir nome do paciente, fazemos um lookup separado em `patients`
| usando os IDs extraídos de new_values/old_values (não pra fazer JOIN
| na audit_logs porque entity_id é dinâmico).
|
| Returns:
| - entries: ref de objetos normalizados:
| { id, kind, label, when, paciente, evento_id, raw }
| onde kind { 'create' | 'move' | 'status' | 'edit' | 'delete' }
| - loading: ref<boolean>
| - refetch: function()
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const STATUS_LABEL = {
agendado: 'Agendado',
realizado: 'Realizada',
realizada: 'Realizada',
faltou: 'Falta',
cancelado: 'Cancelada',
cancelada: 'Cancelada',
remarcar: 'Remarcar',
remarcado: 'Remarcado',
confirmado: 'Confirmada'
};
function fmtTime(iso) {
if (!iso) return '';
const d = new Date(iso);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function fmtDateBR(iso) {
if (!iso) return '';
const d = new Date(iso);
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`;
}
// Classifica a entrada pra um dos 5 "kinds" visuais. Decisão por
// changed_fields quando action=update — ordem importa: hora primeiro
// (mais frequente em movimentação), depois status, depois "edit" genérico.
function classify(row) {
const action = String(row.action || '').toLowerCase();
if (action === 'insert') return 'create';
if (action === 'delete') return 'delete';
if (action === 'update') {
const fields = new Set(row.changed_fields || []);
if (fields.has('inicio_em') || fields.has('fim_em')) return 'move';
if (fields.has('status')) return 'status';
return 'edit';
}
return 'edit';
}
function buildLabel(kind, row) {
const oldV = row.old_values || {};
const newV = row.new_values || {};
switch (kind) {
case 'create': {
const ini = newV.inicio_em ? `${fmtDateBR(newV.inicio_em)} ${fmtTime(newV.inicio_em)}` : '—';
return `Criou sessão em ${ini}`;
}
case 'delete': {
const ini = oldV.inicio_em ? `${fmtDateBR(oldV.inicio_em)} ${fmtTime(oldV.inicio_em)}` : '—';
return `Removeu sessão de ${ini}`;
}
case 'move': {
const from = oldV.inicio_em ? `${fmtDateBR(oldV.inicio_em)} ${fmtTime(oldV.inicio_em)}` : '—';
const to = newV.inicio_em ? `${fmtDateBR(newV.inicio_em)} ${fmtTime(newV.inicio_em)}` : '—';
return `Moveu ${from}${to}`;
}
case 'status': {
const lbl = STATUS_LABEL[String(newV.status || '').toLowerCase()] || newV.status || '—';
return `Status: ${lbl}`;
}
case 'edit':
default: {
const fields = (row.changed_fields || []).filter((f) => f !== 'updated_at');
if (!fields.length) return 'Editou';
return `Editou ${fields.join(', ')}`;
}
}
}
// Resolve o ID do paciente a partir de new_values/old_values (delete usa OLD).
function extractPatientId(row) {
return row.new_values?.patient_id || row.old_values?.patient_id || null;
}
export function useMelissaAgendaHistorico(opts = {}) {
const limit = opts.limit ?? 20;
const days = opts.days ?? 7;
const tenantStore = useTenantStore();
const entries = ref([]);
const loading = ref(false);
const error = ref('');
async function _ensureUid() {
const { data: ses } = await supabase.auth.getSession();
if (ses?.session?.user?.id) return ses.session.user.id;
const { data, error: err } = await supabase.auth.getUser();
if (err) return null;
return data?.user?.id || null;
}
async function refetch() {
loading.value = true;
error.value = '';
try {
const userId = await _ensureUid();
if (typeof tenantStore.ensureLoaded === 'function') {
await tenantStore.ensureLoaded();
}
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
if (!userId || !tid) {
entries.value = [];
return;
}
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const { data: rows, error: err } = await supabase
.from('audit_logs')
.select('id, action, entity_id, changed_fields, old_values, new_values, created_at, user_id, tenant_id')
.eq('entity_type', 'agenda_eventos')
.eq('user_id', userId)
.eq('tenant_id', tid)
.gte('created_at', since)
.order('created_at', { ascending: false })
.limit(limit);
if (err) throw err;
const list = rows || [];
// Resolve nomes dos pacientes em uma única query.
const patientIds = [...new Set(list.map(extractPatientId).filter(Boolean))];
const patientMap = new Map();
if (patientIds.length) {
const { data: pats } = await supabase
.from('patients')
.select('id, nome_completo')
.in('id', patientIds);
for (const p of pats || []) patientMap.set(p.id, p.nome_completo);
}
entries.value = list.map((r) => {
const kind = classify(r);
const pid = extractPatientId(r);
return {
id: r.id,
kind,
label: buildLabel(kind, r),
when: r.created_at,
paciente: pid ? (patientMap.get(pid) || '') : '',
evento_id: r.entity_id,
raw: r
};
});
} catch (e) {
error.value = e?.message || 'Falha ao carregar histórico';
entries.value = [];
// eslint-disable-next-line no-console
console.warn('[useMelissaAgendaHistorico]', e);
} finally {
loading.value = false;
}
}
return { entries, loading, error, refetch };
}
@@ -0,0 +1,134 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/layout/melissa/composables/useMelissaDockPins.js
| Data: 2026-05-04
|
| Pins dinâmicos do dock Melissa modelo híbrido:
|
| - PINNED (manual, max 4): user fixa via menu de contexto, persiste
| entre sessões em localStorage. Sempre visíveis, ordenados por ordem
| de fixação.
| - RECENT (MRU automático, max 3): toda vez que o user abre uma seção
| que NÃO é built-in (agenda/conversas) e NÃO pinned, vira o pin
| temporário mais recente, empurrando os mais antigos pra fora.
|
| Persistência: localStorage com chave `melissa.dock.pins.v1`. Salva
| slugs de seção (string), nada de dado clínico LGPD-safe. Singleton via
| módulo (estado fora da função) pra todas as instâncias compartilharem.
|
| Builtin (não-pinnável, não-recente): agenda, conversas esses têm
| pin permanente próprio no template (.dock-pin com hardcode).
|--------------------------------------------------------------------------
*/
import { ref, watch } from 'vue';
const STORAGE_KEY = 'melissa.dock.pins.v1';
const MAX_PINNED = 4;
const MAX_RECENT = 3;
const BUILTIN_SLUGS = new Set(['agenda', 'conversas']);
// Estado singleton compartilhado entre todas as instâncias.
const pinned = ref([]);
const recent = ref([]);
let _hydrated = false;
function _hydrate() {
if (_hydrated) return;
_hydrated = true;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed?.pinned)) {
pinned.value = parsed.pinned.filter((s) => typeof s === 'string').slice(0, MAX_PINNED);
}
if (Array.isArray(parsed?.recent)) {
recent.value = parsed.recent.filter((s) => typeof s === 'string').slice(0, MAX_RECENT);
}
} catch { /* localStorage corrompido — ignora silenciosamente */ }
}
function _persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
pinned: pinned.value,
recent: recent.value
}));
} catch { /* quota excedida ou storage desabilitado — ok, em memória */ }
}
let _persistWatcherActive = false;
function _ensurePersistWatcher() {
if (_persistWatcherActive) return;
_persistWatcherActive = true;
watch([pinned, recent], _persist, { deep: true });
}
export function useMelissaDockPins() {
_hydrate();
_ensurePersistWatcher();
function isBuiltin(slug) {
return BUILTIN_SLUGS.has(slug);
}
function isPinned(slug) {
return pinned.value.includes(slug);
}
function isRecent(slug) {
return recent.value.includes(slug);
}
// Chamado quando o user abre uma seção. Builtins e já-pinned não viram
// recent (não duplica). Mais recente entra no topo, expulsa o mais
// antigo se passar do limite.
function pushRecent(slug) {
if (!slug || isBuiltin(slug) || isPinned(slug)) return;
recent.value = [slug, ...recent.value.filter((s) => s !== slug)].slice(0, MAX_RECENT);
}
// Move um slug de "recent" pra "pinned" (ou cria pinned direto).
// Retorna { ok, reason } — reason='full' quando já tem 4 pinned.
function pin(slug) {
if (!slug || isBuiltin(slug)) return { ok: false, reason: 'builtin' };
if (isPinned(slug)) return { ok: true, reason: 'already' };
if (pinned.value.length >= MAX_PINNED) return { ok: false, reason: 'full' };
recent.value = recent.value.filter((s) => s !== slug);
pinned.value = [...pinned.value, slug];
return { ok: true };
}
// Tira de "pinned" — não volta automaticamente pra recent (o user
// explicitamente desafixou). Próxima abertura da seção vai pra recent
// pelo fluxo normal de pushRecent.
function unpin(slug) {
pinned.value = pinned.value.filter((s) => s !== slug);
}
// Remove completamente (de ambas as listas). Usado pelo "Remover" do menu.
function remove(slug) {
pinned.value = pinned.value.filter((s) => s !== slug);
recent.value = recent.value.filter((s) => s !== slug);
}
function clearAll() {
pinned.value = [];
recent.value = [];
}
return {
pinned,
recent,
isBuiltin,
isPinned,
isRecent,
pushRecent,
pin,
unpin,
remove,
clearAll,
MAX_PINNED,
MAX_RECENT
};
}
@@ -26,6 +26,7 @@
import { ref, watch, onMounted, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
// ── Cores por tipo/status (consistente com o resto do Melissa) ──
function pickColor(tipo, status) {
@@ -319,17 +320,49 @@ export function useMelissaTodasSessoesPaciente() {
}
// ── COMPOSABLE 2: apenas hoje (MelissaLayout) ──────────────────
export function useMelissaEventosHoje() {
// opts: { autoFetch=true } — passar false pra adiar o fetch inicial
// (MelissaLayout faz isso quando a URL inicial já tem uma seção, pra
// não competir com o fetch da seção que vai cobrir o resumo).
export function useMelissaEventosHoje(opts = {}) {
const autoFetch = opts.autoFetch !== false;
const cache = useMelissaCacheStore();
const eventos = ref([]);
const loading = ref(false);
const error = ref(null);
async function fetch() {
async function _doFetch(cacheKey) {
const { start, end } = rangeHoje();
const data = await _fetchRange(start, end);
cache.set('eventosHoje', data, cacheKey);
eventos.value = data;
return data;
}
// useCache=true (boot/auto): stale-while-revalidate.
// useCache=false (refetch pós-mutation: status sessão, etc): força.
async function _fetch({ useCache = true } = {}) {
const today = new Date();
// Cache key amarra ao dia — depois de 00:00 vira automaticamente outro slot.
const cacheKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
if (useCache) {
const cached = cache.get('eventosHoje', cacheKey, MELISSA_CACHE_TTL.eventosHoje);
if (cached) {
eventos.value = cached;
_doFetch(cacheKey).catch((e) => {
// eslint-disable-next-line no-console
console.warn('[useMelissaEventosHoje] revalidate', e);
});
return;
}
} else {
cache.invalidate('eventosHoje');
}
loading.value = true;
error.value = null;
try {
const { start, end } = rangeHoje();
eventos.value = await _fetchRange(start, end);
await _doFetch(cacheKey);
} catch (e) {
error.value = e?.message || 'Erro ao carregar agenda';
eventos.value = [];
@@ -340,7 +373,15 @@ export function useMelissaEventosHoje() {
}
}
onMounted(fetch);
if (autoFetch) onMounted(() => _fetch({ useCache: true }));
return { eventos, loading, error, refetch: fetch };
return {
eventos,
loading,
error,
// refetch força query nova (após status update etc).
refetch: () => _fetch({ useCache: false }),
// fetchCached é stale-while-revalidate (idle/defer).
fetchCached: () => _fetch({ useCache: true })
};
}
@@ -17,6 +17,7 @@
import { ref, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
// Mesma lógica da PatientsListPage.vue — DB pode ter valores variados
function normalizeStatus(s) {
@@ -37,7 +38,9 @@ function normalizeStatus(s) {
*/
export function useMelissaPacientes(opts = {}) {
const onlyActive = opts.onlyActive !== false; // default true (compat)
const autoFetch = opts.autoFetch !== false; // default true (compat)
const tenantStore = useTenantStore();
const cache = useMelissaCacheStore();
const pacientes = ref([]);
const loading = ref(false);
@@ -46,17 +49,51 @@ export function useMelissaPacientes(opts = {}) {
async function ensureUid() {
if (uid.value) return uid.value;
// Fast path: session do storage local (<10ms vs ~80ms do getUser)
const { data: ses } = await supabase.auth.getSession();
if (ses?.session?.user?.id) {
uid.value = ses.session.user.id;
return uid.value;
}
const { data, error: err } = await supabase.auth.getUser();
if (err) return null;
uid.value = data?.user?.id || null;
return uid.value;
}
async function fetchPacientes() {
const userId = await ensureUid();
async function _doFetch(userId, tid, cacheKey) {
const { data, error: err } = await supabase
.from('patients')
.select('id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at, data_nascimento')
.eq('owner_id', userId)
.eq('tenant_id', tid)
.order('nome_completo', { ascending: true })
.limit(1000);
// Garante que o tenantStore foi hidratado (preview misc não passa por
// guard de auth, então o store pode estar vazio mesmo com user logado)
if (err) throw err;
const todos = (data || []).map((r) => ({
id: r.id,
nome: r.nome_completo || '',
email: r.email_principal || '',
telefone: r.telefone || '',
avatar_url: r.avatar_url || null,
status: normalizeStatus(r.status),
last_attended_at: r.last_attended_at || null,
created_at: r.created_at || null,
data_nascimento: r.data_nascimento || null
}));
const finalList = onlyActive ? todos.filter((p) => p.status === 'Ativo') : todos;
cache.set('pacientesTimeline', finalList, cacheKey);
pacientes.value = finalList;
return finalList;
}
// useCache=true (boot/auto): hidrata do cache se válido + revalida em background.
// useCache=false (refetch após mutation): força query nova, descarta cache.
async function _fetch({ useCache = true } = {}) {
const userId = await ensureUid();
if (typeof tenantStore.ensureLoaded === 'function') {
await tenantStore.ensureLoaded();
}
@@ -67,35 +104,28 @@ export function useMelissaPacientes(opts = {}) {
return;
}
const cacheKey = `${userId}:${tid}:${onlyActive ? 'a' : 'all'}`;
if (useCache) {
const cached = cache.get('pacientesTimeline', cacheKey, MELISSA_CACHE_TTL.pacientesTimeline);
if (cached) {
pacientes.value = cached;
_doFetch(userId, tid, cacheKey).catch((e) => {
// eslint-disable-next-line no-console
console.warn('[useMelissaPacientes] revalidate', e);
});
return;
}
} else {
// Force: invalida o slot pra outras instâncias (se houver) também
// pegarem fresh na próxima leitura.
cache.invalidate('pacientesTimeline');
}
loading.value = true;
error.value = null;
try {
// Não filtra status no SQL — DB tem valores inconsistentes
// ('ativo', 'Ativo', 'active', null...). Normaliza e filtra no client.
const { data, error: err } = await supabase
.from('patients')
.select('id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at, data_nascimento')
.eq('owner_id', userId)
.eq('tenant_id', tid)
.order('nome_completo', { ascending: true })
.limit(1000);
if (err) throw err;
const todos = (data || []).map((r) => ({
id: r.id,
nome: r.nome_completo || '',
email: r.email_principal || '',
telefone: r.telefone || '',
avatar_url: r.avatar_url || null,
status: normalizeStatus(r.status),
last_attended_at: r.last_attended_at || null,
created_at: r.created_at || null,
data_nascimento: r.data_nascimento || null
}));
pacientes.value = onlyActive ? todos.filter((p) => p.status === 'Ativo') : todos;
await _doFetch(userId, tid, cacheKey);
} catch (e) {
error.value = e?.message || 'Erro ao carregar pacientes';
pacientes.value = [];
@@ -106,12 +136,15 @@ export function useMelissaPacientes(opts = {}) {
}
}
onMounted(fetchPacientes);
if (autoFetch) onMounted(() => _fetch({ useCache: true }));
return {
pacientes,
loading,
error,
refetch: fetchPacientes
// refetch força query nova (uso em handlers pós-mutation: criar/editar/deletar).
refetch: () => _fetch({ useCache: false }),
// fetchCached usa stale-while-revalidate (uso em defer/idle callback).
fetchCached: () => _fetch({ useCache: true })
};
}
@@ -66,6 +66,14 @@ export function useMelissaPacientesAside(opts) {
async function _ensureUid() {
if (_uid.value) return _uid.value;
// Fast path: session já hidratada no storage (<10ms).
const { data: ses } = await supabase.auth.getSession();
const uid = ses?.session?.user?.id;
if (uid) {
_uid.value = uid;
return uid;
}
// Fallback: round-trip pro auth server (cold start).
const { data, error: err } = await supabase.auth.getUser();
if (err) return null;
_uid.value = data?.user?.id || null;
+9
View File
@@ -37,6 +37,15 @@ export default {
component: () => import('@/layout/melissa/MelissaLayout.vue')
},
// Preview do AgendaEventDialog V2 (A66 sub-sessão 2). Iteração
// visual antes de migrar consumers (sub-sessão 3). Pública pra
// facilitar dev — remover quando V2 estabilizar.
{
path: 'preview/agenda-dialog-v2',
name: 'PreviewAgendaDialogV2',
component: () => import('@/views/pages/preview/AgendaDialogV2Preview.vue')
},
// 404
{
path: 'pages/notfound',
+72
View File
@@ -0,0 +1,72 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/stores/melissaCacheStore.js
| Data: 2026-05-04
|
| Cache in-memory (Pinia) com stale-while-revalidate pra dados que o
| Melissa Layout consome em todas as visitas e raramente mudam:
| - pacientesTimeline: lista de pacientes do tenant (1000)
| - eventosHoje: eventos do dia (resumo)
| - feriados: municipais + globais por (tenant_id, ano)
| - agendaSettings: configurações + workRules do owner
|
| LGPD: tudo em RAM. Some ao recarregar a aba ou trocar de sessão. Nunca
| persistido em localStorage/IndexedDB porque contém dados clínicos.
|
| Pattern de uso (composable):
| const cached = cache.get('pacientesTimeline', key, TTL.pacientes);
| if (cached) { ref.value = cached; refetchInBackground(); return; }
| const fresh = await fetch();
| cache.set('pacientesTimeline', fresh, key);
|
| Invalidação manual: chamar `cache.invalidate('slot')` em mutations
| (ex: criar paciente invalidate('pacientesTimeline')).
|--------------------------------------------------------------------------
*/
import { defineStore } from 'pinia';
// Time-to-live por slot (ms). Slots de dados que mudam pouco ganham TTL
// mais longo; eventos do dia ganham TTL curto pra não mostrar lista
// desatualizada se uma sessão foi marcada/cancelada em outra aba.
export const MELISSA_CACHE_TTL = {
pacientesTimeline: 5 * 60 * 1000, // 5 min
eventosHoje: 90 * 1000, // 90 s
feriados: 60 * 60 * 1000, // 1 h
agendaSettings: 5 * 60 * 1000 // 5 min
};
function emptySlot() {
return { data: null, ts: 0, key: null };
}
export const useMelissaCacheStore = defineStore('melissaCache', {
state: () => ({
pacientesTimeline: emptySlot(),
eventosHoje: emptySlot(),
feriados: emptySlot(),
agendaSettings: emptySlot()
}),
actions: {
// Retorna data se houver cache válido pro `slot` E se a `key` bater
// (key encapsula contexto: uid, tenant, ano, dia — o que mudar
// invalida o slot automaticamente). Retorna null se inválido/expirado.
get(slot, key, ttl) {
const s = this[slot];
if (!s?.ts) return null;
if (key !== undefined && s.key !== key) return null;
if (Date.now() - s.ts > ttl) return null;
return s.data;
},
set(slot, data, key) {
this[slot] = { data, ts: Date.now(), key: key ?? null };
},
invalidate(slot) {
this[slot] = emptySlot();
},
invalidateAll() {
for (const k of Object.keys(this.$state)) this.invalidate(k);
}
}
});
@@ -0,0 +1,308 @@
<!--
|--------------------------------------------------------------------------
| Preview de AgendaEventDialogV2 com fixtures.
| Rota: /preview/agenda-dialog-v2
| Uso: visualizar e iterar o template V2 sem subir pra produção.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, reactive } from 'vue';
import AgendaEventDialogV2 from '@/features/agenda/components/AgendaEventDialogV2.vue';
const open = ref(false);
const scenario = ref('novo-vazio');
// Fixtures
const commitmentOptions = [
{ id: 'cm-sessao', name: 'Sessão', description: 'Atendimento clínico padrão', native_type: 'session', bg_color: '6366f1', fields: [] },
{ id: 'cm-bloqueio', name: 'Bloqueio', description: 'Indisponibilidade', native_type: 'bloqueio', bg_color: 'ef4444', fields: [] },
{ id: 'cm-supervisao', name: 'Supervisão', description: 'Encontro com supervisor', bg_color: 'f59e0b', fields: [] },
{ id: 'cm-evento', name: 'Evento', description: 'Reunião, palestra, etc.', bg_color: '10b981', fields: [] }
];
const workRules = [
{ dia_semana: 1, hora_inicio: '08:00', hora_fim: '18:00', ativo: true },
{ dia_semana: 2, hora_inicio: '08:00', hora_fim: '18:00', ativo: true },
{ dia_semana: 3, hora_inicio: '08:00', hora_fim: '18:00', ativo: true },
{ dia_semana: 4, hora_inicio: '08:00', hora_fim: '18:00', ativo: true },
{ dia_semana: 5, hora_inicio: '08:00', hora_fim: '17:00', ativo: true }
];
const agendaSettings = {
session_duration_min: 50,
session_break_min: 10,
slot_mode: 'fixed',
online_ativo: true
};
const allEvents = [];
const pausasSemanais = [];
const blockedDates = [];
// Cenários
const scenarios = {
'novo-vazio': {
label: 'Novo (vazio)',
eventRow: null,
presetCommitmentId: null
},
'novo-sessao': {
label: 'Novo (preset Sessão)',
eventRow: null,
presetCommitmentId: 'cm-sessao'
},
'edit-avulsa': {
label: 'Editar avulsa',
eventRow: {
id: 'evt-edit-1',
commitment_id: 'cm-sessao',
paciente_id: 'p-1',
paciente_nome: 'Marina Souza',
paciente_status: 'Ativo',
paciente_avatar: null,
inicio_em: '2026-05-09T14:00:00',
fim_em: '2026-05-09T14:50:00',
modalidade: 'presencial',
status: 'agendado',
price: 200,
observacoes: 'Sessão de retorno',
recurrence_id: null,
serie_id: null
},
presetCommitmentId: null
},
'edit-serie': {
label: 'Editar série',
eventRow: {
id: 'evt-serie-1',
commitment_id: 'cm-sessao',
paciente_id: 'p-2',
paciente_nome: 'Carlos Mendes',
paciente_status: 'Ativo',
paciente_avatar: null,
inicio_em: '2026-05-12T10:00:00',
fim_em: '2026-05-12T10:50:00',
modalidade: 'online',
status: 'agendado',
price: 220,
recurrence_id: 'rec-1',
serie_id: 'rec-1',
serie_dia_semana: 2,
serie_hora: '10:00'
},
presetCommitmentId: null
},
'paciente-inativo': {
label: 'Paciente inativo (lock)',
eventRow: {
id: 'evt-inativo-1',
commitment_id: 'cm-sessao',
paciente_id: 'p-3',
paciente_nome: 'João Pereira',
paciente_status: 'Inativo',
inicio_em: '2026-06-01T15:00:00',
fim_em: '2026-06-01T15:50:00',
modalidade: 'presencial',
status: 'agendado'
},
presetCommitmentId: null
}
};
const dialogProps = ref({
eventRow: null,
presetCommitmentId: null
});
function abrir(key) {
scenario.value = key;
const sc = scenarios[key];
dialogProps.value = {
eventRow: sc.eventRow,
presetCommitmentId: sc.presetCommitmentId
};
open.value = true;
}
// Logs de events
const log = reactive({ entries: [] });
function pushLog(label, payload) {
log.entries.unshift({
ts: new Date().toLocaleTimeString('pt-BR'),
label,
payload: JSON.stringify(payload, null, 2)
});
if (log.entries.length > 8) log.entries.pop();
}
function onSave(payload) { pushLog('save', payload); }
function onDelete(payload) { pushLog('delete', payload); }
function onUpdateSeries(payload) { pushLog('updateSeriesEvent', payload); }
function onEditSeriesOccurrence(payload) { pushLog('editSeriesOccurrence', payload); }
</script>
<template>
<div class="aev2-preview-page">
<header class="aev2-preview-header">
<div>
<h1 class="text-xl font-bold">AgendaEventDialogV2 Preview</h1>
<p class="text-sm opacity-70">A66 sub-sessão 2. Iterar visual antes de migrar consumers (sub-sessão 3).</p>
</div>
<div class="text-xs opacity-60">
Cenário atual: <code>{{ scenario }}</code>
</div>
</header>
<div class="aev2-preview-grid">
<section>
<h2 class="aev2-preview-section-title">Cenários</h2>
<div class="aev2-preview-buttons">
<button
v-for="(sc, key) in scenarios"
:key="key"
type="button"
class="aev2-preview-btn"
:class="{ 'aev2-preview-btn--active': scenario === key }"
@click="abrir(key)"
>
{{ sc.label }}
</button>
</div>
</section>
<section>
<h2 class="aev2-preview-section-title">Eventos emitidos</h2>
<div class="aev2-preview-log">
<div v-if="!log.entries.length" class="opacity-50 text-sm">
Abra o dialog e dispare ações pra ver os emits aqui.
</div>
<div v-else>
<div v-for="(e, i) in log.entries" :key="i" class="aev2-preview-log-entry">
<div class="aev2-preview-log-meta">
<span class="font-mono text-xs opacity-60">{{ e.ts }}</span>
<span class="aev2-preview-log-label">{{ e.label }}</span>
</div>
<pre class="aev2-preview-log-payload">{{ e.payload }}</pre>
</div>
</div>
</div>
</section>
</div>
<AgendaEventDialogV2
v-model="open"
:event-row="dialogProps.eventRow"
:preset-commitment-id="dialogProps.presetCommitmentId"
owner-id="owner-preview"
tenant-id="tenant-preview"
:commitment-options="commitmentOptions"
:work-rules="workRules"
:agenda-settings="agendaSettings"
:all-events="allEvents"
:pausas-semanais="pausasSemanais"
:blocked-dates="blockedDates"
@save="onSave"
@delete="onDelete"
@update-series-event="onUpdateSeries"
@edit-series-occurrence="onEditSeriesOccurrence"
/>
</div>
</template>
<style scoped>
.aev2-preview-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
color: var(--text-color);
}
.aev2-preview-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--surface-border);
}
.aev2-preview-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1.5rem;
}
@media (max-width: 800px) {
.aev2-preview-grid { grid-template-columns: 1fr; }
}
.aev2-preview-section-title {
font-size: .8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
opacity: .7;
margin-bottom: .75rem;
}
.aev2-preview-buttons {
display: flex;
flex-direction: column;
gap: .35rem;
}
.aev2-preview-btn {
text-align: left;
border: 1px solid var(--surface-border);
background: var(--surface-card);
color: var(--text-color);
padding: .65rem .85rem;
border-radius: 10px;
font-size: .85rem;
cursor: pointer;
transition: all .15s;
}
.aev2-preview-btn:hover {
border-color: var(--p-primary-500, #6366f1);
transform: translateX(2px);
}
.aev2-preview-btn--active {
background: var(--p-primary-500, #6366f1);
color: #fff;
border-color: var(--p-primary-500, #6366f1);
font-weight: 600;
}
.aev2-preview-log {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 10px;
padding: 1rem;
max-height: 480px;
overflow-y: auto;
}
.aev2-preview-log-entry {
border-top: 1px solid var(--surface-border);
padding: .5rem 0;
}
.aev2-preview-log-entry:first-child { border-top: 0; padding-top: 0; }
.aev2-preview-log-meta {
display: flex;
align-items: center;
gap: .5rem;
margin-bottom: .25rem;
}
.aev2-preview-log-label {
background: var(--p-primary-500, #6366f1);
color: #fff;
padding: 1px 7px;
border-radius: 4px;
font-size: .7rem;
font-weight: 600;
}
.aev2-preview-log-payload {
font-family: ui-monospace, monospace;
font-size: .72rem;
background: var(--surface-100);
padding: .5rem;
border-radius: 6px;
overflow-x: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
</style>