Melissa Agenda: toolbar polish + stats interativos com filtro

B1 — Toolbar
- Cluster Hoje + chevrons num pill único (mais coeso)
- Título com flex+ellipsis (some min-width:130px que truncava feio em
  view Mês/Lista)
- Botão "Hoje" disabled visual (opacity 0.45) quando hoje cai no range
  visível — antes ficava idêntico, sem affordance
- title="" → v-tooltip.top nos chevrons (memória: tooltips PrimeVue)
- focus-visible com outline accent em todos os botões da toolbar
- Visual refinado: padding/font-weight, view-btn ativo com box-shadow

B2 — Stats interativos
- Click no stat filtra fcEvents + sessoesHoje pelo predicado correspondente
  (Total/Sessões/Realizadas/Faltas — feriados continuam sempre)
- Stat ativo ganha borda accent + bg color-mix
- Stats com value=0 ficam disabled (cursor:not-allowed, opacity 0.4)
- Click no stat ativo limpa o filtro
- Chip flutuante "Filtrando: X" no canto sup direito do FC, click limpa
- Tooltip dinâmico explicando a ação esperada

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-26 08:29:59 -03:00
parent f2b15ce0f7
commit 6a92735366
+229 -54
View File
@@ -115,6 +115,16 @@ const fcRef = ref(null);
// Title visível na toolbar (atualizado pelo FC via datesSet)
const currentTitle = ref('');
// Botão "Hoje" fica desabilitado quando o range visível JÁ inclui hoje
// (evita click ruidoso e dá affordance de "está em hoje").
const refDateIsToday = computed(() => {
const hoje = new Date();
const inicio = viewStart.value;
const fim = viewEnd.value;
if (!inicio || !fim) return false;
return hoje >= inicio && hoje < fim;
});
// Range visível atual (passa pro composable que refetcha)
const viewStart = ref(new Date());
const viewEnd = ref(new Date());
@@ -149,22 +159,47 @@ function dateKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
// ── Filtro por stat ────────────────────────────────────────────
// Click num stat do "Hoje" filtra fcEvents + sessoesHoje pelo predicado
// correspondente. Click no stat ativo limpa o filtro. Stats com value=0
// ficam disabled. Feriados continuam sempre (background events).
const STAT_FILTERS = {
total: () => true,
sessoes: (e) => e.tipo === 'sessao',
realizadas: (e) => /realizad/i.test(e.status),
faltas: (e) => /falt/i.test(e.status)
};
const STAT_LABELS = {
total: 'Total', sessoes: 'Sessões', realizadas: 'Realizadas', faltas: 'Faltas'
};
const statFiltroAtivo = ref(null); // null | 'total' | 'sessoes' | 'realizadas' | 'faltas'
function toggleStatFiltro(key) {
statFiltroAtivo.value = statFiltroAtivo.value === key ? null : key;
}
function statLabel(key) {
return STAT_LABELS[key] || '';
}
// FC events derivados dos eventos do composable + feriados como background.
// feriadoFcEvents vem do useFeriados (display:'background', cor amber suave).
// Pattern espelha AgendaTerapeutaPage:601.
const fcEvents = computed(() => [
...eventosSemana.value.map((ev) => ({
id: ev.id,
title: ev.label,
start: ev.inicio_em,
end: ev.fim_em,
backgroundColor: `${ev.color}26`, // ~15% opacity
borderColor: ev.color,
textColor: 'white',
extendedProps: ev
})),
...feriadoFcEvents.value
]);
const fcEvents = computed(() => {
const pred = statFiltroAtivo.value ? STAT_FILTERS[statFiltroAtivo.value] : () => true;
return [
...eventosSemana.value.filter(pred).map((ev) => ({
id: ev.id,
title: ev.label,
start: ev.inicio_em,
end: ev.fim_em,
backgroundColor: `${ev.color}26`, // ~15% opacity
borderColor: ev.color,
textColor: 'white',
extendedProps: ev
})),
...feriadoFcEvents.value
];
});
function escHtml(s) {
return String(s ?? '')
@@ -476,23 +511,23 @@ const eventosHojeReal = computed(() => {
});
// ── Stats do dia (real) ────────────────────────────────────────
// Cada stat tem `key` que casa com STAT_FILTERS pra interatividade.
const statsHoje = computed(() => {
const arr = eventosHojeReal.value;
const total = arr.length;
const sessoes = arr.filter((e) => e.tipo === 'sessao').length;
const realizadas = arr.filter((e) => /realizad/i.test(e.status)).length;
const faltas = arr.filter((e) => /falt/i.test(e.status)).length;
return [
{ label: 'Total', value: total, cls: '' },
{ label: 'Sessões', value: sessoes, cls: 'ok' },
{ label: 'Realizadas', value: realizadas, cls: '' },
{ label: 'Faltas', value: faltas, cls: 'warn' }
{ key: 'total', label: 'Total', value: arr.length, cls: '' },
{ key: 'sessoes', label: 'Sessões', value: arr.filter(STAT_FILTERS.sessoes).length, cls: 'ok' },
{ key: 'realizadas', label: 'Realizadas', value: arr.filter(STAT_FILTERS.realizadas).length, cls: '' },
{ key: 'faltas', label: 'Faltas', value: arr.filter(STAT_FILTERS.faltas).length, cls: 'warn' }
];
});
// ── Sessões hoje (lista, ordenadas por hora) ──────────────────
// Aplica o mesmo filtro dos stats pra coerência visual: clicar no stat
// "Faltas" mostra só faltas tanto no FC quanto na lista logo abaixo.
const sessoesHoje = computed(() => {
return [...eventosHojeReal.value].sort((a, b) => a.startH - b.startH);
const pred = statFiltroAtivo.value ? STAT_FILTERS[statFiltroAtivo.value] : () => true;
return [...eventosHojeReal.value].filter(pred).sort((a, b) => a.startH - b.startH);
});
// ── Cadastro de paciente (popover + dialogs) ──────────────────
@@ -621,15 +656,18 @@ const kebabItems = computed(() => {
<span v-if="sessoesHoje.length > 0" class="ma-w__count">{{ sessoesHoje.length }}</span>
</div>
<div class="ma-stats">
<div
<button
v-for="s in statsHoje"
:key="s.label"
:key="s.key"
class="ma-stat"
:class="`is-${s.cls}`"
:class="[`is-${s.cls}`, { 'is-active': statFiltroAtivo === s.key }]"
:disabled="s.value === 0"
v-tooltip.top="s.value === 0 ? 'Nenhum evento' : (statFiltroAtivo === s.key ? 'Limpar filtro' : `Filtrar por ${s.label.toLowerCase()}`)"
@click="toggleStatFiltro(s.key)"
>
<div class="ma-stat__val">{{ s.value }}</div>
<div class="ma-stat__lbl">{{ s.label }}</div>
</div>
</button>
</div>
<div class="ma-w__divider" />
@@ -724,17 +762,28 @@ const kebabItems = computed(() => {
<!-- COL 2: Calendar central -->
<div class="ma-cal">
<!-- Toolbar -->
<!-- Toolbar cluster nav (Hoje + chevrons) à esquerda, título
com flex+ellipsis no centro, view-switcher à direita. -->
<div class="ma-cal__toolbar">
<div class="ma-cal__nav">
<button class="ma-cal__btn" @click="goToday">Hoje</button>
<button class="ma-cal__icon" title="Anterior" @click="goPrev">
<i class="pi pi-chevron-left" />
</button>
<div class="ma-cal__period">{{ currentTitle }}</div>
<button class="ma-cal__icon" title="Próximo" @click="goNext">
<i class="pi pi-chevron-right" />
</button>
<div class="ma-cal__nav-cluster">
<button
class="ma-cal__btn"
:class="{ 'is-today-active': refDateIsToday }"
:disabled="refDateIsToday"
@click="goToday"
>
Hoje
</button>
<span class="ma-cal__nav-divider" />
<button class="ma-cal__icon" v-tooltip.top="'Anterior'" @click="goPrev">
<i class="pi pi-chevron-left" />
</button>
<button class="ma-cal__icon" v-tooltip.top="'Próximo'" @click="goNext">
<i class="pi pi-chevron-right" />
</button>
</div>
<div class="ma-cal__period" :title="currentTitle">{{ currentTitle }}</div>
</div>
<div class="ma-cal__view">
<button
@@ -751,6 +800,19 @@ const kebabItems = computed(() => {
<!-- FullCalendar view selecionada via :key pra re-mount limpo -->
<div class="ma-cal__fc">
<!-- Chip flutuante quando filtro de stat ativo. Click limpa. -->
<Transition name="ma-filter-chip">
<button
v-if="statFiltroAtivo"
class="ma-cal__filter-chip"
v-tooltip.bottom="'Click pra limpar'"
@click="statFiltroAtivo = null"
>
<i class="pi pi-filter-fill text-xs" />
<span>Filtrando: {{ statLabel(statFiltroAtivo) }}</span>
<i class="pi pi-times text-xs" />
</button>
</Transition>
<FullCalendar ref="fcRef" :key="calendarView" :options="fcOptions" />
</div>
</div>
@@ -1160,67 +1222,114 @@ const kebabItems = computed(() => {
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
gap: 10px;
gap: 14px;
min-width: 0;
}
.ma-cal__nav {
display: flex;
align-items: center;
gap: 6px;
gap: 10px;
min-width: 0;
flex: 1;
}
/* Cluster Hoje + chevrons num pill único pra ler como uma unidade */
.ma-cal__nav-cluster {
display: flex;
align-items: center;
gap: 2px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 2px;
flex-shrink: 0;
}
.ma-cal__nav-divider {
width: 1px;
height: 18px;
background: var(--m-border);
margin: 0 2px;
flex-shrink: 0;
}
.ma-cal__btn {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
background: transparent;
border: none;
color: var(--m-text);
padding: 6px 12px;
padding: 5px 12px;
border-radius: 8px;
font-size: 0.78rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background-color 140ms ease;
transition: background-color 140ms ease, opacity 140ms ease;
}
.ma-cal__btn:hover { background: var(--m-bg-soft-hover); }
.ma-cal__btn:focus-visible {
outline: 2px solid var(--m-accent);
outline-offset: 2px;
}
.ma-cal__btn.is-today-active {
opacity: 0.45;
cursor: default;
}
.ma-cal__btn.is-today-active:hover { background: transparent; }
.ma-cal__icon {
width: 30px; height: 30px;
width: 28px; height: 28px;
display: grid; place-items: center;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
background: transparent;
border: none;
color: var(--m-text);
border-radius: 8px;
border-radius: 7px;
cursor: pointer;
font-size: 0.7rem;
font-size: 0.78rem;
transition: background-color 140ms ease;
}
.ma-cal__icon:hover { background: var(--m-bg-soft-hover); }
.ma-cal__icon:focus-visible {
outline: 2px solid var(--m-accent);
outline-offset: 2px;
}
.ma-cal__period {
padding: 4px 14px;
flex: 1;
min-width: 0;
padding: 4px 4px;
color: var(--m-text);
font-size: 0.85rem;
font-weight: 500;
min-width: 130px;
text-align: center;
font-size: 0.92rem;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: capitalize;
}
.ma-cal__view {
display: flex;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 8px;
border-radius: 10px;
padding: 2px;
flex-shrink: 0;
}
.ma-cal__view-btn {
background: transparent;
border: none;
color: var(--m-text-muted);
padding: 4px 12px;
border-radius: 6px;
padding: 5px 12px;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 140ms ease;
}
.ma-cal__view-btn:hover { color: var(--m-text); }
.ma-cal__view-btn:focus-visible {
outline: 2px solid var(--m-accent);
outline-offset: 2px;
}
.ma-cal__view-btn.is-active {
background: var(--m-bg-soft-hover);
color: var(--m-text);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
/* ─── FullCalendar wrapper ─────────────────────────────────── */
@@ -1228,6 +1337,7 @@ const kebabItems = computed(() => {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative; /* ancora .ma-cal__filter-chip */
/* Vars CSS do próprio FC sobrescritas pra glass aesthetic adaptativo */
--fc-border-color: var(--m-border);
--fc-page-bg-color: transparent;
@@ -1240,6 +1350,51 @@ const kebabItems = computed(() => {
font-family: inherit;
}
/* Chip flutuante "Filtrando: X" — flutua sobre o canto superior direito
do FC quando há filtro de stat ativo. Click limpa. */
.ma-cal__filter-chip {
position: absolute;
top: 8px;
right: 8px;
z-index: 5;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: color-mix(in srgb, var(--m-accent) 22%, var(--m-bg-medium));
border: 1px solid var(--m-accent);
border-radius: 999px;
color: var(--m-text);
font-size: 0.72rem;
font-weight: 600;
font-family: inherit;
letter-spacing: 0.02em;
cursor: pointer;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: background-color 140ms ease, transform 140ms ease;
}
.ma-cal__filter-chip:hover {
background: color-mix(in srgb, var(--m-accent) 32%, var(--m-bg-medium));
transform: translateY(-1px);
}
.ma-cal__filter-chip:focus-visible {
outline: 2px solid var(--m-accent);
outline-offset: 2px;
}
/* Transition do chip — pop sutil de cima */
.ma-filter-chip-enter-active,
.ma-filter-chip-leave-active {
transition: opacity 180ms ease, transform 180ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.ma-filter-chip-enter-from,
.ma-filter-chip-leave-to {
opacity: 0;
transform: translateY(-6px) scale(0.92);
}
/* ─── Override do FullCalendar pra glass (via :deep) ───────── */
.ma-cal__fc :deep(.fc) {
height: 100%;
@@ -1651,6 +1806,26 @@ const kebabItems = computed(() => {
border-radius: 8px;
padding: 8px 4px;
text-align: center;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease, opacity 140ms ease;
}
.ma-stat:hover:not(:disabled) {
background: var(--m-bg-soft-hover);
transform: translateY(-1px);
}
.ma-stat:focus-visible {
outline: 2px solid var(--m-accent);
outline-offset: 2px;
}
.ma-stat:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ma-stat.is-active {
border-color: var(--m-accent);
background: color-mix(in srgb, var(--m-accent) 16%, var(--m-bg-soft));
box-shadow: 0 0 0 1px var(--m-accent);
}
.ma-stat__val {
font-size: 1.1rem;