Files
agenciapsilmno/blueprints/melissa-page-blueprint.md
T
Leonardo 86311ef305 Melissa: hub Configuracoes + Embed + 9 Pages novas + dialog blueprint dark
Sprints 04-29 + 04-30 acumuladas.

- MelissaConfiguracoes: hub 2-col com 6 grupos (Layout/Conta/Agenda/
  Financeiro/WhatsApp/Sistema), tudo embedado via MelissaEmbed.
- MelissaEmbed: wrapper generico que injeta layout-variant=melissa
  e remove cromos pra reaproveitar Pages tradicionais.
- 9 Melissa Pages novas: CadastrosRecebidos, Compromissos, Configuracoes,
  Conversas, Embed, Grupos, Medicos, Recorrencias, Tags.
- Dialog blueprint atualizado: bg-gray-100 (hardcoded light) ->
  bg-[var(--surface-ground)] (tema-aware). 22 dialogs migrados em
  9 arquivos. Anti-pattern documentado.
- PatientsCadastroPage: bug fix dropdown Grupo (optionLabel nome->name),
  toggle vertical/abas com persist localStorage, sticky margin-top.
- Surface picker no popover do MelissaLayout (8 swatches).
- useTopbarPlanMenu, useMelissaWhatsapp, useMelissaPacientesAside novos.
- Migration: status agenda remarcado/confirmado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:41:19 -03:00

