Compare commits
11 Commits
463d71ce44
...
629e7ce18e
| Author | SHA1 | Date | |
|---|---|---|---|
| 629e7ce18e | |||
| 06bce11e1c | |||
| 7b67bd083a | |||
| dac3198873 | |||
| a57cf27a6a | |||
| ffcb8b17f9 | |||
| ff3695fbb1 | |||
| 6a92735366 | |||
| f2b15ce0f7 | |||
| 1bcb969f72 | |||
| ab103ec88b |
@@ -27,6 +27,9 @@ evolution-api/
|
||||
# Backups locais do banco — não comitar (regeneráveis via db.cjs backup)
|
||||
database-novo/backups/
|
||||
|
||||
# Rascunhos de design locais (Melissa Direção A, etc)
|
||||
layout-scratchs/
|
||||
|
||||
# Outputs do Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
+200
-360
@@ -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.**
|
||||
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)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **🔴 Críticos** | **0** ✅ |
|
||||
| **🟠 Altos** | **0** ✅ |
|
||||
| Vitest | 208/208 |
|
||||
| SQL integration | 33/33 |
|
||||
| E2E (Playwright) | 5/5 |
|
||||
| Migrations totais | **47** (36 → 47) |
|
||||
| Edge functions | **25** (20 → 25) |
|
||||
| Cron jobs ativos | 2 (heartbeat 2min + SLA 5min) |
|
||||
| Commits hoje | **18** (de `f76a2e3` a `f1c97ee`) |
|
||||
Implementado mas **ainda não testado em browser**. Working tree:
|
||||
|
||||
```
|
||||
M src/layout/melissa/MelissaAgenda.vue
|
||||
M src/layout/melissa/MelissaLayout.vue
|
||||
M src/layout/melissa/composables/useMelissaEventos.js
|
||||
?? src/layout/melissa/MelissaEventoPanel.vue
|
||||
```
|
||||
|
||||
**O que mudou:**
|
||||
- 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
|
||||
- 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).
|
||||
Working tree limpo exceto pelo B3 não testado (ver acima).
|
||||
|
||||
---
|
||||
|
||||
## 🧪 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
|
||||
# Reiniciar Supabase functions serve pra carregar 5 edges novas/alteradas
|
||||
# Terminal 1 — functions
|
||||
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
|
||||
SELECT cron.schedule('convert-abandoned-intakes-every-15min', '*/15 * * * *', $$
|
||||
SELECT net.http_post(
|
||||
url := current_setting('app.settings.supabase_url') || '/functions/v1/convert-abandoned-intakes',
|
||||
headers := jsonb_build_object('Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'), 'Content-Type', 'application/json'),
|
||||
body := '{}'::jsonb
|
||||
-- via docker exec supabase_db_agenciapsi-primesakai psql -U postgres -d postgres -c "..."
|
||||
INSERT INTO agenda_eventos (owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, observacoes)
|
||||
VALUES (
|
||||
'aaaaaaaa-0002-0002-0002-000000000002',
|
||||
'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
|
||||
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';
|
||||
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 →".
|
||||
|
||||
---
|
||||
|
||||
### 9. Analytics 1ª resposta (~3 min)
|
||||
|
||||
`/admin` (ClinicDashboard) → card "Tempo de 1ª resposta" com sparkline + ranking terapeutas.
|
||||
|
||||
`/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
|
||||
**2. Commitar B3 após testar:**
|
||||
```
|
||||
- **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`
|
||||
Melissa: MelissaEventoPanel novo + bug fix latente do panel inline
|
||||
|
||||
- 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 + ...
|
||||
|
||||
---
|
||||
|
||||
## 📚 Memória persistente (carregada automaticamente)
|
||||
## 📚 Tracking persistente
|
||||
|
||||
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
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## 📌 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,
|
||||
updated_at timestamp with time zone DEFAULT now() 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_theme_mode_check CHECK ((theme_mode = ANY (ARRAY['light'::text, 'dark'::text])))
|
||||
);
|
||||
|
||||
Vendored
+4
-4
@@ -105,7 +105,7 @@ export async function bootstrapUserSettings({
|
||||
const _lsV = (() => {
|
||||
try {
|
||||
const v = localStorage.getItem('layout_variant');
|
||||
return v === 'rail' || v === 'classic' ? v : null;
|
||||
return v === 'rail' || v === 'classic' || v === 'melissa' ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -114,9 +114,9 @@ export async function bootstrapUserSettings({
|
||||
if (_lsV !== null) {
|
||||
// localStorage já tem valor → aplica ele (garante coerência com layoutConfig)
|
||||
if (_lsV !== (settings.layout_variant ?? _lsV)) setVariant(_lsV);
|
||||
} else if (settings.layout_variant === 'rail') {
|
||||
// localStorage vazio + banco tem 'rail' → aplica e grava no localStorage
|
||||
setVariant('rail');
|
||||
} else if (settings.layout_variant === 'rail' || settings.layout_variant === 'melissa') {
|
||||
// localStorage vazio + banco tem 'rail'/'melissa' → aplica e grava no localStorage
|
||||
setVariant(settings.layout_variant);
|
||||
}
|
||||
// localStorage vazio + banco tem 'classic' → mantém padrão 'rail' (não aplica)
|
||||
|
||||
|
||||
@@ -279,13 +279,18 @@
|
||||
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 th {
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.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,
|
||||
|
||||
@@ -1103,6 +1103,45 @@ function insertEmoji(emoji) {
|
||||
</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 -->
|
||||
<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">
|
||||
|
||||
@@ -20,10 +20,14 @@ import { computed, reactive, ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
const BREAKPOINT_XL = 1280;
|
||||
|
||||
// ── 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() {
|
||||
try {
|
||||
const v = localStorage.getItem('layout_variant');
|
||||
if (v === 'rail' || v === 'classic') return v;
|
||||
if (v === 'rail' || v === 'classic' || v === 'melissa') return v;
|
||||
} catch {}
|
||||
return 'rail';
|
||||
}
|
||||
@@ -199,7 +203,7 @@ export function useLayout() {
|
||||
};
|
||||
|
||||
const setVariant = (v, { fromUser = true } = {}) => {
|
||||
if (v !== 'classic' && v !== 'rail') return;
|
||||
if (v !== 'classic' && v !== 'rail' && v !== 'melissa') return;
|
||||
layoutConfig.variant = v;
|
||||
try {
|
||||
localStorage.setItem('layout_variant', v);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
só 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" lá. */
|
||||
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 já 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>
|
||||
@@ -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 (só 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 — só 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 — só 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 só status='Ativo' (uso original: cards do
|
||||
* resumo, cronômetro, eventos hoje — só 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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,17 @@ export default {
|
||||
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
|
||||
{
|
||||
path: 'pages/notfound',
|
||||
|
||||
@@ -14,22 +14,125 @@ import { defineStore } from 'pinia';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// Mapeia códigos de erro do edge function pra mensagens amigáveis ao usuário.
|
||||
// O edge function retorna { ok: false, error: '<code>', message: '<human>' } — priorizamos message.
|
||||
function friendlySendError(code, providedMessage) {
|
||||
if (providedMessage) return providedMessage;
|
||||
// Classifica o erro do envio em { message, hint, action } pra UI mostrar
|
||||
// um banner persistente no chat (não só toast). Diferente do friendly antigo
|
||||
// que retornava só string, esse devolve estrutura — assim o componente pode
|
||||
// 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();
|
||||
if (c === 'insufficient_credits') return 'Saldo de créditos insuficiente. Compre um pacote em Configurações → Créditos WhatsApp.';
|
||||
if (c.startsWith('twilio_send_failed')) return 'Não conseguimos enviar pelo WhatsApp oficial. Verifique se o canal está conectado em Configurações → WhatsApp.';
|
||||
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.';
|
||||
if (c.includes('twilio credenciais incompletas')) return 'As credenciais do WhatsApp Oficial estão incompletas. Contate o suporte.';
|
||||
if (c.startsWith('evolution retornou')) return 'O servidor do WhatsApp Pessoal não respondeu. Verifique se o celular está conectado.';
|
||||
if (c === 'auth') return 'Sua sessão expirou. Faça login novamente.';
|
||||
if (c === 'forbidden') return 'Você não tem permissão pra enviar por este canal.';
|
||||
if (c === 'edge function returned a non-2xx status code') return 'O servidor recusou o envio. Tente novamente ou contate o suporte.';
|
||||
// Fallback: retorna o código original se nada bateu
|
||||
return code || 'Falha ao enviar. Tente novamente.';
|
||||
const hasMsg = !!providedMessage;
|
||||
const httpStatus = Number(status) || null;
|
||||
|
||||
// ── 5xx (gateway/server offline): caso típico de Evolution/Twilio fora ──
|
||||
// Quando a edge function de envio retorna 502/503/504, normalmente o
|
||||
// provedor (Evolution self-hosted ou Twilio) está fora ou inalcançável.
|
||||
if (httpStatus === 502 || httpStatus === 503 || httpStatus === 504) {
|
||||
return {
|
||||
code: c || `http_${httpStatus}`,
|
||||
status: httpStatus,
|
||||
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', {
|
||||
@@ -39,6 +142,9 @@ export const useConversationDrawerStore = defineStore('conversationDrawer', {
|
||||
messages: [],
|
||||
loading: 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,
|
||||
_realtimeChannel: null,
|
||||
// cache compartilhado
|
||||
@@ -61,6 +167,8 @@ export const useConversationDrawerStore = defineStore('conversationDrawer', {
|
||||
if (!thread) return;
|
||||
this.thread = { ...thread };
|
||||
this.isOpen = true;
|
||||
// Erros de envio são por-conversa — não vazam pra próxima
|
||||
this.lastSendError = null;
|
||||
await this.loadMessages();
|
||||
this._ensureTenantName();
|
||||
this._subscribeRealtime();
|
||||
@@ -216,7 +324,11 @@ export const useConversationDrawerStore = defineStore('conversationDrawer', {
|
||||
async sendMessage(text) {
|
||||
const cleanText = String(text || '').trim();
|
||||
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();
|
||||
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) {
|
||||
const status = error.context?.status || null;
|
||||
let body = null;
|
||||
try {
|
||||
body = await error.context?.json?.();
|
||||
} catch { /* noop */ }
|
||||
return { ok: false, error: friendlySendError(body?.error, body?.message) };
|
||||
} catch { /* noop — pode não ter body JSON em 502 */ }
|
||||
const cls = classifySendError(body?.error, body?.message, status);
|
||||
this.lastSendError = cls;
|
||||
return { ok: false, error: cls.message, classification: cls };
|
||||
}
|
||||
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';
|
||||
return { ok: true, data };
|
||||
} 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 {
|
||||
this.sending = false;
|
||||
}
|
||||
},
|
||||
|
||||
dismissSendError() {
|
||||
this.lastSendError = null;
|
||||
},
|
||||
|
||||
async setKanbanStatus(status) {
|
||||
if (!['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'].includes(status)) return;
|
||||
if (!this.thread) return;
|
||||
|
||||
@@ -1366,7 +1366,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<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 -->
|
||||
<button
|
||||
class="lv-card"
|
||||
@@ -1423,12 +1423,44 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</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 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" />
|
||||
<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>
|
||||
@@ -1702,6 +1734,90 @@ onBeforeUnmount(() => {
|
||||
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 ─────────────────────────────────────────── */
|
||||
@keyframes prof-fadeUp {
|
||||
from {
|
||||
|
||||
@@ -207,10 +207,8 @@ async function savePartialProgress() {
|
||||
} catch { /* silencioso — autosave não é crítico */ }
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [form.nome_completo, form.telefone, form.email_principal, form.onde_nos_conheceu],
|
||||
scheduleProgressSave
|
||||
);
|
||||
// O watch que dispara `scheduleProgressSave` foi movido pra depois da
|
||||
// declaração de `form` (linha ~319) — referência aqui violava TDZ.
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -319,6 +317,12 @@ function resetForm() {
|
||||
const form = reactive(resetForm());
|
||||
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({
|
||||
nome_completo: '', email_principal: '', email_alternativo: '', telefone: '', consentimento: ''
|
||||
});
|
||||
|
||||
@@ -581,15 +581,15 @@ async function doWaAdjust() {
|
||||
|
||||
function submitWaTopup() {
|
||||
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);
|
||||
if (amt < 1) { toast.add({ severity: 'warn', 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 < 1) { toast.add({ severity: 'error', summary: 'Valor deve ser >= 1', life: 2500 }); 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') {
|
||||
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 (amt > max) { toast.add({ severity: 'warn', summary: `Máximo removível: ${max}`, life: 3000 }); 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: 'error', summary: `Máximo removível: ${max}`, life: 3000 }); return; }
|
||||
|
||||
const msgItems = [
|
||||
`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)]">
|
||||
{{ waTopup.mode === 'remove' ? 'Créditos a remover' : 'Créditos a adicionar' }}
|
||||
</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)]">
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user