melissa/agenda: view lista 2 anos + selector SelectButton + sticky header + visual inativo

View lista: 'listWeek' -> custom 'listAll' (duration { years: 2 },
centrada em hoje via gotoDate(hoje - 1 ano) no setView). Antes a lista
mostrava so 7 dias e ocultava 3 das 4 ocorrencias semanais — agora cobre
passado + presente + futuro numa varredura. Cap MAX_RANGE_DAYS=730 do
loadAndExpand bate exato com 2 anos.

Banner showRecurrenceHint: aparece quando ha virtuais visiveis em
day/week/month (nao mostra em listAll). Texto curto + botao "Ver na
lista" que chama setView('lista').

Sticky day header (.fc-list-day): adicionado position:relative + z-index 3
+ bg opaco. Sem isso, .fc-event passava POR CIMA do header conforme
scroll (stacking context da row de evento ganhava do cushion sem z-index).

View selector: botoes manuais (.ma-cal__view-btn) -> PrimeVue SelectButton.
Visual herdado do tema, menos CSS custom.

Visual evento inativo: classNames=['ma-evt--inactive-patient'] em fcEvents
quando paciente_status === 'Arquivado'|'Inativo'. CSS aplica borda
tracejada + opacidade 0.58 (italico em list view). Mantem a cor do
commitment pra preservar contexto.