750 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Blueprint — Melissa Page
Padrão de página fullscreen dentro do MelissaLayout (Direção B do redesign).
Use isto como molde pra cada nova página: Financeiro, WhatsApp, Prontuários
etc. Validado em `MelissaAgenda.vue` (referência canônica) e
`MelissaPacientes.vue`.
---
## 1. Princípio
Cada Melissa Page é um componente fullscreen que ocupa o viewport inteiro
(menos 6px de respiro + faixa do dock 76px no bottom), montado via
`v-if="layoutReady && secaoAberta === '<key>'"` no `MelissaLayout.vue`.
A página tem **uma área central de conteúdo principal** (a coluna que importa)
e **0N colunas auxiliares** (asides). No desktop convivem lado a lado; no
mobile (<lg), as auxiliares saem do layout e viajam pra um drawer
off-canvas via `<Teleport>`.
---
## 2. Estrutura macro do template
```
<template>
<!-- 1) Drawer host: SEMPRE fora do .xx-page, sibling. v-show controla
visibilidade pra ser um Teleport target válido em todo momento. -->
<aside class="xx-mobile-drawer" :class="{ 'is-open': drawerOpen }" v-show="isMobile">
<div id="xx-mobile-drawer-target" class="xx-mobile-drawer__scroll" />
</aside>
<!-- 2) Backdrop: irmão do drawer, animado via <Transition>. -->
<Transition name="xx-drawer-fade">
<div v-if="isMobile && drawerOpen" class="xx-mobile-drawer__backdrop" @click="fecharDrawer" />
</Transition>
<!-- 3) Página propriamente dita -->
<section class="xx-page">
<header class="xx-page__head">
<button class="xx-menu-btn xx-menu-btn--mobile-only" @click="toggleDrawer">
<i class="pi pi-bars" /><span>Menu</span>
</button>
<div class="xx-page__title">…</div>
<div class="xx-page__actions">…</div>
</header>
<div class="xx-body">
<!-- Asides: cada um vai pro drawer em mobile via Teleport -->
<Teleport to="#xx-mobile-drawer-target" :disabled="!isMobile">
<aside class="xx-side">…</aside>
</Teleport>
<!-- Conteúdo central — SEMPRE fica em .xx-page, nunca teleporta -->
<div class="xx-main">…</div>
<Teleport to="#xx-mobile-drawer-target" :disabled="!isMobile">
<aside class="xx-widgets">…</aside>
</Teleport>
</div>
</section>
</template>
```
> Substitua `xx-` pelo prefixo da página (`ma-` agenda, `mp-` pacientes,
> `mf-` financeiro, etc.).
---
## 3. Breakpoints
```
≥1280px (xl) → todas as colunas + filtros inline na toolbar
10241279 (lg→xl) → todas as colunas + filtros migram pro botão "Ações"
≤1023px (<lg) → 1 coluna (central 100%) + asides off-canvas no drawer
título da página some em <lg, "Menu" button aparece
```
Convenção: se a página não tem filtros/toolbar complexa, ignore o
breakpoint xl e trabalhe só com lg.
---
## 4. Z-index hierarchy
```
.xx-mobile-drawer 80 ← drawer aberto cobre o ψ
.xx-mobile-drawer__backdrop 79 ← acima do ψ, abaixo do drawer
.psi-btn 70 ← botão Melissa (workspace)
.melissa-dock 65 ← faixa bottom (chip cronômetro etc.)
.xx-page 40 ← página em si
```
Drawer e backdrop **devem ficar acima do ψ**. O ψ continua abaixo pra ser
coberto quando o drawer está aberto (decisão de UX validada com Leonardo).
---
## 5. Setup do `<script setup>`
```js
import { ref, onMounted, onBeforeUnmount } from 'vue';
const drawerOpen = ref(false);
const isMobile = ref(false);
const isCompact = ref(false);
let _mqMobile = null;
let _mqCompact = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false; // saiu do mobile, fecha drawer
}
function _onMqCompactChange(e) {
isCompact.value = e.matches;
}
onMounted(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
_mqMobile.addEventListener('change', _onMqMobileChange);
_mqCompact = window.matchMedia('(max-width: 1279px)');
isCompact.value = _mqCompact.matches;
_mqCompact.addEventListener('change', _onMqCompactChange);
}
});
onBeforeUnmount(() => {
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
if (_mqCompact) _mqCompact.removeEventListener('change', _onMqCompactChange);
});
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
```
---
## 6. CSS base (copy-paste, troque `xx-` pelo prefixo)
```css
/* Container glass — convenção das Melissa Pages */
.xx-page {
position: absolute;
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 6px;
z-index: 40;
display: flex;
flex-direction: column;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: var(--m-text);
animation: xx-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes xx-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
/* Header da página */
.xx-page__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
gap: 10px;
}
.xx-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 500;
}
.xx-page__title > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xx-page__actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* Body — flex row em desktop, column em mobile */
.xx-body {
flex: 1;
display: flex;
min-height: 0;
position: relative;
}
/* Botão "Menu" (mobile only) — primary filled, abre o drawer */
.xx-menu-btn { display: none; /* show via @media abaixo */ }
.xx-menu-btn {
height: 32px;
align-items: center;
gap: 6px;
flex-shrink: 0;
background: var(--m-accent);
border: 1px solid var(--m-accent);
color: white;
padding: 0 11px;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, transform 140ms ease;
}
.xx-menu-btn:hover { background: color-mix(in srgb, var(--m-accent) 88%, white); transform: translateY(-1px); }
.xx-menu-btn:active { transform: translateY(0); }
/* Drawer mobile — fora do .xx-page, fullheight */
.xx-mobile-drawer {
position: fixed;
top: 0;
left: 0;
height: 100vh;
height: 100dvh; /* iOS toolbar dynamic */
width: min(360px, 88vw);
z-index: 80; /* acima do ψ (70) */
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
}
.xx-mobile-drawer.is-open { transform: translateX(0); }
.xx-mobile-drawer__scroll {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 12px 12px 24px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.xx-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.xx-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Asides perdem padding/scroll/borda próprios quando teleportados pro drawer */
.xx-mobile-drawer__scroll .xx-side,
.xx-mobile-drawer__scroll .xx-widgets {
width: 100%;
flex-shrink: 0;
height: auto;
overflow: visible;
border-right: none;
border-left: none;
background: transparent;
padding: 0;
}
/* Backdrop */
.xx-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.xx-drawer-fade-enter-active,
.xx-drawer-fade-leave-active { transition: opacity 200ms ease; }
.xx-drawer-fade-enter-from,
.xx-drawer-fade-leave-to { opacity: 0; }
/* Mobile (<lg) — central 100%, asides off-canvas, título some */
@media (max-width: 1023px) {
.xx-body { flex-direction: column; }
.xx-main { width: 100%; }
.xx-page__title { display: none; }
.xx-menu-btn { display: inline-flex; }
}
```
---
## 7. Pegadinhas (DON'Ts)
### ❌ NÃO envolver Melissa Page com `<Transition>` no `MelissaLayout`
```vue
<!-- ERRADO leave delay cria orphan placeholder em Teleport
targets compartilhados. Crash: "Cannot set properties of null
(setting '__vnode')". -->
<Transition name="page-fade">
<MelissaXxx v-if="secaoAberta === 'xxx'" />
</Transition>
<!-- CERTO animação como @keyframes na própria .xx-page -->
<MelissaXxx v-if="layoutReady && secaoAberta === 'xxx'" />
```
### ❌ NÃO importar `Menu` do PrimeVue manualmente
PrimeVueResolver auto-importa. Import duplo cria instâncias fantasmas e
quebra o reconciler com `emitsOptions: null` em `shouldUpdateComponent`.
```js
// ❌ NÃO faça
import Menu from 'primevue/menu';
```
### ❌ NÃO usar `<Teleport><Transition><Element v-if>`
Quando múltiplos Teleports compartilham target (ex: `.melissa-dock`):
```vue
<!-- ERRADO placeholders órfãos no target compartilhado -->
<Teleport to=".melissa-dock">
<Transition name="...">
<Element v-if="cond" />
</Transition>
</Teleport>
<!-- CERTO Transition envolve Teleport, não o contrário -->
<Transition name="...">
<Teleport v-if="cond" to=".melissa-dock">
<Element />
</Teleport>
</Transition>
```
### ❌ NÃO escopar CSS de Teleport target
Targets globais (`.melissa-dock`, `#xx-mobile-drawer-target`) precisam
de CSS no `<style>` (sem `scoped`). Vue compiler hoista nodes static e
perde `data-v-{hash}`, então o seletor scoped não casa.
### ⚠️ Em deep-link (URL → secaoAberta), precisa do `layoutReady`
`MelissaLayout` expõe `layoutReady` que vira true 1 nextTick após mount.
Use `v-if="layoutReady && secaoAberta === 'xxx'"` no MelissaLayout, não
`v-if="secaoAberta === 'xxx'"`. Sem isso, o `<Teleport to=".melissa-dock">`
da Melissa Page tenta achar target que ainda não foi montado → crash em
`moveTeleport → insertBefore(null, ...)` quando triggers reativos do
PrimeVue setTheme caem entre mount e flush.
### ⚠️ Tooltips PrimeVue
Em código real use `v-tooltip.top="'texto'"` (auto-registrado via
PrimeVueResolver). NÃO use `title=""` em produção — só vale em preview.
---
## 8. Wire-up no `MelissaLayout.vue`
1. Importar o componente:
```js
import MelissaFinanceiro from './MelissaFinanceiro.vue';
```
2. Adicionar a section na lista de seções "promovidas" (perto de
`MelissaAgenda`/`MelissaPacientes` em `MelissaLayout.vue:~1273`):
```vue
<MelissaFinanceiro
v-if="layoutReady && secaoAberta === 'financeiro'"
@close="fecharSecao"
/>
```
3. Adicionar `'financeiro'` ao `SECOES` map se ainda não estiver.
4. Atualizar o item correspondente no `MelissaMenu.vue` pra emit
`select('financeiro')` (sem `route`) — fica seção interna do Melissa.
OU manter com `route: { name: 'therapist-financeiro' }` se for navegar
pra fora do Melissa (depende do escopo da página).
---
## 9. Loading states
Princípio: **skeleton só na primeira carga** (sem dados ainda). Refetches
subsequentes (mudança de range, refresh manual) mantêm a UI estável e
mostram só feedback discreto (overlay leve / spinner em botão).
### Classe global `.melissa-skeleton`
Definida no bloco `<style>` (não scoped) do `MelissaLayout.vue`. Herda do
shimmer global, respeita `prefers-reduced-motion`. Variantes:
| Classe | Uso |
|---|---|
| `.melissa-skeleton--text` | Linha de texto (~12px) |
| `.melissa-skeleton--title` | Heading (~18px) |
| `.melissa-skeleton--number` | Número de stat (~24×32px) |
| `.melissa-skeleton--avatar` | Círculo 32×32 |
### Pattern: skeleton só na 1ª carga
```js
// Computed no <script setup>
const pacientesCarregandoInicial = computed(
() => props.pacientesLoading && (props.pacientes?.length || 0) === 0
);
```
```vue
<!-- Template — bifurca pelo computed -->
<template v-if="pacientesCarregandoInicial">
<div v-for="i in 6" :key="`psk-${i}`" class="xx-pat xx-pat--skeleton" aria-busy="true">
<span class="xx-pat__avatar melissa-skeleton melissa-skeleton--avatar" />
<span class="melissa-skeleton melissa-skeleton--text" :style="{ width: `${55 + (i * 7) % 30}%` }" />
</div>
</template>
<div v-for="p in pacientes" v-else :key="p.id" class="xx-pat">…</div>
```
Variar a `width` do skeleton com a expressão `${55 + (i * 7) % 30}%` evita
linhas idênticas — fica mais natural visualmente.
### Pattern: classe `--skeleton` neutraliza hover/cursor
```css
.xx-pat--skeleton,
.xx-stat--skeleton,
.xx-sess--skeleton {
cursor: default;
pointer-events: none;
opacity: 0.95;
}
.xx-pat--skeleton:hover { background: inherit; transform: none; }
```
### Pattern: overlay de loading (refetch silencioso)
Quando o componente já tem dados mas tá refetcheando (ex: FullCalendar
trocando de view), use um overlay pequeno no canto:
```vue
<Transition name="xx-loading-fade">
<div v-if="loadingRef" class="xx-loading-corner" aria-busy="true">
<i class="pi pi-spin pi-spinner" />
</div>
</Transition>
```
```css
.xx-loading-corner {
position: absolute;
top: 8px; right: 8px;
z-index: 5;
pointer-events: none; /* não bloqueia clicks */
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border-radius: 50%;
background: color-mix(in srgb, var(--m-bg-medium) 80%, transparent);
backdrop-filter: blur(8px);
border: 1px solid var(--m-border);
color: var(--m-text-muted);
}
```
### Pattern: botão com spinner durante operação
```vue
<button
class="xx-act-btn"
:disabled="busy"
@click="onClick"
>
<i :class="busy ? 'pi pi-spin pi-spinner' : 'pi pi-plus'" />
<span>Agendar</span>
</button>
```
```js
const busy = ref(false);
async function onClick() {
if (busy.value) return;
busy.value = true;
try {
await operacao();
} finally {
// Pequeno timeout pra UI mostrar o spinner mesmo quando a operação
// é síncrona (perceived performance).
setTimeout(() => { busy.value = false; }, 200);
}
}
```
---
## 10. Popover de "Ações" da toolbar
Quando filtros/toggles inline ficam apertados (`<xl`), migre pra um
**Popover com `<SelectButton>`** em vez do antigo `<Menu>` com lista.
Vantagens: estado visível direto (não precisa abrir/fechar pra ver),
mudança imediata sem fechar o popover, melhor pra dedo em mobile.
```vue
<button class="xx-cal__btn xx-cal__btn--compact-only" @click="openActions">
<i class="pi pi-ellipsis-v" /><span>Ações</span>
</button>
<Popover ref="actionsPopRef" class="xx-actions-pop">
<div class="xx-actions">
<div class="xx-actions__group">
<div class="xx-actions__label">Visualização</div>
<SelectButton v-model="view" :options="viewOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" class="w-full" />
</div>
<div class="xx-actions__divider" />
<!-- Ações que não são toggle de estado ficam como botões -->
<div class="xx-actions__group">
<div class="xx-actions__label">Bloquear</div>
<div class="xx-actions__buttons">
<button class="xx-actions__btn" @click="onBlock('horario')">
<i class="pi pi-clock" /><span>Por horário</span>
</button>
<!-- … -->
</div>
</div>
</div>
</Popover>
```
```js
import Popover from 'primevue/popover'; // ← obrigatório (auto-import só pega Menu)
const actionsPopRef = ref(null);
function openActions(e) { actionsPopRef.value?.toggle(e); }
function closeActions() { try { actionsPopRef.value?.hide(); } catch {} }
```
CSS do popover: ver `.ma-actions*` em `MelissaAgenda.vue` como referência
(min-width 260px, gap 14px entre groups, divisor sutil, botões em grid 2×2).
> **Quando usar `<Menu>` em vez de `<Popover>`:** menus de ação simples
> com 1-2 items (kebab de paciente, etc.) — lista vertical funciona e é
> mais leve. Use `<Popover>` quando tiver SelectButton, layout custom ou
> quiser que mudanças não fechem.
---
## 11. Header — convenção de botões
| Tipo | Tamanho | Border-radius | Notas |
|---|---|---|---|
| **Botão close** (X) | 32×32 icon-only | 9px | `display: grid; place-items: center` |
| **Botão action icon-only** (config, settings) | 32×32 icon-only | 9px | Mesmo template do close |
| **Botão "Menu" mobile** (abre drawer) | 32px alto, padding 0 11px | 9px | Primary filled (`var(--m-accent)`) |
Regra: **botões icon-only no header sempre 32×32**. Não use `padding`
livre — sai com tamanho diferente do close e quebra alinhamento visual.
```css
.xx-head-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
border-radius: 9px;
cursor: pointer;
transition: background-color 140ms ease;
}
.xx-head-btn > i { font-size: 0.85rem; }
```
---
## 12. Border-radius — convenção
Teto **12px** pra qualquer elemento dentro de uma Melissa Page. Hierarquia:
| Nível | Elemento | Radius |
|---|---|---|
| Container externo | `.xx-page` (a "tela" inteira) | **18px** |
| Card / widget | `.xx-w` (containers internos) | **12px** |
| Item dentro de card | `.xx-stat`, `.xx-sess`, `.xx-pat` | **10px** |
| Botão small | `.xx-head-btn`, `.xx-close`, ações da toolbar | **9px** |
| Pill / badge | counts, novo, status | **999px** (full round) |
| Avatar | `.xx-pat__avatar` | **50%** |
**Não passe de 12px em cards internos.** Visualmente conflita com o radius
do container externo (18px) e fica "infantil".
---
## 13. Checklist pra cada nova Melissa Page
### Estrutura
- [ ] Componente `Melissa<Nome>.vue` em `src/layout/melissa/`
- [ ] Prefixo CSS único (`mf-`, `mw-`, `mr-`...)
- [ ] Estrutura template: drawer host (sibling) + backdrop + `<section class="xx-page">`
- [ ] `<Teleport>` em cada aside, target `#xx-mobile-drawer-target`
- [ ] `isMobile`/`isCompact` via matchMedia (1023/1279)
- [ ] `drawerOpen`/`toggleDrawer`/`fecharDrawer`
- [ ] Botão "Menu" mobile-only no header
- [ ] Botão "Fechar" no header → `emit('close')` (volta pro resumo)
- [ ] `@keyframes xx-page-enter` em `.xx-page` (não use `<Transition>` no parent)
- [ ] z-index drawer 80, backdrop 79
- [ ] CSS de drawer e backdrop com mesmas dimensões da Agenda (`min(360px, 88vw)`)
- [ ] Wire-up no `MelissaLayout.vue` com `layoutReady &&`
- [ ] Adicionar entry no `MelissaMenu` (com ou sem `route`)
### Loading
- [ ] Composable expõe `loading` ref
- [ ] Prop `xxxLoading` na Melissa Page (passa do parent)
- [ ] Computed `xxxCarregandoInicial` (`loading && data.length === 0`)
- [ ] Skeleton com `melissa-skeleton` + variantes nos lugares que importam
- [ ] Botões de ação (criar, salvar) com `:disabled="busy"` + spinner
### Visual
- [ ] Botões icon-only no header: 32×32, radius 9px
- [ ] Cards internos: radius 12px (containers) / 10px (items)
- [ ] Toggles/filtros em `<Popover>` com `<SelectButton>` (não `<Menu>` lista)
---
## 14. Pattern: CRUD de catálogo (Tags / Grupos / Médicos)
Páginas estilo "catálogo simples" — entidades com nome + cor (ou só dados de
contato), CRUD básico, contagem de itens vinculados. Layout 2-col padrão:
- **Aside (~280px)**: stats (4 cards 2×2) + busca
- **Main**: lista de cards (cor/avatar + nome + meta + actions)
- **Click no card** → dialog edit
- **Botão "+ Novo"** no header do `mp-page__actions`
- **Lock visual** em items "do sistema" (tags padrão, grupos sistema, etc.) —
cards não-clicáveis, sem botões editar/excluir
- **Color picker** nativo (`<input type="color">`) + 12 preset colors clicáveis
no dialog
Em mobile: `Novo` vira icon-only 32×32 (texto some via media query).
## 15. Pattern: Lista com dialog de detalhes (Cadastros Recebidos)
Páginas onde cada item tem **muitas informações** que não cabem no card.
Padrão:
- Card mostra **só o essencial** (nome + contato + status + tempo)
- Click → **dialog de detalhes** com seções de campos (`grid-cols-2 gap-x-4 gap-y-1`)
- Footer do dialog tem **ações principais à direita** (Rejeitar / Converter)
- Dialog usa `Dialog` do PrimeVue com `:visible` controlado (não `v-model:visible`
pra ter mais controle do close)
```vue
<Dialog
:visible="dlg.open"
modal
dismissable-mask
:style="{ width: '640px', maxWidth: '94vw' }"
@update:visible="(v) => !v && closeDlg()"
>
<div class="flex flex-col gap-4">
<!-- Header com avatar + status + tempo -->
<!-- Seções: Identificação, Documentos, Endereço, ... -->
<div v-for="sec in dlgSections" :key="sec.title">
<div class="text-[0.62rem] uppercase tracking-wider font-semibold opacity-70">{{ sec.title }}</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<template v-for="r in sec.rows" :key="r.label">
<div class="text-[var(--text-color-secondary)]">{{ r.label }}</div>
<div>{{ r.value }}</div>
</template>
</div>
</div>
</div>
<template #footer>
<Button label="Fechar" text @click="closeDlg" />
<div class="flex-1" />
<!-- Ações principais à direita -->
</template>
</Dialog>
```
## 16. Pattern: Kanban grid (Conversas / threads)
Páginas com **status discretos** (urgent / awaiting / resolved) como Conversas:
- Aside esquerda: filtros + atribuição + canais + resumo por status
- Main: **grid kanban N-col** (4 cols xl, 2 cols compact, 1 col mobile)
- Cada coluna tem header colorido por status (red/amber/blue/emerald)
- Cards são botões clicáveis dentro de scroll vertical da coluna
```css
.xx-kanban {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
flex: 1;
min-height: 0;
}
.xx-col { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.xx-col__body { flex: 1; overflow-y: auto; }
@media (max-width: 1279px) { .xx-kanban { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 1023px) { .xx-kanban { grid-template-columns: 1fr; } }
```
Cores semânticas (consistentes em todas as Melissa Pages):
- `red`: 248,113,113 (urgente, faltou, rejeitado)
- `amber`: 251,191,36 (aguardando, novo, pendente)
- `blue`: 96,165,250 (info, remarcado)
- `emerald`/`green`: 74,222,128 (ok, resolvido, compareceu)
## 17. Reaproveitamento de composables/services
Sempre **reutilizar a lógica de fetch/CRUD existente** em vez de duplicar:
| Página Melissa | Reutiliza |
|---|---|
| `MelissaCompromissos` | `DeterminedCommitmentDialog`, queries supabase diretas |
| `MelissaRecorrencias` | Lógica buildSessions/ruleStats da page antiga |
| `MelissaConversas` | `useConversations`, `useConversationTags`, `ConversationDrawer` |
| `MelissaCadastrosRecebidos` | Lógica de `convertToPatient` da page antiga |
| `MelissaMedicos` | `Medicos.service.js` (createMedico/updateMedico/deleteMedico) |
| `MelissaPacientes` | `useMelissaPacientes`, `patientsRepository` |
| `MelissaAgenda` | `useMelissaAgenda` (composable orquestrador) |
Isso garante:
- 0 duplicação de regras de negócio
- Bugs corrigidos numa página antiga já valem na Melissa version
- Migração futura pra route real (Fase 5) é trivial
## 18. Referência canônica
- **3 colunas + breakpoints xl+lg + popover Ações + skeletons**: `MelissaAgenda.vue`
- **3 colunas com filtros + cards + quickview + drill-down mobile**: `MelissaPacientes.vue`
- **CRUD catálogo simples (cor+nome+contagem)**: `MelissaTags.vue`, `MelissaGrupos.vue`
- **Catálogo com mais campos**: `MelissaMedicos.vue`
- **Lista + dialog de detalhes + ações finais**: `MelissaCadastrosRecebidos.vue`
- **Cards com expansão (timeline/sessions)**: `MelissaRecorrencias.vue`
- **Kanban N-col por status**: `MelissaConversas.vue`
- **Reusa dialog externo**: `MelissaCompromissos.vue` (`DeterminedCommitmentDialog`)
- **Wrapper**: `MelissaLayout.vue` (`layoutReady`, montagem das páginas, classe global `.melissa-skeleton`)
- **Menu de navegação**: `MelissaMenu.vue` (drill-down mobile + drawer 360px)