11 Commits

Author SHA1 Message Date
Leonardo 629e7ce18e DB: melissa_prefs em user_settings + 'melissa' como layout_variant
Migration nova (database-novo/migrations/20260427000001_*):
- ALTER TABLE user_settings ADD COLUMN melissa_prefs jsonb DEFAULT '{}'
  NOT NULL — guarda toqueTermino, overlayOpacity, bgImageOpacity, use24h,
  cardsAtivos[] e cardsLayout. Sanitizacao no client antes do upsert.
- bgUrl (data URL da foto, MBs) NAO entra aqui — segue em localStorage
  ate migrarmos pra Supabase Storage.

Schema canonico (tenants_multi_tenant.sql) atualizado em paralelo:
- mesma coluna melissa_prefs jsonb
- check de layout_variant agora aceita 'melissa' alem de 'classic' e
  'rail' (precondicao pra plugar o tema Direcao B no preference real)

Leitura/escrita no client ainda pendente — feita em sessao separada.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:12:25 -03:00
Leonardo 06bce11e1c Melissa: deep-link via URL + Pacientes (WIP) + cronometro reset
Roteamento por URL (substitui o ref local secaoAberta):
- routes.misc.js: rota vira /preview/melissa/:secao? — param opcional
- MelissaLayout.vue: secaoAberta agora e computed do route.params.secao,
  validado contra SECOES (chave invalida -> null). abrirSecao/fecharSecao
  fazem router.push em vez de mutar ref. Habilita back/forward, refresh
  e deep-link tipo /preview/melissa/agenda.

Pagina Pacientes (WIP, ainda nao wireada no slot do Layout):
- src/layout/melissa/MelissaPacientes.vue (novo, ~? linhas) — fullscreen
  3-col espelhando MelissaAgenda: aside esquerda com filtros (status /
  grupos / tags), lista central com cards + busca, quick view direita
  com KPIs do paciente selecionado + acoes.
- Carrega pacientes (todos os status), grupos/tags do tenant, vinculos
  patient_groups + patient_tags + session counts em paralelo.
- Integra PatientProntuario (overlay), PatientCadastroDialog,
  PatientCreatePopover + ComponentCadastroRapido, e
  conversationDrawerStore (acao WhatsApp da quick view).

useMelissaPacientes ganha opcao { onlyActive }:
- default true (compat com cards do resumo / cronometro / eventos hoje
  — so faz sentido com ativos)
- false retorna Ativo + Inativo + Arquivado, pra uso na pagina nova
- select agora inclui data_nascimento (necessario pros KPIs da quick view)

Cronometro: zera ao parar — terminou a sessao, fica pronto pra proxima
sem precisar reabrir o popover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:12:15 -03:00
Leonardo 7b67bd083a Melissa Agenda: breakpoint compact + drawer mobile teleportado
Dois pontos de quebra agora:
- <xl (<=1279px) "compact": view-switcher (Dia/Semana/Mes/Lista) sai da
  toolbar e entra no menu "Acoes" com check icon no ativo. Filtros
  tambem migram pra dentro pra nao inflar a barra.
- <lg (<=1023px) "mobile": .ma-side e .ma-widgets viajam pra fora do
  .ma-page via Teleport, num <aside class="ma-mobile-drawer"> sempre
  presente no DOM (v-show controla display) — garante target valido
  desde o mount. Botao "Menu" mobile-only aparece a esquerda do header.
  Backdrop entre drawer e .ma-page com Transition de fade.

Bonus styles.scss: fix borda dupla do FullCalendar.
.fc-scrollgrid em light mode mantinha borda externa que somada com a
borda das celulas da ponta dava 2px na borda do calendario. Zera o
contorno do contairner — celulas (td/th) ja desenham a grade visual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:11:56 -03:00
Leonardo dac3198873 Drawer WhatsApp: banner persistente em erros de envio
friendlySendError (string única) virou classifySendError, que devolve
{ code, status, message, hint, action, secondaryAction }. UI passa a
renderizar banner persistente no chat (não só toast efêmero) com título
+ dica explicativa + CTA contextual.

Casos cobertos:
- 502/503/504 -> "Servidor de WhatsApp fora do ar" + CTA Configurar +
  CTA Comprar créditos (caso ainda não tenha contratado)
- insufficient_credits -> CTA Comprar créditos
- canal nao configurado / inativo -> CTA Configurar agora
- credenciais evolution incompletas -> CTA Configuracoes WhatsApp
- twilio credenciais incompletas -> sem CTA (fala pra contatar suporte)
- evolution retornou ... -> CTA Ver status
- twilio_send_failed... -> CTA Configuracoes WhatsApp
- auth -> "sessao expirou", sem CTA
- forbidden -> sem CTA

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:11:44 -03:00
Leonardo a57cf27a6a Fix TDZ no autosave do cadastro externo
O watch de scheduleProgressSave referenciava form.* antes da declaração
do reactive form, violando TDZ e quebrando a página inteira no load.
Move o watch pra depois da `const form = reactive(resetForm())`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:11:31 -03:00
Leonardo ffcb8b17f9 Melissa Agenda: paridade com AgendaTerapeuta + responsivo mobile
Composable useMelissaAgenda (~1150 linhas, exclusivo Melissa):
- Orquestra useAgendaEvents + useRecurrence + useDeterminedCommitments
  + useFeriados + useCommitmentServices
- 7 cases de save (avulso, recorrente C, somente_este D, este_e_seguintes E,
  todos F, todos_sem_excecao G + tratamento de exclusion constraint)
- 3 cases de delete (somente_este, este_e_seguintes, todos com encerrar série)
- onCreateEvento (botão Agendar), onSelectTime com cap de 120min,
  persistMoveOrResize com confirm dialog descritivo e bold em datas/horas
- Bloqueio: openBloqueioDialog(mode) com 4 modos

MelissaLayout:
- Provide composable via MELISSA_AGENDA_KEY (inject em MelissaAgenda)
- Renderiza AgendaEventDialog + BloqueioDialog + ConfirmDialog
- Slot #message v-html pra renderizar HTML em messages do confirm
- onEditEvento liga panel ao dialog completo (B3 não-stub)

MelissaAgenda:
- Drop useMelissaEventosRange — eventos vêm do composable injetado
- Drag/resize/select-to-create habilitados quando há composable
- Cluster Paciente + Agendar (50/50 primary)
- Toolbar: timeMode (24/12/Meu) + onlySessions + bloquear-menu (desktop)
- Header: Pacientes (mobile-only, abre drawer) + Configurações + Fechar
- Mobile <lg: aside + widgets viram drawer off-canvas (slide esquerda);
  calendar fullwidth; "Ações" menu mobile concentra timeMode/onlySessions/
  bloquear; backdrop com click-outside

MelissaEventoPanel (B3 estático-revisado):
- Substitui panel inline que crashava em campos inexistentes
- Action bar agrupada (status / paciente / geral)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:15:56 -03:00
Leonardo ff3695fbb1 HANDOFF 2026-04-27: bug Teleport + B1 toolbar + B2 stats; B3 pendente teste
Sessão de domingo curta. Bug do chip resolvido pela manhã, polimento
da Agenda à tarde (toolbar + stats interativos), à noite extração do
MelissaEventoPanel novo (não testado em browser, fica pra amanhã).

Working tree não commitado: B3 (MelissaEventoPanel novo + handlers
no MelissaLayout + patient_id no normalize + defineExpose). Ver
seção "PENDENTE DE TESTE" no HANDOFF pra plano de validação.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:41:00 -03:00
Leonardo 6a92735366 Melissa Agenda: toolbar polish + stats interativos com filtro
B1 — Toolbar
- Cluster Hoje + chevrons num pill único (mais coeso)
- Título com flex+ellipsis (some min-width:130px que truncava feio em
  view Mês/Lista)
- Botão "Hoje" disabled visual (opacity 0.45) quando hoje cai no range
  visível — antes ficava idêntico, sem affordance
- title="" → v-tooltip.top nos chevrons (memória: tooltips PrimeVue)
- focus-visible com outline accent em todos os botões da toolbar
- Visual refinado: padding/font-weight, view-btn ativo com box-shadow

B2 — Stats interativos
- Click no stat filtra fcEvents + sessoesHoje pelo predicado correspondente
  (Total/Sessões/Realizadas/Faltas — feriados continuam sempre)
- Stat ativo ganha borda accent + bg color-mix
- Stats com value=0 ficam disabled (cursor:not-allowed, opacity 0.4)
- Click no stat ativo limpa o filtro
- Chip flutuante "Filtrando: X" no canto sup direito do FC, click limpa
- Tooltip dinâmico explicando a ação esperada

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:29:59 -03:00
Leonardo f2b15ce0f7 HANDOFF + cleanup: bug Teleport resolvido, backups antigos removidos
- HANDOFF.md atualizado: bug do chip do cronômetro resolvido em 2026-04-27.
  Causa-raiz documentada (múltiplos Teleports compartilhando target +
  Transition>Element v-if gera comment placeholder VNode → emitsOptions:null
  no shouldUpdateComponent) e fix oficial (Transition envolvendo Teleport).
- Backups locais 2026-03-23 removidos do índice (já estavam .gitignored,
  apenas saneamento).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:11:28 -03:00
Leonardo 1bcb969f72 Layout Melissa (Direção B): preview, /profile, Agenda, dock, cadastro
Sandbox completo do novo layout Win11 lockscreen-style. Não troca o
AppLayout atual — Fase 5 (router wire-up) fica pra sessão dedicada.

Estrutura
- src/layout/melissa/ — MelissaLayout (bg+ψ+overlays), MelissaCronometro,
  MelissaAgenda (fullscreen), MelissaCard, MelissaMenu, MelissaBusca
- composables/useMelissaEventos.js — semana real do FC + range mensal
  pros dots do mini-cal
- composables/useMelissaPacientes.js — agora retorna created_at p/ "novo"
- melissaToques.js — toques Web Audio do término

Rota e persistência
- /preview/melissa (sem auth, sem AppLayout)
- /account/profile ganha 3º card "Melissa" com badge "Em construção"
- bootstrapUserSettings + layout composable aceitam variant='melissa'
- Migration: CHECK constraint user_settings.layout_variant aceita 'melissa'

Light mode
- Gradiente Bloom flipa via CSS vars (--bloom-c1/c2/base-1/base-2)
  Dark: 400/300/950 · Light: 200/100/0
- Cronômetro/Personalização: color: white → var(--m-text)
- Pílula psi-kbd ganha tokens --m-kbd-bg/--m-kbd-text
- Override mapeia text-X-200/300/400 → text-X-600 (17 cores Tailwind)

Agenda fullscreen
- Mini-cal funcional: click pula FC, range visível destacado, dots reais
- Feriados nacional/municipal/personalizado (rose/amber/violet)
- Dias fechados (workRules) cinza apagado, mutex feriado vence
- Card "Hoje" (stats+sessões) mesclado e movido pra sidebar esquerda
- ProximosFeriadosCard reaproveitado entre mini-cal e Hoje
- Avatar paciente: bg --m-accent-strong → --m-accent (saturado em light)
- Cores light: 12 substituições color:white → var(--m-text)

Dock taskbar Win11-style
- .melissa-dock 76px fixed bottom (CSS global, não scoped — Vue static
  hoisting perderia data-v-{hash})
- ψ centralizado vertical na faixa (bottom:10px)
- Chip cronômetro teleportado pro dock + animação minimize macOS
  (dialog encolhe + voa pro canto bottom-left, 340ms cubic-bezier)
- transform-origin: 96px calc(100% - 38px) (posição do chip no dock)

Pacientes na sidebar
- Botão fake "+" no topo abre PatientCreatePopover (rápido/completo/link)
- Reaproveita PatientCadastroDialog + ComponentCadastroRapido
- Pacientes criados nos últimos 7d sobem pro topo + badge "novo"

Dock contextual (ações do paciente selecionado)
- Avatar + nome + count + 5 ações (sessões/whatsapp/prontuário/editar/fechar)
- Teleportado pro .melissa-dock quando há paciente selecionado
- Em mobile, ações vivem em <Menu> kebab por linha
- Pattern <Transition><Teleport v-if> obrigatório (NUNCA o contrário)
  pra evitar comment placeholder + emitsOptions:null no reconciler

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:10:53 -03:00
Leonardo ab103ec88b Fix admin adjust créditos WhatsApp: clamp silencioso vira erro vermelho
- Severidade dos toasts de validação: warn → error (não selecionar tenant,
  valor < 1, > WA_ADJUST_MAX, nada removível, excede max removível)
- Remove :max do <InputNumber> no formulário — antes ele clampeava
  silenciosamente o valor digitado pro máximo permitido, escondendo o erro.
  Agora deixa o usuário digitar e estourar o toast vermelho do submit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:10:12 -03:00
