Melissa: 6 Pages aplicando blueprint + dialogs unificados + Conversa estilo WhatsApp

Sprint F (05-06). Blueprint tabular aplicado nas 6 paginas restantes;
dialogs harmonizados (FloatLabel + IconField + variant=filled + section
dividers, espelhando PatientsCadastroPage Identidade); ConversationDrawer
repaginado pra visual estilo WhatsApp.

Pages refatoradas (cada uma com subheader, sidebar __scroll + __footer
fixo "Limpar filtros", Xs inline pra zerar filtro individual, mobile
drawer com sticky footer):

- MelissaCompromissos: blueprint mantendo row design original (color
  stripe + name + badges + descricao + meta inline). Filtros Status
  (Ativos/Inativos) + Tipo (Nativos/Meus). Coluna Acoes frozen 140px
  com toggle+pencil+trash.

- MelissaGrupos / MelissaTags: pattern completo + dialog "Pacientes
  do grupo/tag" com lista vinculada via patient_group_patient /
  patient_patient_tag. Avatar primary nos pacientes, header colorido
  com cor da entidade, X de fechar igual .mc-close. Dialog de
  criar/editar com FloatLabel + section dividers.

- MelissaMedicos: blueprint + dialog "Pacientes encaminhados" usando
  cor primary do tema (medicos nao tem cor propria); dialog de
  criar/editar com 4 secoes (Identificacao/Contato/Localizacao/Obs)
  espelhando PatientsCadastroPage. Service ja tinha
  fetchPatientsByMedicoNome (ILIKE em encaminhado_por).

- MelissaConversas: subheader, sidebar com bg-soft + border-right e
  cards com sombra (mw-w--side), Limpar filtros global no footer fixo
  (fix bug: filters era ref({...}) e eu lia filters.search direto, agora
  usa .value), alerta de unlinked movido pro topo, kanban mobile com
  min-height nas colunas pra mostrar mensagens.

- MelissaRecorrencias: subheader, button list de status (Ativas verde/
  Encerradas vermelho/Todas) substitui SelectButton, busca por nome do
  paciente, footer Limpar filtros, X inline no filtro Status.

ConversationDrawer redesign (WhatsApp-style):
- Header com avatar circular primary + iniciais + numero formatado
- Container de mensagens com bg "papel de parede" (color-mix com bege
  esverdeado WA + radial-gradient pattern)