FC touch defaults: adiciona spread de FC_TOUCH_DEFAULTS (utility commitada
antes) — paridade touch <-> mouse, tap dispara select na hora em vez de
exigir long-press de 1000ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-11 10:46:31 -03:00
parent 988a4e5892
commit 279b4f78e8
+113 -19
View File
@@ -22,6 +22,7 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import listPlugin from '@fullcalendar/list'; import listPlugin from '@fullcalendar/list';
import interactionPlugin from '@fullcalendar/interaction'; import interactionPlugin from '@fullcalendar/interaction';
import ptBrLocale from '@fullcalendar/core/locales/pt-br'; import ptBrLocale from '@fullcalendar/core/locales/pt-br';
import { FC_TOUCH_DEFAULTS } from '@/features/agenda/utils/fcDefaults';
import { useMelissaEventosRange, useMelissaTodasSessoesPaciente } from './composables/useMelissaEventos'; import { useMelissaEventosRange, useMelissaTodasSessoesPaciente } from './composables/useMelissaEventos';
import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda'; import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
import { useMelissaPacientesAside } from './composables/useMelissaPacientesAside'; import { useMelissaPacientesAside } from './composables/useMelissaPacientesAside';
@@ -244,7 +245,11 @@ const VIEW_MAP = {
dia: 'timeGridDay', dia: 'timeGridDay',
semana: 'timeGridWeek', semana: 'timeGridWeek',
mes: 'dayGridMonth', mes: 'dayGridMonth',
lista: 'listWeek' // listAll é view custom (configurada em fcOptions.views) — cobre 2 anos
// (1 ano antes + 1 ano depois de hoje). Substituiu listWeek que mostrava
// só 7 dias e escondia recorrências semanais/quinzenais futuras. Cap do
// loadAndExpand é 730d (MAX_RANGE_DAYS), 2 anos cai exatamente no limite.
lista: 'listAll'
}; };
const fcRef = ref(null); const fcRef = ref(null);
@@ -384,6 +389,11 @@ const fcEvents = computed(() => {
const evPid = ev.patient_id || ev.paciente_id; const evPid = ev.patient_id || ev.paciente_id;
if (onlySess && !evPid) continue; if (onlySess && !evPid) continue;
if (pacienteId && evPid !== pacienteId) continue; if (pacienteId && evPid !== pacienteId) continue;
// Eventos cujo paciente foi arquivado/desativado ganham classe
// visual ("inativo") — borda tracejada + opacidade reduzida no CSS
// abaixo. Mantem a cor do commitment pra nao perder contexto.
const pStatus = ev.paciente_status;
const isInactivePatient = pStatus === 'Arquivado' || pStatus === 'Inativo';
out.push({ out.push({
id: ev.id, id: ev.id,
title: ev.label, title: ev.label,
@@ -392,6 +402,7 @@ const fcEvents = computed(() => {
backgroundColor: `${ev.color}26`, // ~15% opacity backgroundColor: `${ev.color}26`, // ~15% opacity
borderColor: ev.color, borderColor: ev.color,
textColor: 'white', textColor: 'white',
classNames: isInactivePatient ? ['ma-evt--inactive-patient'] : undefined,
extendedProps: ev extendedProps: ev
}); });
} }
@@ -403,6 +414,19 @@ const fcEvents = computed(() => {
return out; return out;
}); });
// Hint pra trocar pra view 'lista' quando há ocorrências de recorrência
// visíveis em view dia/semana/mês. Cada view dessas cobre janela curta
// (1d / 7d / ~35d) — séries semanais com 4+ ocorrências sempre extrapolam.
// Lista cobre 2 anos centrada no usuário, então mostra passado/presente/futuro.
// Não mostra no modo "Ver todas" (já tá em modo lista paralela) nem na própria
// view lista. Some quando nenhum virtual aparece (sem recorrência ativa visível).
const showRecurrenceHint = computed(() => {
if (calendarView.value === 'lista') return false;
if (verTodasSessoes.value) return false;
if (!eventosSemana.value?.length) return false;
return eventosSemana.value.some((ev) => ev.is_occurrence);
});
// ── slotMinTime / slotMaxTime baseado em timeMode ───────────── // ── slotMinTime / slotMaxTime baseado em timeMode ─────────────
// 24: 0024h. 12: 0618h. my: range das workRules (snap em 30min). // 24: 0024h. 12: 0618h. my: range das workRules (snap em 30min).
function _hhmmToMin(t) { function _hhmmToMin(t) {
@@ -492,11 +516,22 @@ function slotLabelContent(arg) {
const fcOptions = computed(() => ({ const fcOptions = computed(() => ({
plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin], plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
...FC_TOUCH_DEFAULTS,
locale: ptBrLocale, locale: ptBrLocale,
headerToolbar: false, // toolbar é nossa (custom glass) headerToolbar: false, // toolbar é nossa (custom glass)
initialView: VIEW_MAP[calendarView.value] || 'timeGridWeek', initialView: VIEW_MAP[calendarView.value] || 'timeGridWeek',
initialDate: refDate.value, initialDate: refDate.value,
nowIndicator: true, nowIndicator: true,
// View custom "listAll": list view cobrindo 2 anos. Usada quando user clica
// o toggle "Lista" — exibe passadas + presentes + futuras numa varredura só.
// setView('lista') faz gotoDate(hoje - 1 ano) pra centrar.
views: {
listAll: {
type: 'list',
duration: { years: 2 },
buttonText: 'Lista'
}
},
// Drag/resize/select habilitam apenas com M (composable disponível) — // Drag/resize/select habilitam apenas com M (composable disponível) —
// standalone (sem M) fica readonly por compat (preview puro). // standalone (sem M) fica readonly por compat (preview puro).
editable: !!M, editable: !!M,
@@ -696,6 +731,14 @@ function goToday() { fcApi()?.today(); }
function setView(v) { function setView(v) {
calendarView.value = v; calendarView.value = v;
fcApi()?.changeView(VIEW_MAP[v]); fcApi()?.changeView(VIEW_MAP[v]);
// Lista cobre 2 anos — abrimos centrado: pula pra (hoje - 1 ano) pra
// mostrar passado + presente + futuro de uma vez. Outras views mantém
// o refDate atual (datesSet sincroniza viewStart/End normalmente).
if (v === 'lista') {
const umAnoAtras = new Date();
umAnoAtras.setFullYear(umAnoAtras.getFullYear() - 1);
fcApi()?.gotoDate(umAnoAtras);
}
} }
// ── Menu de Bloqueio (toolbar) ───────────────────────────────── // ── Menu de Bloqueio (toolbar) ─────────────────────────────────
@@ -1574,16 +1617,21 @@ defineExpose({
@bloqueio="onActionsBloqueio" @bloqueio="onActionsBloqueio"
/> />
<div class="ma-cal__view ma-cal__view--xl-only flex bg-[var(--m-bg-soft)] border border-[var(--m-border)] rounded-[10px] p-0.5 flex-shrink-0 max-[1279px]:hidden"> <div class="ma-cal__view ma-cal__view--xl-only flex-shrink-0 max-[1279px]:hidden">
<button <SelectButton
v-for="opt in [{v:'dia',l:'Dia'},{v:'semana',l:'Semana'},{v:'mes',l:'Mês'},{v:'lista',l:'Lista'}]" :model-value="calendarView"
:key="opt.v" :options="[
class="ma-cal__view-btn bg-transparent border-0 text-[var(--m-text-muted)] px-3 py-[5px] rounded-lg text-[0.75rem] font-medium [font-family:inherit] cursor-pointer transition-all duration-[140ms] hover:text-[var(--m-text)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2" { v: 'dia', l: 'Dia' },
:class="{ 'is-active': calendarView === opt.v }" { v: 'semana', l: 'Semana' },
@click="setView(opt.v)" { v: 'mes', l: 'Mês' },
> { v: 'lista', l: 'Lista' }
{{ opt.l }} ]"
</button> option-value="v"
option-label="l"
:allow-empty="false"
size="small"
@update:model-value="(v) => v && setView(v)"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -1662,6 +1710,32 @@ defineExpose({
</div> </div>
</Transition> </Transition>
<!-- Aviso quando há ocorrências de recorrência visíveis E o user
não está na view 'lista'. Cobre o caso "sessão recorrente
com count=4 aparece na semana atual; outras 3 ficam
fora do range visível". Botão troca pra view lista, que
cobre 2 anos (passado + futuro) numa varredura só. -->
<Transition name="ma-patient-banner">
<div
v-if="showRecurrenceHint"
class="ma-cal__recurrence-hint flex items-center gap-2.5 px-3.5 py-1.5 border-b border-[var(--m-border)] bg-[color-mix(in_srgb,var(--m-accent)_6%,var(--m-bg-medium))] text-[var(--m-text)] text-[0.78rem] flex-shrink-0"
role="status"
>
<i class="pi pi-refresh text-[var(--m-accent)] text-[0.74rem] flex-shrink-0" />
<span class="min-w-0 flex-1 leading-[1.3] text-[var(--m-text-muted)]">
Há sessões recorrentes visíveis — pode haver mais fora deste período.
</span>
<button
class="inline-flex items-center gap-1.5 px-2.5 py-[5px] rounded-lg border border-[var(--m-border)] bg-[var(--m-bg-soft)] text-[var(--m-text)] text-[0.74rem] font-medium [font-family:inherit] cursor-pointer transition-[background-color,border-color,color] duration-[140ms] flex-shrink-0 hover:bg-[var(--m-bg-soft-hover)] hover:border-[var(--m-border-strong)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2"
v-tooltip.bottom="'Lista cobre 2 anos centrada em hoje'"
@click="setView('lista')"
>
<i class="pi pi-list" />
<span>Ver na lista</span>
</button>
</div>
</Transition>
<!-- FullCalendar — view selecionada via :key pra re-mount limpo --> <!-- FullCalendar — view selecionada via :key pra re-mount limpo -->
<div class="ma-cal__fc"> <div class="ma-cal__fc">
<!-- Overlay leve enquanto eventos carregam. Não bloqueia interação, <!-- Overlay leve enquanto eventos carregam. Não bloqueia interação,
@@ -2015,18 +2089,14 @@ defineExpose({
/* ═══ COL 2: Calendar central ════════════════════════════════ */ /* ═══ COL 2: Calendar central ════════════════════════════════ */
/* .ma-cal*, .ma-cal__toolbar, .ma-cal__nav*, .ma-cal__btn (versão ghost /* .ma-cal*, .ma-cal__toolbar, .ma-cal__nav*, .ma-cal__btn (versão ghost
da toolbar), .ma-cal__icon, .ma-cal__period, .ma-cal__right, .ma-cal__view* da toolbar), .ma-cal__icon, .ma-cal__period, .ma-cal__right, .ma-cal__view*
migraram pra Tailwind no template. Aqui ficam SO os state modifiers: migraram pra Tailwind no template. Aqui fica SO o state modifier
.is-today-active (botao Hoje desabilitado) e .is-active do view-btn. */ .is-today-active (botao Hoje desabilitado). O view selector agora e
PrimeVue SelectButton — visual herdado do tema do projeto. */
.ma-cal__btn.is-today-active { .ma-cal__btn.is-today-active {
opacity: 0.45; opacity: 0.45;
cursor: default; cursor: default;
} }
.ma-cal__btn.is-today-active:hover { background: transparent; } .ma-cal__btn.is-today-active:hover { background: transparent; }
.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);
}
/* .ma-cal__loading migrou pra Tailwind utilities no template. */ /* .ma-cal__loading migrou pra Tailwind utilities no template. */
.ma-loading-fade-enter-active, .ma-loading-fade-enter-active,
@@ -2171,6 +2241,16 @@ defineExpose({
.ma-cal__fc :deep(.fc-list-day-cushion) { .ma-cal__fc :deep(.fc-list-day-cushion) {
background: var(--m-bg-soft); background: var(--m-bg-soft);
} }
/* Sticky day header em listAll precisa de z-index + bg opaco — sem isso,
conforme o user dá scroll, .fc-list-event passa POR CIMA do header
(stacking context da row de evento vence o cushion sem z-index). */
.ma-cal__fc :deep(.fc-list-day) {
position: relative;
z-index: 3;
}
.ma-cal__fc :deep(.fc-list-day > *) {
background: var(--m-bg-medium);
}
.ma-cal__fc :deep(.fc-list-event:hover td) { .ma-cal__fc :deep(.fc-list-event:hover td) {
background: var(--m-bg-soft); background: var(--m-bg-soft);
} }
@@ -2197,6 +2277,20 @@ defineExpose({
.ma-cal__fc :deep(.fc-event-main) { .ma-cal__fc :deep(.fc-event-main) {
padding: 0; padding: 0;
} }
/* Eventos de paciente Inativo/Arquivado — borda tracejada + opacidade reduzida.
Mantem a cor do commitment (contexto preservado) e funciona em todas as
views (day/week/month/list) porque ataca .fc-event direto. */
.ma-cal__fc :deep(.fc-event.ma-evt--inactive-patient) {
opacity: 0.58;
border-left-style: dashed;
}
.ma-cal__fc :deep(.fc-list-event.ma-evt--inactive-patient) {
opacity: 0.58;
}
.ma-cal__fc :deep(.fc-list-event.ma-evt--inactive-patient td) {
font-style: italic;
}
.ma-cal__fc :deep(.mc-fc-event) { .ma-cal__fc :deep(.mc-fc-event) {
padding: 4px 6px; padding: 4px 6px;
color: var(--m-text); color: var(--m-text);
@@ -2393,7 +2487,7 @@ html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--modal.is-presencial)
font-size: 0.7rem; font-size: 0.7rem;
} }
/* Lista (listWeek) */ /* Lista (listAll — view custom 2 anos centrada em hoje) */
.ma-cal__fc :deep(.fc-list-day-text), .ma-cal__fc :deep(.fc-list-day-text),
.ma-cal__fc :deep(.fc-list-day-side-text) { .ma-cal__fc :deep(.fc-list-day-side-text) {
color: var(--m-text); color: var(--m-text);