29 changed files with 11792 additions and 41376 deletions
+3
View File
@@ -27,6 +27,9 @@ evolution-api/
# Backups locais do banco — não comitar (regeneráveis via db.cjs backup) # Backups locais do banco — não comitar (regeneráveis via db.cjs backup)
database-novo/backups/ database-novo/backups/
# Rascunhos de design locais (Melissa Direção A, etc)
layout-scratchs/
# Outputs do Playwright # Outputs do Playwright
test-results/ test-results/
playwright-report/ playwright-report/
+200 -360
View File
@@ -1,399 +1,239 @@
# HANDOFF — 2026-04-23 (fim do dia) # HANDOFF — 2026-04-27 (domingo, pincelada de polimento Melissa)
Documento de continuidade. **Quando voltar, comece lendo esta página.** Documento de continuidade. **Quando voltar, comece lendo esta página.**
Todo o estado vive no banco (`/saas/desenvolvimento` → Auditoria/Verificações/Testes). Sessão de domingo curta — bug do chip resolvido, polimento da Agenda
(toolbar + stats interativos + extração do evento panel novo).
--- ---
## 📊 Estado atual ## 🧪 PENDENTE DE TESTE — MelissaEventoPanel novo (B3)
| | | Implementado mas **ainda não testado em browser**. Working tree:
|---|---|
| **🔴 Críticos** | **0** ✅ | ```
| **🟠 Altos** | **0** ✅ | M src/layout/melissa/MelissaAgenda.vue
| Vitest | 208/208 | M src/layout/melissa/MelissaLayout.vue
| SQL integration | 33/33 | M src/layout/melissa/composables/useMelissaEventos.js
| E2E (Playwright) | 5/5 | ?? src/layout/melissa/MelissaEventoPanel.vue
| Migrations totais | **47** (36 → 47) | ```
| Edge functions | **25** (20 → 25) |
| Cron jobs ativos | 2 (heartbeat 2min + SLA 5min) | **O que mudou:**
| Commits hoje | **18** (de `f76a2e3` a `f1c97ee`) | - Novo `MelissaEventoPanel.vue` (~350 linhas) substitui o panel inline que
vivia em `MelissaLayout.vue:851-940`
- **Bug latente do panel inline corrigido:** o panel referenciava
`.valor.toFixed`, `.participantes`, `.supervisorNome`, `.local` — campos
que NÃO existem no `useMelissaEventos.normalizeEvent`. Crashava ao clicar
em qualquer sessão real.
- Novo painel mostra só campos REAIS: hora, modalidade (com ícone correto
pra online/presencial), status pílula colorida (realizado verde, faltou
vermelho, cancelado cinza, remarcar amber), descrição se houver
- Action bar agrupada em 3 grupos:
- **Status** (só pra sessão e quando status NÃO é final): Concluir |
Faltou | Remarcar | Cancelar
- **Paciente** (só pra sessão com paciente vinculado): Prontuário |
WhatsApp | Histórico
- **Geral**: Editar (sempre)
- Tooltips PrimeVue (`v-tooltip.top`) em todos os botões
- `MelissaAgenda.defineExpose({ refetch, openProntuario, setView })`
`MelissaLayout` chama via `melissaAgendaRef`
- `MelissaLayout` ganhou imports: `useToast`, `MelissaEventoPanel`,
`supabase`, `useConversationDrawerStore`. Handlers:
`updateEventoStatus(novoStatus, msg)` faz UPDATE em `agenda_eventos`,
toast verde, refetch da agenda, fecha painel. Erro → toast vermelho.
- `useMelissaEventos.js`: adicionado `patient_id` ao `normalizeEvent` +
ao SELECT (era `null`, agora vem do DB)
- Removido CSS órfão do panel inline (linhas 1495-1561) e função `tipoLabel`
que ficou sem uso
**O que esperar ao testar (em /preview/melissa → Agenda):**
5 sessões REALIZADAS já estão no banco hoje (criadas em 2026-04-27 pra
testar B2). Click em qualquer uma delas:
- Painel abre SEM CRASH (antes morria em `.valor.toFixed`)
- Status pílula verde "Realizada"
- Como status é final, **NÃO mostra grupo de mudança de status**
- Mostra: Prontuário, WhatsApp, Histórico (paciente) + Editar (geral)
**Pra testar mudança de status**, precisa criar evento com status
`agendado`. Ver query de seed no fim do HANDOFF.
**Actions a validar:**
- Prontuário → abre `PatientProntuario` do paciente real
- WhatsApp → abre `conversationDrawerStore.openForPatient(patient_id)`
- Histórico → muda FC pra view "Lista" (MVP — filtro por paciente futuro)
- Editar → toast info "Use a Agenda completa em /agenda" (TODO real:
integrar `AgendaEventDialog` numa sessão futura — é grande)
- Concluir/Faltou/Cancelar (em evento agendado): UPDATE supabase →
toast verde → refetch → painel fecha. Card "Realizadas" deve subir.
- Remarcar: muda status pra `'remarcar'` (MVP). Reagendamento real fica
pra integrar com `AgendaEventDialog` depois.
**Edge cases pra olhar:**
- Evento de **supervisão/reunião** (sem `tipo='sessao'`) — só mostra
"Editar" no action bar, sem grupos paciente/status. Visual ok?
- Evento sem `patient_id` (sessão antiga sem fk) — `onWhatsapp` mostra
toast warn "Paciente sem id"; `onAbrirProntuario` mesmo padrão
- `eventoBusy` durante UPDATE bloqueia todos os botões (`:disabled`)
--- ---
## 🎯 O que rolou hoje (2026-04-23) ## ✅ COMMITS DO DIA (2026-04-27)
### ✅ Admin SaaS: ajuste manual de créditos WhatsApp (f76a2e3) ```
6a92735 Melissa Agenda: toolbar polish + stats interativos com filtro ← B1+B2
f2b15ce HANDOFF + cleanup: bug Teleport resolvido, backups antigos removidos ← reset HANDOFF
1bcb969 Layout Melissa (Direção B): preview, /profile, Agenda, dock, cadastro ← grupos 2-7 do antigo HANDOFF (todo o trabalho 24-26)
ab103ec Fix admin adjust créditos WhatsApp: clamp silencioso vira erro vermelho ← grupo 1 do antigo HANDOFF
```
- RPC `admin_adjust_whatsapp_credits` (+/-) com `|amount| ≤ 1000` por operação Working tree limpo exceto pelo B3 não testado (ver acima).
- Remoção só afeta pool cortesia (topup/adjustment/refund) — compras `purchase` são intocáveis (FIFO cortesia primeiro)
- Helper RPC `get_whatsapp_removable_balance` pra UI mostrar breakdown
- UI em `/saas/addons`: SelectButton Adicionar/Remover, ConfirmDialog com impactados ao desativar pacote
### ✅ Heartbeat + Reconnect (6.1 + 6.3) — e1f756e / 4e4bac6
- Tabela `whatsapp_connection_incidents` com UNIQUE parcial (1 aberto por channel)
- RPCs `whatsapp_heartbeat_open_incident/resolve/mark_notified`
- Edge `whatsapp-heartbeat-check` com threshold configurável (padrão 5min)
- **Reconnect automático** (6.3): antes de abrir incident tenta `POST /instance/restart`, espera 3s, re-checa state. Se voltou → resolve. Cooldown 10min/channel.
- UI em `/configuracoes/whatsapp-pessoal` ganhou card "Monitoramento de conexão" (toggles alerts/reconnect + threshold + histórico 7d)
- Painel SaaS `/saas/whatsapp` mostra badge de incidents + "Verificar tudo agora"
- Cron `*/2 * * * *` ativo (job 5)
### ✅ SLA de conversas (3.4) — 771b636
- `conversation_sla_rules` (config 1/tenant, threshold 1-1440 min, horário comercial opcional, escopo assigned_only|all)
- `conversation_sla_breaches` com UNIQUE parcial 1 aberto/thread
- Trigger `trg_sla_resolve_on_outbound` resolve breach automaticamente quando chega outbound
- Edge `conversation-sla-check` calcula `businessMinutesElapsed` em TS
- UI `/configuracoes/conversas-sla` (config + histórico 7d)
- Cron `*/5 * * * *` ativo (job 6)
### ✅ Saldo baixo WhatsApp — e409ba6
- Trigger `fn_whatsapp_low_balance_notify` BEFORE UPDATE em `whatsapp_credits_balance`
- Dispara quando saldo cruza threshold + anti-spam via `low_balance_alerted_at`
- Reset automático quando `add_whatsapp_credits` recredita
### ✅ Pipeline de alertas robusto — 881fa16 / 5c50db6 / 6db06ab / 4441661 / 36fbc02 / 5f51bc0 / f646efe / 4026415 / 64e7634
Múltiplos fixes e melhorias do pipeline `system_alert`:
- Toast vermelho **sticky** com botão de ação (deeplink ou "Abrir conversa")
- Polling a cada 60s + catch-up no `visibilitychange` como fallback pro Realtime
- Agrega múltiplas pendentes no catch-up (mostra só mais recente + `+N outros no sino`)
- Não redispara toast pra `system_alert` já existentes no mount (F5 limpo)
- Sistema de aliases `/conversas``/therapist/conversas` ou `/admin/conversas` por role
- Browser notification do Chrome/Windows agora leva pro drawer da conversa ao clicar
- NotificationItem no sino ganhou botões inline "💬 Conversa" / "Abrir →" + fix NotFound
- Notifica **owner_id do channel + admins** (deduplicado)
### ✅ Analytics 7.1 — adf9208
- Helper interno `_first_response_runs` identifica "runs" de inbound (sequências do paciente) + delta até próxima outbound
- RPCs: `first_response_stats`, `first_response_by_therapist`, `first_response_evolution`
- Card `FirstResponseCard.vue` no Clinic e Therapist Dashboards com 3 KPIs + sparkline + ranking
### ✅ Fluxo de reativação de canal — 881fa16
- Edge `reactivate-notification-channel` (espelho da deactivate)
- SaasWhatsappPage detecta canal soft-deleted e reativa ao salvar
- ConfiguracoesWhatsappPage (tenant) mostra card "Reativar WhatsApp Pessoal"
- ChooserPage intercepta clique e reativa antes de ir pro setup
- Migration RLS `notification_channels` permite ler soft-deleted (donos/saas_admin/membros)
### ✅ Bot auto-triagem (3.7) — c2c42a1
- Tabelas `conversation_bots` + `conversation_bot_sessions`
- Helper `maybeProcessBot` em `_shared/whatsapp-hooks.ts`
- Integrado em `evolution-whatsapp-inbound` E `twilio-whatsapp-inbound`
- Página `/configuracoes/conversas-bots` com editor de steps, trigger, keywords, opt-out
- Ao terminar: closing + `conversation_notes` com resumo das respostas
### ✅ Grupo 8 completo — b8ea292
- **8.2** Botão "Lembrar paciente" no `AgendaEventDialog` — edge `send-session-reminder-manual`
- **8.3** Trigger DB em `agenda_eventos` dispara edge `send-session-status-notification` (cancelado/remarcado/confirmado)
- **8.4** Intake abandonado: coluna `last_progress_at`, edges `save-intake-progress` + `convert-abandoned-intakes`, RPC `convert_abandoned_intake_to_lead`, autosave no form público
### ✅ Dashboard SaaS receita créditos — f1c97ee
- 4 RPCs (`saas_wa_credits_revenue_stats/top_packages/usage_summary/revenue_evolution`) — saas_admin only
- Card `SaasCreditsRevenueCard.vue` com 4 KPIs (receita, compras, créditos, consumo) + sparkline + top pacotes
- Integrado em `/saas` (SaasDashboard)
### ✅ Fix lateral — 0f64381
`send-session-reminders` comparava `provider='evolution'` mas DB tem `'evolution_api'` — caía em `unknown_provider`. Corrigido e validado end-to-end (lembrete chegou pro paciente André Green no celular +55 16 98828 0038).
--- ---
## 🧪 ROTEIRO DE TESTES PRA AMANHÃ ## ✅ BUG RESOLVIDO — chip do cronômetro
Ordem sugerida (3h estimado com tudo). Cada seção é independente — pode testar em qualquer ordem depois de **0**. **Era:** dois `<Teleport to=".melissa-dock">` (chip cronômetro + dock contextual)
com `<Transition>` interno + `v-if` interno apontando pro mesmo target. O slot
do `<Transition>` colapsava pra comment placeholder VNode quando falsy, e
esses placeholders ficavam intercalados no array de children do target —
ao patch/reorder, `shouldUpdateComponent` lia `.component.emitsOptions` em
nó cujo `.component` foi anulado pelo unmount irmão. Daí `null`.
### 0. Pré-requisitos (5 min) **Fix (pattern oficial Vue):** `<Transition>` envolvendo `<Teleport>`, não o
contrário. Assim o Teleport some/aparece como unidade, sem deixar placeholder
no target compartilhado.
```vue
<!-- ANTES -->
<Teleport to=".melissa-dock">
<Transition name="...">
<Element v-if="cond" />
</Transition>
</Teleport>
<!-- DEPOIS -->
<Transition name="...">
<Teleport v-if="cond" to=".melissa-dock">
<Element />
</Teleport>
</Transition>
```
Aplicado em `MelissaCronometro.vue:322` e `MelissaAgenda.vue:842`.
**Lição persistida:** quando múltiplos Teleports compartilham target, evitar
`v-if` dentro do Teleport — coloca o `v-if` no próprio Teleport.
---
## ✅ B1 — Toolbar Agenda (commit 6a92735)
- Cluster Hoje + chevrons num pill único (mais coeso)
- Título com flex+ellipsis (some `min-width:130px` que truncava em Mês/Lista)
- Botão "Hoje" disabled visual (opacity 0.45) quando hoje cai no range
visível — antes ficava idêntico, sem affordance. Computed `refDateIsToday`
- `title=""``v-tooltip.top` nos chevrons (memória: tooltips PrimeVue)
- `focus-visible` outline accent em todos os botões da toolbar
- View-btn ativo ganhou `box-shadow` sutil
## ✅ B2 — Stats interativos (commit 6a92735)
- Click no stat filtra `fcEvents` + `sessoesHoje` pelo predicado correspondente
(Total/Sessões/Realizadas/Faltas — feriados continuam sempre como background)
- Stat ativo: borda accent + bg `color-mix(--m-accent 16%, --m-bg-soft)`
- Stats com `value=0` ficam disabled (opacity 0.4, `cursor:not-allowed`)
- Click no stat ativo limpa o filtro
- Chip flutuante "Filtrando: X" no canto sup direito do FC, click limpa
- `STAT_FILTERS` map global + `toggleStatFiltro(key)` helper
- Tooltip dinâmico explicando ação esperada por estado
---
## 📦 Setup pra retomar
```bash ```bash
# Reiniciar Supabase functions serve pra carregar 5 edges novas/alteradas # Terminal 1 — functions
supabase functions serve --no-verify-jwt --env-file supabase/functions/.env supabase functions serve --no-verify-jwt --env-file supabase/functions/.env
# Terminal 2 — vite
npm run dev
# Browser
http://localhost:5173/preview/melissa # ← Melissa preview
http://localhost:5173/account/profile # ← /profile com card Melissa
http://localhost:5173/auth/login # ← Login (dados reais)
``` ```
Confirma no output que aparecem:
- `reactivate-notification-channel`
- `whatsapp-heartbeat-check`
- `conversation-sla-check`
- `send-session-reminder-manual`
- `send-session-status-notification`
- `save-intake-progress`
- `convert-abandoned-intakes`
Também confirme crons:
```bash
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres -c "SELECT jobid, schedule, jobname, active FROM cron.job WHERE active=true;"
```
Esperado: 2 jobs ativos (5 e 6).
--- ---
### 1. Admin adjust créditos (~10 min) ## 🌱 Seeds de teste (DB local)
Login como **saas_admin**`/saas/addons` → aba **Topup WhatsApp**. 5 sessões REALIZADAS já existem hoje (owner: `aaaaaaaa-0002-...`):
08:00 Karen Horney, 09:30 André Green, 11:00 Felipe Santos, 14:00 Otto Rank,
16:00 Larissa Souza. Pra testar mudança de status, criar evento agendado:
1. Selecionar tenant Bruno Terapeuta
2. Card "Breakdown do saldo" mostra removível/protegido
3. Tentar **adicionar 1500** → deve recusar (máx 1000)
4. Adicionar **500** → ok
5. Mudar pra **Remover** → deve respeitar limite removível
6. Testar confirmação antes de remover
Aba **Pacotes WhatsApp**: desativar um pacote → confirmação com N compras / M tenants distintos.
---
### 2. Heartbeat + Reconnect (~10 min)
**2.1. Baseline:** `/saas/whatsapp` → "Verificar tudo agora" → 1 canal, status ok.
**2.2. Simular queda com Evolution respondendo:**
```bash
# Faz logout da instância no Evolution (Evolution API continua de pé)
curl -X POST http://localhost:8080/instance/logout/agenciapsi-teste -H "apikey: <APIKEY>"
```
Clica "Verificar tudo agora" 2 vezes. Na segunda, o heartbeat tenta **restart automático** e conecta de novo (precisa escanear QR, mas connection_status volta). Summary deve ter `auto_reconnected: 1`.
**2.3. Simular queda total (Evolution offline):**
```bash
docker stop evolution-api # ou o nome do container
```
Baixa threshold pra 1 min em `/configuracoes/whatsapp-pessoal`. Clica "Verificar tudo agora", espera 1 min, clica de novo. Agora abre incident + toast vermelho + notificação.
Resubir o container + clica verificar → breach fica "Resolvido".
---
### 3. SLA de conversas (~15 min)
`/configuracoes/conversas-sla` → ativa + threshold 1 min + escopo "Todas as conversas" + notify admin ON.
Apague breaches antigos e dispare manual:
```bash
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres -c "DELETE FROM conversation_sla_breaches WHERE resolved_at IS NULL;"
SERVICE_KEY=$(supabase status -o env 2>/dev/null | grep SERVICE_ROLE | cut -d'"' -f2)
curl -s -X POST http://localhost:54321/functions/v1/conversation-sla-check -H "Authorization: Bearer $SERVICE_KEY" -d '{}'
```
Esperado: abre breaches pras threads inbound sem resposta. Notifica.
Responda uma das threads no CRM → trigger fecha o breach automaticamente (vê card "Resolvido" em `/configuracoes/conversas-sla`).
---
### 4. Bot de triagem (~15 min)
`/configuracoes/conversas-bots` → ligar + salvar com 4 perguntas default.
De **outro celular que não seja paciente cadastrado**, manda "Oi" pro WhatsApp conectado.
Esperado:
- Bot responde saudação + primeira pergunta (nome)
- Cada resposta avança 1 pergunta
- Ao final: closing + nota interna em `conversation_notes` com resumo
Conferir:
```bash
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres -c "
SELECT current_step, status, collected_data FROM conversation_bot_sessions ORDER BY started_at DESC LIMIT 3;"
```
Abra o CRM na thread desse número → no drawer, na aba notas, deve ter o resumo.
---
### 5. Botão "Lembrar paciente" na agenda (8.2) (~5 min)
Agenda → abrir evento existente (Edit) com paciente que tenha telefone. Footer tem botão verde de WhatsApp.
Clicar → confirmação → toast sucesso → mensagem chega no celular do paciente.
Teste erros: paciente sem telefone → mensagem clara.
---
### 6. Status sessão dispara mensagem (8.3) (~10 min)
**Antes:** criar templates custom (ou deixar sem — skip silencioso) pra `cancelamento_sessao` / `remarcacao_sessao` / `confirmacao_sessao` em `/configuracoes/whatsapp` → aba Templates.
No dialog de evento existente, mudar status pra **"Cancelado"** → Salvar.
Trigger DB chama edge, edge resolve template + envia. Conferir:
```bash
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres -c "
SELECT direction, provider, provider_raw, substring(body from 1 for 80) FROM conversation_messages
WHERE provider_raw->>'status_change' = 'true' ORDER BY created_at DESC LIMIT 3;"
```
Se não tem template configurado, a edge retorna `skipped: template_not_found` (silencioso).
---
### 7. Intake abandonado → lead (8.4) (~15 min)
1. Gere um link de convite em `/admin/agendamentos-recebidos` ou similar
2. Abra o link anônimo (incognito) → form público
3. Preencha **só nome + telefone** (espera 1.5s pra autosave rodar)
4. Feche a aba (não submete)
Conferir que o intake ficou `in_progress`:
```bash
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres -c "
SELECT status, nome_completo, telefone, last_progress_at FROM patient_intake_requests ORDER BY updated_at DESC LIMIT 3;"
```
Forçar conversão (sem esperar 30 min):
```bash
SERVICE_KEY=$(supabase status -o env 2>/dev/null | grep SERVICE_ROLE | cut -d'"' -f2)
curl -s -X POST http://localhost:54321/functions/v1/convert-abandoned-intakes \
-H "Authorization: Bearer $SERVICE_KEY" -d '{"idle_minutes": 0}'
```
Esperado: `converted: 1`. Abre o CRM → thread nova com o telefone + nota interna com dados coletados.
**Ativar cron** (opcional, a cada 15 min):
```sql ```sql
SELECT cron.schedule('convert-abandoned-intakes-every-15min', '*/15 * * * *', $$ -- via docker exec supabase_db_agenciapsi-primesakai psql -U postgres -d postgres -c "..."
SELECT net.http_post( INSERT INTO agenda_eventos (owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, observacoes)
url := current_setting('app.settings.supabase_url') || '/functions/v1/convert-abandoned-intakes', VALUES (
headers := jsonb_build_object('Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'), 'Content-Type', 'application/json'), 'aaaaaaaa-0002-0002-0002-000000000002',
body := '{}'::jsonb 'bbbbbbbb-0002-0002-0002-000000000002',
'fe670066-0d81-49ea-b177-61e83b455c59', -- Henrique Ferreira
'sessao', 'agendado',
current_date + time '18:00', current_date + time '19:00',
'presencial', 'Seed B3 - testar action bar de status'
); );
$$); ```
Pra LIMPAR todas as sessões de seed depois:
```sql
DELETE FROM agenda_eventos WHERE observacoes LIKE 'Seed B%';
``` ```
--- ---
### 8. Saldo baixo WhatsApp (~5 min) ## 📌 Próximos passos (amanhã)
Login como terapeuta → `/configuracoes/creditos-whatsapp` → threshold em 20. **1. Testar B3 (prioridade)** — ver seção topo. Se quebrar:
- Console errors → me passar pra debug
- Visual ruim em light mode → ajustar tokens no `MelissaEventoPanel.vue`
- Action falha em paciente sem `patient_id` → revisar guards
```bash **2. Commitar B3 após testar:**
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres -c " ```
UPDATE whatsapp_credits_balance SET balance=100, low_balance_alerted_at=NULL WHERE tenant_id='bbbbbbbb-0002-0002-0002-000000000002'; Melissa: MelissaEventoPanel novo + bug fix latente do panel inline
UPDATE whatsapp_credits_balance SET balance=10 WHERE tenant_id='bbbbbbbb-0002-0002-0002-000000000002';"
```
Toast vermelho "Saldo baixo" aparece + botão "Ir pra loja →". - Componente extraído MelissaLayout:851-940 → MelissaEventoPanel.vue
- Bug fix: panel inline crashava em .valor.toFixed (campo undefined no
normalizeEvent). Novo só usa campos reais
- Action bar agrupada (status / paciente / geral) com 7 ações
- useMelissaEventos.normalizeEvent: adicionado patient_id
- MelissaAgenda.defineExpose({ refetch, openProntuario, setView })
- MelissaLayout: handlers updateEventoStatus + onConcluir/Faltou/etc
```
**3. Itens futuros maiores (sessão dedicada cada):**
- **Editar/Remarcar reais** — integrar `AgendaEventDialog` no MelissaLayout
(precisa carregar row crua de `agenda_eventos` + props extensas).
~60-90min, blast radius médio
- **Drag/resize no FC com persist** (Fase 2 do roadmap) — UPDATE
`inicio_em/fim_em` no drop, conflito detection, undo
- **Recorrências virtuais (RRULE → ocorrências)** — biggest fish
- **Histórico de sessões** com filtro por paciente — refinar lo que hoje
só muda view do FC
- **Fase 5 (A#32)** — wire-up router pra Layout Melissa virar real
(alto risco, precisa de Fase 3 antes — split MelissaLayout/MelissaResumo)
**4. Outras opções (não-Melissa):**
- **QA Seção 3.4 do roteiro de testes** — SLA conversas + Bot triagem +
Lembrar paciente + ...
--- ---
### 9. Analytics 1ª resposta (~3 min) ## 📚 Tracking persistente
`/admin` (ClinicDashboard) → card "Tempo de 1ª resposta" com sparkline + ranking terapeutas. - **A#32** — Fase 5 router wire-up (pendente, sessão dedicada)
- **A#33** — Bug do chip cronômetro: ✅ RESOLVIDO 2026-04-27
- Memória atualizada: `project_layout_melissa.md` registra pattern
Transition>Teleport e convenções
`/therapist` (TherapistDashboard) → card filtrado pelo user logado.
---
### 10. Dashboard SaaS receita (~3 min)
Login como saas_admin → `/saas` → seção nova "Receita de créditos WhatsApp":
- KPIs receita/compras/créditos/consumo
- Sparkline de evolução
- Ranking top 5 pacotes
Mudar período (30d/90d/6m/12m) → recarrega.
---
## 🎯 Próxima sessão (se tudo der ok no teste)
### Items do backlog original que ficaram
- **5.4 Export LGPD de conversas** — incluir conversas no export de paciente (que já existe)
- **Tour guiado / onboarding wizard** — refino UX
- **Rotação de credenciais Twilio** — se subconta vazar, precisa de flow pra regenerar
- **Retention 5.1** — apagar/anonimizar conversas > X dias **(você pulou — voltar quando for beta fechado)**
### Items novos nascidos desta sessão
- **Suporte Twilio no status→msg e lembrete manual** — hoje só Evolution (Twilio retorna `provider_not_supported_yet`)
- **Persistir user "bot" sintético** — hoje `conversation_notes.created_by` usa primeiro admin como hack
- **Autosave do form de intake mais robusto** — hoje só 4 campos (nome, telefone, email, onde_nos_conheceu); ideal seria todos os campos preenchidos até então
- **Cron de `convert-abandoned-intakes`** — template pronto, ativar quando testar o fluxo
- **Bot v2**: fallback quando paciente digita algo que não encaixa (ex: "sim" na pergunta de nome) — hoje aceita qualquer string
### Pós-beta (deixar pra depois)
- **(a)** Smoke test infra — cloud Supabase + hospedagem. ~2-3h
- **(b)** Beta fechado com clínicas
- **(c)** Ampliar analytics (conversão por terapeuta, SLA por tag, etc)
---
## 🔧 Setup Evolution/WhatsApp / Asaas
Tudo em **`WHATSAPP_SETUP.md`**. Resumo crítico:
1. `supabase functions serve --no-verify-jwt --env-file supabase/functions/.env` em terminal separado
2. `.env` do functions tem: `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `ASAAS_API_KEY`, `ASAAS_API_URL=https://api-sandbox.asaas.com/v3`
3. Evolution: `/saas/whatsapp` cadastra creds global → `/configuracoes/whatsapp-pessoal` conecta QR
4. Twilio: `/saas/twilio-whatsapp` provisiona subconta → tenant ativa em `/configuracoes/whatsapp-oficial` (usa créditos)
⚠️ Após editar qualquer `supabase/functions/**` precisa reiniciar o `supabase functions serve` — sem hot reload.
---
## 📦 Commits de hoje (cronológico)
```
f76a2e3 Admin SaaS: ajuste manual de créditos WhatsApp (+/-) com proteção de compras
e1f756e Heartbeat WhatsApp Evolution (Grupo 6.1): detecção + incident + alerta admin
881fa16 Fluxo de reativação de canal WhatsApp + alerta toast sticky + notify owner
e409ba6 Saldo baixo WhatsApp: trigger dispara notificação ao cruzar threshold
5c50db6 Notifications: fallback de polling + catch-up ao focar a aba
6db06ab Toast system_alert ganha botão de ação com deeplink
4441661 Toast system_alert: agregar no catch-up pra não empilhar enxurrada
771b636 SLA de conversas WhatsApp (Grupo 3.4): config + detecção + alerta
4026415 Notifications: não redispara toast pra system_alert antigas após F5
5f51bc0 Fix deeplink /crm/conversas não existe; alias dinâmico por role
f646efe Toast SLA: botão "Abrir conversa" abre drawer direto da thread
64e7634 NotificationItem: resolve alias + botões inline "Conversa"/"Abrir"
36fbc02 Browser notification: click leva pro destino real (drawer ou rota)
adf9208 Analytics 7.1: tempo médio de 1ª resposta WhatsApp no dashboard
0f64381 Fix send-session-reminders comparava provider='evolution' mas DB guarda 'evolution_api'
4e4bac6 6.3 Reconnect automático Evolution antes de abrir incident
c2c42a1 3.7 Bot auto-triagem WhatsApp: config por tenant + hook nos inbound
b8ea292 Grupo 8: agenda ↔ WhatsApp completo (8.2 lembrar manual, 8.3 status→msg, 8.4 lead)
f1c97ee Dashboard SaaS ganha seção de receita de créditos WhatsApp (Asaas)
```
---
## 🛠️ Stack lembretes
- **DB local:** `docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres`
- **DB como supabase_admin (ALTER POLICY em tabelas owned):**
```bash
docker exec -i -e PGPASSWORD=postgres -e PGCLIENTENCODING=UTF8 \
supabase_db_agenciapsi-primesakai \
psql -U supabase_admin -d postgres -h localhost -f migration.sql
```
- **Vitest:** `npx vitest run`
- **SQL integration:** `node database-novo/tests/run.cjs`
- **Edge functions serve:** `supabase functions serve --no-verify-jwt --env-file supabase/functions/.env`
- **Evolution Manager:** `http://localhost:8080/manager/`
- **Supabase Studio:** `http://localhost:54323`
- **Asaas sandbox:** `https://sandbox.asaas.com`
---
## 📚 Memória persistente (carregada automaticamente)
Já saved em `MEMORY.md`:
- Project overview · MVP Assessment · Deploy options
- Sanitização sempre · Priorização por severidade · Self-hosted > provider externo
- Gotcha supabase_admin · Tracking dev_*_items
---
## 📌 Bom descanso. Amanhã, testes.
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,22 @@
-- ==========================================================================
-- Agencia PSI — Migracao: layout_variant aceita 'melissa'
-- ==========================================================================
-- O CHECK constraint user_settings_layout_variant_check restringia o valor
-- a ('classic', 'rail'). Com a chegada do Layout Melissa (Direção B do
-- redesign — wrapper estilo Win11 lockscreen), precisamos aceitar o valor
-- 'melissa' tambem.
--
-- Wire-up real do router (troca do AppLayout pelo MelissaLayout) ainda nao
-- foi feito (Fase 5 do roadmap Melissa) — mas a preferencia ja precisa
-- persistir desde agora pra UI do /profile funcionar.
-- ==========================================================================
ALTER TABLE public.user_settings
DROP CONSTRAINT IF EXISTS user_settings_layout_variant_check;
ALTER TABLE public.user_settings
ADD CONSTRAINT user_settings_layout_variant_check
CHECK (layout_variant = ANY (ARRAY['classic'::text, 'rail'::text, 'melissa'::text]));
COMMENT ON COLUMN public.user_settings.layout_variant
IS 'classic (sidebar) | rail (mini rail + painel) | melissa (Win11 lockscreen, Beta)';
@@ -0,0 +1,25 @@
-- ==========================================================================
-- Agencia PSI — Migracao: melissa_prefs em user_settings
-- ==========================================================================
-- Persiste as preferencias do layout Melissa (Direção B) no DB em vez de
-- viverem só em localStorage (que perde ao trocar de navegador/dispositivo).
--
-- Estrutura do JSONB (sanitizado no client antes de salvar):
-- {
-- "toqueTermino": "sino", // id em melissaToques.js
-- "overlayOpacity": 0.35, // 0..0.8 — escurecedor sobre o bg
-- "bgImageOpacity": 1, // 0..1 — transparencia da foto custom
-- "use24h": true, // formato do relogio
-- "cardsAtivos": ["proximo-..."], // ids de cards do resumo
-- "cardsLayout": "linha-unica" // 'linha-unica' | 'duas-linhas'
-- }
--
-- bgUrl (data URL da foto) NAO entra aqui — pode ter MBs e estouraria a row.
-- Permanece em localStorage até migrarmos pra Supabase Storage.
-- ==========================================================================
ALTER TABLE public.user_settings
ADD COLUMN IF NOT EXISTS melissa_prefs jsonb DEFAULT '{}'::jsonb NOT NULL;
COMMENT ON COLUMN public.user_settings.melissa_prefs IS
'Preferencias do layout Melissa (toque, opacidade overlay/imagem, formato hora, cards). Imagem de fundo permanece no localStorage por ser data URL pesada.';
@@ -132,7 +132,8 @@ CREATE TABLE public.user_settings (
created_at timestamp with time zone DEFAULT now() NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL,
layout_variant text DEFAULT 'classic'::text NOT NULL, layout_variant text DEFAULT 'classic'::text NOT NULL,
CONSTRAINT user_settings_layout_variant_check CHECK ((layout_variant = ANY (ARRAY['classic'::text, 'rail'::text]))), melissa_prefs jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT user_settings_layout_variant_check CHECK ((layout_variant = ANY (ARRAY['classic'::text, 'rail'::text, 'melissa'::text]))),
CONSTRAINT user_settings_menu_mode_check CHECK ((menu_mode = ANY (ARRAY['static'::text, 'overlay'::text]))), CONSTRAINT user_settings_menu_mode_check CHECK ((menu_mode = ANY (ARRAY['static'::text, 'overlay'::text]))),
CONSTRAINT user_settings_theme_mode_check CHECK ((theme_mode = ANY (ARRAY['light'::text, 'dark'::text]))) CONSTRAINT user_settings_theme_mode_check CHECK ((theme_mode = ANY (ARRAY['light'::text, 'dark'::text])))
); );
+4 -4
View File
@@ -105,7 +105,7 @@ export async function bootstrapUserSettings({
const _lsV = (() => { const _lsV = (() => {
try { try {
const v = localStorage.getItem('layout_variant'); const v = localStorage.getItem('layout_variant');
return v === 'rail' || v === 'classic' ? v : null; return v === 'rail' || v === 'classic' || v === 'melissa' ? v : null;
} catch { } catch {
return null; return null;
} }
@@ -114,9 +114,9 @@ export async function bootstrapUserSettings({
if (_lsV !== null) { if (_lsV !== null) {
// localStorage já tem valor → aplica ele (garante coerência com layoutConfig) // localStorage já tem valor → aplica ele (garante coerência com layoutConfig)
if (_lsV !== (settings.layout_variant ?? _lsV)) setVariant(_lsV); if (_lsV !== (settings.layout_variant ?? _lsV)) setVariant(_lsV);
} else if (settings.layout_variant === 'rail') { } else if (settings.layout_variant === 'rail' || settings.layout_variant === 'melissa') {
// localStorage vazio + banco tem 'rail' → aplica e grava no localStorage // localStorage vazio + banco tem 'rail'/'melissa' → aplica e grava no localStorage
setVariant('rail'); setVariant(settings.layout_variant);
} }
// localStorage vazio + banco tem 'classic' → mantém padrão 'rail' (não aplica) // localStorage vazio + banco tem 'classic' → mantém padrão 'rail' (não aplica)
+6 -1
View File
@@ -279,13 +279,18 @@
background: var(--surface-hover); background: var(--surface-hover);
} }
/* Bordas do FullCalendar solução pra "borda dupla":
- células (td/th) MANTÊM a borda forma a grade visual
- contêiner .fc-scrollgrid ZERA sem isso, a borda externa fica
dobrada (1px do contêiner + 1px da célula da ponta = 2px na borda) */
.app-dark .fc-theme-standard td, .app-dark .fc-theme-standard td,
.app-dark .fc-theme-standard th { .app-dark .fc-theme-standard th {
border: 1px solid var(--surface-border); border: 1px solid var(--surface-border);
} }
.fc-theme-standard .fc-scrollgrid,
.app-dark .fc-theme-standard .fc-scrollgrid { .app-dark .fc-theme-standard .fc-scrollgrid {
border: 1px solid var(--surface-border); border: none;
} }
.app-dark .fc-timegrid-event-harness-inset .fc-timegrid-event, .app-dark .fc-timegrid-event-harness-inset .fc-timegrid-event,
@@ -1103,6 +1103,45 @@ function insertEmoji(emoji) {
</div> </div>
</div> </div>
<!-- Banner de erro de envio (persistente até user enviar com sucesso ou dispensar) -->
<div
v-if="store.lastSendError"
class="flex items-start gap-2.5 mt-2 px-3 py-2.5 rounded-lg border border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
>
<i class="pi pi-exclamation-triangle text-amber-600 dark:text-amber-300 mt-0.5 shrink-0" />
<div class="flex-1 min-w-0">
<div class="text-sm font-medium leading-snug">{{ store.lastSendError.message }}</div>
<div v-if="store.lastSendError.hint" class="text-xs opacity-85 mt-0.5 leading-snug">
{{ store.lastSendError.hint }}
</div>
<div v-if="store.lastSendError.action || store.lastSendError.secondaryAction" class="flex flex-wrap gap-3 mt-1.5">
<RouterLink
v-if="store.lastSendError.action"
:to="store.lastSendError.action.url"
class="inline-flex items-center gap-1 text-xs font-semibold underline hover:no-underline"
@click="store.dismissSendError()"
>
{{ store.lastSendError.action.label }} <i class="pi pi-arrow-right text-[0.6rem]" />
</RouterLink>
<RouterLink
v-if="store.lastSendError.secondaryAction"
:to="store.lastSendError.secondaryAction.url"
class="inline-flex items-center gap-1 text-xs font-medium underline hover:no-underline opacity-90"
@click="store.dismissSendError()"
>
{{ store.lastSendError.secondaryAction.label }} <i class="pi pi-arrow-right text-[0.6rem]" />
</RouterLink>
</div>
</div>
<button
class="shrink-0 w-6 h-6 grid place-items-center rounded hover:bg-amber-100 dark:hover:bg-amber-500/20 transition-colors"
title="Dispensar"
@click="store.dismissSendError()"
>
<i class="pi pi-times text-[0.7rem]" />
</button>
</div>
<!-- Compose --> <!-- 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 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"> <div class="flex items-end gap-2">
+6 -2
View File
@@ -20,10 +20,14 @@ import { computed, reactive, ref, onMounted, onBeforeUnmount } from 'vue';
const BREAKPOINT_XL = 1280; const BREAKPOINT_XL = 1280;
// ── resolve variant salvo no localStorage ─────────────────── // ── resolve variant salvo no localStorage ───────────────────
// 'classic' = Sidebar lateral
// 'rail' = Mini rail + painel
// 'melissa' = Layout Melissa (Direção B). Hoje só persiste a preferência —
// o switch real do app vem na Fase 5 (router wire-up).
function _loadVariant() { function _loadVariant() {
try { try {
const v = localStorage.getItem('layout_variant'); const v = localStorage.getItem('layout_variant');
if (v === 'rail' || v === 'classic') return v; if (v === 'rail' || v === 'classic' || v === 'melissa') return v;
} catch {} } catch {}
return 'rail'; return 'rail';
} }
@@ -199,7 +203,7 @@ export function useLayout() {
}; };
const setVariant = (v, { fromUser = true } = {}) => { const setVariant = (v, { fromUser = true } = {}) => {
if (v !== 'classic' && v !== 'rail') return; if (v !== 'classic' && v !== 'rail' && v !== 'melissa') return;
layoutConfig.variant = v; layoutConfig.variant = v;
try { try {
localStorage.setItem('layout_variant', v); localStorage.setItem('layout_variant', v);
File diff suppressed because it is too large Load Diff
+427
View File
@@ -0,0 +1,427 @@
<script setup>
/*
* MelissaBusca
* --------------------------------------------------
* Busca rápida glass-style. Adaptação do GlobalSearch da topbar
* (`src/components/search/GlobalSearch.vue`) pro layout Melissa.
*
* Diferenças vs. GlobalSearch:
* - Não chama Supabase recebe pacientes/eventos via prop (preview)
* - Visual glass (white-on-glass) ao invés de surface-card
* - Sem loading state (busca client-side, instantâneo)
* - Emite ações pro parent decidir o que fazer
*
* Quando promover pra produção: trocar a busca por chamada à RPC
* `search_global` + manter a mesma estrutura de panel/items.
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
pacientes: { type: Array, default: () => [] },
eventos: { type: Array, default: () => [] },
atalhos: {
type: Array,
default: () => [
{ id: 'agenda', label: 'Agenda', icon: 'pi pi-calendar', sublabel: 'Sessões e compromissos', keywords: ['agenda', 'calendario', 'sessoes', 'hoje'] },
{ id: 'pacientes', label: 'Pacientes', icon: 'pi pi-users', sublabel: 'Cadastro e prontuários', keywords: ['pacientes', 'lista', 'cadastro'] },
{ id: 'conversas', label: 'WhatsApp', icon: 'pi pi-whatsapp', sublabel: 'Conversas em andamento', keywords: ['whatsapp', 'conversas', 'mensagens', 'chat'] },
{ id: 'financeiro', label: 'Financeiro', icon: 'pi pi-wallet', sublabel: 'Recebíveis e lançamentos', keywords: ['financeiro', 'pagamentos', 'cobrancas', 'dinheiro'] },
{ id: 'configuracoes', label: 'Configurações', icon: 'pi pi-cog', sublabel: 'Preferências e equipe', keywords: ['configuracoes', 'ajustes', 'preferencias', 'settings'] }
]
}
});
const emit = defineEmits(['acao', 'paciente', 'evento']);
const rootEl = ref(null);
const inputEl = ref(null);
const query = ref('');
const showPanel = ref(false);
const activeIndex = ref(-1);
function normalize(s) {
return String(s || '')
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.trim();
}
function fmtHora(h) {
const horas = Math.floor(h);
const mins = Math.round((h - horas) * 60);
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
const filteredAtalhos = computed(() => {
const q = normalize(query.value);
if (!q) return props.atalhos.slice(0, 4); // 4 defaults quando vazio
return props.atalhos.filter((a) => {
const hay = normalize(a.label + ' ' + (a.keywords || []).join(' '));
return hay.includes(q);
}).slice(0, 5);
});
const filteredPacientes = computed(() => {
const q = normalize(query.value);
if (q.length < 2) return [];
return props.pacientes
.filter((p) => normalize(p.nome).includes(q))
.slice(0, 5);
});
const filteredEventos = computed(() => {
const q = normalize(query.value);
if (q.length < 2) return [];
return props.eventos
.filter((e) => {
const hay = normalize(
(e.label || '') + ' ' + (e.pacienteNome || '') + ' ' + (e.descricao || '')
);
return hay.includes(q);
})
.slice(0, 5);
});
const flatList = computed(() => {
const out = [];
filteredAtalhos.value.forEach((a, i) => out.push({ group: 'atalhos', item: a, idx: i }));
filteredPacientes.value.forEach((p, i) => out.push({ group: 'pacientes', item: p, idx: i }));
filteredEventos.value.forEach((e, i) => out.push({ group: 'eventos', item: e, idx: i }));
return out;
});
const hasAnyResult = computed(() => flatList.value.length > 0);
function findFlatIndex(group, idx) {
return flatList.value.findIndex((x) => x.group === group && x.idx === idx);
}
function selectEntry(entry) {
if (entry.group === 'atalhos') emit('acao', entry.item.id);
else if (entry.group === 'pacientes') emit('paciente', entry.item);
else if (entry.group === 'eventos') emit('evento', entry.item);
closePanel();
}
function closePanel() {
showPanel.value = false;
query.value = '';
activeIndex.value = -1;
inputEl.value?.blur();
}
function onFocus() {
showPanel.value = true;
}
function onClickOutside(e) {
if (rootEl.value && !rootEl.value.contains(e.target)) {
showPanel.value = false;
activeIndex.value = -1;
}
}
function onKeydown(e) {
if (!showPanel.value) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, flatList.value.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0);
} else if (e.key === 'Enter' && activeIndex.value >= 0) {
e.preventDefault();
selectEntry(flatList.value[activeIndex.value]);
} else if (e.key === 'Escape') {
// Stop bubbling pra ESC do parent não fechar overlay aleatório
e.stopPropagation();
closePanel();
}
}
function onGlobalKeydown(e) {
// Ctrl+K / +K foca input
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
showPanel.value = true;
inputEl.value?.focus();
}
}
onMounted(() => {
document.addEventListener('mousedown', onClickOutside);
window.addEventListener('keydown', onGlobalKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onClickOutside);
window.removeEventListener('keydown', onGlobalKeydown);
});
</script>
<template>
<div ref="rootEl" class="mb-search">
<div class="mb-field">
<i class="pi pi-search mb-field__icon" />
<input
ref="inputEl"
v-model="query"
type="text"
placeholder="Buscar paciente, agenda, atalho…"
class="mb-field__input"
@focus="onFocus"
@keydown="onKeydown"
/>
<span class="mb-field__kbd" aria-hidden="true">Ctrl K</span>
</div>
<Transition name="mb-fade">
<div v-if="showPanel" class="mb-panel" role="listbox">
<div
v-if="query.trim().length >= 2 && !hasAnyResult"
class="mb-empty"
>
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
</div>
<!-- Atalhos -->
<div v-if="filteredAtalhos.length" class="mb-group">
<div class="mb-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
<button
v-for="(a, i) in filteredAtalhos"
:key="'a-' + a.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('atalhos', i) === activeIndex }"
@click="selectEntry({ group: 'atalhos', item: a })"
@mouseenter="activeIndex = findFlatIndex('atalhos', i)"
>
<span class="mb-item__icon"><i :class="a.icon" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ a.label }}</span>
<span v-if="a.sublabel" class="mb-item__sub">{{ a.sublabel }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Pacientes -->
<div v-if="filteredPacientes.length" class="mb-group">
<div class="mb-group__title">Pacientes</div>
<button
v-for="(p, i) in filteredPacientes"
:key="'p-' + p.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('pacientes', i) === activeIndex }"
@click="selectEntry({ group: 'pacientes', item: p })"
@mouseenter="activeIndex = findFlatIndex('pacientes', i)"
>
<span class="mb-item__icon"><i class="pi pi-user" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ p.nome }}</span>
<span class="mb-item__sub">Abrir prontuário</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Eventos -->
<div v-if="filteredEventos.length" class="mb-group">
<div class="mb-group__title">Agenda de hoje</div>
<button
v-for="(e, i) in filteredEventos"
:key="'e-' + e.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('eventos', i) === activeIndex }"
@click="selectEntry({ group: 'eventos', item: e })"
@mouseenter="activeIndex = findFlatIndex('eventos', i)"
>
<span
class="mb-item__icon"
:style="{ backgroundColor: `${e.color}33`, color: e.color }"
>
<i class="pi pi-clock" />
</span>
<span class="mb-item__main">
<span class="mb-item__label">{{ e.label }}</span>
<span class="mb-item__sub">{{ fmtHora(e.startH) }} {{ fmtHora(e.endH) }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.mb-search {
position: relative;
width: 100%;
max-width: 560px;
/* Só horizontal — top/bottom ficam livres pro parent (mt-X do Tailwind) */
margin-left: auto;
margin-right: auto;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mb-field {
position: relative;
display: flex;
align-items: center;
background: var(--m-bg-soft);
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
border: 1px solid var(--m-border-strong);
border-radius: 12px;
padding: 0 14px;
height: 44px;
transition: background-color 160ms ease, border-color 160ms ease;
}
.mb-field:focus-within {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mb-field__icon {
color: var(--m-text-muted);
font-size: 0.95rem;
margin-right: 10px;
flex-shrink: 0;
}
.mb-field__input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: white;
font-size: 0.9rem;
font-family: inherit;
min-width: 0;
}
.mb-field__input::placeholder {
color: var(--m-text-muted);
}
.mb-field__kbd {
color: var(--m-text-muted);
font-size: 0.62rem;
font-weight: 500;
padding: 2px 7px;
border-radius: 4px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
margin-left: 8px;
flex-shrink: 0;
letter-spacing: 0.05em;
}
.mb-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 30;
max-height: 60vh;
overflow-y: auto;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--m-border-strong);
border-radius: 12px;
padding: 6px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mb-panel::-webkit-scrollbar { width: 6px; }
.mb-panel::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mb-empty {
padding: 18px 14px;
text-align: center;
color: var(--m-text-muted);
font-size: 0.85rem;
}
.mb-group + .mb-group {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--m-border);
}
.mb-group__title {
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--m-text-faint);
font-size: 0.6rem;
font-weight: 600;
padding: 8px 10px 4px;
}
.mb-item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: transparent;
border: none;
border-radius: 8px;
color: white;
text-align: left;
cursor: pointer;
font-family: inherit;
transition: background-color 100ms ease;
}
.mb-item:hover,
.mb-item.is-active {
background: var(--m-bg-soft);
}
.mb-item__icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
background: var(--m-bg-soft);
border-radius: 7px;
color: var(--m-text-muted);
flex-shrink: 0;
font-size: 0.85rem;
}
.mb-item__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.mb-item__label {
font-size: 0.85rem;
color: white;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-item__sub {
font-size: 0.7rem;
color: var(--m-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-item__go {
color: var(--m-text-faint);
font-size: 0.7rem;
flex-shrink: 0;
}
.mb-fade-enter-active,
.mb-fade-leave-active {
transition: opacity 140ms ease, transform 160ms ease;
}
.mb-fade-enter-from,
.mb-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>
+175
View File
@@ -0,0 +1,175 @@
<script setup>
/*
* MelissaCard
* --------------------------------------------------
* Card glass do dashboard Melissa.
*
* Dois variants:
* - default: header (ícone+título+badge opcional) + slot de conteúdo
* + botão redondo "+" centralizado na borda inferior que
* emite `open` (parent decide o que fazer)
* - add: placeholder tracejado, meia largura, com "+" centralizado;
* emite `add` ao clicar
*
* Largura é fixa (não estica) o layout flex no parent define o número
* de cards visíveis conforme o tamanho da tela.
*/
defineProps({
variant: {
type: String,
default: 'default',
validator: (v) => ['default', 'add'].includes(v)
},
icon: { type: String, default: '' }, // ex.: 'pi pi-user'
iconColor: { type: String, default: '' }, // classe Tailwind ex.: 'text-emerald-300'
title: { type: String, default: '' },
badge: { type: [String, Number, null], default: null },
badgeColor: { type: String, default: 'bg-red-500/80' }, // classe Tailwind
actionTitle: { type: String, default: 'Abrir' }
});
defineEmits(['open', 'add']);
</script>
<template>
<article v-if="variant === 'default'" class="mc-card">
<div class="mc-card__head">
<span v-if="icon" class="mc-card__icon">
<i :class="[icon, iconColor]" />
</span>
<span class="mc-card__title">{{ title }}</span>
<span v-if="badge !== null && badge !== ''" class="mc-card__badge" :class="badgeColor">
{{ badge }}
</span>
</div>
<div class="mc-card__body">
<slot />
</div>
<button class="mc-card__go" :title="actionTitle" @click="$emit('open')">
<i class="pi pi-plus text-xs" />
</button>
</article>
<button v-else class="mc-card-add" :title="actionTitle" @click="$emit('add')">
<i class="pi pi-plus" />
</button>
</template>
<style scoped>
/* ─── Card padrão ──────────────────────────────────────────── */
.mc-card {
position: relative; /* ancora o botão "+" */
flex-shrink: 0;
width: 210px;
background: var(--m-bg-soft);
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
border: 1px solid var(--m-border);
border-radius: 14px;
padding: 14px 16px;
transition: background-color 160ms ease, transform 160ms ease;
}
.mc-card:hover {
background: var(--m-bg-soft-hover);
transform: translateY(-2px);
}
.mc-card__head {
display: flex;
align-items: center;
gap: 10px;
color: var(--m-text);
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.01em;
}
.mc-card__icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 12px;
background: var(--m-bg-soft-hover);
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
border: 1px solid var(--m-border-strong);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
flex-shrink: 0;
}
.mc-card__title {
/* deixa o título absorver o espaço entre ícone e badge */
flex: 1;
min-width: 0;
}
.mc-card__badge {
/* margin-left: auto não precisa mais (title flex:1 empurra o badge) */
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 9999px;
color: white;
font-weight: 600;
flex-shrink: 0;
}
.mc-card__body {
margin-top: 12px;
color: white;
}
/* Botão "+" centralizado na borda inferior do card */
.mc-card__go {
position: absolute;
bottom: -16px;
left: 50%;
transform: translateX(-50%);
width: 32px;
height: 32px;
border-radius: 12px;
background: rgba(30, 30, 45, 0.85);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border: 1px solid var(--m-border-strong);
color: white;
display: grid;
place-items: center;
cursor: pointer;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
transition: background-color 180ms ease, transform 180ms ease, border-color 180ms ease;
z-index: 5;
}
.mc-card__go:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
transform: translateX(-50%) scale(1.08);
}
.mc-card__go:active {
transform: translateX(-50%) scale(0.96);
}
/* ─── Card "Adicionar" (tracejado, meia largura) ──────────── */
/* Sem align-self aqui o parent (cards-shell) decide se estica
(linha única) ou mantém intrínseco (wrap). */
.mc-card-add {
flex-shrink: 0;
width: 130px; /* metade do card padrão */
min-height: 100px;
border: 1.5px dashed var(--m-border-strong);
border-radius: 14px;
background: var(--m-bg-soft);
color: var(--m-text-muted);
display: grid;
place-items: center;
cursor: pointer;
font-size: 1.5rem;
transition: all 200ms ease;
}
.mc-card-add:hover {
background: var(--m-bg-soft);
border-color: var(--m-text-muted);
color: white;
transform: translateY(-2px);
}
.mc-card-add:active {
transform: translateY(0);
}
</style>
+622
View File
@@ -0,0 +1,622 @@
<script setup>
/*
* MelissaCronometro
* --------------------------------------------------
* Cronômetro de sessão estilo "janela do Windows":
* - Dialog centralizado com select de paciente, display gigante e ações
* - Click fora minimiza (chip no canto superior esquerdo)
* - X/ESC fecha (destrói)
* - Botão inferior alterna entre "▶ Começar" e "⏹ Parar"
* - "+1 minuto" estende o tempo
* - Quando minimizado, o timer continua rodando em background
*
* Uso:
* <MelissaCronometro
* ref="cronoRef"
* :pacientes="lista"
* default-paciente-id="p1"
* :duracao-minutos="50"
* @visible-change="cronoVisivel = $event"
* />
*
* cronoRef.value.abrir() // abre / restaura
* cronoRef.value.fechar() // destrói
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { playToque } from './melissaToques';
const STORAGE_KEY = 'melissa.cronometro.v1';
const props = defineProps({
pacientes: {
type: Array,
default: () => []
// Cada item: { id: string, nome: string }
},
defaultPacienteId: {
type: [String, Number, null],
default: null
},
duracaoMinutos: {
type: Number,
default: 50
},
toqueTermino: {
type: String,
default: 'sino'
}
});
const emit = defineEmits(['visible-change', 'close', 'complete']);
// Estado interno
const exists = ref(false);
const minimized = ref(false);
const running = ref(false);
const seconds = ref(props.duracaoMinutos * 60);
const pacienteId = ref(props.defaultPacienteId);
let timer = null;
// True só durante a transição de minimizar (dialog chip).
// Permite trocar a animação leave do dialog por "shrink + fly to dock"
// (tipo macOS minimize) em vez do lift normal usado pra fechar.
const isMinimizing = ref(false);
// Computeds
const visible = computed(() => exists.value && !minimized.value);
const dialogTransitionName = computed(() => (isMinimizing.value ? 'minimize' : 'lift'));
const display = computed(() => {
const total = seconds.value;
const sign = total < 0 ? '-' : '';
const abs = Math.abs(total);
const m = Math.floor(abs / 60);
const s = abs % 60;
return `${sign}${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
});
const excedido = computed(() => seconds.value < 0);
const status = computed(() => {
if (running.value) return 'Em andamento';
if (seconds.value === props.duracaoMinutos * 60) return 'Pronto';
return 'Pausado';
});
const pacienteNome = computed(() => {
if (!pacienteId.value) return 'Atividade livre';
const p = props.pacientes.find((x) => x.id === pacienteId.value);
return p ? p.nome : '';
});
// Watch: avisa parent quando dialog aparece/some
watch(visible, (v) => emit('visible-change', v));
// Ações
function abrir() {
if (exists.value) {
// Já existe apenas restaura se tava minimizado (não cria outro)
if (minimized.value) minimized.value = false;
return;
}
seconds.value = props.duracaoMinutos * 60;
pacienteId.value = props.defaultPacienteId;
running.value = false;
minimized.value = false;
exists.value = true;
}
function toggle() {
if (running.value) {
if (timer) clearInterval(timer);
timer = null;
running.value = false;
// Zera ao parar sessão acabou, deixa pronto pra próxima
seconds.value = props.duracaoMinutos * 60;
} else {
if (timer) clearInterval(timer);
timer = setInterval(() => {
const wasPositive = seconds.value > 0;
seconds.value -= 1;
// Toca exatamente na transição positivo zero/negativo (uma única vez)
if (wasPositive && seconds.value <= 0) {
playToque(props.toqueTermino);
}
}, 1000);
running.value = true;
}
}
function minimizar() {
isMinimizing.value = true;
minimized.value = true;
// Reset do flag depois da animação completar (340ms + buffer)
setTimeout(() => { isMinimizing.value = false; }, 380);
}
function restaurar() {
minimized.value = false;
}
function fechar() {
if (timer) clearInterval(timer);
timer = null;
running.value = false;
minimized.value = false;
exists.value = false;
emit('close');
}
function ajustarMinutos(delta) {
seconds.value += delta * 60;
// Re-baselina savedAt pra restore após reload pegar o novo valor correto
saveState();
}
// Persistência (localStorage)
// Snapshot inclui timestamp pra calcular elapsed real no restore
// caso o usuário recarregue/feche a aba enquanto o timer roda, ao
// voltar o seconds reflete o tempo real (a sessão acontece na sala,
// não na aba).
function saveState() {
if (!exists.value) {
try { localStorage.removeItem(STORAGE_KEY); } catch {}
return;
}
const snap = {
pacienteId: pacienteId.value,
minimized: !!minimized.value,
running: !!running.value,
seconds: seconds.value,
savedAt: Date.now()
};
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snap)); } catch {}
}
function loadState() {
let raw;
try { raw = localStorage.getItem(STORAGE_KEY); } catch { return false; }
if (!raw) return false;
let snap;
try { snap = JSON.parse(raw); } catch { return false; }
if (!snap || typeof snap !== 'object') return false;
// Sanitização: cada campo do storage é input externo, não confiar
const validIds = new Set(props.pacientes.map((p) => p.id));
const pid = snap.pacienteId === null || validIds.has(snap.pacienteId)
? snap.pacienteId
: props.defaultPacienteId;
const savedSeconds = Number(snap.seconds);
if (!Number.isFinite(savedSeconds)) return false;
const savedAt = Number(snap.savedAt);
const wasRunning = !!snap.running;
// Se rodava, desconta tempo real desde o save (limite 24h pra evitar abuso de relógio)
let restoredSeconds = savedSeconds;
if (wasRunning && Number.isFinite(savedAt)) {
const elapsed = Math.max(0, Math.min(86400, Math.floor((Date.now() - savedAt) / 1000)));
restoredSeconds = savedSeconds - elapsed;
}
pacienteId.value = pid;
minimized.value = !!snap.minimized;
seconds.value = restoredSeconds;
exists.value = true;
running.value = false; // toggle abaixo flipa pra true
if (wasRunning) {
// Retoma o interval. NÃO toca o toque retroativo se o tempo
// já está negativo no restore, foi "perdido" durante o reload.
toggle();
}
return true;
}
// Watch nas mudanças de estado discreto (não em seconds enquanto roda savedAt+delta dá conta)
watch([exists, minimized, running, pacienteId], () => saveState());
// Mount / Cleanup
onMounted(() => {
loadState();
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
</script>
<template>
<!-- Dialog centralizado.
Transition name dinâmico: 'minimize' quando user minimiza (anima
shrink + fly em direção ao dock no bottom-left), 'lift' default
pros outros casos (fechar, abrir). -->
<Transition :name="dialogTransitionName">
<div v-if="visible" class="mc-layer" @click.self="minimizar">
<div class="mc-panel">
<div class="flex items-center justify-between mb-5">
<div>
<div class="text-white/55 text-xs uppercase tracking-[0.2em]">Cronômetro</div>
<div class="text-white text-lg font-light mt-1">{{ status }}</div>
</div>
<div class="flex items-center gap-1.5">
<button class="mc-glass-btn" title="Minimizar" @click="minimizar">
<i class="pi pi-window-minimize text-white/90 text-xs" />
</button>
<button class="mc-glass-btn" title="Fechar" @click="fechar">
<i class="pi pi-times text-white/90 text-sm" />
</button>
</div>
</div>
<!-- Select de paciente / atividade -->
<div class="mb-6">
<label class="text-[0.65rem] uppercase tracking-widest text-white/50 mb-2 block">
Paciente / atividade
</label>
<div class="relative">
<select v-model="pacienteId" class="mc-select">
<option :value="null"> Atividade livre (sem paciente)</option>
<option v-for="p in pacientes" :key="p.id" :value="p.id">
{{ p.nome }}
</option>
</select>
<i class="pi pi-chevron-down mc-select-icon" />
</div>
</div>
<!-- Display gigante + steppers manuais (+5 / -5) -->
<div class="mb-7">
<div class="flex items-center justify-center gap-4">
<div class="flex flex-col gap-1.5">
<button
class="mc-step-btn"
title="Adicionar 5 minutos"
@click="ajustarMinutos(5)"
>
+5
</button>
<button
class="mc-step-btn"
title="Remover 5 minutos"
:disabled="excedido"
@click="ajustarMinutos(-5)"
>
5
</button>
</div>
<div class="mc-display" :class="{ 'is-excedido': excedido }">
{{ display }}
</div>
</div>
<div class="text-xs mt-2 text-center" :class="excedido ? 'text-red-400 font-medium' : 'text-white/50'">
{{ excedido ? 'tempo excedido' : 'tempo restante' }}
</div>
</div>
<!-- Ações -->
<div class="flex gap-3">
<button class="mc-btn mc-btn--secondary flex-1" @click="ajustarMinutos(1)">
+1 minuto
</button>
<button
class="mc-btn flex-1"
:class="running ? 'mc-btn--danger' : 'mc-btn--primary'"
@click="toggle"
>
<i :class="running ? 'pi pi-stop-circle' : 'pi pi-play'" class="text-xs" />
{{ running ? 'Parar' : 'Começar' }}
</button>
</div>
</div>
</div>
</Transition>
<!-- Chip minimizado teleporta pro .melissa-dock (taskbar Win11
no bottom). Vive ao lado do ψ (à direita dele, dentro do flex
do dock). Transition envolve o Teleport (pattern oficial Vue):
o Teleport some/aparece como unidade, sem deixar comment
placeholder no target compartilhado o que evitaria o bug
"emitsOptions: null" causado por múltiplos Teleports + v-if
interno apontando pro mesmo target. -->
<Transition name="chip-pop">
<Teleport v-if="exists && minimized" to=".melissa-dock">
<button
class="mc-chip"
title="Restaurar cronômetro"
@click="restaurar"
>
<i
class="pi pi-stopwatch text-sm"
:class="running ? 'text-emerald-300 mc-chip-pulse' : 'text-white/70'"
/>
<span class="mc-chip-time" :class="{ 'text-red-300': excedido }">
{{ display }}
</span>
<span class="mc-chip-name">{{ pacienteNome }}</span>
</button>
</Teleport>
</Transition>
</template>
<style scoped>
/* ─── Dialog ───────────────────────────────────────────────── */
.mc-layer {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.mc-panel {
width: min(420px, 100%);
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border-strong);
border-radius: 22px;
padding: 1.75rem;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mc-glass-btn {
width: 36px;
height: 36px;
display: grid;
place-items: center;
background: var(--m-bg-soft);
backdrop-filter: blur(20px);
border: 1px solid var(--m-border-strong);
border-radius: 9999px;
cursor: pointer;
transition: background-color 160ms ease;
}
.mc-glass-btn:hover {
background: var(--m-bg-soft-hover);
}
/* ─── Select customizado ───────────────────────────────────── */
.mc-select {
width: 100%;
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
padding: 11px 38px 11px 14px;
border-radius: 10px;
font-size: 0.95rem;
font-family: inherit;
outline: none;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
transition: border-color 140ms ease, background-color 140ms ease;
}
.mc-select:hover {
background: var(--m-bg-soft-hover);
}
.mc-select:focus {
border-color: var(--m-border-strong);
}
.mc-select option {
/* renderizado pelo OS — usa tokens semânticos pra acompanhar dark/light */
background: var(--p-content-background);
color: var(--m-text);
}
.mc-select-icon {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--m-text-muted);
font-size: 0.7rem;
pointer-events: none;
}
/* ─── Display gigante ──────────────────────────────────────── */
.mc-display {
font-size: 5rem;
font-weight: 200;
line-height: 1;
letter-spacing: -0.04em;
color: var(--m-text);
font-variant-numeric: tabular-nums;
text-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
transition: color 200ms ease;
}
.mc-display.is-excedido {
color: #ef4444;
text-shadow: 0 4px 24px rgba(239, 68, 68, 0.45);
animation: mc-excedido-pulse 1.4s ease-in-out infinite;
}
@keyframes mc-excedido-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
/* ─── Steppers manuais (+5 / -5) ───────────────────────────── */
.mc-step-btn {
width: 44px;
height: 36px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
font-family: inherit;
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease, transform 100ms ease;
}
.mc-step-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mc-step-btn:active {
transform: scale(0.96);
}
.mc-step-btn:disabled {
background: var(--m-bg-soft);
border-color: var(--m-border);
color: var(--m-text-faint);
cursor: not-allowed;
pointer-events: none;
}
/* ─── Botões de ação ───────────────────────────────────────── */
.mc-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 14px;
border-radius: 12px;
font-size: 0.9rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mc-btn--secondary {
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
}
.mc-btn--secondary:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mc-btn--primary {
background: var(--m-accent);
border: 1px solid var(--m-accent);
color: var(--p-primary-contrast-color, white);
}
.mc-btn--primary:hover {
background: color-mix(in srgb, var(--m-accent) 80%, white);
}
.mc-btn--danger {
background: rgba(239, 68, 68, 0.55);
border: 1px solid rgba(239, 68, 68, 0.7);
color: white;
}
.mc-btn--danger:hover {
background: rgba(239, 68, 68, 0.75);
}
/* Chip minimizado (teleportado pro .melissa-dock)
Vive como filho flex do dock não usa position fixed. Dock
posiciona ele à esquerda (depois do ψ, via padding-left). */
.mc-chip {
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 14px 8px 12px;
background: var(--m-bg-medium);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
border: 1px solid var(--m-border-strong);
border-radius: 9999px;
color: var(--m-text);
cursor: pointer;
transition: background-color 200ms ease, transform 200ms ease, border-color 200ms ease;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mc-chip:hover {
background: var(--m-bg-medium);
transform: translateY(-2px);
border-color: var(--m-border-strong);
}
.mc-chip-time {
font-variant-numeric: tabular-nums;
font-weight: 500;
font-size: 0.85rem;
}
.mc-chip-name {
font-size: 0.72rem;
color: var(--m-text-muted);
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: 6px;
border-left: 1px solid var(--m-border-strong);
}
.mc-chip-pulse {
animation: mc-pulse 1.6s ease-in-out infinite;
}
@keyframes mc-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ─── Transições ───────────────────────────────────────────── */
.lift-enter-active,
.lift-leave-active {
transition: opacity 240ms ease, transform 280ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
.lift-enter-from {
opacity: 0;
transform: scale(0.96) translateY(12px);
}
.lift-leave-to {
opacity: 0;
transform: scale(0.98) translateY(8px);
}
.chip-pop-enter-active,
.chip-pop-leave-active {
transition: opacity 200ms ease, transform 220ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
.chip-pop-enter-from,
.chip-pop-leave-to {
opacity: 0;
transform: scale(0.85) translateY(-8px);
}
/* Chip aparecendo após minimize: leve atraso pra deixar o dialog
"voar" antes de aparecer no dock (sensação macOS) */
.chip-pop-enter-active {
transition-delay: 120ms;
}
/* Animação "minimize" (macOS-style): dialog encolhe + voa pro
bottom-left em direção ao chip que vai aparecer no dock. Aplicada
durante minimizar() fechar normal usa lift. */
.minimize-enter-active,
.minimize-leave-active {
transition: opacity 280ms ease, transform 340ms cubic-bezier(0.5, 0, 0.6, 1);
/* origem ~= posição do chip no dock (left:96px após ψ, bottom centro
da faixa de 76px = ~38px). Faz o scale "encolher em direção" . */
transform-origin: 96px calc(100% - 38px);
}
.minimize-enter-from {
opacity: 0;
transform: scale(0.05);
}
.minimize-leave-to {
opacity: 0;
transform: scale(0.05);
}
/* Light mode tweaks
Texto usa var(--m-text) (flipa automático), mas alguns
shadows escuros agressivos precisam ser suavizados. */
html:not(.app-dark) .mc-display {
text-shadow: none;
}
html:not(.app-dark) .mc-chip {
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.06);
}
html:not(.app-dark) .mc-panel {
box-shadow: 0 12px 36px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15, 23, 42, 0.06);
}
</style>
+436
View File
@@ -0,0 +1,436 @@
<script setup>
/*
* MelissaEventoPanel Painel de detalhes do evento selecionado.
* --------------------------------------------------
* Substitui o panel inline que vivia em MelissaLayout (era prone a crash
* por referenciar campos inexistentes no normalize: .valor, .participantes,
* .supervisorNome, .local).
*
* Renderiza apenas campos REAIS do useMelissaEventos.normalizeEvent:
* tipo, status, modalidade, descricao, pacienteNome, patient_id,
* color, label, startH, endH, inicio_em, fim_em
*
* Actions emitidas (parent decide o que fazer):
* - close
* - concluir / faltou / cancelar (mudança de status)
* - remarcar / edit (abre dialog de edição TODO no parent)
* - abrir-prontuario / whatsapp / historico (paciente actions)
*/
import { computed } from 'vue';
const props = defineProps({
evento: { type: Object, required: true },
busy: { type: Boolean, default: false } // bloqueia botões enquanto UPDATE roda
});
const emit = defineEmits([
'close',
'concluir',
'faltou',
'cancelar',
'remarcar',
'edit',
'abrir-prontuario',
'whatsapp',
'historico'
]);
const ev = computed(() => props.evento || {});
const tipoLabel = computed(() => {
const t = String(ev.value.tipo || '').toLowerCase();
if (t === 'sessao') return 'Sessão';
if (t === 'supervisao' || t === 'supervisão') return 'Supervisão';
if (t === 'reuniao' || t === 'reunião') return 'Reunião';
if (t === 'bloqueio') return 'Bloqueio';
return t || 'Evento';
});
const statusLabel = computed(() => {
const s = String(ev.value.status || '').toLowerCase();
if (!s || s === 'agendado') return 'Agendado';
if (s === 'realizado' || s === 'realizada') return 'Realizada';
if (s === 'faltou') return 'Faltou';
if (s === 'cancelado' || s === 'cancelada') return 'Cancelada';
if (s === 'remarcar') return 'A remarcar';
return ev.value.status;
});
const statusSlug = computed(() => {
const s = String(ev.value.status || '').toLowerCase();
if (s === 'realizada') return 'realizado';
if (s === 'cancelada') return 'cancelado';
return s || 'agendado';
});
// Sessão com paciente vinculado mostra o grupo de actions de paciente
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);
const m = Math.round((decimal - h) * 60);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
function duracaoMin() {
const s = ev.value.startH;
const e = ev.value.endH;
if (typeof s !== 'number' || typeof e !== 'number') return null;
return Math.max(0, Math.round((e - s) * 60));
}
function modalidadeIcon(mod) {
const m = String(mod || '').toLowerCase();
if (m === 'online') return 'pi pi-video';
return 'pi pi-map-marker';
}
</script>
<template>
<div class="evento-layer" @click.self="emit('close')">
<div class="evento-panel">
<!-- Header -->
<div class="evento-head">
<div class="evento-head__main">
<div class="evento-pill" :style="{ backgroundColor: ev.color }" />
<div class="min-w-0">
<div class="evento-tipo">{{ tipoLabel }}</div>
<div class="evento-titulo">
{{ isSessaoComPaciente ? ev.pacienteNome : (ev.label || ev.titulo || '—') }}
</div>
</div>
</div>
<button
class="glass-btn evento-close"
v-tooltip.left="'Fechar (Esc)'"
@click="emit('close')"
>
<i class="pi pi-times" />
</button>
</div>
<!-- Conteúdo ( campos reais) -->
<div class="evento-content">
<div class="evento-row">
<i class="pi pi-clock" />
<span>
{{ fmtHora(ev.startH) }} {{ fmtHora(ev.endH) }}
<span v-if="duracaoMin() !== null" class="evento-row__sub">· {{ duracaoMin() }}min</span>
</span>
</div>
<div v-if="ev.modalidade" class="evento-row">
<i :class="modalidadeIcon(ev.modalidade)" />
<span class="capitalize">{{ ev.modalidade }}</span>
</div>
<div class="evento-row">
<i class="pi pi-info-circle" />
<span class="evento-status" :class="`is-${statusSlug}`">{{ statusLabel }}</span>
</div>
<div v-if="ev.descricao" class="evento-desc">
{{ ev.descricao }}
</div>
</div>
<!-- Action bar agrupada por contexto -->
<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 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 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>
</footer>
</div>
</div>
</template>
<style scoped>
/* Camada full-screen com backdrop blur mantém pattern .evento-layer
que vivia inline no MelissaLayout (assim o lift transition no parent
continua funcionando sem alteração). */
.evento-layer {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
padding: 20px;
}
.evento-panel {
width: 100%;
max-width: 480px;
background: var(--m-bg-medium, rgba(20, 20, 20, 0.85));
backdrop-filter: blur(28px) saturate(170%);
-webkit-backdrop-filter: blur(28px) saturate(170%);
border: 1px solid var(--m-border, rgba(255, 255, 255, 0.12));
border-radius: 18px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
padding: 22px 22px 18px;
display: flex;
flex-direction: column;
gap: 18px;
}
/* ─── Header ────────────────────────────────────── */
.evento-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.evento-head__main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.evento-pill {
width: 4px;
height: 38px;
border-radius: 2px;
flex-shrink: 0;
}
.evento-tipo {
color: var(--m-text-muted);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.2em;
font-weight: 600;
}
.evento-titulo {
color: var(--m-text);
font-size: 1.1rem;
font-weight: 500;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 340px;
}
.evento-close {
width: 34px;
height: 34px;
display: grid;
place-items: center;
flex-shrink: 0;
background: var(--m-bg-soft, rgba(255, 255, 255, 0.08));
border: 1px solid var(--m-border, rgba(255, 255, 255, 0.12));
color: var(--m-text);
border-radius: 10px;
cursor: pointer;
transition: background-color 140ms ease;
font-size: 0.85rem;
}
.evento-close:hover { background: var(--m-bg-soft-hover, rgba(255, 255, 255, 0.16)); }
/* ─── Conteúdo ──────────────────────────────────── */
.evento-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.evento-row {
display: flex;
align-items: center;
gap: 10px;
color: var(--m-text);
font-size: 0.88rem;
}
.evento-row > i {
color: var(--m-text-muted);
font-size: 0.95rem;
width: 18px;
text-align: center;
}
.evento-row__sub {
color: var(--m-text-muted);
margin-left: 4px;
font-size: 0.82rem;
}
.evento-status {
padding: 2px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
}
.evento-status.is-realizado {
color: rgb(16, 185, 129);
border-color: rgba(16, 185, 129, 0.35);
background: rgba(16, 185, 129, 0.12);
}
.evento-status.is-faltou {
color: rgb(239, 68, 68);
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.12);
}
.evento-status.is-cancelado {
color: rgb(148, 163, 184);
border-color: rgba(148, 163, 184, 0.35);
background: rgba(148, 163, 184, 0.12);
}
.evento-status.is-remarcar {
color: rgb(245, 158, 11);
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.12);
}
.evento-desc {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 10px 12px;
color: var(--m-text);
font-size: 0.85rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-height: 140px;
overflow-y: auto;
}
/* ─── Action bar ────────────────────────────────── */
.evento-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding-top: 14px;
border-top: 1px solid var(--m-border);
justify-content: space-between;
}
.evento-actions__group {
display: flex;
gap: 6px;
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;
background: transparent;
border: none;
color: var(--m-text);
border-radius: 9px;
cursor: pointer;
font-size: 1rem;
font-family: inherit;
transition: background-color 140ms ease, color 140ms ease, transform 140ms ease;
}
.evento-act:hover:not(:disabled) {
background: var(--m-bg-soft-hover);
transform: translateY(-1px);
}
.evento-act:focus-visible {
outline: 2px solid var(--m-accent);
outline-offset: 2px;
}
.evento-act:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.evento-act--ok:hover:not(:disabled) {
color: rgb(16, 185, 129);
background: rgba(16, 185, 129, 0.15);
}
.evento-act--warn:hover:not(:disabled) {
color: rgb(245, 158, 11);
background: rgba(245, 158, 11, 0.15);
}
.evento-act--danger:hover:not(:disabled) {
color: rgb(239, 68, 68);
background: rgba(239, 68, 68, 0.15);
}
/* Light mode — overlay menos escuro */
html:not(.app-dark) .evento-layer {
background: rgba(0, 0, 0, 0.32);
}
</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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
/*
* useMelissaEventos composables que carregam eventos reais da agenda
* --------------------------------------------------
* Pattern espelhado de `src/features/agenda/pages/AgendaTerapeutaPage.vue`
* (loadMonthSearchRows ~ linha 539).
*
* Exporta dois composables:
* - useMelissaEventosSemana(refDateRef) semana de refDate (ref<Date>)
* - useMelissaEventosHoje() apenas o dia atual
*
* Forma normalizada do evento:
* {
* id, tipo, status, titulo,
* pacienteNome, modalidade, descricao,
* color, label,
* inicio_em, fim_em,
* startH, endH, // decimais (9.5 = 09:30) — usado pelo layout
* dateKey // 'YYYY-MM-DD' pra agrupar por dia
* }
*
* Sem auth/tenant: retorna [] silencioso (UI segue funcional).
*
* NÃO inclui ocorrências virtuais de recorrência (loadAndExpand) pra simplificar
* o preview. Adicionar quando promover Melissa pra produção.
*/
import { ref, watch, onMounted, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// ── Cores por tipo/status (consistente com o resto do Melissa) ──
function pickColor(tipo, status) {
const s = String(status || '').toLowerCase();
if (s === 'realizado' || s === 'realizada') return '#10b981';
if (s === 'faltou') return '#ef4444';
if (s === 'cancelado' || s === 'cancelada') return '#94a3b8';
const t = String(tipo || '').toLowerCase();
if (t === 'supervisao' || t === 'supervisão') return '#a855f7';
if (t === 'reuniao' || t === 'reunião') return '#0ea5e9';
if (t === 'bloqueio') return '#64748b';
return '#6366f1'; // sessao default
}
function isoToDecimalHour(iso) {
const d = new Date(iso);
return d.getHours() + d.getMinutes() / 60;
}
function normalizeEvent(r) {
const pacNome = r.patients?.nome_completo || '';
return {
id: r.id,
tipo: r.tipo || 'sessao',
status: r.status || '',
titulo: r.titulo || '',
patient_id: r.patient_id || null,
pacienteNome: pacNome,
modalidade: r.modalidade || '',
descricao: r.observacoes || '',
color: pickColor(r.tipo, r.status),
label: pacNome || r.titulo || '—',
inicio_em: r.inicio_em,
fim_em: r.fim_em,
startH: isoToDecimalHour(r.inicio_em),
endH: isoToDecimalHour(r.fim_em),
dateKey: String(r.inicio_em || '').slice(0, 10)
};
}
// ── Helper interno: garante uid + tenant + faz a query ──
async function _fetchRange(start, end) {
const tenantStore = useTenantStore();
const { data: userData } = await supabase.auth.getUser();
const userId = userData?.user?.id || null;
if (typeof tenantStore.ensureLoaded === 'function') {
await tenantStore.ensureLoaded();
}
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
if (!userId || !tid) return [];
const { data, error } = await supabase
.from('agenda_eventos')
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
.eq('owner_id', userId)
.is('mirror_of_event_id', null)
.gte('inicio_em', start.toISOString())
.lt('inicio_em', end.toISOString())
.order('inicio_em', { ascending: true });
if (error) throw error;
return (data || []).map(normalizeEvent);
}
// ── Range helpers ──────────────────────────────────────────────
function rangeSemana(refDate) {
const ref = new Date(refDate);
const dow = ref.getDay(); // 0=dom, 1=seg
const diff = dow === 0 ? -6 : 1 - dow;
const segunda = new Date(ref);
segunda.setDate(ref.getDate() + diff);
segunda.setHours(0, 0, 0, 0);
// domingo final → usa dia seguinte 00:00 com `lt` pra incluir tudo até 23:59:59.999
const apósDomingo = new Date(segunda);
apósDomingo.setDate(segunda.getDate() + 7);
return { start: segunda, end: apósDomingo };
}
function rangeHoje() {
const inicio = new Date();
inicio.setHours(0, 0, 0, 0);
const fim = new Date(inicio);
fim.setDate(inicio.getDate() + 1); // amanhã 00:00
return { start: inicio, end: fim };
}
// ── COMPOSABLE 1: semana visível (MelissaAgenda) ──────────────
export function useMelissaEventosSemana(refDateRef) {
const eventos = ref([]);
const loading = ref(false);
const error = ref(null);
async function fetch() {
loading.value = true;
error.value = null;
try {
const { start, end } = rangeSemana(refDateRef.value);
eventos.value = await _fetchRange(start, end);
} catch (e) {
error.value = e?.message || 'Erro ao carregar agenda';
eventos.value = [];
// eslint-disable-next-line no-console
console.warn('[useMelissaEventosSemana]', e);
} finally {
loading.value = false;
}
}
onMounted(fetch);
watch(refDateRef, fetch);
// Helper computado: agrupa por dateKey ('YYYY-MM-DD')
const eventosPorDia = computed(() => {
const map = {};
for (const ev of eventos.value) {
(map[ev.dateKey] ||= []).push(ev);
}
return map;
});
return { eventos, eventosPorDia, loading, error, refetch: fetch };
}
// ── COMPOSABLE 3: range arbitrário (FullCalendar passa via datesSet) ──
// Usado pelo MelissaAgenda — refetcha sempre que start/end mudam (mudança
// de view, navegação prev/next/today). Cobre Day/Week/Month/List sem custo.
export function useMelissaEventosRange(startRef, endRef) {
const eventos = ref([]);
const loading = ref(false);
const error = ref(null);
async function fetch() {
const s = startRef.value;
const e = endRef.value;
if (!s || !e) { eventos.value = []; return; }
loading.value = true;
error.value = null;
try {
eventos.value = await _fetchRange(new Date(s), new Date(e));
} catch (err) {
error.value = err?.message || 'Erro ao carregar agenda';
eventos.value = [];
// eslint-disable-next-line no-console
console.warn('[useMelissaEventosRange]', err);
} finally {
loading.value = false;
}
}
onMounted(fetch);
watch([startRef, endRef], fetch);
return { eventos, loading, error, refetch: fetch };
}
// ── COMPOSABLE 2: apenas hoje (MelissaLayout) ──────────────────
export function useMelissaEventosHoje() {
const eventos = ref([]);
const loading = ref(false);
const error = ref(null);
async function fetch() {
loading.value = true;
error.value = null;
try {
const { start, end } = rangeHoje();
eventos.value = await _fetchRange(start, end);
} catch (e) {
error.value = e?.message || 'Erro ao carregar agenda';
eventos.value = [];
// eslint-disable-next-line no-console
console.warn('[useMelissaEventosHoje]', e);
} finally {
loading.value = false;
}
}
onMounted(fetch);
return { eventos, loading, error, refetch: fetch };
}
@@ -0,0 +1,117 @@
/*
* useMelissaPacientes composable que carrega pacientes ativos do terapeuta
* --------------------------------------------------
* Pattern espelhado de `src/features/patients/PatientsListPage.vue`:
* - withOwnerFilter (apenas os pacientes do owner_id = uid do user logado)
* - withTenantFilter (defesa em profundidade RLS cobre, mas blindamos no client)
* - normalizeStatus client-side (DB pode ter 'ativo'/'active'/null/etc.)
*
* Sem auth (sem uid ou tenant), retorna vazio sem erro uso em /preview/melissa
* permite a página renderizar mesmo sem session.
*
* Forma normalizada do paciente:
* { id, nome, email, telefone, avatar_url, status, last_attended_at, created_at }
*
* Quando precisar fora do Melissa: promover pra src/composables/.
*/
import { ref, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// Mesma lógica da PatientsListPage.vue — DB pode ter valores variados
function normalizeStatus(s) {
const v = String(s || '').toLowerCase().trim();
if (!v) return 'Ativo';
if (v === 'active' || v === 'ativo') return 'Ativo';
if (v === 'inactive' || v === 'inativo') return 'Inativo';
return v.charAt(0).toUpperCase() + v.slice(1);
}
/**
* @param {object} [opts]
* @param {boolean} [opts.onlyActive=true]
* true (default, legado) = retorna status='Ativo' (uso original: cards do
* resumo, cronômetro, eventos hoje faz sentido com ativos).
* false = retorna todos (Ativo + Inativo + Arquivado), pra páginas que
* precisam mostrar/filtrar por status (ex.: MelissaPacientes).
*/
export function useMelissaPacientes(opts = {}) {
const onlyActive = opts.onlyActive !== false; // default true (compat)
const tenantStore = useTenantStore();
const pacientes = ref([]);
const loading = ref(false);
const error = ref(null);
const uid = ref(null);
async function ensureUid() {
if (uid.value) 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();
// 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 (typeof tenantStore.ensureLoaded === 'function') {
await tenantStore.ensureLoaded();
}
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
if (!userId || !tid) {
pacientes.value = [];
return;
}
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;
} catch (e) {
error.value = e?.message || 'Erro ao carregar pacientes';
pacientes.value = [];
// eslint-disable-next-line no-console
console.warn('[useMelissaPacientes]', e);
} finally {
loading.value = false;
}
}
onMounted(fetchPacientes);
return {
pacientes,
loading,
error,
refetch: fetchPacientes
};
}
+116
View File
@@ -0,0 +1,116 @@
/*
* melissaToques geração de toques de término via Web Audio API
* --------------------------------------------------------------
* Não usa arquivos de áudio externos. Tudo gerado em runtime com
* osciladores. Mantém self-hosted, leve e sem build de assets.
*
* Uso:
* import { TOQUES, playToque } from './melissaToques';
* playToque('sino');
*/
export const TOQUES = [
{ id: 'sino', label: 'Sino' },
{ id: 'acorde', label: 'Acorde' },
{ id: 'tic-tac', label: 'Tic-tac' },
{ id: 'suave', label: 'Suave' },
{ id: 'nenhum', label: 'Nenhum (silencioso)' }
];
function getCtx() {
const Ctx = window.AudioContext || window.webkitAudioContext;
return Ctx ? new Ctx() : null;
}
// Sino: clássico ding com decaimento longo
function playSino(ctx) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.value = 880; // A5
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.25, ctx.currentTime + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.6);
osc.start();
osc.stop(ctx.currentTime + 1.7);
setTimeout(() => ctx.close(), 1900);
}
// Acorde: C maior arpejado (C5 E5 G5)
function playAcorde(ctx) {
const freqs = [523.25, 659.25, 783.99];
freqs.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.value = freq;
const start = ctx.currentTime + i * 0.07;
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.18, start + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, start + 1.5);
osc.start(start);
osc.stop(start + 1.6);
});
setTimeout(() => ctx.close(), 2100);
}
// Tic-tac: dois cliques curtos e secos
function playTicTac(ctx) {
[0, 0.18].forEach((delay) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'square';
osc.frequency.value = 1500;
const start = ctx.currentTime + delay;
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.12, start + 0.005);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.06);
osc.start(start);
osc.stop(start + 0.07);
});
setTimeout(() => ctx.close(), 500);
}
// Suave: fade-in/fade-out lento, quase respiração
function playSuave(ctx) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.value = 660; // E5
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.18, ctx.currentTime + 0.5);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 2.5);
osc.start();
osc.stop(ctx.currentTime + 2.6);
setTimeout(() => ctx.close(), 2800);
}
const PLAYERS = {
sino: playSino,
acorde: playAcorde,
'tic-tac': playTicTac,
suave: playSuave,
nenhum: () => {}
};
export function playToque(id) {
if (id === 'nenhum') return;
const fn = PLAYERS[id] || PLAYERS.sino;
try {
const ctx = getCtx();
if (!ctx) return;
// Web Audio em alguns browsers começa suspended até primeira interação
if (ctx.state === 'suspended') ctx.resume?.();
fn(ctx);
} catch {
// Falha silenciosa: não há nada útil a fazer aqui
}
}
+11
View File
@@ -23,6 +23,17 @@ export default {
component: () => import('@/views/pages/Landing.vue') component: () => import('@/views/pages/Landing.vue')
}, },
// Sandbox do layout Melissa (Direção B — lockscreen-style)
// Standalone, sem auth, sem AppLayout. Promovido de /preview/dashboard-win11.
// Param `:secao?` opcional reflete a seção aberta na URL (agenda,
// pacientes, conversas, etc.) — permite deep-link, back/forward,
// refresh preservando estado.
{
path: 'preview/melissa/:secao?',
name: 'PreviewMelissa',
component: () => import('@/layout/melissa/MelissaLayout.vue')
},
// 404 // 404
{ {
path: 'pages/notfound', path: 'pages/notfound',
+147 -21
View File
@@ -14,22 +14,125 @@ import { defineStore } from 'pinia';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
// Mapeia códigos de erro do edge function pra mensagens amigáveis ao usuário. // Classifica o erro do envio em { message, hint, action } pra UI mostrar
// O edge function retorna { ok: false, error: '<code>', message: '<human>' } — priorizamos message. // um banner persistente no chat (não só toast). Diferente do friendly antigo
function friendlySendError(code, providedMessage) { // que retornava só string, esse devolve estrutura — assim o componente pode
if (providedMessage) return providedMessage; // renderizar título + dica explicativa + botão de ação contextual.
//
// Inputs:
// code — string vinda de error.error / data.error
// providedMessage — string vinda de error.message / data.message
// status — HTTP status (error.context?.status) — captura 502/503/504
//
// O edge function retorna { ok: false, error: '<code>', message: '<human>' }.
function classifySendError(code, providedMessage, status) {
const c = String(code || '').toLowerCase(); const c = String(code || '').toLowerCase();
if (c === 'insufficient_credits') return 'Saldo de créditos insuficiente. Compre um pacote em Configurações → Créditos WhatsApp.'; const hasMsg = !!providedMessage;
if (c.startsWith('twilio_send_failed')) return 'Não conseguimos enviar pelo WhatsApp oficial. Verifique se o canal está conectado em Configurações → WhatsApp.'; const httpStatus = Number(status) || null;
if (c.includes('canal whatsapp nao configurado') || c.includes('canal whatsapp inativo')) return 'Nenhum canal WhatsApp ativo. Configure em Configurações → WhatsApp.';
if (c.includes('credenciais evolution incompletas')) return 'As credenciais do WhatsApp Pessoal estão incompletas. Acesse Configurações → WhatsApp.'; // ── 5xx (gateway/server offline): caso típico de Evolution/Twilio fora ──
if (c.includes('twilio credenciais incompletas')) return 'As credenciais do WhatsApp Oficial estão incompletas. Contate o suporte.'; // Quando a edge function de envio retorna 502/503/504, normalmente o
if (c.startsWith('evolution retornou')) return 'O servidor do WhatsApp Pessoal não respondeu. Verifique se o celular está conectado.'; // provedor (Evolution self-hosted ou Twilio) está fora ou inalcançável.
if (c === 'auth') return 'Sua sessão expirou. Faça login novamente.'; if (httpStatus === 502 || httpStatus === 503 || httpStatus === 504) {
if (c === 'forbidden') return 'Você não tem permissão pra enviar por este canal.'; return {
if (c === 'edge function returned a non-2xx status code') return 'O servidor recusou o envio. Tente novamente ou contate o suporte.'; code: c || `http_${httpStatus}`,
// Fallback: retorna o código original se nada bateu status: httpStatus,
return code || 'Falha ao enviar. Tente novamente.'; message: 'Servidor de WhatsApp temporariamente fora do ar.',
hint: 'Os envios costumam voltar em poucos minutos. Se você ainda não configurou ou contratou o serviço, verifique abaixo.',
action: { label: 'Configurar WhatsApp', url: '/configuracoes/whatsapp' },
secondaryAction: { label: 'Comprar créditos', url: '/configuracoes/creditos-whatsapp' }
};
}
if (c === 'insufficient_credits') {
return {
code: c, status: httpStatus,
message: 'Saldo de créditos insuficiente.',
hint: 'Você precisa de créditos pra enviar mensagens pelo WhatsApp Oficial.',
action: { label: 'Comprar créditos', url: '/configuracoes/creditos-whatsapp' }
};
}
if (c.includes('canal whatsapp nao configurado') || c.includes('canal whatsapp inativo')) {
return {
code: c, status: httpStatus,
message: 'Nenhum canal WhatsApp ativo.',
hint: 'Você ainda não configurou um número. Conecte um WhatsApp Pessoal (gratuito) ou contrate o WhatsApp Oficial.',
action: { label: 'Configurar agora', url: '/configuracoes/whatsapp' }
};
}
if (c.includes('credenciais evolution incompletas')) {
return {
code: c, status: httpStatus,
message: 'WhatsApp Pessoal está com credenciais incompletas.',
hint: 'Termine a conexão do seu número (escaneie o QR Code novamente, se necessário).',
action: { label: 'Configurações WhatsApp', url: '/configuracoes/whatsapp' }
};
}
if (c.includes('twilio credenciais incompletas')) {
return {
code: c, status: httpStatus,
message: 'WhatsApp Oficial está com credenciais incompletas.',
hint: 'Contate o suporte pra finalizar a configuração da sua conta Twilio.',
action: null
};
}
if (c.startsWith('evolution retornou')) {
return {
code: c, status: httpStatus,
message: 'WhatsApp Pessoal não respondeu.',
hint: 'Verifique se o celular conectado está com internet e a sessão do WhatsApp ativa.',
action: { label: 'Ver status', url: '/configuracoes/whatsapp' }
};
}
if (c.startsWith('twilio_send_failed')) {
return {
code: c, status: httpStatus,
message: 'WhatsApp Oficial recusou o envio.',
hint: 'Verifique se o canal está conectado e se o número de destino aceita mensagens template fora da janela de 24h.',
action: { label: 'Configurações WhatsApp', url: '/configuracoes/whatsapp' }
};
}
if (c === 'auth') {
return {
code: c, status: httpStatus,
message: 'Sua sessão expirou.',
hint: 'Faça login novamente pra continuar.',
action: null
};
}
if (c === 'forbidden') {
return {
code: c, status: httpStatus,
message: 'Você não tem permissão pra enviar por este canal.',
hint: 'Verifique seu plano ou contate o admin do tenant.',
action: null
};
}
if (c === 'edge function returned a non-2xx status code') {
return {
code: c, status: httpStatus,
message: 'O servidor recusou o envio.',
hint: 'Tente novamente em instantes. Se persistir, verifique configuração ou contate o suporte.',
action: { label: 'Configurar WhatsApp', url: '/configuracoes/whatsapp' }
};
}
// Fallback genérico — usa providedMessage se houver, senão o código
return {
code: c || 'unknown',
status: httpStatus,
message: hasMsg ? providedMessage : 'Não foi possível enviar a mensagem.',
hint: 'Tente novamente em alguns instantes. Se persistir, verifique sua conexão e a configuração do WhatsApp.',
action: { label: 'Configurações WhatsApp', url: '/configuracoes/whatsapp' }
};
} }
export const useConversationDrawerStore = defineStore('conversationDrawer', { export const useConversationDrawerStore = defineStore('conversationDrawer', {
@@ -39,6 +142,9 @@ export const useConversationDrawerStore = defineStore('conversationDrawer', {
messages: [], messages: [],
loading: false, loading: false,
sending: false, sending: false,
// Último erro de envio (estruturado, com hint + action) — fica no chat
// como banner persistente até user enviar com sucesso ou dispensar.
lastSendError: null, // { code, status, message, hint, action: { label, url }, secondaryAction? }
error: null, error: null,
_realtimeChannel: null, _realtimeChannel: null,
// cache compartilhado // cache compartilhado
@@ -61,6 +167,8 @@ export const useConversationDrawerStore = defineStore('conversationDrawer', {
if (!thread) return; if (!thread) return;
this.thread = { ...thread }; this.thread = { ...thread };
this.isOpen = true; this.isOpen = true;
// Erros de envio são por-conversa — não vazam pra próxima
this.lastSendError = null;
await this.loadMessages(); await this.loadMessages();
this._ensureTenantName(); this._ensureTenantName();
this._subscribeRealtime(); this._subscribeRealtime();
@@ -216,7 +324,11 @@ export const useConversationDrawerStore = defineStore('conversationDrawer', {
async sendMessage(text) { async sendMessage(text) {
const cleanText = String(text || '').trim(); const cleanText = String(text || '').trim();
if (!cleanText || this.sending) return { ok: false, error: 'Mensagem vazia' }; if (!cleanText || this.sending) return { ok: false, error: 'Mensagem vazia' };
if (!this.thread?.contact_number) return { ok: false, error: 'Conversa sem número de contato' }; if (!this.thread?.contact_number) {
const cls = { code: 'no_contact', status: null, message: 'Conversa sem número de contato', hint: 'Vincule um paciente com telefone à conversa antes de enviar.', action: null };
this.lastSendError = cls;
return { ok: false, error: cls.message, classification: cls };
}
const tenantStore = useTenantStore(); const tenantStore = useTenantStore();
this.sending = true; this.sending = true;
@@ -230,27 +342,41 @@ export const useConversationDrawerStore = defineStore('conversationDrawer', {
} }
}); });
// Erro HTTP (não-2xx) — extrai body da resposta pra mostrar msg amigável // Erro HTTP (não-2xx) — extrai status + body da resposta
if (error) { if (error) {
const status = error.context?.status || null;
let body = null; let body = null;
try { try {
body = await error.context?.json?.(); body = await error.context?.json?.();
} catch { /* noop */ } } catch { /* noop — pode não ter body JSON em 502 */ }
return { ok: false, error: friendlySendError(body?.error, body?.message) }; const cls = classifySendError(body?.error, body?.message, status);
this.lastSendError = cls;
return { ok: false, error: cls.message, classification: cls };
} }
if (!data?.ok) { if (!data?.ok) {
return { ok: false, error: friendlySendError(data?.error, data?.message) }; const cls = classifySendError(data?.error, data?.message, null);
this.lastSendError = cls;
return { ok: false, error: cls.message, classification: cls };
} }
// Sucesso — limpa banner de erro anterior
this.lastSendError = null;
this.thread.kanban_status = 'awaiting_patient'; this.thread.kanban_status = 'awaiting_patient';
return { ok: true, data }; return { ok: true, data };
} catch (err) { } catch (err) {
return { ok: false, error: friendlySendError(err?.message || String(err)) }; // Provavelmente erro de rede (fetch falhou). Sem `error.context`.
const cls = classifySendError(err?.message || String(err), null, null);
this.lastSendError = cls;
return { ok: false, error: cls.message, classification: cls };
} finally { } finally {
this.sending = false; this.sending = false;
} }
}, },
dismissSendError() {
this.lastSendError = null;
},
async setKanbanStatus(status) { async setKanbanStatus(status) {
if (!['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'].includes(status)) return; if (!['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'].includes(status)) return;
if (!this.thread) return; if (!this.thread) return;
+118 -2
View File
@@ -1366,7 +1366,7 @@ onBeforeUnmount(() => {
<div class="h-px bg-[var(--surface-border)] my-5" /> <div class="h-px bg-[var(--surface-border)] my-5" />
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Layout 1: Clássico --> <!-- Layout 1: Clássico -->
<button <button
class="lv-card" class="lv-card"
@@ -1423,12 +1423,44 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
</button> </button>
<!-- Layout 3: Melissa (Direção B) Em construção -->
<button
class="lv-card lv-card--wip"
:class="{ 'lv-card--active': layoutConfig.variant === 'melissa' }"
@click="
setVariant('melissa');
markDirty();
"
>
<span class="lv-card__badge">Em construção</span>
<div class="lv-card__preview lv-card__preview--melissa">
<div class="lv-pm__clock">12<span class="lv-pm__sep">:</span>30</div>
<div class="lv-pm__cards">
<div class="lv-pm__card" />
<div class="lv-pm__card" />
<div class="lv-pm__card" />
</div>
<div class="lv-pm__psi">ψ</div>
</div>
<div class="lv-card__foot">
<div class="lv-card__radio">
<div v-if="layoutConfig.variant === 'melissa'" class="lv-card__dot" />
</div>
<div>
<div class="lv-card__name">Melissa</div>
<div class="lv-card__sub">Tela cheia estilo Win11 lockscreen</div>
</div>
</div>
</button>
</div> </div>
<div class="mt-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3"> <div class="mt-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 shrink-0" /> <i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 shrink-0" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed"> <div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
O layout Rail exibe ícones no canto esquerdo. Ao clicar em um ícone, o painel lateral expande com os itens de navegação. Disponível apenas no desktop. <strong>Rail:</strong> ícones no canto esquerdo + painel expansível. Disponível apenas no desktop.<br />
<strong>Melissa</strong> <span class="text-[0.85rem] uppercase tracking-wider font-semibold" style="color: var(--primary-color)"> em construção:</span> layout fullscreen com resumo do dia, busca rápida e cronômetro de sessão. Selecionar aqui salva sua preferência, mas a navegação completa ainda não está integrada. Acesse o preview em
<a href="/preview/melissa" target="_blank" rel="noopener" class="underline hover:text-[var(--text-color)]">/preview/melissa</a>.
</div> </div>
</div> </div>
</div> </div>
@@ -1702,6 +1734,90 @@ onBeforeUnmount(() => {
margin-top: 1px; margin-top: 1px;
} }
/* ─── Card Melissa (Direção B) — preview Win11 lockscreen ──── */
.lv-card--wip {
/* Listras suaves no fundo do card pra reforçar visualmente "em obras",
sem prejudicar o preview no topo. */
background-image: repeating-linear-gradient(
135deg,
transparent 0,
transparent 12px,
color-mix(in srgb, var(--primary-color) 4%, transparent) 12px,
color-mix(in srgb, var(--primary-color) 4%, transparent) 14px
);
}
.lv-card__badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 3px 9px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary-color) 22%, transparent);
color: var(--primary-color);
border: 1px solid color-mix(in srgb, var(--primary-color) 40%, transparent);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.lv-card__preview--melissa {
position: relative;
background:
radial-gradient(circle at 70% 30%, color-mix(in srgb, var(--primary-color) 35%, transparent) 0%, transparent 55%),
radial-gradient(circle at 25% 75%, color-mix(in srgb, var(--primary-color) 22%, transparent) 0%, transparent 50%),
linear-gradient(135deg, var(--surface-900, #0f172a) 0%, color-mix(in srgb, var(--primary-color) 50%, var(--surface-900, #0f172a)) 50%, var(--surface-900, #0f172a) 100%);
padding: 0;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 4px;
}
.lv-pm__clock {
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 1.4rem;
font-weight: 200;
color: rgba(255, 255, 255, 0.95);
letter-spacing: -0.04em;
line-height: 1;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.lv-pm__sep {
opacity: 0.6;
}
.lv-pm__cards {
display: flex;
gap: 4px;
margin-top: 2px;
}
.lv-pm__card {
width: 14px;
height: 10px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(2px);
}
.lv-pm__psi {
position: absolute;
bottom: 6px;
left: 8px;
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.25);
color: rgba(255, 255, 255, 0.95);
font-family: 'Instrument Serif', Georgia, serif;
font-size: 0.7rem;
font-style: italic;
display: grid;
place-items: center;
line-height: 1;
}
/* ─── Animation ─────────────────────────────────────────── */ /* ─── Animation ─────────────────────────────────────────── */
@keyframes prof-fadeUp { @keyframes prof-fadeUp {
from { from {
@@ -207,10 +207,8 @@ async function savePartialProgress() {
} catch { /* silencioso — autosave não é crítico */ } } catch { /* silencioso — autosave não é crítico */ }
} }
watch( // O watch que dispara `scheduleProgressSave` foi movido pra depois da
() => [form.nome_completo, form.telefone, form.email_principal, form.onde_nos_conheceu], // declaração de `form` (linha ~319) referência aqui violava TDZ.
scheduleProgressSave
);
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Helpers // Helpers
@@ -319,6 +317,12 @@ function resetForm() {
const form = reactive(resetForm()); const form = reactive(resetForm());
const consent = ref(false); const consent = ref(false);
// Autosave de progresso: precisa de `form` declarado antes (TDZ).
watch(
() => [form.nome_completo, form.telefone, form.email_principal, form.onde_nos_conheceu],
scheduleProgressSave
);
const errors = reactive({ const errors = reactive({
nome_completo: '', email_principal: '', email_alternativo: '', telefone: '', consentimento: '' nome_completo: '', email_principal: '', email_alternativo: '', telefone: '', consentimento: ''
}); });
+6 -6
View File
@@ -581,15 +581,15 @@ async function doWaAdjust() {
function submitWaTopup() { function submitWaTopup() {
const t = waTopup.value; const t = waTopup.value;
if (!t.tenantId) { toast.add({ severity: 'warn', summary: 'Selecione o tenant', life: 2500 }); return; } if (!t.tenantId) { toast.add({ severity: 'error', summary: 'Selecione o tenant', life: 2500 }); return; }
const amt = Math.round(Number(t.amount) || 0); const amt = Math.round(Number(t.amount) || 0);
if (amt < 1) { toast.add({ severity: 'warn', summary: 'Valor deve ser >= 1', life: 2500 }); return; } if (amt < 1) { toast.add({ severity: 'error', summary: 'Valor deve ser >= 1', life: 2500 }); return; }
if (amt > WA_ADJUST_MAX) { toast.add({ severity: 'warn', summary: `Máximo ${WA_ADJUST_MAX} por operação`, life: 3000 }); return; } if (amt > WA_ADJUST_MAX) { toast.add({ severity: 'error', summary: `Máximo ${WA_ADJUST_MAX} por operação`, life: 3000 }); return; }
if (t.mode === 'remove') { if (t.mode === 'remove') {
const max = waMaxAmountForMode(); const max = waMaxAmountForMode();
if (max <= 0) { toast.add({ severity: 'warn', summary: 'Nada removível', detail: 'Este tenant não tem créditos de cortesia disponíveis pra remoção.', life: 4000 }); return; } if (max <= 0) { toast.add({ severity: 'error', summary: 'Nada removível', detail: 'Este tenant não tem créditos de cortesia disponíveis pra remoção.', life: 4000 }); return; }
if (amt > max) { toast.add({ severity: 'warn', summary: `Máximo removível: ${max}`, life: 3000 }); return; } if (amt > max) { toast.add({ severity: 'error', summary: `Máximo removível: ${max}`, life: 3000 }); return; }
const msgItems = [ const msgItems = [
`Vai subtrair <strong>${amt} créditos</strong> de <strong>${escapeHtml(tenantName(t.tenantId))}</strong> (pool cortesia).`, `Vai subtrair <strong>${amt} créditos</strong> de <strong>${escapeHtml(tenantName(t.tenantId))}</strong> (pool cortesia).`,
@@ -1045,7 +1045,7 @@ onMounted(() => {
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]"> <label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">
{{ waTopup.mode === 'remove' ? 'Créditos a remover' : 'Créditos a adicionar' }} {{ waTopup.mode === 'remove' ? 'Créditos a remover' : 'Créditos a adicionar' }}
</label> </label>
<InputNumber v-model="waTopup.amount" :min="1" :max="WA_ADJUST_MAX" class="w-full" fluid /> <InputNumber v-model="waTopup.amount" :min="1" class="w-full" fluid />
<small class="text-[var(--text-color-secondary)]"> <small class="text-[var(--text-color-secondary)]">
Máx. {{ WA_ADJUST_MAX }} por operação<span v-if="waTopup.mode === 'remove' && waRemovable"> · removível agora: {{ Math.min(WA_ADJUST_MAX, waRemovable.removable) }}</span>. Máx. {{ WA_ADJUST_MAX }} por operação<span v-if="waTopup.mode === 'remove' && waRemovable"> · removível agora: {{ Math.min(WA_ADJUST_MAX, waRemovable.removable) }}</span>.
</small> </small>