Melissa: blueprint tabular + Cadastros/Agendamentos/Pacientes + restore
Sprint E (05-05). Blueprint tabular oficial pras paginas Melissa de
listagem (DataTable + sidebar com stats e filtros coloridos, view
toggle list/grade, subheader explicativo, mobile pencil+popover).
Novo arquivo:
- blueprints/melissa-table-page-blueprint.md (~530L, 18 secoes) —
referencia canonica MelissaCadastrosRecebidos
Paginas refatoradas/criadas:
- MelissaCadastrosRecebidos: refator pra blueprint (DataTable + frozen
action + view toggle + subheader)
- MelissaAgendamentosRecebidos (NOVO): substitui o embed via
MelissaEmbed; 4 status coloridos (Pendente/Autorizado/Convertido/
Recusado), 3 acoes condicionais (Recusar/Autorizar/Converter em
sessao), wired com AgendaEventDialog
- MelissaPacientes: refator parcial (subheader, sombras, status pills
coloridas, email/phone colunas proprias, mobile pencil+popover, fix
scroll mobile com min-height:0 na .mp-list, view toggle persistido,
tags/grupos color fix g.cor->g.color, restore de arquivados)
- MelissaEmbed: agendamentos-recebidos removido do EMBED_MAP
- MelissaLayout: wire-up MelissaAgendamentosRecebidos nativo
- composables/useMelissaPacientes + useMelissaPacientesAside ajustes
Restore de pacientes arquivados:
- patientsRepository: novo restorePatient(id, { tenantId })
- PatientsCadastroPage statusOpts: +Arquivado (fecha gap de
inconsistencia ao editar paciente arquivado)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,812 @@
|
|||||||
|
# Blueprint — Melissa Table Page
|
||||||
|
|
||||||
|
Padrão de página Melissa que apresenta uma **coleção tabular** (intake
|
||||||
|
requests, médicos, recorrências, compromissos, etc.) com 2 modos de
|
||||||
|
visualização (lista/grade), filtros laterais coloridos, busca, e
|
||||||
|
DataTable com paginação + coluna de ação fixa.
|
||||||
|
|
||||||
|
Validado em `src/layout/melissa/MelissaCadastrosRecebidos.vue`
|
||||||
|
(referência canônica). Estende o
|
||||||
|
[`melissa-page-blueprint.md`](./melissa-page-blueprint.md) — leia aquele
|
||||||
|
primeiro pra entender a estrutura macro (`.xx-page` / `.xx-body` /
|
||||||
|
`.xx-side` / `.xx-main`, drawer mobile, header).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Princípio
|
||||||
|
|
||||||
|
Página de coleção = **sidebar de filtros + coluna principal com
|
||||||
|
toolbar + visualização tabular**. O user controla:
|
||||||
|
|
||||||
|
- **Busca** (texto livre — nome / email / telefone / etc.)
|
||||||
|
- **Filtro de status** (mutualmente exclusivo, com botão "Limpar")
|
||||||
|
- **Modo de visualização** (lista densa via DataTable ou grade de cards)
|
||||||
|
- **Paginação** (10/20/50/100 por página)
|
||||||
|
|
||||||
|
A linha tem 1 ação primária visível (botão pencil) que abre um Dialog
|
||||||
|
com detalhes + ações secundárias (rejeitar, converter, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Estrutura do template
|
||||||
|
|
||||||
|
Segue a macro do `melissa-page-blueprint.md` (drawer + backdrop + page
|
||||||
|
+ header + body com aside Teleportada). Sobre essa base, esta blueprint
|
||||||
|
adiciona um **subheader explicativo** (logo abaixo do header, antes do
|
||||||
|
body) e a estrutura tabular dentro da `.xx-main`:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<section class="xx-page">
|
||||||
|
<header class="xx-page__head">…</header>
|
||||||
|
|
||||||
|
<!-- Subheader explicativo — 1 frase de contexto sobre o que essa
|
||||||
|
página faz, com palavras-chave em <strong>. Diferencia páginas
|
||||||
|
que têm layout idêntico (ex: Cadastros Recebidos vs.
|
||||||
|
Agendamentos Recebidos). -->
|
||||||
|
<div class="xx-subheader">
|
||||||
|
<i class="pi pi-info-circle xx-subheader__icon" />
|
||||||
|
<span class="xx-subheader__text">
|
||||||
|
Texto descritivo da página em 1-2 frases. Use
|
||||||
|
<strong>palavras-chave</strong> em negrito pra destacar as
|
||||||
|
ações disponíveis (autorize, recuse, converta, etc.).
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="xx-body">…sidebar + main…</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
A diferença dentro da `.xx-main`:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div class="xx-main">
|
||||||
|
<!-- A) Toolbar — busca + view toggle -->
|
||||||
|
<div class="xx-toolbar">
|
||||||
|
<div class="xx-search">
|
||||||
|
<i class="pi pi-search xx-search__icon" />
|
||||||
|
<input v-model="busca" class="xx-search__input" placeholder="…" />
|
||||||
|
<button v-if="busca" class="xx-search__clear" @click="busca = ''">
|
||||||
|
<i class="pi pi-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="xx-view-toggle" role="group" aria-label="Visualização">
|
||||||
|
<button :class="{ 'is-active': viewMode === 'list' }" @click="setViewMode('list')">
|
||||||
|
<i class="pi pi-list" />
|
||||||
|
</button>
|
||||||
|
<button :class="{ 'is-active': viewMode === 'grid' }" @click="setViewMode('grid')">
|
||||||
|
<i class="pi pi-th-large" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- B) View Lista (DataTable) -->
|
||||||
|
<DataTable v-if="viewMode === 'list'" … />
|
||||||
|
|
||||||
|
<!-- C) View Grade (cards em CSS grid + Paginator standalone) -->
|
||||||
|
<div v-else-if="viewMode === 'grid'" class="xx-grid-wrap">
|
||||||
|
<div class="xx-grid">
|
||||||
|
<div v-for="r in pagedItems" class="xx-grid__card" role="button" tabindex="0" @click="openDetails(r)">…</div>
|
||||||
|
</div>
|
||||||
|
<Paginator class="xx-paginator" :rows="rowsXX" :first="firstXX" … />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
E na sidebar (`.xx-side`), ao invés de Hoje/Pacientes/Mini-cal, tem:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<aside class="xx-side">
|
||||||
|
<!-- Stats (4 contadores em grid 2x2) -->
|
||||||
|
<div class="xx-w xx-w--side">
|
||||||
|
<div class="xx-w__head">
|
||||||
|
<span class="xx-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
|
||||||
|
</div>
|
||||||
|
<div class="xx-stats">
|
||||||
|
<div v-for="s in stats" class="xx-stat" :class="`is-${s.cls}`">
|
||||||
|
<div class="xx-stat__val">{{ s.value }}</div>
|
||||||
|
<div class="xx-stat__lbl">{{ s.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros (botões coloridos por status + Limpar filtro) -->
|
||||||
|
<div class="xx-w xx-w--side">
|
||||||
|
<div class="xx-w__head">
|
||||||
|
<span class="xx-w__title"><i class="pi pi-filter" /> Status</span>
|
||||||
|
<span v-if="statusFilter" class="xx-w__count">1</span>
|
||||||
|
</div>
|
||||||
|
<div class="xx-side__list">
|
||||||
|
<button
|
||||||
|
v-for="o in STATUS_FILTER_OPTIONS"
|
||||||
|
class="xx-side__item"
|
||||||
|
:class="[`is-${o.key}`, { 'is-active': statusFilter === o.key }]"
|
||||||
|
@click="toggleStatusFilter(o.key)"
|
||||||
|
>
|
||||||
|
<i :class="o.icon" /><span>{{ o.label }}</span>
|
||||||
|
</button>
|
||||||
|
<Transition name="xx-clear">
|
||||||
|
<button v-if="statusFilter" class="xx-side__item is-clear" @click="statusFilter = ''">
|
||||||
|
<i class="pi pi-filter-slash" /><span>Limpar filtro</span>
|
||||||
|
</button>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Estado JS (script setup)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ── Filtros + busca ──
|
||||||
|
const busca = ref('');
|
||||||
|
const statusFilter = ref('');
|
||||||
|
function toggleStatusFilter(s) {
|
||||||
|
statusFilter.value = statusFilter.value === s ? '' : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Computeds derivados ──
|
||||||
|
const stats = computed(() => {/* contadores por status */});
|
||||||
|
const filtered = computed(() => {/* aplica busca + statusFilter sobre rows */});
|
||||||
|
|
||||||
|
// ── Paginação compartilhada (DataTable + grid) ──
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||||
|
const rowsXX = ref(10);
|
||||||
|
const firstXX = ref(0);
|
||||||
|
function onPage(event) {
|
||||||
|
firstXX.value = event.first;
|
||||||
|
rowsXX.value = event.rows;
|
||||||
|
}
|
||||||
|
watch([busca, statusFilter], () => { firstXX.value = 0; }); // reset à pg 1
|
||||||
|
|
||||||
|
// ── View mode persistido ──
|
||||||
|
const VIEW_MODE_KEY = 'xx.viewMode.v1';
|
||||||
|
const viewMode = ref('list');
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(VIEW_MODE_KEY);
|
||||||
|
if (saved === 'list' || saved === 'grid') viewMode.value = saved;
|
||||||
|
} catch (_) {}
|
||||||
|
function setViewMode(m) {
|
||||||
|
if (m !== 'list' && m !== 'grid') return;
|
||||||
|
viewMode.value = m;
|
||||||
|
try { localStorage.setItem(VIEW_MODE_KEY, m); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slice da grid (DataTable pagina internamente) ──
|
||||||
|
const pagedItems = computed(() =>
|
||||||
|
filtered.value.slice(firstXX.value, firstXX.value + rowsXX.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Row click + ação ──
|
||||||
|
function onRowClick(event) { if (event?.data) openDetails(event.data); }
|
||||||
|
function rowStatusClass(data) { return statusClass(data?.status); }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. DataTable (view Lista) — props canônicas
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<DataTable
|
||||||
|
v-if="viewMode === 'list'"
|
||||||
|
:value="filtered"
|
||||||
|
:loading="loading"
|
||||||
|
dataKey="id"
|
||||||
|
paginator
|
||||||
|
:rows="rowsXX"
|
||||||
|
:first="firstXX"
|
||||||
|
:rowsPerPageOptions="PAGE_SIZE_OPTIONS"
|
||||||
|
paginatorTemplate="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
|
||||||
|
currentPageReportTemplate="{first}–{last} de {totalRecords}"
|
||||||
|
:rowClass="rowStatusClass"
|
||||||
|
selectionMode="single"
|
||||||
|
scrollable
|
||||||
|
scrollHeight="flex"
|
||||||
|
tableStyle="min-width: 640px"
|
||||||
|
class="xx-table"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
@page="onPage"
|
||||||
|
>
|
||||||
|
<Column header="Paciente" style="min-width: 220px">
|
||||||
|
<template #body="{ data }">…avatar + nome + badge…</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Contato" style="min-width: 220px">
|
||||||
|
<template #body="{ data }">…email + tel…</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Recebido" style="width: 130px">
|
||||||
|
<template #body="{ data }">…tempo relativo…</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Coluna de ação fixa (frozen à direita) -->
|
||||||
|
<Column
|
||||||
|
header=""
|
||||||
|
:style="{ width: '60px', maxWidth: '60px', minWidth: '60px' }"
|
||||||
|
frozen
|
||||||
|
alignFrozen="right"
|
||||||
|
>
|
||||||
|
<template #body="{ data }">
|
||||||
|
<button class="xx-row__action" @click.stop="openDetails(data)">
|
||||||
|
<i class="pi pi-pencil" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<template #empty>…empty state contextual…</template>
|
||||||
|
<template #loading>…spinner inline…</template>
|
||||||
|
</DataTable>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props críticas explicadas
|
||||||
|
|
||||||
|
| Prop | Por quê |
|
||||||
|
|---|---|
|
||||||
|
| `:loading="loading"` | Overlay nativo do PrimeVue + slot `#loading` custom — substitui skeleton manual. |
|
||||||
|
| `paginator + :rows + :first + @page` | Paginator embutido controlado; `firstXX` permite resetar à página 1 quando filtros mudam. |
|
||||||
|
| `paginatorTemplate="RowsPerPageDropdown First… Last…"` | Ordem do exemplo PrimeVue 4: dropdown ANTES dos navegadores; CurrentPageReport no meio. |
|
||||||
|
| `currentPageReportTemplate="{first}–{last} de {totalRecords}"` | i18n PT-BR. |
|
||||||
|
| `:rowClass="rowStatusClass"` | Aplica `is-new` / `is-done` / `is-rejected` no `<tr>` → border-left colorido via CSS deep. |
|
||||||
|
| `selectionMode="single"` | Marca visualmente a row selecionada; `@row-click` abre o dialog. |
|
||||||
|
| `scrollable + scrollHeight="flex"` | Tabela preenche o flex restante da `.xx-main` e scrolla internamente (vertical). |
|
||||||
|
| `tableStyle="min-width: 640px"` | Força scroll horizontal em mobile pra ativar a coluna frozen. |
|
||||||
|
| `dataKey="id"` | Identificação estável de rows pra seleção + reactive updates. |
|
||||||
|
|
||||||
|
### Coluna frozen — regras
|
||||||
|
|
||||||
|
- **Última `<Column>`** do template
|
||||||
|
- `frozen alignFrozen="right"` — fixa à direita
|
||||||
|
- `width: 60px, maxWidth: 60px, minWidth: 60px` — todas três pra evitar reflow durante scroll
|
||||||
|
- **`header=""`** vazio (icon do botão é auto-explicativo; tooltip cobre o resto)
|
||||||
|
- Botão interno usa **`@click.stop`** — sem isso, o row-click do DataTable também dispararia
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. View Grade (cards em CSS grid)
|
||||||
|
|
||||||
|
Quando `viewMode === 'grid'`, renderiza cards num grid responsivo com
|
||||||
|
Paginator standalone abaixo (compartilha state com a list view):
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div v-else-if="viewMode === 'grid'" class="xx-grid-wrap">
|
||||||
|
<div v-if="loading && filtered.length === 0" class="xx-grid__loading">…</div>
|
||||||
|
<div v-else-if="filtered.length === 0" class="xx-empty">…</div>
|
||||||
|
<div v-else class="xx-grid">
|
||||||
|
<div
|
||||||
|
v-for="r in pagedItems"
|
||||||
|
class="xx-grid__card"
|
||||||
|
:class="statusClass(r.status)"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="openDetails(r)"
|
||||||
|
@keydown.enter.prevent="openDetails(r)"
|
||||||
|
@keydown.space.prevent="openDetails(r)"
|
||||||
|
>
|
||||||
|
<div class="xx-grid__top">
|
||||||
|
<span class="xx-card__avatar">…</span>
|
||||||
|
<div class="xx-grid__top-right">
|
||||||
|
<span class="xx-card__badge" :class="statusClass(r.status)">…</span>
|
||||||
|
<button class="xx-row__action" @click.stop="openDetails(r)">
|
||||||
|
<i class="pi pi-pencil" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="xx-grid__name">…</div>
|
||||||
|
<div class="xx-grid__meta">…</div>
|
||||||
|
<div class="xx-grid__time">…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Paginator
|
||||||
|
v-if="filtered.length > 0"
|
||||||
|
class="xx-paginator"
|
||||||
|
:rows="rowsXX"
|
||||||
|
:totalRecords="filtered.length"
|
||||||
|
:first="firstXX"
|
||||||
|
:rowsPerPageOptions="PAGE_SIZE_OPTIONS"
|
||||||
|
template="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
|
||||||
|
currentPageReportTemplate="{first}–{last} de {totalRecords}"
|
||||||
|
@page="onPage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Por que `<div role="button">` em vez de `<button>`?
|
||||||
|
|
||||||
|
HTML não permite aninhar `<button>` em `<button>`. A grid card tem o
|
||||||
|
botão pencil interno, então o card precisa ser um `<div>` com
|
||||||
|
`role="button"`, `tabindex="0"` e handlers de teclado (`@keydown.enter`
|
||||||
|
+ `@keydown.space`) pra manter acessibilidade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Tokens de surface (light/dark)
|
||||||
|
|
||||||
|
A consistência visual entre **header da tabela**, **coluna frozen**, e
|
||||||
|
**cards da sidebar** depende de usar o token certo:
|
||||||
|
|
||||||
|
| Elemento | Token | Light | Dark |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `.xx-page` (background da página) | `var(--m-bg-medium)` | branco opaco | 88% opaco (glass) |
|
||||||
|
| `.xx-side` (sidebar) | `var(--m-bg-soft)` | surface-100 | 50% opaco |
|
||||||
|
| `.xx-w` (cards) | `var(--m-bg-medium)` | branco opaco | 88% opaco |
|
||||||
|
| `.xx-card` / `.xx-grid__card` (cards de linha) | `var(--m-bg-soft)` | surface-100 | 50% opaco |
|
||||||
|
| **Header da tabela** (`.p-datatable-thead > tr > th`) | **`var(--p-content-background)`** | **branco opaco** | **surface dark configurado** |
|
||||||
|
| **Coluna frozen** (header + body) | **`var(--p-content-background)`** | **branco opaco** | **surface dark configurado** |
|
||||||
|
| **Botão pencil** (bg) | **`var(--p-content-background)`** | **branco opaco** | **surface dark configurado** |
|
||||||
|
|
||||||
|
**Por que `--p-content-background` e não `--m-bg-medium`** pro frozen?
|
||||||
|
No dark mode `--m-bg-medium` tem 12% de transparência (efeito glass),
|
||||||
|
o que faz a coluna frozen vazar conteúdo de outras colunas durante
|
||||||
|
scroll horizontal. `--p-content-background` é 100% opaco em ambos os
|
||||||
|
modos e segue a config de surface do tema PrimeVue (token canônico de
|
||||||
|
"superfície de card").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Cores de status (semântica + paleta)
|
||||||
|
|
||||||
|
Tailwind 600 — fortes o bastante pra ler em ambos os modos:
|
||||||
|
|
||||||
|
| Status | Cor | RGB | Uso |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Novo / Pendente | 🔵 azul | `rgb(37, 99, 235)` | item recém-chegado, ação requerida |
|
||||||
|
| Convertido / Concluído | 🟢 verde | `rgb(22, 163, 74)` | sucesso, finalizado |
|
||||||
|
| Rejeitado / Cancelado | 🔴 vermelho | `rgb(220, 38, 38)` | descartado, falha |
|
||||||
|
|
||||||
|
**Aplicação consistente** em 4 lugares por status:
|
||||||
|
|
||||||
|
1. **Stat value** (`.xx-stat.is-info / is-ok / is-danger`) — número colorido
|
||||||
|
2. **Filtro lateral** (`.xx-side__item.is-X`) — bg/border/ícone tinted (3 níveis: default 5% / hover 10% / active 16% + ring)
|
||||||
|
3. **Border-left da row** (`.xx-table tr.is-X`) — 3px sólido na cor
|
||||||
|
4. **Badge** (`.xx-card__badge.is-X`) — pill colorido no card/row
|
||||||
|
|
||||||
|
Variável `cls` no objeto stats:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{ key: 'new', label: 'Novos', value: n, cls: n > 0 ? 'info' : 'neutral' },
|
||||||
|
{ key: 'converted', label: 'Convertidos', value: c, cls: 'ok' },
|
||||||
|
{ key: 'rejected', label: 'Rejeitados', value: r, cls: r > 0 ? 'danger' : 'neutral' },
|
||||||
|
```
|
||||||
|
|
||||||
|
**Não usar `is-warn`** (amarelo) pra "Novo" — semanticamente novo é
|
||||||
|
informativo, não alerta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Filtro de status — botões + "Limpar filtro"
|
||||||
|
|
||||||
|
3 botões coloridos (Novo / Convertido / Rejeitado) + 4º botão
|
||||||
|
**"Limpar filtro"** que aparece com `<Transition name="xx-clear">`
|
||||||
|
quando algum filtro está ativo:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.xx-side__item.is-clear {
|
||||||
|
margin-top: 4px;
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
border-color: var(--m-border);
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade + slide vertical + collapse de altura */
|
||||||
|
.xx-clear-enter-active,
|
||||||
|
.xx-clear-leave-active {
|
||||||
|
transition: opacity 220ms ease, transform 220ms ease,
|
||||||
|
max-height 220ms ease, margin-top 220ms ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.xx-clear-enter-from,
|
||||||
|
.xx-clear-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
max-height: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.xx-clear-enter-to,
|
||||||
|
.xx-clear-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
max-height: 40px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Estilo neutro/itálico** (não colorido) pra distinguir dos 3 botões
|
||||||
|
de filtro coloridos. Ícone `pi pi-filter-slash`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Subheader explicativo
|
||||||
|
|
||||||
|
Faixa estreita abaixo do `xx-page__head`, antes do `xx-body`. Tem 2
|
||||||
|
papéis:
|
||||||
|
|
||||||
|
1. **Diferenciar páginas** que têm o mesmo layout (Cadastros Recebidos
|
||||||
|
vs. Agendamentos Recebidos parecem visualmente idênticos sem isso)
|
||||||
|
2. **Resumir as ações** disponíveis pra reduzir cliques exploratórios
|
||||||
|
do user
|
||||||
|
|
||||||
|
```css
|
||||||
|
.xx-subheader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-bottom: 1px solid var(--m-border);
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.xx-subheader__icon {
|
||||||
|
color: var(--p-primary-color);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.xx-subheader__text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.xx-subheader__text strong {
|
||||||
|
color: var(--m-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Convenção do texto
|
||||||
|
|
||||||
|
- 1-2 frases curtas (~20-30 palavras max)
|
||||||
|
- Inicia descrevendo a fonte/origem dos dados ("Solicitações vindas
|
||||||
|
de...", "Cadastros enviados por...")
|
||||||
|
- Termina enumerando as ações principais com `<strong>`
|
||||||
|
(`autorize`, `recuse`, `converta`)
|
||||||
|
- Tom direto, sem formalidade excessiva ("a gente cria o paciente
|
||||||
|
automaticamente" ✓ vs. "o sistema procederá com a criação" ✗)
|
||||||
|
- Ícone fixo: `pi pi-info-circle` em primary
|
||||||
|
|
||||||
|
### Exemplos validados
|
||||||
|
|
||||||
|
**Cadastros Recebidos:**
|
||||||
|
> Cadastros completos enviados por pacientes via formulário externo
|
||||||
|
> (link público). Revise os dados, **converta em paciente ativo** com
|
||||||
|
> 1 clique ou **rejeite** com motivo opcional.
|
||||||
|
|
||||||
|
**Agendamentos Recebidos:**
|
||||||
|
> Solicitações de horário vindas do agendador online à espera de ação.
|
||||||
|
> **Autorize** pra reservar o slot, **recuse** com motivo, ou
|
||||||
|
> **converta direto em sessão** — a gente cria o paciente
|
||||||
|
> automaticamente se ainda não existir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Toolbar — busca + view toggle (no main column)
|
||||||
|
|
||||||
|
```css
|
||||||
|
.xx-toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.xx-search {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.xx-search__input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--m-bg-medium);
|
||||||
|
border: 1px solid var(--m-border);
|
||||||
|
padding: 9px 36px 9px 34px; /* espaço pro ícone esq + clear dir */
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.xx-search__icon { position: absolute; left: 12px; }
|
||||||
|
.xx-search__clear { position: absolute; right: 8px; }
|
||||||
|
|
||||||
|
/* Segmented control list/grid */
|
||||||
|
.xx-view-toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--m-bg-medium);
|
||||||
|
border: 1px solid var(--m-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 2px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.xx-view-toggle__btn {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.xx-view-toggle__btn.is-active {
|
||||||
|
background: var(--m-accent-soft);
|
||||||
|
color: var(--m-accent);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A **busca está no main column** (não na sidebar). Esta é a regra do
|
||||||
|
blueprint — sidebar só tem stats + filtros; busca + toggle ficam acima
|
||||||
|
da tabela.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. DataTable — estilos de header, rows, paginator
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Wrapper que faz a DataTable ocupar o flex restante */
|
||||||
|
.xx-table {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.xx-table :deep(.p-datatable) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--m-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.xx-table :deep(.p-datatable-table-container) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header — totalmente transparente nos níveis externos, surface no <th> */
|
||||||
|
.xx-table :deep(.p-datatable-thead),
|
||||||
|
.xx-table :deep(.p-datatable-thead > tr) {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
.xx-table :deep(.p-datatable-thead > tr > th) {
|
||||||
|
background: var(--p-content-background) !important; /* canônico */
|
||||||
|
color: var(--m-text);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700; /* negrito */
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--m-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rows */
|
||||||
|
.xx-table :deep(.p-datatable-tbody > tr) {
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 3px solid var(--m-border); /* default neutro */
|
||||||
|
transition: background-color 140ms ease;
|
||||||
|
}
|
||||||
|
.xx-table :deep(.p-datatable-tbody > tr > td) {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--m-border);
|
||||||
|
background: transparent;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.xx-table :deep(.p-datatable-tbody > tr:hover) { background: var(--m-bg-soft-hover); }
|
||||||
|
.xx-table :deep(.p-datatable-tbody > tr.p-datatable-row-selected) {
|
||||||
|
background: var(--m-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Border-left colorido por status — espelha .ma-sess do MelissaAgenda */
|
||||||
|
.xx-table :deep(tr.is-new) { border-left-color: rgb(37, 99, 235); }
|
||||||
|
.xx-table :deep(tr.is-done) { border-left-color: rgb(22, 163, 74); }
|
||||||
|
.xx-table :deep(tr.is-rejected) { border-left-color: rgb(220, 38, 38); opacity: 0.85; }
|
||||||
|
|
||||||
|
/* Coluna frozen — mesma surface do header */
|
||||||
|
.xx-table :deep(td.p-datatable-frozen-column),
|
||||||
|
.xx-table :deep(th.p-datatable-frozen-column) {
|
||||||
|
background: var(--p-content-background) !important;
|
||||||
|
box-shadow: -3px 0 6px -3px rgba(0, 0, 0, 0.18);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.xx-table :deep(.p-datatable-tbody > tr:hover td.p-datatable-frozen-column) {
|
||||||
|
background: var(--m-bg-soft-hover);
|
||||||
|
}
|
||||||
|
.xx-table :deep(.p-datatable-tbody > tr.p-datatable-row-selected td.p-datatable-frozen-column) {
|
||||||
|
background: var(--m-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paginator integrado — centralizado, sem refresh à esquerda */
|
||||||
|
.xx-table :deep(.p-paginator) {
|
||||||
|
background: var(--m-bg-medium);
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--m-border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.xx-table :deep(.p-paginator-current) {
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.xx-table :deep(.p-paginator-page.p-paginator-page-selected) {
|
||||||
|
background: var(--m-accent-soft);
|
||||||
|
border-color: var(--m-accent-strong);
|
||||||
|
color: var(--m-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select de "rows per page" — bg transparente + label centralizado */
|
||||||
|
.xx-table :deep(.p-select) {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--m-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.xx-table :deep(.p-select-label) {
|
||||||
|
padding: 0 8px;
|
||||||
|
color: var(--m-text);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Botão de ação (pencil) — coluna fixa
|
||||||
|
|
||||||
|
```css
|
||||||
|
.xx-row__action {
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--p-content-background); /* opaco — não vaza no scroll */
|
||||||
|
border: 1px solid color-mix(in srgb, var(--p-primary-color) 30%, var(--m-border));
|
||||||
|
color: var(--p-primary-color); /* primary do tema */
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
|
||||||
|
}
|
||||||
|
.xx-row__action:hover {
|
||||||
|
background: color-mix(in srgb, var(--p-primary-color) 12%, var(--p-content-background));
|
||||||
|
border-color: var(--p-primary-color);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reutilizável **na list view (dentro da coluna frozen)** e **na grid
|
||||||
|
view (dentro do `.xx-grid__top-right`)** — mesma classe, mesmo visual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Empty / loading
|
||||||
|
|
||||||
|
Ambos via slot do DataTable + replicados na grid view:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template #empty>
|
||||||
|
<div class="xx-empty">
|
||||||
|
<i class="pi pi-inbox xx-empty__icon" />
|
||||||
|
<div class="xx-empty__title">Nenhum cadastro encontrado</div>
|
||||||
|
<div class="xx-empty__hint">
|
||||||
|
<template v-if="busca || statusFilter">Ajuste os filtros pra ver mais.</template>
|
||||||
|
<template v-else>… mensagem default contextual …</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #loading>
|
||||||
|
<div class="xx-table__loading">
|
||||||
|
<i class="pi pi-spin pi-spinner" />
|
||||||
|
<span>Carregando…</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.xx-empty {
|
||||||
|
margin: 24px 0;
|
||||||
|
padding: 56px 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
border: 2px dashed var(--m-border-strong);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--m-bg-soft) 40%, transparent);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.xx-empty__icon { font-size: 2rem; opacity: 0.6; }
|
||||||
|
.xx-empty__title { font-size: 0.92rem; font-weight: 600; }
|
||||||
|
.xx-empty__hint { font-size: 0.78rem; }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Mobile (<1024px)
|
||||||
|
|
||||||
|
A sidebar é Teleportada pro drawer (já documentado em
|
||||||
|
`melissa-page-blueprint.md`). Específico desta página:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.xx-body { flex-direction: column; padding: 0; }
|
||||||
|
.xx-main { width: 100%; padding: 8px; }
|
||||||
|
.xx-page__title > span:first-of-type { display: none; }
|
||||||
|
.xx-menu-btn--mobile-only { display: inline-flex; }
|
||||||
|
|
||||||
|
/* IMPORTANTE: NÃO esconder colunas em mobile.
|
||||||
|
O scroll horizontal (via tableStyle min-width:640px) cuida
|
||||||
|
do overflow, e a coluna frozen "Ação" continua visível na
|
||||||
|
borda direita enquanto o user scrolla as outras. */
|
||||||
|
/* (sem display: none em qualquer th/td) */
|
||||||
|
|
||||||
|
/* Reset do bg/border-right da sidebar quando teleportada */
|
||||||
|
.xx-mobile-drawer__scroll .xx-side {
|
||||||
|
background: transparent;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Acessibilidade
|
||||||
|
|
||||||
|
- `role="button" tabindex="0"` no card grid + `@keydown.enter.prevent` + `@keydown.space.prevent`
|
||||||
|
- `:focus-visible { outline: 2px solid var(--p-primary-color); outline-offset: 2px; }` nos cards
|
||||||
|
- `aria-label` em todos os icon-only buttons (pencil, view toggle, search clear)
|
||||||
|
- `v-tooltip` complementa visualmente (não substitui aria-label)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Checklist de adoção
|
||||||
|
|
||||||
|
Ao criar uma nova página tabular Melissa (ex: MelissaCompromissos):
|
||||||
|
|
||||||
|
- [ ] Renomeia `xx` → prefixo da página (`mco`, `mmd`, `mcv` etc.)
|
||||||
|
- [ ] Define `STATUS_FILTER_OPTIONS` com 3 keys/labels/icons
|
||||||
|
- [ ] Define `stats` computed retornando 4 itens (total + 3 status) com `cls` correto
|
||||||
|
- [ ] Implementa `filtered` computed (busca + statusFilter)
|
||||||
|
- [ ] Adiciona `rowsXX/firstXX/onPage` + watch reset
|
||||||
|
- [ ] Adiciona `viewMode` com persistência (`xx.viewMode.v1`)
|
||||||
|
- [ ] Adiciona `pagedItems` computed (slice pra grid)
|
||||||
|
- [ ] Adiciona `onRowClick + rowStatusClass`
|
||||||
|
- [ ] Adiciona `openDetails(r)` que abre o Dialog
|
||||||
|
- [ ] **Subheader explicativo** abaixo do `xx-page__head` (1-2 frases, fonte/origem + ações com `<strong>`, ícone `pi pi-info-circle`)
|
||||||
|
- [ ] Template: drawer + backdrop + page + header + **subheader** + body + sidebar (stats + filtros + clear) + main (toolbar + DataTable + grid)
|
||||||
|
- [ ] DataTable: `:loading + paginator + scrollable + scrollHeight="flex" + tableStyle="min-width: 640px"`
|
||||||
|
- [ ] Coluna frozen Ação: `width 60px + frozen alignFrozen="right"` + button pencil com `@click.stop`
|
||||||
|
- [ ] Grid card: `<div role="button" tabindex="0">` + handlers de teclado
|
||||||
|
- [ ] CSS: tokens `--p-content-background` em header, frozen, e botão pencil
|
||||||
|
- [ ] Mobile: NÃO esconder colunas; scroll horizontal via `tableStyle min-width`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Anti-patterns (NÃO fazer)
|
||||||
|
|
||||||
|
- ❌ **Busca na sidebar** — sempre no topo do main, ao lado do view toggle
|
||||||
|
- ❌ **`display: none` em colunas no mobile** — usar scroll horizontal + frozen
|
||||||
|
- ❌ **`<button>` envolvendo card no grid** — quebra HTML quando tem pencil interno; usar `<div role="button">`
|
||||||
|
- ❌ **`var(--m-bg-medium)` na coluna frozen no dark** — tem 12% transparência, vaza scroll. Usar `var(--p-content-background)`
|
||||||
|
- ❌ **`text-amber-300` Tailwind hardcoded** no ícone do header da página — usar `color: var(--p-primary-color)` via classe
|
||||||
|
- ❌ **`cls: 'warn'` pra "Novo"** — semanticamente errado (warn = aviso amarelo, novo = info azul)
|
||||||
|
- ❌ **Paginator com `#paginatorstart` slot duplicando refresh** — refresh já vive no header da página; centralizar o paginator (sem paginatorstart)
|
||||||
|
- ❌ **Skeleton manual + `carregandoInicial` na lista** — DataTable tem `:loading` nativo
|
||||||
|
- ❌ **`pageMCR + filteredPaginated` manual** — DataTable pagina internamente; só usa `firstXX/rowsXX` compartilhado
|
||||||
|
- ❌ **Border-left só em `is-new`** — todos os 3 status devem ter border-left colorido (consistência visual)
|
||||||
|
- ❌ **Misturar opacidade pesada (0.55, 0.75) com border colorido** — escolher uma estratégia; preferir border + opacidade leve (0.85 max)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Referência canônica
|
||||||
|
|
||||||
|
`src/layout/melissa/MelissaCadastrosRecebidos.vue` — implementação 1:1
|
||||||
|
deste blueprint. Quando dúvida, abrir esse arquivo lado-a-lado e
|
||||||
|
copiar o padrão exato (variáveis, ordem dos templates, tokens CSS).
|
||||||
|
|
||||||
|
Próximas adoções planejadas: `MelissaCompromissos`, `MelissaMedicos`,
|
||||||
|
`MelissaConversas`, `MelissaRecorrencias`, `MelissaTags`,
|
||||||
|
`MelissaGrupos` — todas seguem este blueprint.
|
||||||
@@ -746,7 +746,7 @@ const escolaridadeOpts = [
|
|||||||
{ label:'Prefere não informar', value:'Prefere não informar' },
|
{ label:'Prefere não informar', value:'Prefere não informar' },
|
||||||
]
|
]
|
||||||
const canalOpts = [{ label:'WhatsApp',value:'WhatsApp' },{ label:'Telefone',value:'Telefone' },{ label:'E-mail',value:'E-mail' },{ label:'SMS',value:'SMS' }]
|
const canalOpts = [{ label:'WhatsApp',value:'WhatsApp' },{ label:'Telefone',value:'Telefone' },{ label:'E-mail',value:'E-mail' },{ label:'SMS',value:'SMS' }]
|
||||||
const statusOpts = [{ label:'Ativo',value:'Ativo' },{ label:'Em espera',value:'Em espera' },{ label:'Inativo',value:'Inativo' },{ label:'Alta',value:'Alta' }]
|
const statusOpts = [{ label:'Ativo',value:'Ativo' },{ label:'Em espera',value:'Em espera' },{ label:'Inativo',value:'Inativo' },{ label:'Alta',value:'Alta' },{ label:'Arquivado',value:'Arquivado' }]
|
||||||
const scopeOpts = [{ label:'Clínica',value:'Clínica' },{ label:'Particular',value:'Particular' },{ label:'Online',value:'Online' },{ label:'Híbrido',value:'Híbrido' }]
|
const scopeOpts = [{ label:'Clínica',value:'Clínica' },{ label:'Particular',value:'Particular' },{ label:'Online',value:'Online' },{ label:'Híbrido',value:'Híbrido' }]
|
||||||
const tipoContatoOpts = [{ label:'Emergência',value:'emergencia' },{ label:'Familiar',value:'familiar' },{ label:'Profissional de saúde',value:'profissional_saude' },{ label:'Amigo(a)',value:'amigo' },{ label:'Outro',value:'outro' }]
|
const tipoContatoOpts = [{ label:'Emergência',value:'emergencia' },{ label:'Familiar',value:'familiar' },{ label:'Profissional de saúde',value:'profissional_saude' },{ label:'Amigo(a)',value:'amigo' },{ label:'Outro',value:'outro' }]
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,22 @@ export async function softDeletePatient(id, { tenantId } = {}) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restaura um paciente arquivado — volta status pra 'Ativo'.
|
||||||
|
* Inverso explícito do softDeletePatient. Uso: botão "Restaurar"
|
||||||
|
* que aparece nas ações quando p.status === 'Arquivado'.
|
||||||
|
*/
|
||||||
|
export async function restorePatient(id, { tenantId } = {}) {
|
||||||
|
if (!id) throw new Error('id obrigatório');
|
||||||
|
assertTenantId(tenantId);
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('patients')
|
||||||
|
.update({ status: 'Ativo' })
|
||||||
|
.eq('id', id)
|
||||||
|
.eq('tenant_id', tenantId);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Groups
|
// Groups
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -53,12 +53,9 @@ const EMBED_MAP = {
|
|||||||
icon: 'pi pi-file-edit',
|
icon: 'pi pi-file-edit',
|
||||||
comp: defineAsyncComponent(() => import('@/features/documents/DocumentTemplatesPage.vue'))
|
comp: defineAsyncComponent(() => import('@/features/documents/DocumentTemplatesPage.vue'))
|
||||||
},
|
},
|
||||||
'agendamentos-recebidos': {
|
// 'agendamentos-recebidos' migrou pra Melissa Page nativa
|
||||||
label: 'Agendamentos recebidos',
|
// (MelissaAgendamentosRecebidos.vue) — segue o blueprint
|
||||||
desc: 'Solicitações vindas do agendador online à espera de confirmação.',
|
// melissa-table-page-blueprint.md. Removido do embed map.
|
||||||
icon: 'pi pi-inbox',
|
|
||||||
comp: defineAsyncComponent(() => import('@/features/agenda/pages/AgendamentosRecebidosPage.vue'))
|
|
||||||
},
|
|
||||||
'online-scheduling': {
|
'online-scheduling': {
|
||||||
label: 'Agendador online',
|
label: 'Agendador online',
|
||||||
desc: 'Configure o link público pra pacientes solicitarem horários.',
|
desc: 'Configure o link público pra pacientes solicitarem horários.',
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import MelissaGrupos from './MelissaGrupos.vue';
|
|||||||
import MelissaConfiguracoes from './MelissaConfiguracoes.vue';
|
import MelissaConfiguracoes from './MelissaConfiguracoes.vue';
|
||||||
import MelissaEmbed from './MelissaEmbed.vue';
|
import MelissaEmbed from './MelissaEmbed.vue';
|
||||||
import MelissaCadastrosRecebidos from './MelissaCadastrosRecebidos.vue';
|
import MelissaCadastrosRecebidos from './MelissaCadastrosRecebidos.vue';
|
||||||
|
import MelissaAgendamentosRecebidos from './MelissaAgendamentosRecebidos.vue';
|
||||||
import MelissaMedicos from './MelissaMedicos.vue';
|
import MelissaMedicos from './MelissaMedicos.vue';
|
||||||
import MelissaEventoPanel from './MelissaEventoPanel.vue';
|
import MelissaEventoPanel from './MelissaEventoPanel.vue';
|
||||||
import { TOQUES, playToque } from './melissaToques';
|
import { TOQUES, playToque } from './melissaToques';
|
||||||
@@ -42,6 +43,7 @@ import { useMelissaPacientes } from './composables/useMelissaPacientes';
|
|||||||
import { useMelissaEventosHoje } from './composables/useMelissaEventos';
|
import { useMelissaEventosHoje } from './composables/useMelissaEventos';
|
||||||
import { useMelissaWhatsapp } from './composables/useMelissaWhatsapp';
|
import { useMelissaWhatsapp } from './composables/useMelissaWhatsapp';
|
||||||
import { useMelissaAgenda, MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
|
import { useMelissaAgenda, MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
|
||||||
|
import { useMelissaDockPins } from './composables/useMelissaDockPins';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||||
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
|
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
|
||||||
@@ -56,10 +58,53 @@ import { useNotificationStore } from '@/stores/notificationStore';
|
|||||||
import { useAjuda } from '@/composables/useAjuda';
|
import { useAjuda } from '@/composables/useAjuda';
|
||||||
import { useTopbarPlanMenu } from '@/composables/useTopbarPlanMenu';
|
import { useTopbarPlanMenu } from '@/composables/useTopbarPlanMenu';
|
||||||
|
|
||||||
// Pacientes ativos do tenant (real, via Supabase)
|
// Pacientes + eventos do dia.
|
||||||
const { pacientes: pacientesReais, loading: pacientesLoading, refetch: refetchPacientes } = useMelissaPacientes();
|
//
|
||||||
// Eventos reais de hoje — alimenta timeline + cards + busca + "Hoje há"
|
// PERF: quando o usuário entra direto numa seção (`/melissa/agenda`,
|
||||||
const { eventos: eventosHojeReais, refetch: refetchEventosHoje } = useMelissaEventosHoje();
|
// `/melissa/pacientes`...), o resumo fica tapado pelo conteúdo da seção.
|
||||||
|
// Carregar os dois loaders na hora competiria com as queries da seção
|
||||||
|
// (que é o que o user efetivamente vai ver). Adiamos os loaders do
|
||||||
|
// resumo pra rodarem em background via `requestIdleCallback` quando a
|
||||||
|
// rota inicial já tem seção. Quando o user fecha a seção (volta pro
|
||||||
|
// resumo), o cache provavelmente já tá quente — se não estiver, o
|
||||||
|
// loading aparece naturalmente.
|
||||||
|
//
|
||||||
|
// CACHE: composables usam stale-while-revalidate via melissaCacheStore.
|
||||||
|
// Reabertura do Melissa na mesma sessão SPA é instantânea.
|
||||||
|
// Snapshot da rota no setup pra detectar deep-link com seção já no boot.
|
||||||
|
// (`route` reativo é declarado mais abaixo, mas só precisamos do params
|
||||||
|
// inicial aqui — `setup` roda 1× por mount, params do router já estão
|
||||||
|
// resolvidos nesse ponto.)
|
||||||
|
const _hasInitialSecao = !!useRoute().params?.secao;
|
||||||
|
const {
|
||||||
|
pacientes: pacientesReais,
|
||||||
|
loading: pacientesLoading,
|
||||||
|
refetch: refetchPacientes,
|
||||||
|
fetchCached: fetchPacientesCached
|
||||||
|
} = useMelissaPacientes({ autoFetch: !_hasInitialSecao });
|
||||||
|
const {
|
||||||
|
eventos: eventosHojeReais,
|
||||||
|
refetch: refetchEventosHoje,
|
||||||
|
fetchCached: fetchEventosHojeCached
|
||||||
|
} = useMelissaEventosHoje({ autoFetch: !_hasInitialSecao });
|
||||||
|
|
||||||
|
// Defer manual quando a rota inicial é uma seção: agenda os fetches do
|
||||||
|
// resumo pra rodarem após a seção montar (idle callback) — fetchCached
|
||||||
|
// usa stale-while-revalidate, então não tomba o cache. setTimeout 200
|
||||||
|
// como fallback pra navegadores sem requestIdleCallback (Safari < 16).
|
||||||
|
if (_hasInitialSecao) {
|
||||||
|
const _idleFetch = () => {
|
||||||
|
fetchPacientesCached();
|
||||||
|
fetchEventosHojeCached();
|
||||||
|
};
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
if (typeof window.requestIdleCallback === 'function') {
|
||||||
|
window.requestIdleCallback(_idleFetch, { timeout: 1500 });
|
||||||
|
} else {
|
||||||
|
setTimeout(_idleFetch, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
// Catálogo de cards do resumo (extensível — novos cards entram aqui)
|
// Catálogo de cards do resumo (extensível — novos cards entram aqui)
|
||||||
@@ -129,14 +174,14 @@ const SECOES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Set de keys que renderizam via MelissaEmbed (Onda 1 — pages 1-coluna)
|
// Set de keys que renderizam via MelissaEmbed (Onda 1 — pages 1-coluna)
|
||||||
const MELISSA_EMBED_KEYS = ['financeiro', 'financeiro-lancamentos', 'documentos', 'documentos-templates', 'agendamentos-recebidos', 'online-scheduling', 'relatorios', 'notificacoes', 'link-externo'];
|
const MELISSA_EMBED_KEYS = ['financeiro', 'financeiro-lancamentos', 'documentos', 'documentos-templates', 'online-scheduling', 'relatorios', 'notificacoes', 'link-externo'];
|
||||||
|
|
||||||
// Slugs reservados pra páginas dedicadas (não-config) — agenda, pacientes,
|
// Slugs reservados pra páginas dedicadas (não-config) — agenda, pacientes,
|
||||||
// conversas etc. + as MELISSA_EMBED_KEYS. Usado no isConfigRoute pra
|
// conversas etc. + as MELISSA_EMBED_KEYS. Usado no isConfigRoute pra
|
||||||
// evitar colisão (ex: /melissa/agenda → MelissaAgenda, não config).
|
// evitar colisão (ex: /melissa/agenda → MelissaAgenda, não config).
|
||||||
const MELISSA_NON_CONFIG_SLUGS = new Set([
|
const MELISSA_NON_CONFIG_SLUGS = new Set([
|
||||||
'agenda', 'pacientes', 'compromissos', 'recorrencias', 'conversas',
|
'agenda', 'pacientes', 'compromissos', 'recorrencias', 'conversas',
|
||||||
'tags', 'grupos', 'cadastros-recebidos', 'medicos',
|
'tags', 'grupos', 'cadastros-recebidos', 'medicos', 'agendamentos-recebidos',
|
||||||
...MELISSA_EMBED_KEYS
|
...MELISSA_EMBED_KEYS
|
||||||
]);
|
]);
|
||||||
// Aliases "bonitos" + INLINE_KEYS reconhecidos pelo MelissaConfiguracoes.
|
// Aliases "bonitos" + INLINE_KEYS reconhecidos pelo MelissaConfiguracoes.
|
||||||
@@ -171,6 +216,17 @@ const secaoAberta = computed(() => {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Quando o usuário fecha a seção e volta pro resumo, garante que os
|
||||||
|
// dados estão prontos (caso o idle callback ainda não tenha disparado
|
||||||
|
// no fluxo deep-link). fetchCached é idempotente: cache hit → instant,
|
||||||
|
// cache miss → fetch real. Sem cache, não dispara nada estranho.
|
||||||
|
watch(secaoAberta, (newVal, oldVal) => {
|
||||||
|
if (oldVal && !newVal) {
|
||||||
|
fetchPacientesCached();
|
||||||
|
fetchEventosHojeCached();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function abrirSecao(key) {
|
function abrirSecao(key) {
|
||||||
// Fecha overlays paralelos pra evitar empilhamento
|
// Fecha overlays paralelos pra evitar empilhamento
|
||||||
workspaceOpen.value = false;
|
workspaceOpen.value = false;
|
||||||
@@ -183,6 +239,74 @@ function fecharSecao() {
|
|||||||
router.push({ name: 'Melissa', params: {} });
|
router.push({ name: 'Melissa', params: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pins dinâmicos do dock (híbrido: 4 fixos + 3 MRU) ──────────
|
||||||
|
const dockPins = useMelissaDockPins();
|
||||||
|
const pinContextMenu = ref(null);
|
||||||
|
const pinContextSlug = ref('');
|
||||||
|
|
||||||
|
// Toda vez que a seção muda, registra como "recente" no dock (se não
|
||||||
|
// for builtin nem pinned). Slugs de configuração (cfg-*) também viram
|
||||||
|
// recent — útil quando o user fica navegando entre páginas de config.
|
||||||
|
watch(secaoAberta, (slug) => {
|
||||||
|
if (slug) dockPins.pushRecent(slug);
|
||||||
|
});
|
||||||
|
|
||||||
|
function openPinContextMenu(event, slug) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
pinContextSlug.value = slug;
|
||||||
|
pinContextMenu.value?.show(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinContextMenuItems = computed(() => {
|
||||||
|
const slug = pinContextSlug.value;
|
||||||
|
if (!slug) return [];
|
||||||
|
const isPinned = dockPins.isPinned(slug);
|
||||||
|
const items = [];
|
||||||
|
if (isPinned) {
|
||||||
|
items.push({
|
||||||
|
label: 'Desafixar',
|
||||||
|
icon: 'pi pi-bookmark',
|
||||||
|
command: () => dockPins.unpin(slug)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
label: 'Fixar no dock',
|
||||||
|
icon: 'pi pi-bookmark-fill',
|
||||||
|
command: () => {
|
||||||
|
const result = dockPins.pin(slug);
|
||||||
|
if (!result.ok && result.reason === 'full') {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Limite atingido',
|
||||||
|
detail: `Você pode fixar até ${dockPins.MAX_PINNED} atalhos. Desafixe um pra liberar espaço.`,
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.push({ separator: true });
|
||||||
|
items.push({
|
||||||
|
label: 'Remover do dock',
|
||||||
|
icon: 'pi pi-times',
|
||||||
|
command: () => dockPins.remove(slug)
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve label/ícone a partir do slug pra renderizar no pin.
|
||||||
|
// Usa o catálogo SECOES (já existente) — fallback genérico se slug
|
||||||
|
// for de uma rota cfg-* (configurações embeds).
|
||||||
|
function pinMeta(slug) {
|
||||||
|
const fromSecoes = SECOES[slug];
|
||||||
|
if (fromSecoes) return { label: fromSecoes.label, icon: fromSecoes.icon };
|
||||||
|
if (slug?.startsWith('cfg-')) {
|
||||||
|
return { label: 'Configuração', icon: 'pi pi-cog' };
|
||||||
|
}
|
||||||
|
return { label: slug, icon: 'pi pi-bookmark' };
|
||||||
|
}
|
||||||
|
|
||||||
// Prefs de layout/UI (toque, fundo, opacidade, formato hora)
|
// Prefs de layout/UI (toque, fundo, opacidade, formato hora)
|
||||||
// TODO: migrar pra configs do tenant — hoje só localStorage pra survive refresh
|
// TODO: migrar pra configs do tenant — hoje só localStorage pra survive refresh
|
||||||
const LAYOUT_STORAGE_KEY = 'melissa.layout.v1';
|
const LAYOUT_STORAGE_KEY = 'melissa.layout.v1';
|
||||||
@@ -619,14 +743,23 @@ function onConcluir() { updateEventoStatus('realizado', 'Sessão marcada como r
|
|||||||
function onFaltou() { updateEventoStatus('faltou', 'Marcada como falta'); }
|
function onFaltou() { updateEventoStatus('faltou', 'Marcada como falta'); }
|
||||||
function onCancelar() { updateEventoStatus('cancelado', 'Evento cancelado'); }
|
function onCancelar() { updateEventoStatus('cancelado', 'Evento cancelado'); }
|
||||||
|
|
||||||
function onWhatsapp() {
|
async function onWhatsapp() {
|
||||||
const ev = eventoSelecionado.value;
|
const ev = eventoSelecionado.value;
|
||||||
if (!ev?.patient_id) {
|
if (!ev?.patient_id) {
|
||||||
toast.add({ severity: 'warn', summary: 'Paciente sem id', life: 2200 });
|
toast.add({ severity: 'warn', summary: 'Paciente sem id', life: 2200 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
conversationDrawerStore.openForPatient(String(ev.patient_id));
|
const patientId = String(ev.patient_id);
|
||||||
fecharEvento();
|
fecharEvento();
|
||||||
|
// openForPatient é async — busca thread existente ou cria stub.
|
||||||
|
// Se paciente não tem telefone (ou outro erro), o store seta `error`
|
||||||
|
// e mantém `isOpen=false` silenciosamente. Aguardamos pra dar feedback.
|
||||||
|
await conversationDrawerStore.openForPatient(patientId);
|
||||||
|
if (!conversationDrawerStore.isOpen) {
|
||||||
|
const detail = conversationDrawerStore.error?.message || 'Não foi possível abrir a conversa.';
|
||||||
|
toast.add({ severity: 'warn', summary: 'WhatsApp', detail, life: 3500 });
|
||||||
|
conversationDrawerStore.error = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pending agenda actions ────────────────────────────────────
|
// ── Pending agenda actions ────────────────────────────────────
|
||||||
@@ -666,8 +799,31 @@ function onAbrirProntuario() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onHistoricoSessoes() {
|
function onHistoricoSessoes() {
|
||||||
|
const ev = eventoSelecionado.value;
|
||||||
|
if (!ev?.patient_id) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Paciente não vinculado', life: 2200 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const patientId = ev.patient_id;
|
||||||
fecharEvento();
|
fecharEvento();
|
||||||
_callOnAgenda((agenda) => agenda.setView?.('lista'));
|
// Abre a Agenda (se não estiver) e dispara o overlay "Todas as sessões"
|
||||||
|
// filtrado pelo paciente — mesmo fluxo do botão Sessões em .ma-dock-actions.
|
||||||
|
_callOnAgenda((agenda) => agenda.openSessoesPaciente?.(patientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editar cadastro do paciente vinculado à sessão. Difere de onEditEvento
|
||||||
|
// (que abre o AgendaEventDialog pra mexer na sessão em si). Reusa o
|
||||||
|
// PatientCadastroDialog já montado dentro do MelissaAgenda via método
|
||||||
|
// exposto openEditPatient.
|
||||||
|
function onEditPaciente() {
|
||||||
|
const ev = eventoSelecionado.value;
|
||||||
|
if (!ev?.patient_id) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Paciente não vinculado', life: 2200 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const patientId = ev.patient_id;
|
||||||
|
fecharEvento();
|
||||||
|
_callOnAgenda((agenda) => agenda.openEditPatient?.(patientId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onEditEvento() {
|
async function onEditEvento() {
|
||||||
@@ -1178,10 +1334,20 @@ function onKeydown(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (e.key !== 'Escape') return;
|
if (e.key !== 'Escape') return;
|
||||||
|
// Bail-out: se há um overlay PrimeVue (Dialog/Drawer) aberto, deixa
|
||||||
|
// o componente cuidar do ESC pelo seu próprio closeOnEscape. Sem
|
||||||
|
// este guard, o ESC fechava o overlay E uma camada do cascade —
|
||||||
|
// o usuário via "duas janelas fechando" (drawer WhatsApp + agenda,
|
||||||
|
// AgendaEventDialog + evento panel, ConfirmDialog + workspace, etc).
|
||||||
|
if (document.querySelector('.p-dialog-mask, .p-drawer-mask')) return;
|
||||||
|
|
||||||
|
// Cascata top-down do z-order — ESC fecha SOMENTE a camada do topo.
|
||||||
|
// Ordem (mais sobreposto → menos): central modal > evento panel >
|
||||||
|
// cronômetro > seção (agenda/pacientes/etc) > workspace > settings.
|
||||||
if (centralOpen.value) centralOpen.value = false;
|
if (centralOpen.value) centralOpen.value = false;
|
||||||
else if (secaoAberta.value) fecharSecao();
|
|
||||||
else if (eventoSelecionado.value) fecharEvento();
|
else if (eventoSelecionado.value) fecharEvento();
|
||||||
else if (cronoVisible.value) fecharCronometro();
|
else if (cronoVisible.value) fecharCronometro();
|
||||||
|
else if (secaoAberta.value) fecharSecao();
|
||||||
else if (workspaceOpen.value) closeWorkspace();
|
else if (workspaceOpen.value) closeWorkspace();
|
||||||
else if (settingsOpen.value) settingsOpen.value = false;
|
else if (settingsOpen.value) settingsOpen.value = false;
|
||||||
}
|
}
|
||||||
@@ -1774,7 +1940,7 @@ function onKeydown(e) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="dock-pin"
|
class="dock-pin"
|
||||||
v-tooltip.top="'WhatsApp'"
|
v-tooltip.top="'WhatsApp'"
|
||||||
:class="{ 'dock-pin--active': secaoAtual === 'conversas' }"
|
:class="{ 'dock-pin--active': secaoAberta === 'conversas' }"
|
||||||
@click="abrirSecao('conversas')"
|
@click="abrirSecao('conversas')"
|
||||||
>
|
>
|
||||||
<i class="pi pi-whatsapp" />
|
<i class="pi pi-whatsapp" />
|
||||||
@@ -1784,6 +1950,46 @@ function onKeydown(e) {
|
|||||||
:title="`${whatsappPendente.count} mensagens não lidas`"
|
:title="`${whatsappPendente.count} mensagens não lidas`"
|
||||||
>{{ whatsappPendente.count > 99 ? '99+' : whatsappPendente.count }}</span>
|
>{{ whatsappPendente.count > 99 ? '99+' : whatsappPendente.count }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Divisor entre builtins e pins dinâmicos. Só aparece se
|
||||||
|
o user tem pelo menos 1 pin (fixo ou recente). -->
|
||||||
|
<div
|
||||||
|
v-if="dockPins.pinned.value.length || dockPins.recent.value.length"
|
||||||
|
class="dock-divider"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Pins fixados pelo user (max 4). Click direito → menu
|
||||||
|
desafixar/remover. Hover mostra subtle ring. -->
|
||||||
|
<button
|
||||||
|
v-for="slug in dockPins.pinned.value" :key="`p-${slug}`"
|
||||||
|
type="button"
|
||||||
|
class="dock-pin dock-pin--user"
|
||||||
|
:class="{ 'dock-pin--active': secaoAberta === slug }"
|
||||||
|
v-tooltip.top="pinMeta(slug).label + ' (fixado)'"
|
||||||
|
@click="abrirSecao(slug)"
|
||||||
|
@contextmenu="openPinContextMenu($event, slug)"
|
||||||
|
>
|
||||||
|
<i :class="pinMeta(slug).icon" />
|
||||||
|
<span class="dock-pin__pinned-mark" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Pins MRU (max 3) — empurrados pelas últimas seções abertas.
|
||||||
|
Visual mais leve (opacity menor) pra destacar dos fixos. -->
|
||||||
|
<button
|
||||||
|
v-for="slug in dockPins.recent.value" :key="`r-${slug}`"
|
||||||
|
type="button"
|
||||||
|
class="dock-pin dock-pin--recent"
|
||||||
|
:class="{ 'dock-pin--active': secaoAberta === slug }"
|
||||||
|
v-tooltip.top="pinMeta(slug).label + ' (recente — clique direito pra fixar)'"
|
||||||
|
@click="abrirSecao(slug)"
|
||||||
|
@contextmenu="openPinContextMenu($event, slug)"
|
||||||
|
>
|
||||||
|
<i :class="pinMeta(slug).icon" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Menu de contexto dos pins dinâmicos (popup global) -->
|
||||||
|
<Menu ref="pinContextMenu" :model="pinContextMenuItems" :popup="true" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -1812,7 +2018,8 @@ function onKeydown(e) {
|
|||||||
@faltou="onFaltou"
|
@faltou="onFaltou"
|
||||||
@cancelar="onCancelar"
|
@cancelar="onCancelar"
|
||||||
@remarcar="onRemarcar"
|
@remarcar="onRemarcar"
|
||||||
@edit="onEditEvento"
|
@edit-sessao="onEditEvento"
|
||||||
|
@edit-paciente="onEditPaciente"
|
||||||
@abrir-prontuario="onAbrirProntuario"
|
@abrir-prontuario="onAbrirProntuario"
|
||||||
@whatsapp="onWhatsapp"
|
@whatsapp="onWhatsapp"
|
||||||
@historico="onHistoricoSessoes"
|
@historico="onHistoricoSessoes"
|
||||||
@@ -1910,6 +2117,8 @@ function onKeydown(e) {
|
|||||||
@close="fecharSecao"
|
@close="fecharSecao"
|
||||||
@patient-created="refetchPacientes"
|
@patient-created="refetchPacientes"
|
||||||
@goto-agenda="abrirSecao('agenda')"
|
@goto-agenda="abrirSecao('agenda')"
|
||||||
|
@goto-grupos="abrirSecao('grupos')"
|
||||||
|
@goto-tags="abrirSecao('tags')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MelissaCompromissos
|
<MelissaCompromissos
|
||||||
@@ -1942,6 +2151,11 @@ function onKeydown(e) {
|
|||||||
@close="fecharSecao"
|
@close="fecharSecao"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MelissaAgendamentosRecebidos
|
||||||
|
v-if="layoutReady && secaoAberta === 'agendamentos-recebidos'"
|
||||||
|
@close="fecharSecao"
|
||||||
|
/>
|
||||||
|
|
||||||
<MelissaMedicos
|
<MelissaMedicos
|
||||||
v-if="layoutReady && secaoAberta === 'medicos'"
|
v-if="layoutReady && secaoAberta === 'medicos'"
|
||||||
@close="fecharSecao"
|
@close="fecharSecao"
|
||||||
@@ -3177,6 +3391,42 @@ html:not(.app-dark) .melissa-dock .dock-pin:hover {
|
|||||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.16);
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Pins dinâmicos do dock (híbrido fixo + recente) ───────── */
|
||||||
|
/* Divisor entre builtins e dinâmicos. Fininho, atravessa o gap. */
|
||||||
|
.dock-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
align-self: center;
|
||||||
|
background: var(--m-border, rgba(255, 255, 255, 0.18));
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
html:not(.app-dark) .dock-divider {
|
||||||
|
background: rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pin fixado pelo user — pequena marca no canto pra diferenciar do recente. */
|
||||||
|
.dock-pin--user .dock-pin__pinned-mark {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--p-primary-color) 70%, white);
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--p-primary-color) 50%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pin recente (MRU) — visualmente mais leve pra denotar transitoriedade.
|
||||||
|
Fica entre opacity total (active/hover) e ~70% no estado normal. */
|
||||||
|
.dock-pin--recent {
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
.dock-pin--recent:hover,
|
||||||
|
.dock-pin--recent.dock-pin--active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Skeleton loading utilitário (global pra atravessar scoped CSS dos
|
/* ─── Skeleton loading utilitário (global pra atravessar scoped CSS dos
|
||||||
componentes filhos). Use a classe .melissa-skeleton em qualquer
|
componentes filhos). Use a classe .melissa-skeleton em qualquer
|
||||||
container — vira um placeholder com shimmer suave.
|
container — vira um placeholder com shimmer suave.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
|||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
import { useTenantStore } from '@/stores/tenantStore';
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
|
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
|
||||||
|
|
||||||
// Mesma lógica da PatientsListPage.vue — DB pode ter valores variados
|
// Mesma lógica da PatientsListPage.vue — DB pode ter valores variados
|
||||||
function normalizeStatus(s) {
|
function normalizeStatus(s) {
|
||||||
@@ -37,7 +38,9 @@ function normalizeStatus(s) {
|
|||||||
*/
|
*/
|
||||||
export function useMelissaPacientes(opts = {}) {
|
export function useMelissaPacientes(opts = {}) {
|
||||||
const onlyActive = opts.onlyActive !== false; // default true (compat)
|
const onlyActive = opts.onlyActive !== false; // default true (compat)
|
||||||
|
const autoFetch = opts.autoFetch !== false; // default true (compat)
|
||||||
const tenantStore = useTenantStore();
|
const tenantStore = useTenantStore();
|
||||||
|
const cache = useMelissaCacheStore();
|
||||||
|
|
||||||
const pacientes = ref([]);
|
const pacientes = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -46,17 +49,51 @@ export function useMelissaPacientes(opts = {}) {
|
|||||||
|
|
||||||
async function ensureUid() {
|
async function ensureUid() {
|
||||||
if (uid.value) return uid.value;
|
if (uid.value) return uid.value;
|
||||||
|
// Fast path: session do storage local (<10ms vs ~80ms do getUser)
|
||||||
|
const { data: ses } = await supabase.auth.getSession();
|
||||||
|
if (ses?.session?.user?.id) {
|
||||||
|
uid.value = ses.session.user.id;
|
||||||
|
return uid.value;
|
||||||
|
}
|
||||||
const { data, error: err } = await supabase.auth.getUser();
|
const { data, error: err } = await supabase.auth.getUser();
|
||||||
if (err) return null;
|
if (err) return null;
|
||||||
uid.value = data?.user?.id || null;
|
uid.value = data?.user?.id || null;
|
||||||
return uid.value;
|
return uid.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPacientes() {
|
async function _doFetch(userId, tid, cacheKey) {
|
||||||
const userId = await ensureUid();
|
const { data, error: err } = await supabase
|
||||||
|
.from('patients')
|
||||||
|
.select('id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at, data_nascimento')
|
||||||
|
.eq('owner_id', userId)
|
||||||
|
.eq('tenant_id', tid)
|
||||||
|
.order('nome_completo', { ascending: true })
|
||||||
|
.limit(1000);
|
||||||
|
|
||||||
// Garante que o tenantStore foi hidratado (preview misc não passa por
|
if (err) throw err;
|
||||||
// guard de auth, então o store pode estar vazio mesmo com user logado)
|
|
||||||
|
const todos = (data || []).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
nome: r.nome_completo || '',
|
||||||
|
email: r.email_principal || '',
|
||||||
|
telefone: r.telefone || '',
|
||||||
|
avatar_url: r.avatar_url || null,
|
||||||
|
status: normalizeStatus(r.status),
|
||||||
|
last_attended_at: r.last_attended_at || null,
|
||||||
|
created_at: r.created_at || null,
|
||||||
|
data_nascimento: r.data_nascimento || null
|
||||||
|
}));
|
||||||
|
|
||||||
|
const finalList = onlyActive ? todos.filter((p) => p.status === 'Ativo') : todos;
|
||||||
|
cache.set('pacientesTimeline', finalList, cacheKey);
|
||||||
|
pacientes.value = finalList;
|
||||||
|
return finalList;
|
||||||
|
}
|
||||||
|
|
||||||
|
// useCache=true (boot/auto): hidrata do cache se válido + revalida em background.
|
||||||
|
// useCache=false (refetch após mutation): força query nova, descarta cache.
|
||||||
|
async function _fetch({ useCache = true } = {}) {
|
||||||
|
const userId = await ensureUid();
|
||||||
if (typeof tenantStore.ensureLoaded === 'function') {
|
if (typeof tenantStore.ensureLoaded === 'function') {
|
||||||
await tenantStore.ensureLoaded();
|
await tenantStore.ensureLoaded();
|
||||||
}
|
}
|
||||||
@@ -67,35 +104,28 @@ export function useMelissaPacientes(opts = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${userId}:${tid}:${onlyActive ? 'a' : 'all'}`;
|
||||||
|
|
||||||
|
if (useCache) {
|
||||||
|
const cached = cache.get('pacientesTimeline', cacheKey, MELISSA_CACHE_TTL.pacientesTimeline);
|
||||||
|
if (cached) {
|
||||||
|
pacientes.value = cached;
|
||||||
|
_doFetch(userId, tid, cacheKey).catch((e) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[useMelissaPacientes] revalidate', e);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Force: invalida o slot pra outras instâncias (se houver) também
|
||||||
|
// pegarem fresh na próxima leitura.
|
||||||
|
cache.invalidate('pacientesTimeline');
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Não filtra status no SQL — DB tem valores inconsistentes
|
await _doFetch(userId, tid, cacheKey);
|
||||||
// ('ativo', 'Ativo', 'active', null...). Normaliza e filtra no client.
|
|
||||||
const { data, error: err } = await supabase
|
|
||||||
.from('patients')
|
|
||||||
.select('id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at, data_nascimento')
|
|
||||||
.eq('owner_id', userId)
|
|
||||||
.eq('tenant_id', tid)
|
|
||||||
.order('nome_completo', { ascending: true })
|
|
||||||
.limit(1000);
|
|
||||||
|
|
||||||
if (err) throw err;
|
|
||||||
|
|
||||||
const todos = (data || []).map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
nome: r.nome_completo || '',
|
|
||||||
email: r.email_principal || '',
|
|
||||||
telefone: r.telefone || '',
|
|
||||||
avatar_url: r.avatar_url || null,
|
|
||||||
status: normalizeStatus(r.status),
|
|
||||||
last_attended_at: r.last_attended_at || null,
|
|
||||||
created_at: r.created_at || null,
|
|
||||||
data_nascimento: r.data_nascimento || null
|
|
||||||
}));
|
|
||||||
|
|
||||||
pacientes.value = onlyActive ? todos.filter((p) => p.status === 'Ativo') : todos;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e?.message || 'Erro ao carregar pacientes';
|
error.value = e?.message || 'Erro ao carregar pacientes';
|
||||||
pacientes.value = [];
|
pacientes.value = [];
|
||||||
@@ -106,12 +136,15 @@ export function useMelissaPacientes(opts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchPacientes);
|
if (autoFetch) onMounted(() => _fetch({ useCache: true }));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pacientes,
|
pacientes,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetch: fetchPacientes
|
// refetch força query nova (uso em handlers pós-mutation: criar/editar/deletar).
|
||||||
|
refetch: () => _fetch({ useCache: false }),
|
||||||
|
// fetchCached usa stale-while-revalidate (uso em defer/idle callback).
|
||||||
|
fetchCached: () => _fetch({ useCache: true })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ export function useMelissaPacientesAside(opts) {
|
|||||||
|
|
||||||
async function _ensureUid() {
|
async function _ensureUid() {
|
||||||
if (_uid.value) return _uid.value;
|
if (_uid.value) return _uid.value;
|
||||||
|
// Fast path: session já hidratada no storage (<10ms).
|
||||||
|
const { data: ses } = await supabase.auth.getSession();
|
||||||
|
const uid = ses?.session?.user?.id;
|
||||||
|
if (uid) {
|
||||||
|
_uid.value = uid;
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
// Fallback: round-trip pro auth server (cold start).
|
||||||
const { data, error: err } = await supabase.auth.getUser();
|
const { data, error: err } = await supabase.auth.getUser();
|
||||||
if (err) return null;
|
if (err) return null;
|
||||||
_uid.value = data?.user?.id || null;
|
_uid.value = data?.user?.id || null;
|
||||||
|
|||||||
Reference in New Issue
Block a user