Melissa: hub Configuracoes + Embed + 9 Pages novas + dialog blueprint dark
Sprints 04-29 + 04-30 acumuladas. - MelissaConfiguracoes: hub 2-col com 6 grupos (Layout/Conta/Agenda/ Financeiro/WhatsApp/Sistema), tudo embedado via MelissaEmbed. - MelissaEmbed: wrapper generico que injeta layout-variant=melissa e remove cromos pra reaproveitar Pages tradicionais. - 9 Melissa Pages novas: CadastrosRecebidos, Compromissos, Configuracoes, Conversas, Embed, Grupos, Medicos, Recorrencias, Tags. - Dialog blueprint atualizado: bg-gray-100 (hardcoded light) -> bg-[var(--surface-ground)] (tema-aware). 22 dialogs migrados em 9 arquivos. Anti-pattern documentado. - PatientsCadastroPage: bug fix dropdown Grupo (optionLabel nome->name), toggle vertical/abas com persist localStorage, sticky margin-top. - Surface picker no popover do MelissaLayout (8 swatches). - useTopbarPlanMenu, useMelissaWhatsapp, useMelissaPacientesAside novos. - Migration: status agenda remarcado/confirmado. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+586
-181
@@ -1,239 +1,644 @@
|
||||
# HANDOFF — 2026-04-27 (domingo, pincelada de polimento Melissa)
|
||||
# HANDOFF — 2026-04-30 (quinta, sprint MelissaConfiguracoes + MelissaEmbed + dialog blueprint dark)
|
||||
|
||||
Documento de continuidade. **Quando voltar, comece lendo esta página.**
|
||||
Sessão de domingo curta — bug do chip resolvido, polimento da Agenda
|
||||
(toolbar + stats interativos + extração do evento panel novo).
|
||||
Sessão grande — criação do hub de configs Melissa (com tudo embedado),
|
||||
wrapper genérico pra pages tradicionais, polimento do cadastro de paciente,
|
||||
migração de 22 ocorrências de `bg-gray-100` em dialogs pra tema-aware.
|
||||
Working tree **NÃO commitado** ainda.
|
||||
|
||||
A sessão anterior (2026-04-29) deixou 7 Melissa Pages novas também não
|
||||
commitadas — estão acumuladas com esta. Decidir junto: 1 commit grande
|
||||
ou chunks lógicos.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 PENDENTE DE TESTE — MelissaEventoPanel novo (B3)
|
||||
|
||||
Implementado mas **ainda não testado em browser**. Working tree:
|
||||
## 🚦 STATUS — Working tree
|
||||
|
||||
**Modificados (acumulado das 2 sprints):**
|
||||
```
|
||||
M HANDOFF.md
|
||||
M blueprints/dialog-blueprint.md
|
||||
M src/assets/layout/_menu.scss
|
||||
M src/components/CadastroRapidoConvenio.vue
|
||||
M src/components/CadastroRapidoMedico.vue
|
||||
M src/components/agenda/AgendaQuickAddDialog.vue
|
||||
M src/components/ui/PatientCadastroDialog.vue
|
||||
M src/features/patients/cadastro/PatientsCadastroPage.vue
|
||||
M src/features/patients/medicos/MedicosPage.vue
|
||||
M src/layout/AppMenu.vue
|
||||
M src/layout/AppRail.vue
|
||||
M src/layout/AppRailPanel.vue
|
||||
M src/layout/AppRailSidebar.vue
|
||||
M src/layout/composables/layout.js
|
||||
M src/layout/configuracoes/ConfiguracoesAgendaPage.vue
|
||||
M src/layout/configuracoes/ConfiguracoesRecursosExtrasPage.vue
|
||||
M src/layout/configuracoes/ConfiguracoesWhatsappPage.vue
|
||||
M src/layout/melissa/MelissaAgenda.vue
|
||||
M src/layout/melissa/MelissaLayout.vue
|
||||
M src/layout/melissa/composables/useMelissaEventos.js
|
||||
?? src/layout/melissa/MelissaEventoPanel.vue
|
||||
M src/layout/melissa/MelissaMenu.vue
|
||||
M src/layout/melissa/MelissaPacientes.vue
|
||||
M src/layout/melissa/composables/useMelissaAgenda.js
|
||||
M src/router/routes.clinic.js
|
||||
M src/router/routes.therapist.js
|
||||
M src/views/pages/account/ProfilePage.vue
|
||||
M src/views/pages/saas/SaasAddonsPage.vue
|
||||
M src/views/pages/saas/SaasNotificationTemplatesPage.vue
|
||||
```
|
||||
|
||||
**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`)
|
||||
**Novos (acumulado):**
|
||||
```
|
||||
?? blueprints/melissa-page-blueprint.md
|
||||
?? src/layout/melissa/MelissaCadastrosRecebidos.vue
|
||||
?? src/layout/melissa/MelissaCompromissos.vue
|
||||
?? src/layout/melissa/MelissaConfiguracoes.vue
|
||||
?? src/layout/melissa/MelissaConversas.vue
|
||||
?? src/layout/melissa/MelissaEmbed.vue
|
||||
?? src/layout/melissa/MelissaGrupos.vue
|
||||
?? src/layout/melissa/MelissaMedicos.vue
|
||||
?? src/layout/melissa/MelissaRecorrencias.vue
|
||||
?? src/layout/melissa/MelissaTags.vue
|
||||
?? src/layout/melissa/composables/useMelissaPacientesAside.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ COMMITS DO DIA (2026-04-27)
|
||||
## ✅ FEITO HOJE (2026-04-30)
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
### PatientsCadastroPage — polimento
|
||||
|
||||
Working tree limpo exceto pelo B3 não testado (ver acima).
|
||||
1. **Bug fix dropdown Grupo vazio** — `optionLabel="nome"` → `"name"` (`PatientsCadastroPage.vue:1542`). Repo `listGroups` retorna `{name,color}` mas template buscava `nome`. Tags já estavam OK.
|
||||
2. **Toggle Vertical/Abas** — botão segmented na sidebar (entre avatar e nav). Em "Abas", esconde nav vertical da sidebar + mostra tab list em cima do main + esconde headers do Accordion (via `.pcd-horizontal :deep(.p-accordionheader)`). Persiste em `localStorage` (chave `pcd.viewMode.v1`).
|
||||
3. **Sticky + margin-top fantasma** — adicionado `xl:items-start` no grid container (era stretch por default). Sticky da esquerda mantido como estava. Margin-top fantasma resolveu.
|
||||
|
||||
### Dialog blueprint — dark/light aware
|
||||
|
||||
4. **`blueprints/dialog-blueprint.md`** atualizado: trocado `bg-gray-100` (hardcoded light) por `bg-[var(--surface-ground)]` (tema-aware: `--p-surface-100` light / `--p-surface-950` dark). Removido o hack `shadow-[0_1px_0_0_rgba(255,255,255,0.06)]`. Adicionada seção Anti-pattern explícita.
|
||||
5. **22 ocorrências em 9 arquivos migradas** pro novo padrão:
|
||||
- `MedicosPage.vue` (1 dialog)
|
||||
- `SaasNotificationTemplatesPage.vue` (1)
|
||||
- `SaasAddonsPage.vue` (3 dialogs)
|
||||
- `PatientsCadastroPage.vue` (2 dialogs internos: criar grupo, criar tag)
|
||||
- `ConfiguracoesWhatsappPage.vue` (1)
|
||||
- `CadastroRapidoMedico.vue` (1)
|
||||
- `CadastroRapidoConvenio.vue` (1)
|
||||
- `ConfiguracoesRecursosExtrasPage.vue` (1)
|
||||
- `AgendaQuickAddDialog.vue` (1)
|
||||
|
||||
### Surface picker no popover do canto superior direito
|
||||
|
||||
6. **`MelissaLayout.vue`** — importado `surfaces` de `theme.options`, criado `setSurface()` análogo a `setPrimary()`, computed `activeSurface` com fallback (`zinc` no dark, `slate` no light). Popover do canto sup. dir. agora mostra **Surface** (8 swatches) abaixo de **Cor primária**.
|
||||
|
||||
### MelissaConfiguracoes — hub de configurações
|
||||
|
||||
7. **Novo arquivo: `src/layout/melissa/MelissaConfiguracoes.vue`**
|
||||
- Layout 2-col (estilo MelissaAgenda): aside ~320px com Accordion de grupos + main com conteúdo da seção ativa
|
||||
- **6 grupos** (espelham `/configuracoes/` + extensões):
|
||||
- **Layout Melissa**: Aparência, Plano de fundo, Relógio, Cronômetro (4 inlines com controles definidos no próprio arquivo)
|
||||
- **Conta** (NOVO): Meu Perfil, Meu Plano, Meu Negócio, Segurança
|
||||
- **Agenda**: 3 sub-itens
|
||||
- **Financeiro**: 5 sub-itens
|
||||
- **WhatsApp & Conversas**: 9 sub-itens
|
||||
- **Comunicação**: 2 sub-itens
|
||||
- **Empresa & Plataforma**: 3 sub-itens
|
||||
- **Total: 30 sub-itens** — 4 inlines + 26 embedados via `defineAsyncComponent` + `<Suspense>`
|
||||
- **`COMPONENT_MAP`** mapeia keys pra `defineAsyncComponent(() => import(...))`
|
||||
- **`mcfg-embed-hero`** sticky no topo (`z-index: 11`) com ícone+label+desc+slot `#cfg-page-actions` pros Teleports das pages tradicionais
|
||||
- **`mcfg-embed-wrap`** dá padding equivalente ao `px-3 md:px-4 pb-5` da `ConfiguracoesPage`
|
||||
- Aceita prop `secaoRota` que mapeia rota → `secaoAtiva` (`ROUTE_TO_SECAO`): permite deep-link tipo `/melissa/perfil` abrir direto na seção certa
|
||||
|
||||
8. **`MelissaLayout.vue`** — adicionou ao `SECOES`:
|
||||
- `aparencia` (default do MelissaConfiguracoes)
|
||||
- `perfil`, `plano`, `negocio`, `seguranca` (atalhos de Conta — montam MelissaConfiguracoes pré-selecionado)
|
||||
- **`provide('melissaSettings', {...})`** após `testarToque` (ordem importa pra ter todas as refs definidas) — expõe pro MelissaConfiguracoes
|
||||
|
||||
### MelissaEmbed — wrapper genérico (Onda 1)
|
||||
|
||||
9. **Novo arquivo: `src/layout/melissa/MelissaEmbed.vue`**
|
||||
- 1-coluna (sem aside) — mais leve que MelissaConfiguracoes
|
||||
- Hero glass sticky (z-index 11) + `<Suspense>` + `<component :is>` lazy
|
||||
- **9 pages embedadas** (`EMBED_MAP`):
|
||||
- Financeiro Visão geral + Lançamentos
|
||||
- Documents List + Templates
|
||||
- Agendamentos Recebidos
|
||||
- Online Scheduling
|
||||
- Relatórios
|
||||
- Notifications History
|
||||
- Patients External Link
|
||||
- Reusa Teleport target `#cfg-page-actions` (compartilhado com ConfiguracoesPage tradicional)
|
||||
|
||||
10. **`MelissaLayout.vue`** — `MELISSA_EMBED_KEYS` array + render condicional + filtro do placeholder atualizado
|
||||
|
||||
### MelissaMenu — handlers internos + Onda 1
|
||||
|
||||
11. **`MelissaMenu.vue`** — várias mudanças:
|
||||
- **Footer**: `goPerfil/goPlano/goSeguranca` trocados de `navAndClose(rota_externa)` pra `emit('select', '...')` interno. Adicionado `goNegocio` + botão "Meu Negócio".
|
||||
- **Backdrop blur** no `.mm-layer`: `background: rgba(0,0,0,0.25)` + `backdrop-filter: blur(2px)` (equivalente ao `backdrop-blur-xs` do PrimeVue Dialog)
|
||||
- **Categoria Configurações > Layout Melissa** > "Aparência e cronômetro"
|
||||
- **Onda 1 entries**:
|
||||
- Agenda e Pacientes > Principais: + Agendamentos recebidos
|
||||
- Agenda e Pacientes > Outros: + Agendador online, Link externo de cadastro
|
||||
- Prontuários: novo grupo "Documentos" (Documentos, Templates)
|
||||
- Financeiro: convertido `fin-overview/fin-lancamentos` (route externo) → `financeiro/financeiro-lancamentos` (internos), + grupo "Análise" com Relatórios
|
||||
- WhatsApp > Atendimento: + Notificações enviadas
|
||||
|
||||
---
|
||||
|
||||
## ✅ BUG RESOLVIDO — chip do cronômetro
|
||||
## 📊 Estado da migração Melissa
|
||||
|
||||
**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`.
|
||||
✅ **Promovidas (10 nativas)**: Agenda, Pacientes, Compromissos, Recorrências, Conversas, Tags, Grupos, CadastrosRecebidos, Médicos, Configurações
|
||||
✅ **Embedadas via MelissaConfiguracoes (26)**: 22 configs + 4 conta
|
||||
✅ **Embedadas via MelissaEmbed (9)**: Financeiro+Lançamentos, Documents+Templates, Agendamentos Recebidos, Online Scheduling, Relatórios, Notificações, Link Externo
|
||||
|
||||
**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.
|
||||
❌ **Faltam (🟢 baixa prioridade)**: Dashboards (Therapist/Clinic — paradigma diferente, resumo já cobre), Setup Wizard, Upgrade pages, Clinic admin (Features/Professionals)
|
||||
|
||||
```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.
|
||||
🚫 **Fora de escopo**: routes.saas.js (admin SaaS), Auth pages
|
||||
|
||||
---
|
||||
|
||||
## ✅ B1 — Toolbar Agenda (commit 6a92735)
|
||||
## 🧪 ROTEIRO DE TESTE — passo a passo (7 fases, ~35 min)
|
||||
|
||||
- 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
|
||||
### Setup (1 min)
|
||||
|
||||
## ✅ B2 — Stats interativos (commit 6a92735)
|
||||
```powershell
|
||||
Remove-Item -Recurse -Force node_modules\.vite -ErrorAction SilentlyContinue
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- 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
|
||||
- Abre `http://localhost:5173` → faz login
|
||||
- `/account/profile` → terceiro card **"Layout"** → confirma `Melissa` selecionado
|
||||
- **DevTools console aberto** durante TODOS os testes (F12)
|
||||
|
||||
❌ Se ver `Cannot read properties of null` → mata vite, limpa cache (`Remove-Item -Recurse -Force node_modules\.vite`), reinicia.
|
||||
|
||||
---
|
||||
|
||||
### Fase 1 — Smoke (3 min)
|
||||
|
||||
| # | Passo | Esperado |
|
||||
|---|---|---|
|
||||
| 1.1 | Acessa `/melissa` | Resumo carrega: relógio gigante + saudação + cards. Console limpo. |
|
||||
| 1.2 | Click no **ψ** (canto inf. esq.) | Menu Win11 abre. **Fundo escurece com blur sutil** (mudança nova) |
|
||||
| 1.3 | Click fora do menu | Menu fecha |
|
||||
|
||||
---
|
||||
|
||||
### Fase 2 — Cadastro de paciente (5 min)
|
||||
|
||||
1. `/melissa/pacientes` → click **"+ Novo paciente"**
|
||||
2. **Toggle Vertical/Abas** (topo da sidebar do dialog):
|
||||
- Click **"Abas"** → tabs aparecem em cima do form (6 com cores) + nav vertical da sidebar some
|
||||
- Painel ativo continua sendo mostrado
|
||||
- Click **"Vertical"** → volta ao Accordion
|
||||
3. **Painel "Clínico & origem"** (seção 2):
|
||||
- Dropdown **Grupo** mostra nomes (era vazio antes)
|
||||
- Multiselect **Tags** funciona
|
||||
- Botão **+** ao lado de Grupo → abre dialog "Novo grupo"
|
||||
- Botão **+** ao lado de Tag → abre dialog "Nova tag"
|
||||
4. **Sticky / margin-top fantasma**:
|
||||
- Expande painel grande, scroll pra baixo
|
||||
- Coluna esquerda acompanha sticky
|
||||
- **Sem espaço fantasma** entre topo e sidebar
|
||||
5. **Refresh hard** (Ctrl+Shift+R) → reabre dialog → toggle Vertical/Abas **persiste**
|
||||
|
||||
---
|
||||
|
||||
### Fase 3 — Dialogs dark/light (3 min)
|
||||
|
||||
1. `/account/profile` → tema **claro**
|
||||
2. `/melissa/pacientes` → abre dialog (criar tag pelo + na sidebar)
|
||||
- Header e footer com **cor levemente cinza** (var `--surface-ground`)
|
||||
3. Troca pra **escuro** → reabre dialog
|
||||
- Header e footer com **cor levemente preta** (mais escuro que body)
|
||||
4. Repete em outros dialogs:
|
||||
- Cadastro paciente
|
||||
- Médicos (`/melissa/medicos` → editar)
|
||||
- Convênio (cadastro rápido)
|
||||
- Agenda quick add (`/melissa/agenda` → click numa célula vazia)
|
||||
|
||||
---
|
||||
|
||||
### Fase 4 — MelissaConfiguracoes (10 min) — **mais densa**
|
||||
|
||||
Acessa `/melissa/aparencia`
|
||||
|
||||
**Sidebar — confirma 6 grupos no Accordion:**
|
||||
- Layout Melissa (aberto por default)
|
||||
- Conta, Agenda, Financeiro, WhatsApp & Conversas, Comunicação, Empresa & Plataforma
|
||||
|
||||
**Grupo "Layout Melissa" — 4 inlines:**
|
||||
|
||||
| Sub-item | O que confere |
|
||||
|---|---|
|
||||
| Aparência | Segmented Tema, swatches Primary, swatches Surface, botão "Padrão" |
|
||||
| Plano de fundo | Preview, upload de imagem, sliders |
|
||||
| Relógio | Segmented 24h/12h |
|
||||
| Cronômetro | Select de toque + botão "Testar" |
|
||||
|
||||
**Grupo "Conta" — 4 embedados:**
|
||||
- Meu Perfil → ProfilePage com hero "Meu Perfil" no topo
|
||||
- Meu Plano → TherapistMeuPlanoPage
|
||||
- Meu Negócio → NegocioPage
|
||||
- Segurança → SecurityPage
|
||||
|
||||
**Grupo "Agenda" — 3 embedados:** Agenda, Bloqueios, Agendador Online
|
||||
|
||||
**Grupo "Financeiro" — 5 embedados:** Pagamento, Precificação, Descontos, Exceções, Convênios
|
||||
|
||||
**Grupo "WhatsApp" — 9 embedados:** testa pelo menos 2-3 (ex: Templates, Créditos, Histórico)
|
||||
|
||||
**Grupo "Plataforma" — 3 embedados:** Empresa, Recursos Extras, Auditoria
|
||||
|
||||
**Em TODAS as embedadas, conferir:**
|
||||
- **Hero contextual** sticky no topo do main, z-index acima de qq sticky interno
|
||||
- Ícone + título + descrição corretos por seção
|
||||
- **Padding adequado** — conteúdo não cola na borda
|
||||
|
||||
---
|
||||
|
||||
### Fase 5 — Atalhos MelissaMenu (5 min)
|
||||
|
||||
Click no **ψ** pra abrir menu:
|
||||
|
||||
**Footer (5 botões):**
|
||||
|
||||
| Botão | Esperado |
|
||||
|---|---|
|
||||
| Meu Perfil | Fecha menu, abre `/melissa/perfil` (embedado, **não** navega externo) |
|
||||
| Meus Planos | `/melissa/plano` |
|
||||
| **Meu Negócio** | `/melissa/negocio` (botão NOVO desta sprint) |
|
||||
| Segurança | `/melissa/seguranca` |
|
||||
| Modo escuro | Toggle dark/light |
|
||||
|
||||
**Categoria Configurações:**
|
||||
- Configurações → grupo **"Layout Melissa"** → "Aparência e cronômetro" → vai pra `/melissa/aparencia`
|
||||
|
||||
**Backdrop:**
|
||||
- Ao abrir menu, fundo escurece (rgba 0.25) + blur 2px
|
||||
|
||||
---
|
||||
|
||||
### Fase 6 — Onda 1 embedadas (8 min)
|
||||
|
||||
Pra cada URL: **hero contextual + conteúdo renderiza + botão X fecha + console limpo**
|
||||
|
||||
| URL | Como chegar pelo menu |
|
||||
|---|---|
|
||||
| `/melissa/financeiro` | ψ → Financeiro → Visão geral |
|
||||
| `/melissa/financeiro-lancamentos` | ψ → Financeiro → Lançamentos |
|
||||
| `/melissa/documentos` | ψ → Prontuários → Documentos |
|
||||
| `/melissa/documentos-templates` | ψ → Prontuários → Templates de documentos |
|
||||
| `/melissa/agendamentos-recebidos` | ψ → Agenda e Pacientes → Agendamentos recebidos |
|
||||
| `/melissa/online-scheduling` | ψ → Agenda e Pacientes → Agendador online |
|
||||
| `/melissa/relatorios` | ψ → Financeiro → Relatórios |
|
||||
| `/melissa/notificacoes` | ψ → WhatsApp → Notificações enviadas |
|
||||
| `/melissa/link-externo` | ψ → Agenda e Pacientes → Link externo de cadastro |
|
||||
|
||||
---
|
||||
|
||||
### Fase 7 — Surface picker (1 min)
|
||||
|
||||
1. `/melissa` → click **engrenagem** (canto sup. dir.)
|
||||
2. Aparece **Cor primária** + **Surface** abaixo (8 swatches)
|
||||
3. Click em diferentes surfaces → fundo de cards/dialogs muda
|
||||
|
||||
---
|
||||
|
||||
### Como reportar bugs (pra cada problema)
|
||||
|
||||
- **Fase + step** (ex: "Fase 4, Conta > Meu Plano")
|
||||
- **O que aconteceu** (frase curta)
|
||||
- **Console error** (stack se tiver)
|
||||
- **Screenshot** se for visual
|
||||
|
||||
**Prioridade pra arrumar:**
|
||||
1. 🔴 Crashes (página em branco, erro vermelho)
|
||||
2. 🟠 Funcionalidade quebrada
|
||||
3. 🟡 Polish visual
|
||||
|
||||
---
|
||||
|
||||
## ⏭️ PRÓXIMOS PASSOS (após testes)
|
||||
|
||||
**1. Bugs encontrados → arrumar pontual**
|
||||
|
||||
**2. Bug aberto pendente — A#37**
|
||||
- "Erros ao salvar paciente + dialog não fecha"
|
||||
- Botão maximizar já corrigido (sessão anterior)
|
||||
- Falta erro específico do Leonardo (console + toast + Network)
|
||||
- Hipóteses: RLS, NOT NULL constraint, validação silenciosa
|
||||
|
||||
**3. Commits**
|
||||
- Decidir: 1 mega-commit ou chunks lógicos. Acumulado = 27 modificados + 11 novos.
|
||||
- Sugestão de chunks (em ordem):
|
||||
1. Sprint anterior (HANDOFF 2026-04-29) — 7 Melissa Pages + bugfixes Teleport
|
||||
2. PatientsCadastroPage polish (toggle + bug Grupo + sticky)
|
||||
3. Dialog blueprint dark/light (blueprint + 9 arquivos migrados)
|
||||
4. Surface picker + provide melissaSettings
|
||||
5. MelissaConfiguracoes (hub completo)
|
||||
6. MelissaEmbed (Onda 1)
|
||||
7. MelissaMenu (handlers internos + Onda 1 entries + blur)
|
||||
|
||||
**4. Onda 2/3 — decisão pendente**
|
||||
- Onda 2: MelissaDocumentos nativa (~700 linhas glass card 2-col)
|
||||
- Onda 3: MelissaFinanceiro nativa (~1000 linhas, dashboard cards Win11)
|
||||
- Critério: se usar muito → vale nativa. Se ocasional → embedado tá bom.
|
||||
- Decidir DEPOIS de testar a Onda 1 atual.
|
||||
|
||||
**5. Itens 🟢 baixa prioridade (talvez nunca)**
|
||||
- Dashboards tradicionais (resumo já cobre)
|
||||
- Setup Wizard (fluxo pontual)
|
||||
- Upgrade pages
|
||||
- Clinic admin specific (Features/Professionals — admin só)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Tracking persistente
|
||||
|
||||
- **A#32** — Fase 5 router wire-up (ainda pendente, sessão dedicada)
|
||||
- **A#33-A#36** — bugs resolvidos sessões anteriores (memória project_layout_melissa.md)
|
||||
- **A#37** — Cadastro paciente erro ao salvar: ⚠️ INVESTIGAR (preciso erro específico)
|
||||
- Memória atualizada: `project_layout_melissa.md` (sprint 2026-04-29 completa)
|
||||
- Blueprints: `dialog-blueprint.md` (atualizado dark/light), `melissa-page-blueprint.md` (referência)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Setup pra retomar
|
||||
|
||||
```bash
|
||||
# Terminal 1 — functions
|
||||
# Terminal 1 — Functions
|
||||
supabase functions serve --no-verify-jwt --env-file supabase/functions/.env
|
||||
|
||||
# Terminal 2 — vite
|
||||
# Terminal 2 — Vite
|
||||
rm -rf node_modules/.vite # se cache estiver bagunçado
|
||||
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)
|
||||
http://localhost:5173/melissa
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌱 Seeds de teste (DB local)
|
||||
# HANDOFF — 2026-05-03 (sábado, sprint Melissa polishing + topbar parity + notif redesign)
|
||||
|
||||
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:
|
||||
Sessão longa. Working tree continua não-commitado (acumula com as duas anteriores
|
||||
de 04-29 e 04-30). Dados de demo no DB (9 eventos hoje com price/billed) — limpar
|
||||
antes de qualquer push pra outro ambiente.
|
||||
|
||||
## ✅ FEITO HOJE
|
||||
|
||||
### Melissa: timeline do resumo com paridade FullCalendar
|
||||
- **Range derivado de `agenda_regras_semanais`** (regra do dia atual) com fallback
|
||||
pra `agenda_configuracoes` (`agenda_custom_start/end`) → 08–18h hardcoded.
|
||||
Substitui as constantes `HORA_INICIO=8` / `HORA_FIM=20`.
|
||||
- **Range expande pra fora do expediente** — sessões agendadas fora da jornada
|
||||
(ex: 22h num dia 08–18) ainda aparecem; a timeline cresce pra acomodar.
|
||||
- **Badge "Folga" / "Feriado: <nome>"** no header — `isFolga` checa ausência de
|
||||
rule pro `dia_semana`; `todayFeriado` busca em `agendaFeriados` (já exposto via
|
||||
`useMelissaAgenda`). Não bloqueia, só sinaliza.
|
||||
- **Scroll horizontal** com `min-width` por slot via CSS var `--m-tl-slot-w`
|
||||
(default 80px); `--m-tl-cols` setada inline. Wrapper `.tl-h-scroll` com
|
||||
scrollbar fina theme-aware.
|
||||
- **Auto-scroll inicial pra "agora"** registrado dentro de `onMounted` (TDZ-safe);
|
||||
watch `[HORA_INICIO, HORA_FIM]` com `_tlAutoScrolled` flag pra disparar uma
|
||||
vez só. ResizeObserver mantém viewW/innerW frescos.
|
||||
- **Eco lateral** — minimap pulsante de cores nas bordas. Faixa 8px com tracinhos
|
||||
coloridos (cor do status) posicionados por tempo dentro da janela invisível.
|
||||
Click anima scroll até o evento. Pulse 2.4s só quando há off-screen. Hover
|
||||
expande tracinho `scaleX(2.2)` pra fora da faixa.
|
||||
|
||||
### Cards do resumo Melissa (todos funcionais)
|
||||
- **Próximo paciente** — computed de `eventosHojeReais`, filtra sessao pendente,
|
||||
ordem por startH, primeiro. "Em curso" pulsa avatar quando `hNow ∈ [start, end]`.
|
||||
- **Recebíveis hoje** — `price + billed` do agenda_eventos. 3 estados: com valores
|
||||
(barra de progresso), sem preço, sem sessões. `billed=true` é proxy pra "pago"
|
||||
(fonte real seria `financial_records.status='paid'` — refinamento futuro).
|
||||
- **WhatsApp** — novo composable `useMelissaWhatsapp` lendo `conversation_threads`
|
||||
filtrando channel=whatsapp + unread_count>0. Limit 50, agregação local.
|
||||
Fallback pra contact_number quando patient_name vazio.
|
||||
- **Copilot** — virou `implementado: false` no catálogo; cai no placeholder
|
||||
"Em breve" honesto (sem mock). Backend de IA não existe.
|
||||
|
||||
### Dock pinned (Agenda + WhatsApp)
|
||||
- Adicionados ao `.melissa-dock` (que estava vazio, esperando uso). Ícones
|
||||
rounded-square 44px (vs ψ full-circle 56px) — hierarquia auto-resolve.
|
||||
- Active state primary-tinted quando seção correspondente aberta. Badge vermelho
|
||||
no WhatsApp com `whatsappPendente.count` (99+ se passar).
|
||||
- Hover: lift `-3px` + sombra crescendo (Win11 dock peek).
|
||||
|
||||
### Topbar Melissa (paridade com Rail)
|
||||
- 3 botões trazidos do AppTopbar pro Melissa (canto sup. dir., flex row): plan
|
||||
switcher DEV (DEV-only), notificações (com badge), ajuda. Cog continua o
|
||||
rightmost.
|
||||
- **Composable extraído**: `src/composables/useTopbarPlanMenu.js` encapsula
|
||||
~250 linhas de plan-menu logic (resolveActiveSubscriptionContext, listPlans,
|
||||
changePlanTo, etc). Reusável. AppTopbar inline NÃO foi refatorado (evita
|
||||
risco de regressão — refactor opcional).
|
||||
- `<NotificationDrawer />` montado em MelissaLayout (idem `<Toast />`).
|
||||
- `<AjudaDrawer />` já é global em App.vue, só importei o toggle.
|
||||
|
||||
### Toast funcionando no Melissa
|
||||
- Bug: rota `/melissa/:secao?` está em `routes.misc.js` fora do `AppLayout`
|
||||
(que monta `<Toast />`). Resultado: toast.add() era silenciado em qualquer
|
||||
page embedada.
|
||||
- Fix: `<Toast />` adicionado no fim do template do MelissaLayout (PrimeVue
|
||||
auto-import via PrimeVueResolver).
|
||||
|
||||
### Refresh da timeline ao salvar jornada
|
||||
- Bug: `useMelissaAgenda.loadSettings()` rodava só no mount. Salvar jornada em
|
||||
`/melissa/configuracoes/agenda` atualizava DB mas Melissa ficava com workRules
|
||||
antigas até reload.
|
||||
- Fix: `ConfiguracoesAgendaPage.saveJornada()` dispara
|
||||
`window.dispatchEvent(new CustomEvent('agenda:settings-saved'))` após sucesso.
|
||||
`useMelissaAgenda` registra listener em window, chama `loadSettings()` quando
|
||||
ouve. Cleanup em `onBeforeUnmount`. Pattern espelha `app:session-refreshed`
|
||||
já em uso (`main.js:134`).
|
||||
|
||||
### Profile: redirect ao trocar Melissa→Rail/Classic
|
||||
- Bug: `saveAll()` em ProfilePage persistia `layout_variant` mas não navegava.
|
||||
Usuário ficava preso em `/melissa` mesmo com flag mudado.
|
||||
- Fix: depois do save bem-sucedido, se estamos em `/melissa` E variant ≠ melissa
|
||||
→ toast "Aplicando layout" + `setTimeout(700ms, window.location.assign('/'))`.
|
||||
Hard reload pra remontar a árvore com AppLayout. Caminho inverso (qualquer→melissa)
|
||||
já era tratado em selectMelissa.
|
||||
|
||||
### Tratamento visual por status nas pílulas (Melissa)
|
||||
- Helpers no MelissaLayout: `statusKey(ev)`, `statusIcon(ev)`, `isEvEmCurso(ev)`,
|
||||
`pillStatusClass(ev)`. Aplicados na pílula horizontal e no `.vt-event` vertical.
|
||||
- Tratamentos:
|
||||
- **realizado** ✓ → glow verde sutil
|
||||
- **faltou** ✗ → opacidade 0.78 + label tachado
|
||||
- **cancelado** ⊘ → opacidade 0.6 + diagonal hatching 135° + label tachado
|
||||
- **remarcar** 🔄 → ring âmbar 2px + glow âmbar
|
||||
- **em-curso** → pulse 2.2s usando `--ev-color` (color-mix) — herda hue do bg
|
||||
- `eventStyle()` agora também injeta `--ev-color` inline pra o pulse usar.
|
||||
|
||||
### Status updates: refetch fix
|
||||
- Bug: status mudava no DB mas timeline visual não atualizava. `M.refetch()`
|
||||
só refresh a Agenda (composable separado).
|
||||
- Fix: capturei `refetch` do `useMelissaEventosHoje` no destructure e chamo
|
||||
junto com `M.refetch()` em `updateEventoStatus`.
|
||||
|
||||
### Dialog do evento (MelissaEventoPanel) — botões
|
||||
- **Blur XS** — `.evento-layer` baixou `blur(20px)` pra `blur(4px)` + saturate
|
||||
110%. Overlay opacidade 0.5→0.32 (dark) / 0.18 (light). Resumo atrás continua
|
||||
legível, só com leve "tilt-shift".
|
||||
- **Editar** funciona — antes precisava de `_raw` que `useMelissaEventosHoje`
|
||||
não inclui (select limitado). Agora busca `select('*')` do row antes de chamar
|
||||
`M.onEditEvento(data)`. Toast de erro com detalhe se falhar.
|
||||
- **Prontuário e Histórico** funcionam mesmo sem Agenda montada — novo helper
|
||||
`_callOnAgenda(action)`: se `melissaAgendaRef.value` existe executa imediato,
|
||||
senão enfileira em `_pendingAgendaAction` ref e abre seção Agenda. Watch em
|
||||
`melissaAgendaRef` drena a queue quando o ref aparece.
|
||||
|
||||
### Notification drawer redesign
|
||||
- **NotificationItem.vue** — repensado como card-item:
|
||||
- `--type-rgb` injetado inline; uma var pinta ícone, spine, chips e actions
|
||||
via color-mix em várias intensidades.
|
||||
- Avatar 40px circular (iniciais OU ícone do tipo) com pulse dot 9px no
|
||||
canto sup-direito quando não-lida.
|
||||
- Spine vertical 3px à esquerda na cor do tipo (cresce no hover de 80% pra 100%).
|
||||
- Card border-radius 10px + margin lateral; hover lifta 1px com shadow tinted.
|
||||
- Header com type label uppercase + horário relativo. Title 2-line clamp,
|
||||
detail 2-line clamp.
|
||||
- Quick actions: chips type-colored, primária filled (deeplink), outline
|
||||
(Conversa). Actions secundárias revelam no hover deslizando da direita.
|
||||
- **NotificationDrawer.vue** — toolbar limpa + grupos por data:
|
||||
- Width 380→420px. Header: título + count-pill ("3 não lidas").
|
||||
- Browser-notif vira icon-btn com active state primary-tinted.
|
||||
- Body com bg ground sutil. Toolbar sticky. Tabs em segmented control.
|
||||
- Mark-all virou ghost button compacto.
|
||||
- **Grupos por data**: Hoje / Ontem / Esta semana / Mais antigas (date-fns
|
||||
`isToday`/`isYesterday`/`differenceInDays`). Cada grupo tem header
|
||||
label-uppercase + linha + count.
|
||||
- Empty state: círculo 72px primary-tinted com check-circle, copy refinada,
|
||||
link "Ver tudo" se filtro=unread mas há itens.
|
||||
- Footer: pílula com border (em vez de link plain), arrow scoot 2px no hover.
|
||||
|
||||
### TherapistDashboard (rail layout) — timeline parity
|
||||
- Mesmas features do Melissa aplicadas inline no `TherapistDashboard.vue`
|
||||
(cópia consciente, não extração — risco de regressão).
|
||||
- `useAgendaSettings` + `useFeriados` importados; `loadFeriadosBase(tid)`
|
||||
chamado em `load()` após resolver tid.
|
||||
- `TL_START`/`TL_END` viraram computeds (eram constantes 7/20). Default
|
||||
fallback 07–20h (preserva range visual original do Dashboard, em vez
|
||||
do 08–18 do Melissa).
|
||||
- Novo `timelineEventsRaw` evita dep circular: TL_START depende de
|
||||
`timelineEventsRaw` (que NÃO depende de TL_START). `timelineEvents`
|
||||
então adiciona positioning baseado em TL_START.
|
||||
- Folga/feriado badge inline no header.
|
||||
- Wrapper `.dash-tl-frame > .dash-tl-scroll > .dash-tl-inner`. Eco lateral
|
||||
com `.dash-tl-eco`. CSS scoped ao componente — duplicação consciente do
|
||||
Melissa até eventual extração pra `<DailyTimelineStrip />` compartilhado.
|
||||
|
||||
### Plan menu popover — ancoragem
|
||||
- Bug: ao clicar no botão Plan-DEV no Melissa, popover abria no top-left
|
||||
(sem âncora). Causas:
|
||||
1. `planBtn.value?.$el` é a forma correta pra PrimeVue Button mas Melissa
|
||||
usa `<button>` HTML cru, onde planBtn.value já É o DOM (sem `$el`).
|
||||
2. `event.currentTarget` é null DEPOIS do `await loadPlanMenuData()` — DOM
|
||||
event spec normal: após a microtask, ciclo de propagação acabou.
|
||||
- Fix em `useTopbarPlanMenu.js`: captura âncora ANTES do await, com fallback
|
||||
ampliado: `planBtn.value?.$el || planBtn.value || event?.currentTarget || event?.target`.
|
||||
Suporta os dois tipos de trigger sem branch.
|
||||
|
||||
### Migration aplicada: status_evento_agenda + remarcado + confirmado
|
||||
- Enum tinha só `{agendado, realizado, faltou, cancelado, remarcar}` mas o
|
||||
código (`AgendaTerapeutaPage.vue:1339` — bloqueio por feriado) e o trigger
|
||||
`fn_notify_agenda_status_change` (migration `20260423000009`) referenciam
|
||||
`'remarcado'` e `'confirmado'`. Tentativas de save falhavam com
|
||||
`invalid input value for enum status_evento_agenda`.
|
||||
- Migration: `database-novo/migrations/20260503000001_status_evento_agenda_remarcado_confirmado.sql`.
|
||||
Idempotente (ADD VALUE IF NOT EXISTS). Aplicada local.
|
||||
- `NOTIFY pgrst, 'reload schema'` + restart do container `supabase_rest_*`
|
||||
pra dropar cache do PostgREST.
|
||||
- Distinção semântica preservada: `remarcar` (verbo, action label) ≠ `remarcado`
|
||||
(state final, sessão remarcada efetivamente). `confirmado` = paciente
|
||||
confirmou presença antes da sessão.
|
||||
|
||||
## 🗄️ DEMO DATA NO DB (LOCAL)
|
||||
|
||||
9 atendimentos populados em `agenda_eventos` pra hoje (2026-05-03), owner
|
||||
`aaaaaaaa-...02`, tenant `bbbbbbbb-...02`. Mix de status (realizado/faltou/
|
||||
cancelado/agendado), price R$ 200-280, billed em 3 (R$ 780 / R$ 1.680 = 46%).
|
||||
Sessão demo "em curso" às 00:12 (John Bowlby) realinhada pra `NOW()±window`
|
||||
pra preservar pulse.
|
||||
|
||||
**Ao virar o dia**, shift +1d com:
|
||||
```sql
|
||||
-- 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'
|
||||
);
|
||||
UPDATE agenda_eventos
|
||||
SET inicio_em = inicio_em + interval '1 day',
|
||||
fim_em = fim_em + interval '1 day'
|
||||
WHERE owner_id = 'aaaaaaaa-0002-0002-0002-000000000002'
|
||||
AND inicio_em >= (CURRENT_DATE - 1)::timestamp AT TIME ZONE 'America/Sao_Paulo'
|
||||
AND inicio_em < (CURRENT_DATE)::timestamp AT TIME ZONE 'America/Sao_Paulo';
|
||||
```
|
||||
|
||||
Pra LIMPAR todas as sessões de seed depois:
|
||||
|
||||
**Limpar tudo (antes de qualquer push)**:
|
||||
```sql
|
||||
DELETE FROM agenda_eventos WHERE observacoes LIKE 'Seed B%';
|
||||
DELETE FROM agenda_eventos
|
||||
WHERE owner_id = 'aaaaaaaa-0002-0002-0002-000000000002'
|
||||
AND id IN (
|
||||
'6b3ed093-fe1c-48de-a0ad-9bb4ea4d4c00',
|
||||
'136c2dcc-9da6-4fe8-bf24-3ca72d36a9e7',
|
||||
'8a6386b1-53d6-4043-a80d-90b4d146446b',
|
||||
'7002e899-f97e-4ca8-b3f2-826fc71c6e84',
|
||||
'3c7f5a11-8684-4adf-9fa0-76906dd5cc3c',
|
||||
'8f9f9ee1-3227-408c-9a97-91635a62078a',
|
||||
'71623e8b-38a9-4b62-8743-1e66eea2ab73',
|
||||
'b673d2db-335a-4c38-a939-9db1f9e400c6',
|
||||
'5142ad19-0224-456f-9c41-18c19fafd067'
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
## 📂 ARQUIVOS MEXIDOS (SESSÃO 05-03)
|
||||
|
||||
## 📌 Próximos passos (amanhã)
|
||||
**Modificados:**
|
||||
```
|
||||
M HANDOFF.md
|
||||
M src/layout/melissa/MelissaLayout.vue ← massivo
|
||||
M src/layout/melissa/MelissaEventoPanel.vue (blur XS)
|
||||
M src/layout/melissa/composables/useMelissaEventos.js (price/billed/select)
|
||||
M src/layout/melissa/composables/useMelissaAgenda.js (listener settings-saved)
|
||||
M src/components/notifications/NotificationDrawer.vue (redesign)
|
||||
M src/components/notifications/NotificationItem.vue (redesign)
|
||||
M src/views/pages/therapist/TherapistDashboard.vue (timeline parity)
|
||||
M src/layout/configuracoes/ConfiguracoesAgendaPage.vue (dispatch event)
|
||||
M src/views/pages/account/ProfilePage.vue (post-save redirect)
|
||||
```
|
||||
|
||||
**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
|
||||
**Novos:**
|
||||
```
|
||||
?? src/layout/melissa/composables/useMelissaWhatsapp.js
|
||||
?? src/composables/useTopbarPlanMenu.js
|
||||
?? database-novo/migrations/20260503000001_status_evento_agenda_remarcado_confirmado.sql
|
||||
```
|
||||
|
||||
**2. Commitar B3 após testar:**
|
||||
```
|
||||
Melissa: MelissaEventoPanel novo + bug fix latente do panel inline
|
||||
## ⚠️ EM ABERTO
|
||||
|
||||
- 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
|
||||
```
|
||||
- **Erro de enum persistente** — usuário relatou que erro de enum persiste
|
||||
depois da migration + reload do schema. Não recebi o **valor literal**
|
||||
rejeitado (devtools → network → resposta da API). Suspeitos: `tipo_evento_agenda`
|
||||
(só tem `sessao`/`bloqueio`, mas código fala em `supervisao`/`reuniao`),
|
||||
ou outro enum. **Próximo passo amanhã**: capturar o erro literal e decidir
|
||||
fix (migration nova ou correção de código).
|
||||
- **WhatsApp realtime no card** — MVP só faz fetch on mount. Pluggar
|
||||
`useConversations` realtime channel (`conv_msg_tenant_<tid>` em
|
||||
`conversation_messages` INSERT) pra atualizar count instantâneo.
|
||||
- **Recebíveis usa proxy `billed`** — ideal seria query em `financial_records`
|
||||
com `status='paid'` filtrado por reference_date hoje. Refinamento futuro.
|
||||
- **`<DailyTimelineStrip />` extração** — Dashboard duplicou inline em vez
|
||||
de extrair componente compartilhado. Quando aparecer 3º caller (admin
|
||||
dashboard?), vale a refatoração.
|
||||
- **AppTopbar plan logic ainda inline** — composable `useTopbarPlanMenu`
|
||||
existe mas AppTopbar não foi refatorado (evita risco). Quando tocar nele
|
||||
por outro motivo, dedupe.
|
||||
- **Cleanup do demo data** — 9 eventos populados local. Não comitar nem
|
||||
pushar antes de DELETE.
|
||||
|
||||
**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)
|
||||
## 🎯 PRÓXIMOS PASSOS (sugestão)
|
||||
|
||||
**4. Outras opções (não-Melissa):**
|
||||
- **QA Seção 3.4 do roteiro de testes** — SLA conversas + Bot triagem +
|
||||
Lembrar paciente + ...
|
||||
|
||||
---
|
||||
|
||||
## 📚 Tracking persistente
|
||||
|
||||
- **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
|
||||
1. Capturar erro literal de enum + corrigir.
|
||||
2. Decidir commits acumulados (3 sprints sem commitar).
|
||||
3. WhatsApp realtime (rápido — só pluggar o channel).
|
||||
4. Avaliar extração `<DailyTimelineStrip />` se for tocar Dashboard de novo.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Dialog — Padrão de Componente
|
||||
|
||||
> **Stack**: Vue 3 + PrimeVue 4 + Tailwind CSS
|
||||
> **Tema-aware**: header e footer respeitam dark/light automaticamente via CSS vars
|
||||
|
||||
---
|
||||
|
||||
@@ -19,6 +20,23 @@
|
||||
|
||||
---
|
||||
|
||||
## Sistema de cores (tema-aware)
|
||||
|
||||
O dialog **nunca** deve usar `bg-gray-100` ou cores hardcoded — isso quebra no dark mode.
|
||||
Usar sempre as CSS vars do projeto:
|
||||
|
||||
| Var | Light | Dark | Uso |
|
||||
|---|---|---|---|
|
||||
| `--surface-card` | `--p-surface-0` (branco) | `--p-surface-900` (quase preto) | Fundo do **corpo** do dialog (default) |
|
||||
| `--surface-ground` | `--p-surface-100` (cinza claro) | `--p-surface-950` (preto) | Fundo do **header** e **footer** — um shade mais escuro que o card |
|
||||
| `--surface-border` | `--p-content-border-color` | idem | Borda separadora entre header/content/footer |
|
||||
| `--text-color` | preto | branco | Título principal |
|
||||
| `--text-color-secondary` | cinza médio | cinza claro | Subtítulo, hints |
|
||||
|
||||
> Resumo: `bg-[var(--surface-ground)]` no header/footer fica **sempre um pouco mais escuro que o corpo**, em ambos os temas. Definido em `_light.scss:19` e `_dark.scss:19`.
|
||||
|
||||
---
|
||||
|
||||
## Estrutura obrigatória
|
||||
|
||||
```
|
||||
@@ -44,9 +62,9 @@
|
||||
class="dc-dialog w-[50rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
@@ -58,14 +76,16 @@
|
||||
|
||||
| Chave | O que faz |
|
||||
|---|---|
|
||||
| `header` | `!p-3` padding uniforme; `!rounded-t-[12px]` borda top arredondada; `border-b` + `shadow` separador com profundidade; `bg-gray-100` fundo levemente cinza |
|
||||
| `content` | `!p-3` padding interno do corpo |
|
||||
| `footer` | `!p-0` remove padding nativo (controlado pelo wrapper interno); `!rounded-b-[12px]` borda bottom arredondada; `border-t` + `shadow` separador; `bg-gray-100` fundo levemente cinza |
|
||||
| `header` | `!p-3` padding uniforme; `!rounded-t-[12px]` borda top arredondada; `border-b` separador; `bg-[var(--surface-ground)]` fundo um shade mais escuro que o card (tema-aware) |
|
||||
| `content` | `!p-3` padding interno do corpo (herda `bg-[var(--surface-card)]` do PrimeVue) |
|
||||
| `footer` | `!p-0` remove padding nativo (controlado pelo wrapper interno); `!rounded-b-[12px]` borda bottom arredondada; `border-t` separador; `bg-[var(--surface-ground)]` mesmo fundo do header |
|
||||
| `pcCloseButton` | `!rounded-md` remove o círculo nativo; `hover:!text-red-500` feedback de danger no hover |
|
||||
| `pcMaximizeButton` | `!rounded-md` remove o círculo nativo; `hover:!text-primary` feedback de cor primária no hover |
|
||||
|
||||
> O `!` (important) é necessário porque o PrimeVue injeta estilos inline nos botões e no root do Dialog — sem ele o Tailwind perde a disputa de especificidade.
|
||||
|
||||
> **Migração de dialogs antigos**: trocar `bg-gray-100` por `bg-[var(--surface-ground)]`. O `shadow-[0_1px_0_0_rgba(255,255,255,0.06)]` antigo era um hack pro dark mode; pode ser removido (a borda já dá a separação).
|
||||
|
||||
---
|
||||
|
||||
## Header — slot `#header`
|
||||
@@ -89,10 +109,10 @@
|
||||
:style="{ backgroundColor: previewBgColor }"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">
|
||||
<div class="text-base font-semibold truncate text-[var(--text-color)]">
|
||||
{{ form.name || (mode === 'create' ? 'Novo item' : 'Editar item') }}
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
{{ mode === 'create' ? 'Criando novo registro' : 'Editando registro' }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,6 +136,8 @@
|
||||
</template>
|
||||
```
|
||||
|
||||
> **Cores**: usar `text-[var(--text-color)]` no título e `text-[var(--text-color-secondary)]` no subtítulo. Não usar `opacity-50` — a cor secondary já tem contraste calibrado por tema.
|
||||
|
||||
---
|
||||
|
||||
## Footer — slot `#footer`
|
||||
@@ -157,6 +179,20 @@ Use a prop nativa `maximizable`. O PrimeVue injeta e gerencia o botão automatic
|
||||
<Dialog maximizable ...>
|
||||
```
|
||||
|
||||
Se você precisar customizar a largura/altura quando maximizado (ex: `100vw`), use `:style` reativo a um ref `maximized` E passe `:maximizable="false"` + um botão manual no `#header`. Padrão preferido: deixar o PrimeVue gerenciar.
|
||||
|
||||
---
|
||||
|
||||
## Dialogs aninhados (Dialog dentro de Dialog)
|
||||
|
||||
Quando um Dialog secundário (criar tag, criar grupo, criar convênio) é aberto a partir do form de um Dialog principal:
|
||||
|
||||
- Cada Dialog é independente — `v-model:visible` próprio
|
||||
- O Dialog secundário usa o **mesmo blueprint** (mesmo `pt`, mesmas cores)
|
||||
- Pode ser menor: `w-[36rem]` é o tamanho típico de "cadastro rápido"
|
||||
- Z-index: PrimeVue gerencia automaticamente (último aberto fica em cima)
|
||||
- Ao salvar no Dialog secundário, o item criado pode ser auto-selecionado no Dialog principal (UX comum em formulários grandes)
|
||||
|
||||
---
|
||||
|
||||
## Checklist antes de publicar um Dialog
|
||||
@@ -165,11 +201,13 @@ Use a prop nativa `maximizable`. O PrimeVue injeta e gerencia o botão automatic
|
||||
- [ ] `maximizable` na prop (botão nativo, sem estado manual)
|
||||
- [ ] `class="dc-dialog w-[50rem]"` + `:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"`
|
||||
- [ ] `pt` completo: header, content, footer, pcCloseButton, pcMaximizeButton
|
||||
- [ ] Header com `bg-gray-100`, `border-b`, shadow e `!rounded-t-[12px]`
|
||||
- [ ] Footer com `bg-gray-100`, `border-t`, shadow e `!rounded-b-[12px]`
|
||||
- [ ] Header com `bg-[var(--surface-ground)]`, `border-b`, e `!rounded-t-[12px]`
|
||||
- [ ] Footer com `bg-[var(--surface-ground)]`, `border-t`, e `!rounded-b-[12px]`
|
||||
- [ ] **Nenhum `bg-gray-100` ou cor hardcoded** — só CSS vars tema-aware
|
||||
- [ ] Botão **Excluir** no header (nunca no footer), desabilitado se nativo
|
||||
- [ ] Cancelar = `text` + `hover:!text-red-500` | Salvar = primary
|
||||
- [ ] Padding do footer via `px-3 py-3` no `div` interno
|
||||
- [ ] Texto usa `text-[var(--text-color)]` e `text-[var(--text-color-secondary)]`
|
||||
|
||||
---
|
||||
|
||||
@@ -177,7 +215,33 @@ Use a prop nativa `maximizable`. O PrimeVue injeta e gerencia o botão automatic
|
||||
|
||||
| Uso | Classe |
|
||||
|---|---|
|
||||
| Formulário simples | `w-[36rem]` |
|
||||
| Cadastro rápido / formulário simples | `w-[36rem]` |
|
||||
| Formulário padrão | `w-[50rem]` ← **padrão** |
|
||||
| Formulário complexo | `w-[70rem]` |
|
||||
| Formulário complexo (multi-coluna) | `w-[70rem]` |
|
||||
| Cadastro completo (paciente, agenda) | `w-[1100px]` |
|
||||
| Tela cheia | `maximizable` — usuário controla |
|
||||
|
||||
---
|
||||
|
||||
## Anti-pattern
|
||||
|
||||
```vue
|
||||
<!-- ❌ NÃO fazer: -->
|
||||
<Dialog :pt="{
|
||||
header: { class: 'bg-gray-100' }, // quebra no dark
|
||||
footer: { class: 'bg-gray-100' }, // quebra no dark
|
||||
}" />
|
||||
|
||||
<!-- ❌ NÃO fazer: -->
|
||||
<div class="text-base opacity-50">subtítulo</div> <!-- usar text-color-secondary -->
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ✅ Pattern correto: -->
|
||||
<Dialog :pt="{
|
||||
header: { class: 'bg-[var(--surface-ground)] border-b border-[var(--surface-border)]' },
|
||||
footer: { class: 'bg-[var(--surface-ground)] border-t border-[var(--surface-border)]' },
|
||||
}" />
|
||||
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">subtítulo</div>
|
||||
```
|
||||
|
||||
@@ -0,0 +1,749 @@
|
||||
# Blueprint — Melissa Page
|
||||
|
||||
Padrão de página fullscreen dentro do MelissaLayout (Direção B do redesign).
|
||||
Use isto como molde pra cada nova página: Financeiro, WhatsApp, Prontuários
|
||||
etc. Validado em `MelissaAgenda.vue` (referência canônica) e
|
||||
`MelissaPacientes.vue`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípio
|
||||
|
||||
Cada Melissa Page é um componente fullscreen que ocupa o viewport inteiro
|
||||
(menos 6px de respiro + faixa do dock 76px no bottom), montado via
|
||||
`v-if="layoutReady && secaoAberta === '<key>'"` no `MelissaLayout.vue`.
|
||||
|
||||
A página tem **uma área central de conteúdo principal** (a coluna que importa)
|
||||
e **0–N colunas auxiliares** (asides). No desktop convivem lado a lado; no
|
||||
mobile (<lg), as auxiliares saem do layout e viajam pra um drawer
|
||||
off-canvas via `<Teleport>`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Estrutura macro do template
|
||||
|
||||
```
|
||||
<template>
|
||||
<!-- 1) Drawer host: SEMPRE fora do .xx-page, sibling. v-show controla
|
||||
visibilidade pra ser um Teleport target válido em todo momento. -->
|
||||
<aside class="xx-mobile-drawer" :class="{ 'is-open': drawerOpen }" v-show="isMobile">
|
||||
<div id="xx-mobile-drawer-target" class="xx-mobile-drawer__scroll" />
|
||||
</aside>
|
||||
|
||||
<!-- 2) Backdrop: irmão do drawer, animado via <Transition>. -->
|
||||
<Transition name="xx-drawer-fade">
|
||||
<div v-if="isMobile && drawerOpen" class="xx-mobile-drawer__backdrop" @click="fecharDrawer" />
|
||||
</Transition>
|
||||
|
||||
<!-- 3) Página propriamente dita -->
|
||||
<section class="xx-page">
|
||||
<header class="xx-page__head">
|
||||
<button class="xx-menu-btn xx-menu-btn--mobile-only" @click="toggleDrawer">
|
||||
<i class="pi pi-bars" /><span>Menu</span>
|
||||
</button>
|
||||
<div class="xx-page__title">…</div>
|
||||
<div class="xx-page__actions">…</div>
|
||||
</header>
|
||||
|
||||
<div class="xx-body">
|
||||
<!-- Asides: cada um vai pro drawer em mobile via Teleport -->
|
||||
<Teleport to="#xx-mobile-drawer-target" :disabled="!isMobile">
|
||||
<aside class="xx-side">…</aside>
|
||||
</Teleport>
|
||||
|
||||
<!-- Conteúdo central — SEMPRE fica em .xx-page, nunca teleporta -->
|
||||
<div class="xx-main">…</div>
|
||||
|
||||
<Teleport to="#xx-mobile-drawer-target" :disabled="!isMobile">
|
||||
<aside class="xx-widgets">…</aside>
|
||||
</Teleport>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
```
|
||||
|
||||
> Substitua `xx-` pelo prefixo da página (`ma-` agenda, `mp-` pacientes,
|
||||
> `mf-` financeiro, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 3. Breakpoints
|
||||
|
||||
```
|
||||
≥1280px (xl) → todas as colunas + filtros inline na toolbar
|
||||
1024–1279 (lg→xl) → todas as colunas + filtros migram pro botão "Ações"
|
||||
≤1023px (<lg) → 1 coluna (central 100%) + asides off-canvas no drawer
|
||||
título da página some em <lg, "Menu" button aparece
|
||||
```
|
||||
|
||||
Convenção: se a página não tem filtros/toolbar complexa, ignore o
|
||||
breakpoint xl e trabalhe só com lg.
|
||||
|
||||
---
|
||||
|
||||
## 4. Z-index hierarchy
|
||||
|
||||
```
|
||||
.xx-mobile-drawer 80 ← drawer aberto cobre o ψ
|
||||
.xx-mobile-drawer__backdrop 79 ← acima do ψ, abaixo do drawer
|
||||
.psi-btn 70 ← botão Melissa (workspace)
|
||||
.melissa-dock 65 ← faixa bottom (chip cronômetro etc.)
|
||||
.xx-page 40 ← página em si
|
||||
```
|
||||
|
||||
Drawer e backdrop **devem ficar acima do ψ**. O ψ continua abaixo pra ser
|
||||
coberto quando o drawer está aberto (decisão de UX validada com Leonardo).
|
||||
|
||||
---
|
||||
|
||||
## 5. Setup do `<script setup>`
|
||||
|
||||
```js
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const drawerOpen = ref(false);
|
||||
const isMobile = ref(false);
|
||||
const isCompact = ref(false);
|
||||
|
||||
let _mqMobile = null;
|
||||
let _mqCompact = null;
|
||||
function _onMqMobileChange(e) {
|
||||
isMobile.value = e.matches;
|
||||
if (!e.matches) drawerOpen.value = false; // saiu do mobile, fecha drawer
|
||||
}
|
||||
function _onMqCompactChange(e) {
|
||||
isCompact.value = e.matches;
|
||||
}
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
_mqMobile = window.matchMedia('(max-width: 1023px)');
|
||||
isMobile.value = _mqMobile.matches;
|
||||
_mqMobile.addEventListener('change', _onMqMobileChange);
|
||||
|
||||
_mqCompact = window.matchMedia('(max-width: 1279px)');
|
||||
isCompact.value = _mqCompact.matches;
|
||||
_mqCompact.addEventListener('change', _onMqCompactChange);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
|
||||
if (_mqCompact) _mqCompact.removeEventListener('change', _onMqCompactChange);
|
||||
});
|
||||
|
||||
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
|
||||
function fecharDrawer() { drawerOpen.value = false; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. CSS base (copy-paste, troque `xx-` pelo prefixo)
|
||||
|
||||
```css
|
||||
/* Container glass — convenção das Melissa Pages */
|
||||
.xx-page {
|
||||
position: absolute;
|
||||
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 6px;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(32px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(32px) saturate(160%);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
color: var(--m-text);
|
||||
animation: xx-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
@keyframes xx-page-enter {
|
||||
from { opacity: 0; transform: scale(0.985); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Header da página */
|
||||
.xx-page__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.xx-page__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.xx-page__title > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xx-page__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Body — flex row em desktop, column em mobile */
|
||||
.xx-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Botão "Menu" (mobile only) — primary filled, abre o drawer */
|
||||
.xx-menu-btn { display: none; /* show via @media abaixo */ }
|
||||
.xx-menu-btn {
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
background: var(--m-accent);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
padding: 0 11px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.xx-menu-btn:hover { background: color-mix(in srgb, var(--m-accent) 88%, white); transform: translateY(-1px); }
|
||||
.xx-menu-btn:active { transform: translateY(0); }
|
||||
|
||||
/* Drawer mobile — fora do .xx-page, fullheight */
|
||||
.xx-mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh; /* iOS toolbar dynamic */
|
||||
width: min(360px, 88vw);
|
||||
z-index: 80; /* acima do ψ (70) */
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border-right: 1px solid var(--m-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--m-text);
|
||||
}
|
||||
.xx-mobile-drawer.is-open { transform: translateX(0); }
|
||||
|
||||
.xx-mobile-drawer__scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px 12px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--m-border-strong) transparent;
|
||||
}
|
||||
.xx-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
|
||||
.xx-mobile-drawer__scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Asides perdem padding/scroll/borda próprios quando teleportados pro drawer */
|
||||
.xx-mobile-drawer__scroll .xx-side,
|
||||
.xx-mobile-drawer__scroll .xx-widgets {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Backdrop */
|
||||
.xx-mobile-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 79;
|
||||
}
|
||||
.xx-drawer-fade-enter-active,
|
||||
.xx-drawer-fade-leave-active { transition: opacity 200ms ease; }
|
||||
.xx-drawer-fade-enter-from,
|
||||
.xx-drawer-fade-leave-to { opacity: 0; }
|
||||
|
||||
/* Mobile (<lg) — central 100%, asides off-canvas, título some */
|
||||
@media (max-width: 1023px) {
|
||||
.xx-body { flex-direction: column; }
|
||||
.xx-main { width: 100%; }
|
||||
.xx-page__title { display: none; }
|
||||
.xx-menu-btn { display: inline-flex; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Pegadinhas (DON'Ts)
|
||||
|
||||
### ❌ NÃO envolver Melissa Page com `<Transition>` no `MelissaLayout`
|
||||
|
||||
```vue
|
||||
<!-- ❌ ERRADO — leave delay cria orphan placeholder em Teleport
|
||||
targets compartilhados. Crash: "Cannot set properties of null
|
||||
(setting '__vnode')". -->
|
||||
<Transition name="page-fade">
|
||||
<MelissaXxx v-if="secaoAberta === 'xxx'" />
|
||||
</Transition>
|
||||
|
||||
<!-- ✅ CERTO — animação como @keyframes na própria .xx-page -->
|
||||
<MelissaXxx v-if="layoutReady && secaoAberta === 'xxx'" />
|
||||
```
|
||||
|
||||
### ❌ NÃO importar `Menu` do PrimeVue manualmente
|
||||
|
||||
PrimeVueResolver auto-importa. Import duplo cria instâncias fantasmas e
|
||||
quebra o reconciler com `emitsOptions: null` em `shouldUpdateComponent`.
|
||||
|
||||
```js
|
||||
// ❌ NÃO faça
|
||||
import Menu from 'primevue/menu';
|
||||
```
|
||||
|
||||
### ❌ NÃO usar `<Teleport><Transition><Element v-if>`
|
||||
|
||||
Quando múltiplos Teleports compartilham target (ex: `.melissa-dock`):
|
||||
|
||||
```vue
|
||||
<!-- ❌ ERRADO — placeholders órfãos no target compartilhado -->
|
||||
<Teleport to=".melissa-dock">
|
||||
<Transition name="...">
|
||||
<Element v-if="cond" />
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- ✅ CERTO — Transition envolve Teleport, não o contrário -->
|
||||
<Transition name="...">
|
||||
<Teleport v-if="cond" to=".melissa-dock">
|
||||
<Element />
|
||||
</Teleport>
|
||||
</Transition>
|
||||
```
|
||||
|
||||
### ❌ NÃO escopar CSS de Teleport target
|
||||
|
||||
Targets globais (`.melissa-dock`, `#xx-mobile-drawer-target`) precisam
|
||||
de CSS no `<style>` (sem `scoped`). Vue compiler hoista nodes static e
|
||||
perde `data-v-{hash}`, então o seletor scoped não casa.
|
||||
|
||||
### ⚠️ Em deep-link (URL → secaoAberta), precisa do `layoutReady`
|
||||
|
||||
`MelissaLayout` expõe `layoutReady` que vira true 1 nextTick após mount.
|
||||
Use `v-if="layoutReady && secaoAberta === 'xxx'"` no MelissaLayout, não
|
||||
só `v-if="secaoAberta === 'xxx'"`. Sem isso, o `<Teleport to=".melissa-dock">`
|
||||
da Melissa Page tenta achar target que ainda não foi montado → crash em
|
||||
`moveTeleport → insertBefore(null, ...)` quando triggers reativos do
|
||||
PrimeVue setTheme caem entre mount e flush.
|
||||
|
||||
### ⚠️ Tooltips PrimeVue
|
||||
|
||||
Em código real use `v-tooltip.top="'texto'"` (auto-registrado via
|
||||
PrimeVueResolver). NÃO use `title=""` em produção — só vale em preview.
|
||||
|
||||
---
|
||||
|
||||
## 8. Wire-up no `MelissaLayout.vue`
|
||||
|
||||
1. Importar o componente:
|
||||
```js
|
||||
import MelissaFinanceiro from './MelissaFinanceiro.vue';
|
||||
```
|
||||
|
||||
2. Adicionar a section na lista de seções "promovidas" (perto de
|
||||
`MelissaAgenda`/`MelissaPacientes` em `MelissaLayout.vue:~1273`):
|
||||
```vue
|
||||
<MelissaFinanceiro
|
||||
v-if="layoutReady && secaoAberta === 'financeiro'"
|
||||
@close="fecharSecao"
|
||||
/>
|
||||
```
|
||||
|
||||
3. Adicionar `'financeiro'` ao `SECOES` map se ainda não estiver.
|
||||
|
||||
4. Atualizar o item correspondente no `MelissaMenu.vue` pra emit
|
||||
`select('financeiro')` (sem `route`) — fica seção interna do Melissa.
|
||||
OU manter com `route: { name: 'therapist-financeiro' }` se for navegar
|
||||
pra fora do Melissa (depende do escopo da página).
|
||||
|
||||
---
|
||||
|
||||
## 9. Loading states
|
||||
|
||||
Princípio: **skeleton só na primeira carga** (sem dados ainda). Refetches
|
||||
subsequentes (mudança de range, refresh manual) mantêm a UI estável e
|
||||
mostram só feedback discreto (overlay leve / spinner em botão).
|
||||
|
||||
### Classe global `.melissa-skeleton`
|
||||
|
||||
Definida no bloco `<style>` (não scoped) do `MelissaLayout.vue`. Herda do
|
||||
shimmer global, respeita `prefers-reduced-motion`. Variantes:
|
||||
|
||||
| Classe | Uso |
|
||||
|---|---|
|
||||
| `.melissa-skeleton--text` | Linha de texto (~12px) |
|
||||
| `.melissa-skeleton--title` | Heading (~18px) |
|
||||
| `.melissa-skeleton--number` | Número de stat (~24×32px) |
|
||||
| `.melissa-skeleton--avatar` | Círculo 32×32 |
|
||||
|
||||
### Pattern: skeleton só na 1ª carga
|
||||
|
||||
```js
|
||||
// Computed no <script setup>
|
||||
const pacientesCarregandoInicial = computed(
|
||||
() => props.pacientesLoading && (props.pacientes?.length || 0) === 0
|
||||
);
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Template — bifurca pelo computed -->
|
||||
<template v-if="pacientesCarregandoInicial">
|
||||
<div v-for="i in 6" :key="`psk-${i}`" class="xx-pat xx-pat--skeleton" aria-busy="true">
|
||||
<span class="xx-pat__avatar melissa-skeleton melissa-skeleton--avatar" />
|
||||
<span class="melissa-skeleton melissa-skeleton--text" :style="{ width: `${55 + (i * 7) % 30}%` }" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-for="p in pacientes" v-else :key="p.id" class="xx-pat">…</div>
|
||||
```
|
||||
|
||||
Variar a `width` do skeleton com a expressão `${55 + (i * 7) % 30}%` evita
|
||||
linhas idênticas — fica mais natural visualmente.
|
||||
|
||||
### Pattern: classe `--skeleton` neutraliza hover/cursor
|
||||
|
||||
```css
|
||||
.xx-pat--skeleton,
|
||||
.xx-stat--skeleton,
|
||||
.xx-sess--skeleton {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
opacity: 0.95;
|
||||
}
|
||||
.xx-pat--skeleton:hover { background: inherit; transform: none; }
|
||||
```
|
||||
|
||||
### Pattern: overlay de loading (refetch silencioso)
|
||||
|
||||
Quando o componente já tem dados mas tá refetcheando (ex: FullCalendar
|
||||
trocando de view), use um overlay pequeno no canto:
|
||||
|
||||
```vue
|
||||
<Transition name="xx-loading-fade">
|
||||
<div v-if="loadingRef" class="xx-loading-corner" aria-busy="true">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
</div>
|
||||
</Transition>
|
||||
```
|
||||
|
||||
```css
|
||||
.xx-loading-corner {
|
||||
position: absolute;
|
||||
top: 8px; right: 8px;
|
||||
z-index: 5;
|
||||
pointer-events: none; /* não bloqueia clicks */
|
||||
width: 32px; height: 32px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--m-bg-medium) 80%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text-muted);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: botão com spinner durante operação
|
||||
|
||||
```vue
|
||||
<button
|
||||
class="xx-act-btn"
|
||||
:disabled="busy"
|
||||
@click="onClick"
|
||||
>
|
||||
<i :class="busy ? 'pi pi-spin pi-spinner' : 'pi pi-plus'" />
|
||||
<span>Agendar</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
```js
|
||||
const busy = ref(false);
|
||||
async function onClick() {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await operacao();
|
||||
} finally {
|
||||
// Pequeno timeout pra UI mostrar o spinner mesmo quando a operação
|
||||
// é síncrona (perceived performance).
|
||||
setTimeout(() => { busy.value = false; }, 200);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Popover de "Ações" da toolbar
|
||||
|
||||
Quando filtros/toggles inline ficam apertados (`<xl`), migre pra um
|
||||
**Popover com `<SelectButton>`** em vez do antigo `<Menu>` com lista.
|
||||
Vantagens: estado visível direto (não precisa abrir/fechar pra ver),
|
||||
mudança imediata sem fechar o popover, melhor pra dedo em mobile.
|
||||
|
||||
```vue
|
||||
<button class="xx-cal__btn xx-cal__btn--compact-only" @click="openActions">
|
||||
<i class="pi pi-ellipsis-v" /><span>Ações</span>
|
||||
</button>
|
||||
<Popover ref="actionsPopRef" class="xx-actions-pop">
|
||||
<div class="xx-actions">
|
||||
<div class="xx-actions__group">
|
||||
<div class="xx-actions__label">Visualização</div>
|
||||
<SelectButton v-model="view" :options="viewOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" class="w-full" />
|
||||
</div>
|
||||
<div class="xx-actions__divider" />
|
||||
<!-- Ações que não são toggle de estado ficam como botões -->
|
||||
<div class="xx-actions__group">
|
||||
<div class="xx-actions__label">Bloquear</div>
|
||||
<div class="xx-actions__buttons">
|
||||
<button class="xx-actions__btn" @click="onBlock('horario')">
|
||||
<i class="pi pi-clock" /><span>Por horário</span>
|
||||
</button>
|
||||
<!-- … -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
```
|
||||
|
||||
```js
|
||||
import Popover from 'primevue/popover'; // ← obrigatório (auto-import só pega Menu)
|
||||
|
||||
const actionsPopRef = ref(null);
|
||||
function openActions(e) { actionsPopRef.value?.toggle(e); }
|
||||
function closeActions() { try { actionsPopRef.value?.hide(); } catch {} }
|
||||
```
|
||||
|
||||
CSS do popover: ver `.ma-actions*` em `MelissaAgenda.vue` como referência
|
||||
(min-width 260px, gap 14px entre groups, divisor sutil, botões em grid 2×2).
|
||||
|
||||
> **Quando usar `<Menu>` em vez de `<Popover>`:** menus de ação simples
|
||||
> com 1-2 items (kebab de paciente, etc.) — lista vertical funciona e é
|
||||
> mais leve. Use `<Popover>` quando tiver SelectButton, layout custom ou
|
||||
> quiser que mudanças não fechem.
|
||||
|
||||
---
|
||||
|
||||
## 11. Header — convenção de botões
|
||||
|
||||
| Tipo | Tamanho | Border-radius | Notas |
|
||||
|---|---|---|---|
|
||||
| **Botão close** (X) | 32×32 icon-only | 9px | `display: grid; place-items: center` |
|
||||
| **Botão action icon-only** (config, settings) | 32×32 icon-only | 9px | Mesmo template do close |
|
||||
| **Botão "Menu" mobile** (abre drawer) | 32px alto, padding 0 11px | 9px | Primary filled (`var(--m-accent)`) |
|
||||
|
||||
Regra: **botões icon-only no header sempre 32×32**. Não use `padding`
|
||||
livre — sai com tamanho diferente do close e quebra alinhamento visual.
|
||||
|
||||
```css
|
||||
.xx-head-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.xx-head-btn > i { font-size: 0.85rem; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Border-radius — convenção
|
||||
|
||||
Teto **12px** pra qualquer elemento dentro de uma Melissa Page. Hierarquia:
|
||||
|
||||
| Nível | Elemento | Radius |
|
||||
|---|---|---|
|
||||
| Container externo | `.xx-page` (a "tela" inteira) | **18px** |
|
||||
| Card / widget | `.xx-w` (containers internos) | **12px** |
|
||||
| Item dentro de card | `.xx-stat`, `.xx-sess`, `.xx-pat` | **10px** |
|
||||
| Botão small | `.xx-head-btn`, `.xx-close`, ações da toolbar | **9px** |
|
||||
| Pill / badge | counts, novo, status | **999px** (full round) |
|
||||
| Avatar | `.xx-pat__avatar` | **50%** |
|
||||
|
||||
**Não passe de 12px em cards internos.** Visualmente conflita com o radius
|
||||
do container externo (18px) e fica "infantil".
|
||||
|
||||
---
|
||||
|
||||
## 13. Checklist pra cada nova Melissa Page
|
||||
|
||||
### Estrutura
|
||||
- [ ] Componente `Melissa<Nome>.vue` em `src/layout/melissa/`
|
||||
- [ ] Prefixo CSS único (`mf-`, `mw-`, `mr-`...)
|
||||
- [ ] Estrutura template: drawer host (sibling) + backdrop + `<section class="xx-page">`
|
||||
- [ ] `<Teleport>` em cada aside, target `#xx-mobile-drawer-target`
|
||||
- [ ] `isMobile`/`isCompact` via matchMedia (1023/1279)
|
||||
- [ ] `drawerOpen`/`toggleDrawer`/`fecharDrawer`
|
||||
- [ ] Botão "Menu" mobile-only no header
|
||||
- [ ] Botão "Fechar" no header → `emit('close')` (volta pro resumo)
|
||||
- [ ] `@keyframes xx-page-enter` em `.xx-page` (não use `<Transition>` no parent)
|
||||
- [ ] z-index drawer 80, backdrop 79
|
||||
- [ ] CSS de drawer e backdrop com mesmas dimensões da Agenda (`min(360px, 88vw)`)
|
||||
- [ ] Wire-up no `MelissaLayout.vue` com `layoutReady &&`
|
||||
- [ ] Adicionar entry no `MelissaMenu` (com ou sem `route`)
|
||||
|
||||
### Loading
|
||||
- [ ] Composable expõe `loading` ref
|
||||
- [ ] Prop `xxxLoading` na Melissa Page (passa do parent)
|
||||
- [ ] Computed `xxxCarregandoInicial` (`loading && data.length === 0`)
|
||||
- [ ] Skeleton com `melissa-skeleton` + variantes nos lugares que importam
|
||||
- [ ] Botões de ação (criar, salvar) com `:disabled="busy"` + spinner
|
||||
|
||||
### Visual
|
||||
- [ ] Botões icon-only no header: 32×32, radius 9px
|
||||
- [ ] Cards internos: radius 12px (containers) / 10px (items)
|
||||
- [ ] Toggles/filtros em `<Popover>` com `<SelectButton>` (não `<Menu>` lista)
|
||||
|
||||
---
|
||||
|
||||
## 14. Pattern: CRUD de catálogo (Tags / Grupos / Médicos)
|
||||
|
||||
Páginas estilo "catálogo simples" — entidades com nome + cor (ou só dados de
|
||||
contato), CRUD básico, contagem de itens vinculados. Layout 2-col padrão:
|
||||
|
||||
- **Aside (~280px)**: stats (4 cards 2×2) + busca
|
||||
- **Main**: lista de cards (cor/avatar + nome + meta + actions)
|
||||
- **Click no card** → dialog edit
|
||||
- **Botão "+ Novo"** no header do `mp-page__actions`
|
||||
- **Lock visual** em items "do sistema" (tags padrão, grupos sistema, etc.) —
|
||||
cards não-clicáveis, sem botões editar/excluir
|
||||
- **Color picker** nativo (`<input type="color">`) + 12 preset colors clicáveis
|
||||
no dialog
|
||||
|
||||
Em mobile: `Novo` vira icon-only 32×32 (texto some via media query).
|
||||
|
||||
## 15. Pattern: Lista com dialog de detalhes (Cadastros Recebidos)
|
||||
|
||||
Páginas onde cada item tem **muitas informações** que não cabem no card.
|
||||
Padrão:
|
||||
|
||||
- Card mostra **só o essencial** (nome + contato + status + tempo)
|
||||
- Click → **dialog de detalhes** com seções de campos (`grid-cols-2 gap-x-4 gap-y-1`)
|
||||
- Footer do dialog tem **ações principais à direita** (Rejeitar / Converter)
|
||||
- Dialog usa `Dialog` do PrimeVue com `:visible` controlado (não `v-model:visible`
|
||||
pra ter mais controle do close)
|
||||
|
||||
```vue
|
||||
<Dialog
|
||||
:visible="dlg.open"
|
||||
modal
|
||||
dismissable-mask
|
||||
:style="{ width: '640px', maxWidth: '94vw' }"
|
||||
@update:visible="(v) => !v && closeDlg()"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Header com avatar + status + tempo -->
|
||||
<!-- Seções: Identificação, Documentos, Endereço, ... -->
|
||||
<div v-for="sec in dlgSections" :key="sec.title">
|
||||
<div class="text-[0.62rem] uppercase tracking-wider font-semibold opacity-70">{{ sec.title }}</div>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
<template v-for="r in sec.rows" :key="r.label">
|
||||
<div class="text-[var(--text-color-secondary)]">{{ r.label }}</div>
|
||||
<div>{{ r.value }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" text @click="closeDlg" />
|
||||
<div class="flex-1" />
|
||||
<!-- Ações principais à direita -->
|
||||
</template>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
## 16. Pattern: Kanban grid (Conversas / threads)
|
||||
|
||||
Páginas com **status discretos** (urgent / awaiting / resolved) como Conversas:
|
||||
|
||||
- Aside esquerda: filtros + atribuição + canais + resumo por status
|
||||
- Main: **grid kanban N-col** (4 cols xl, 2 cols compact, 1 col mobile)
|
||||
- Cada coluna tem header colorido por status (red/amber/blue/emerald)
|
||||
- Cards são botões clicáveis dentro de scroll vertical da coluna
|
||||
|
||||
```css
|
||||
.xx-kanban {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.xx-col { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.xx-col__body { flex: 1; overflow-y: auto; }
|
||||
|
||||
@media (max-width: 1279px) { .xx-kanban { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 1023px) { .xx-kanban { grid-template-columns: 1fr; } }
|
||||
```
|
||||
|
||||
Cores semânticas (consistentes em todas as Melissa Pages):
|
||||
- `red`: 248,113,113 (urgente, faltou, rejeitado)
|
||||
- `amber`: 251,191,36 (aguardando, novo, pendente)
|
||||
- `blue`: 96,165,250 (info, remarcado)
|
||||
- `emerald`/`green`: 74,222,128 (ok, resolvido, compareceu)
|
||||
|
||||
## 17. Reaproveitamento de composables/services
|
||||
|
||||
Sempre **reutilizar a lógica de fetch/CRUD existente** em vez de duplicar:
|
||||
|
||||
| Página Melissa | Reutiliza |
|
||||
|---|---|
|
||||
| `MelissaCompromissos` | `DeterminedCommitmentDialog`, queries supabase diretas |
|
||||
| `MelissaRecorrencias` | Lógica buildSessions/ruleStats da page antiga |
|
||||
| `MelissaConversas` | `useConversations`, `useConversationTags`, `ConversationDrawer` |
|
||||
| `MelissaCadastrosRecebidos` | Lógica de `convertToPatient` da page antiga |
|
||||
| `MelissaMedicos` | `Medicos.service.js` (createMedico/updateMedico/deleteMedico) |
|
||||
| `MelissaPacientes` | `useMelissaPacientes`, `patientsRepository` |
|
||||
| `MelissaAgenda` | `useMelissaAgenda` (composable orquestrador) |
|
||||
|
||||
Isso garante:
|
||||
- 0 duplicação de regras de negócio
|
||||
- Bugs corrigidos numa página antiga já valem na Melissa version
|
||||
- Migração futura pra route real (Fase 5) é trivial
|
||||
|
||||
## 18. Referência canônica
|
||||
|
||||
- **3 colunas + breakpoints xl+lg + popover Ações + skeletons**: `MelissaAgenda.vue`
|
||||
- **3 colunas com filtros + cards + quickview + drill-down mobile**: `MelissaPacientes.vue`
|
||||
- **CRUD catálogo simples (cor+nome+contagem)**: `MelissaTags.vue`, `MelissaGrupos.vue`
|
||||
- **Catálogo com mais campos**: `MelissaMedicos.vue`
|
||||
- **Lista + dialog de detalhes + ações finais**: `MelissaCadastrosRecebidos.vue`
|
||||
- **Cards com expansão (timeline/sessions)**: `MelissaRecorrencias.vue`
|
||||
- **Kanban N-col por status**: `MelissaConversas.vue`
|
||||
- **Reusa dialog externo**: `MelissaCompromissos.vue` (`DeterminedCommitmentDialog`)
|
||||
- **Wrapper**: `MelissaLayout.vue` (`layoutReady`, montagem das páginas, classe global `.melissa-skeleton`)
|
||||
- **Menu de navegação**: `MelissaMenu.vue` (drill-down mobile + drawer 360px)
|
||||
@@ -0,0 +1,20 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: status_evento_agenda + remarcado + confirmado
|
||||
-- ==========================================================================
|
||||
-- O enum tinha so {agendado, realizado, faltou, cancelado, remarcar}, mas
|
||||
-- o codigo e o trigger fn_notify_agenda_status_change (migration 20260423000009)
|
||||
-- referenciam 'remarcado' (state pos-remarcacao) e 'confirmado' (paciente
|
||||
-- confirmou presenca). Tentativas de UPDATE com esses valores falhavam com
|
||||
-- 'invalid input value for enum status_evento_agenda'.
|
||||
--
|
||||
-- ADD VALUE IF NOT EXISTS e idempotente. Dados existentes (status='remarcar')
|
||||
-- continuam validos — esses dois valores sao acrescimo, nao substituicao.
|
||||
--
|
||||
-- Refs:
|
||||
-- - src/features/agenda/pages/AgendaTerapeutaPage.vue:1339 (bloqueio por feriado)
|
||||
-- - src/features/agenda/services/agendaMappers.js:246,256 (cor/icone)
|
||||
-- - migration 20260423000009 (trigger de notificacao)
|
||||
-- ==========================================================================
|
||||
|
||||
ALTER TYPE public.status_evento_agenda ADD VALUE IF NOT EXISTS 'remarcado';
|
||||
ALTER TYPE public.status_evento_agenda ADD VALUE IF NOT EXISTS 'confirmado';
|
||||
@@ -99,6 +99,7 @@
|
||||
transition: opacity 0.12s;
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.layout-menuitem-text {
|
||||
|
||||
@@ -36,6 +36,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom PrimeVue */
|
||||
|
||||
.p-floatlabel-on:has(input.p-filled) label,
|
||||
.p-floatlabel-on:has(.p-inputwrapper-filled) label,
|
||||
.p-floatlabel-on:has(textarea.p-filled) label {
|
||||
padding: 2px 4px !important;
|
||||
border: 1px solid var(--p-inputtext-border-color);
|
||||
border-radius: 4px !important;
|
||||
left: 10px !important;
|
||||
top: -2px !important;
|
||||
}
|
||||
|
||||
.p-floatlabel > label[for="f_nasc"] {
|
||||
left: 44px !important;
|
||||
}
|
||||
|
||||
.p-floatlabel:has(input.p-filled) label, .p-floatlabel:has(textarea.p-filled) label, .p-floatlabel:has(.p-inputwrapper-filled) label {
|
||||
color: var(--p-floatlabel-focus-color) !important;
|
||||
}
|
||||
|
||||
.p-dialog-header {
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
.p-dialog-footer {
|
||||
padding: 0.6rem !important;
|
||||
}
|
||||
|
||||
/* Highlight pulse (acionado externamente via classe JS) */
|
||||
@keyframes highlight-pulse {
|
||||
0% {
|
||||
|
||||
@@ -181,9 +181,9 @@ function close () { emit('update:visible', false) }
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
|
||||
@@ -298,9 +298,9 @@ function close () {
|
||||
class="dc-dialog w-[50rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
|
||||
@@ -193,9 +193,9 @@ function skipProcedures() {
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
|
||||
}"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { isToday, isYesterday, differenceInDays } from 'date-fns';
|
||||
import {
|
||||
useNotificationStore,
|
||||
requestBrowserNotificationPermission,
|
||||
@@ -69,6 +70,26 @@ const drawerOpen = computed({
|
||||
|
||||
const displayedItems = computed(() => (filter.value === 'unread' ? store.unreadItems : store.allItems));
|
||||
|
||||
// Agrupa por bucket temporal: Hoje / Ontem / Esta semana / Mais antigas.
|
||||
// Mantém ordem original (já vem desc do store).
|
||||
const groupedItems = computed(() => {
|
||||
const buckets = { hoje: [], ontem: [], semana: [], antigas: [] };
|
||||
const now = new Date();
|
||||
for (const item of displayedItems.value) {
|
||||
const d = new Date(item.created_at);
|
||||
if (isToday(d)) buckets.hoje.push(item);
|
||||
else if (isYesterday(d)) buckets.ontem.push(item);
|
||||
else if (differenceInDays(now, d) <= 7) buckets.semana.push(item);
|
||||
else buckets.antigas.push(item);
|
||||
}
|
||||
return [
|
||||
{ key: 'hoje', label: 'Hoje', items: buckets.hoje },
|
||||
{ key: 'ontem', label: 'Ontem', items: buckets.ontem },
|
||||
{ key: 'semana', label: 'Esta semana', items: buckets.semana },
|
||||
{ key: 'antigas', label: 'Mais antigas', items: buckets.antigas }
|
||||
].filter((g) => g.items.length > 0);
|
||||
});
|
||||
|
||||
function handleRead(id) {
|
||||
store.markRead(id);
|
||||
// Fecha o drawer e deixa a navegação acontecer
|
||||
@@ -86,53 +107,109 @@ function goToHistory() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer v-model:visible="drawerOpen" position="right" :style="{ width: '380px' }" :pt="{ header: { class: 'notification-drawer__header' } }">
|
||||
<Drawer
|
||||
v-model:visible="drawerOpen"
|
||||
position="right"
|
||||
:style="{ width: '420px' }"
|
||||
:pt="{ header: { class: 'notification-drawer__header' } }"
|
||||
>
|
||||
<!-- Header -->
|
||||
<template #header>
|
||||
<div class="notification-drawer__header-content">
|
||||
<span class="notification-drawer__title">Notificações</span>
|
||||
<Badge v-if="store.unreadCount > 0" :value="store.unreadCount > 99 ? '99+' : store.unreadCount" severity="danger" />
|
||||
<Button
|
||||
:icon="browserNotifOn ? 'pi pi-bell' : 'pi pi-bell-slash'"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="ml-auto"
|
||||
<div class="notification-drawer__title-wrap">
|
||||
<span class="notification-drawer__title">Notificações</span>
|
||||
<span v-if="store.unreadCount > 0" class="notification-drawer__count-pill">
|
||||
{{ store.unreadCount > 99 ? '99+' : store.unreadCount }} não lida{{ store.unreadCount === 1 ? '' : 's' }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="notification-drawer__icon-btn"
|
||||
:class="{ 'notification-drawer__icon-btn--active': browserNotifOn }"
|
||||
:title="browserNotifOn ? 'Desativar notificações do browser' : 'Ativar notificações do browser'"
|
||||
@click="toggleBrowserNotif"
|
||||
/>
|
||||
>
|
||||
<i :class="['pi', browserNotifOn ? 'pi-bell' : 'pi-bell-slash']" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Corpo -->
|
||||
<div class="notification-drawer__body">
|
||||
<!-- Ação em lote -->
|
||||
<!-- Toolbar: tabs + mark-all -->
|
||||
<div class="notification-drawer__toolbar">
|
||||
<!-- Filtro tabs -->
|
||||
<div class="notification-drawer__tabs">
|
||||
<button class="notification-drawer__tab" :class="{ 'notification-drawer__tab--active': filter === 'unread' }" @click="filter = 'unread'">
|
||||
<div class="notification-drawer__tabs" role="tablist">
|
||||
<button
|
||||
class="notification-drawer__tab"
|
||||
:class="{ 'notification-drawer__tab--active': filter === 'unread' }"
|
||||
role="tab"
|
||||
:aria-selected="filter === 'unread'"
|
||||
@click="filter = 'unread'"
|
||||
>
|
||||
Não lidas
|
||||
<span v-if="store.unreadCount > 0" class="notification-drawer__tab-count">
|
||||
{{ store.unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
<button class="notification-drawer__tab" :class="{ 'notification-drawer__tab--active': filter === 'all' }" @click="filter = 'all'">Todas</button>
|
||||
<button
|
||||
class="notification-drawer__tab"
|
||||
:class="{ 'notification-drawer__tab--active': filter === 'all' }"
|
||||
role="tab"
|
||||
:aria-selected="filter === 'all'"
|
||||
@click="filter = 'all'"
|
||||
>Todas</button>
|
||||
</div>
|
||||
|
||||
<Button v-if="store.unreadCount > 0" link size="small" label="Marcar todas como lidas" @click="store.markAllRead()" class="notification-drawer__mark-all" />
|
||||
<button
|
||||
v-if="store.unreadCount > 0"
|
||||
type="button"
|
||||
class="notification-drawer__mark-all"
|
||||
@click="store.markAllRead()"
|
||||
>
|
||||
<i class="pi pi-check-square" />
|
||||
<span>Marcar todas</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<!-- Lista agrupada por data -->
|
||||
<div v-if="displayedItems.length > 0" class="notification-drawer__list">
|
||||
<NotificationItem v-for="item in displayedItems" :key="item.id" :item="item" @read="handleRead" @archive="handleArchive" />
|
||||
<section
|
||||
v-for="group in groupedItems"
|
||||
:key="group.key"
|
||||
class="notification-drawer__group"
|
||||
>
|
||||
<header class="notification-drawer__group-head">
|
||||
<span class="notification-drawer__group-label">{{ group.label }}</span>
|
||||
<span class="notification-drawer__group-line" />
|
||||
<span class="notification-drawer__group-count">{{ group.items.length }}</span>
|
||||
</header>
|
||||
<NotificationItem
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@read="handleRead"
|
||||
@archive="handleArchive"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="notification-drawer__empty">
|
||||
<i class="pi pi-bell-slash notification-drawer__empty-icon" />
|
||||
<p class="notification-drawer__empty-text">Tudo em dia por aqui 🎉</p>
|
||||
<p class="notification-drawer__empty-sub">Nenhuma notificação{{ filter === 'unread' ? ' não lida' : '' }}.</p>
|
||||
<div class="notification-drawer__empty-art" aria-hidden="true">
|
||||
<i class="pi pi-check-circle" />
|
||||
</div>
|
||||
<p class="notification-drawer__empty-text">Tudo em dia por aqui</p>
|
||||
<p class="notification-drawer__empty-sub">
|
||||
{{ filter === 'unread' ? 'Nenhuma notificação não lida no momento.' : 'Nenhuma notificação ainda.' }}
|
||||
</p>
|
||||
<button
|
||||
v-if="filter === 'unread' && store.allItems.length > 0"
|
||||
type="button"
|
||||
class="notification-drawer__empty-link"
|
||||
@click="filter = 'all'"
|
||||
>
|
||||
Ver tudo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +218,8 @@ function goToHistory() {
|
||||
<div class="notification-drawer__footer">
|
||||
<button class="notification-drawer__history-link" @click="goToHistory">
|
||||
<i class="pi pi-history" />
|
||||
Ver histórico completo →
|
||||
<span>Ver histórico completo</span>
|
||||
<i class="pi pi-arrow-right notification-drawer__history-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -149,59 +227,113 @@ function goToHistory() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ─── Header ─────────────────────────────────────────────────── */
|
||||
.notification-drawer__header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
}
|
||||
.notification-drawer__title-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.2rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.notification-drawer__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-color);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.notification-drawer__count-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--primary-color);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.notification-drawer__icon-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card, transparent);
|
||||
color: var(--text-color-secondary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.78rem;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.notification-drawer__icon-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.notification-drawer__icon-btn--active {
|
||||
background: color-mix(in srgb, var(--primary-color) 14%, transparent);
|
||||
color: var(--primary-color);
|
||||
border-color: color-mix(in srgb, var(--primary-color) 35%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Body ───────────────────────────────────────────────────── */
|
||||
.notification-drawer__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--surface-ground, #f8fafc) 60%, var(--surface-card, #fff));
|
||||
}
|
||||
|
||||
/* ─── Toolbar (tabs + mark-all) ───────────────────────────────── */
|
||||
.notification-drawer__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
background: var(--surface-card);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.notification-drawer__tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
background: var(--surface-100, color-mix(in srgb, var(--text-color) 6%, transparent));
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.notification-drawer__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--surface-border);
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.85rem;
|
||||
border-radius: 9999px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
.notification-drawer__tab:hover { color: var(--text-color); }
|
||||
.notification-drawer__tab--active {
|
||||
background: var(--primary-color);
|
||||
color: var(--primary-color-text);
|
||||
border-color: var(--primary-color);
|
||||
background: var(--surface-card, #fff);
|
||||
color: var(--primary-color);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.notification-drawer__tab-count {
|
||||
display: inline-flex;
|
||||
@@ -209,71 +341,169 @@ function goToHistory() {
|
||||
justify-content: center;
|
||||
min-width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
font-size: 0.7rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--primary-color) 16%, transparent);
|
||||
color: var(--primary-color);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.notification-drawer__tab--active .notification-drawer__tab-count {
|
||||
background: color-mix(in srgb, var(--primary-color) 22%, transparent);
|
||||
}
|
||||
|
||||
.notification-drawer__mark-all {
|
||||
white-space: nowrap;
|
||||
font-size: 0.78rem !important;
|
||||
padding: 0 !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.32rem 0.7rem;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
.notification-drawer__mark-all:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.notification-drawer__mark-all i { font-size: 0.78rem; }
|
||||
|
||||
/* ─── Lista + grupos por data ─────────────────────────────────── */
|
||||
.notification-drawer__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--surface-300, #cbd5e1) transparent;
|
||||
}
|
||||
.notification-drawer__list::-webkit-scrollbar { width: 6px; }
|
||||
.notification-drawer__list::-webkit-scrollbar-thumb {
|
||||
background: var(--surface-300, #cbd5e1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.notification-drawer__group {
|
||||
/* o NotificationItem já tem margin lateral */
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
.notification-drawer__group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 1rem 0.35rem;
|
||||
}
|
||||
.notification-drawer__group-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.85;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notification-drawer__group-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--surface-border);
|
||||
}
|
||||
.notification-drawer__group-count {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.6;
|
||||
padding: 1px 7px;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--text-color) 6%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Empty state ─────────────────────────────────────────────── */
|
||||
.notification-drawer__empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 3rem 1rem;
|
||||
gap: 0.6rem;
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.notification-drawer__empty-icon {
|
||||
font-size: 2.5rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.4;
|
||||
.notification-drawer__empty-art {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.notification-drawer__empty-art i { font-size: 1.8rem; }
|
||||
.notification-drawer__empty-text {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.notification-drawer__empty-sub {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.notification-drawer__empty-link {
|
||||
margin-top: 0.6rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--primary-color);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 9999px;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.notification-drawer__empty-link:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Footer ──────────────────────────────────────────────────── */
|
||||
.notification-drawer__footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notification-drawer__history-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
gap: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--primary-color);
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: opacity 0.15s;
|
||||
padding: 0.45rem 0.95rem;
|
||||
border-radius: 9999px;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.notification-drawer__history-link:hover {
|
||||
opacity: 0.75;
|
||||
text-decoration: underline;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
border-color: color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
}
|
||||
.notification-drawer__history-arrow {
|
||||
font-size: 0.7rem;
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
.notification-drawer__history-link:hover .notification-drawer__history-arrow {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,14 +34,17 @@ const store = useNotificationStore();
|
||||
const conversationDrawer = useConversationDrawerStore();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
// Cores por tipo (usadas pra ícone + chips). RGB explícito pra usar com
|
||||
// color-mix() no scoped CSS sem depender de tokens var().
|
||||
const typeMap = {
|
||||
new_scheduling: { icon: 'pi-inbox', border: 'border-red-500' },
|
||||
new_patient: { icon: 'pi-user-plus', border: 'border-sky-500' },
|
||||
recurrence_alert: { icon: 'pi-refresh', border: 'border-amber-500' },
|
||||
session_status: { icon: 'pi-calendar-times', border: 'border-orange-500' },
|
||||
inbound_message: { icon: 'pi-whatsapp', border: 'border-emerald-500' },
|
||||
system_alert: { icon: 'pi-exclamation-circle', border: 'border-red-600' }
|
||||
new_scheduling: { icon: 'pi-inbox', label: 'Agendamento', rgb: '244, 63, 94' }, // rose-500
|
||||
new_patient: { icon: 'pi-user-plus', label: 'Novo paciente', rgb: '14, 165, 233' }, // sky-500
|
||||
recurrence_alert: { icon: 'pi-refresh', label: 'Recorrência', rgb: '245, 158, 11' }, // amber-500
|
||||
session_status: { icon: 'pi-calendar-times', label: 'Sessão', rgb: '249, 115, 22' }, // orange-500
|
||||
inbound_message: { icon: 'pi-whatsapp', label: 'WhatsApp', rgb: '16, 185, 129' }, // emerald-500
|
||||
system_alert: { icon: 'pi-exclamation-circle', label: 'Alerta', rgb: '239, 68, 68' } // red-500
|
||||
};
|
||||
const DEFAULT_TYPE = { icon: 'pi-bell', label: '', rgb: '99, 102, 241' };
|
||||
|
||||
// Aliases semânticos do deeplink → rota real por role. Mesmo map do AppLayout.
|
||||
const DEEPLINK_ALIASES = {
|
||||
@@ -57,11 +60,17 @@ function resolveDeeplink(link) {
|
||||
return alias[role] || alias.therapist;
|
||||
}
|
||||
|
||||
const meta = computed(() => typeMap[props.item.type] || { icon: 'pi-bell', border: 'border-gray-300' });
|
||||
const meta = computed(() => typeMap[props.item.type] || DEFAULT_TYPE);
|
||||
const isUnread = computed(() => !props.item.read_at);
|
||||
|
||||
const timeAgo = computed(() => formatDistanceToNow(new Date(props.item.created_at), { addSuffix: true, locale: ptBR }));
|
||||
|
||||
// CSS vars injetadas no item via :style — permite color-mix() no scoped CSS
|
||||
// sem precisar de N classes. `--type-rgb` segue a paleta do typeMap.
|
||||
const itemStyle = computed(() => ({
|
||||
'--type-rgb': meta.value.rgb
|
||||
}));
|
||||
|
||||
const initials = computed(() => props.item.payload?.avatar_initials || '?');
|
||||
|
||||
async function openConversationByThreadKey(threadKey) {
|
||||
@@ -161,45 +170,54 @@ function handleArchive(e) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notif-item" :class="[meta.border, isUnread ? 'notif-item--unread' : '']" role="button" tabindex="0" @click="handleRowClick" @keydown.enter="handleRowClick">
|
||||
<!-- Ícone do tipo -->
|
||||
<div class="notif-item__icon" aria-hidden="true">
|
||||
<i :class="['pi', meta.icon]" />
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="notif-item__avatar" aria-hidden="true">
|
||||
{{ initials }}
|
||||
<div
|
||||
class="notif-item"
|
||||
:class="{ 'notif-item--unread': isUnread }"
|
||||
:style="itemStyle"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleRowClick"
|
||||
@keydown.enter="handleRowClick"
|
||||
>
|
||||
<!-- Ícone do tipo (avatar de iniciais quando vier do payload, senão o ícone) -->
|
||||
<div class="notif-item__type" aria-hidden="true">
|
||||
<span v-if="item.payload?.avatar_initials" class="notif-item__avatar">{{ initials }}</span>
|
||||
<i v-else :class="['pi', meta.icon, 'notif-item__icon']" />
|
||||
<span v-if="isUnread" class="notif-item__unread-dot" aria-label="Não lida" />
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="notif-item__body">
|
||||
<div class="notif-item__head">
|
||||
<span class="notif-item__type-label">{{ meta.label }}</span>
|
||||
<span class="notif-item__time">{{ timeAgo }}</span>
|
||||
</div>
|
||||
<p class="notif-item__title">{{ item.payload?.title }}</p>
|
||||
<p class="notif-item__detail">{{ item.payload?.detail }}</p>
|
||||
<div class="notif-item__footer">
|
||||
<p class="notif-item__time">{{ timeAgo }}</p>
|
||||
<div v-if="item.payload?.thread_key || item.payload?.deeplink" class="notif-item__quick" @click.stop>
|
||||
<button
|
||||
v-if="item.payload?.thread_key"
|
||||
class="notif-quick-btn"
|
||||
title="Abrir conversa"
|
||||
@click="handleOpenConversation">
|
||||
<i class="pi pi-comment" />
|
||||
<span>Conversa</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.payload?.deeplink"
|
||||
class="notif-quick-btn"
|
||||
:title="item.payload?.actionLabel || 'Abrir'"
|
||||
@click="handleOpenDeeplink">
|
||||
<i class="pi pi-arrow-right" />
|
||||
<span>{{ item.payload?.actionLabel || 'Abrir' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="item.payload?.detail" class="notif-item__detail">{{ item.payload.detail }}</p>
|
||||
|
||||
<div v-if="item.payload?.thread_key || item.payload?.deeplink" class="notif-item__quick" @click.stop>
|
||||
<button
|
||||
v-if="item.payload?.thread_key"
|
||||
class="notif-quick-btn"
|
||||
title="Abrir conversa"
|
||||
@click="handleOpenConversation"
|
||||
>
|
||||
<i class="pi pi-comment" />
|
||||
<span>Conversa</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.payload?.deeplink"
|
||||
class="notif-quick-btn notif-quick-btn--primary"
|
||||
:title="item.payload?.actionLabel || 'Abrir'"
|
||||
@click="handleOpenDeeplink"
|
||||
>
|
||||
<span>{{ item.payload?.actionLabel || 'Abrir' }}</span>
|
||||
<i class="pi pi-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<!-- Ações secundárias (revelam no hover) -->
|
||||
<div class="notif-item__actions" @click.stop>
|
||||
<button v-if="isUnread" class="notif-item__btn" title="Marcar como lida" @click="handleMarkRead">
|
||||
<i class="pi pi-check" />
|
||||
@@ -212,142 +230,216 @@ function handleArchive(e) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Item-card: layout flat, mas com type-color como spine visual.
|
||||
--type-rgb é injetado inline (vem do typeMap). color-mix() pinta
|
||||
bg/border/icon usando a mesma cor — uma var, várias intensidades. */
|
||||
.notif-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left-width: 3px;
|
||||
border-left-style: solid;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: transparent;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto;
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.85rem 0.75rem 0.95rem;
|
||||
margin: 0.4rem 0.6rem;
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
/* Spine colorida: 3px na esquerda da cor do tipo. Reforça pertencimento
|
||||
sem usar background pesado. */
|
||||
.notif-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10%;
|
||||
bottom: 10%;
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: rgb(var(--type-rgb));
|
||||
opacity: 0.55;
|
||||
transition: opacity 160ms ease, top 160ms ease, bottom 160ms ease;
|
||||
}
|
||||
.notif-item:hover {
|
||||
background: var(--surface-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, rgb(var(--type-rgb)) 12%, transparent);
|
||||
border-color: color-mix(in srgb, rgb(var(--type-rgb)) 40%, var(--surface-border));
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.notif-item:hover::before { opacity: 1; top: 0; bottom: 0; }
|
||||
.notif-item:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, rgb(var(--type-rgb)) 60%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* Não-lida: bg sutil tinted da cor do tipo + título mais forte */
|
||||
.notif-item--unread {
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
background: color-mix(in srgb, rgb(var(--type-rgb)) 6%, var(--surface-card, #fff));
|
||||
border-color: color-mix(in srgb, rgb(var(--type-rgb)) 22%, var(--surface-border));
|
||||
}
|
||||
.notif-item--unread:hover {
|
||||
background: rgba(99, 102, 241, 0.09);
|
||||
background: color-mix(in srgb, rgb(var(--type-rgb)) 9%, var(--surface-card, #fff));
|
||||
}
|
||||
|
||||
.notif-item__icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 0.15rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.notif-item__avatar {
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
/* Type avatar: círculo 40px com bg type-color 14% + ícone 100% */
|
||||
.notif-item__type {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6366f1, #38bdf8);
|
||||
color: #fff;
|
||||
font-size: 0.68rem;
|
||||
background: color-mix(in srgb, rgb(var(--type-rgb)) 14%, transparent);
|
||||
color: rgb(var(--type-rgb));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notif-item__icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.notif-item__avatar {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgb(var(--type-rgb));
|
||||
}
|
||||
|
||||
/* Pulse dot pra "não lida" — fica no canto sup-direito do avatar */
|
||||
.notif-item__unread-dot {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--type-rgb));
|
||||
border: 2px solid var(--surface-card, #fff);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, rgb(var(--type-rgb)) 50%, transparent);
|
||||
animation: notif-unread-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes notif-unread-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, rgb(var(--type-rgb)) 50%, transparent); }
|
||||
50% { box-shadow: 0 0 0 5px color-mix(in srgb, rgb(var(--type-rgb)) 0%, transparent); }
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.notif-item__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.notif-item__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
.notif-item__type-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgb(var(--type-rgb));
|
||||
opacity: 0.85;
|
||||
}
|
||||
.notif-item__time {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.75;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notif-item__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-color);
|
||||
margin: 0 0 0.1rem;
|
||||
white-space: nowrap;
|
||||
margin: 0 0 0.15rem;
|
||||
line-height: 1.3;
|
||||
/* 2 linhas máx */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.notif-item--unread .notif-item__title { font-weight: 700; }
|
||||
.notif-item__detail {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0 0 0.1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.notif-item__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.notif-item__time {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Quick actions: chips type-color */
|
||||
.notif-item__quick {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
.notif-quick-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
color: var(--text-color-secondary);
|
||||
gap: 0.3rem;
|
||||
padding: 0.28rem 0.7rem;
|
||||
border: 1px solid color-mix(in srgb, rgb(var(--type-rgb)) 28%, var(--surface-border));
|
||||
background: color-mix(in srgb, rgb(var(--type-rgb)) 8%, transparent);
|
||||
color: rgb(var(--type-rgb));
|
||||
border-radius: 9999px;
|
||||
font-size: 0.68rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.notif-quick-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-color);
|
||||
border-color: var(--text-color-secondary);
|
||||
background: color-mix(in srgb, rgb(var(--type-rgb)) 16%, transparent);
|
||||
border-color: color-mix(in srgb, rgb(var(--type-rgb)) 50%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.notif-quick-btn i {
|
||||
font-size: 0.65rem;
|
||||
.notif-quick-btn i { font-size: 0.7rem; }
|
||||
.notif-quick-btn--primary {
|
||||
background: rgb(var(--type-rgb));
|
||||
color: #fff;
|
||||
border-color: rgb(var(--type-rgb));
|
||||
}
|
||||
.notif-quick-btn--primary:hover {
|
||||
background: color-mix(in srgb, rgb(var(--type-rgb)) 88%, #000 12%);
|
||||
border-color: color-mix(in srgb, rgb(var(--type-rgb)) 88%, #000 12%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Ações secundárias (mark-read / archive): revelam no hover */
|
||||
.notif-item__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
gap: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
transform: translateX(4px);
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
.notif-item:hover .notif-item__actions {
|
||||
.notif-item:hover .notif-item__actions,
|
||||
.notif-item:focus-within .notif-item__actions {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.notif-item__btn {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
background: var(--surface-100, var(--surface-hover, rgba(0,0,0,0.04)));
|
||||
color: var(--text-color-secondary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
font-size: 0.7rem;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.notif-item__btn:hover {
|
||||
background: var(--surface-border);
|
||||
color: var(--text-color);
|
||||
background: color-mix(in srgb, rgb(var(--type-rgb)) 12%, transparent);
|
||||
color: rgb(var(--type-rgb));
|
||||
border-color: color-mix(in srgb, rgb(var(--type-rgb)) 30%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -126,18 +126,22 @@ watch(() => props.entityId, async (v) => {
|
||||
if (v) await api.loadEmails(props.entityType, v);
|
||||
else api.emails.value = [];
|
||||
});
|
||||
|
||||
// Re-emite `change` sempre que a lista mudar (load, add, edit, remove,
|
||||
// setPrimary). Permite que o parent trackee count e faça validação de
|
||||
// "pelo menos 1 email obrigatório". immediate:true garante emit no load.
|
||||
watch(api.emails, (arr) => emit('change', arr), { deep: true, immediate: true });
|
||||
|
||||
// Exposto pro parent — flush em lote dos emails pendentes (modo "novo
|
||||
// paciente" antes do save). Ver doc no useContactEmails.flushPending.
|
||||
async function flushPending(entityType, entityId) {
|
||||
return api.flushPending(entityType, entityId);
|
||||
}
|
||||
defineExpose({ flushPending });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)] flex items-start gap-1.5 px-1">
|
||||
<i class="pi pi-info-circle text-sky-500 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Marque um email como <strong>principal</strong> — ele é usado pra
|
||||
<strong>envio de faturas, templates e notificações por email</strong>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="api.loading.value" class="text-xs text-center py-3 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-spin pi-spinner mr-1" /> Carregando…
|
||||
</div>
|
||||
@@ -232,6 +236,7 @@ watch(() => props.entityId, async (v) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botão + (sempre habilitado: sem entityId vai pro modo pendente) -->
|
||||
<Button
|
||||
v-if="!readonly && !showAddForm"
|
||||
label="Adicionar email"
|
||||
@@ -240,8 +245,6 @@ watch(() => props.entityId, async (v) => {
|
||||
outlined
|
||||
size="small"
|
||||
class="self-start rounded-full"
|
||||
:disabled="!props.entityId"
|
||||
v-tooltip.right="!props.entityId ? 'Salve o cadastro primeiro pra adicionar emails' : null"
|
||||
@click="openAddForm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -153,20 +153,24 @@ watch(() => props.entityId, async (v) => {
|
||||
if (v) await api.loadPhones(props.entityType, v);
|
||||
else api.phones.value = [];
|
||||
});
|
||||
|
||||
// Re-emite `change` sempre que a lista mudar (load, add, edit, remove,
|
||||
// setPrimary). Permite que o parent trackee count e faça validação de
|
||||
// "pelo menos 1 telefone obrigatório" sem precisar inspeccionar o
|
||||
// componente. immediate:true garante emit no load inicial.
|
||||
watch(api.phones, (arr) => emit('change', arr), { deep: true, immediate: true });
|
||||
|
||||
// Exposto pro parent — usado pelo PatientsCadastroPage no fluxo de criação:
|
||||
// telefones inseridos antes de salvar o paciente ficam em modo pendente
|
||||
// (id: 'pending_*') e são gravados em lote depois que o paciente recebe id.
|
||||
async function flushPending(entityType, entityId) {
|
||||
return api.flushPending(entityType, entityId);
|
||||
}
|
||||
defineExpose({ flushPending });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<!-- Aviso sobre telefone principal -->
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)] flex items-start gap-1.5 px-1">
|
||||
<i class="pi pi-info-circle text-sky-500 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Marque um telefone como <strong>principal</strong> — ele é usado pra
|
||||
<strong>cobranças, lembretes automáticos e contato padrão</strong>.
|
||||
Número vindo do CRM WhatsApp recebe a etiqueta <strong>"vinculado"</strong>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Lista de telefones -->
|
||||
<div v-if="api.loading.value" class="text-xs text-center py-3 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-spin pi-spinner mr-1" /> Carregando…
|
||||
@@ -325,7 +329,7 @@ watch(() => props.entityId, async (v) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botão + -->
|
||||
<!-- Botão + (sempre habilitado: sem entityId vai pro modo pendente) -->
|
||||
<Button
|
||||
v-if="!readonly && !showAddForm"
|
||||
label="Adicionar telefone"
|
||||
@@ -334,8 +338,6 @@ watch(() => props.entityId, async (v) => {
|
||||
outlined
|
||||
size="small"
|
||||
class="self-start rounded-full"
|
||||
:disabled="!props.entityId"
|
||||
v-tooltip.right="!props.entityId ? 'Salve o cadastro primeiro pra adicionar telefones' : null"
|
||||
@click="openAddForm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,11 @@ async function onCreated(data) {
|
||||
:closable="false"
|
||||
:dismissableMask="false"
|
||||
:maximizable="false"
|
||||
:style="{ width: '90vw', maxWidth: '1100px', height: maximized ? '100vh' : '90vh' }"
|
||||
:style="{
|
||||
width: maximized ? '100vw' : '90vw',
|
||||
maxWidth: maximized ? 'none' : '1100px',
|
||||
height: maximized ? '100vh' : '90vh'
|
||||
}"
|
||||
:contentStyle="{ padding: 0, overflow: 'auto', height: '100%' }"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
|
||||
@@ -19,6 +19,14 @@ function normalizeEmail(raw) {
|
||||
return String(raw || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Mesma estratégia do useContactPhones: emails sem entidade ficam em
|
||||
// memória com id 'pending_*' até flushPending gravar tudo em lote.
|
||||
const PENDING_PREFIX = 'pending_';
|
||||
function isPending(id) { return typeof id === 'string' && id.startsWith(PENDING_PREFIX); }
|
||||
function genPendingId() {
|
||||
return `${PENDING_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function useContactEmails() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
@@ -77,12 +85,34 @@ export function useContactEmails() {
|
||||
}
|
||||
|
||||
async function addEmail(entityType, entityId, { contact_email_type_id, email, is_primary = false, notes = null }) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const clean = normalizeEmail(email);
|
||||
if (!tenantId || !entityType || !entityId) return { ok: false, error: 'invalid_context' };
|
||||
if (!contact_email_type_id) return { ok: false, error: 'Tipo obrigatório' };
|
||||
if (!clean || !EMAIL_RE.test(clean)) return { ok: false, error: 'Email inválido' };
|
||||
|
||||
// Modo pendente: entidade ainda não existe — mantém em memória até flushPending.
|
||||
if (!entityType || !entityId) {
|
||||
const wasFirst = emails.value.length === 0;
|
||||
if (is_primary || wasFirst) {
|
||||
emails.value.forEach((e) => { e.is_primary = false; });
|
||||
is_primary = true;
|
||||
}
|
||||
const maxPos = emails.value.reduce((m, e) => Math.max(m, e.position || 0), 0);
|
||||
const tempEmail = {
|
||||
id: genPendingId(),
|
||||
contact_email_type_id,
|
||||
email: clean,
|
||||
is_primary,
|
||||
notes,
|
||||
position: maxPos + 10,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
emails.value = [...emails.value, tempEmail];
|
||||
return { ok: true, email: tempEmail };
|
||||
}
|
||||
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (is_primary) {
|
||||
@@ -117,15 +147,28 @@ export function useContactEmails() {
|
||||
}
|
||||
|
||||
async function updateEmail(entityType, entityId, id, patch) {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.email !== undefined) {
|
||||
sanitized.email = normalizeEmail(sanitized.email);
|
||||
if (!sanitized.email || !EMAIL_RE.test(sanitized.email)) {
|
||||
return { ok: false, error: 'Email inválido' };
|
||||
}
|
||||
}
|
||||
|
||||
// Pending: muta no array local, sem DB
|
||||
if (isPending(id) || !entityType || !entityId) {
|
||||
const idx = emails.value.findIndex((e) => e.id === id);
|
||||
if (idx === -1) return { ok: false, error: 'not_found' };
|
||||
if (sanitized.is_primary === true) {
|
||||
emails.value.forEach((e, i) => { if (i !== idx) e.is_primary = false; });
|
||||
}
|
||||
emails.value[idx] = { ...emails.value[idx], ...sanitized };
|
||||
emails.value = [...emails.value];
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.email !== undefined) {
|
||||
sanitized.email = normalizeEmail(sanitized.email);
|
||||
if (!sanitized.email || !EMAIL_RE.test(sanitized.email)) {
|
||||
return { ok: false, error: 'Email inválido' };
|
||||
}
|
||||
}
|
||||
if (sanitized.is_primary === true) {
|
||||
await unsetOtherPrimaries(entityType, entityId, id);
|
||||
}
|
||||
@@ -141,6 +184,19 @@ export function useContactEmails() {
|
||||
}
|
||||
|
||||
async function removeEmail(entityType, entityId, id) {
|
||||
// Pending: tira do array + promove próximo a primary se necessário
|
||||
if (isPending(id) || !entityType || !entityId) {
|
||||
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
|
||||
emails.value = emails.value.filter((e) => e.id !== id);
|
||||
if (wasPrimary && emails.value.length > 0) {
|
||||
const remaining = [...emails.value].sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
emails.value = emails.value.map((e) =>
|
||||
e.id === remaining[0].id ? { ...e, is_primary: true } : e
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
|
||||
@@ -161,6 +217,36 @@ export function useContactEmails() {
|
||||
}
|
||||
}
|
||||
|
||||
// Grava em lote os emails que estavam em modo pendente.
|
||||
async function flushPending(entityType, entityId) {
|
||||
if (!entityType || !entityId) return { ok: false, error: 'missing_entity' };
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
||||
const pendingItems = emails.value.filter((e) => isPending(e.id));
|
||||
if (pendingItems.length === 0) return { ok: true, count: 0 };
|
||||
saving.value = true;
|
||||
try {
|
||||
const rows = pendingItems.map((e) => ({
|
||||
tenant_id: tenantId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
contact_email_type_id: e.contact_email_type_id,
|
||||
email: normalizeEmail(e.email),
|
||||
is_primary: !!e.is_primary,
|
||||
notes: e.notes || null,
|
||||
position: e.position
|
||||
}));
|
||||
const { error } = await supabase.from('contact_emails').insert(rows);
|
||||
if (error) throw error;
|
||||
await loadEmails(entityType, entityId);
|
||||
return { ok: true, count: rows.length };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'flush_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimary(entityType, entityId, id) {
|
||||
return updateEmail(entityType, entityId, id, { is_primary: true });
|
||||
}
|
||||
@@ -179,6 +265,7 @@ export function useContactEmails() {
|
||||
updateEmail,
|
||||
removeEmail,
|
||||
setPrimary,
|
||||
flushPending,
|
||||
typeBySlug,
|
||||
typeById
|
||||
};
|
||||
|
||||
@@ -17,6 +17,15 @@ function normalizeDigits(raw) {
|
||||
return String(raw || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
// Telefones em "modo pendente" (entidade ainda não existe no DB) usam ID
|
||||
// com este prefixo. Permite reusar o mesmo array `phones` na UI sem
|
||||
// sub-state e detectar quais precisam de INSERT no flushPending.
|
||||
const PENDING_PREFIX = 'pending_';
|
||||
function isPending(id) { return typeof id === 'string' && id.startsWith(PENDING_PREFIX); }
|
||||
function genPendingId() {
|
||||
return `${PENDING_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function useContactPhones() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
@@ -76,12 +85,37 @@ export function useContactPhones() {
|
||||
}
|
||||
|
||||
async function addPhone(entityType, entityId, { contact_type_id, number, is_primary = false, whatsapp_linked_at = null, notes = null }) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const digits = normalizeDigits(number);
|
||||
if (!tenantId || !entityType || !entityId) return { ok: false, error: 'invalid_context' };
|
||||
if (!contact_type_id) return { ok: false, error: 'Tipo de contato obrigatório' };
|
||||
if (!digits || digits.length < 8 || digits.length > 15) return { ok: false, error: 'Telefone inválido' };
|
||||
|
||||
// Modo pendente: entidade ainda não existe (ex: novo paciente sendo
|
||||
// cadastrado). Mantém em memória — flushPending grava tudo em lote
|
||||
// depois que a entidade for criada.
|
||||
if (!entityType || !entityId) {
|
||||
const wasFirst = phones.value.length === 0;
|
||||
if (is_primary || wasFirst) {
|
||||
phones.value.forEach((p) => { p.is_primary = false; });
|
||||
is_primary = true;
|
||||
}
|
||||
const maxPos = phones.value.reduce((m, p) => Math.max(m, p.position || 0), 0);
|
||||
const tempPhone = {
|
||||
id: genPendingId(),
|
||||
contact_type_id,
|
||||
number: digits,
|
||||
is_primary,
|
||||
whatsapp_linked_at,
|
||||
notes,
|
||||
position: maxPos + 10,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
phones.value = [...phones.value, tempPhone];
|
||||
return { ok: true, phone: tempPhone };
|
||||
}
|
||||
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
// Se marcou como primary, desmarca outros
|
||||
@@ -119,14 +153,26 @@ export function useContactPhones() {
|
||||
}
|
||||
|
||||
async function updatePhone(entityType, entityId, id, patch) {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.number !== undefined) sanitized.number = normalizeDigits(sanitized.number);
|
||||
if (sanitized.number && (sanitized.number.length < 8 || sanitized.number.length > 15)) {
|
||||
return { ok: false, error: 'Telefone inválido' };
|
||||
}
|
||||
|
||||
// Pending: muta no array local sem ir pro DB
|
||||
if (isPending(id) || !entityType || !entityId) {
|
||||
const idx = phones.value.findIndex((p) => p.id === id);
|
||||
if (idx === -1) return { ok: false, error: 'not_found' };
|
||||
if (sanitized.is_primary === true) {
|
||||
phones.value.forEach((p, i) => { if (i !== idx) p.is_primary = false; });
|
||||
}
|
||||
phones.value[idx] = { ...phones.value[idx], ...sanitized };
|
||||
phones.value = [...phones.value];
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.number !== undefined) sanitized.number = normalizeDigits(sanitized.number);
|
||||
if (sanitized.number && (sanitized.number.length < 8 || sanitized.number.length > 15)) {
|
||||
return { ok: false, error: 'Telefone inválido' };
|
||||
}
|
||||
|
||||
if (sanitized.is_primary === true) {
|
||||
await unsetOtherPrimaries(entityType, entityId, id);
|
||||
}
|
||||
@@ -146,6 +192,19 @@ export function useContactPhones() {
|
||||
}
|
||||
|
||||
async function removePhone(entityType, entityId, id) {
|
||||
// Pending: tira do array local + promove o próximo a primary se sumiu
|
||||
if (isPending(id) || !entityType || !entityId) {
|
||||
const wasPrimary = phones.value.find((p) => p.id === id)?.is_primary;
|
||||
phones.value = phones.value.filter((p) => p.id !== id);
|
||||
if (wasPrimary && phones.value.length > 0) {
|
||||
const remaining = [...phones.value].sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
phones.value = phones.value.map((p) =>
|
||||
p.id === remaining[0].id ? { ...p, is_primary: true } : p
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const wasPrimary = phones.value.find((p) => p.id === id)?.is_primary;
|
||||
@@ -171,6 +230,40 @@ export function useContactPhones() {
|
||||
}
|
||||
}
|
||||
|
||||
// Grava em lote os telefones que estavam em modo pendente. Chamado pelo
|
||||
// parent (ex: PatientsCadastroPage) logo depois de criar a entidade no DB.
|
||||
// Mantém ordem (position) e o flag is_primary do estado local.
|
||||
async function flushPending(entityType, entityId) {
|
||||
if (!entityType || !entityId) return { ok: false, error: 'missing_entity' };
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
||||
const pendingItems = phones.value.filter((p) => isPending(p.id));
|
||||
if (pendingItems.length === 0) return { ok: true, count: 0 };
|
||||
saving.value = true;
|
||||
try {
|
||||
const rows = pendingItems.map((p) => ({
|
||||
tenant_id: tenantId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
contact_type_id: p.contact_type_id,
|
||||
number: normalizeDigits(p.number),
|
||||
is_primary: !!p.is_primary,
|
||||
whatsapp_linked_at: p.whatsapp_linked_at || null,
|
||||
notes: p.notes || null,
|
||||
position: p.position
|
||||
}));
|
||||
const { error } = await supabase.from('contact_phones').insert(rows);
|
||||
if (error) throw error;
|
||||
// Recarrega do DB pra ter IDs reais — substitui os pending_* por uuids.
|
||||
await loadPhones(entityType, entityId);
|
||||
return { ok: true, count: rows.length };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'flush_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimary(entityType, entityId, id) {
|
||||
return updatePhone(entityType, entityId, id, { is_primary: true });
|
||||
}
|
||||
@@ -193,6 +286,7 @@ export function useContactPhones() {
|
||||
updatePhone,
|
||||
removePhone,
|
||||
setPrimary,
|
||||
flushPending,
|
||||
typeBySlug,
|
||||
typeById
|
||||
};
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* useTopbarPlanMenu — DEV-only switcher de subscription_plan no topbar.
|
||||
*
|
||||
* Extraído do AppTopbar.vue pra ser reusado pelo Melissa (e qualquer
|
||||
* topbar futuro). Encapsula toda a máquina de estados:
|
||||
* - resolve subscription ativa do contexto (clinic vs therapist)
|
||||
* - lista plans ativos do target
|
||||
* - ordena (free primeiro)
|
||||
* - troca via RPC `change_subscription_plan` + invalida entitlements
|
||||
*
|
||||
* Visibilidade controlada por `showPlanDevMenu` (DEV mode + feature flag
|
||||
* `VITE_ENABLE_PLAN_TOGGLE` + permissão settings.view).
|
||||
*
|
||||
* Uso:
|
||||
* const { planBtn, planMenu, planMenuModel, planMenuLoading,
|
||||
* trocandoPlano, showPlanDevMenu, openPlanMenu } = useTopbarPlanMenu();
|
||||
*
|
||||
* <Button v-if="showPlanDevMenu" ref="planBtn" :loading="planMenuLoading || trocandoPlano" @click="openPlanMenu">…</Button>
|
||||
* <Menu ref="planMenu" :model="planMenuModel" popup appendTo="body" />
|
||||
*/
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore';
|
||||
import { useRoleGuard } from '@/composables/useRoleGuard';
|
||||
|
||||
export function useTopbarPlanMenu() {
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
const entitlementsStore = useEntitlementsStore();
|
||||
const { canSee } = useRoleGuard();
|
||||
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || null);
|
||||
|
||||
const planBtn = ref(null);
|
||||
const planMenu = ref(null);
|
||||
const planMenuLoading = ref(false);
|
||||
const planMenuTarget = ref(null); // 'therapist' | 'clinic' | null
|
||||
const planMenuSub = ref(null); // subscription ativa
|
||||
const planMenuPlans = ref([]); // plans ativos do target
|
||||
const trocandoPlano = ref(false);
|
||||
|
||||
const enablePlanToggle = computed(() => {
|
||||
const flag = String(import.meta.env?.VITE_ENABLE_PLAN_TOGGLE || '').toLowerCase();
|
||||
return Boolean(import.meta.env?.DEV) || flag === 'true';
|
||||
});
|
||||
|
||||
const showPlanDevMenu = computed(() => canSee('settings.view') && enablePlanToggle.value);
|
||||
|
||||
async function getMyUserId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Sessão inválida (sem user).');
|
||||
return uid;
|
||||
}
|
||||
|
||||
async function getActiveTherapistSubscription() {
|
||||
const uid = await getMyUserId();
|
||||
const { data, error } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, tenant_id, user_id, plan_id, status, updated_at')
|
||||
.eq('user_id', uid)
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(10);
|
||||
if (error) throw error;
|
||||
const list = data || [];
|
||||
if (!list.length) return null;
|
||||
const priority = (st) => {
|
||||
const s = String(st || '').toLowerCase();
|
||||
if (s === 'active') return 1;
|
||||
if (s === 'trialing') return 2;
|
||||
if (s === 'past_due') return 3;
|
||||
if (s === 'unpaid') return 4;
|
||||
if (s === 'incomplete') return 5;
|
||||
if (s === 'canceled' || s === 'cancelled') return 9;
|
||||
return 8;
|
||||
};
|
||||
return list.slice().sort((a, b) => {
|
||||
const pa = priority(a?.status);
|
||||
const pb = priority(b?.status);
|
||||
if (pa !== pb) return pa - pb;
|
||||
return new Date(b?.updated_at || 0) - new Date(a?.updated_at || 0);
|
||||
})[0];
|
||||
}
|
||||
|
||||
async function getActiveClinicSubscription() {
|
||||
const tid = tenantId.value;
|
||||
if (!tid) return null;
|
||||
const { data, error } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, tenant_id, user_id, plan_id, status, updated_at')
|
||||
.eq('tenant_id', tid)
|
||||
.eq('status', 'active')
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
}
|
||||
|
||||
async function listActivePlansByTarget(target) {
|
||||
const { data, error } = await supabase
|
||||
.from('plans')
|
||||
.select('id, key, target, is_active')
|
||||
.eq('target', target)
|
||||
.eq('is_active', true)
|
||||
.order('key', { ascending: true });
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
async function refreshEntitlementsAfterToggle(target) {
|
||||
if (target === 'clinic') {
|
||||
const tid = tenantId.value;
|
||||
if (!tid) return;
|
||||
await entitlementsStore.loadForTenant(tid, { force: true });
|
||||
return;
|
||||
}
|
||||
const uid = await getMyUserId();
|
||||
await entitlementsStore.loadForUser(uid, { force: true });
|
||||
}
|
||||
|
||||
// Áreas de clínica (/admin, /supervisor) → contexto tenant_id (clinic).
|
||||
// Demais áreas (/therapist, /editor, /portal, etc.) → contexto user_id.
|
||||
// Em /melissa não há um caminho semântico de área, então cai no fallback
|
||||
// therapist (que é o role mais comum do user que escolhe Melissa).
|
||||
async function resolveActiveSubscriptionContext() {
|
||||
const path = route.path || '';
|
||||
const isClinicContext = path.startsWith('/admin') || path.startsWith('/supervisor');
|
||||
|
||||
if (isClinicContext && tenantId.value) {
|
||||
const clinicSub = await getActiveClinicSubscription();
|
||||
if (clinicSub) return { sub: clinicSub, target: 'clinic' };
|
||||
}
|
||||
|
||||
const therapistSub = await getActiveTherapistSubscription();
|
||||
if (therapistSub) return { sub: therapistSub, target: 'therapist' };
|
||||
|
||||
if (tenantId.value) {
|
||||
const clinicSub = await getActiveClinicSubscription();
|
||||
return { sub: clinicSub || null, target: clinicSub ? 'clinic' : null };
|
||||
}
|
||||
return { sub: null, target: null };
|
||||
}
|
||||
|
||||
function normalizeKey(k) {
|
||||
return String(k || '').trim();
|
||||
}
|
||||
|
||||
// free primeiro, depois o resto por key
|
||||
function sortPlansSmart(plans) {
|
||||
const arr = [...(plans || [])];
|
||||
arr.sort((a, b) => {
|
||||
const ak = normalizeKey(a?.key).toLowerCase();
|
||||
const bk = normalizeKey(b?.key).toLowerCase();
|
||||
const aIsFree = ak.endsWith('_free') || ak === 'free';
|
||||
const bIsFree = bk.endsWith('_free') || bk === 'free';
|
||||
if (aIsFree && !bIsFree) return -1;
|
||||
if (!aIsFree && bIsFree) return 1;
|
||||
return ak.localeCompare(bk);
|
||||
});
|
||||
return arr;
|
||||
}
|
||||
|
||||
async function loadPlanMenuData() {
|
||||
planMenuLoading.value = true;
|
||||
try {
|
||||
const { sub, target } = await resolveActiveSubscriptionContext();
|
||||
planMenuSub.value = sub;
|
||||
planMenuTarget.value = target;
|
||||
if (!sub?.id || !target) {
|
||||
planMenuPlans.value = [];
|
||||
return;
|
||||
}
|
||||
const plans = await listActivePlansByTarget(target);
|
||||
planMenuPlans.value = sortPlansSmart(plans);
|
||||
} finally {
|
||||
planMenuLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const planMenuModel = computed(() => {
|
||||
const sub = planMenuSub.value;
|
||||
const target = planMenuTarget.value;
|
||||
const plans = planMenuPlans.value || [];
|
||||
|
||||
if (!sub?.id || !target) {
|
||||
return [
|
||||
{ label: 'Sem assinatura ativa', icon: 'pi pi-exclamation-triangle', disabled: true },
|
||||
{ label: 'Não encontrei subscription ativa nem para therapist (user_id) nem para clinic (tenant_id).', disabled: true }
|
||||
];
|
||||
}
|
||||
|
||||
const currentPlanId = String(sub.plan_id || '');
|
||||
|
||||
const header = {
|
||||
label: `Planos (${target})`,
|
||||
icon: target === 'therapist' ? 'pi pi-user' : 'pi pi-building',
|
||||
disabled: true
|
||||
};
|
||||
|
||||
const subInfo = {
|
||||
label: `Sub: ${String(sub.id).slice(0, 8)}… • Atual: ${String(currentPlanId).slice(0, 8)}…`,
|
||||
icon: 'pi pi-info-circle',
|
||||
disabled: true
|
||||
};
|
||||
|
||||
const items = [];
|
||||
let insertedSeparator = false;
|
||||
|
||||
plans.forEach((p) => {
|
||||
const isCurrent = String(p.id) === currentPlanId;
|
||||
const keyLower = String(p.key || '').toLowerCase();
|
||||
const isFree = keyLower.endsWith('_free') || keyLower === 'free';
|
||||
|
||||
items.push({
|
||||
label: isCurrent ? `${p.key} (atual)` : p.key,
|
||||
icon: isCurrent ? 'pi pi-check' : isFree ? 'pi pi-star' : 'pi pi-circle',
|
||||
disabled: isCurrent || planMenuLoading.value || trocandoPlano.value,
|
||||
command: async () => {
|
||||
await changePlanTo(p.id, p.key, target);
|
||||
}
|
||||
});
|
||||
|
||||
if (!insertedSeparator && isFree) {
|
||||
items.push({ separator: true });
|
||||
insertedSeparator = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (items.length && items[items.length - 1]?.separator) items.pop();
|
||||
|
||||
if (!plans.length) {
|
||||
return [header, subInfo, { separator: true }, { label: 'Nenhum plano ativo encontrado', icon: 'pi pi-info-circle', disabled: true }];
|
||||
}
|
||||
|
||||
return [header, subInfo, { separator: true }, ...items];
|
||||
});
|
||||
|
||||
async function openPlanMenu(event) {
|
||||
if (!showPlanDevMenu.value) return;
|
||||
|
||||
// Captura a âncora ANTES do await — `event.currentTarget` é null
|
||||
// depois que a microtask resume (DOM behavior). Suporta tanto
|
||||
// PrimeVue <Button> (expõe `$el`) quanto <button> HTML cru
|
||||
// (planBtn.value já é o DOM element).
|
||||
const anchorEl =
|
||||
planBtn.value?.$el ||
|
||||
planBtn.value ||
|
||||
event?.currentTarget ||
|
||||
event?.target;
|
||||
|
||||
try {
|
||||
await loadPlanMenuData();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[PLANO][DEV menu] erro:', err?.message || err);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro ao carregar planos',
|
||||
detail: err?.message || 'Falha desconhecida.',
|
||||
life: 5200
|
||||
});
|
||||
}
|
||||
|
||||
if (!anchorEl) {
|
||||
planMenu.value?.toggle?.(event);
|
||||
return;
|
||||
}
|
||||
planMenu.value?.show?.({ currentTarget: anchorEl });
|
||||
}
|
||||
|
||||
async function changePlanTo(newPlanId, newPlanKey, target) {
|
||||
if (trocandoPlano.value) return;
|
||||
trocandoPlano.value = true;
|
||||
try {
|
||||
const sub = planMenuSub.value;
|
||||
if (!sub?.id) throw new Error('Subscription inválida.');
|
||||
const { error: rpcError } = await supabase.rpc('change_subscription_plan', {
|
||||
p_subscription_id: sub.id,
|
||||
p_new_plan_id: newPlanId
|
||||
});
|
||||
if (rpcError) throw rpcError;
|
||||
planMenuSub.value = { ...sub, plan_id: newPlanId };
|
||||
await refreshEntitlementsAfterToggle(target);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Plano alterado (DEV)',
|
||||
detail: `${String(newPlanKey).toUpperCase()} aplicado (${target})`,
|
||||
life: 3200
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[PLANO] Erro ao trocar:', err?.message || err);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro ao trocar plano',
|
||||
detail: err?.message || 'Falha desconhecida.',
|
||||
life: 6000
|
||||
});
|
||||
} finally {
|
||||
trocandoPlano.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
planBtn,
|
||||
planMenu,
|
||||
planMenuModel,
|
||||
planMenuLoading,
|
||||
trocandoPlano,
|
||||
showPlanDevMenu,
|
||||
openPlanMenu
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useFeriados } from '@/composables/useFeriados';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import DatePicker from 'primevue/datepicker';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
@@ -37,6 +38,14 @@ const emit = defineEmits(['bloqueado']);
|
||||
const router = useRouter();
|
||||
const tenantStore = useTenantStore();
|
||||
const toast = useToast();
|
||||
const { layoutConfig } = useLayout();
|
||||
|
||||
// Quando o layout ativo é Melissa, "Ver todos os feriados" leva pra rota
|
||||
// interna /melissa/bloqueios (abre MelissaConfiguracoes na seção embed
|
||||
// de Bloqueios). Caso contrário usa a rota tradicional de configurações.
|
||||
const verTodosFeriadosRoute = computed(() =>
|
||||
layoutConfig.variant === 'melissa' ? '/melissa/bloqueios' : '/configuracoes/bloqueios'
|
||||
);
|
||||
|
||||
const { nacionais, municipais, todos, loading, load, criar, remover, isDuplicata, doMes } = useFeriados();
|
||||
|
||||
@@ -236,7 +245,7 @@ function fmtDate(iso) {
|
||||
<i class="pi pi-star text-amber-500 text-sm" />
|
||||
<span class="font-semibold text-sm">Próximos feriados</span>
|
||||
</div>
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">{{ nomeMes }}</span>
|
||||
<span class="pfc-month-badge">{{ nomeMes }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
@@ -282,12 +291,12 @@ function fmtDate(iso) {
|
||||
<!-- Confirmação inline (expande abaixo do item) -->
|
||||
<Transition name="pfc-expand">
|
||||
<div v-if="confirmandoIso === f.data" class="pfc-confirm">
|
||||
<i class="pi pi-exclamation-triangle pfc-confirm__icon" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-semibold mb-0.5">Bloquear {{ f.nome }}?</p>
|
||||
<p class="text-xs opacity-70 leading-snug">O dia inteiro ficará indisponível. Sessões existentes serão marcadas para reagendamento.</p>
|
||||
</div>
|
||||
<div class="flex gap-1.5 shrink-0">
|
||||
<p class="text-xs font-semibold mb-0.5">
|
||||
<i class="pi pi-exclamation-triangle pfc-confirm__icon" />
|
||||
Bloquear {{ f.nome }}?
|
||||
</p>
|
||||
<p class="text-xs opacity-70 leading-snug">O dia inteiro ficará indisponível. Sessões existentes serão marcadas para reagendamento.</p>
|
||||
<div class="pfc-confirm__actions flex gap-1.5">
|
||||
<Button label="Não" size="small" severity="secondary" outlined class="rounded-full h-7 text-xs px-3" @click="cancelarConfirmacao" />
|
||||
<Button label="Bloquear" size="small" severity="danger" icon="pi pi-lock" class="rounded-full h-7 text-xs px-3" @click="confirmarBloqueio(f)" />
|
||||
</div>
|
||||
@@ -300,7 +309,7 @@ function fmtDate(iso) {
|
||||
<!-- Ações -->
|
||||
<div class="flex flex-col gap-1.5 px-4 pb-4">
|
||||
<Button icon="pi pi-plus" label="Cadastrar feriado municipal" severity="secondary" outlined size="small" class="w-full rounded-full" @click="abrirDialog" />
|
||||
<Button icon="pi pi-list" label="Ver todos os feriados" text size="small" class="w-full rounded-full" @click="router.push('/configuracoes/bloqueios')" />
|
||||
<Button icon="pi pi-list" label="Ver todos os feriados" text size="small" class="w-full rounded-full" @click="router.push(verTodosFeriadosRoute)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -370,20 +379,38 @@ function fmtDate(iso) {
|
||||
|
||||
/* ── Confirmação inline ───────────────────────────────────── */
|
||||
.pfc-confirm {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
/* sem display:flex — texto flui em bloco; botões ganham margin-top
|
||||
pra distanciar do parágrafo. */
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.875rem;
|
||||
background: color-mix(in srgb, var(--red-400, #f87171) 10%, var(--surface-card));
|
||||
border: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
|
||||
margin-left: 2.75rem; /* alinha com o nome, após a data */
|
||||
}
|
||||
.pfc-confirm__icon {
|
||||
color: var(--red-500, #ef4444);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
margin-right: 0.375rem;
|
||||
font-size: 0.8rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* Espaço entre os botões "Não/Bloquear" e o texto acima. */
|
||||
.pfc-confirm__actions {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* ── Mês atual no header (badge primary) ─────────────────── */
|
||||
.pfc-month-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--p-primary-color);
|
||||
color: var(--p-primary-contrast-color, white);
|
||||
border: 1px solid var(--p-primary-color);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: capitalize;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Transição expand ─────────────────────────────────────── */
|
||||
|
||||
@@ -310,6 +310,16 @@ const navPopover = ref(null)
|
||||
const isCompact = ref(false)
|
||||
let mql = null, mqlCb = null
|
||||
|
||||
// View mode: 'vertical' (Accordion) | 'horizontal' (Tabs)
|
||||
const VIEW_MODE_KEY = 'pcd.viewMode.v1'
|
||||
const viewMode = ref('vertical')
|
||||
try {
|
||||
const saved = localStorage.getItem(VIEW_MODE_KEY)
|
||||
if (saved === 'vertical' || saved === 'horizontal') viewMode.value = saved
|
||||
} catch (_) {}
|
||||
watch(viewMode, (v) => { try { localStorage.setItem(VIEW_MODE_KEY, v) } catch (_) {} })
|
||||
function setViewMode (m) { if (m === 'vertical' || m === 'horizontal') viewMode.value = m }
|
||||
|
||||
function syncCompact () { isCompact.value = !!mql?.matches }
|
||||
function toggleNav (e) { navPopover.value?.toggle(e) }
|
||||
function selectNav (s) { openPanel(Number(s.value)); navPopover.value?.hide() }
|
||||
@@ -563,6 +573,19 @@ async function onCepBlur () {
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
// `submitted` é true depois da primeira tentativa de salvar — usado pra
|
||||
// mostrar borda vermelha + msg "Campo obrigatório" embaixo dos inputs
|
||||
// sem incomodar o usuário antes da primeira interação.
|
||||
const submitted = ref(false)
|
||||
// Counts dos editores polimórficos (telefones/emails) — atualizados via
|
||||
// @change. Telefone e email são obrigatórios: pelo menos 1 cada.
|
||||
const phonesCount = ref(0)
|
||||
const emailsCount = ref(0)
|
||||
// Refs pros editores — usados pra chamar `flushPending` depois que o
|
||||
// paciente é criado (telefones/emails inseridos antes do save ficam
|
||||
// em modo pendente até a entidade existir no DB).
|
||||
const phonesEditorRef = ref(null)
|
||||
const emailsEditorRef = ref(null)
|
||||
|
||||
async function fetchAll () {
|
||||
loading.value = true
|
||||
@@ -600,6 +623,10 @@ watch(patientId, fetchAll, { immediate:true })
|
||||
// Submit
|
||||
// ─────────────────────────────────────────────────────────
|
||||
async function onSubmit () {
|
||||
// Marca pra que :invalid + mensagens de erro fiquem visíveis nos inputs
|
||||
// exigidos. Reseta no sucesso (logo abaixo) ou na próxima edição válida
|
||||
// (não reseta automaticamente — só atrapalharia o feedback visual).
|
||||
submitted.value = true
|
||||
saving.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
@@ -609,6 +636,16 @@ async function onSubmit () {
|
||||
toast.add({ severity:'warn', summary:'Nome obrigatório', detail:'Preencha o nome completo.', life:3500 })
|
||||
await openPanel(0); return
|
||||
}
|
||||
// Telefone e email são obrigatórios: pelo menos 1 cada. Toast aponta
|
||||
// pro campo faltando + abre a seção Identidade (onde os editores ficam).
|
||||
if (phonesCount.value === 0) {
|
||||
toast.add({ severity:'warn', summary:'Telefone obrigatório', detail:'Adicione pelo menos um telefone.', life:3500 })
|
||||
await openPanel(0); return
|
||||
}
|
||||
if (emailsCount.value === 0) {
|
||||
toast.add({ severity:'warn', summary:'E-mail obrigatório', detail:'Adicione pelo menos um e-mail.', life:3500 })
|
||||
await openPanel(0); return
|
||||
}
|
||||
const payload = sanitizePayload(form.value, ownerId)
|
||||
payload.tenant_id = tenantId; payload.responsible_member_id = memberId
|
||||
if (isEdit.value) {
|
||||
@@ -618,15 +655,23 @@ async function onSubmit () {
|
||||
await replacePatientTags(patientId.value, tagIdsSelecionadas.value)
|
||||
await saveContatosSuporte(patientId.value, tenantId, ownerId)
|
||||
toast.add({ severity:'success', summary:'Salvo', detail:'Paciente atualizado.', life:2500 })
|
||||
submitted.value = false
|
||||
if (props.dialogMode) { emit('created', { id:patientId.value }); return }
|
||||
return
|
||||
}
|
||||
const created = await createPatient(payload)
|
||||
// Telefones/emails podem ter sido adicionados ANTES do paciente existir
|
||||
// (modo pendente — id 'pending_*' em memória). Agora que temos `created.id`,
|
||||
// gravamos tudo em lote no DB. Roda antes de avatar/grupos/tags pra que
|
||||
// qualquer falha aqui aborte o resto do fluxo.
|
||||
await phonesEditorRef.value?.flushPending('patient', created.id)
|
||||
await emailsEditorRef.value?.flushPending('patient', created.id)
|
||||
await maybeUploadAvatar(ownerId, created.id)
|
||||
await replacePatientGroups(created.id, grupoIdSelecionado.value)
|
||||
await replacePatientTags(created.id, tagIdsSelecionadas.value)
|
||||
await saveContatosSuporte(created.id, tenantId, ownerId)
|
||||
toast.add({ severity:'success', summary:'Salvo', detail:'Paciente cadastrado.', life:2500 })
|
||||
submitted.value = false
|
||||
if (props.dialogMode) { emit('created', created); return }
|
||||
form.value=resetForm(); grupoIdSelecionado.value=null; tagIdsSelecionadas.value=[]
|
||||
contatosSuporte.value=[]; avatarFile.value=null; revokePreview(); avatarPreviewUrl.value=''
|
||||
@@ -1014,7 +1059,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<i class="pi pi-spin pi-spinner text-xl" /> Carregando…
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-[220px_1fr] max-w-[1040px] mx-auto">
|
||||
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-[220px_1fr] xl:items-start max-w-[1040px] mx-auto">
|
||||
|
||||
<!-- ── SIDEBAR ────────────────────────────────────── -->
|
||||
<aside
|
||||
@@ -1044,8 +1089,32 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav desktop (≥ xl) -->
|
||||
<div v-if="!isCompact" class="flex flex-col gap-0.5">
|
||||
<!-- Toggle layout vertical/horizontal -->
|
||||
<div class="flex items-center gap-1 mb-3 p-0.5 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-2 py-1 rounded-md text-[0.68rem] font-medium transition-all duration-150"
|
||||
:class="viewMode === 'vertical' ? 'bg-[var(--surface-card)] text-[var(--text-color)] shadow-sm' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
|
||||
title="Layout vertical (acordeão)"
|
||||
@click="setViewMode('vertical')"
|
||||
>
|
||||
<i class="pi pi-bars text-[0.68rem]" />
|
||||
<span>Vertical</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-2 py-1 rounded-md text-[0.68rem] font-medium transition-all duration-150"
|
||||
:class="viewMode === 'horizontal' ? 'bg-[var(--surface-card)] text-[var(--text-color)] shadow-sm' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
|
||||
title="Layout horizontal (abas)"
|
||||
@click="setViewMode('horizontal')"
|
||||
>
|
||||
<i class="pi pi-th-large text-[0.68rem] rotate-90" />
|
||||
<span>Abas</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Nav desktop (≥ xl) — só em vertical (em horizontal as tabs ficam acima do form) -->
|
||||
<div v-if="!isCompact && viewMode === 'vertical'" class="flex flex-col gap-0.5">
|
||||
<div class="text-[0.62rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-40 px-2 mb-1">Seções</div>
|
||||
<button
|
||||
v-for="s in sections" :key="s.value" type="button"
|
||||
@@ -1080,8 +1149,8 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav compacto (< xl) -->
|
||||
<div v-if="isCompact">
|
||||
<!-- Nav compacto (< xl) — só em vertical -->
|
||||
<div v-if="isCompact && viewMode === 'vertical'">
|
||||
<Button
|
||||
type="button" class="w-full !rounded-full"
|
||||
icon="pi pi-list" iconPos="right"
|
||||
@@ -1155,7 +1224,39 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</aside>
|
||||
|
||||
<!-- ── MAIN ───────────────────────────────────────── -->
|
||||
<main class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
|
||||
<main
|
||||
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm"
|
||||
:class="{ 'pcd-horizontal': viewMode === 'horizontal' }"
|
||||
>
|
||||
<!-- Tab list (só em horizontal) -->
|
||||
<div
|
||||
v-if="viewMode === 'horizontal'"
|
||||
class="flex gap-0.5 overflow-x-auto px-2 pt-2 border-b border-[var(--surface-border)] bg-[var(--surface-ground)]/40"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
v-for="s in sections" :key="s.value"
|
||||
type="button" role="tab"
|
||||
:aria-selected="activeValue === s.value"
|
||||
class="flex items-center gap-1.5 px-3 py-2 rounded-t-lg text-[0.78rem] font-medium border-b-2 transition-all duration-150 shrink-0 whitespace-nowrap"
|
||||
:class="activeValue === s.value
|
||||
? `${pal[s.accent].activeBtn} !rounded-b-none`
|
||||
: 'text-[var(--text-color-secondary)] border-transparent hover:bg-[var(--surface-card)]/60 hover:text-[var(--text-color)]'"
|
||||
@click="activeValue = s.value"
|
||||
>
|
||||
<span class="flex items-center justify-center w-5 h-5 rounded-md text-[0.62rem] shrink-0" :class="pal[s.accent].iconBox">
|
||||
<i :class="s.icon"/>
|
||||
</span>
|
||||
<span>{{ s.label }}</span>
|
||||
<i v-if="p(s.value).filled === p(s.value).total"
|
||||
class="pi pi-check-circle text-emerald-500 text-[0.7rem] shrink-0" />
|
||||
<span v-else-if="p(s.value).filled > 0"
|
||||
class="text-[0.6rem] text-amber-600 font-bold shrink-0">
|
||||
{{ p(s.value).filled }}/{{ p(s.value).total }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Accordion :multiple="false" v-model:value="activeValue">
|
||||
|
||||
<!-- ╔═══════════════════════════════════════════╗
|
||||
@@ -1177,22 +1278,37 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="p-4">
|
||||
<div class="p-5">
|
||||
|
||||
<!-- Nome & identidade -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Nome & identidade</span>
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Nome & identidade</span>
|
||||
<div class="flex-1 h-px" :class="pal.indigo.divLine"/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2 mb-6">
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-2 mb-7">
|
||||
|
||||
<!-- Nome completo — full width -->
|
||||
<div class="xl:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-user"/><InputText id="f_nome" v-model="form.nome_completo" class="w-full" variant="filled"/></IconField>
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-user"/>
|
||||
<InputText
|
||||
id="f_nome"
|
||||
v-model="form.nome_completo"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:invalid="submitted && !String(form.nome_completo || '').trim()"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="f_nome">Nome completo *</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Exibido no header do perfil do paciente.</div>
|
||||
<small
|
||||
v-if="submitted && !String(form.nome_completo || '').trim()"
|
||||
class="mt-2 text-[0.85rem] text-red-500 flex items-center gap-1.5"
|
||||
>
|
||||
<i class="pi pi-exclamation-circle text-[0.78rem]"/>
|
||||
<span>Campo obrigatório.</span>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Nome social -->
|
||||
@@ -1201,7 +1317,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<InputText id="f_nome_social" v-model="form.nome_social" class="w-full" variant="filled"/>
|
||||
<label for="f_nome_social">Nome social</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" → como prefere ser chamado(a).</div>
|
||||
</div>
|
||||
|
||||
<!-- Pronomes -->
|
||||
@@ -1210,16 +1325,18 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<Select id="f_pronomes" v-model="form.pronomes" :options="pronounsOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
|
||||
<label for="f_pronomes">Pronomes</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Header: <em>"32 anos · <strong>ela/dela</strong> · São Carlos, SP"</em></div>
|
||||
</div>
|
||||
|
||||
<!-- Data de nascimento -->
|
||||
<!-- Data de nascimento — InputGroup com idade calculada como addon à direita -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-calendar"/><InputMask id="f_nasc" v-model="form.data_nascimento" mask="99-99-9999" :unmask="false" class="w-full" variant="filled"/></IconField>
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-calendar"/></InputGroupAddon>
|
||||
<InputMask id="f_nasc" v-model="form.data_nascimento" mask="99-99-9999" :unmask="false" variant="filled"/>
|
||||
<InputGroupAddon v-if="ageLabel !== '—'" class="font-semibold text-[var(--primary-color)]">{{ ageLabel }}</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<label for="f_nasc">Data de nascimento</label>
|
||||
</FloatLabel>
|
||||
<div v-if="ageLabel!=='—'" class="mt-1 text-[0.63rem] text-indigo-600 font-semibold"><i class="pi pi-info-circle mr-1"/>{{ ageLabel }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Gênero -->
|
||||
@@ -1244,7 +1361,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<IconField><InputIcon class="pi pi-id-card"/><InputMask id="f_cpf" v-model="form.cpf" mask="999.999.999-99" :unmask="false" class="w-full" variant="filled"/></IconField>
|
||||
<label for="f_cpf">CPF</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Exibido mascarado: <em>••••456••••90</em></div>
|
||||
</div>
|
||||
|
||||
<!-- RG -->
|
||||
@@ -1261,7 +1377,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<Select id="f_etnia" v-model="form.etnia" :options="etniaOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
|
||||
<label for="f_etnia">Etnia / raça</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" → linha "Etnia".</div>
|
||||
</div>
|
||||
|
||||
<!-- Naturalidade -->
|
||||
@@ -1278,7 +1393,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<IconField><InputIcon class="pi pi-briefcase"/><InputText id="f_prof" v-model="form.profissao" class="w-full" variant="filled"/></IconField>
|
||||
<label for="f_prof">Profissão</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" → "Desenvolvedora".</div>
|
||||
</div>
|
||||
|
||||
<!-- Escolaridade -->
|
||||
@@ -1287,41 +1401,91 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<Select id="f_esc" v-model="form.escolaridade" :options="escolaridadeOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
|
||||
<label for="f_esc">Escolaridade</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" → "Superior completo".</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contato — alimenta card "Contato" -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Contato</span>
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Contato</span>
|
||||
<div class="flex-1 h-px" :class="pal.indigo.divLine"/>
|
||||
<span class="text-[0.6rem]" :class="pal.indigo.hint">Card "Contato" no detalhe</span>
|
||||
</div>
|
||||
<!-- Telefones (polimórfico — tipo/número/principal/vinculado) -->
|
||||
<div class="col-span-full">
|
||||
<div class="text-xs font-semibold text-[var(--text-color-secondary)] mb-1.5 flex items-center gap-1.5">
|
||||
<i class="pi pi-phone text-[var(--primary-color)]" />
|
||||
Telefones
|
||||
<div class="col-span-full mb-7">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 mb-5 rounded-xl border transition-colors"
|
||||
:class="submitted && phonesCount === 0
|
||||
? 'border-red-300 bg-red-50/60 text-red-700'
|
||||
: pal.indigo.infoBox"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg shrink-0"
|
||||
:class="submitted && phonesCount === 0
|
||||
? 'bg-red-100 text-red-600'
|
||||
: pal.indigo.iconBox"
|
||||
>
|
||||
<i class="pi pi-phone text-[0.95rem]"/>
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[0.95rem] font-semibold leading-tight">Telefones *</div>
|
||||
<div class="text-[0.85rem] mt-1 leading-snug opacity-90">
|
||||
Marque um telefone como <strong>principal</strong> — ele é usado pra cobranças, lembretes automáticos e contato padrão. Número vindo do CRM WhatsApp recebe a etiqueta <strong>"vinculado"</strong>.
|
||||
</div>
|
||||
<div
|
||||
v-if="submitted && phonesCount === 0"
|
||||
class="mt-2 text-[0.85rem] flex items-center gap-1.5 font-semibold"
|
||||
>
|
||||
<i class="pi pi-exclamation-circle text-[0.78rem]"/>
|
||||
<span>Adicione pelo menos um telefone.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ContactPhonesEditor
|
||||
ref="phonesEditorRef"
|
||||
entity-type="patient"
|
||||
:entity-id="patientId || null"
|
||||
@change="(arr) => phonesCount = (arr || []).length"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Emails (polimórfico — tipo/endereço/principal) -->
|
||||
<div class="col-span-full">
|
||||
<div class="text-xs font-semibold text-[var(--text-color-secondary)] mb-1.5 flex items-center gap-1.5">
|
||||
<i class="pi pi-envelope text-[var(--primary-color)]" />
|
||||
Emails
|
||||
<div class="col-span-full mb-7">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 mb-5 rounded-xl border transition-colors"
|
||||
:class="submitted && emailsCount === 0
|
||||
? 'border-red-300 bg-red-50/60 text-red-700'
|
||||
: pal.indigo.infoBox"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg shrink-0"
|
||||
:class="submitted && emailsCount === 0
|
||||
? 'bg-red-100 text-red-600'
|
||||
: pal.indigo.iconBox"
|
||||
>
|
||||
<i class="pi pi-envelope text-[0.95rem]"/>
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[0.95rem] font-semibold leading-tight">E-mails *</div>
|
||||
<div class="text-[0.85rem] mt-1 leading-snug opacity-90">
|
||||
Marque um e-mail como <strong>principal</strong> — ele é usado pra envio de recibos, comprovantes e comunicações oficiais.
|
||||
</div>
|
||||
<div
|
||||
v-if="submitted && emailsCount === 0"
|
||||
class="mt-2 text-[0.85rem] flex items-center gap-1.5 font-semibold"
|
||||
>
|
||||
<i class="pi pi-exclamation-circle text-[0.78rem]"/>
|
||||
<span>Adicione pelo menos um e-mail.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ContactEmailsEditor
|
||||
ref="emailsEditorRef"
|
||||
entity-type="patient"
|
||||
:entity-id="patientId || null"
|
||||
@change="(arr) => emailsCount = (arr || []).length"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
|
||||
<!-- Canal preferido -->
|
||||
<div>
|
||||
@@ -1329,7 +1493,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<Select id="f_canal" v-model="form.canal_preferido" :options="canalOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
|
||||
<label for="f_canal">Canal preferido de contato</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card Contato → "Canal preferido: <strong>WhatsApp</strong>".</div>
|
||||
</div>
|
||||
|
||||
<!-- Horário de contato -->
|
||||
@@ -1338,7 +1501,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<IconField><InputIcon class="pi pi-clock"/><InputText id="f_horario" v-model="form.horario_contato" class="w-full" variant="filled" placeholder="Ex: 08h–18h"/></IconField>
|
||||
<label for="f_horario">Horário de contato</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card Contato → "Horário: <strong>08h–18h</strong>".</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações de endereço -->
|
||||
@@ -1347,7 +1509,10 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<Textarea id="f_obs" v-model="form.observacoes" rows="2" class="w-full" variant="filled"/>
|
||||
<label for="f_obs">Observações de endereço</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Ex: Próximo ao posto, portão azul, sem interfone.</div>
|
||||
<div class="mt-2 text-[0.85rem] text-[var(--primary-color)] flex items-center gap-1.5">
|
||||
<i class="pi pi-info-circle text-[0.78rem]"/>
|
||||
<span>Ex: Próximo ao posto, portão azul, sem interfone.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1372,18 +1537,17 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="p-4">
|
||||
<div class="p-5">
|
||||
<div :class="`flex items-start gap-2 mb-4 p-2.5 rounded-lg border text-[0.75rem] ${pal.teal.infoBox}`">
|
||||
<i class="pi pi-lightbulb mt-0.5 shrink-0"/>
|
||||
<span>Digite o CEP e cidade, estado, bairro e logradouro são preenchidos automaticamente via <strong>ViaCEP</strong>.</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-map-marker"/><InputText id="f_cep" v-model="form.cep" class="w-full" @blur="onCepBlur" variant="filled" placeholder="00000-000"/></IconField>
|
||||
<label for="f_cep">CEP</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.teal.hint">Card Contato → <em>"13560-000 · São Carlos"</em></div>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
@@ -1396,14 +1560,12 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<IconField><InputIcon class="pi pi-building"/><InputText id="f_city" v-model="form.cidade" class="w-full" variant="filled"/></IconField>
|
||||
<label for="f_city">Cidade</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.teal.hint">Header → <em>"<strong>São Carlos</strong>, SP"</em></div>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-compass"/><InputText id="f_uf" v-model="form.estado" class="w-full" variant="filled"/></IconField>
|
||||
<label for="f_uf">Estado (UF)</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.teal.hint">Header → "São Carlos, <strong>SP</strong>"</div>
|
||||
</div>
|
||||
<div class="xl:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
@@ -1453,7 +1615,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="p-4">
|
||||
<div class="p-5">
|
||||
|
||||
<!-- Preview dos badges ao vivo -->
|
||||
<div v-if="form.status||convenioNome||form.patient_scope||tagIdsSelecionadas.length"
|
||||
@@ -1472,18 +1634,16 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
|
||||
<!-- Situação clínica -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Situação clínica</span>
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Situação clínica</span>
|
||||
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
|
||||
<span class="text-[0.6rem]" :class="pal.violet.hint">Badges no header do perfil</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-3 mb-6">
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3 mb-7">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<Select id="f_status" v-model="form.status" :options="statusOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
|
||||
<label for="f_status">Status</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Badge <span class="font-bold text-green-600">verde</span> no header.</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- CONVÊNIO — seleciona de insurance_plans, máx 1 -->
|
||||
@@ -1517,33 +1677,33 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
@click="showConvenioDlg = true"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Badge <span class="font-bold text-blue-500">azul</span> no header · máx 1 convênio.</div>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<Select id="f_scope" v-model="form.patient_scope" :options="scopeOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
|
||||
<label for="f_scope">Escopo de atendimento</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Badge <span class="font-bold text-gray-500">cinza</span> no header.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Organização: grupo + tags -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Organização</span>
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Organização</span>
|
||||
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
|
||||
<span class="text-[0.6rem]" :class="pal.violet.hint">Chips coloridos no header</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2 mb-6">
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-2 mb-7">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-folder-open"/>
|
||||
<Select id="f_grupo" v-model="grupoIdSelecionado" :options="groups" optionLabel="nome" optionValue="id" class="w-full pl-[25px]" showClear filter variant="filled"/>
|
||||
<Select id="f_grupo" v-model="grupoIdSelecionado" :options="groups" optionLabel="name" optionValue="id" class="w-full pl-[25px]" showClear filter variant="filled"/>
|
||||
</IconField>
|
||||
<label for="f_grupo">Grupo</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Define o modelo de anamnese.</div>
|
||||
<div class="mt-2 text-[0.85rem] text-[var(--primary-color)] flex items-center gap-1.5">
|
||||
<i class="pi pi-info-circle text-[0.78rem]"/>
|
||||
<span>Define o modelo de anamnese aplicado ao paciente.</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0 h-[42px] mt-[1px]" title="Criar grupo" @click="openGroupDlg"/>
|
||||
</div>
|
||||
@@ -1555,25 +1715,22 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</IconField>
|
||||
<label for="f_tags">Tags clínicas</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Aparecem como chips coloridos no header do perfil.</div>
|
||||
</div>
|
||||
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0 h-[42px] mt-[1px]" title="Criar tag" @click="openTagDlg"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Origem — alimenta card "Origem" do detalhe -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Origem</span>
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Origem</span>
|
||||
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
|
||||
<span class="text-[0.6rem]" :class="pal.violet.hint">Card "Origem" no perfil</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-megaphone"/><InputText id="f_lead" v-model="form.onde_nos_conheceu" class="w-full" variant="filled"/></IconField>
|
||||
<label for="f_lead">Como chegou até mim?</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Origem → "Como chegou: <strong>Indicação</strong>".</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- ENCAMINHADO POR — múltiplos médicos -->
|
||||
@@ -1623,14 +1780,16 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
class="rounded-full w-full"
|
||||
@click="showMedicoDlg = true"
|
||||
/>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Pode adicionar mais de um profissional de referência.</div>
|
||||
<div class="mt-2 text-[0.85rem] text-[var(--primary-color)] flex items-center gap-1.5">
|
||||
<i class="pi pi-info-circle text-[0.78rem]"/>
|
||||
<span>Você pode adicionar mais de um profissional de referência.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-sign-out"/><InputText id="f_saida" v-model="form.motivo_saida" class="w-full" variant="filled" placeholder="Se aplicável"/></IconField>
|
||||
<label for="f_saida">Motivo de saída</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Origem → "Motivo de saída" quando preenchido.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1659,7 +1818,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="p-4">
|
||||
<div class="p-5">
|
||||
<div :class="`flex items-start gap-2 mb-4 p-2.5 rounded-lg border text-[0.75rem] ${pal.amber.infoBox}`">
|
||||
<i class="pi pi-info-circle mt-0.5 shrink-0"/>
|
||||
<span>Cada contato aqui aparece no card <strong>"Contatos & rede de suporte"</strong> do perfil. O marcado como <strong>emergência primária</strong> recebe badge vermelho.</span>
|
||||
@@ -1699,7 +1858,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<InputText :id="`cr_${idx}`" v-model="c.relacao" class="w-full" variant="filled" placeholder="Ex: mãe, psiquiatra"/>
|
||||
<label :for="`cr_${idx}`">Relação / papel</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.amber.hint">Subtítulo no card: "Maria Lima · <strong>mãe</strong>".</div>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
@@ -1714,7 +1872,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</IconField>
|
||||
<label :for="`ctel_${idx}`">Telefone</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.amber.hint">Exibido abaixo do nome no card.</div>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
@@ -1723,7 +1880,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</IconField>
|
||||
<label :for="`cemail_${idx}`">E-mail</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.amber.hint">Exibido ao lado do telefone.</div>
|
||||
</div>
|
||||
<!-- Emergência primária -->
|
||||
<div class="xl:col-span-2">
|
||||
@@ -1767,8 +1923,8 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
|
||||
<div class="p-5">
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div class="xl:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-user"/><InputText id="f_rn" v-model="form.nome_responsavel" class="w-full" variant="filled"/></IconField>
|
||||
@@ -1826,7 +1982,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="p-4">
|
||||
<div class="p-5">
|
||||
<div :class="`flex items-start gap-2 mb-4 p-2.5 rounded-lg border text-[0.75rem] ${pal.rose.infoBox}`">
|
||||
<i class="pi pi-shield mt-0.5 shrink-0"/>
|
||||
<span>Campo interno: <strong>não aparece</strong> no cadastro externo nem é compartilhado com o paciente.</span>
|
||||
@@ -1861,9 +2017,9 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
@@ -1918,9 +2074,9 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
@@ -2015,3 +2171,24 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Em modo horizontal, esconde os headers do Accordion (a navegação fica nas tabs em cima do main) */
|
||||
.pcd-horizontal :deep(.p-accordionheader) {
|
||||
display: none !important;
|
||||
}
|
||||
.pcd-horizontal :deep(.p-accordion-header) {
|
||||
display: none !important;
|
||||
}
|
||||
.pcd-horizontal :deep(.p-accordioncontent),
|
||||
.pcd-horizontal :deep(.p-accordion-content) {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Tira o padding interno do wrapper do AccordionContent — o conteúdo já tem
|
||||
o próprio padding (.p-5) por seção, então o do PrimeVue duplicava o
|
||||
espaçamento e dava sensação de elementos descolados. */
|
||||
:deep(.p-accordioncontent-content) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -688,9 +688,9 @@ function isRecent(row) {
|
||||
maximizable
|
||||
class="w-[96vw] max-w-2xl"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-4' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
|
||||
@@ -368,11 +368,15 @@ function onSearchFocus() {
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<!-- type="text" (não "search"): o nativo "search" injeta
|
||||
um X de "clear" do navegador que duplicava o botão
|
||||
custom logo abaixo. `inputmode="search"` mantém o
|
||||
teclado correto em mobile sem trazer o X nativo. -->
|
||||
<InputText
|
||||
ref="searchEl"
|
||||
id="menu_search"
|
||||
name="menu_search"
|
||||
type="search"
|
||||
type="text"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
|
||||
@@ -158,7 +158,7 @@ function toggleUserMenu(e) {
|
||||
@click="selectHome"
|
||||
@mouseenter="onHomeHover"
|
||||
>
|
||||
<i class="pi pi-fw pi-home" />
|
||||
<i class="pi pi-fw pi-home text-[var(--primary-color)]" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -170,7 +170,7 @@ function toggleUserMenu(e) {
|
||||
@click="selectSection(section)"
|
||||
@mouseenter="onSectionHover(section)"
|
||||
>
|
||||
<i :class="section.icon" />
|
||||
<i :class="section.icon" class="text-[var(--primary-color)]" />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -181,7 +181,7 @@ function toggleUserMenu(e) {
|
||||
aria-label="Configurações"
|
||||
@click="$router.push('/configuracoes')"
|
||||
>
|
||||
<i class="pi pi-fw pi-cog" />
|
||||
<i class="pi pi-fw pi-cog text-[var(--primary-color)]" />
|
||||
</button>
|
||||
|
||||
<!-- Avatar — trigger do menu de usuário -->
|
||||
|
||||
@@ -448,7 +448,7 @@ async function goToResult(r) {
|
||||
}"
|
||||
@click="navigate(child)"
|
||||
>
|
||||
<i v-if="child.icon" :class="child.icon" class="text-[1rem] shrink-0 opacity-75" />
|
||||
<i v-if="child.icon" :class="child.icon" class="text-[1rem] shrink-0 opacity-75 !text-[var(--primary-color)]" />
|
||||
<span class="flex-1">{{ child.label }}</span>
|
||||
<span v-if="isLocked(child)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
|
||||
<span v-if="menuBadgeLabel(child)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(child) }}</span>
|
||||
@@ -465,7 +465,7 @@ async function goToResult(r) {
|
||||
}"
|
||||
@click="navigate(item)"
|
||||
>
|
||||
<i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75" />
|
||||
<i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75 !text-[var(--primary-color)]" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<span v-if="isLocked(item)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
|
||||
<span v-if="menuBadgeLabel(item)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(item) }}</span>
|
||||
|
||||
@@ -341,11 +341,14 @@ function onQuickCreate() {
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<!-- type="text" (não "search"): vide AppMenu.vue —
|
||||
o "search" nativo dá um X de clear próprio que
|
||||
duplica o botão custom abaixo. -->
|
||||
<InputText
|
||||
ref="searchEl"
|
||||
id="rs_menu_search"
|
||||
name="rs_menu_search"
|
||||
type="search"
|
||||
type="text"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
|
||||
@@ -204,15 +204,28 @@ export function useLayout() {
|
||||
|
||||
const setVariant = (v, { fromUser = true } = {}) => {
|
||||
if (v !== 'classic' && v !== 'rail' && v !== 'melissa') return;
|
||||
const prev = layoutConfig.variant;
|
||||
layoutConfig.variant = v;
|
||||
try {
|
||||
localStorage.setItem('layout_variant', v);
|
||||
} catch {}
|
||||
// reset rail state ao trocar
|
||||
layoutState.railSectionKey = null;
|
||||
layoutState.railPanelOpen = false;
|
||||
// Reset do estado do rail SÓ quando o novo variant não é 'rail'.
|
||||
// Antes, o reset acontecia em todo setVariant — incluindo na troca
|
||||
// de volta pra rail (ex.: rail → melissa → rail). Resultado: o rail
|
||||
// remontava sem seção ativa e o menu da esquerda aparecia sem itens
|
||||
// até o usuário clicar em algo. Preservar o estado quando volta pra
|
||||
// rail mantém a UX coerente.
|
||||
if (v !== 'rail') {
|
||||
layoutState.railSectionKey = null;
|
||||
layoutState.railPanelOpen = false;
|
||||
}
|
||||
// marca que o usuário fez uma escolha explícita (não restauração do DB)
|
||||
if (fromUser) layoutState._variantDirty = true;
|
||||
// dev-only: facilita auditar trocas de layout sem instrumentar o profile
|
||||
if (typeof window !== 'undefined' && window?.__DEV_LAYOUT_LOG) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('[layout.setVariant]', prev, '→', v);
|
||||
}
|
||||
};
|
||||
|
||||
const setRailOpenMode = (mode) => {
|
||||
|
||||
@@ -535,6 +535,9 @@ async function saveJornada() {
|
||||
cfg.value.setup_clinica_concluido = true;
|
||||
cfg.value.jornada_igual_todos = igualTodos;
|
||||
toast.add({ severity: 'success', summary: 'Jornada salva', detail: 'Horários de trabalho atualizados.', life: 3500 });
|
||||
// Notifica consumidores (ex: MelissaLayout/timeline) pra refetch
|
||||
// do agenda_regras_semanais sem precisar reload da página.
|
||||
window.dispatchEvent(new CustomEvent('agenda:settings-saved', { detail: { source: 'jornada' } }));
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar jornada.', life: 3500 });
|
||||
} finally {
|
||||
@@ -1385,7 +1388,7 @@ const jornadaEndDate = computed({
|
||||
<div class="anim-child [--delay:120ms] xl:w-[42%] xl:top-4 xl:self-start">
|
||||
<div class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm agenda-altura">
|
||||
<!-- Header do preview -->
|
||||
<div class="sticky top-0 z-10">
|
||||
<div class="sticky top-0 z-10 bg-[var(--surface-card)]">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
|
||||
<div class="font-semibold text-sm">Preview da agenda</div>
|
||||
<div class="flex gap-1">
|
||||
|
||||
@@ -222,9 +222,9 @@ onMounted(async () => {
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
|
||||
}"
|
||||
|
||||
@@ -1154,9 +1154,9 @@ onBeforeUnmount(() => {
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
|
||||
}"
|
||||
|
||||
+1625
-148
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaEmbed — Wrapper genérico pra embedar pages tradicionais dentro
|
||||
* do MelissaLayout (Onda 1 da migração).
|
||||
*
|
||||
* Usado pra páginas que ainda não viraram Melissa Pages dedicadas mas
|
||||
* que o user precisa acessar sem sair do overlay Melissa: Financeiro,
|
||||
* Documents, Agendamentos Recebidos, Online Scheduling, etc.
|
||||
*
|
||||
* Diferença pra MelissaConfiguracoes:
|
||||
* - MelissaConfiguracoes tem aside com sidebar de seções (hub de configs)
|
||||
* - MelissaEmbed é 1-coluna full-width (1 page só, sem nav lateral)
|
||||
*
|
||||
* Padrão: hero glass sticky no topo + Suspense + <component :is>.
|
||||
*
|
||||
* Reusa o mesmo Teleport target #cfg-page-actions pra que pages que
|
||||
* injetam ações no header da ConfiguracoesPage tradicional não quebrem.
|
||||
*/
|
||||
import { computed, defineAsyncComponent } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
// Key da rota /melissa/:secao — determina qual page embedar
|
||||
secaoRota: { type: String, required: true }
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
// ── Catálogo de seções embedáveis ──────────────────────────────
|
||||
// Cada entry tem label, descrição, ícone e o componente assíncrono.
|
||||
// Mantido neste arquivo (não no MelissaLayout) pra que adicionar uma
|
||||
// nova page aqui não exija mexer no parent.
|
||||
const EMBED_MAP = {
|
||||
'financeiro': {
|
||||
label: 'Financeiro',
|
||||
desc: 'Visão geral, recebíveis e indicadores do mês.',
|
||||
icon: 'pi pi-wallet',
|
||||
comp: defineAsyncComponent(() => import('@/features/financeiro/pages/FinanceiroDashboardPage.vue'))
|
||||
},
|
||||
'financeiro-lancamentos': {
|
||||
label: 'Lançamentos financeiros',
|
||||
desc: 'Lista detalhada de cobranças, pagamentos e recebimentos.',
|
||||
icon: 'pi pi-list',
|
||||
comp: defineAsyncComponent(() => import('@/features/financeiro/pages/FinanceiroPage.vue'))
|
||||
},
|
||||
'documentos': {
|
||||
label: 'Documentos',
|
||||
desc: 'Documentos clínicos do tenant — geração, edição e histórico.',
|
||||
icon: 'pi pi-file',
|
||||
comp: defineAsyncComponent(() => import('@/features/documents/DocumentsListPage.vue'))
|
||||
},
|
||||
'documentos-templates': {
|
||||
label: 'Templates de documentos',
|
||||
desc: 'Modelos reutilizáveis pra prontuários e relatórios.',
|
||||
icon: 'pi pi-file-edit',
|
||||
comp: defineAsyncComponent(() => import('@/features/documents/DocumentTemplatesPage.vue'))
|
||||
},
|
||||
'agendamentos-recebidos': {
|
||||
label: 'Agendamentos recebidos',
|
||||
desc: 'Solicitações vindas do agendador online à espera de confirmação.',
|
||||
icon: 'pi pi-inbox',
|
||||
comp: defineAsyncComponent(() => import('@/features/agenda/pages/AgendamentosRecebidosPage.vue'))
|
||||
},
|
||||
'online-scheduling': {
|
||||
label: 'Agendador online',
|
||||
desc: 'Configure o link público pra pacientes solicitarem horários.',
|
||||
icon: 'pi pi-calendar-clock',
|
||||
comp: defineAsyncComponent(() => import('@/views/pages/therapist/OnlineSchedulingPage.vue'))
|
||||
},
|
||||
'relatorios': {
|
||||
label: 'Relatórios',
|
||||
desc: 'Indicadores e relatórios do tenant — clínico e financeiro.',
|
||||
icon: 'pi pi-chart-bar',
|
||||
comp: defineAsyncComponent(() => import('@/views/pages/therapist/RelatoriosPage.vue'))
|
||||
},
|
||||
'notificacoes': {
|
||||
label: 'Notificações',
|
||||
desc: 'Histórico de notificações enviadas (WhatsApp, e-mail, SMS).',
|
||||
icon: 'pi pi-bell',
|
||||
comp: defineAsyncComponent(() => import('@/views/pages/therapist/NotificationsHistoryPage.vue'))
|
||||
},
|
||||
'link-externo': {
|
||||
label: 'Link externo de cadastro',
|
||||
desc: 'Link público pra pacientes preencherem o cadastro online.',
|
||||
icon: 'pi pi-share-alt',
|
||||
comp: defineAsyncComponent(() => import('@/features/patients/cadastro/PatientsExternalLinkPage.vue'))
|
||||
}
|
||||
};
|
||||
|
||||
const info = computed(() => EMBED_MAP[props.secaoRota] || null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="me-page">
|
||||
<header class="me-page__head">
|
||||
<div class="me-page__title">
|
||||
<i :class="info?.icon || 'pi pi-file'" />
|
||||
<span>{{ info?.label || 'Página' }}</span>
|
||||
</div>
|
||||
<div class="me-page__actions">
|
||||
<button class="me-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="me-body">
|
||||
<!-- Hero contextual (igual ao mcfg-embed-hero) -->
|
||||
<div v-if="info" class="me-hero">
|
||||
<div class="me-hero__icon">
|
||||
<i :class="info.icon" />
|
||||
</div>
|
||||
<div class="me-hero__text">
|
||||
<div class="me-hero__title">{{ info.label }}</div>
|
||||
<div class="me-hero__desc">{{ info.desc }}</div>
|
||||
</div>
|
||||
<!-- Teleport target compartilhado com ConfiguracoesPage:
|
||||
algumas pages que migram fazem <Teleport to="#cfg-page-actions">.
|
||||
Mantemos o id pra não quebrar. -->
|
||||
<div id="cfg-page-actions" class="me-hero__actions"></div>
|
||||
</div>
|
||||
|
||||
<!-- Embed dinâmico -->
|
||||
<div class="me-content">
|
||||
<Suspense v-if="info">
|
||||
<template #default>
|
||||
<component :is="info.comp" :key="secaoRota" />
|
||||
</template>
|
||||
<template #fallback>
|
||||
<div class="me-loading">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
<span>Carregando…</span>
|
||||
</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
<div v-else class="me-loading">
|
||||
<i class="pi pi-exclamation-triangle" />
|
||||
<span>Seção desconhecida: {{ secaoRota }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ═════ Container glass ═════ */
|
||||
.me-page {
|
||||
position: absolute;
|
||||
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 6px;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(32px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(32px) saturate(160%);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
color: var(--m-text);
|
||||
animation: me-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
@keyframes me-page-enter {
|
||||
from { opacity: 0; transform: scale(0.985); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ═════ Header ═════ */
|
||||
.me-page__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.me-page__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.me-page__title > i { color: var(--m-text-muted); font-size: 0.95rem; }
|
||||
.me-page__title > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.me-page__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
.me-close {
|
||||
width: 32px; height: 32px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.me-close:hover { background: var(--m-bg-soft-hover); }
|
||||
.me-close > i { font-size: 0.85rem; }
|
||||
|
||||
/* ═════ Body ═════ */
|
||||
.me-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--m-border-strong) transparent;
|
||||
}
|
||||
.me-body::-webkit-scrollbar { width: 6px; }
|
||||
.me-body::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
|
||||
|
||||
/* Hero contextual (mesmo padrão do mcfg-embed-hero) */
|
||||
.me-hero {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 11;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(20px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.me-hero__icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--m-accent-soft);
|
||||
color: var(--m-accent);
|
||||
border-radius: 9px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.me-hero__icon > i { font-size: 0.92rem; }
|
||||
.me-hero__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.me-hero__title {
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.me-hero__desc {
|
||||
font-size: 0.74rem;
|
||||
color: var(--m-text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.me-hero__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Wrapper que dá padding ao conteúdo embedado */
|
||||
.me-content {
|
||||
padding: 16px 18px 28px;
|
||||
}
|
||||
|
||||
/* Loading do Suspense */
|
||||
.me-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 60px 20px;
|
||||
color: var(--m-text-muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.me-loading > i { font-size: 1.2rem; color: var(--m-accent); }
|
||||
|
||||
/* Mobile (<lg) */
|
||||
@media (max-width: 1023px) {
|
||||
.me-content { padding: 12px; }
|
||||
.me-hero { padding: 10px 12px; }
|
||||
}
|
||||
</style>
|
||||
@@ -232,9 +232,11 @@ function modalidadeIcon(mod) {
|
||||
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%);
|
||||
/* Blur XS — bem leve. O resumo continua legível atrás, só ganha
|
||||
um leve "tilt-shift" pra direcionar o olhar pro panel. */
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
backdrop-filter: blur(4px) saturate(110%);
|
||||
-webkit-backdrop-filter: blur(4px) saturate(110%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@@ -429,8 +431,8 @@ function modalidadeIcon(mod) {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
/* Light mode — overlay menos escuro */
|
||||
/* Light mode — overlay ainda mais discreto */
|
||||
html:not(.app-dark) .evento-layer {
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
background: rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,790 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaGrupos — CRUD de grupos de pacientes dentro de Melissa.
|
||||
* Segue blueprint melissa-page-blueprint.md.
|
||||
*
|
||||
* Layout 2-col (espelha MelissaTags):
|
||||
* - COL 1 — Aside (~280px): stats + busca
|
||||
* - COL 2 — Lista de grupos (cor + nome + contagem de pacientes)
|
||||
*
|
||||
* Tabela: patient_groups, vínculo: patient_group_patient.
|
||||
* Sem view agregada — contagem feita no client após carregar vínculos.
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
// ── Breakpoints + drawer ───────────────────────────────────
|
||||
const drawerOpen = ref(false);
|
||||
const isMobile = ref(false);
|
||||
let _mqMobile = null;
|
||||
function _onMqMobileChange(e) {
|
||||
isMobile.value = e.matches;
|
||||
if (!e.matches) drawerOpen.value = false;
|
||||
}
|
||||
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
|
||||
function fecharDrawer() { drawerOpen.value = false; }
|
||||
|
||||
// ── Estado ─────────────────────────────────────────────────
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const grupos = ref([]);
|
||||
const counts = ref(new Map()); // groupId → patient count
|
||||
const busca = ref('');
|
||||
const carregandoInicial = computed(
|
||||
() => loading.value && grupos.value.length === 0
|
||||
);
|
||||
|
||||
async function getOwnerId() {
|
||||
const { data } = await supabase.auth.getUser();
|
||||
if (!data?.user?.id) throw new Error('Sessão não inicializada.');
|
||||
return data.user.id;
|
||||
}
|
||||
async function getTenantId() {
|
||||
if (typeof tenantStore.ensureLoaded === 'function') await tenantStore.ensureLoaded();
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId;
|
||||
if (!tid) throw new Error('Tenant não inicializado.');
|
||||
return tid;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getTenantId();
|
||||
|
||||
const [{ data: gData, error: gErr }, { data: vData }] = await Promise.all([
|
||||
supabase.from('patient_groups')
|
||||
.select('id, owner_id, tenant_id, nome, cor, is_system, is_active, created_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('nome', { ascending: true }),
|
||||
supabase.from('patient_group_patient').select('patient_group_id')
|
||||
]);
|
||||
if (gErr) throw gErr;
|
||||
|
||||
// Conta vínculos por grupo no client
|
||||
const map = new Map();
|
||||
for (const v of vData || []) {
|
||||
const id = v.patient_group_id;
|
||||
map.set(id, (map.get(id) || 0) + 1);
|
||||
}
|
||||
counts.value = map;
|
||||
grupos.value = (gData || []).map((g) => ({
|
||||
...g,
|
||||
pacientes_count: map.get(g.id) || 0
|
||||
}));
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar grupos', detail: e?.message, life: 4500 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const stats = computed(() => {
|
||||
const all = grupos.value;
|
||||
const ativos = all.filter((g) => g.is_active !== false).length;
|
||||
const sistema = all.filter((g) => g.is_system).length;
|
||||
const meus = all.filter((g) => !g.is_system).length;
|
||||
const emUso = all.filter((g) => g.pacientes_count > 0).length;
|
||||
return [
|
||||
{ key: 'total', label: 'Total', value: all.length, cls: 'neutral' },
|
||||
{ key: 'meus', label: 'Meus', value: meus, cls: meus > 0 ? 'ok' : 'neutral' },
|
||||
{ key: 'uso', label: 'Em uso', value: emUso, cls: emUso > 0 ? 'ok' : 'neutral' },
|
||||
{ key: 'sistema', label: 'Sistema', value: sistema, cls: 'neutral' }
|
||||
];
|
||||
});
|
||||
|
||||
const gruposFiltrados = computed(() => {
|
||||
const q = String(busca.value || '').trim().toLowerCase();
|
||||
if (!q) return grupos.value;
|
||||
return grupos.value.filter((g) => String(g.nome || '').toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
// Dialog
|
||||
const dlgOpen = ref(false);
|
||||
const dlgMode = ref('create');
|
||||
const dlgForm = ref({ id: '', nome: '', cor: '#6366F1' });
|
||||
const dlgError = ref('');
|
||||
const PRESET_COLORS = ['6366f1', '8b5cf6', 'ec4899', 'ef4444', 'f97316', 'eab308', '22c55e', '14b8a6', '3b82f6', '06b6d4', '64748b', '292524'];
|
||||
|
||||
function abrirCriar() {
|
||||
dlgMode.value = 'create';
|
||||
dlgForm.value = { id: '', nome: '', cor: '#6366F1' };
|
||||
dlgError.value = '';
|
||||
dlgOpen.value = true;
|
||||
}
|
||||
function abrirEditar(row) {
|
||||
if (row.is_system) {
|
||||
toast.add({ severity: 'info', summary: 'Grupo do sistema', detail: 'Não dá pra editar grupos do sistema.', life: 2500 });
|
||||
return;
|
||||
}
|
||||
dlgMode.value = 'edit';
|
||||
dlgForm.value = {
|
||||
id: row.id,
|
||||
nome: row.nome || '',
|
||||
cor: row.cor ? (row.cor.startsWith('#') ? row.cor : '#' + row.cor) : '#6366F1'
|
||||
};
|
||||
dlgError.value = '';
|
||||
dlgOpen.value = true;
|
||||
}
|
||||
|
||||
async function salvar() {
|
||||
const nome = String(dlgForm.value.nome || '').trim();
|
||||
if (!nome) {
|
||||
dlgError.value = 'Informe um nome.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
dlgError.value = '';
|
||||
try {
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getTenantId();
|
||||
const cor = dlgForm.value.cor.startsWith('#') ? dlgForm.value.cor : '#' + dlgForm.value.cor;
|
||||
if (dlgMode.value === 'create') {
|
||||
const { error } = await supabase.from('patient_groups').insert({
|
||||
owner_id: ownerId, tenant_id: tenantId,
|
||||
nome, cor, is_system: false, is_active: true
|
||||
});
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Grupo criado', life: 2200 });
|
||||
} else {
|
||||
const { error } = await supabase.from('patient_groups')
|
||||
.update({ nome, cor })
|
||||
.eq('id', dlgForm.value.id);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Grupo atualizado', life: 2200 });
|
||||
}
|
||||
dlgOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
const msg = e?.message || '';
|
||||
dlgError.value = (e?.code === '23505' || /duplicate/i.test(msg)) ? 'Já existe um grupo com esse nome.' : (msg || 'Falha ao salvar.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmarExcluir(row) {
|
||||
if (row.is_system) return;
|
||||
confirm.require({
|
||||
message: `Excluir o grupo "${row.nome}"? Os pacientes serão desvinculados.`,
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Excluir',
|
||||
rejectLabel: 'Cancelar',
|
||||
acceptSeverity: 'danger',
|
||||
accept: () => excluir(row)
|
||||
});
|
||||
}
|
||||
async function excluir(row) {
|
||||
saving.value = true;
|
||||
try {
|
||||
await supabase.from('patient_group_patient').delete().eq('patient_group_id', row.id);
|
||||
const { error } = await supabase.from('patient_groups').delete().eq('id', row.id);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Grupo excluído', life: 2200 });
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir.', life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
_mqMobile = window.matchMedia('(max-width: 1023px)');
|
||||
isMobile.value = _mqMobile.matches;
|
||||
_mqMobile.addEventListener('change', _onMqMobileChange);
|
||||
}
|
||||
load();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="mg-mobile-drawer"
|
||||
:class="{ 'is-open': drawerOpen }"
|
||||
v-show="isMobile"
|
||||
aria-label="Estatísticas e busca"
|
||||
>
|
||||
<div id="mg-mobile-drawer-target" class="mg-mobile-drawer__scroll" />
|
||||
</aside>
|
||||
<Transition name="mg-drawer-fade">
|
||||
<div
|
||||
v-if="isMobile && drawerOpen"
|
||||
class="mg-mobile-drawer__backdrop"
|
||||
@click="fecharDrawer"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<section class="mg-page">
|
||||
<header class="mg-page__head">
|
||||
<button
|
||||
class="mg-menu-btn mg-menu-btn--mobile-only"
|
||||
v-tooltip.bottom="'Estatísticas & busca'"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
<span>Menu Grupos</span>
|
||||
</button>
|
||||
<div class="mg-page__title">
|
||||
<i class="pi pi-th-large text-cyan-300" />
|
||||
<span>Grupos</span>
|
||||
<span class="mg-page__count">{{ gruposFiltrados.length }}</span>
|
||||
</div>
|
||||
<div class="mg-page__actions">
|
||||
<button
|
||||
class="mg-act-btn"
|
||||
v-tooltip.bottom="'Novo grupo'"
|
||||
:disabled="loading"
|
||||
@click="abrirCriar"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
<span>Novo</span>
|
||||
</button>
|
||||
<button
|
||||
class="mg-head-btn"
|
||||
v-tooltip.bottom="'Recarregar'"
|
||||
:disabled="loading"
|
||||
@click="load"
|
||||
>
|
||||
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
|
||||
</button>
|
||||
<button class="mg-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mg-body">
|
||||
<Teleport to="#mg-mobile-drawer-target" :disabled="!isMobile">
|
||||
<aside class="mg-side">
|
||||
<div class="mg-w">
|
||||
<div class="mg-w__head">
|
||||
<span class="mg-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
|
||||
</div>
|
||||
<div class="mg-stats">
|
||||
<template v-if="carregandoInicial">
|
||||
<div v-for="i in 4" :key="`gsk-${i}`" class="mg-stat" aria-busy="true">
|
||||
<div class="mg-stat__val melissa-skeleton melissa-skeleton--number" />
|
||||
<div class="mg-stat__lbl melissa-skeleton melissa-skeleton--text" style="width: 60%; margin-top: 6px;" />
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-for="s in stats"
|
||||
v-else
|
||||
:key="s.key"
|
||||
class="mg-stat"
|
||||
:class="`is-${s.cls}`"
|
||||
>
|
||||
<div class="mg-stat__val">{{ s.value }}</div>
|
||||
<div class="mg-stat__lbl">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mg-w">
|
||||
<div class="mg-w__head">
|
||||
<span class="mg-w__title"><i class="pi pi-search" /> Buscar</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="busca"
|
||||
type="text"
|
||||
placeholder="Nome do grupo…"
|
||||
class="mg-search__input"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</Teleport>
|
||||
|
||||
<div class="mg-main">
|
||||
<div class="mg-list">
|
||||
<template v-if="carregandoInicial">
|
||||
<div v-for="i in 5" :key="`gpsk-${i}`" class="mg-card mg-card--skeleton" aria-busy="true">
|
||||
<span class="mg-card__dot melissa-skeleton" style="border-radius: 50%;" />
|
||||
<div style="flex:1; display:flex; flex-direction:column; gap:6px;">
|
||||
<span class="melissa-skeleton melissa-skeleton--title" :style="{ width: `${50 + (i * 9) % 30}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="gruposFiltrados.length === 0" class="mg-empty">
|
||||
<i class="pi pi-th-large mg-empty__icon" />
|
||||
<div class="mg-empty__title">Nenhum grupo encontrado</div>
|
||||
<div class="mg-empty__hint">
|
||||
<template v-if="busca">Ajuste a busca pra ver mais resultados.</template>
|
||||
<template v-else>Crie seu primeiro grupo pra organizar pacientes.</template>
|
||||
</div>
|
||||
<button class="mg-act-btn" @click="abrirCriar">
|
||||
<i class="pi pi-plus" /><span>Novo grupo</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="g in gruposFiltrados"
|
||||
v-else
|
||||
:key="g.id"
|
||||
class="mg-card"
|
||||
:class="{ 'is-system': g.is_system }"
|
||||
@click="abrirEditar(g)"
|
||||
>
|
||||
<span class="mg-card__dot" :style="{ background: g.cor || '#6366f1' }" />
|
||||
<div class="mg-card__main">
|
||||
<div class="mg-card__name-row">
|
||||
<span class="mg-card__name">{{ g.nome }}</span>
|
||||
<span v-if="g.is_system" class="mg-card__badge">Sistema</span>
|
||||
</div>
|
||||
<div class="mg-card__meta">
|
||||
<span><i class="pi pi-users" /> {{ g.pacientes_count }} {{ g.pacientes_count === 1 ? 'paciente' : 'pacientes' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mg-card__actions" @click.stop>
|
||||
<button
|
||||
v-if="!g.is_system"
|
||||
class="mg-card__btn"
|
||||
v-tooltip.left="'Editar'"
|
||||
@click="abrirEditar(g)"
|
||||
>
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!g.is_system"
|
||||
class="mg-card__btn mg-card__btn--danger"
|
||||
v-tooltip.left="'Excluir'"
|
||||
@click="confirmarExcluir(g)"
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="dlgOpen"
|
||||
modal
|
||||
dismissable-mask
|
||||
:style="{ width: '380px', maxWidth: '92vw' }"
|
||||
:header="dlgMode === 'create' ? 'Novo grupo' : 'Editar grupo'"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Nome
|
||||
<InputText v-model="dlgForm.nome" placeholder="Ex: Adolescentes, Casais, Adultos…" class="w-full mt-1" autofocus @keydown.enter="salvar" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Cor
|
||||
<input v-model="dlgForm.cor" type="color" class="w-full h-9 mt-1 rounded-md border border-[var(--surface-border)] bg-transparent cursor-pointer" />
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="c in PRESET_COLORS"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="w-6 h-6 rounded-full border border-white/20 transition-transform hover:scale-110"
|
||||
:style="{ background: '#' + c }"
|
||||
@click="dlgForm.cor = '#' + c"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="dlgError" class="text-xs text-red-400">{{ dlgError }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="dlgOpen = false" />
|
||||
<Button :label="saving ? 'Salvando…' : 'Salvar'" :loading="saving" :disabled="!dlgForm.nome.trim() || saving" @click="salvar" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Mesmo CSS do MelissaTags trocando o prefixo mt- → mg- */
|
||||
.mg-page {
|
||||
position: absolute;
|
||||
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 6px;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(32px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(32px) saturate(160%);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
color: var(--m-text);
|
||||
animation: mg-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
@keyframes mg-page-enter {
|
||||
from { opacity: 0; transform: scale(0.985); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.mg-page__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.mg-page__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.mg-page__title > span:not(.mg-page__count) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mg-page__count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-accent);
|
||||
background: var(--m-accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.mg-page__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
.mg-close, .mg-head-btn {
|
||||
width: 32px; height: 32px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.mg-close:hover, .mg-head-btn:hover { background: var(--m-bg-soft-hover); }
|
||||
.mg-head-btn > i { font-size: 0.85rem; }
|
||||
|
||||
.mg-act-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
background: var(--m-accent);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.mg-act-btn:hover {
|
||||
background: color-mix(in srgb, var(--m-accent) 88%, white);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.mg-act-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
|
||||
.mg-menu-btn {
|
||||
display: none;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
background: var(--m-accent);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
padding: 0 11px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.mg-menu-btn:hover {
|
||||
background: color-mix(in srgb, var(--m-accent) 88%, white);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mg-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.mg-side {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mg-side::-webkit-scrollbar { width: 5px; }
|
||||
.mg-side::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mg-w {
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.mg-w__head { margin-bottom: 10px; }
|
||||
.mg-w__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mg-w__title > i { color: var(--m-text-muted); font-size: 0.78rem; }
|
||||
|
||||
.mg-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.mg-stat {
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.mg-stat__val {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.mg-stat__lbl {
|
||||
font-size: 0.65rem;
|
||||
color: var(--m-text-muted);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.mg-stat.is-ok .mg-stat__val { color: rgb(74, 222, 128); }
|
||||
|
||||
.mg-search__input {
|
||||
width: 100%;
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
padding: 8px 12px;
|
||||
border-radius: 9px;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.mg-search__input:focus { border-color: var(--m-border-strong); }
|
||||
|
||||
.mg-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.mg-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 4px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.mg-list::-webkit-scrollbar { width: 5px; }
|
||||
.mg-list::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mg-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
text-align: left;
|
||||
}
|
||||
.mg-card:hover {
|
||||
background: var(--m-bg-soft-hover);
|
||||
border-color: var(--m-border-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.mg-card.is-system { cursor: default; }
|
||||
.mg-card.is-system:hover { transform: none; }
|
||||
.mg-card--skeleton { cursor: default; pointer-events: none; opacity: 0.95; }
|
||||
.mg-card--skeleton:hover { background: var(--m-bg-soft); transform: none; }
|
||||
|
||||
.mg-card__dot {
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.mg-card__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mg-card__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.mg-card__name {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mg-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.mg-card__meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--m-text-muted);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.mg-card__meta i { margin-right: 4px; }
|
||||
|
||||
.mg-card__actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.mg-card__btn {
|
||||
width: 28px; height: 28px;
|
||||
display: grid; place-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text-muted);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.mg-card__btn:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
|
||||
.mg-card__btn--danger:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
.mg-card__btn > i { font-size: 0.7rem; }
|
||||
|
||||
.mg-empty {
|
||||
margin: 24px 0;
|
||||
padding: 56px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--m-text-muted);
|
||||
border: 2px dashed var(--m-border-strong);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--m-bg-soft) 40%, transparent);
|
||||
gap: 8px;
|
||||
}
|
||||
.mg-empty__icon {
|
||||
font-size: 2rem;
|
||||
color: var(--m-text-faint);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mg-empty__title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-text);
|
||||
}
|
||||
.mg-empty__hint {
|
||||
font-size: 0.78rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mg-mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: min(360px, 88vw);
|
||||
z-index: 80;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border-right: 1px solid var(--m-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--m-text);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.mg-mobile-drawer.is-open { transform: translateX(0); }
|
||||
.mg-mobile-drawer__scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 12px 12px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.mg-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
|
||||
.mg-mobile-drawer__scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mg-mobile-drawer__scroll .mg-side {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
.mg-mobile-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 79;
|
||||
}
|
||||
.mg-drawer-fade-enter-active,
|
||||
.mg-drawer-fade-leave-active { transition: opacity 200ms ease; }
|
||||
.mg-drawer-fade-enter-from,
|
||||
.mg-drawer-fade-leave-to { opacity: 0; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.mg-body { flex-direction: column; padding: 8px; }
|
||||
.mg-main { width: 100%; }
|
||||
.mg-page__title > span:first-of-type { display: none; }
|
||||
.mg-menu-btn--mobile-only { display: inline-flex; }
|
||||
.mg-act-btn span { display: none; }
|
||||
.mg-act-btn { width: 32px; padding: 0; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
+1227
-158
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,876 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaMedicos — CRUD de médicos/encaminhadores dentro de Melissa.
|
||||
* Segue blueprint melissa-page-blueprint.md.
|
||||
*
|
||||
* Layout 2-col:
|
||||
* - COL 1 — Aside (~280px): stats (total, com pacientes, especialidades) + busca
|
||||
* - COL 2 — Lista de cards (avatar com inicial, nome, especialidade, contato,
|
||||
* contagem de pacientes encaminhados)
|
||||
*
|
||||
* Click no card abre dialog de edição. Botão "+" cria novo.
|
||||
* Reusa Medicos.service.js (createMedico, updateMedico, deleteMedico).
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import {
|
||||
listMedicosWithPatientCounts,
|
||||
createMedico,
|
||||
updateMedico,
|
||||
deleteMedico
|
||||
} from '@/services/Medicos.service.js';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// ── Breakpoints + drawer ───────────────────────────────────
|
||||
const drawerOpen = ref(false);
|
||||
const isMobile = ref(false);
|
||||
let _mqMobile = null;
|
||||
function _onMqMobileChange(e) {
|
||||
isMobile.value = e.matches;
|
||||
if (!e.matches) drawerOpen.value = false;
|
||||
}
|
||||
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
|
||||
function fecharDrawer() { drawerOpen.value = false; }
|
||||
|
||||
// ── Estado ─────────────────────────────────────────────────
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const medicos = ref([]);
|
||||
const busca = ref('');
|
||||
const carregandoInicial = computed(
|
||||
() => loading.value && medicos.value.length === 0
|
||||
);
|
||||
|
||||
// ── Especialidades ─────────────────────────────────────────
|
||||
const ESPECIALIDADES = [
|
||||
'Psiquiatria',
|
||||
'Neurologia',
|
||||
'Neuropsiquiatria infantil',
|
||||
'Clínica geral',
|
||||
'Pediatria',
|
||||
'Geriatria',
|
||||
'Endocrinologia',
|
||||
'Psicologia (encaminhador)',
|
||||
'Assistência social',
|
||||
'Fonoaudiologia',
|
||||
'Terapia ocupacional',
|
||||
'Fisioterapia',
|
||||
'Outra'
|
||||
];
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────
|
||||
function digitsOnly(v) { return String(v ?? '').replace(/\D/g, ''); }
|
||||
function fmtPhone(v) {
|
||||
const d = digitsOnly(v);
|
||||
if (!d) return '';
|
||||
if (d.length === 11) return `(${d.slice(0,2)}) ${d.slice(2,7)}-${d.slice(7,11)}`;
|
||||
if (d.length === 10) return `(${d.slice(0,2)}) ${d.slice(2,6)}-${d.slice(6,10)}`;
|
||||
return d;
|
||||
}
|
||||
function iniciais(nome) {
|
||||
if (!nome) return '?';
|
||||
const partes = String(nome).trim().split(/\s+/);
|
||||
if (partes.length === 1) return partes[0][0]?.toUpperCase() || '?';
|
||||
return (partes[0][0] + partes[partes.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
// ── Stats ──────────────────────────────────────────────────
|
||||
const stats = computed(() => {
|
||||
const all = medicos.value;
|
||||
const comPacs = all.filter((m) => Number(m.patients_count || 0) > 0).length;
|
||||
const totalPacs = all.reduce((s, m) => s + Number(m.patients_count || 0), 0);
|
||||
const especs = new Set(all.map((m) => m.especialidade).filter(Boolean)).size;
|
||||
return [
|
||||
{ key: 'total', label: 'Médicos', value: all.length, cls: 'neutral' },
|
||||
{ key: 'esp', label: 'Especialidades', value: especs, cls: 'neutral' },
|
||||
{ key: 'com', label: 'Com pacientes', value: comPacs, cls: comPacs > 0 ? 'ok' : 'neutral' },
|
||||
{ key: 'enc', label: 'Encaminhados', value: totalPacs, cls: totalPacs > 0 ? 'ok' : 'neutral' }
|
||||
];
|
||||
});
|
||||
|
||||
const medicosFiltrados = computed(() => {
|
||||
const q = String(busca.value || '').trim().toLowerCase();
|
||||
if (!q) return medicos.value;
|
||||
return medicos.value.filter((m) => {
|
||||
return String(m.nome || '').toLowerCase().includes(q) ||
|
||||
String(m.especialidade || '').toLowerCase().includes(q) ||
|
||||
String(m.crm || '').toLowerCase().includes(q) ||
|
||||
String(m.clinica || '').toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────
|
||||
async function fetchAll() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await listMedicosWithPatientCounts();
|
||||
medicos.value = data || [];
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e?.message, life: 4500 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialog ─────────────────────────────────────────────────
|
||||
const dlgOpen = ref(false);
|
||||
const dlgMode = ref('create');
|
||||
const dlgError = ref('');
|
||||
const dlgForm = ref({
|
||||
id: '',
|
||||
nome: '',
|
||||
crm: '',
|
||||
especialidade: '',
|
||||
especialidade_outra: '',
|
||||
telefone_profissional: '',
|
||||
telefone_pessoal: '',
|
||||
email: '',
|
||||
clinica: '',
|
||||
cidade: '',
|
||||
estado: 'SP',
|
||||
observacoes: ''
|
||||
});
|
||||
|
||||
const especialidadeFinal = computed(() => {
|
||||
if (dlgForm.value.especialidade === 'Outra') return dlgForm.value.especialidade_outra.trim();
|
||||
return dlgForm.value.especialidade;
|
||||
});
|
||||
|
||||
function abrirCriar() {
|
||||
dlgMode.value = 'create';
|
||||
dlgForm.value = {
|
||||
id: '', nome: '', crm: '', especialidade: '', especialidade_outra: '',
|
||||
telefone_profissional: '', telefone_pessoal: '', email: '',
|
||||
clinica: '', cidade: '', estado: 'SP', observacoes: ''
|
||||
};
|
||||
dlgError.value = '';
|
||||
dlgOpen.value = true;
|
||||
}
|
||||
function abrirEditar(row) {
|
||||
dlgMode.value = 'edit';
|
||||
const isOutraEsp = row.especialidade && !ESPECIALIDADES.includes(row.especialidade);
|
||||
dlgForm.value = {
|
||||
id: row.id,
|
||||
nome: row.nome || '',
|
||||
crm: row.crm || '',
|
||||
especialidade: isOutraEsp ? 'Outra' : (row.especialidade || ''),
|
||||
especialidade_outra: isOutraEsp ? row.especialidade : '',
|
||||
telefone_profissional: fmtPhone(row.telefone_profissional),
|
||||
telefone_pessoal: fmtPhone(row.telefone_pessoal),
|
||||
email: row.email || '',
|
||||
clinica: row.clinica || '',
|
||||
cidade: row.cidade || '',
|
||||
estado: row.estado || 'SP',
|
||||
observacoes: row.observacoes || ''
|
||||
};
|
||||
dlgError.value = '';
|
||||
dlgOpen.value = true;
|
||||
}
|
||||
|
||||
async function salvar() {
|
||||
const nome = String(dlgForm.value.nome || '').trim();
|
||||
if (!nome) {
|
||||
dlgError.value = 'Informe o nome do médico.';
|
||||
return;
|
||||
}
|
||||
if (dlgForm.value.especialidade === 'Outra' && !dlgForm.value.especialidade_outra.trim()) {
|
||||
dlgError.value = 'Informe a especialidade.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
dlgError.value = '';
|
||||
|
||||
const payload = {
|
||||
nome,
|
||||
crm: dlgForm.value.crm.trim() || null,
|
||||
especialidade: especialidadeFinal.value || null,
|
||||
telefone_profissional: dlgForm.value.telefone_profissional ? digitsOnly(dlgForm.value.telefone_profissional) : null,
|
||||
telefone_pessoal: dlgForm.value.telefone_pessoal ? digitsOnly(dlgForm.value.telefone_pessoal) : null,
|
||||
email: dlgForm.value.email.trim() || null,
|
||||
clinica: dlgForm.value.clinica.trim() || null,
|
||||
cidade: dlgForm.value.cidade.trim() || null,
|
||||
estado: dlgForm.value.estado.trim() || null,
|
||||
observacoes: dlgForm.value.observacoes.trim() || null
|
||||
};
|
||||
|
||||
try {
|
||||
if (dlgMode.value === 'create') {
|
||||
await createMedico(payload);
|
||||
toast.add({ severity: 'success', summary: 'Médico cadastrado', life: 2200 });
|
||||
} else {
|
||||
await updateMedico(dlgForm.value.id, payload);
|
||||
toast.add({ severity: 'success', summary: 'Médico atualizado', life: 2200 });
|
||||
}
|
||||
dlgOpen.value = false;
|
||||
await fetchAll();
|
||||
} catch (e) {
|
||||
dlgError.value = e?.message || 'Falha ao salvar.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────
|
||||
function confirmarExcluir(row) {
|
||||
confirm.require({
|
||||
message: `Desativar "Dr(a). ${row.nome}"? O registro será ocultado da listagem.`,
|
||||
header: 'Desativar médico',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Desativar',
|
||||
rejectLabel: 'Cancelar',
|
||||
acceptSeverity: 'danger',
|
||||
accept: () => excluir(row)
|
||||
});
|
||||
}
|
||||
async function excluir(row) {
|
||||
saving.value = true;
|
||||
try {
|
||||
await deleteMedico(row.id);
|
||||
toast.add({ severity: 'success', summary: 'Médico desativado', life: 2200 });
|
||||
await fetchAll();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao desativar.', life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
_mqMobile = window.matchMedia('(max-width: 1023px)');
|
||||
isMobile.value = _mqMobile.matches;
|
||||
_mqMobile.addEventListener('change', _onMqMobileChange);
|
||||
}
|
||||
await fetchAll();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="mm-mobile-drawer"
|
||||
:class="{ 'is-open': drawerOpen }"
|
||||
v-show="isMobile"
|
||||
aria-label="Estatísticas e busca"
|
||||
>
|
||||
<div id="mm-mobile-drawer-target" class="mm-mobile-drawer__scroll" />
|
||||
</aside>
|
||||
<Transition name="mm-drawer-fade">
|
||||
<div
|
||||
v-if="isMobile && drawerOpen"
|
||||
class="mm-mobile-drawer__backdrop"
|
||||
@click="fecharDrawer"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<section class="mm-page">
|
||||
<header class="mm-page__head">
|
||||
<button
|
||||
class="mm-menu-btn mm-menu-btn--mobile-only"
|
||||
v-tooltip.bottom="'Estatísticas & busca'"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
<span>Menu Médicos</span>
|
||||
</button>
|
||||
<div class="mm-page__title">
|
||||
<i class="pi pi-user-edit text-rose-300" />
|
||||
<span>Médicos & referências</span>
|
||||
<span class="mm-page__count">{{ medicosFiltrados.length }}</span>
|
||||
</div>
|
||||
<div class="mm-page__actions">
|
||||
<button
|
||||
class="mm-act-btn"
|
||||
v-tooltip.bottom="'Novo médico'"
|
||||
:disabled="loading"
|
||||
@click="abrirCriar"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
<span>Novo</span>
|
||||
</button>
|
||||
<button
|
||||
class="mm-head-btn"
|
||||
v-tooltip.bottom="'Recarregar'"
|
||||
:disabled="loading"
|
||||
@click="fetchAll"
|
||||
>
|
||||
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
|
||||
</button>
|
||||
<button class="mm-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mm-body">
|
||||
<Teleport to="#mm-mobile-drawer-target" :disabled="!isMobile">
|
||||
<aside class="mm-side">
|
||||
<div class="mm-w">
|
||||
<div class="mm-w__head">
|
||||
<span class="mm-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
|
||||
</div>
|
||||
<div class="mm-stats">
|
||||
<template v-if="carregandoInicial">
|
||||
<div v-for="i in 4" :key="`sk-${i}`" class="mm-stat" aria-busy="true">
|
||||
<div class="mm-stat__val melissa-skeleton melissa-skeleton--number" />
|
||||
<div class="mm-stat__lbl melissa-skeleton melissa-skeleton--text" style="width: 60%; margin-top: 6px;" />
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-for="s in stats"
|
||||
v-else
|
||||
:key="s.key"
|
||||
class="mm-stat"
|
||||
:class="`is-${s.cls}`"
|
||||
>
|
||||
<div class="mm-stat__val">{{ s.value }}</div>
|
||||
<div class="mm-stat__lbl">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mm-w">
|
||||
<div class="mm-w__head">
|
||||
<span class="mm-w__title"><i class="pi pi-search" /> Buscar</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="busca"
|
||||
type="text"
|
||||
placeholder="Nome, especialidade, CRM, clínica…"
|
||||
class="mm-search__input"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</Teleport>
|
||||
|
||||
<div class="mm-main">
|
||||
<div class="mm-list">
|
||||
<template v-if="carregandoInicial">
|
||||
<div v-for="i in 5" :key="`csk-${i}`" class="mm-card mm-card--skeleton" aria-busy="true">
|
||||
<span class="mm-card__avatar melissa-skeleton melissa-skeleton--avatar" />
|
||||
<div style="flex:1; display:flex; flex-direction:column; gap:6px;">
|
||||
<span class="melissa-skeleton melissa-skeleton--title" :style="{ width: `${50 + (i * 9) % 30}%` }" />
|
||||
<span class="melissa-skeleton melissa-skeleton--text" style="width: 60%;" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="medicosFiltrados.length === 0" class="mm-empty">
|
||||
<i class="pi pi-user-edit mm-empty__icon" />
|
||||
<div class="mm-empty__title">Nenhum médico encontrado</div>
|
||||
<div class="mm-empty__hint">
|
||||
<template v-if="busca">Ajuste a busca pra ver mais resultados.</template>
|
||||
<template v-else>Cadastre médicos pra registrar encaminhamentos.</template>
|
||||
</div>
|
||||
<button class="mm-act-btn" @click="abrirCriar">
|
||||
<i class="pi pi-plus" /><span>Novo médico</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="m in medicosFiltrados"
|
||||
v-else
|
||||
:key="m.id"
|
||||
class="mm-card"
|
||||
@click="abrirEditar(m)"
|
||||
>
|
||||
<span class="mm-card__avatar">{{ iniciais(m.nome) }}</span>
|
||||
<div class="mm-card__main">
|
||||
<div class="mm-card__name-row">
|
||||
<span class="mm-card__name">Dr(a). {{ m.nome }}</span>
|
||||
<span v-if="m.especialidade" class="mm-card__esp">{{ m.especialidade }}</span>
|
||||
</div>
|
||||
<div class="mm-card__meta">
|
||||
<span v-if="m.crm"><i class="pi pi-id-card" /> CRM {{ m.crm }}</span>
|
||||
<span v-if="m.telefone_profissional"><i class="pi pi-phone" /> {{ fmtPhone(m.telefone_profissional) }}</span>
|
||||
<span v-if="m.email"><i class="pi pi-envelope" /> {{ m.email }}</span>
|
||||
<span v-if="m.clinica"><i class="pi pi-building" /> {{ m.clinica }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mm-card__right">
|
||||
<div class="mm-card__count">
|
||||
<i class="pi pi-users" />
|
||||
{{ m.patients_count || 0 }}
|
||||
</div>
|
||||
<div class="mm-card__actions" @click.stop>
|
||||
<button
|
||||
class="mm-card__btn"
|
||||
v-tooltip.left="'Editar'"
|
||||
@click="abrirEditar(m)"
|
||||
>
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
<button
|
||||
class="mm-card__btn mm-card__btn--danger"
|
||||
v-tooltip.left="'Desativar'"
|
||||
@click="confirmarExcluir(m)"
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog create/edit -->
|
||||
<Dialog
|
||||
v-model:visible="dlgOpen"
|
||||
modal
|
||||
dismissable-mask
|
||||
:style="{ width: '560px', maxWidth: '94vw' }"
|
||||
:header="dlgMode === 'create' ? 'Novo médico' : 'Editar médico'"
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<label class="text-xs text-[var(--text-color-secondary)] md:col-span-2">
|
||||
Nome *
|
||||
<InputText v-model="dlgForm.nome" placeholder="Nome completo" class="w-full mt-1" autofocus />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
CRM
|
||||
<InputText v-model="dlgForm.crm" placeholder="Ex: 123456-SP" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Especialidade
|
||||
<Select v-model="dlgForm.especialidade" :options="ESPECIALIDADES" placeholder="Selecione…" class="w-full mt-1" />
|
||||
</label>
|
||||
<label v-if="dlgForm.especialidade === 'Outra'" class="text-xs text-[var(--text-color-secondary)] md:col-span-2">
|
||||
Especialidade (livre)
|
||||
<InputText v-model="dlgForm.especialidade_outra" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Telefone profissional
|
||||
<InputText v-model="dlgForm.telefone_profissional" placeholder="(11) 91234-5678" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Telefone pessoal
|
||||
<InputText v-model="dlgForm.telefone_pessoal" placeholder="(11) 91234-5678" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)] md:col-span-2">
|
||||
E-mail
|
||||
<InputText v-model="dlgForm.email" placeholder="email@dominio.com" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)] md:col-span-2">
|
||||
Clínica/instituição
|
||||
<InputText v-model="dlgForm.clinica" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Cidade
|
||||
<InputText v-model="dlgForm.cidade" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Estado
|
||||
<InputText v-model="dlgForm.estado" maxlength="2" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)] md:col-span-2">
|
||||
Observações
|
||||
<Textarea v-model="dlgForm.observacoes" autoResize rows="2" class="w-full mt-1" />
|
||||
</label>
|
||||
<div v-if="dlgError" class="text-xs text-red-400 md:col-span-2">{{ dlgError }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="dlgOpen = false" />
|
||||
<Button :label="saving ? 'Salvando…' : 'Salvar'" :loading="saving" :disabled="!dlgForm.nome.trim() || saving" @click="salvar" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mm-page {
|
||||
position: absolute;
|
||||
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 6px;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(32px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(32px) saturate(160%);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
color: var(--m-text);
|
||||
animation: mm-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
@keyframes mm-page-enter {
|
||||
from { opacity: 0; transform: scale(0.985); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.mm-page__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.mm-page__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.mm-page__title > span:not(.mm-page__count) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mm-page__count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-accent);
|
||||
background: var(--m-accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.mm-page__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
.mm-close, .mm-head-btn {
|
||||
width: 32px; height: 32px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.mm-close:hover, .mm-head-btn:hover { background: var(--m-bg-soft-hover); }
|
||||
.mm-head-btn > i { font-size: 0.85rem; }
|
||||
|
||||
.mm-act-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
background: var(--m-accent);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.mm-act-btn:hover {
|
||||
background: color-mix(in srgb, var(--m-accent) 88%, white);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.mm-act-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
|
||||
.mm-menu-btn {
|
||||
display: none;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
background: var(--m-accent);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
padding: 0 11px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.mm-menu-btn:hover {
|
||||
background: color-mix(in srgb, var(--m-accent) 88%, white);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mm-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.mm-side {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mm-side::-webkit-scrollbar { width: 5px; }
|
||||
.mm-side::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mm-w {
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.mm-w__head { margin-bottom: 10px; }
|
||||
.mm-w__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mm-w__title > i { color: var(--m-text-muted); font-size: 0.78rem; }
|
||||
|
||||
.mm-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.mm-stat {
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.mm-stat__val {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.mm-stat__lbl {
|
||||
font-size: 0.65rem;
|
||||
color: var(--m-text-muted);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.mm-stat.is-ok .mm-stat__val { color: rgb(74, 222, 128); }
|
||||
|
||||
.mm-search__input {
|
||||
width: 100%;
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
padding: 8px 12px;
|
||||
border-radius: 9px;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.mm-search__input:focus { border-color: var(--m-border-strong); }
|
||||
|
||||
.mm-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.mm-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 4px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.mm-list::-webkit-scrollbar { width: 5px; }
|
||||
.mm-list::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mm-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
text-align: left;
|
||||
}
|
||||
.mm-card:hover {
|
||||
background: var(--m-bg-soft-hover);
|
||||
border-color: var(--m-border-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.mm-card--skeleton { cursor: default; pointer-events: none; opacity: 0.95; }
|
||||
.mm-card--skeleton:hover { background: var(--m-bg-soft); transform: none; }
|
||||
|
||||
.mm-card__avatar {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--m-accent-strong);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
display: grid; place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mm-card__main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.mm-card__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mm-card__name {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mm-card__esp {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--m-accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, var(--m-border));
|
||||
color: var(--m-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.mm-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--m-text-muted);
|
||||
}
|
||||
.mm-card__meta i { margin-right: 4px; font-size: 0.65rem; }
|
||||
|
||||
.mm-card__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mm-card__count {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-accent);
|
||||
background: var(--m-accent-soft);
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.mm-card__count i { font-size: 0.65rem; }
|
||||
|
||||
.mm-card__actions { display: flex; gap: 4px; }
|
||||
.mm-card__btn {
|
||||
width: 28px; height: 28px;
|
||||
display: grid; place-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text-muted);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.mm-card__btn:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
|
||||
.mm-card__btn--danger:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
.mm-card__btn > i { font-size: 0.7rem; }
|
||||
|
||||
.mm-empty {
|
||||
margin: 24px 0;
|
||||
padding: 56px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--m-text-muted);
|
||||
border: 2px dashed var(--m-border-strong);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--m-bg-soft) 40%, transparent);
|
||||
gap: 8px;
|
||||
}
|
||||
.mm-empty__icon { font-size: 2rem; color: var(--m-text-faint); margin-bottom: 4px; }
|
||||
.mm-empty__title { font-size: 0.92rem; font-weight: 600; color: var(--m-text); }
|
||||
.mm-empty__hint { font-size: 0.78rem; margin-bottom: 8px; }
|
||||
|
||||
/* Drawer mobile */
|
||||
.mm-mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: min(360px, 88vw);
|
||||
z-index: 80;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border-right: 1px solid var(--m-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--m-text);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.mm-mobile-drawer.is-open { transform: translateX(0); }
|
||||
.mm-mobile-drawer__scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 12px 12px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.mm-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
|
||||
.mm-mobile-drawer__scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mm-mobile-drawer__scroll .mm-side {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
.mm-mobile-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 79;
|
||||
}
|
||||
.mm-drawer-fade-enter-active,
|
||||
.mm-drawer-fade-leave-active { transition: opacity 200ms ease; }
|
||||
.mm-drawer-fade-enter-from,
|
||||
.mm-drawer-fade-leave-to { opacity: 0; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.mm-body { flex-direction: column; padding: 8px; }
|
||||
.mm-main { width: 100%; }
|
||||
.mm-page__title > span:first-of-type { display: none; }
|
||||
.mm-menu-btn--mobile-only { display: inline-flex; }
|
||||
.mm-act-btn span { display: none; }
|
||||
.mm-act-btn { width: 32px; padding: 0; justify-content: center; }
|
||||
.mm-card__count { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -54,6 +54,7 @@ const CATEGORIAS = [
|
||||
{ key: 'agenda', label: 'Minha Agenda', icon: 'pi pi-calendar' },
|
||||
{ key: 'pacientes', label: 'Meus Pacientes', icon: 'pi pi-users' },
|
||||
{ key: 'cadastros-recebidos', label: 'Cadastros recebidos', icon: 'pi pi-inbox' },
|
||||
{ key: 'agendamentos-recebidos', label: 'Agendamentos recebidos', icon: 'pi pi-bell' },
|
||||
{ key: 'meu-link-cadastro', label: 'Meu link de cadastro', icon: 'pi pi-link', tipo: 'link-cadastro' }
|
||||
]
|
||||
},
|
||||
@@ -64,7 +65,9 @@ const CATEGORIAS = [
|
||||
{ key: 'compromissos', label: 'Compromissos determinados', icon: 'pi pi-flag' },
|
||||
{ key: 'grupos', label: 'Grupos de pacientes', icon: 'pi pi-th-large' },
|
||||
{ key: 'tags', label: 'Tags', icon: 'pi pi-tag' },
|
||||
{ key: 'medicos', label: 'Médicos e referências', icon: 'pi pi-user-edit' }
|
||||
{ key: 'medicos', label: 'Médicos e referências', icon: 'pi pi-user-edit' },
|
||||
{ key: 'online-scheduling', label: 'Agendador online', icon: 'pi pi-calendar-clock' },
|
||||
{ key: 'link-externo', label: 'Link externo de cadastro', icon: 'pi pi-share-alt' }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -74,28 +77,98 @@ const CATEGORIAS = [
|
||||
label: 'WhatsApp',
|
||||
icon: 'pi pi-whatsapp',
|
||||
color: '#22c55e',
|
||||
groups: []
|
||||
groups: [
|
||||
{
|
||||
title: 'Atendimento',
|
||||
items: [
|
||||
{ key: 'conversas', label: 'Conversas', icon: 'pi pi-comments' },
|
||||
{ key: 'notificacoes', label: 'Notificações enviadas', icon: 'pi pi-bell' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Configuração',
|
||||
items: [
|
||||
{ key: 'wa-canal', label: 'Configurar canal', icon: 'pi pi-cog', route: { name: 'ConfiguracoesWhatsapp' } },
|
||||
{ key: 'wa-templates', label: 'Templates de mensagem', icon: 'pi pi-file-edit', route: { name: 'ConfiguracoesWhatsappTemplates' } },
|
||||
{ key: 'wa-creditos', label: 'Créditos', icon: 'pi pi-credit-card', route: { name: 'ConfiguracoesCreditosWhatsapp' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'prontuarios',
|
||||
label: 'Prontuários',
|
||||
icon: 'pi pi-file',
|
||||
color: '#0ea5e9',
|
||||
groups: []
|
||||
groups: [
|
||||
{
|
||||
title: 'Acesso',
|
||||
items: [
|
||||
// Sem route — emit('select', 'pacientes') aciona o MelissaPacientes
|
||||
// (lá o duplo-click no card abre PatientProntuario). Mantém o
|
||||
// user dentro do Melissa em vez de jogar pra rota externa.
|
||||
{ key: 'pacientes', label: 'Abrir por paciente', icon: 'pi pi-users' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Documentos',
|
||||
items: [
|
||||
{ key: 'documentos', label: 'Documentos', icon: 'pi pi-file' },
|
||||
{ key: 'documentos-templates', label: 'Templates de documentos', icon: 'pi pi-file-edit' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'financeiro',
|
||||
label: 'Financeiro',
|
||||
icon: 'pi pi-wallet',
|
||||
color: '#f59e0b',
|
||||
groups: []
|
||||
groups: [
|
||||
{
|
||||
title: 'Principais',
|
||||
items: [
|
||||
// Sem route — abre embedado via MelissaEmbed dentro do overlay Melissa
|
||||
{ key: 'financeiro', label: 'Visão geral', icon: 'pi pi-chart-line' },
|
||||
{ key: 'financeiro-lancamentos', label: 'Lançamentos', icon: 'pi pi-list' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Análise',
|
||||
items: [
|
||||
{ key: 'relatorios', label: 'Relatórios', icon: 'pi pi-chart-bar' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'configuracoes',
|
||||
label: 'Configurações',
|
||||
icon: 'pi pi-cog',
|
||||
color: '#94a3b8',
|
||||
groups: []
|
||||
groups: [
|
||||
{
|
||||
title: 'Layout Melissa',
|
||||
items: [
|
||||
// Sem `route` — emit('select', 'aparencia') abre página interna do Melissa
|
||||
{ key: 'aparencia', label: 'Aparência e cronômetro', icon: 'pi pi-palette' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Agenda',
|
||||
items: [
|
||||
{ key: 'cfg-agenda', label: 'Agenda', icon: 'pi pi-calendar', route: { name: 'ConfiguracoesAgenda' } },
|
||||
{ key: 'cfg-agendador', label: 'Agendador externo', icon: 'pi pi-link', route: { name: 'ConfiguracoesAgendador' } }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'WhatsApp',
|
||||
items: [
|
||||
{ key: 'cfg-wa', label: 'Canal de WhatsApp', icon: 'pi pi-whatsapp', route: { name: 'ConfiguracoesWhatsapp' } },
|
||||
{ key: 'cfg-wa-templates', label: 'Templates', icon: 'pi pi-file-edit', route: { name: 'ConfiguracoesWhatsappTemplates' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -103,6 +176,11 @@ const CATEGORIAS = [
|
||||
const selectedKey = ref(CATEGORIAS[0].key); // primeira categoria por default
|
||||
const copiado = ref(false);
|
||||
|
||||
// Drill-down mobile: false = lista de categorias, true = sub-itens da
|
||||
// categoria escolhida. CSS controla visibilidade via translateX em <lg.
|
||||
// Em desktop o flag é ignorado (ambas colunas sempre visíveis).
|
||||
const mobileSubView = ref(false);
|
||||
|
||||
const categoriaAtiva = computed(() =>
|
||||
CATEGORIAS.find((c) => c.key === selectedKey.value) || CATEGORIAS[0]
|
||||
);
|
||||
@@ -115,10 +193,23 @@ function selecionarCategoria(key) {
|
||||
selectedKey.value = key;
|
||||
copiado.value = false;
|
||||
themeViewActive.value = false; // sai do tema ao mudar categoria
|
||||
mobileSubView.value = true; // drill-down em mobile
|
||||
}
|
||||
|
||||
function voltarParaCategorias() {
|
||||
mobileSubView.value = false;
|
||||
themeViewActive.value = false;
|
||||
}
|
||||
|
||||
function clicarSubItem(item) {
|
||||
if (item.tipo === 'link-cadastro') return; // inline, não navega
|
||||
// Se item tem route definida, navega direto (rota externa ao Melissa).
|
||||
// Senão, emite 'select' pro pai decidir (seções internas ao MelissaLayout).
|
||||
if (item.route) {
|
||||
emit('close');
|
||||
safePush(item.route);
|
||||
return;
|
||||
}
|
||||
emit('select', item.key);
|
||||
}
|
||||
|
||||
@@ -172,16 +263,13 @@ function navAndClose(target, fallback) {
|
||||
safePush(target, fallback);
|
||||
}
|
||||
|
||||
function goPerfil() { navAndClose({ name: 'account-profile' }, '/account/profile'); }
|
||||
function goSeguranca() { navAndClose({ name: 'account-security' }, '/account/security'); }
|
||||
function goPlano() {
|
||||
const r = role.value || sessionRole.value;
|
||||
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') {
|
||||
return navAndClose({ name: 'admin-meu-plano' }, '/admin/meu-plano');
|
||||
}
|
||||
if (r === 'supervisor') return navAndClose({ name: 'supervisor.meu-plano' }, '/supervisor/meu-plano');
|
||||
return navAndClose({ name: 'therapist-meu-plano' }, '/therapist/meu-plano');
|
||||
}
|
||||
// Atalhos de Conta — abrem embedados dentro do MelissaConfiguracoes
|
||||
// (em vez de navegar pra rota externa). Cada um vira uma section pré-
|
||||
// selecionada na sidebar de configs.
|
||||
function goPerfil() { emit('select', 'perfil'); emit('close'); }
|
||||
function goPlano() { emit('select', 'plano'); emit('close'); }
|
||||
function goNegocio() { emit('select', 'negocio'); emit('close'); }
|
||||
function goSeguranca() { emit('select', 'seguranca'); emit('close'); }
|
||||
|
||||
async function toggleDarkAndPersist() {
|
||||
try {
|
||||
@@ -203,6 +291,7 @@ const themeViewActive = ref(false);
|
||||
|
||||
function toggleThemeView() {
|
||||
themeViewActive.value = !themeViewActive.value;
|
||||
if (themeViewActive.value) mobileSubView.value = true; // drill-down em mobile
|
||||
}
|
||||
|
||||
function saveThemeToStorage() {
|
||||
@@ -252,11 +341,22 @@ async function sair() {
|
||||
|
||||
<template>
|
||||
<div class="mm-layer" @click.self="emit('close')">
|
||||
<div class="mm-panel">
|
||||
<div class="mm-panel" :class="{ 'is-mobile-sub': mobileSubView }">
|
||||
<!-- ════ ESQUERDA: categorias ════ -->
|
||||
<nav class="mm-side">
|
||||
<div class="mm-side__head">
|
||||
<div class="mm-side__title">Menu</div>
|
||||
<!-- Fechar (mobile only): em desktop o ψ continua visível
|
||||
no canto inferior pra fechar; em mobile o menu cobre
|
||||
tudo, então precisa de botão dedicado. -->
|
||||
<button
|
||||
class="mm-side__close mm-side__close--mobile-only"
|
||||
title="Fechar menu"
|
||||
aria-label="Fechar menu"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mm-side__list">
|
||||
@@ -291,6 +391,9 @@ async function sair() {
|
||||
<button class="mm-foot-item" @click="goPlano">
|
||||
<i class="pi pi-credit-card" /><span>Meus Planos</span>
|
||||
</button>
|
||||
<button class="mm-foot-item" @click="goNegocio">
|
||||
<i class="pi pi-briefcase" /><span>Meu Negócio</span>
|
||||
</button>
|
||||
<button class="mm-foot-item" @click="goSeguranca">
|
||||
<i class="pi pi-shield" /><span>Segurança</span>
|
||||
</button>
|
||||
@@ -328,6 +431,17 @@ async function sair() {
|
||||
<!-- ════ DIREITA: sub-itens OU cores do tema ════ -->
|
||||
<aside class="mm-aside">
|
||||
<div class="mm-aside__head">
|
||||
<!-- Voltar (mobile only): só aparece em <lg quando o
|
||||
drill-down está em modo "sub-itens". Em desktop as
|
||||
duas colunas convivem, voltar não faz sentido. -->
|
||||
<button
|
||||
class="mm-aside__back mm-aside__back--mobile-only"
|
||||
title="Voltar"
|
||||
aria-label="Voltar pra categorias"
|
||||
@click="voltarParaCategorias"
|
||||
>
|
||||
<i class="pi pi-arrow-left" />
|
||||
</button>
|
||||
<div class="mm-aside__title">
|
||||
{{ themeViewActive ? 'Cores do Tema' : categoriaAtiva.label }}
|
||||
</div>
|
||||
@@ -490,11 +604,18 @@ async function sair() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ─── Layer (overlay full-screen, transparente) ───────────── */
|
||||
/* ─── Layer (overlay full-screen com blur sutil) ─────────────
|
||||
Aplica um leve escurecimento + blur-xs (2px) atrás do menu pra dar
|
||||
sensação de "modal" e desfocar o conteúdo embaixo. Em mobile (<lg)
|
||||
o media query mais embaixo aumenta a intensidade pra cobrir todo
|
||||
o viewport com força. */
|
||||
.mm-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
/* ─── Painel float ───────────────────────────────────────── */
|
||||
@@ -525,7 +646,13 @@ async function sair() {
|
||||
border-right: 1px solid var(--m-border);
|
||||
background: var(--m-bg-soft);
|
||||
}
|
||||
.mm-side__head { padding: 18px 18px 8px; }
|
||||
.mm-side__head {
|
||||
padding: 18px 18px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.mm-side__title {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
@@ -533,6 +660,22 @@ async function sair() {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Botão fechar — só visível em mobile (≤lg). Vira display:flex no @media. */
|
||||
.mm-side__close {
|
||||
display: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.mm-side__close:hover { background: var(--m-bg-soft-hover); }
|
||||
.mm-side__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -724,12 +867,39 @@ async function sair() {
|
||||
flex-direction: column;
|
||||
padding: 18px;
|
||||
}
|
||||
.mm-aside__head { margin-bottom: 14px; }
|
||||
.mm-aside__head {
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.mm-aside__title {
|
||||
color: var(--m-text);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Botão voltar — só visível em mobile (≤lg) com drill-down ativo. */
|
||||
.mm-aside__back {
|
||||
display: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.mm-aside__back:hover { background: var(--m-bg-soft-hover); transform: translateX(-1px); }
|
||||
.mm-aside__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -1039,4 +1209,108 @@ async function sair() {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Responsivo <lg (≤1023px) — drawer da esquerda (paridade com Agenda)
|
||||
───────────────────────────────────────────────────────────────
|
||||
- .mm-layer vira backdrop fullscreen (escurece + blur), click fora fecha
|
||||
- .mm-panel vira drawer 360px (mesmo tamanho do .ma-mobile-drawer),
|
||||
desliza da esquerda
|
||||
- .mm-side e .mm-aside viram camadas absolutas, alternam via
|
||||
translateX controlado pelo modificador .is-mobile-sub
|
||||
- Botão "fechar" no header da side, "voltar" no header do aside
|
||||
- z-index do .mm-layer sobe pra 90 pra cobrir o ψ (70) e o dock (65)
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@media (max-width: 1023px) {
|
||||
/* Layer = backdrop. Click fora (no próprio layer) fecha via @click.self
|
||||
que já existe no template. position:fixed garante cobertura mesmo
|
||||
se algum ancestor estiver scrollado. */
|
||||
.mm-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
.mm-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: auto;
|
||||
width: min(360px, 88vw); /* paridade com .ma-mobile-drawer */
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
border-radius: 0;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-bottom: none;
|
||||
border-right: 1px solid var(--m-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* As duas colunas viram camadas full do painel, animadas via translateX. */
|
||||
.mm-side,
|
||||
.mm-aside {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
.mm-side {
|
||||
transform: translateX(0);
|
||||
z-index: 1;
|
||||
border-right: none;
|
||||
}
|
||||
.mm-aside {
|
||||
transform: translateX(100%);
|
||||
z-index: 2;
|
||||
background: var(--m-bg-medium);
|
||||
}
|
||||
|
||||
/* Modo "sub-itens" (drill-down ativo) */
|
||||
.mm-panel.is-mobile-sub .mm-side {
|
||||
transform: translateX(-12%); /* leve parallax pra dar profundidade */
|
||||
}
|
||||
.mm-panel.is-mobile-sub .mm-aside {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Botões mobile-only ganham display */
|
||||
.mm-side__close--mobile-only { display: inline-flex; }
|
||||
.mm-aside__back--mobile-only { display: inline-flex; }
|
||||
|
||||
/* Header da side fica um pouco mais aberto pra acomodar o close */
|
||||
.mm-side__head {
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.mm-side__title {
|
||||
font-size: 0.7rem; /* lê melhor em mobile */
|
||||
}
|
||||
|
||||
/* Aside head: o título fica MAIS espaçado no topo, e o aside ganha
|
||||
padding lateral menor (telas pequenas precisam de cada pixel). */
|
||||
.mm-aside {
|
||||
padding: 14px 14px 18px;
|
||||
}
|
||||
.mm-aside__head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Sub-itens com mais respiro vertical (toque tem que pegar) */
|
||||
.mm-sub {
|
||||
padding: 12px 12px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.mm-cat {
|
||||
padding: 12px 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.mm-cat__icon { width: 36px; height: 36px; }
|
||||
|
||||
/* Footer continua na tela 1 (lista de categorias) */
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,806 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaTags — CRUD de tags de pacientes dentro de Melissa.
|
||||
* Segue blueprint melissa-page-blueprint.md.
|
||||
*
|
||||
* Layout 2-col:
|
||||
* - COL 1 — Aside (~280px): stats (total, em uso, padrão, minhas) + busca
|
||||
* - COL 2 — Lista central: cards de tag (dot colorido + nome + contagem)
|
||||
*
|
||||
* Click num card abre dialog de edição. Botão "+" cria nova.
|
||||
* Reutiliza lógica de patient_tags da TagsPage (mais enxuta — sem multi-select).
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
// Dialog/InputText/Button auto-imported via PrimeVueResolver
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// ── Breakpoints + drawer (blueprint §2/§3) ─────────────────
|
||||
const drawerOpen = ref(false);
|
||||
const isMobile = ref(false);
|
||||
let _mqMobile = null;
|
||||
function _onMqMobileChange(e) {
|
||||
isMobile.value = e.matches;
|
||||
if (!e.matches) drawerOpen.value = false;
|
||||
}
|
||||
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
|
||||
function fecharDrawer() { drawerOpen.value = false; }
|
||||
|
||||
// ── Estado ─────────────────────────────────────────────────
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const tags = ref([]);
|
||||
const busca = ref('');
|
||||
const carregandoInicial = computed(
|
||||
() => loading.value && tags.value.length === 0
|
||||
);
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────
|
||||
async function getOwnerId() {
|
||||
const { data } = await supabase.auth.getUser();
|
||||
if (!data?.user?.id) throw new Error('Sessão não inicializada.');
|
||||
return data.user.id;
|
||||
}
|
||||
async function getActiveTenantId(uid) {
|
||||
const { data } = await supabase
|
||||
.from('tenant_members').select('tenant_id')
|
||||
.eq('user_id', uid).eq('status', 'active')
|
||||
.order('created_at', { ascending: false }).limit(1).single();
|
||||
if (!data?.tenant_id) throw new Error('Tenant não encontrado.');
|
||||
return data.tenant_id;
|
||||
}
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────
|
||||
function normalize(r) {
|
||||
return {
|
||||
...r,
|
||||
nome: r?.nome ?? r?.name ?? '',
|
||||
cor: r?.cor ?? r?.color ?? null,
|
||||
is_padrao: Boolean(r?.is_padrao ?? r?.is_native ?? false),
|
||||
pacientes_count: Number(r?.pacientes_count ?? r?.patient_count ?? 0)
|
||||
};
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const ownerId = await getOwnerId();
|
||||
const v = await supabase.from('v_tag_patient_counts').select('*').eq('owner_id', ownerId).order('nome', { ascending: true });
|
||||
if (!v.error) {
|
||||
tags.value = (v.data || []).map(normalize);
|
||||
return;
|
||||
}
|
||||
const t = await supabase.from('patient_tags').select('*').eq('owner_id', ownerId);
|
||||
if (t.error) throw t.error;
|
||||
tags.value = (t.data || []).map(normalize).sort((a, b) => a.nome.localeCompare(b.nome));
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar tags', detail: e?.message, life: 4500 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats ──────────────────────────────────────────────────
|
||||
const stats = computed(() => {
|
||||
const all = tags.value;
|
||||
const padrao = all.filter((t) => t.is_padrao).length;
|
||||
const minhas = all.filter((t) => !t.is_padrao).length;
|
||||
const emUso = all.filter((t) => t.pacientes_count > 0).length;
|
||||
const totalPac = all.reduce((s, t) => s + Number(t.pacientes_count || 0), 0);
|
||||
return [
|
||||
{ key: 'total', label: 'Total', value: all.length, cls: 'neutral' },
|
||||
{ key: 'minhas', label: 'Minhas', value: minhas, cls: minhas > 0 ? 'ok' : 'neutral' },
|
||||
{ key: 'uso', label: 'Em uso', value: emUso, cls: emUso > 0 ? 'ok' : 'neutral' },
|
||||
{ key: 'pacientes', label: 'Pacientes', value: totalPac, cls: 'neutral' }
|
||||
];
|
||||
});
|
||||
|
||||
// ── Filtro ─────────────────────────────────────────────────
|
||||
const tagsFiltradas = computed(() => {
|
||||
const q = String(busca.value || '').trim().toLowerCase();
|
||||
if (!q) return tags.value;
|
||||
return tags.value.filter((t) => String(t.nome || '').toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
// ── Dialog create/edit ─────────────────────────────────────
|
||||
const dlgOpen = ref(false);
|
||||
const dlgMode = ref('create');
|
||||
const dlgForm = ref({ id: '', nome: '', cor: '#22C55E' });
|
||||
const dlgError = ref('');
|
||||
|
||||
const PRESET_COLORS = ['6366f1', '8b5cf6', 'ec4899', 'ef4444', 'f97316', 'eab308', '22c55e', '14b8a6', '3b82f6', '06b6d4', '64748b', '292524'];
|
||||
|
||||
function abrirCriar() {
|
||||
dlgMode.value = 'create';
|
||||
dlgForm.value = { id: '', nome: '', cor: '#22C55E' };
|
||||
dlgError.value = '';
|
||||
dlgOpen.value = true;
|
||||
}
|
||||
function abrirEditar(row) {
|
||||
if (row.is_padrao) {
|
||||
toast.add({ severity: 'info', summary: 'Tag padrão', detail: 'Não dá pra editar tags do sistema.', life: 2500 });
|
||||
return;
|
||||
}
|
||||
dlgMode.value = 'edit';
|
||||
dlgForm.value = {
|
||||
id: row.id,
|
||||
nome: row.nome || '',
|
||||
cor: row.cor ? (row.cor.startsWith('#') ? row.cor : '#' + row.cor) : '#22C55E'
|
||||
};
|
||||
dlgError.value = '';
|
||||
dlgOpen.value = true;
|
||||
}
|
||||
|
||||
async function salvar() {
|
||||
const nome = String(dlgForm.value.nome || '').trim();
|
||||
if (!nome) {
|
||||
dlgError.value = 'Informe um nome.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
dlgError.value = '';
|
||||
try {
|
||||
const ownerId = await getOwnerId();
|
||||
const cor = dlgForm.value.cor.startsWith('#') ? dlgForm.value.cor : '#' + dlgForm.value.cor;
|
||||
if (dlgMode.value === 'create') {
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
const { error } = await supabase.from('patient_tags').insert({ owner_id: ownerId, tenant_id: tenantId, nome, cor });
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Tag criada', life: 2200 });
|
||||
} else {
|
||||
const { error } = await supabase.from('patient_tags').update({ nome, cor, updated_at: new Date().toISOString() }).eq('id', dlgForm.value.id).eq('owner_id', ownerId);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Tag atualizada', life: 2200 });
|
||||
}
|
||||
dlgOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
const msg = e?.message || '';
|
||||
dlgError.value = (e?.code === '23505' || /duplicate/i.test(msg)) ? 'Já existe uma tag com esse nome.' : (msg || 'Falha ao salvar.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────
|
||||
function confirmarExcluir(row) {
|
||||
if (row.is_padrao) return;
|
||||
confirm.require({
|
||||
message: `Excluir a tag "${row.nome}"? Os vínculos com pacientes também serão removidos.`,
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Excluir',
|
||||
rejectLabel: 'Cancelar',
|
||||
acceptSeverity: 'danger',
|
||||
accept: () => excluir(row)
|
||||
});
|
||||
}
|
||||
async function excluir(row) {
|
||||
saving.value = true;
|
||||
try {
|
||||
const ownerId = await getOwnerId();
|
||||
await supabase.from('patient_patient_tag').delete().eq('tag_id', row.id);
|
||||
const { error } = await supabase.from('patient_tags').delete().eq('id', row.id).eq('owner_id', ownerId);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Tag excluída', life: 2200 });
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir.', life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
_mqMobile = window.matchMedia('(max-width: 1023px)');
|
||||
isMobile.value = _mqMobile.matches;
|
||||
_mqMobile.addEventListener('change', _onMqMobileChange);
|
||||
}
|
||||
load();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="mt-mobile-drawer"
|
||||
:class="{ 'is-open': drawerOpen }"
|
||||
v-show="isMobile"
|
||||
aria-label="Estatísticas e busca"
|
||||
>
|
||||
<div id="mt-mobile-drawer-target" class="mt-mobile-drawer__scroll" />
|
||||
</aside>
|
||||
<Transition name="mt-drawer-fade">
|
||||
<div
|
||||
v-if="isMobile && drawerOpen"
|
||||
class="mt-mobile-drawer__backdrop"
|
||||
@click="fecharDrawer"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<section class="mt-page">
|
||||
<header class="mt-page__head">
|
||||
<button
|
||||
class="mt-menu-btn mt-menu-btn--mobile-only"
|
||||
v-tooltip.bottom="'Estatísticas & busca'"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
<span>Menu Tags</span>
|
||||
</button>
|
||||
<div class="mt-page__title">
|
||||
<i class="pi pi-tag text-purple-300" />
|
||||
<span>Tags</span>
|
||||
<span class="mt-page__count">{{ tagsFiltradas.length }}</span>
|
||||
</div>
|
||||
<div class="mt-page__actions">
|
||||
<button
|
||||
class="mt-act-btn mt-act-btn--primary"
|
||||
v-tooltip.bottom="'Nova tag'"
|
||||
:disabled="loading"
|
||||
@click="abrirCriar"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
<span>Nova</span>
|
||||
</button>
|
||||
<button
|
||||
class="mt-head-btn"
|
||||
v-tooltip.bottom="'Recarregar'"
|
||||
:disabled="loading"
|
||||
@click="load"
|
||||
>
|
||||
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
|
||||
</button>
|
||||
<button class="mt-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-body">
|
||||
<Teleport to="#mt-mobile-drawer-target" :disabled="!isMobile">
|
||||
<aside class="mt-side">
|
||||
<div class="mt-w">
|
||||
<div class="mt-w__head">
|
||||
<span class="mt-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
|
||||
</div>
|
||||
<div class="mt-stats">
|
||||
<template v-if="carregandoInicial">
|
||||
<div v-for="i in 4" :key="`stsk-${i}`" class="mt-stat" aria-busy="true">
|
||||
<div class="mt-stat__val melissa-skeleton melissa-skeleton--number" />
|
||||
<div class="mt-stat__lbl melissa-skeleton melissa-skeleton--text" style="width: 60%; margin-top: 6px;" />
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-for="s in stats"
|
||||
v-else
|
||||
:key="s.key"
|
||||
class="mt-stat"
|
||||
:class="`is-${s.cls}`"
|
||||
>
|
||||
<div class="mt-stat__val">{{ s.value }}</div>
|
||||
<div class="mt-stat__lbl">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-w">
|
||||
<div class="mt-w__head">
|
||||
<span class="mt-w__title"><i class="pi pi-search" /> Buscar</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="busca"
|
||||
type="text"
|
||||
placeholder="Nome da tag…"
|
||||
class="mt-search__input"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</Teleport>
|
||||
|
||||
<div class="mt-main">
|
||||
<div class="mt-list">
|
||||
<template v-if="carregandoInicial">
|
||||
<div v-for="i in 5" :key="`tsk-${i}`" class="mt-card mt-card--skeleton" aria-busy="true">
|
||||
<span class="mt-card__dot melissa-skeleton" style="border-radius: 50%;" />
|
||||
<div style="flex:1; display:flex; flex-direction:column; gap:6px;">
|
||||
<span class="melissa-skeleton melissa-skeleton--title" :style="{ width: `${50 + (i * 9) % 30}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="tagsFiltradas.length === 0" class="mt-empty">
|
||||
<i class="pi pi-tag mt-empty__icon" />
|
||||
<div class="mt-empty__title">Nenhuma tag encontrada</div>
|
||||
<div class="mt-empty__hint">
|
||||
<template v-if="busca">Ajuste a busca pra ver mais resultados.</template>
|
||||
<template v-else>Crie sua primeira tag pra organizar pacientes.</template>
|
||||
</div>
|
||||
<button class="mt-act-btn mt-act-btn--primary" @click="abrirCriar">
|
||||
<i class="pi pi-plus" /><span>Nova tag</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="t in tagsFiltradas"
|
||||
v-else
|
||||
:key="t.id"
|
||||
class="mt-card"
|
||||
:class="{ 'is-padrao': t.is_padrao }"
|
||||
@click="abrirEditar(t)"
|
||||
>
|
||||
<span class="mt-card__dot" :style="{ background: t.cor || '#64748b' }" />
|
||||
<div class="mt-card__main">
|
||||
<div class="mt-card__name-row">
|
||||
<span class="mt-card__name">{{ t.nome }}</span>
|
||||
<span v-if="t.is_padrao" class="mt-card__badge">Padrão</span>
|
||||
</div>
|
||||
<div class="mt-card__meta">
|
||||
<span><i class="pi pi-users" /> {{ t.pacientes_count }} {{ t.pacientes_count === 1 ? 'paciente' : 'pacientes' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-card__actions" @click.stop>
|
||||
<button
|
||||
v-if="!t.is_padrao"
|
||||
class="mt-card__btn"
|
||||
v-tooltip.left="'Editar'"
|
||||
@click="abrirEditar(t)"
|
||||
>
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!t.is_padrao"
|
||||
class="mt-card__btn mt-card__btn--danger"
|
||||
v-tooltip.left="'Excluir'"
|
||||
@click="confirmarExcluir(t)"
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog create/edit -->
|
||||
<Dialog
|
||||
v-model:visible="dlgOpen"
|
||||
modal
|
||||
dismissable-mask
|
||||
:style="{ width: '380px', maxWidth: '92vw' }"
|
||||
:header="dlgMode === 'create' ? 'Nova tag' : 'Editar tag'"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Nome
|
||||
<InputText v-model="dlgForm.nome" placeholder="Ex: TDAH, VIP, Convênio…" class="w-full mt-1" autofocus @keydown.enter="salvar" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Cor
|
||||
<input v-model="dlgForm.cor" type="color" class="w-full h-9 mt-1 rounded-md border border-[var(--surface-border)] bg-transparent cursor-pointer" />
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="c in PRESET_COLORS"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="w-6 h-6 rounded-full border border-white/20 transition-transform hover:scale-110"
|
||||
:style="{ background: '#' + c }"
|
||||
@click="dlgForm.cor = '#' + c"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="dlgError" class="text-xs text-red-400">{{ dlgError }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="dlgOpen = false" />
|
||||
<Button :label="saving ? 'Salvando…' : 'Salvar'" :loading="saving" :disabled="!dlgForm.nome.trim() || saving" @click="salvar" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mt-page {
|
||||
position: absolute;
|
||||
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 6px;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(32px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(32px) saturate(160%);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
color: var(--m-text);
|
||||
animation: mt-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
@keyframes mt-page-enter {
|
||||
from { opacity: 0; transform: scale(0.985); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.mt-page__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.mt-page__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.mt-page__title > span:not(.mt-page__count) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mt-page__count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-accent);
|
||||
background: var(--m-accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.mt-page__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mt-close,
|
||||
.mt-head-btn {
|
||||
width: 32px; height: 32px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.mt-close:hover, .mt-head-btn:hover { background: var(--m-bg-soft-hover); }
|
||||
.mt-head-btn > i { font-size: 0.85rem; }
|
||||
|
||||
.mt-act-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
background: var(--m-accent);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
}
|
||||
.mt-act-btn:hover {
|
||||
background: color-mix(in srgb, var(--m-accent) 88%, white);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.mt-act-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
|
||||
.mt-menu-btn {
|
||||
display: none;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
background: var(--m-accent);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
padding: 0 11px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.mt-menu-btn:hover {
|
||||
background: color-mix(in srgb, var(--m-accent) 88%, white);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mt-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.mt-side {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mt-side::-webkit-scrollbar { width: 5px; }
|
||||
.mt-side::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mt-w {
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.mt-w__head { margin-bottom: 10px; }
|
||||
.mt-w__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mt-w__title > i { color: var(--m-text-muted); font-size: 0.78rem; }
|
||||
|
||||
.mt-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.mt-stat {
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.mt-stat__val {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.mt-stat__lbl {
|
||||
font-size: 0.65rem;
|
||||
color: var(--m-text-muted);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.mt-stat.is-ok .mt-stat__val { color: rgb(74, 222, 128); }
|
||||
|
||||
.mt-search__input {
|
||||
width: 100%;
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
padding: 8px 12px;
|
||||
border-radius: 9px;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.mt-search__input:focus {
|
||||
border-color: var(--m-border-strong);
|
||||
}
|
||||
|
||||
.mt-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mt-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 4px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.mt-list::-webkit-scrollbar { width: 5px; }
|
||||
.mt-list::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mt-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
text-align: left;
|
||||
}
|
||||
.mt-card:hover {
|
||||
background: var(--m-bg-soft-hover);
|
||||
border-color: var(--m-border-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.mt-card.is-padrao { cursor: default; }
|
||||
.mt-card.is-padrao:hover { transform: none; }
|
||||
.mt-card--skeleton { cursor: default; pointer-events: none; opacity: 0.95; }
|
||||
.mt-card--skeleton:hover { background: var(--m-bg-soft); transform: none; }
|
||||
|
||||
.mt-card__dot {
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.mt-card__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mt-card__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.mt-card__name {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mt-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.mt-card__meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--m-text-muted);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.mt-card__meta i { margin-right: 4px; }
|
||||
|
||||
.mt-card__actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mt-card__btn {
|
||||
width: 28px; height: 28px;
|
||||
display: grid; place-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text-muted);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.mt-card__btn:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
|
||||
.mt-card__btn--danger:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
.mt-card__btn > i { font-size: 0.7rem; }
|
||||
|
||||
.mt-empty {
|
||||
margin: 24px 0;
|
||||
padding: 56px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--m-text-muted);
|
||||
border: 2px dashed var(--m-border-strong);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--m-bg-soft) 40%, transparent);
|
||||
gap: 8px;
|
||||
}
|
||||
.mt-empty__icon {
|
||||
font-size: 2rem;
|
||||
color: var(--m-text-faint);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mt-empty__title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-text);
|
||||
}
|
||||
.mt-empty__hint {
|
||||
font-size: 0.78rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Drawer mobile */
|
||||
.mt-mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: min(360px, 88vw);
|
||||
z-index: 80;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border-right: 1px solid var(--m-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--m-text);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.mt-mobile-drawer.is-open { transform: translateX(0); }
|
||||
.mt-mobile-drawer__scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 12px 12px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.mt-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
|
||||
.mt-mobile-drawer__scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mt-mobile-drawer__scroll .mt-side {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
.mt-mobile-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 79;
|
||||
}
|
||||
.mt-drawer-fade-enter-active,
|
||||
.mt-drawer-fade-leave-active { transition: opacity 200ms ease; }
|
||||
.mt-drawer-fade-enter-from,
|
||||
.mt-drawer-fade-leave-to { opacity: 0; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.mt-body { flex-direction: column; padding: 8px; }
|
||||
.mt-main { width: 100%; }
|
||||
.mt-page__title > span:first-of-type { display: none; }
|
||||
.mt-menu-btn--mobile-only { display: inline-flex; }
|
||||
.mt-act-btn span { display: none; }
|
||||
.mt-act-btn { width: 32px; padding: 0; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
@@ -24,7 +24,7 @@
|
||||
* Os handlers exibem toasts (success/warn) — o composable assume que os
|
||||
* componentes consumidores já registraram `<Toast />` e `<ConfirmDialog />`.
|
||||
*/
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
@@ -322,11 +322,38 @@ export function useMelissaAgenda() {
|
||||
// ── Inicialização ───────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
await loadSettings();
|
||||
await loadDeterminedCommitments();
|
||||
const tid = clinicTenantId.value;
|
||||
if (tid) await loadFeriadosBase(tid);
|
||||
});
|
||||
|
||||
// Refetch settings + workRules quando o user salva jornada/ritmo/online
|
||||
// em /configuracoes/agenda (embedado no Melissa). Sem isso, a timeline
|
||||
// do resumo continuaria mostrando o range antigo até reload da página.
|
||||
function _onSettingsSaved() {
|
||||
loadSettings();
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('agenda:settings-saved', _onSettingsSaved);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('agenda:settings-saved', _onSettingsSaved);
|
||||
});
|
||||
|
||||
// Commitments + feriados dependem do tenant. Em refresh "frio", o
|
||||
// tenantStore ainda não terminou de hidratar quando o composable
|
||||
// monta, e clinicTenantId fica null. loadDeterminedCommitments faz
|
||||
// bail-out silencioso quando tenantId é vazio (rows = [], sem retry)
|
||||
// — daí o "às vezes" do bug onde commitmentOptions chegava vazio no
|
||||
// AgendaEventDialog. Watch com immediate: true dispara já se o tenant
|
||||
// estiver pronto, ou no momento exato em que ele aparecer.
|
||||
watch(
|
||||
clinicTenantId,
|
||||
async (tid) => {
|
||||
if (!tid) return;
|
||||
await loadDeterminedCommitments();
|
||||
await loadFeriadosBase(tid);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Reload quando view muda OU quando settings/ownerId aparece
|
||||
watch([viewStart, viewEnd], _reloadRange);
|
||||
watch(ownerId, (v) => {
|
||||
|
||||
@@ -63,7 +63,9 @@ function normalizeEvent(r) {
|
||||
fim_em: r.fim_em,
|
||||
startH: isoToDecimalHour(r.inicio_em),
|
||||
endH: isoToDecimalHour(r.fim_em),
|
||||
dateKey: String(r.inicio_em || '').slice(0, 10)
|
||||
dateKey: String(r.inicio_em || '').slice(0, 10),
|
||||
price: r.price != null ? Number(r.price) : 0,
|
||||
billed: !!r.billed
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,7 +83,7 @@ async function _fetchRange(start, end) {
|
||||
|
||||
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)')
|
||||
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||
.eq('owner_id', userId)
|
||||
.is('mirror_of_event_id', null)
|
||||
.gte('inicio_em', start.toISOString())
|
||||
@@ -185,6 +187,137 @@ export function useMelissaEventosRange(startRef, endRef) {
|
||||
return { eventos, loading, error, refetch: fetch };
|
||||
}
|
||||
|
||||
// ── Busca server-side: por nome de paciente ou título do evento ──
|
||||
// Usado pela busca da toolbar do MelissaAgenda. Procura sem range temporal
|
||||
// (acha sessões fora do que está visível no FC). Limite de 20 resultados,
|
||||
// ordenados por inicio_em DESC (mais recente primeiro).
|
||||
//
|
||||
// Acento-insensitive: troca cada vogal/c do termo por um character class
|
||||
// que casa com todas as variantes ("andre" vira "[aáàâã]ndr[eéèê]") e usa
|
||||
// `imatch` (operador POSIX `~*` do Postgres — case-insensitive regex).
|
||||
// Estratégia evita depender da extensão `unaccent` no DB.
|
||||
function _buildAccentInsensitivePattern(term) {
|
||||
// Escapa regex specials primeiro pra não quebrar o pattern.
|
||||
const escaped = String(term || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const map = {
|
||||
a: '[aáàâãAÁÀÂÃ]', e: '[eéèêEÉÈÊ]', i: '[iíìîIÍÌÎ]',
|
||||
o: '[oóòôõOÓÒÔÕ]', u: '[uúùûUÚÙÛ]', c: '[cçCÇ]',
|
||||
A: '[aáàâãAÁÀÂÃ]', E: '[eéèêEÉÈÊ]', I: '[iíìîIÍÌÎ]',
|
||||
O: '[oóòôõOÓÒÔÕ]', U: '[uúùûUÚÙÛ]', C: '[cçCÇ]'
|
||||
};
|
||||
return escaped.split('').map((ch) => map[ch] || ch).join('');
|
||||
}
|
||||
|
||||
export async function searchEventosByText(termo) {
|
||||
const term = String(termo || '').trim();
|
||||
if (term.length < 2) return [];
|
||||
|
||||
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 SELECT = 'id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)';
|
||||
const pattern = _buildAccentInsensitivePattern(term);
|
||||
|
||||
try {
|
||||
const [byPatient, byTitle] = await Promise.all([
|
||||
supabase
|
||||
.from('agenda_eventos')
|
||||
.select(SELECT.replace('patients!agenda_eventos_patient_id_fkey', 'patients!inner!agenda_eventos_patient_id_fkey'))
|
||||
.eq('owner_id', userId)
|
||||
.is('mirror_of_event_id', null)
|
||||
.filter('patients.nome_completo', 'imatch', pattern)
|
||||
.order('inicio_em', { ascending: false })
|
||||
.limit(20),
|
||||
supabase
|
||||
.from('agenda_eventos')
|
||||
.select(SELECT)
|
||||
.eq('owner_id', userId)
|
||||
.is('mirror_of_event_id', null)
|
||||
.filter('titulo', 'imatch', pattern)
|
||||
.order('inicio_em', { ascending: false })
|
||||
.limit(20)
|
||||
]);
|
||||
if (byPatient.error) throw byPatient.error;
|
||||
if (byTitle.error) throw byTitle.error;
|
||||
|
||||
const merged = [...(byPatient.data || []), ...(byTitle.data || [])];
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
for (const r of merged) {
|
||||
if (seen.has(r.id)) continue;
|
||||
seen.add(r.id);
|
||||
unique.push(r);
|
||||
}
|
||||
unique.sort((a, b) => String(b.inicio_em).localeCompare(String(a.inicio_em)));
|
||||
return unique.slice(0, 20).map(normalizeEvent);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[searchEventosByText]', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── COMPOSABLE 4: todas as sessões de um paciente (sem range) ──
|
||||
// Usado pelo banner "Ver todas" da MelissaAgenda quando o usuário
|
||||
// quer escapar do range visível e ver o histórico completo do
|
||||
// paciente selecionado. Diferente do useMelissaEventosRange, aqui
|
||||
// filtramos por patient_id e ignoramos qualquer range temporal.
|
||||
//
|
||||
// Retorna eventos ordenados por inicio_em DESC (mais recente primeiro)
|
||||
// — coerente com listas de "histórico" no resto do sistema.
|
||||
export function useMelissaTodasSessoesPaciente() {
|
||||
const eventos = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
async function fetch(patientId) {
|
||||
if (!patientId) { eventos.value = []; return; }
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
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) { eventos.value = []; return; }
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||
.eq('owner_id', userId)
|
||||
.eq('patient_id', patientId)
|
||||
.is('mirror_of_event_id', null)
|
||||
.order('inicio_em', { ascending: false });
|
||||
|
||||
if (err) throw err;
|
||||
eventos.value = (data || []).map(normalizeEvent);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar sessões';
|
||||
eventos.value = [];
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[useMelissaTodasSessoesPaciente]', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
eventos.value = [];
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
return { eventos, loading, error, fetch, reset };
|
||||
}
|
||||
|
||||
// ── COMPOSABLE 2: apenas hoje (MelissaLayout) ──────────────────
|
||||
export function useMelissaEventosHoje() {
|
||||
const eventos = ref([]);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* useMelissaPacientesAside — paginação async DEDICADA pra coluna direita
|
||||
* da MelissaAgenda (lista de "Pacientes" lá no aside).
|
||||
* --------------------------------------------------
|
||||
* Por que existe (em vez de reaproveitar `useMelissaPacientes`):
|
||||
* - useMelissaPacientes carrega TUDO num array só (até 1000) porque outras
|
||||
* partes do sistema dependem da lista completa em memória (lookup por ID
|
||||
* em eventos da agenda, página MelissaPacientes, cards de resumo).
|
||||
* - A coluna do aside só precisa renderizar 6 por vez. Pra clínicas com
|
||||
* milhares de pacientes, faz sentido essa coluna ir ao banco a cada
|
||||
* página/busca em vez de paginar client-side em cima de um array gigante.
|
||||
*
|
||||
* Compromisso: a ordenação é alfabética (server-side por nome_completo). O
|
||||
* destaque visual de "novo paciente" continua funcionando (compara created_at
|
||||
* < 7 dias no consumer), mas pacientes novos NÃO são mais empurrados pro
|
||||
* topo da lista — eles aparecem na ordem alfabética normal. Pra ver os mais
|
||||
* recentes, usuário precisa filtrar/buscar pelo nome.
|
||||
*
|
||||
* Sanitização (regra do projeto):
|
||||
* - busca trimada e capada em 100 chars
|
||||
* - wildcards LIKE (%, _) são escapados antes de irem pro .ilike()
|
||||
*
|
||||
* Race-safety:
|
||||
* - sequence number (_seq) ignora respostas tardias de queries antigas
|
||||
* quando o usuário troca de página/digita rápido.
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const SEARCH_MAX_LEN = 100;
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Escapa wildcards do LIKE/ILIKE pra evitar que o usuário injete
|
||||
// padrões de busca não intencionais ao digitar % ou _.
|
||||
function escapeLike(s) {
|
||||
return String(s).replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {import('vue').Ref<number>} opts.pagina ref 1-based
|
||||
* @param {import('vue').Ref<string>} opts.busca ref de string (texto livre)
|
||||
* @param {number} [opts.porPagina=6] tamanho da página
|
||||
*/
|
||||
export function useMelissaPacientesAside(opts) {
|
||||
const { pagina, busca, porPagina = 6 } = opts;
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const pacientes = ref([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const _uid = ref(null);
|
||||
const _seq = ref(0);
|
||||
let _debounceTimer = 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 _fetch() {
|
||||
const seq = ++_seq.value;
|
||||
const userId = await _ensureUid();
|
||||
if (typeof tenantStore.ensureLoaded === 'function') {
|
||||
await tenantStore.ensureLoaded();
|
||||
}
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
|
||||
|
||||
if (!userId || !tid) {
|
||||
if (seq !== _seq.value) return;
|
||||
pacientes.value = [];
|
||||
total.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitização da busca: trim + cap + escape de wildcards LIKE.
|
||||
const rawQ = String(busca.value || '').trim().slice(0, SEARCH_MAX_LEN);
|
||||
const hasQ = rawQ.length > 0;
|
||||
|
||||
const start = Math.max(0, (pagina.value - 1) * porPagina);
|
||||
const end = start + porPagina - 1;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
let q = supabase
|
||||
.from('patients')
|
||||
.select(
|
||||
'id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at, data_nascimento',
|
||||
{ count: 'exact' }
|
||||
)
|
||||
.eq('owner_id', userId)
|
||||
.eq('tenant_id', tid)
|
||||
// Mesmo critério do useMelissaPacientes original (onlyActive=true).
|
||||
// DB tem valores variados ('ativo'/'Ativo'/'active'); aceita os 3.
|
||||
.in('status', ['ativo', 'Ativo', 'active'])
|
||||
.order('nome_completo', { ascending: true })
|
||||
.range(start, end);
|
||||
|
||||
if (hasQ) {
|
||||
q = q.ilike('nome_completo', `%${escapeLike(rawQ)}%`);
|
||||
}
|
||||
|
||||
const { data, error: err, count } = await q;
|
||||
if (err) throw err;
|
||||
|
||||
// Race-guard: outra chamada disparou enquanto esperávamos a resposta.
|
||||
if (seq !== _seq.value) return;
|
||||
|
||||
pacientes.value = (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
|
||||
}));
|
||||
total.value = count ?? 0;
|
||||
} catch (e) {
|
||||
if (seq !== _seq.value) return;
|
||||
error.value = e?.message || 'Erro ao carregar pacientes';
|
||||
pacientes.value = [];
|
||||
total.value = 0;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[useMelissaPacientesAside]', e);
|
||||
} finally {
|
||||
if (seq === _seq.value) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _scheduleFetch({ debounce }) {
|
||||
if (_debounceTimer) {
|
||||
clearTimeout(_debounceTimer);
|
||||
_debounceTimer = null;
|
||||
}
|
||||
if (debounce) {
|
||||
_debounceTimer = setTimeout(() => {
|
||||
_debounceTimer = null;
|
||||
_fetch();
|
||||
}, DEBOUNCE_MS);
|
||||
} else {
|
||||
_fetch();
|
||||
}
|
||||
}
|
||||
|
||||
// Página muda → fetch imediato (clique no paginator é deliberado).
|
||||
watch(pagina, () => _scheduleFetch({ debounce: false }), { immediate: true });
|
||||
// Busca muda → debounce (usuário digitando).
|
||||
watch(busca, () => {
|
||||
// Reset implícito: ao buscar, qualquer página > 1 deve voltar pra 1.
|
||||
// Como `pagina` é refs do consumer, não mexemos aqui — o consumer faz isso.
|
||||
_scheduleFetch({ debounce: true });
|
||||
});
|
||||
|
||||
const totalPaginas = computed(() => Math.max(1, Math.ceil(total.value / porPagina)));
|
||||
|
||||
return {
|
||||
pacientes,
|
||||
total,
|
||||
totalPaginas,
|
||||
loading,
|
||||
error,
|
||||
refetch: () => _scheduleFetch({ debounce: false })
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* useMelissaWhatsapp — agregado leve pro card "WhatsApp" do resumo Melissa.
|
||||
*
|
||||
* Lê da view `conversation_threads` (mesma fonte do drawer/kanban):
|
||||
* - count = soma de unread_count em threads WhatsApp não-lidas
|
||||
* - top1 = thread mais recente com mensagens não-lidas (preview)
|
||||
*
|
||||
* Filtra channel='whatsapp' pra coerência com o título do card. Inclui
|
||||
* só threads com unread_count > 0 (limit 50 — payload pequeno e cobre
|
||||
* praticamente qualquer clínica; se passar disso, o count fica ligeiramente
|
||||
* subestimado mas o card já cumpre o papel de "alerta visual").
|
||||
*
|
||||
* Sem realtime no MVP — refetch manual via `refetch()`. Quando quiser
|
||||
* atualização instantânea, plugar a subscription do `useConversations`
|
||||
* (channel `conv_msg_tenant_<tid>` em conversation_messages INSERT).
|
||||
*/
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const EMPTY = { count: 0, ultimaMsg: '', ultimoNome: '', ultimaEm: null };
|
||||
|
||||
export function useMelissaWhatsapp() {
|
||||
const summary = ref({ ...EMPTY });
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
async function fetch() {
|
||||
const tenantStore = useTenantStore();
|
||||
if (typeof tenantStore.ensureLoaded === 'function') {
|
||||
await tenantStore.ensureLoaded();
|
||||
}
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
|
||||
if (!tid) { summary.value = { ...EMPTY }; return; }
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('conversation_threads')
|
||||
.select('patient_name, contact_number, unread_count, last_message_body, last_message_at, last_message_direction')
|
||||
.eq('tenant_id', tid)
|
||||
.eq('channel', 'whatsapp')
|
||||
.gt('unread_count', 0)
|
||||
.order('last_message_at', { ascending: false })
|
||||
.limit(50);
|
||||
if (err) throw err;
|
||||
|
||||
const rows = data || [];
|
||||
const totalUnread = rows.reduce((s, t) => s + Number(t.unread_count || 0), 0);
|
||||
const top = rows[0] || null;
|
||||
summary.value = {
|
||||
count: totalUnread,
|
||||
ultimaMsg: String(top?.last_message_body || '').trim(),
|
||||
ultimoNome: String(top?.patient_name || top?.contact_number || '').trim() || '—',
|
||||
ultimaEm: top?.last_message_at || null
|
||||
};
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[useMelissaWhatsapp]', e);
|
||||
error.value = e?.message || 'Erro ao carregar WhatsApp';
|
||||
summary.value = { ...EMPTY };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetch);
|
||||
|
||||
return { summary, loading, error, refetch: fetch };
|
||||
}
|
||||
@@ -44,11 +44,14 @@ export default {
|
||||
// ======================================================
|
||||
// 💬 CONVERSAS (CRM de WhatsApp)
|
||||
// ======================================================
|
||||
// Redirect pro layout Melissa (versão oficial). RLS do Postgres
|
||||
// garante que o admin vê dados da clínica e o terapeuta vê só os
|
||||
// dele — independente da rota de entrada. A `CRMConversasPage`
|
||||
// antiga continua no repo (sem rota apontando) até validarmos.
|
||||
{
|
||||
path: 'conversas',
|
||||
name: 'admin-conversas',
|
||||
component: () => import('@/features/conversations/CRMConversasPage.vue'),
|
||||
meta: { roles: ['clinic_admin', 'tenant_admin'] }
|
||||
redirect: { name: 'Melissa', params: { secao: 'conversas' } }
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
|
||||
@@ -41,10 +41,13 @@ export default {
|
||||
// ======================================================
|
||||
// 💬 CONVERSAS (CRM de WhatsApp)
|
||||
// ======================================================
|
||||
// Redirect pro layout Melissa (versão oficial). A `CRMConversasPage`
|
||||
// antiga continua no repo (sem rota apontando) até validarmos a versão
|
||||
// Melissa por algumas semanas; depois pode ser removida.
|
||||
{
|
||||
path: 'conversas',
|
||||
name: 'therapist-conversas',
|
||||
component: () => import('@/features/conversations/CRMConversasPage.vue')
|
||||
redirect: { name: 'Melissa', params: { secao: 'conversas' } }
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
|
||||
@@ -20,15 +20,18 @@ import { useToast } from 'primevue/usetoast';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useLayout as _useLayout } from '@/layout/composables/layout';
|
||||
import { applyThemeEngine } from '@/theme/theme.options';
|
||||
const { setVariant } = _useLayout();
|
||||
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import InputMask from 'primevue/inputmask';
|
||||
import Select from 'primevue/select';
|
||||
import Textarea from 'primevue/textarea';
|
||||
|
||||
// `useLayout` precisa ser importado UMA vez. Antes havia 2 imports (e
|
||||
// duas chamadas: `_useLayout()` no topo + `useLayout()` mais abaixo) —
|
||||
// como o composable usa state singleton no escopo do módulo, essas duas
|
||||
// chamadas retornam o mesmo `layoutConfig`, mas o duplo import deixava
|
||||
// a leitura confusa e mascarava regressões. Unificado aqui.
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
@@ -75,6 +78,12 @@ const passwordSent = ref(false);
|
||||
|
||||
const userEmail = ref('');
|
||||
const userId = ref('');
|
||||
// `profiles.role` do usuário logado. Usado pra desabilitar o card "Melissa"
|
||||
// quando o role é `saas_admin` (Melissa não foi pensado pro shell SaaS — não
|
||||
// tem gestão de tenants nem dashboards globais). Os outros roles seguem o
|
||||
// fluxo normal.
|
||||
const userRole = ref(null);
|
||||
const isSaasAdmin = computed(() => userRole.value === 'saas_admin');
|
||||
|
||||
const fileInput = ref(null);
|
||||
|
||||
@@ -159,6 +168,76 @@ function markDirty() {
|
||||
dirty.value = true;
|
||||
}
|
||||
|
||||
// ── Trocar pra Melissa exige reload (o AppLayout não tem branch pra
|
||||
// `melissa` — quem renderiza o layout Melissa é uma rota separada
|
||||
// (`/melissa`). Sem reload, o user fica visualmente em clássico mesmo
|
||||
// tendo escolhido Melissa). Por isso confirma com o usuário antes,
|
||||
// persiste imediato no DB pra não depender do botão "Salvar alterações"
|
||||
// e redireciona pra /melissa.
|
||||
//
|
||||
// `melissaConfirmOpen` é um guard contra duplo-clique: o `:disabled` no
|
||||
// botão depende de `layoutConfig.variant === 'melissa'`, mas isso só vira
|
||||
// true depois do user aceitar o confirm. Entre o 1º clique e o accept,
|
||||
// um segundo clique abriria outro confirm sobreposto. O guard fecha
|
||||
// essa janela.
|
||||
const melissaConfirmOpen = ref(false);
|
||||
async function selectMelissa() {
|
||||
if (isSaasAdmin.value) return; // defesa em profundidade — botão também é :disabled
|
||||
if (layoutConfig.variant === 'melissa') return; // já é o atual
|
||||
if (melissaConfirmOpen.value) return; // confirm já está aberto
|
||||
|
||||
melissaConfirmOpen.value = true;
|
||||
confirm.require({
|
||||
header: 'Trocar para o layout Melissa',
|
||||
message: 'A página será recarregada para aplicar o novo layout. Confirma?',
|
||||
icon: 'pi pi-th-large',
|
||||
acceptLabel: 'Trocar e recarregar',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
try {
|
||||
setVariant('melissa');
|
||||
// Persiste só o layout_variant — não chama saveAll porque o
|
||||
// resto do form pode estar dirty/inválido e não queremos
|
||||
// segurar a troca de layout por causa disso.
|
||||
if (userId.value) {
|
||||
const { error } = await supabase
|
||||
.from('user_settings')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId.value,
|
||||
layout_variant: 'melissa',
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{ onConflict: 'user_id' }
|
||||
);
|
||||
if (error) {
|
||||
// Tolerante a relation/RLS errors — localStorage já tem
|
||||
// o valor, então o redirect home → Melissa funciona pra
|
||||
// esta sessão mesmo se o DB falhar.
|
||||
const msg = String(error.message || '');
|
||||
const tolerant = /does not exist/i.test(msg) || /permission denied/i.test(msg) || /violates row-level security/i.test(msg);
|
||||
if (!tolerant) throw error;
|
||||
}
|
||||
}
|
||||
toast.add({ severity: 'info', summary: 'Aplicando Melissa', detail: 'Recarregando…', life: 1500 });
|
||||
// Hard reload — entra na home, beforeEach do router detecta
|
||||
// `localStorage.layout_variant === 'melissa'` e manda pra /melissa.
|
||||
window.location.assign('/');
|
||||
} catch (e) {
|
||||
melissaConfirmOpen.value = false;
|
||||
toast.add({ severity: 'error', summary: 'Erro ao aplicar Melissa', detail: e?.message || 'Tente novamente.', life: 4000 });
|
||||
}
|
||||
},
|
||||
reject: () => {
|
||||
melissaConfirmOpen.value = false;
|
||||
},
|
||||
onHide: () => {
|
||||
// Cobre fechamento via Esc / clickoutside (não dispara reject/accept).
|
||||
melissaConfirmOpen.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------
|
||||
Gamificação / Progresso
|
||||
----------------------------- */
|
||||
@@ -384,7 +463,7 @@ async function uploadAvatarIfNeeded() {
|
||||
/* ----------------------------
|
||||
Aparência (SEM duplicar engine)
|
||||
----------------------------- */
|
||||
const { layoutConfig, layoutState, toggleDarkMode, changeMenuMode } = useLayout();
|
||||
const { layoutConfig, layoutState, setVariant, toggleDarkMode, changeMenuMode } = useLayout();
|
||||
|
||||
function isDarkNow() {
|
||||
return document.documentElement.classList.contains('app-dark');
|
||||
@@ -532,12 +611,13 @@ async function loadProfile() {
|
||||
const { data: prof, error: pErr } = await supabase
|
||||
.from('profiles')
|
||||
.select(
|
||||
'full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news'
|
||||
'role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news'
|
||||
)
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!pErr && prof) {
|
||||
userRole.value = prof.role || null;
|
||||
form.full_name = prof.full_name ?? form.full_name;
|
||||
form.avatar_url = prof.avatar_url ?? form.avatar_url;
|
||||
form.phone = prof.phone ?? '';
|
||||
@@ -671,6 +751,20 @@ async function saveAll() {
|
||||
clearAvatarFile();
|
||||
dirty.value = false;
|
||||
layoutState._variantDirty = false;
|
||||
|
||||
// Se trocou de Melissa pra outro layout estando dentro de /melissa,
|
||||
// o MelissaLayout (rota fullscreen, fora do AppLayout) continua
|
||||
// montado mesmo com o flag novo no localStorage/DB. Hard reload pra
|
||||
// home: o beforeEach do router lê o flag e cai no layout correto.
|
||||
// Caso inverso (qualquer → melissa) já é tratado no selectMelissa.
|
||||
const variantAgora = layoutConfig.variant;
|
||||
const naMelissa = router.currentRoute.value.path.startsWith('/melissa');
|
||||
if (naMelissa && variantAgora !== 'melissa') {
|
||||
toast.add({ severity: 'info', summary: 'Aplicando layout', detail: 'Recarregando…', life: 1500 });
|
||||
setTimeout(() => window.location.assign('/'), 700);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Seu perfil foi atualizado.', life: 2500 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e?.message || 'Não consegui salvar.', life: 6000 });
|
||||
@@ -1368,9 +1462,18 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Layout 1: Clássico -->
|
||||
<!-- Desabilitado quando já é o variant ativo: evita re-clicar
|
||||
no layout em uso (que dispararia setVariant inutilmente
|
||||
e — no caso do rail — causava remount do menu sumindo
|
||||
os itens visualmente). -->
|
||||
<button
|
||||
class="lv-card"
|
||||
:class="{ 'lv-card--active': layoutConfig.variant === 'classic' }"
|
||||
:class="{
|
||||
'lv-card--active': layoutConfig.variant === 'classic',
|
||||
'lv-card--current': layoutConfig.variant === 'classic'
|
||||
}"
|
||||
:disabled="layoutConfig.variant === 'classic'"
|
||||
v-tooltip.top="layoutConfig.variant === 'classic' ? 'Layout atual' : null"
|
||||
@click="
|
||||
setVariant('classic');
|
||||
markDirty();
|
||||
@@ -1398,7 +1501,12 @@ onBeforeUnmount(() => {
|
||||
<!-- Layout 2: Rail -->
|
||||
<button
|
||||
class="lv-card"
|
||||
:class="{ 'lv-card--active': layoutConfig.variant === 'rail' }"
|
||||
:class="{
|
||||
'lv-card--active': layoutConfig.variant === 'rail',
|
||||
'lv-card--current': layoutConfig.variant === 'rail'
|
||||
}"
|
||||
:disabled="layoutConfig.variant === 'rail'"
|
||||
v-tooltip.top="layoutConfig.variant === 'rail' ? 'Layout atual' : null"
|
||||
@click="
|
||||
setVariant('rail');
|
||||
markDirty();
|
||||
@@ -1425,13 +1533,23 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
|
||||
<!-- Layout 3: Melissa (Direção B) -->
|
||||
<!-- Desabilitado em 2 cenários:
|
||||
- SaaS admin (Melissa não foi pensado pro shell SaaS)
|
||||
- já é o variant ativo (evita re-disparar o confirm
|
||||
dialog múltiplas vezes — bug onde clicar de novo
|
||||
abria 2 confirms sobrepostos) -->
|
||||
<button
|
||||
class="lv-card"
|
||||
:class="{ 'lv-card--active': layoutConfig.variant === 'melissa' }"
|
||||
@click="
|
||||
setVariant('melissa');
|
||||
markDirty();
|
||||
"
|
||||
:class="{
|
||||
'lv-card--active': layoutConfig.variant === 'melissa',
|
||||
'lv-card--current': layoutConfig.variant === 'melissa',
|
||||
'lv-card--disabled': isSaasAdmin
|
||||
}"
|
||||
:disabled="isSaasAdmin || layoutConfig.variant === 'melissa'"
|
||||
v-tooltip.top="isSaasAdmin
|
||||
? 'Não disponível para o perfil SaaS'
|
||||
: (layoutConfig.variant === 'melissa' ? 'Layout atual' : null)"
|
||||
@click="selectMelissa"
|
||||
>
|
||||
<span class="lv-card__badge lv-card__badge--beta">Beta</span>
|
||||
<div class="lv-card__preview lv-card__preview--melissa">
|
||||
@@ -1649,6 +1767,52 @@ onBeforeUnmount(() => {
|
||||
.lv-card--active .lv-card__radio {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
/* Garante que o <button disabled> não responda a hover/click — alguns
|
||||
browsers ainda processam pointer events em <button disabled> com CSS
|
||||
conflitante. Forçar `pointer-events: none` fecha qualquer brecha. */
|
||||
.lv-card:disabled,
|
||||
.lv-card[disabled] {
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* Disabled state — usado pra Melissa quando role=saas_admin. Cinza forte. */
|
||||
.lv-card--disabled {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(0.4);
|
||||
}
|
||||
.lv-card--disabled:hover {
|
||||
border-color: var(--surface-border);
|
||||
}
|
||||
/* Current state — usado quando o variant já é o ativo. Mantém o border
|
||||
colorido do --active (a identidade de "ativo" continua), mas escurece
|
||||
sutilmente e mostra um badge "Atual" pra deixar inequívoco que esse é
|
||||
o layout em uso e não responde a clique.
|
||||
`pointer-events: none` reforça o :disabled do <button>. */
|
||||
.lv-card--current {
|
||||
opacity: 0.72;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.lv-card--current:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
}
|
||||
.lv-card--current::before {
|
||||
content: 'Atual';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 14%, var(--surface-card));
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 35%, transparent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.lv-card__preview {
|
||||
height: 90px;
|
||||
display: flex;
|
||||
|
||||
@@ -1109,9 +1109,9 @@ onMounted(() => {
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
|
||||
}"
|
||||
@@ -1203,9 +1203,9 @@ onMounted(() => {
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
|
||||
}"
|
||||
@@ -1267,9 +1267,9 @@ onMounted(() => {
|
||||
class="dc-dialog w-[32rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' }
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
|
||||
@@ -425,9 +425,9 @@ onMounted(load);
|
||||
class="dc-dialog w-[50rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
|
||||
}"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
@@ -27,6 +27,10 @@ import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vu
|
||||
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
|
||||
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue';
|
||||
import FirstResponseCard from '@/components/dashboard/FirstResponseCard.vue';
|
||||
// Timeline com paridade ao Melissa: respeita jornada (agenda_regras_semanais),
|
||||
// folga/feriado, scroll horizontal com min-slot + auto-scroll, eco lateral.
|
||||
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
|
||||
import { useFeriados } from '@/composables/useFeriados';
|
||||
|
||||
const dashHeroSentinelRef = ref(null);
|
||||
const heroStuck = ref(false);
|
||||
@@ -644,15 +648,47 @@ const commitments = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const hoursRange = [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
|
||||
const TL_START = 7,
|
||||
TL_END = 20,
|
||||
TL_SPAN = TL_END - TL_START;
|
||||
function toPercent(h, m) {
|
||||
return ((h + m / 60 - TL_START) / TL_SPAN) * 100;
|
||||
// ── Timeline: range derivado de agenda_regras_semanais (regra do dia
|
||||
// atual). Fallback: agenda_configuracoes global → 07–20h. Range expande
|
||||
// pra incluir eventos fora do expediente. Espelha o Melissa.
|
||||
const { settings: agendaSettings, workRules: agendaWorkRules, load: loadAgendaSettings } = useAgendaSettings();
|
||||
const { todos: feriadosList, load: loadFeriadosBase } = useFeriados();
|
||||
onMounted(() => {
|
||||
loadAgendaSettings();
|
||||
});
|
||||
|
||||
function _timeStrToHour(s, fb) {
|
||||
const str = String(s || '').slice(0, 5);
|
||||
const [h, m] = str.split(':').map(Number);
|
||||
if (Number.isFinite(h) && Number.isFinite(m)) return h + m / 60;
|
||||
return fb;
|
||||
}
|
||||
const todayRules = computed(() => {
|
||||
const dow = new Date().getDay();
|
||||
return (agendaWorkRules.value || []).filter((r) => r.dia_semana === dow && r.ativo !== false);
|
||||
});
|
||||
const isFolga = computed(() => todayRules.value.length === 0);
|
||||
const todayFeriado = computed(() => {
|
||||
const d = new Date();
|
||||
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
return (feriadosList.value || []).find((f) => f.data === k) || null;
|
||||
});
|
||||
function _baseTimelineRange() {
|
||||
const rules = todayRules.value;
|
||||
if (rules.length > 0) {
|
||||
const starts = rules.map((r) => _timeStrToHour(r.hora_inicio, 7));
|
||||
const ends = rules.map((r) => _timeStrToHour(r.hora_fim, 20));
|
||||
return { start: Math.min(...starts), end: Math.max(...ends) };
|
||||
}
|
||||
const s = agendaSettings.value;
|
||||
const fbStart = (s?.usar_horario_admin_custom && s?.admin_inicio_visualizacao) || s?.agenda_custom_start || '07:00';
|
||||
const fbEnd = (s?.usar_horario_admin_custom && s?.admin_fim_visualizacao) || s?.agenda_custom_end || '20:00';
|
||||
return { start: _timeStrToHour(fbStart, 7), end: _timeStrToHour(fbEnd, 20) };
|
||||
}
|
||||
|
||||
const timelineEvents = computed(() =>
|
||||
// `timelineEventsRaw` extrai a info dos eventos SEM depender de TL_START/TL_END
|
||||
// (pra evitar dep circular: TL_START depende dos eventos pra expandir o range).
|
||||
const timelineEventsRaw = computed(() =>
|
||||
_statsDoMes.value.timelineLista
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))
|
||||
@@ -660,25 +696,178 @@ const timelineEvents = computed(() =>
|
||||
const item = buildEventoItem(ev);
|
||||
const [hh, mm] = item.hora.split(':').map(Number);
|
||||
const durMin = parseInt(item.dur) || 50;
|
||||
const startH = hh + mm / 60;
|
||||
const endH = startH + durMin / 60;
|
||||
return {
|
||||
id: item.id,
|
||||
inicio_em: ev.inicio_em,
|
||||
label: item.nome.split(' ')[0],
|
||||
tipo: item.tipo,
|
||||
status: item.status,
|
||||
modalidade: item.modalidade,
|
||||
tooltip: `${item.hora} · ${item.nome} · ${item.modalidade}`,
|
||||
badge: item.modalidade?.toLowerCase() === 'online' ? '📱' : '',
|
||||
bgColor: item.bgColor,
|
||||
txtColor: item.txtColor,
|
||||
style: { left: toPercent(hh, mm) + '%', width: Math.max((durMin / 60 / TL_SPAN) * 100, 4) + '%' }
|
||||
durMin,
|
||||
startH,
|
||||
endH
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const TL_START = computed(() => {
|
||||
const { start } = _baseTimelineRange();
|
||||
const evs = timelineEventsRaw.value || [];
|
||||
const minEv = evs.length ? Math.min(...evs.map((e) => e.startH)) : Infinity;
|
||||
return Math.max(0, Math.floor(Math.min(start, minEv)));
|
||||
});
|
||||
const TL_END = computed(() => {
|
||||
const { end } = _baseTimelineRange();
|
||||
const evs = timelineEventsRaw.value || [];
|
||||
const maxEv = evs.length ? Math.max(...evs.map((e) => e.endH)) : -Infinity;
|
||||
return Math.min(24, Math.ceil(Math.max(end, maxEv)));
|
||||
});
|
||||
const hoursRange = computed(() => {
|
||||
const arr = [];
|
||||
for (let h = TL_START.value; h <= TL_END.value; h++) arr.push(h);
|
||||
return arr;
|
||||
});
|
||||
function toPercent(h, m) {
|
||||
const span = TL_END.value - TL_START.value;
|
||||
if (span <= 0) return 0;
|
||||
return ((h + m / 60 - TL_START.value) / span) * 100;
|
||||
}
|
||||
|
||||
// `timelineEvents` adiciona positioning (left/width %) baseado em TL_START/END.
|
||||
const timelineEvents = computed(() =>
|
||||
timelineEventsRaw.value.map((ev) => {
|
||||
const span = TL_END.value - TL_START.value;
|
||||
const left = span > 0 ? ((ev.startH - TL_START.value) / span) * 100 : 0;
|
||||
const width = span > 0 ? Math.max(((ev.endH - ev.startH) / span) * 100, 4) : 4;
|
||||
return { ...ev, style: { left: `${left}%`, width: `${width}%` } };
|
||||
})
|
||||
);
|
||||
|
||||
const nowCursorLeft = computed(() => {
|
||||
const pct = toPercent(agora.value.getHours(), agora.value.getMinutes());
|
||||
return Math.min(Math.max(pct, 0), 100) + '%';
|
||||
});
|
||||
|
||||
// ── Scroll horizontal + eco lateral (paridade Melissa) ──────────
|
||||
// scroll state, eco state, auto-scroll to-now, scrollToEvent.
|
||||
const tlHScrollEl = ref(null);
|
||||
const tlScrollState = ref({ scrollL: 0, viewW: 0, innerW: 0 });
|
||||
function _updateTlScrollState() {
|
||||
const el = tlHScrollEl.value;
|
||||
if (!el) {
|
||||
tlScrollState.value = { scrollL: 0, viewW: 0, innerW: 0 };
|
||||
return;
|
||||
}
|
||||
const inner = el.firstElementChild;
|
||||
tlScrollState.value = {
|
||||
scrollL: el.scrollLeft,
|
||||
viewW: el.clientWidth,
|
||||
innerW: inner ? (inner.scrollWidth || inner.offsetWidth) : 0
|
||||
};
|
||||
}
|
||||
function onTimelineScroll() {
|
||||
_updateTlScrollState();
|
||||
}
|
||||
const tlEcoState = computed(() => {
|
||||
const { scrollL, viewW, innerW } = tlScrollState.value;
|
||||
const total = TL_END.value - TL_START.value;
|
||||
const empty = { left: [], right: [], vStart: TL_START.value, vEnd: TL_END.value };
|
||||
if (total <= 0 || !innerW || !viewW || innerW <= viewW) return empty;
|
||||
const vStart = TL_START.value + (scrollL / innerW) * total;
|
||||
const vEnd = TL_START.value + ((scrollL + viewW) / innerW) * total;
|
||||
const left = [];
|
||||
const right = [];
|
||||
for (const ev of timelineEventsRaw.value) {
|
||||
if (ev.endH <= vStart) left.push(ev);
|
||||
else if (ev.startH >= vEnd) right.push(ev);
|
||||
}
|
||||
return { left, right, vStart, vEnd };
|
||||
});
|
||||
function ecoTickStyle(ev, side) {
|
||||
const { vStart, vEnd } = tlEcoState.value;
|
||||
let topPct = 50;
|
||||
if (side === 'left') {
|
||||
const span = vStart - TL_START.value;
|
||||
if (span > 0) topPct = ((ev.startH - TL_START.value) / span) * 100;
|
||||
} else {
|
||||
const span = TL_END.value - vEnd;
|
||||
if (span > 0) topPct = ((ev.startH - vEnd) / span) * 100;
|
||||
}
|
||||
return {
|
||||
top: `${Math.max(0, Math.min(100, topPct))}%`,
|
||||
backgroundColor: ev.bgColor || '#6366f1'
|
||||
};
|
||||
}
|
||||
function scrollToEvent(ev) {
|
||||
const el = tlHScrollEl.value;
|
||||
if (!el) return;
|
||||
const inner = el.firstElementChild;
|
||||
if (!inner) return;
|
||||
const innerWidth = inner.scrollWidth || inner.offsetWidth;
|
||||
const visibleWidth = el.clientWidth;
|
||||
const total = TL_END.value - TL_START.value;
|
||||
if (total <= 0) return;
|
||||
const ratio = (ev.startH - TL_START.value) / total;
|
||||
const target = Math.max(0, Math.min(innerWidth - visibleWidth, ratio * innerWidth - visibleWidth / 2));
|
||||
el.scrollTo({ left: target, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
let _tlAutoScrolled = false;
|
||||
function _scrollTimelineToNow() {
|
||||
const el = tlHScrollEl.value;
|
||||
if (!el) return;
|
||||
const h = agora.value.getHours() + agora.value.getMinutes() / 60;
|
||||
const total = TL_END.value - TL_START.value;
|
||||
if (total <= 0 || h < TL_START.value || h > TL_END.value) return;
|
||||
const inner = el.firstElementChild;
|
||||
if (!inner) return;
|
||||
const innerWidth = inner.scrollWidth || inner.offsetWidth;
|
||||
const visibleWidth = el.clientWidth;
|
||||
if (innerWidth <= visibleWidth) return;
|
||||
const ratio = (h - TL_START.value) / total;
|
||||
el.scrollLeft = Math.max(0, ratio * innerWidth - visibleWidth / 2);
|
||||
}
|
||||
onMounted(() => {
|
||||
const stop = watch(
|
||||
[TL_START, TL_END],
|
||||
() => {
|
||||
nextTick(() => {
|
||||
if (!_tlAutoScrolled) {
|
||||
_scrollTimelineToNow();
|
||||
const el = tlHScrollEl.value;
|
||||
const inner = el?.firstElementChild;
|
||||
if (inner && inner.scrollWidth > el.clientWidth) {
|
||||
_tlAutoScrolled = true;
|
||||
stop();
|
||||
}
|
||||
}
|
||||
_updateTlScrollState();
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const el = tlHScrollEl.value;
|
||||
if (el && typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(() => _updateTlScrollState());
|
||||
ro.observe(el);
|
||||
if (el.firstElementChild) ro.observe(el.firstElementChild);
|
||||
onBeforeUnmount(() => ro.disconnect());
|
||||
}
|
||||
});
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
const { data: authData } = await supabase.auth.getUser();
|
||||
@@ -689,6 +878,10 @@ async function load() {
|
||||
}
|
||||
await tenantStore.ensureLoaded();
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
|
||||
// Feriados pra badge da timeline (precisa do tenant — RLS).
|
||||
if (tid) {
|
||||
loadFeriadosBase(tid).catch(() => {});
|
||||
}
|
||||
await loadCommitments();
|
||||
const mesInicio = new Date(anoAtual, mesAtual, 1, 0, 0, 0, 0).toISOString();
|
||||
const mesFim = new Date(anoAtual, mesAtual + 1, 0, 23, 59, 59, 999).toISOString();
|
||||
@@ -987,7 +1180,25 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col leading-tight">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Linha do tempo — Hoje</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Linha do tempo — Hoje</div>
|
||||
<span
|
||||
v-if="todayFeriado"
|
||||
class="dash-tl-badge dash-tl-badge--feriado"
|
||||
:title="`Feriado: ${todayFeriado.nome}`"
|
||||
>
|
||||
<i class="pi pi-star text-[0.6rem]" />
|
||||
Feriado{{ todayFeriado.nome ? `: ${todayFeriado.nome}` : '' }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isFolga"
|
||||
class="dash-tl-badge dash-tl-badge--folga"
|
||||
title="Hoje não é dia de trabalho na sua agenda — sessões fora do expediente continuam permitidas."
|
||||
>
|
||||
<i class="pi pi-moon text-[0.6rem]" />
|
||||
Folga
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs font-semibold text-[var(--text-color-secondary)] flex items-center gap-1.5">
|
||||
<span class="pulse-dot w-[15px] h-[5px] rounded-full bg-red-500"></span>
|
||||
Agora: {{ horaAtual }}
|
||||
@@ -995,37 +1206,74 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exemplo: badge ou ação -->
|
||||
<span class="text-xs font-semibold text-[var(--text-color-secondary)]">
|
||||
<Button icon="pi pi-cog" severity="secondary" outlined class="rounded-full" title="Ver sua Agenda" @click="$router.push('/therapist/agenda')" label="Agenda" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2.5 relative">
|
||||
<div class="flex justify-between mb-1">
|
||||
<div v-for="h in hoursRange" :key="h" class="flex-1 text-left">
|
||||
<span class="text-[0.80rem] text-[var(--text-color-secondary)] font-semibold">{{ h }}h</span>
|
||||
<!-- Frame relativo abriga scroll horizontal + eco lateral overlay -->
|
||||
<div class="dash-tl-frame mt-2.5 relative">
|
||||
<div ref="tlHScrollEl" class="dash-tl-scroll" @scroll.passive="onTimelineScroll">
|
||||
<div class="dash-tl-inner relative" :style="{ '--m-tl-cols': TL_END - TL_START }">
|
||||
<div class="flex justify-between mb-1">
|
||||
<div v-for="h in hoursRange" :key="h" class="flex-1 text-left">
|
||||
<span class="text-[0.80rem] text-[var(--text-color-secondary)] font-semibold">{{ h }}h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-10 bg-[var(--surface-ground,#f8fafc)] rounded-md overflow-visible">
|
||||
<div
|
||||
v-for="ev in timelineEvents"
|
||||
:key="ev.id"
|
||||
class="absolute top-[3px] h-[34px] rounded flex items-center px-1.5 overflow-hidden cursor-default min-w-[32px] hover:brightness-110 transition-[filter] duration-150 z-10"
|
||||
:style="{ ...ev.style, ...(ev.bgColor ? { backgroundColor: ev.bgColor, color: ev.txtColor || '#fff' } : {}) }"
|
||||
:class="{
|
||||
'bg-sky-400': !ev.bgColor && ev.tipo === 'reuniao',
|
||||
'bg-green-500': !ev.bgColor && ev.status === 'realizado',
|
||||
'bg-[var(--primary-color,#6366f1)]': !ev.bgColor && ev.tipo !== 'reuniao' && ev.status !== 'realizado'
|
||||
}"
|
||||
:title="ev.tooltip"
|
||||
>
|
||||
<span class="text-[0.58rem] font-bold text-white truncate">{{ ev.label }}</span>
|
||||
<span v-if="ev.badge" class="text-xs ml-auto">{{ ev.badge }}</span>
|
||||
</div>
|
||||
<div class="absolute top-0 h-full flex flex-col items-center pointer-events-none z-20" :style="{ left: nowCursorLeft }">
|
||||
<div class="w-0.5 h-full bg-red-500 opacity-80" />
|
||||
<div class="absolute -top-0.5 w-[7px] h-[7px] rounded-full bg-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-10 bg-[var(--surface-ground,#f8fafc)] rounded-md overflow-visible">
|
||||
<div
|
||||
v-for="ev in timelineEvents"
|
||||
:key="ev.id"
|
||||
class="absolute top-[3px] h-[34px] rounded flex items-center px-1.5 overflow-hidden cursor-default min-w-[32px] hover:brightness-110 transition-[filter] duration-150 z-10"
|
||||
:style="{ ...ev.style, ...(ev.bgColor ? { backgroundColor: ev.bgColor, color: ev.txtColor || '#fff' } : {}) }"
|
||||
:class="{
|
||||
'bg-sky-400': !ev.bgColor && ev.tipo === 'reuniao',
|
||||
'bg-green-500': !ev.bgColor && ev.status === 'realizado',
|
||||
'bg-[var(--primary-color,#6366f1)]': !ev.bgColor && ev.tipo !== 'reuniao' && ev.status !== 'realizado'
|
||||
}"
|
||||
:title="ev.tooltip"
|
||||
>
|
||||
<span class="text-[0.58rem] font-bold text-white truncate">{{ ev.label }}</span>
|
||||
<span v-if="ev.badge" class="text-xs ml-auto">{{ ev.badge }}</span>
|
||||
</div>
|
||||
<div class="absolute top-0 h-full flex flex-col items-center pointer-events-none z-20" :style="{ left: nowCursorLeft }">
|
||||
<div class="w-0.5 h-full bg-red-500 opacity-80" />
|
||||
<div class="absolute -top-0.5 w-[7px] h-[7px] rounded-full bg-red-500" />
|
||||
</div>
|
||||
|
||||
<!-- Eco lateral — minimap pulsante de cores. Tracinhos
|
||||
posicionados por tempo, click suaviza scroll até o evento. -->
|
||||
<div
|
||||
v-if="tlEcoState.left.length"
|
||||
class="dash-tl-eco dash-tl-eco--left"
|
||||
:title="`${tlEcoState.left.length} antes — clique pra centralizar`"
|
||||
>
|
||||
<button
|
||||
v-for="ev in tlEcoState.left"
|
||||
:key="`eco-l-${ev.id}`"
|
||||
type="button"
|
||||
class="dash-tl-eco__tick"
|
||||
:style="ecoTickStyle(ev, 'left')"
|
||||
:title="`${_fmtHora(ev.startH)} · ${ev.label}`"
|
||||
@click="scrollToEvent(ev)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="tlEcoState.right.length"
|
||||
class="dash-tl-eco dash-tl-eco--right"
|
||||
:title="`${tlEcoState.right.length} à frente — clique pra centralizar`"
|
||||
>
|
||||
<button
|
||||
v-for="ev in tlEcoState.right"
|
||||
:key="`eco-r-${ev.id}`"
|
||||
type="button"
|
||||
class="dash-tl-eco__tick"
|
||||
:style="ecoTickStyle(ev, 'right')"
|
||||
:title="`${_fmtHora(ev.startH)} · ${ev.label}`"
|
||||
@click="scrollToEvent(ev)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1423,4 +1671,103 @@ onMounted(async () => {
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Timeline horizontal — paridade com o Melissa ──────────────
|
||||
Range derivado de agenda_regras_semanais + scroll horizontal
|
||||
com min-width de slot pra legibilidade + eco lateral pra eventos
|
||||
off-screen. CSS escopado ao Dashboard (mesmas classes existem
|
||||
no MelissaLayout sob outros nomes — duplicação consciente até
|
||||
eventual extração pra componente compartilhado). */
|
||||
.dash-tl-frame { position: relative; }
|
||||
|
||||
.dash-tl-scroll {
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--surface-300, #cbd5e1) transparent;
|
||||
}
|
||||
.dash-tl-scroll::-webkit-scrollbar { height: 6px; }
|
||||
.dash-tl-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
.dash-tl-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--surface-300, #cbd5e1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.dash-tl-inner {
|
||||
/* --m-tl-cols inline = TL_END - TL_START. Default 13 cobre 7→20. */
|
||||
min-width: calc(var(--m-tl-cols, 13) * 80px);
|
||||
}
|
||||
|
||||
/* Badges (Folga / Feriado) — light theme nativo */
|
||||
.dash-tl-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dash-tl-badge--folga {
|
||||
background: var(--surface-100, #f1f5f9);
|
||||
color: var(--text-color-secondary, #64748b);
|
||||
border: 1px solid var(--surface-300, #cbd5e1);
|
||||
}
|
||||
.dash-tl-badge--feriado {
|
||||
background: color-mix(in srgb, rgb(217, 119, 6) 12%, transparent);
|
||||
color: rgb(180, 83, 9);
|
||||
border: 1px solid color-mix(in srgb, rgb(217, 119, 6) 32%, transparent);
|
||||
}
|
||||
|
||||
/* Eco lateral — minimap pulsante */
|
||||
.dash-tl-eco {
|
||||
position: absolute;
|
||||
top: 24px; /* alinha com a barra (descontando linha de horas) */
|
||||
bottom: 8px;
|
||||
width: 8px;
|
||||
z-index: 6;
|
||||
pointer-events: auto;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--surface-200, #e2e8f0) 70%, transparent);
|
||||
border: 1px solid var(--surface-300, #cbd5e1);
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
animation: dash-tl-eco-pulse 2400ms ease-in-out infinite;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
.dash-tl-eco--left { left: -2px; }
|
||||
.dash-tl-eco--right { right: -2px; }
|
||||
|
||||
.dash-tl-eco__tick {
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
height: 4px;
|
||||
transform: translateY(-50%);
|
||||
border: 0;
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15);
|
||||
transition: opacity 140ms ease, transform 140ms ease, height 140ms ease;
|
||||
}
|
||||
.dash-tl-eco__tick:hover {
|
||||
opacity: 1;
|
||||
height: 6px;
|
||||
transform: translateY(-50%) scaleX(2.2);
|
||||
z-index: 1;
|
||||
}
|
||||
.dash-tl-eco--left .dash-tl-eco__tick:hover { transform-origin: left center; }
|
||||
.dash-tl-eco--right .dash-tl-eco__tick:hover { transform-origin: right center; }
|
||||
|
||||
@keyframes dash-tl-eco-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary-color, #6366f1) 0%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 10px 1px color-mix(in srgb, var(--primary-color, #6366f1) 28%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user