Files
agenciapsilmno/blueprints/melissa-table-page-blueprint.md
T
Leonardo 269b531158 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>
2026-05-06 09:13:53 -03:00

28 KiB
Raw Blame History

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 — 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:
<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:

<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:

<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)

// ── 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

<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):

<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:

{ 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:

.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
.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)

.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

/* 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

.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:

<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>
.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:

@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.