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:
@@ -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 há 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;
|
||||
|
||||
Reference in New Issue
Block a user