- Bolhas com cantos certos (top-left ou top-right zerado simulando
  tail), sombra sutil, cores autenticas (#d9fdd3 light/#005c4b dark
  outbound; #fff/#202c33 inbound), detecao dark via :global
- Time HH:MM + status overlay no canto inferior direito DENTRO do
  balao; checks azuis quando lida (#53bdeb)
- Compose pill rounded-full + botao Send circular verde #00a884
- Removido fmtDateTime obsoleto (substituido por fmtTimeOnly)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-06 09:14:35 -03:00
parent 269b531158
commit 98f7252dcd
7 changed files with 7844 additions and 1432 deletions
File diff suppressed because it is too large Load Diff
+363 -118
View File
@@ -148,6 +148,19 @@ const carregandoInicial = computed(
const unlinkedCount = computed(() => filteredThreads.value.filter((t) => !t.patient_id).length);
// ── "Limpar filtros" global (footer fixo da sidebar) ─────────────
// `filters` é um ref({...}) (vide useConversations.js). No script
// preciso acessar via .value; no template o auto-unwrap cuida.
const hasActiveFilters = computed(() =>
!!(filters.value.search || filters.value.unreadOnly || filters.value.assigned || filters.value.channel)
);
function clearAllFilters() {
filters.value.search = '';
filters.value.unreadOnly = false;
filters.value.assigned = null;
filters.value.channel = null;
}
// Popover de Ações (compact)
const actionsPopRef = ref(null);
function openActions(e) { actionsPopRef.value?.toggle(e); }
@@ -266,119 +279,172 @@ watch(() => tenantStore.activeTenantId, async () => {
</div>
</Popover>
<!-- Subheader explicativo (blueprint §9) diferencia de
outras páginas Melissa que mostram listas tabulares. -->
<div class="mw-subheader">
<i class="pi pi-info-circle mw-subheader__icon" />
<span class="mw-subheader__text">
CRM de mensagens organizado em <strong>kanban por urgência</strong>:
Urgente / Aguardando resposta / Aguardando paciente / Resolvido.
Click num card abre a conversa no painel lateral.
</span>
</div>
<div class="mw-body">
<!-- COL 1: Filtros + atribuição + canais + status -->
<Teleport to="#mw-mobile-drawer-target" :disabled="!isMobile">
<aside class="mw-side">
<!-- Filtros rápidos -->
<div class="mw-w">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-filter" /> Filtros rápidos</span>
</div>
<div class="mw-side__list">
<button
class="mw-side__item"
:class="{ 'is-active': !filters.unreadOnly && !filters.channel }"
@click="filters.unreadOnly = false; filters.channel = null; filters.search = ''"
>
<i class="pi pi-list" />
<span>Todas</span>
<span class="mw-side__count">{{ summary.total }}</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.unreadOnly, 'is-warn': summary.unreadTotal > 0 }"
@click="filters.unreadOnly = !filters.unreadOnly"
>
<i class="pi pi-bell" />
<span>Não lidas</span>
<span class="mw-side__count" :class="{ 'is-danger': summary.unreadTotal > 0 }">{{ summary.unreadTotal }}</span>
</button>
</div>
</div>
<!-- Atribuição -->
<div class="mw-w">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-user" /> Atribuição</span>
</div>
<div class="mw-side__list">
<button
class="mw-side__item"
:class="{ 'is-active': !filters.assigned }"
@click="filters.assigned = null"
>
<i class="pi pi-list" />
<span>Todas</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.assigned === 'me' }"
@click="filters.assigned = 'me'"
>
<i class="pi pi-user" />
<span>Minhas</span>
<span class="mw-side__count">{{ mineCount }}</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.assigned === 'unassigned' }"
@click="filters.assigned = 'unassigned'"
>
<i class="pi pi-user-minus" />
<span>Não atribuídas</span>
<span class="mw-side__count" :class="{ 'is-warn': unassignedCount > 0 }">{{ unassignedCount }}</span>
</button>
</div>
</div>
<!-- Por status (kanban resumo) -->
<div class="mw-w">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-chart-bar" /> Por status</span>
</div>
<div class="mw-side__list">
<div
v-for="col in KANBAN_COLUMNS"
:key="col.key"
class="mw-side__row"
:class="`is-${col.color}`"
>
<i :class="col.icon" />
<span>{{ col.label }}</span>
<span class="mw-side__count">{{ summary[col.key] || 0 }}</span>
<div class="mw-side__scroll">
<!-- Alerta unlinked no topo pra ficar bem visível
(números de telefone sem paciente vinculado). -->
<div v-if="unlinkedCount > 0" class="mw-alert">
<i class="pi pi-exclamation-circle" />
<div>
<div class="mw-alert__title">{{ unlinkedCount }} sem paciente vinculado</div>
<div class="mw-alert__hint">Números de telefone que não batem com pacientes cadastrados.</div>
</div>
</div>
<!-- Filtros rápidos -->
<div class="mw-w mw-w--side">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-filter" /> Filtros rápidos</span>
<button
v-if="filters.unreadOnly"
class="mw-side__clear-inline"
v-tooltip.top="'Limpar filtro de não lidas'"
aria-label="Limpar filtro de não lidas"
@click="filters.unreadOnly = false"
>
<i class="pi pi-times" />
</button>
</div>
<div class="mw-side__list">
<button
class="mw-side__item"
:class="{ 'is-active': !filters.unreadOnly }"
@click="filters.unreadOnly = false"
>
<i class="pi pi-list" />
<span>Todas</span>
<span class="mw-side__count">{{ summary.total }}</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.unreadOnly, 'is-warn': summary.unreadTotal > 0 }"
@click="filters.unreadOnly = !filters.unreadOnly"
>
<i class="pi pi-bell" />
<span>Não lidas</span>
<span class="mw-side__count" :class="{ 'is-danger': summary.unreadTotal > 0 }">{{ summary.unreadTotal }}</span>
</button>
</div>
</div>
<!-- Atribuição -->
<div class="mw-w mw-w--side">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-user" /> Atribuição</span>
<button
v-if="filters.assigned"
class="mw-side__clear-inline"
v-tooltip.top="'Limpar filtro de atribuição'"
aria-label="Limpar filtro de atribuição"
@click="filters.assigned = null"
>
<i class="pi pi-times" />
</button>
</div>
<div class="mw-side__list">
<button
class="mw-side__item"
:class="{ 'is-active': !filters.assigned }"
@click="filters.assigned = null"
>
<i class="pi pi-list" />
<span>Todas</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.assigned === 'me' }"
@click="filters.assigned = 'me'"
>
<i class="pi pi-user" />
<span>Minhas</span>
<span class="mw-side__count">{{ mineCount }}</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.assigned === 'unassigned' }"
@click="filters.assigned = 'unassigned'"
>
<i class="pi pi-user-minus" />
<span>Não atribuídas</span>
<span class="mw-side__count" :class="{ 'is-warn': unassignedCount > 0 }">{{ unassignedCount }}</span>
</button>
</div>
</div>
<!-- Por status (kanban resumo display-only, sem X) -->
<div class="mw-w mw-w--side">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-chart-bar" /> Por status</span>
</div>
<div class="mw-side__list">
<div
v-for="col in KANBAN_COLUMNS"
:key="col.key"
class="mw-side__row"
:class="`is-${col.color}`"
>
<i :class="col.icon" />
<span>{{ col.label }}</span>
<span class="mw-side__count">{{ summary[col.key] || 0 }}</span>
</div>
</div>
</div>
<!-- Canais -->
<div class="mw-w mw-w--side">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-send" /> Canais</span>
<button
v-if="filters.channel"
class="mw-side__clear-inline"
v-tooltip.top="'Limpar filtro de canal'"
aria-label="Limpar filtro de canal"
@click="filters.channel = null"
>
<i class="pi pi-times" />
</button>
</div>
<div class="mw-side__list">
<button
v-for="opt in CHANNEL_OPTIONS"
:key="String(opt.value)"
class="mw-side__item"
:class="{ 'is-active': filters.channel === opt.value }"
@click="filters.channel = opt.value"
>
<i v-if="opt.value" :class="['pi', channelIcon(opt.value)]" />
<i v-else class="pi pi-list" />
<span>{{ opt.label }}</span>
</button>
</div>
</div>
</div>
<!-- Canais -->
<div class="mw-w">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-send" /> Canais</span>
</div>
<div class="mw-side__list">
<button
v-for="opt in CHANNEL_OPTIONS"
:key="String(opt.value)"
class="mw-side__item"
:class="{ 'is-active': filters.channel === opt.value }"
@click="filters.channel = opt.value"
>
<i v-if="opt.value" :class="['pi', channelIcon(opt.value)]" />
<i v-else class="pi pi-list" />
<span>{{ opt.label }}</span>
<!-- Footer fixo: "Limpar filtros" global (zera busca,
unread, atribuição e canal de uma vez). -->
<Transition name="mw-clear">
<div v-if="hasActiveFilters" class="mw-side__footer">
<button class="mw-side__clear-all" @click="clearAllFilters">
<i class="pi pi-filter-slash" />
<span>Limpar filtros</span>
</button>
</div>
</div>
<!-- Alerta unlinked -->
<div v-if="unlinkedCount > 0" class="mw-alert">
<i class="pi pi-exclamation-circle" />
<div>
<div class="mw-alert__title">{{ unlinkedCount }} sem paciente vinculado</div>
<div class="mw-alert__hint">Números de telefone que não batem com pacientes cadastrados.</div>
</div>
</div>
</Transition>
</aside>
</Teleport>
@@ -611,42 +677,166 @@ watch(() => tenantStore.activeTenantId, async () => {
}
.mw-menu-btn > i { font-size: 0.85rem; }
/* Body */
/* Subheader explicativo (blueprint §9) */
.mw-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;
}
.mw-subheader__icon {
color: var(--p-primary-color);
font-size: 0.92rem;
flex-shrink: 0;
margin-top: 1px;
}
.mw-subheader__text { flex: 1; min-width: 0; }
.mw-subheader__text strong { color: var(--m-text); font-weight: 600; }
/* Body — sem padding/gap; a sidebar tem bg+border-right próprios e o
main column controla seu padding interno. Espelha o pattern usado
em MelissaGrupos / MelissaTags / MelissaMedicos. */
.mw-body {
flex: 1;
display: flex;
min-height: 0;
position: relative;
gap: 12px;
padding: 12px;
gap: 0;
padding: 0;
}
/* Aside */
/* Aside — 2 zonas: __scroll (cards) + __footer (Limpar filtros fixo).
bg colorido próprio (--m-bg-soft) + border-right pra separar
visualmente da coluna principal. */
.mw-side {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
overflow: hidden;
}
.mw-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mw-side::-webkit-scrollbar { width: 5px; }
.mw-side::-webkit-scrollbar-thumb {
.mw-side__scroll::-webkit-scrollbar { width: 5px; }
.mw-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Footer fixo no bottom da sidebar (fora do scroll dos filter cards).
Aparece com fade+collapse quando algum filtro está ativo. */
.mw-side__footer {
flex-shrink: 0;
padding: 12px;
background: var(--m-bg-soft);
border-top: 1px solid var(--m-border);
}
.mw-side__clear-all {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
color: var(--m-text);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.mw-side__clear-all:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
color: var(--m-text);
}
.mw-side__clear-all > i {
font-size: 0.78rem;
color: var(--m-text-muted);
transition: color 140ms ease;
}
.mw-side__clear-all:hover > i { color: var(--m-text); }
/* X inline ao lado do título de cada filter card — limpa o filtro
individual. Espelha o pattern do MelissaPacientes/Grupos/Tags. */
.mw-side__clear-inline {
width: 18px;
height: 18px;
display: grid;
place-items: center;
background: transparent;
border: 1px solid color-mix(in srgb, rgb(220, 38, 38) 30%, var(--m-border));
color: rgb(220, 38, 38);
border-radius: 4px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mw-side__clear-inline:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.55);
}
.mw-side__clear-inline > i { font-size: 0.6rem; }
/* Transition do footer "Limpar filtros" */
.mw-clear-enter-active,
.mw-clear-leave-active {
transition: opacity 220ms ease, transform 220ms ease, max-height 240ms ease;
overflow: hidden;
}
.mw-clear-enter-from,
.mw-clear-leave-to {
opacity: 0;
transform: translateY(6px);
max-height: 0;
}
.mw-clear-enter-to,
.mw-clear-leave-from {
opacity: 1;
transform: translateY(0);
max-height: 80px;
}
/* Card-base — alinhado com .ma-w / .mp-w / .mcr-w: surface --m-bg-medium
pra destacar do bg da página/dialog (ambos --m-bg-soft). */
pra destacar do bg da sidebar (--m-bg-soft). */
.mw-w {
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 12px;
}
.mw-w__head { margin-bottom: 10px; }
/* Modifier pros cards dentro da .mw-side — margem lateral + sombra
sutil pra elevar sobre o bg da sidebar. Espelha .mc-w--side, .mt-w--side. */
.mw-w--side {
margin: 12px 12px 0;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.mw-w--side:last-of-type { margin-bottom: 12px; }
.mw-w__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.mw-w__title {
display: inline-flex;
align-items: center;
@@ -734,12 +924,16 @@ watch(() => tenantStore.activeTenantId, async () => {
.mw-alert {
display: flex;
gap: 10px;
margin: 12px 12px 0;
padding: 12px;
border-radius: 10px;
border: 1px solid rgba(251, 191, 36, 0.3);
background: rgba(251, 191, 36, 0.05);
color: rgb(251, 191, 36);
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.mw-alert:last-child { margin-bottom: 12px; }
.mw-alert > i { font-size: 0.85rem; margin-top: 2px; }
.mw-alert__title {
font-size: 0.78rem;
@@ -751,13 +945,15 @@ watch(() => tenantStore.activeTenantId, async () => {
margin-top: 2px;
}
/* Main */
/* Main — recebe padding interno (o body não tem mais padding/gap;
sidebar fica colada à esquerda com border-right). */
.mw-main {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 12px;
}
/* Kanban */
@@ -1009,11 +1205,40 @@ watch(() => tenantStore.activeTenantId, async () => {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Sidebar teleportada pro drawer — perde bg/border-right (o drawer
já tem chrome próprio) + cards perdem margem lateral (drawer já
tem padding). Footer vira sticky no bottom do drawer. */
.mw-mobile-drawer__scroll .mw-side {
width: 100%;
height: auto;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
}
.mw-mobile-drawer__scroll .mw-side__scroll {
flex: none;
min-height: 0;
overflow: visible;
display: flex;
flex-direction: column;
gap: 12px;
}
.mw-mobile-drawer__scroll .mw-w--side {
margin: 0;
}
.mw-mobile-drawer__scroll .mw-w--side:last-of-type { margin-bottom: 0; }
.mw-mobile-drawer__scroll .mw-alert { margin: 0; }
.mw-mobile-drawer__scroll .mw-side__footer {
position: sticky;
bottom: 0;
margin: 8px -12px -24px;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
z-index: 5;
}
.mw-mobile-drawer__backdrop {
position: fixed;
@@ -1035,12 +1260,32 @@ watch(() => tenantStore.activeTenantId, async () => {
.mw-kanban { grid-template-columns: repeat(2, 1fr); }
}
/* ═══ Mobile (<lg) — drawer + kanban 1-col ═══ */
/* ═══ Mobile (<lg) — drawer + kanban 1-col stacked ═══
Em mobile o kanban vira flex column (stacked) e o scroll passa a ser
global no .mw-main (não interno por coluna). Cada .mw-col cresce com
o conteúdo + min-height pra empty state ter altura visível. */
@media (max-width: 1023px) {
.mw-body { flex-direction: column; padding: 8px; }
.mw-main { width: 100%; }
.mw-kanban { grid-template-columns: 1fr; }
.mw-col { min-height: auto; }
.mw-body { flex-direction: column; padding: 0; }
.mw-main {
width: 100%;
padding: 8px;
overflow-y: auto;
}
.mw-kanban {
display: flex;
flex-direction: column;
flex: none;
gap: 8px;
}
.mw-col {
flex: none;
min-height: 200px;
}
.mw-col__body {
flex: none;
overflow: visible;
min-height: 80px;
}
.mw-page__title > span:first-of-type { display: none; }
.mw-menu-btn--mobile-only { display: inline-flex; }
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+424 -122
View File
@@ -14,7 +14,6 @@
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import Popover from 'primevue/popover';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// Dialog/SelectButton/Button/Tag/ProgressBar/Avatar/Select: auto-import via PrimeVueResolver
@@ -49,16 +48,46 @@ const sessionsMap = ref({}); // ruleId → AgendaEvento[]
const expandedId = ref(null);
const filterStatus = ref('ativo');
const statusOptions = [
{ label: 'Ativas', value: 'ativo' },
{ label: 'Encerradas', value: 'cancelado' },
{ label: 'Todas', value: 'all' }
// Button list de status (icons + cores). Ativa = verde / Encerrada = vermelho.
// Todas = neutral. O blueprint pede botões coloridos por status (§7).
const STATUS_FILTER_OPTIONS = [
{ key: 'ativo', label: 'Ativas', icon: 'pi pi-check-circle' },
{ key: 'cancelado', label: 'Encerradas', icon: 'pi pi-times-circle' },
{ key: 'all', label: 'Todas', icon: 'pi pi-list' }
];
const busca = ref('');
function setStatusFilter(s) {
filterStatus.value = s;
load();
}
const hasActiveFilters = computed(() =>
!!(busca.value || filterStatus.value !== 'ativo')
);
function clearAllFilters() {
busca.value = '';
if (filterStatus.value !== 'ativo') {
filterStatus.value = 'ativo';
load();
}
}
const carregandoInicial = computed(
() => loading.value && rules.value.length === 0
);
// Filtragem client-side por nome do paciente (status é server-side via load).
const filteredRules = computed(() => {
const q = String(busca.value || '').trim().toLowerCase();
if (!q) return rules.value;
return rules.value.filter((r) =>
String(r._patient?.nome_completo || '').toLowerCase().includes(q)
);
});
// ── Data load ───────────────────────────────────────────────
async function init() {
const { data } = await supabase.auth.getUser();
@@ -325,10 +354,6 @@ function toggleExpand(ruleId) {
expandedId.value = expandedId.value === ruleId ? null : ruleId;
}
// ── Popover de Ações (mobile compact) ──────────────────────
const actionsPopRef = ref(null);
function openActions(e) { actionsPopRef.value?.toggle(e); }
onMounted(async () => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
@@ -378,18 +403,11 @@ onBeforeUnmount(() => {
<span>Menu Recorrências</span>
</button>
<div class="mr-page__title">
<i class="pi pi-sync text-indigo-300" />
<i class="pi pi-sync mr-page__title-icon" />
<span>Recorrências</span>
<span class="mr-page__count">{{ rules.length }}</span>
<span class="mr-page__count">{{ filteredRules.length }}</span>
</div>
<div class="mr-page__actions">
<button
class="mr-head-btn mr-head-btn--compact-only"
v-tooltip.bottom="'Filtros'"
@click="openActions"
>
<i class="pi pi-sliders-h" />
</button>
<button
class="mr-head-btn"
v-tooltip.bottom="'Recarregar'"
@@ -404,77 +422,110 @@ onBeforeUnmount(() => {
</div>
</header>
<Popover ref="actionsPopRef" class="mr-actions-pop">
<div class="mr-actions">
<div class="mr-actions__group">
<div class="mr-actions__label">Status</div>
<SelectButton
v-model="filterStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
size="small"
class="w-full"
@change="load"
/>
</div>
</div>
</Popover>
<!-- Subheader explicativo (blueprint §9) -->
<div class="mr-subheader">
<i class="pi pi-info-circle mr-subheader__icon" />
<span class="mr-subheader__text">
Séries de sessões que se repetem (semanal, quinzenal, dias específicos).
Acompanhe o <strong>progresso</strong> de cada uma e marque como
<strong>encerrada</strong> quando o paciente terminar o tratamento.
</span>
</div>
<div class="mr-body">
<!-- COL 1: Stats + filtros -->
<Teleport to="#mr-mobile-drawer-target" :disabled="!isMobile">
<aside class="mr-side">
<div class="mr-w">
<div class="mr-w__head">
<span class="mr-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
</div>
<div class="mr-stats">
<template v-if="carregandoInicial">
<div v-for="i in 4" :key="`stsk-${i}`" class="mr-stat mr-stat--skeleton" aria-busy="true">
<div class="mr-stat__val melissa-skeleton melissa-skeleton--number" />
<div class="mr-stat__lbl melissa-skeleton melissa-skeleton--text" style="width: 60%; margin-top: 6px;" />
<div class="mr-side__scroll">
<!-- Stats -->
<div class="mr-w mr-w--side">
<div class="mr-w__head">
<span class="mr-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
</div>
<div class="mr-stats">
<template v-if="carregandoInicial">
<div v-for="i in 4" :key="`stsk-${i}`" class="mr-stat mr-stat--skeleton" aria-busy="true">
<div class="mr-stat__val melissa-skeleton melissa-skeleton--number" />
<div class="mr-stat__lbl melissa-skeleton melissa-skeleton--text" style="width: 60%; margin-top: 6px;" />
</div>
</template>
<div
v-for="s in aggregateStats"
v-else
:key="s.key"
class="mr-stat"
:class="`is-${s.cls}`"
>
<div class="mr-stat__val">{{ s.value }}</div>
<div class="mr-stat__lbl">{{ s.label }}</div>
</div>
</template>
<div
v-for="s in aggregateStats"
v-else
:key="s.key"
class="mr-stat"
:class="`is-${s.cls}`"
>
<div class="mr-stat__val">{{ s.value }}</div>
<div class="mr-stat__lbl">{{ s.label }}</div>
</div>
</div>
<!-- Filtro de status (button list colorido) -->
<div class="mr-w mr-w--side">
<div class="mr-w__head">
<span class="mr-w__title"><i class="pi pi-filter" /> Status</span>
<button
v-if="filterStatus !== 'ativo'"
class="mr-side__clear-inline"
v-tooltip.top="'Voltar pro filtro padrão (Ativas)'"
aria-label="Voltar pro filtro padrão"
@click="setStatusFilter('ativo')"
>
<i class="pi pi-times" />
</button>
</div>
<div class="mr-side__list">
<button
v-for="o in STATUS_FILTER_OPTIONS"
:key="o.key"
class="mr-side__item"
:class="[`is-status-${o.key}`, { 'is-active': filterStatus === o.key }]"
@click="setStatusFilter(o.key)"
>
<i :class="o.icon" />
<span>{{ o.label }}</span>
</button>
</div>
</div>
</div>
<div class="mr-w mr-w--side-only">
<div class="mr-w__head">
<span class="mr-w__title"><i class="pi pi-filter" /> Filtros</span>
<!-- Footer fixo: Limpar filtros (Transition fade+collapse) -->
<Transition name="mr-clear">
<div v-if="hasActiveFilters" class="mr-side__footer">
<button class="mr-side__clear-all" @click="clearAllFilters">
<i class="pi pi-filter-slash" />
<span>Limpar filtros</span>
</button>
</div>
<div class="mr-side__filters">
<div>
<div class="mr-side__label">Status</div>
<SelectButton
v-model="filterStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
size="small"
class="w-full"
@change="load"
/>
</div>
</div>
</div>
</Transition>
</aside>
</Teleport>
<!-- COL 2: Lista de regras -->
<div class="mr-main">
<!-- Toolbar: busca por nome do paciente -->
<div class="mr-toolbar">
<div class="mr-search">
<i class="pi pi-search mr-search__icon" />
<input
v-model="busca"
type="text"
placeholder="Buscar por nome do paciente…"
class="mr-search__input"
/>
<button
v-if="busca"
class="mr-search__clear"
v-tooltip.bottom="'Limpar busca'"
@click="busca = ''"
>
<i class="pi pi-times" />
</button>
</div>
</div>
<div class="mr-list">
<!-- Skeletons (blueprint §9) -->
<template v-if="carregandoInicial">
@@ -490,11 +541,14 @@ onBeforeUnmount(() => {
</div>
</template>
<div v-else-if="!rules.length" class="mr-empty">
<div v-else-if="!filteredRules.length" class="mr-empty">
<i class="pi pi-calendar-times mr-empty__icon" />
<div class="mr-empty__title">Nenhuma série encontrada</div>
<div class="mr-empty__hint">
<template v-if="filterStatus === 'ativo'">
<template v-if="busca">
Nenhum paciente corresponde à busca. Ajuste ou limpe os filtros.
</template>
<template v-else-if="filterStatus === 'ativo'">
Crie sessões recorrentes na agenda pra -las aqui.
</template>
<template v-else>
@@ -503,7 +557,7 @@ onBeforeUnmount(() => {
</div>
</div>
<div v-else v-for="rule in rules" :key="rule.id" class="mr-card">
<div v-else v-for="rule in filteredRules" :key="rule.id" class="mr-card">
<!-- Head: paciente + descrição + período -->
<div class="mr-card__head">
<span class="mr-card__avatar">
@@ -637,6 +691,10 @@ onBeforeUnmount(() => {
font-size: 1rem;
font-weight: 500;
}
.mr-page__title-icon {
color: var(--p-primary-color);
font-size: 1.05rem;
}
.mr-page__title > span:not(.mr-page__count) {
overflow: hidden;
text-overflow: ellipsis;
@@ -699,40 +757,159 @@ onBeforeUnmount(() => {
}
.mr-menu-btn > i { font-size: 0.85rem; }
/* Body */
/* Subheader (blueprint §9) */
.mr-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;
}
.mr-subheader__icon {
color: var(--p-primary-color);
font-size: 0.92rem;
flex-shrink: 0;
margin-top: 1px;
}
.mr-subheader__text { flex: 1; min-width: 0; }
.mr-subheader__text strong { color: var(--m-text); font-weight: 600; }
/* Body — sidebar fica colada à esquerda com border-right; main com
padding interno. Espelha Grupos/Tags/Médicos/Conversas. */
.mr-body {
flex: 1;
display: flex;
min-height: 0;
position: relative;
gap: 12px;
padding: 12px;
gap: 0;
padding: 0;
}
/* Aside */
/* Aside — 2 zonas: __scroll (cards) + __footer (Limpar filtros fixo) */
.mr-side {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
overflow: hidden;
}
.mr-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mr-side::-webkit-scrollbar { width: 5px; }
.mr-side::-webkit-scrollbar-thumb {
.mr-side__scroll::-webkit-scrollbar { width: 5px; }
.mr-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mr-w {
/* Footer fixo: Limpar filtros (espelha Grupos/Tags/Médicos/Conversas) */
.mr-side__footer {
flex-shrink: 0;
padding: 12px;
background: var(--m-bg-soft);
border-top: 1px solid var(--m-border);
}
.mr-side__clear-all {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
color: var(--m-text);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.mr-side__clear-all:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
color: var(--m-text);
}
.mr-side__clear-all > i {
font-size: 0.78rem;
color: var(--m-text-muted);
transition: color 140ms ease;
}
.mr-side__clear-all:hover > i { color: var(--m-text); }
/* X inline ao lado do título do filter card */
.mr-side__clear-inline {
width: 18px;
height: 18px;
display: grid;
place-items: center;
background: transparent;
border: 1px solid color-mix(in srgb, rgb(220, 38, 38) 30%, var(--m-border));
color: rgb(220, 38, 38);
border-radius: 4px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mr-side__clear-inline:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.55);
}
.mr-side__clear-inline > i { font-size: 0.6rem; }
/* Transition do footer */
.mr-clear-enter-active,
.mr-clear-leave-active {
transition: opacity 220ms ease, transform 220ms ease, max-height 240ms ease;
overflow: hidden;
}
.mr-clear-enter-from,
.mr-clear-leave-to {
opacity: 0;
transform: translateY(6px);
max-height: 0;
}
.mr-clear-enter-to,
.mr-clear-leave-from {
opacity: 1;
transform: translateY(0);
max-height: 80px;
}
.mr-w {
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 12px;
}
.mr-w__head { margin-bottom: 10px; }
/* Modifier pros cards dentro da sidebar — margem lateral + sombra */
.mr-w--side {
margin: 12px 12px 0;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.mr-w--side:last-of-type { margin-bottom: 12px; }
.mr-w__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.mr-w__title {
display: inline-flex;
align-items: center;
@@ -748,7 +925,7 @@ onBeforeUnmount(() => {
gap: 6px;
}
.mr-stat {
background: var(--m-bg-medium);
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 8px 10px;
@@ -765,15 +942,80 @@ onBeforeUnmount(() => {
text-transform: uppercase;
letter-spacing: 0.06em;
}
.mr-stat.is-ok .mr-stat__val { color: rgb(74, 222, 128); }
.mr-stat.is-ok .mr-stat__val { color: rgb(22, 163, 74); }
.mr-side__label {
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--m-text-muted);
font-size: 0.62rem;
font-weight: 600;
margin-bottom: 6px;
/* Filter button list (blueprint §8) */
.mr-side__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.mr-side__item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: transparent;
border: 1px solid transparent;
color: var(--m-text);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 0.82rem;
text-align: left;
transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
}
.mr-side__item > i {
font-size: 0.78rem;
width: 14px;
text-align: center;
}
/* Status: Ativas verde / Encerradas vermelho / Todas neutral */
.mr-side__item.is-status-ativo {
background: rgba(22, 163, 74, 0.05);
border-color: rgba(22, 163, 74, 0.18);
}
.mr-side__item.is-status-ativo > i { color: rgb(22, 163, 74); }
.mr-side__item.is-status-ativo:hover {
background: rgba(22, 163, 74, 0.10);
border-color: rgba(22, 163, 74, 0.30);
}
.mr-side__item.is-active.is-status-ativo {
background: rgba(22, 163, 74, 0.16);
border-color: rgba(22, 163, 74, 0.55);
box-shadow: 0 0 0 1px rgba(22, 163, 74, 0.35);
}
.mr-side__item.is-status-cancelado {
background: rgba(220, 38, 38, 0.05);
border-color: rgba(220, 38, 38, 0.18);
}
.mr-side__item.is-status-cancelado > i { color: rgb(220, 38, 38); }
.mr-side__item.is-status-cancelado:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.30);
}
.mr-side__item.is-active.is-status-cancelado {
background: rgba(220, 38, 38, 0.16);
border-color: rgba(220, 38, 38, 0.55);
box-shadow: 0 0 0 1px rgba(220, 38, 38, 0.35);
}
.mr-side__item.is-status-all {
background: var(--m-bg-soft);
border-color: var(--m-border);
}
.mr-side__item.is-status-all > i { color: var(--m-text-muted); }
.mr-side__item.is-status-all:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mr-side__item.is-active.is-status-all {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
box-shadow: 0 0 0 1px var(--m-border-strong);
}
/* Main */
@@ -782,9 +1024,70 @@ onBeforeUnmount(() => {
min-width: 0;
display: flex;
flex-direction: column;
padding: 12px;
gap: 10px;
}
/* Toolbar com busca */
.mr-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
}
.mr-search {
position: relative;
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.mr-search__icon {
position: absolute;
left: 12px;
color: var(--m-text-muted);
font-size: 0.78rem;
pointer-events: none;
}
.mr-search__input {
width: 100%;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
color: var(--m-text);
padding: 9px 36px 9px 34px;
border-radius: 10px;
font-size: 0.85rem;
font-family: inherit;
outline: none;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mr-search__input::placeholder { color: var(--m-text-faint); }
.mr-search__input:focus {
border-color: var(--m-border-strong);
}
.mr-search__clear {
position: absolute;
right: 8px;
width: 22px;
height: 22px;
display: grid;
place-items: center;
background: transparent;
border: none;
color: var(--m-text-muted);
border-radius: 6px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, color 140ms ease;
}
.mr-search__clear:hover {
background: var(--m-bg-soft-hover);
color: var(--m-text);
}
.mr-search__clear > i { font-size: 0.7rem; }
.mr-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 4px 4px;
display: flex;
@@ -1049,27 +1352,6 @@ onBeforeUnmount(() => {
color: var(--m-text-muted);
}
/* Popover de Ações */
.mr-actions {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 240px;
padding: 4px;
}
.mr-actions__group {
display: flex;
flex-direction: column;
gap: 6px;
}
.mr-actions__label {
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--text-color-secondary, var(--m-text-faint));
font-size: 0.62rem;
font-weight: 600;
}
/* Drawer mobile (blueprint §6) */
.mr-mobile-drawer {
position: fixed;
@@ -1110,6 +1392,31 @@ onBeforeUnmount(() => {
height: auto;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
}
.mr-mobile-drawer__scroll .mr-side__scroll {
flex: none;
min-height: 0;
overflow: visible;
display: flex;
flex-direction: column;
gap: 12px;
}
.mr-mobile-drawer__scroll .mr-w--side {
margin: 0;
}
.mr-mobile-drawer__scroll .mr-w--side:last-of-type { margin-bottom: 0; }
.mr-mobile-drawer__scroll .mr-side__footer {
position: sticky;
bottom: 0;
margin: 8px -12px -24px;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
z-index: 5;
}
.mr-mobile-drawer__backdrop {
position: fixed;
@@ -1124,15 +1431,10 @@ onBeforeUnmount(() => {
.mr-drawer-fade-enter-from,
.mr-drawer-fade-leave-to { opacity: 0; }
/* Compact (<xl) */
@media (max-width: 1279px) {
.mr-head-btn--compact-only { display: grid; }
}
/* Mobile (<lg) */
@media (max-width: 1023px) {
.mr-body { flex-direction: column; padding: 8px; }
.mr-main { width: 100%; }
.mr-body { flex-direction: column; padding: 0; }
.mr-main { width: 100%; padding: 8px; }
.mr-page__title > span:first-of-type { display: none; }
.mr-menu-btn--mobile-only { display: inline-flex; }
.mr-card__foot { gap: 4px; }
File diff suppressed because it is too large Load Diff