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:
Leonardo
2026-05-06 09:13:53 -03:00
parent 6d9b36d592
commit 269b531158
10 changed files with 4839 additions and 316 deletions
+812
View File
@@ -0,0 +1,812 @@
# Blueprint — Melissa Table Page
Padrão de página Melissa que apresenta uma **coleção tabular** (intake
requests, médicos, recorrências, compromissos, etc.) com 2 modos de
visualização (lista/grade), filtros laterais coloridos, busca, e
DataTable com paginação + coluna de ação fixa.
Validado em `src/layout/melissa/MelissaCadastrosRecebidos.vue`
(referência canônica). Estende o
[`melissa-page-blueprint.md`](./melissa-page-blueprint.md) — leia aquele
primeiro pra entender a estrutura macro (`.xx-page` / `.xx-body` /
`.xx-side` / `.xx-main`, drawer mobile, header).
---
## 1. Princípio
Página de coleção = **sidebar de filtros + coluna principal com
toolbar + visualização tabular**. O user controla:
- **Busca** (texto livre — nome / email / telefone / etc.)
- **Filtro de status** (mutualmente exclusivo, com botão "Limpar")
- **Modo de visualização** (lista densa via DataTable ou grade de cards)
- **Paginação** (10/20/50/100 por página)
A linha tem 1 ação primária visível (botão pencil) que abre um Dialog
com detalhes + ações secundárias (rejeitar, converter, etc.).
---
## 2. Estrutura do template
Segue a macro do `melissa-page-blueprint.md` (drawer + backdrop + page
+ header + body com aside Teleportada). Sobre essa base, esta blueprint
adiciona um **subheader explicativo** (logo abaixo do header, antes do
body) e a estrutura tabular dentro da `.xx-main`:
```vue
<section class="xx-page">
<header class="xx-page__head"></header>
<!-- Subheader explicativo 1 frase de contexto sobre o que essa
página faz, com palavras-chave em <strong>. Diferencia páginas
que têm layout idêntico (ex: Cadastros Recebidos vs.
Agendamentos Recebidos). -->
<div class="xx-subheader">
<i class="pi pi-info-circle xx-subheader__icon" />
<span class="xx-subheader__text">
Texto descritivo da página em 1-2 frases. Use
<strong>palavras-chave</strong> em negrito pra destacar as
ações disponíveis (autorize, recuse, converta, etc.).
</span>
</div>
<div class="xx-body">sidebar + main</div>
</section>
```
A diferença dentro da `.xx-main`:
```vue
<div class="xx-main">
<!-- A) Toolbar busca + view toggle -->
<div class="xx-toolbar">
<div class="xx-search">
<i class="pi pi-search xx-search__icon" />
<input v-model="busca" class="xx-search__input" placeholder="…" />
<button v-if="busca" class="xx-search__clear" @click="busca = ''">
<i class="pi pi-times" />
</button>
</div>
<div class="xx-view-toggle" role="group" aria-label="Visualização">
<button :class="{ 'is-active': viewMode === 'list' }" @click="setViewMode('list')">
<i class="pi pi-list" />
</button>
<button :class="{ 'is-active': viewMode === 'grid' }" @click="setViewMode('grid')">
<i class="pi pi-th-large" />
</button>
</div>
</div>
<!-- B) View Lista (DataTable) -->
<DataTable v-if="viewMode === 'list'" />
<!-- C) View Grade (cards em CSS grid + Paginator standalone) -->
<div v-else-if="viewMode === 'grid'" class="xx-grid-wrap">
<div class="xx-grid">
<div v-for="r in pagedItems" class="xx-grid__card" role="button" tabindex="0" @click="openDetails(r)"></div>
</div>
<Paginator class="xx-paginator" :rows="rowsXX" :first="firstXX" />
</div>
</div>
```
E na sidebar (`.xx-side`), ao invés de Hoje/Pacientes/Mini-cal, tem:
```vue
<aside class="xx-side">
<!-- Stats (4 contadores em grid 2x2) -->
<div class="xx-w xx-w--side">
<div class="xx-w__head">
<span class="xx-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
</div>
<div class="xx-stats">
<div v-for="s in stats" class="xx-stat" :class="`is-${s.cls}`">
<div class="xx-stat__val">{{ s.value }}</div>
<div class="xx-stat__lbl">{{ s.label }}</div>
</div>
</div>
</div>
<!-- Filtros (botões coloridos por status + Limpar filtro) -->
<div class="xx-w xx-w--side">
<div class="xx-w__head">
<span class="xx-w__title"><i class="pi pi-filter" /> Status</span>
<span v-if="statusFilter" class="xx-w__count">1</span>
</div>
<div class="xx-side__list">
<button
v-for="o in STATUS_FILTER_OPTIONS"
class="xx-side__item"
:class="[`is-${o.key}`, { 'is-active': statusFilter === o.key }]"
@click="toggleStatusFilter(o.key)"
>
<i :class="o.icon" /><span>{{ o.label }}</span>
</button>
<Transition name="xx-clear">
<button v-if="statusFilter" class="xx-side__item is-clear" @click="statusFilter = ''">
<i class="pi pi-filter-slash" /><span>Limpar filtro</span>
</button>
</Transition>
</div>
</div>
</aside>
```
---
## 3. Estado JS (script setup)
```js
// ── Filtros + busca ──
const busca = ref('');
const statusFilter = ref('');
function toggleStatusFilter(s) {
statusFilter.value = statusFilter.value === s ? '' : s;
}
// ── Computeds derivados ──
const stats = computed(() => {/* contadores por status */});
const filtered = computed(() => {/* aplica busca + statusFilter sobre rows */});
// ── Paginação compartilhada (DataTable + grid) ──
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const rowsXX = ref(10);
const firstXX = ref(0);
function onPage(event) {
firstXX.value = event.first;
rowsXX.value = event.rows;
}
watch([busca, statusFilter], () => { firstXX.value = 0; }); // reset à pg 1
// ── View mode persistido ──
const VIEW_MODE_KEY = 'xx.viewMode.v1';
const viewMode = ref('list');
try {
const saved = localStorage.getItem(VIEW_MODE_KEY);
if (saved === 'list' || saved === 'grid') viewMode.value = saved;
} catch (_) {}
function setViewMode(m) {
if (m !== 'list' && m !== 'grid') return;
viewMode.value = m;
try { localStorage.setItem(VIEW_MODE_KEY, m); } catch (_) {}
}
// ── Slice da grid (DataTable pagina internamente) ──
const pagedItems = computed(() =>
filtered.value.slice(firstXX.value, firstXX.value + rowsXX.value)
);
// ── Row click + ação ──
function onRowClick(event) { if (event?.data) openDetails(event.data); }
function rowStatusClass(data) { return statusClass(data?.status); }
```
---
## 4. DataTable (view Lista) — props canônicas
```vue
<DataTable
v-if="viewMode === 'list'"
:value="filtered"
:loading="loading"
dataKey="id"
paginator
:rows="rowsXX"
:first="firstXX"
:rowsPerPageOptions="PAGE_SIZE_OPTIONS"
paginatorTemplate="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
currentPageReportTemplate="{first}{last} de {totalRecords}"
:rowClass="rowStatusClass"
selectionMode="single"
scrollable
scrollHeight="flex"
tableStyle="min-width: 640px"
class="xx-table"
@row-click="onRowClick"
@page="onPage"
>
<Column header="Paciente" style="min-width: 220px">
<template #body="{ data }">avatar + nome + badge</template>
</Column>
<Column header="Contato" style="min-width: 220px">
<template #body="{ data }">email + tel</template>
</Column>
<Column header="Recebido" style="width: 130px">
<template #body="{ data }">tempo relativo</template>
</Column>
<!-- Coluna de ação fixa (frozen à direita) -->
<Column
header=""
:style="{ width: '60px', maxWidth: '60px', minWidth: '60px' }"
frozen
alignFrozen="right"
>
<template #body="{ data }">
<button class="xx-row__action" @click.stop="openDetails(data)">
<i class="pi pi-pencil" />
</button>
</template>
</Column>
<template #empty>empty state contextual</template>
<template #loading>spinner inline</template>
</DataTable>
```
### Props críticas explicadas
| Prop | Por quê |
|---|---|
| `:loading="loading"` | Overlay nativo do PrimeVue + slot `#loading` custom — substitui skeleton manual. |
| `paginator + :rows + :first + @page` | Paginator embutido controlado; `firstXX` permite resetar à página 1 quando filtros mudam. |
| `paginatorTemplate="RowsPerPageDropdown First… Last…"` | Ordem do exemplo PrimeVue 4: dropdown ANTES dos navegadores; CurrentPageReport no meio. |
| `currentPageReportTemplate="{first}{last} de {totalRecords}"` | i18n PT-BR. |
| `:rowClass="rowStatusClass"` | Aplica `is-new` / `is-done` / `is-rejected` no `<tr>` → border-left colorido via CSS deep. |
| `selectionMode="single"` | Marca visualmente a row selecionada; `@row-click` abre o dialog. |
| `scrollable + scrollHeight="flex"` | Tabela preenche o flex restante da `.xx-main` e scrolla internamente (vertical). |
| `tableStyle="min-width: 640px"` | Força scroll horizontal em mobile pra ativar a coluna frozen. |
| `dataKey="id"` | Identificação estável de rows pra seleção + reactive updates. |
### Coluna frozen — regras
- **Última `<Column>`** do template
- `frozen alignFrozen="right"` — fixa à direita
- `width: 60px, maxWidth: 60px, minWidth: 60px` — todas três pra evitar reflow durante scroll
- **`header=""`** vazio (icon do botão é auto-explicativo; tooltip cobre o resto)
- Botão interno usa **`@click.stop`** — sem isso, o row-click do DataTable também dispararia
---
## 5. View Grade (cards em CSS grid)
Quando `viewMode === 'grid'`, renderiza cards num grid responsivo com
Paginator standalone abaixo (compartilha state com a list view):
```vue
<div v-else-if="viewMode === 'grid'" class="xx-grid-wrap">
<div v-if="loading && filtered.length === 0" class="xx-grid__loading"></div>
<div v-else-if="filtered.length === 0" class="xx-empty"></div>
<div v-else class="xx-grid">
<div
v-for="r in pagedItems"
class="xx-grid__card"
:class="statusClass(r.status)"
role="button"
tabindex="0"
@click="openDetails(r)"
@keydown.enter.prevent="openDetails(r)"
@keydown.space.prevent="openDetails(r)"
>
<div class="xx-grid__top">
<span class="xx-card__avatar"></span>
<div class="xx-grid__top-right">
<span class="xx-card__badge" :class="statusClass(r.status)"></span>
<button class="xx-row__action" @click.stop="openDetails(r)">
<i class="pi pi-pencil" />
</button>
</div>
</div>
<div class="xx-grid__name"></div>
<div class="xx-grid__meta"></div>
<div class="xx-grid__time"></div>
</div>
</div>
<Paginator
v-if="filtered.length > 0"
class="xx-paginator"
:rows="rowsXX"
:totalRecords="filtered.length"
:first="firstXX"
:rowsPerPageOptions="PAGE_SIZE_OPTIONS"
template="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
currentPageReportTemplate="{first}{last} de {totalRecords}"
@page="onPage"
/>
</div>
```
### Por que `<div role="button">` em vez de `<button>`?
HTML não permite aninhar `<button>` em `<button>`. A grid card tem o
botão pencil interno, então o card precisa ser um `<div>` com
`role="button"`, `tabindex="0"` e handlers de teclado (`@keydown.enter`
+ `@keydown.space`) pra manter acessibilidade.
---
## 6. Tokens de surface (light/dark)
A consistência visual entre **header da tabela**, **coluna frozen**, e
**cards da sidebar** depende de usar o token certo:
| Elemento | Token | Light | Dark |
|---|---|---|---|
| `.xx-page` (background da página) | `var(--m-bg-medium)` | branco opaco | 88% opaco (glass) |
| `.xx-side` (sidebar) | `var(--m-bg-soft)` | surface-100 | 50% opaco |
| `.xx-w` (cards) | `var(--m-bg-medium)` | branco opaco | 88% opaco |
| `.xx-card` / `.xx-grid__card` (cards de linha) | `var(--m-bg-soft)` | surface-100 | 50% opaco |
| **Header da tabela** (`.p-datatable-thead > tr > th`) | **`var(--p-content-background)`** | **branco opaco** | **surface dark configurado** |
| **Coluna frozen** (header + body) | **`var(--p-content-background)`** | **branco opaco** | **surface dark configurado** |
| **Botão pencil** (bg) | **`var(--p-content-background)`** | **branco opaco** | **surface dark configurado** |
**Por que `--p-content-background` e não `--m-bg-medium`** pro frozen?
No dark mode `--m-bg-medium` tem 12% de transparência (efeito glass),
o que faz a coluna frozen vazar conteúdo de outras colunas durante
scroll horizontal. `--p-content-background` é 100% opaco em ambos os
modos e segue a config de surface do tema PrimeVue (token canônico de
"superfície de card").
---
## 7. Cores de status (semântica + paleta)
Tailwind 600 — fortes o bastante pra ler em ambos os modos:
| Status | Cor | RGB | Uso |
|---|---|---|---|
| Novo / Pendente | 🔵 azul | `rgb(37, 99, 235)` | item recém-chegado, ação requerida |
| Convertido / Concluído | 🟢 verde | `rgb(22, 163, 74)` | sucesso, finalizado |
| Rejeitado / Cancelado | 🔴 vermelho | `rgb(220, 38, 38)` | descartado, falha |
**Aplicação consistente** em 4 lugares por status:
1. **Stat value** (`.xx-stat.is-info / is-ok / is-danger`) — número colorido
2. **Filtro lateral** (`.xx-side__item.is-X`) — bg/border/ícone tinted (3 níveis: default 5% / hover 10% / active 16% + ring)
3. **Border-left da row** (`.xx-table tr.is-X`) — 3px sólido na cor
4. **Badge** (`.xx-card__badge.is-X`) — pill colorido no card/row
Variável `cls` no objeto stats:
```js
{ key: 'new', label: 'Novos', value: n, cls: n > 0 ? 'info' : 'neutral' },
{ key: 'converted', label: 'Convertidos', value: c, cls: 'ok' },
{ key: 'rejected', label: 'Rejeitados', value: r, cls: r > 0 ? 'danger' : 'neutral' },
```
**Não usar `is-warn`** (amarelo) pra "Novo" — semanticamente novo é
informativo, não alerta.
---
## 8. Filtro de status — botões + "Limpar filtro"
3 botões coloridos (Novo / Convertido / Rejeitado) + 4º botão
**"Limpar filtro"** que aparece com `<Transition name="xx-clear">`
quando algum filtro está ativo:
```css
.xx-side__item.is-clear {
margin-top: 4px;
background: var(--m-bg-soft);
border-color: var(--m-border);
color: var(--m-text-muted);
font-style: italic;
}
/* Fade + slide vertical + collapse de altura */
.xx-clear-enter-active,
.xx-clear-leave-active {
transition: opacity 220ms ease, transform 220ms ease,
max-height 220ms ease, margin-top 220ms ease;
overflow: hidden;
}
.xx-clear-enter-from,
.xx-clear-leave-to {
opacity: 0;
transform: translateY(-4px);
max-height: 0;
margin-top: 0;
}
.xx-clear-enter-to,
.xx-clear-leave-from {
opacity: 1;
transform: translateY(0);
max-height: 40px;
}
```
**Estilo neutro/itálico** (não colorido) pra distinguir dos 3 botões
de filtro coloridos. Ícone `pi pi-filter-slash`.
---
## 9. Subheader explicativo
Faixa estreita abaixo do `xx-page__head`, antes do `xx-body`. Tem 2
papéis:
1. **Diferenciar páginas** que têm o mesmo layout (Cadastros Recebidos
vs. Agendamentos Recebidos parecem visualmente idênticos sem isso)
2. **Resumir as ações** disponíveis pra reduzir cliques exploratórios
do user
```css
.xx-subheader {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 18px;
border-bottom: 1px solid var(--m-border);
background: var(--m-bg-soft);
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.45;
flex-shrink: 0;
}
.xx-subheader__icon {
color: var(--p-primary-color);
font-size: 0.92rem;
flex-shrink: 0;
margin-top: 1px;
}
.xx-subheader__text {
flex: 1;
min-width: 0;
}
.xx-subheader__text strong {
color: var(--m-text);
font-weight: 600;
}
```
### Convenção do texto
- 1-2 frases curtas (~20-30 palavras max)
- Inicia descrevendo a fonte/origem dos dados ("Solicitações vindas
de...", "Cadastros enviados por...")
- Termina enumerando as ações principais com `<strong>`
(`autorize`, `recuse`, `converta`)
- Tom direto, sem formalidade excessiva ("a gente cria o paciente
automaticamente" ✓ vs. "o sistema procederá com a criação" ✗)
- Ícone fixo: `pi pi-info-circle` em primary
### Exemplos validados
**Cadastros Recebidos:**
> Cadastros completos enviados por pacientes via formulário externo
> (link público). Revise os dados, **converta em paciente ativo** com
> 1 clique ou **rejeite** com motivo opcional.
**Agendamentos Recebidos:**
> Solicitações de horário vindas do agendador online à espera de ação.
> **Autorize** pra reservar o slot, **recuse** com motivo, ou
> **converta direto em sessão** — a gente cria o paciente
> automaticamente se ainda não existir.
---
## 10. Toolbar — busca + view toggle (no main column)
```css
.xx-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
}
.xx-search {
position: relative;
flex: 1;
min-width: 0;
}
.xx-search__input {
width: 100%;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
padding: 9px 36px 9px 34px; /* espaço pro ícone esq + clear dir */
border-radius: 10px;
}
.xx-search__icon { position: absolute; left: 12px; }
.xx-search__clear { position: absolute; right: 8px; }
/* Segmented control list/grid */
.xx-view-toggle {
flex-shrink: 0;
display: inline-flex;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 2px;
gap: 2px;
}
.xx-view-toggle__btn {
width: 32px; height: 32px;
background: transparent;
border: none;
border-radius: 8px;
}
.xx-view-toggle__btn.is-active {
background: var(--m-accent-soft);
color: var(--m-accent);
}
```
A **busca está no main column** (não na sidebar). Esta é a regra do
blueprint — sidebar só tem stats + filtros; busca + toggle ficam acima
da tabela.
---
## 11. DataTable — estilos de header, rows, paginator
```css
/* Wrapper que faz a DataTable ocupar o flex restante */
.xx-table {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.xx-table :deep(.p-datatable) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: transparent;
border: 1px solid var(--m-border);
border-radius: 10px;
overflow: hidden;
}
.xx-table :deep(.p-datatable-table-container) {
flex: 1;
min-height: 0;
background: transparent;
}
/* Header — totalmente transparente nos níveis externos, surface no <th> */
.xx-table :deep(.p-datatable-thead),
.xx-table :deep(.p-datatable-thead > tr) {
background: transparent !important;
}
.xx-table :deep(.p-datatable-thead > tr > th) {
background: var(--p-content-background) !important; /* canônico */
color: var(--m-text);
font-size: 0.78rem;
font-weight: 700; /* negrito */
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
}
/* Rows */
.xx-table :deep(.p-datatable-tbody > tr) {
background: transparent;
cursor: pointer;
border-left: 3px solid var(--m-border); /* default neutro */
transition: background-color 140ms ease;
}
.xx-table :deep(.p-datatable-tbody > tr > td) {
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
background: transparent;
vertical-align: middle;
}
.xx-table :deep(.p-datatable-tbody > tr:hover) { background: var(--m-bg-soft-hover); }
.xx-table :deep(.p-datatable-tbody > tr.p-datatable-row-selected) {
background: var(--m-accent-soft);
}
/* Border-left colorido por status — espelha .ma-sess do MelissaAgenda */
.xx-table :deep(tr.is-new) { border-left-color: rgb(37, 99, 235); }
.xx-table :deep(tr.is-done) { border-left-color: rgb(22, 163, 74); }
.xx-table :deep(tr.is-rejected) { border-left-color: rgb(220, 38, 38); opacity: 0.85; }
/* Coluna frozen — mesma surface do header */
.xx-table :deep(td.p-datatable-frozen-column),
.xx-table :deep(th.p-datatable-frozen-column) {
background: var(--p-content-background) !important;
box-shadow: -3px 0 6px -3px rgba(0, 0, 0, 0.18);
z-index: 1;
}
.xx-table :deep(.p-datatable-tbody > tr:hover td.p-datatable-frozen-column) {
background: var(--m-bg-soft-hover);
}
.xx-table :deep(.p-datatable-tbody > tr.p-datatable-row-selected td.p-datatable-frozen-column) {
background: var(--m-accent-soft);
}
/* Paginator integrado — centralizado, sem refresh à esquerda */
.xx-table :deep(.p-paginator) {
background: var(--m-bg-medium);
border: none;
border-top: 1px solid var(--m-border);
padding: 8px 12px;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
}
.xx-table :deep(.p-paginator-current) {
color: var(--m-text-muted);
font-size: 0.78rem;
background: transparent;
border: none;
}
.xx-table :deep(.p-paginator-page.p-paginator-page-selected) {
background: var(--m-accent-soft);
border-color: var(--m-accent-strong);
color: var(--m-accent);
}
/* Select de "rows per page" — bg transparente + label centralizado */
.xx-table :deep(.p-select) {
background: transparent;
border: 1px solid var(--m-border);
border-radius: 8px;
height: 30px;
display: inline-flex;
align-items: center;
}
.xx-table :deep(.p-select-label) {
padding: 0 8px;
color: var(--m-text);
font-size: 0.78rem;
display: flex;
align-items: center;
line-height: 1;
height: 100%;
background: transparent;
}
```
---
## 12. Botão de ação (pencil) — coluna fixa
```css
.xx-row__action {
width: 30px; height: 30px;
display: grid;
place-items: center;
background: var(--p-content-background); /* opaco — não vaza no scroll */
border: 1px solid color-mix(in srgb, var(--p-primary-color) 30%, var(--m-border));
color: var(--p-primary-color); /* primary do tema */
border-radius: 8px;
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.xx-row__action:hover {
background: color-mix(in srgb, var(--p-primary-color) 12%, var(--p-content-background));
border-color: var(--p-primary-color);
}
```
Reutilizável **na list view (dentro da coluna frozen)** e **na grid
view (dentro do `.xx-grid__top-right`)** — mesma classe, mesmo visual.
---
## 13. Empty / loading
Ambos via slot do DataTable + replicados na grid view:
```vue
<template #empty>
<div class="xx-empty">
<i class="pi pi-inbox xx-empty__icon" />
<div class="xx-empty__title">Nenhum cadastro encontrado</div>
<div class="xx-empty__hint">
<template v-if="busca || statusFilter">Ajuste os filtros pra ver mais.</template>
<template v-else> mensagem default contextual </template>
</div>
</div>
</template>
<template #loading>
<div class="xx-table__loading">
<i class="pi pi-spin pi-spinner" />
<span>Carregando</span>
</div>
</template>
```
```css
.xx-empty {
margin: 24px 0;
padding: 56px 28px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
color: var(--m-text-muted);
border: 2px dashed var(--m-border-strong);
border-radius: 12px;
background: color-mix(in srgb, var(--m-bg-soft) 40%, transparent);
gap: 8px;
}
.xx-empty__icon { font-size: 2rem; opacity: 0.6; }
.xx-empty__title { font-size: 0.92rem; font-weight: 600; }
.xx-empty__hint { font-size: 0.78rem; }
```
---
## 14. Mobile (<1024px)
A sidebar é Teleportada pro drawer (já documentado em
`melissa-page-blueprint.md`). Específico desta página:
```css
@media (max-width: 1023px) {
.xx-body { flex-direction: column; padding: 0; }
.xx-main { width: 100%; padding: 8px; }
.xx-page__title > span:first-of-type { display: none; }
.xx-menu-btn--mobile-only { display: inline-flex; }
/* IMPORTANTE: NÃO esconder colunas em mobile.
O scroll horizontal (via tableStyle min-width:640px) cuida
do overflow, e a coluna frozen "Ação" continua visível na
borda direita enquanto o user scrolla as outras. */
/* (sem display: none em qualquer th/td) */
/* Reset do bg/border-right da sidebar quando teleportada */
.xx-mobile-drawer__scroll .xx-side {
background: transparent;
border-right: none;
}
}
```
---
## 15. Acessibilidade
- `role="button" tabindex="0"` no card grid + `@keydown.enter.prevent` + `@keydown.space.prevent`
- `:focus-visible { outline: 2px solid var(--p-primary-color); outline-offset: 2px; }` nos cards
- `aria-label` em todos os icon-only buttons (pencil, view toggle, search clear)
- `v-tooltip` complementa visualmente (não substitui aria-label)
---
## 16. Checklist de adoção
Ao criar uma nova página tabular Melissa (ex: MelissaCompromissos):
- [ ] Renomeia `xx` → prefixo da página (`mco`, `mmd`, `mcv` etc.)
- [ ] Define `STATUS_FILTER_OPTIONS` com 3 keys/labels/icons
- [ ] Define `stats` computed retornando 4 itens (total + 3 status) com `cls` correto
- [ ] Implementa `filtered` computed (busca + statusFilter)
- [ ] Adiciona `rowsXX/firstXX/onPage` + watch reset
- [ ] Adiciona `viewMode` com persistência (`xx.viewMode.v1`)
- [ ] Adiciona `pagedItems` computed (slice pra grid)
- [ ] Adiciona `onRowClick + rowStatusClass`
- [ ] Adiciona `openDetails(r)` que abre o Dialog
- [ ] **Subheader explicativo** abaixo do `xx-page__head` (1-2 frases, fonte/origem + ações com `<strong>`, ícone `pi pi-info-circle`)
- [ ] Template: drawer + backdrop + page + header + **subheader** + body + sidebar (stats + filtros + clear) + main (toolbar + DataTable + grid)
- [ ] DataTable: `:loading + paginator + scrollable + scrollHeight="flex" + tableStyle="min-width: 640px"`
- [ ] Coluna frozen Ação: `width 60px + frozen alignFrozen="right"` + button pencil com `@click.stop`
- [ ] Grid card: `<div role="button" tabindex="0">` + handlers de teclado
- [ ] CSS: tokens `--p-content-background` em header, frozen, e botão pencil
- [ ] Mobile: NÃO esconder colunas; scroll horizontal via `tableStyle min-width`
---
## 17. Anti-patterns (NÃO fazer)
-**Busca na sidebar** — sempre no topo do main, ao lado do view toggle
-**`display: none` em colunas no mobile** — usar scroll horizontal + frozen
-**`<button>` envolvendo card no grid** — quebra HTML quando tem pencil interno; usar `<div role="button">`
-**`var(--m-bg-medium)` na coluna frozen no dark** — tem 12% transparência, vaza scroll. Usar `var(--p-content-background)`
-**`text-amber-300` Tailwind hardcoded** no ícone do header da página — usar `color: var(--p-primary-color)` via classe
-**`cls: 'warn'` pra "Novo"** — semanticamente errado (warn = aviso amarelo, novo = info azul)
-**Paginator com `#paginatorstart` slot duplicando refresh** — refresh já vive no header da página; centralizar o paginator (sem paginatorstart)
-**Skeleton manual + `carregandoInicial` na lista** — DataTable tem `:loading` nativo
-**`pageMCR + filteredPaginated` manual** — DataTable pagina internamente; só usa `firstXX/rowsXX` compartilhado
-**Border-left só em `is-new`** — todos os 3 status devem ter border-left colorido (consistência visual)
-**Misturar opacidade pesada (0.55, 0.75) com border colorido** — escolher uma estratégia; preferir border + opacidade leve (0.85 max)
---
## 18. Referência canônica
`src/layout/melissa/MelissaCadastrosRecebidos.vue` — implementação 1:1
deste blueprint. Quando dúvida, abrir esse arquivo lado-a-lado e
copiar o padrão exato (variáveis, ordem dos templates, tokens CSS).
Próximas adoções planejadas: `MelissaCompromissos`, `MelissaMedicos`,
`MelissaConversas`, `MelissaRecorrencias`, `MelissaTags`,
`MelissaGrupos` — todas seguem este blueprint.