Files
agenciapsilmno/src/layout/melissa/MelissaAgenda.vue
T
Leonardo 279b4f78e8 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>
2026-05-11 10:46:31 -03:00

2932 lines
139 KiB
Vue
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.
<script setup>
/*
* MelissaAgenda — Sessão Agenda como página fullscreen dentro de Melissa
* --------------------------------------------------
* Convenção das "Melissa Pages":
* - 6px de padding do viewport
* - Glass container com cantos arredondados
*
* Layout (adaptado da AgendaTerapeutaPage existente):
* - COL 1 — Aside esquerda (~280px): lista de pacientes (search + lista)
* - COL 2 — Calendar central (flex 1): toolbar + week-view mock
* - COL 3 — Widgets direita (~320px): mini-cal + stats + sessões hoje
*
* Mock visual (sem FullCalendar real). Quando integrar de verdade,
* trocar a área central pelo FullCalendar com tema glass.
*/
import { ref, computed, onMounted, onBeforeUnmount, watch, inject } from 'vue';
import { useRouter } from 'vue-router';
import FullCalendar from '@fullcalendar/vue3';
import timeGridPlugin from '@fullcalendar/timegrid';
import dayGridPlugin from '@fullcalendar/daygrid';
import listPlugin from '@fullcalendar/list';
import interactionPlugin from '@fullcalendar/interaction';
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
import { FC_TOUCH_DEFAULTS } from '@/features/agenda/utils/fcDefaults';
import { useMelissaEventosRange, useMelissaTodasSessoesPaciente } from './composables/useMelissaEventos';
import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
import { useMelissaPacientesAside } from './composables/useMelissaPacientesAside';
import { useTenantStore } from '@/stores/tenantStore';
import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosCard.vue';
import MelissaAgendaHistoricoCard from './MelissaAgendaHistoricoCard.vue';
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
import Popover from 'primevue/popover';
import MelissaAgendaSearchPopover from './MelissaAgendaSearchPopover.vue';
import MelissaAgendaActionsPopover from './MelissaAgendaActionsPopover.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { getSessionCounts } from '@/features/patients/services/patientsRepository';
// `Menu` PrimeVue: NÃO importar explicitamente — projeto usa auto-import
// via PrimeVueResolver (vite.config). Importar gera instâncias duplicadas
// e causa "Cannot read properties of null (reading 'emitsOptions')"
// quando Vue tenta reconciliar.
const props = defineProps({
pacientes: { type: Array, default: () => [] },
// eventosHoje: removido — agora deriva do composable interno (semana real)
pacientesLoading: { type: Boolean, default: false }
});
const emit = defineEmits(['select-evento', 'close', 'patient-created']);
// ── Estado ─────────────────────────────────────────────────────
const router = useRouter();
const busca = ref('');
const pacienteSelecionadoId = ref(null);
const calendarView = ref('semana'); // 'dia' | 'semana' | 'mes' | 'lista'
const refDate = ref(new Date()); // data de referência (sincronizada com o FC via datesSet)
// ── Filtros adicionais (espelham AgendaTerapeutaPage) ─────────
// timeMode: '24' (0024h) | '12' (0618h) | 'my' (intervalo da workRules)
const timeMode = ref('my');
const timeModeOptions = [
{ label: '24h', value: '24' },
{ label: '12h', value: '12' },
{ label: 'Meu', value: 'my' }
];
// onlySessions: true filtra eventos não-paciente do FC + lista
const onlySessions = ref(false);
const onlySessionsOptions = [
{ label: 'Apenas Sessões', value: true },
{ label: 'Tudo', value: false }
];
// ── Breakpoints ──────────────────────────────────────────────
// Dois pontos:
// <xl (≤1279px) → "compacto" — filtros viajam pra dentro do botão
// "Ações" da toolbar
// <lg (≤1023px) → "mobile" — aside+widgets viram drawer off-canvas
// (Teleportado pra fora do .ma-page) e o botão "Menu"
// aparece à esquerda do título no header
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);
// Timer de discriminacao click vs dblclick — se desmontar entre o
// setTimeout e o callback (220ms), o callback chamaria selecionarPaciente
// numa instancia morta. clearTimeout aqui evita o warn do Vue + side
// effect spurioso na proxima rota.
if (_patClickTimer) {
clearTimeout(_patClickTimer);
_patClickTimer = null;
}
});
function toggleDrawer() {
drawerOpen.value = !drawerOpen.value;
}
function fecharDrawer() {
drawerOpen.value = false;
}
// ── Configurações da agenda (botão no header) ─────────────────
// MelissaAgenda esta sempre dentro de /melissa/*, entao roteamos pra
// pagina nativa Melissa equivalente (agenda-config) em vez de vazar
// pro layout antigo de /configuracoes/agenda.
function goSettings() {
router.push('/melissa/agenda-config');
}
// ── "Ações" (popover) — toolbar compacta em <xl ───────────────
// Componente MelissaAgendaActionsPopover (autocontido) renderiza o
// popover inteiro. Pai expoe um ref pra abrir via botao da toolbar
// + handler `bloqueio` que delega pro composable da agenda (M).
const actionsPopover = ref(null);
function onActionsBloqueio(mode) {
M?.openBloqueioDialog?.(mode);
}
// ── Pacientes do aside (paginação async server-side) ─────────
// Pra não carregar milhares de registros de uma vez, o aside usa um
// composable dedicado que pagina no banco (6 por request, busca via
// .ilike). A prop `pacientes` (lista completa do useMelissaPacientes)
// continua sendo usada pra `pacienteSelecionado` (lookup por ID) — clínicas
// com clínicas até ~1k pacientes ainda mantêm a lista em memória.
//
// Trade-off: ordenação é puramente alfabética. O destaque "novo" continua
// (badge + bg) quando o paciente recém-cadastrado cair na página visível,
// mas não é mais empurrado pro topo automaticamente.
const NOVO_THRESHOLD_DIAS = 7;
const NOVO_THRESHOLD_MS = NOVO_THRESHOLD_DIAS * 24 * 60 * 60 * 1000;
function isPacienteNovo(p) {
if (!p?.created_at) return false;
const t = new Date(p.created_at).getTime();
if (Number.isNaN(t)) return false;
return Date.now() - t < NOVO_THRESHOLD_MS;
}
// Paginação da lista de pacientes (max 6 por página, server-side)
const PACIENTES_POR_PAGINA = 6;
const pacientesPagina = ref(1);
const {
pacientes: pacientesAside,
total: pacientesAsideTotal,
loading: pacientesAsideLoading,
refetch: refetchPacientesAside
} = useMelissaPacientesAside({
pagina: pacientesPagina,
busca,
porPagina: PACIENTES_POR_PAGINA
});
function onPacientesPageChange(event) {
// Guard contra spam de click durante fetch — sem isso, click rapido em
// "next" 3x dispara 3 requests sequenciais; se voltarem fora de ordem,
// a pagina exibida nao bate com a solicitada. pacientesAsideLoading
// vem do composable e fica true durante o fetch.
if (pacientesAsideLoading.value) return;
pacientesPagina.value = event.page + 1;
}
// Reset pra página 1 quando busca muda (composable dispara o fetch novo).
watch(busca, () => { pacientesPagina.value = 1; });
// Se a página atual ficar fora do range (ex.: paciente removido), volta pra
// última válida. Ouve o total que vem do servidor.
watch(pacientesAsideTotal, (totalNovo) => {
const maxPag = Math.max(1, Math.ceil(totalNovo / PACIENTES_POR_PAGINA));
if (pacientesPagina.value > maxPag) pacientesPagina.value = maxPag;
});
function pacienteIniciais(nome) {
if (!nome) return '?';
const partes = nome.trim().split(/\s+/);
if (partes.length === 1) return partes[0][0]?.toUpperCase() || '?';
return (partes[0][0] + partes[partes.length - 1][0]).toUpperCase();
}
function fmtUltimaSessao(iso) {
if (!iso) return 'Sem atendimento';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return 'Sem atendimento';
return `Última sessão: ${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function selecionarPaciente(id) {
pacienteSelecionadoId.value = pacienteSelecionadoId.value === id ? null : id;
}
// click vs dblclick na linha do paciente: click toggla seleção, dblclick
// abre prontuário. Sem o defer, o dblclick dispararia 2 cliques antes
// (toggle on → toggle off → prontuário), deixando o paciente desselecionado.
// 220ms é a janela típica que o SO usa pra dblclick.
let _patClickTimer = null;
function pacienteRowClick(id) {
if (_patClickTimer) {
clearTimeout(_patClickTimer);
_patClickTimer = null;
}
_patClickTimer = setTimeout(() => {
selecionarPaciente(id);
_patClickTimer = null;
}, 220);
}
function pacienteRowDblclick(id) {
if (_patClickTimer) {
clearTimeout(_patClickTimer);
_patClickTimer = null;
}
pacienteSelecionadoId.value = id;
abrirProntuarioPorId(id);
}
function abrirProntuarioPorId(id) {
if (!id) return;
// Fase 8 wire-up: navega pra MelissaPaciente nativo.
router.push({ path: '/melissa/paciente', query: { id: String(id) } });
}
// ── Calendar (FullCalendar) ───────────────────────────────────
// View modes mapeados pra view names do FC
const VIEW_MAP = {
dia: 'timeGridDay',
semana: 'timeGridWeek',
mes: 'dayGridMonth',
// 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);
// 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;
});
// Composable injetado pelo MelissaLayout — fonte única de eventos do FC
// (real + ocorrências virtuais via useRecurrence). viewStart/End vivem no
// composable; mutamos elas no datesSet do FC pra disparar o refetch lá.
//
// Fallback: se MelissaAgenda for usado fora do MelissaLayout (preview
// standalone), cai pra refs locais e composable legado useMelissaEventosRange.
const M = inject(MELISSA_AGENDA_KEY, null);
// ── Loading flags pra UI (skeletons + disable de botões) ──────
// Skeleton só na PRIMEIRA carga (sem dados ainda). Subsequentes — refetch
// silencioso por mudança de range/filtro — não pisca skeleton, mantém UI.
// Skeleton aparece enquanto o composable do aside ainda não trouxe a 1ª
// página (ou enquanto a prop principal está carregando e ainda vazia).
const pacientesCarregandoInicial = computed(
() => pacientesAsideLoading.value && (pacientesAside.value?.length || 0) === 0
);
const eventosCarregandoInicial = computed(
() => !!M?.loading?.value && (M?.eventos?.value?.length || 0) === 0
);
// Botão "+ Agendar" — feedback durante a abertura do AgendaEventDialog
// (a abertura em si carrega commitments+pacientes+settings via composable).
const agendarBusy = ref(false);
async function onAgendar() {
if (!M?.onCreateEvento || agendarBusy.value) return;
agendarBusy.value = true;
try {
await M.onCreateEvento();
} finally {
// Pequeno timeout pra UI mostrar o spinner mesmo quando a operação
// é síncrona (perceived performance).
setTimeout(() => { agendarBusy.value = false; }, 200);
}
}
const _viewStartLocal = ref(new Date());
const _viewEndLocal = ref(new Date());
{
const hoje = new Date();
const dow = hoje.getDay();
const diff = dow === 0 ? -6 : 1 - dow;
const segunda = new Date(hoje);
segunda.setDate(hoje.getDate() + diff);
segunda.setHours(0, 0, 0, 0);
const domingoNext = new Date(segunda);
domingoNext.setDate(segunda.getDate() + 7);
_viewStartLocal.value = segunda;
_viewEndLocal.value = domingoNext;
refDate.value = hoje;
}
const viewStart = M?.viewStart || _viewStartLocal;
const viewEnd = M?.viewEnd || _viewEndLocal;
// Quando há M (caso normal), eventos vêm dele. Sem M (standalone), cai pro
// composable legado que SÓ traz eventos reais (sem recorrência virtual).
const _legacyRange = M ? null : useMelissaEventosRange(viewStart, viewEnd);
const eventosSemana = M?.eventos ?? _legacyRange?.eventos ?? ref([]);
const refetchEventosFc = M?.refetch ?? _legacyRange?.refetch ?? (() => {});
// Mapa eventos.dateKey → array (pra widgets de stats e sessões hoje)
const eventosPorDia = computed(() => {
const map = {};
for (const ev of eventosSemana.value) {
(map[ev.dateKey] ||= []).push(ev);
}
return map;
});
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.
// fcEvents: filtra+mapeia em UMA passada (for loop) em vez de
// filter().filter().filter().map() (4 iteracoes do array). Em listView
// mensal com 500 eventos, a versao antiga fazia 2000 iteracoes de
// callback; esta faz 500. Resto da logica espelha AgendaTerapeutaPage:446.
//
// onlySessions filtra eventos sem paciente vinculado (compromissos
// pessoais como "Análise" ainda usam tipo='sessao' no banco — patient_id
// é o discriminador real).
//
// Quando há paciente selecionado, restringe aos eventos dele. Feriados
// (background events) não passam por esse filtro — vêm direto do array
// de feriadoFcEvents, mantendo contexto visual do dia.
const fcEvents = computed(() => {
const statKey = statFiltroAtivo.value;
const statPred = statKey ? STAT_FILTERS[statKey] : null;
const onlySess = onlySessions.value;
const pacienteId = pacienteSelecionadoId.value;
const out = [];
for (const ev of eventosSemana.value) {
if (statPred && !statPred(ev)) continue;
const evPid = ev.patient_id || ev.paciente_id;
if (onlySess && !evPid) 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({
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',
classNames: isInactivePatient ? ['ma-evt--inactive-patient'] : undefined,
extendedProps: ev
});
}
// Feriados (background events) — concat direto sem filtro.
const feriados = feriadoFcEvents.value;
if (feriados.length) {
for (const f of feriados) out.push(f);
}
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 ─────────────
// 24: 0024h. 12: 0618h. my: range das workRules (snap em 30min).
function _hhmmToMin(t) {
const [h, m] = String(t || '00:00').split(':').map(Number);
return (h || 0) * 60 + (m || 0);
}
function _floorTo30(hhmmss) {
const m = _hhmmToMin(String(hhmmss).slice(0, 5));
const f = m - (m % 30);
return `${String(Math.floor(f / 60)).padStart(2, '0')}:${String(f % 60).padStart(2, '0')}:00`;
}
function _ceilTo30(hhmmss) {
const m = _hhmmToMin(String(hhmmss).slice(0, 5));
const c = m % 30 === 0 ? m : m + (30 - (m % 30));
return `${String(Math.floor(c / 60)).padStart(2, '0')}:${String(c % 60).padStart(2, '0')}:00`;
}
function _myHoursRange() {
const rules = (workRules.value || []).filter((r) => r.ativo !== false);
if (!rules.length) return { start: '08:00:00', end: '20:00:00' };
const starts = rules.map((r) => _floorTo30(r.hora_inicio));
const ends = rules.map((r) => _ceilTo30(r.hora_fim));
return {
start: starts.reduce((a, b) => (a < b ? a : b)),
end: ends.reduce((a, b) => (a > b ? a : b))
};
}
const slotMinTime = computed(() => {
if (timeMode.value === '24') return '00:00:00';
if (timeMode.value === '12') return '06:00:00';
return _myHoursRange().start;
});
const slotMaxTime = computed(() => {
if (timeMode.value === '24') return '24:00:00';
if (timeMode.value === '12') return '18:00:00';
return _myHoursRange().end;
});
function escHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// Mapeia status do banco → label exibido nas badges. Cobre as variantes
// fem./masc. ('realizada' vs 'realizado') que aparecem em registros antigos.
// Default 'Agendado' quando vier vazio (sessões recém-criadas).
function statusLabelFc(s) {
const v = String(s || '').toLowerCase().trim();
if (!v) return 'Agendado';
const map = {
agendado: 'Agendado',
agendada: 'Agendado',
confirmado: 'Confirmado',
confirmada: 'Confirmado',
realizado: 'Realizado',
realizada: 'Realizado',
faltou: 'Faltou',
cancelado: 'Cancelado',
cancelada: 'Cancelado',
remarcado: 'Remarcado',
remarcada: 'Remarcado'
};
return map[v] || (v.charAt(0).toUpperCase() + v.slice(1));
}
function statusSlugFc(s) {
return String(s || 'agendado').toLowerCase().trim().replace(/\s+/g, '-') || 'agendado';
}
function modalLabelFc(m) {
const v = String(m || '').toLowerCase().trim();
if (!v) return '';
if (v === 'online') return 'Online';
if (v === 'presencial') return 'Presencial';
return m.charAt(0).toUpperCase() + m.slice(1);
}
// Custom render do label de hora — matches pattern do AgendaTerapeutaPage
function slotLabelContent(arg) {
const min = arg.date.getMinutes();
if (min === 0) {
const h = String(arg.date.getHours()).padStart(2, '0');
return { html: `<span class="mc-slot-hour">${h}:00</span>` };
}
return { html: '<span class="mc-slot-half">:30</span>' };
}
const fcOptions = computed(() => ({
plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
...FC_TOUCH_DEFAULTS,
locale: ptBrLocale,
headerToolbar: false, // toolbar é nossa (custom glass)
initialView: VIEW_MAP[calendarView.value] || 'timeGridWeek',
initialDate: refDate.value,
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) —
// standalone (sem M) fica readonly por compat (preview puro).
editable: !!M,
selectable: !!M,
selectMirror: true,
weekends: true,
// Header da semana ("seg 20/04") vira link clicável → vai pra view dia.
// Default do FC mandaria pra dayGridDay; forçamos timeGridDay (nossa "dia").
navLinks: true,
navLinkDayClick: (date) => {
fcApi()?.changeView('timeGridDay', date);
calendarView.value = 'dia';
},
allDaySlot: false, // tira a faixa "Dia inteiro" — recupera altura
// Slots de 30min com label em CADA linha (08:00, :30, 09:00, :30...)
// Snap continua em 15min pra futura criação/drag de eventos.
slotMinTime: slotMinTime.value,
slotMaxTime: slotMaxTime.value,
slotDuration: '00:30:00',
snapDuration: '00:15:00',
slotLabelInterval: '00:30:00',
slotLabelContent,
expandRows: true,
height: '100%',
dayMaxEvents: true,
eventMinHeight: 14,
events: fcEvents.value,
datesSet: (info) => {
viewStart.value = info.start;
viewEnd.value = info.end;
currentTitle.value = info.view?.title || '';
const cd = fcRef.value?.getApi?.()?.getDate?.();
if (cd) refDate.value = new Date(cd);
},
eventClick: (info) => {
const ev = info.event.extendedProps;
if (ev) emit('select-evento', ev);
},
// Drag → reagenda evento (mesmo dia, hora diferente OU outro dia)
eventDrop: (info) => {
if (!M) { info.revert?.(); return; }
M.persistMoveOrResize(info, 'Sessão movida');
// audit_logs grava no AFTER trigger; pequeno delay garante que
// a query do histórico já pegue a entrada nova.
setTimeout(() => historicoCardRef.value?.refetch(), 700);
},
// Resize → muda duração da sessão
eventResize: (info) => {
if (!M) { info.revert?.(); return; }
M.persistMoveOrResize(info, 'Duração alterada');
setTimeout(() => historicoCardRef.value?.refetch(), 700);
},
// Click-drag em área vazia → abre dialog pra criar evento novo, com
// start/end pré-preenchidos. AgendaEventDialog cuida do resto (seleção
// de paciente, modalidade, recorrência).
select: (info) => {
if (!M) return;
M.onSelectTime({ start: info.start, end: info.end });
},
eventContent: (arg) => {
const ext = arg.event.extendedProps || {};
const titulo = arg.event.title || '—';
const isSessao = String(ext.tipo || '').toLowerCase() === 'sessao';
const fmtHour = (d) => {
if (!d) return '';
const h = d.getHours();
const m = d.getMinutes();
return m === 0 ? `${h}h` : `${h}:${String(m).padStart(2, '0')}h`;
};
const range = arg.event.start && arg.event.end
? `${fmtHour(arg.event.start)}-${fmtHour(arg.event.end)}`
: (arg.timeText || '');
// Badges só pra sessões — compromissos pessoais/bloqueios/feriados
// não têm status nem modalidade relevantes pra exibir.
let badgesHtml = '';
if (isSessao) {
const statusLbl = statusLabelFc(ext.status);
const statusSlug = statusSlugFc(ext.status);
const modalLbl = modalLabelFc(ext.modalidade);
const modalSlug = String(ext.modalidade || '').toLowerCase().trim() || '';
badgesHtml = `
<div class="mc-fc-event__badges">
<span class="mc-fc-event__badge mc-fc-event__badge--status is-${escHtml(statusSlug)}">${escHtml(statusLbl)}</span>
${modalLbl ? `<span class="mc-fc-event__badge mc-fc-event__badge--modal is-${escHtml(modalSlug)}">${escHtml(modalLbl)}</span>` : ''}
</div>
`;
}
// Pra eventos não-sessão (compromisso, bloqueio etc.) mantém o
// antigo `__meta` com modalidade ou título secundário.
const metaFallback = !isSessao ? (ext.modalidade || ext.titulo || '') : '';
const titleLine = `<div class="mc-fc-event__title"><span class="mc-fc-event__name">${escHtml(titulo)}</span>${range ? ` <span class="mc-fc-event__hour">(${escHtml(range)})</span>` : ''}</div>`;
return {
html: `
<div class="mc-fc-event">
${titleLine}
${badgesHtml}
${metaFallback ? `<div class="mc-fc-event__meta">${escHtml(metaFallback)}</div>` : ''}
</div>
`
};
}
}));
// ── Busca da toolbar (datas + paciente/título) ────────────────
// Componente MelissaAgendaSearchPopover (autocontido) gerencia o Cmd+K
// search inteiro — input, debounce, parsing de datas, resultados.
// Aqui só ficam o ref pro toggle (botão da toolbar + hotkey global) e
// os 2 handlers que decidem o que fazer com a escolha (gotoDate +
// auto-select de paciente quando há patient_id no evento).
const searchPopover = ref(null);
function onBuscaGotoDate(date) {
fcApi()?.gotoDate(date);
refDate.value = new Date(date);
}
function onBuscaSelectEvento(ev) {
if (!ev?.inicio_em) return;
fcApi()?.gotoDate(ev.inicio_em);
refDate.value = new Date(ev.inicio_em);
// Auto-seleciona o paciente se o evento tiver um — assim a agenda já
// fica filtrada por ele e o dock contextual aparece.
if (ev.patient_id) {
pacienteSelecionadoId.value = ev.patient_id;
}
}
// Card de histórico (audit_logs) — ref pra disparar refetch após
// mutações; handler que abre o evento clicado pelo id.
const historicoCardRef = ref(null);
async function onHistoricoOpen({ id }) {
if (!id) return;
try {
const { data, error } = await supabase
.from('agenda_eventos')
.select('*, patients!agenda_eventos_patient_id_fkey(nome_completo, status, avatar_url)')
.eq('id', id)
.maybeSingle();
if (error || !data) {
// Evento pode ter sido deletado depois da entrada — fail-soft.
return;
}
// Validacao de datas: audit_log pode apontar pra evento incompleto
// (inicio_em/fim_em null/invalido). Sem esse guard, startH/endH
// viravam NaN e propagavam pro MelissaEventoPanel — bug silencioso.
const inicioDate = new Date(data.inicio_em);
const fimDate = new Date(data.fim_em);
if (!data.inicio_em || !data.fim_em ||
Number.isNaN(inicioDate.getTime()) || Number.isNaN(fimDate.getTime())) {
// eslint-disable-next-line no-console
console.warn('[onHistoricoOpen] evento sem datas validas, ignorado:', id);
return;
}
// Foca no dia + emite seleção pro MelissaLayout abrir o panel.
const ev = {
...data,
patient_id: data.patient_id,
paciente_nome: data.patients?.nome_completo || '',
paciente_status: data.patients?.status || '',
paciente_avatar: data.patients?.avatar_url || '',
startH: inicioDate.getHours() + inicioDate.getMinutes() / 60,
endH: fimDate.getHours() + fimDate.getMinutes() / 60,
label: data.patients?.nome_completo || data.titulo || data.titulo_custom || '—'
};
fcApi()?.gotoDate(data.inicio_em);
refDate.value = inicioDate;
emit('select-evento', ev);
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[onHistoricoOpen]', e);
}
}
// Atalho global Ctrl/Cmd+K abre a busca via ref do popover.
function _onSearchHotkey(e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
// Anchor virtual no botão da toolbar — necessário pra Popover do
// PrimeVue posicionar corretamente.
const btn = document.querySelector('.ma-cal__search-btn');
if (btn) searchPopover.value?.toggle({ currentTarget: btn, target: btn });
}
}
onMounted(() => { window.addEventListener('keydown', _onSearchHotkey); });
onBeforeUnmount(() => { window.removeEventListener('keydown', _onSearchHotkey); });
// Toolbar — atalhos pra FC API
function fcApi() {
return fcRef.value?.getApi?.() || null;
}
function goPrev() { fcApi()?.prev(); }
function goNext() { fcApi()?.next(); }
function goToday() { fcApi()?.today(); }
function setView(v) {
calendarView.value = 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) ─────────────────────────────────
// Espelha o blockMenuItems da AgendaTerapeutaPage. Cada modo abre o
// BloqueioDialog renderizado no MelissaLayout (via composable M).
const bloqueioMenuRef = ref(null);
const bloqueioMenuItems = computed(() => [
{ label: 'Bloquear por horário', icon: 'pi pi-clock', command: () => M?.openBloqueioDialog?.('horario') },
{ label: 'Bloquear por período', icon: 'pi pi-calendar-clock', command: () => M?.openBloqueioDialog?.('periodo') },
{ label: 'Bloquear por dia', icon: 'pi pi-calendar-times', command: () => M?.openBloqueioDialog?.('dia') },
{ label: 'Bloqueio por feriados', icon: 'pi pi-star', command: () => M?.openBloqueioDialog?.('feriados') }
]);
function openBloqueioMenu(event) {
bloqueioMenuRef.value?.toggle(event);
}
function fmtHora(h) {
const horas = Math.floor(h);
const mins = Math.round((h - horas) * 60);
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
function clicarEvento(ev) {
emit('select-evento', ev);
}
// ── Mini-calendar (mês atual) ──────────────────────────────────
const miniRefDate = ref(new Date());
// ── Feriados (nacionais via algoritmo + municipais/personalizados via DB)
// + Jornada semanal (workRules) pra marcar dias fechados em cinza no mini-cal.
// Reusa as refs do composable injetado M — antes essa página instanciava
// novamente useFeriados() e useAgendaSettings(), gerando duplicação de
// queries (feriados municipais + agenda_configuracoes + agenda_regras).
const tenantStore = useTenantStore();
// Fallbacks pra modo standalone (sem MelissaLayout parent / M=null) —
// mesmo pattern de viewStart/viewEnd/onCreateEvento. Antes esses 5
// acessos diretos a M.x dispararam TypeError ao montar fora do layout.
const _feriadosFallback = ref([]);
const _feriadoFcEventsFallback = ref([]);
const _feriadosAnoFallback = ref(new Date().getFullYear());
const _workRulesFallback = ref([]);
const feriadosTodos = M?.feriados ?? _feriadosFallback;
const feriadoFcEvents = M?.feriadoFcEvents ?? _feriadoFcEventsFallback;
const feriadosAno = M?.feriadosAno ?? _feriadosAnoFallback;
const loadFeriados = M?.loadFeriadosBase ?? (async () => {});
const workRules = M?.workRules ?? _workRulesFallback;
// Set de dias da semana ativos (0=dom..6=sáb). Fallback seg-sex se sem regras
// — mesmo default do AgendaTerapeutaPage:370.
const workDowSet = computed(() => {
const ativos = workRules.value.filter((r) => r.ativo).map((r) => Number(r.dia_semana));
if (ativos.length > 0) return new Set(ativos);
return new Set([1, 2, 3, 4, 5]);
});
// Carga inicial de feriados/settings já é feita pelo useMelissaAgenda no
// mount (watch immediate em clinicTenantId + loadSettings paralelo). Não
// duplicamos aqui — só mantemos o reload de feriados quando o mini-cal
// navega pra outro ano (municipais variam por ano).
watch(
() => miniRefDate.value.getFullYear(),
(novoAno) => {
if (novoAno === feriadosAno.value) return;
const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
if (tid) loadFeriados(tid, novoAno);
}
);
// Mapa dateKey → primeiro feriado do dia (raríssimo ter > 1).
// Usado pro mini-cal aplicar cor + tooltip.
const feriadosPorDataKey = computed(() => {
const map = {};
for (const f of feriadosTodos.value) {
// f.data já vem no formato 'YYYY-MM-DD' (date column do Postgres)
if (!map[f.data]) map[f.data] = { tipo: f.tipo, nome: f.nome };
}
return map;
});
const miniMesAno = computed(() => {
return miniRefDate.value
.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })
.replace(/(^|\s)\S/g, (l) => l.toUpperCase());
});
// Range do grid do mini-cal — cobre os 42 slots (6 semanas) que aparecem
// na grade, incluindo dias do mês anterior/próximo no padding. Assim o
// composable de eventos cobre tudo que tá visível, não só o mês atual.
const miniRangeStart = computed(() => {
const r = miniRefDate.value;
const primeiro = new Date(r.getFullYear(), r.getMonth(), 1);
const inicio = primeiro.getDay(); // 0=dom; quantos dias do mês anterior aparecem
const start = new Date(primeiro);
start.setDate(primeiro.getDate() - inicio);
start.setHours(0, 0, 0, 0);
return start;
});
const miniRangeEnd = computed(() => {
const start = miniRangeStart.value;
const end = new Date(start);
end.setDate(start.getDate() + 42); // 42 dias depois (exclusivo)
return end;
});
// Fetch separado pros eventos do range visível no mini-cal (≠ semana do FC).
// Mantém o mini com dots reais mesmo navegando meses sem mexer no FC.
const { eventos: eventosMini, refetch: refetchEventosMini } = useMelissaEventosRange(miniRangeStart, miniRangeEnd);
// Tenant ID reativo — pra passar pro ProximosFeriadosCard como prop string
// (ele aceita mas faz boot interno se vazio).
const currentTenantId = computed(
() => tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null
);
// Quando o card bloqueia um feriado, ele faz update em agenda_eventos
// (status='remarcado'). Recarrega ambos os fetchs pra refletir no UI.
function onFeriadoBloqueado() {
refetchEventosFc();
refetchEventosMini();
}
// Mapa dateKey → quantidade de eventos (cap em 3 pra render de dots).
// Filtra pelo paciente selecionado pra coerência com o FC central.
const eventosCountPorDiaMini = computed(() => {
const map = {};
const pid = pacienteSelecionadoId.value;
for (const ev of eventosMini.value) {
if (pid && (ev.patient_id || ev.paciente_id) !== pid) continue;
map[ev.dateKey] = (map[ev.dateKey] || 0) + 1;
}
return map;
});
const miniDias = computed(() => {
const ref = miniRefDate.value;
const ano = ref.getFullYear();
const mes = ref.getMonth();
const primeiroDia = new Date(ano, mes, 1);
const ultimoDia = new Date(ano, mes + 1, 0);
const inicio = primeiroDia.getDay(); // 0=dom
const dias = workDowSet.value;
const arr = [];
// Padding antes (do mês anterior, em cinza)
for (let i = 0; i < inicio; i++) {
const d = new Date(ano, mes, -i);
const k = dateKey(d);
arr.unshift({
dia: d.getDate(),
outro: true,
isHoje: false,
fechado: !dias.has(d.getDay()),
count: eventosCountPorDiaMini.value[k] || 0,
feriado: feriadosPorDataKey.value[k] || null,
date: d
});
}
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
for (let d = 1; d <= ultimoDia.getDate(); d++) {
const data = new Date(ano, mes, d);
const k = dateKey(data);
const isHoje = data.getTime() === hoje.getTime();
arr.push({
dia: d,
outro: false,
isHoje,
fechado: !dias.has(data.getDay()),
count: eventosCountPorDiaMini.value[k] || 0,
feriado: feriadosPorDataKey.value[k] || null,
date: data
});
}
// Padding depois pra completar 6 semanas
while (arr.length % 7 !== 0 || arr.length < 42) {
const ultDia = arr[arr.length - 1].date;
const next = new Date(ultDia);
next.setDate(ultDia.getDate() + 1);
const k = dateKey(next);
arr.push({
dia: next.getDate(),
outro: true,
isHoje: false,
fechado: !dias.has(next.getDay()),
count: eventosCountPorDiaMini.value[k] || 0,
feriado: feriadosPorDataKey.value[k] || null,
date: next
});
}
return arr;
});
function miniPrev() {
const d = new Date(miniRefDate.value);
d.setMonth(d.getMonth() - 1);
miniRefDate.value = d;
}
function miniNext() {
const d = new Date(miniRefDate.value);
d.setMonth(d.getMonth() + 1);
miniRefDate.value = d;
}
// Click no dia do mini-cal → FC pula pra esse dia. Se o dia for de outro
// mês (padding), atualiza miniRefDate também pra "seguir" o FC.
function selecionarDiaMini(d) {
if (!d?.date) return;
fcApi()?.gotoDate(d.date);
if (d.outro) {
const novo = new Date(d.date);
novo.setDate(1);
miniRefDate.value = novo;
}
}
// Range visível atualmente no FC — pra destacar os dias correspondentes
// no mini-cal (semana inteira em view=semana, só o dia em view=dia, etc.).
function isInFcRange(date) {
if (!date) return false;
const t = new Date(date).setHours(0, 0, 0, 0);
const start = new Date(viewStart.value).setHours(0, 0, 0, 0);
const end = new Date(viewEnd.value).setHours(0, 0, 0, 0);
// viewEnd do FC é exclusivo (primeiro dia FORA do range)
return t >= start && t < end;
}
// Tooltip do dia no mini-cal — combina feriado + fechado + count de eventos.
function dayTooltip(d) {
const partes = [];
if (d.feriado) partes.push(`${d.feriado.nome} (feriado)`);
else if (d.fechado) partes.push('Fechado');
if (d.count > 0) partes.push(`${d.count} evento${d.count > 1 ? 's' : ''}`);
return partes.join(' · ');
}
// ── Eventos de hoje (derivados dos eventos da semana) ──────
const eventosHojeReal = computed(() => {
const hoje = new Date();
const k = dateKey(hoje);
return eventosPorDia.value[k] || [];
});
// ── Stats do dia (real) ────────────────────────────────────────
// Cada stat tem `key` que casa com STAT_FILTERS pra interatividade.
const statsHoje = computed(() => {
const arr = eventosHojeReal.value;
return [
{ 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(() => {
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) ──────────────────
// Pattern espelha PatientsListPage:35-38,260-265,341-347
const createPopoverRef = ref(null);
const cadastroFullDialog = ref(false);
const quickDialog = ref(false);
const editPatientId = ref(null);
function openCreatePopover(e) {
createPopoverRef.value?.toggle(e);
}
function openQuickCreate() {
quickDialog.value = true;
}
function goCreateFull() {
editPatientId.value = null;
cadastroFullDialog.value = true;
}
function onPatientCreated() {
// Avisa o parent (MelissaLayout) pra refetchar a lista completa, e
// também refaz a query paginada do aside pra refletir o novo paciente.
emit('patient-created');
refetchPacientesAside();
}
// ── Dock contextual + ações do paciente selecionado ───────────
// Pattern espelha PatientsListPage (goEdit/goConversation/openProntuario).
// Aparece no .melissa-dock via Teleport quando há paciente selecionado.
const conversationDrawerStore = useConversationDrawerStore();
const sessionCountsMap = ref(new Map()); // id → count (cache)
// Cache pra patients carregados sob demanda — quando o pacienteSelecionadoId
// vem de fonte externa (ex: openSessoesPaciente do MelissaLayout) ou de
// search popover, e o paciente nao caiu na pagina visivel do aside nem
// na props.pacientes (cap em 1k). Sem isso, dock contextual e banner de
// paciente sumiam silenciosamente em clinicas grandes.
const _pacientesExtraCache = ref(new Map());
// Index O(1) por id — substitui 2 .find() sequenciais (era O(N+M)).
// Em clinicas com >1k pacientes, lookup vira O(1) trocando por O(N+M)
// na reconstrucao do Map (so dispara quando uma das fontes muda).
// Prioridade: props.pacientes vence (lista geral autoritativa) >
// extraCache > pacientesAside (paginacao).
const pacientesIndex = computed(() => {
const map = new Map();
for (const p of pacientesAside.value) map.set(p.id, p);
for (const [id, p] of _pacientesExtraCache.value) map.set(id, p);
for (const p of props.pacientes) map.set(p.id, p);
return map;
});
// Fetch on-demand — busca no DB se id nao esta em memoria. Resultado
// vira pro extraCache, que o pacientesIndex incorpora reativamente.
async function ensurePacienteCarregado(id) {
if (!id) return;
if (pacientesIndex.value.has(id)) return;
try {
const { data, error } = await supabase
.from('patients')
.select('id, nome_completo, avatar_url, status, last_attended_at, created_at')
.eq('id', id)
.maybeSingle();
if (error || !data) return;
// Normaliza shape pra bater com props.pacientes (nome, nao nome_completo).
const normalized = {
id: data.id,
nome: data.nome_completo,
avatar_url: data.avatar_url,
status: data.status,
last_attended_at: data.last_attended_at,
created_at: data.created_at
};
// Substitui o Map inteiro pra disparar reatividade (Map mutate
// direto nao trigger Vue reactivity).
const next = new Map(_pacientesExtraCache.value);
next.set(id, normalized);
_pacientesExtraCache.value = next;
} catch {
// Silencioso — fail-soft. Dock continua nao aparecendo, igual ao
// comportamento anterior (mas pelo menos tentou).
}
}
const pacienteSelecionado = computed(() => {
const id = pacienteSelecionadoId.value;
if (!id) return null;
return pacientesIndex.value.get(id) || null;
});
const pacienteSelecionadoCount = computed(() => {
if (!pacienteSelecionadoId.value) return null;
return sessionCountsMap.value.get(pacienteSelecionadoId.value) ?? null;
});
// Sessões do paciente selecionado dentro do range visível do FC. Diferente
// do pacienteSelecionadoCount (total histórico via RPC) — esse aqui é o que
// está em tela agora, alimenta o banner entre toolbar e fc.
const pacienteSelecionadoSessoesRange = computed(() => {
const id = pacienteSelecionadoId.value;
if (!id) return 0;
return eventosSemana.value.filter((ev) => (ev.patient_id || ev.paciente_id) === id).length;
});
// Carrega session count via RPC quando seleciona um paciente novo.
// Cache simples — nao refaz se ja tem o count na memoria. _inflight Set
// dedupa fetches em paralelo pro mesmo id — sem ele, alternar rapido entre
// pacientes (A → B → A) dispara fetch A duas vezes (cache so ativa depois
// que a primeira escrita termina).
const _sessionFetchInflight = new Set();
watch(pacienteSelecionadoId, async (id) => {
if (!id) return;
// Garante que o objeto paciente esteja em memoria — fire-and-forget.
// Sem await pra nao bloquear o fetch de count (sao independentes).
ensurePacienteCarregado(id);
// Session count cache + dedup.
if (sessionCountsMap.value.has(id)) return;
if (_sessionFetchInflight.has(id)) return;
_sessionFetchInflight.add(id);
try {
const rows = await getSessionCounts([id]);
const r = rows?.[0];
sessionCountsMap.value.set(id, r?.session_count ?? 0);
} catch {
sessionCountsMap.value.set(id, 0);
} finally {
_sessionFetchInflight.delete(id);
}
});
// Modo "Ver todas" — substitui o FC por uma lista do histórico completo
// do paciente (sem range). FC fica preso ao viewStart/viewEnd do FullCalendar,
// então uma lista paralela é mais simples e mais útil que tentar achar um
// range que cubra tudo (sessões podem cruzar anos).
const verTodasSessoes = ref(false);
const {
eventos: todasSessoesPaciente,
loading: todasSessoesLoading,
fetch: fetchTodasSessoes,
reset: resetTodasSessoes
} = useMelissaTodasSessoesPaciente();
function abrirSessoesPaciente() {
if (!pacienteSelecionadoId.value) return;
verTodasSessoes.value = true;
fetchTodasSessoes(pacienteSelecionadoId.value);
}
// API pública pro MelissaLayout (botão "Sessões" do MelissaEventoPanel):
// seleciona o paciente e abre o overlay "Todas as sessões" no mesmo
// fluxo do .ma-dock-actions. Importante: setar pacienteSelecionadoId
// ANTES de verTodasSessoes — o watch logo abaixo reseta verTodasSessoes
// quando pacienteSelecionadoId muda, então fazemos a ordem inversa.
function openSessoesPaciente(patientId) {
if (!patientId) return;
const id = String(patientId);
if (pacienteSelecionadoId.value !== id) {
pacienteSelecionadoId.value = id;
}
verTodasSessoes.value = true;
fetchTodasSessoes(id);
}
function voltarParaPeriodo() {
verTodasSessoes.value = false;
resetTodasSessoes();
}
// Trocar paciente ou desselecionar sai do modo "todas as sessões".
watch(pacienteSelecionadoId, () => {
if (verTodasSessoes.value) {
verTodasSessoes.value = false;
resetTodasSessoes();
}
});
// Sessões agrupadas por mês/ano pra render visual da lista — mantém
// ordem do composable (DESC), o group-by abaixo só insere headers
// quando o mês muda.
const todasSessoesAgrupadas = computed(() => {
const grupos = [];
let chaveAtual = null;
for (const ev of todasSessoesPaciente.value) {
const d = new Date(ev.inicio_em);
const chave = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (chave !== chaveAtual) {
chaveAtual = chave;
grupos.push({
chave,
label: d.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })
.replace(/(^|\s)\S/g, (l) => l.toUpperCase()),
itens: []
});
}
grupos[grupos.length - 1].itens.push(ev);
}
return grupos;
});
function abrirWhatsappPaciente() {
if (!pacienteSelecionadoId.value) return;
conversationDrawerStore.openForPatient(String(pacienteSelecionadoId.value));
}
function abrirProntuarioPaciente() {
const p = pacienteSelecionado.value;
if (!p?.id) return;
abrirProntuarioPorId(p.id);
}
// API pública pra MelissaLayout chamar via ref (botão "Editar paciente"
// do MelissaEventoPanel). Abre o PatientCadastroDialog já no modo edição.
function openEditPatient(patientId) {
if (!patientId) return;
editPatientId.value = String(patientId);
cadastroFullDialog.value = true;
}
function editarPacienteSelecionado() {
if (!pacienteSelecionadoId.value) return;
editPatientId.value = String(pacienteSelecionadoId.value);
cadastroFullDialog.value = true;
}
function desselecionarPaciente() {
pacienteSelecionadoId.value = null;
}
// Iniciais pra avatar mini do dock (fallback quando sem foto)
function pacienteSelecionadoIniciais() {
return pacienteIniciais(pacienteSelecionado.value?.nome || '');
}
// ── Mobile kebab por linha de paciente ────────────────────────
const kebabMenu = ref(null);
const kebabPaciente = ref(null);
function toggleKebab(p, ev) {
ev.stopPropagation(); // não dispara o click do .ma-pat (selecionar)
kebabPaciente.value = p;
kebabMenu.value?.toggle(ev);
}
const kebabItems = computed(() => {
const p = kebabPaciente.value;
if (!p) return [];
return [
{ label: 'Sessões', icon: 'pi pi-history', command: () => { pacienteSelecionadoId.value = p.id; abrirSessoesPaciente(); } },
{ label: 'WhatsApp', icon: 'pi pi-whatsapp', command: () => conversationDrawerStore.openForPatient(String(p.id)) },
{ label: 'Prontuário', icon: 'pi pi-file', command: () => abrirProntuarioPorId(p.id) },
{ label: 'Editar', icon: 'pi pi-pencil', command: () => { editPatientId.value = String(p.id); cadastroFullDialog.value = true; } }
];
});
// ── API exposta pro parent (MelissaLayout) ────────────────────
// MelissaEventoPanel emite ações que o parent precisa orquestrar com
// a Agenda — aqui ficam os métodos invocáveis via ref.
function openProntuario(patient) {
if (!patient?.id) return;
abrirProntuarioPorId(patient.id);
}
defineExpose({
refetch: refetchEventosFc,
openProntuario,
setView,
openSessoesPaciente,
openEditPatient
});
</script>
<template>
<!-- Drawer host (multi-root, fora do .ma-page pra full viewport height
e single-scroll). Sempre presente no DOM (v-show controla display)
pra ser um Teleport target válido em todo momento.
O backdrop fica logo depois pra ficar entre drawer e .ma-page. -->
<aside
class="ma-mobile-drawer fixed top-0 left-0 z-[80] w-[min(360px,88vw)] h-screen [height:100dvh] transition-transform duration-[250ms] [transition-timing-function:cubic-bezier(0.4,0,0.2,1)] bg-[var(--m-bg-medium,rgba(20,20,20,0.92))] backdrop-blur-[28px] backdrop-saturate-[160%] border-r border-[var(--m-border)] text-[var(--m-text)] [font-family:'Segoe_UI',system-ui,-apple-system,sans-serif]"
:class="drawerOpen ? 'translate-x-0' : '-translate-x-full'"
v-show="isMobile"
aria-label="Menu lateral da agenda"
>
<div id="ma-mobile-drawer-target" class="ma-mobile-drawer__scroll h-full overflow-y-auto overflow-x-hidden p-3 pb-6 flex flex-col gap-3" />
</aside>
<Transition name="ma-drawer-fade">
<div
v-if="isMobile && drawerOpen"
class="ma-mobile-drawer__backdrop fixed inset-0 z-[79] bg-black/45 backdrop-blur-[4px]"
@click="fecharDrawer"
/>
</Transition>
<section class="ma-page absolute inset-[6px] [bottom:calc(var(--m-dock-h,76px)+6px)] z-40 flex flex-col bg-[var(--m-bg-medium)] backdrop-blur-[32px] backdrop-saturate-[160%] border border-[var(--m-border)] rounded-[18px] shadow-[0_16px_48px_rgba(0,0,0,0.4)] overflow-hidden text-[var(--m-text)] [font-family:'Segoe_UI',system-ui,-apple-system,sans-serif]">
<header class="ma-page__head flex items-center justify-between px-[18px] py-[14px] border-b border-[var(--m-border)] flex-shrink-0">
<!-- Menu mobile only (<lg=1023px), abre o drawer com aside+widgets.
Em mobile substitui visualmente o título (Menu Agenda tem o nome). -->
<button
class="ma-menu-btn ma-menu-btn--mobile-only hidden max-[1023px]:inline-flex h-8 items-center gap-1.5 flex-shrink-0 px-[11px] rounded-[9px] cursor-pointer text-[0.78rem] font-semibold text-white bg-[var(--m-accent)] border border-[var(--m-accent)] transition-[background-color,transform] duration-[140ms] hover:bg-[color-mix(in_srgb,var(--m-accent)_88%,white)] hover:-translate-y-px active:translate-y-0 [&>i]:text-[0.85rem]"
v-tooltip.bottom="'Pacientes & widgets'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu Agenda</span>
</button>
<div class="ma-page__title flex items-center gap-[10px] text-[1rem] font-medium flex-1 min-w-0 [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap">
<i class="pi pi-calendar text-emerald-300" />
<span>Agenda</span>
</div>
<div class="ma-page__actions flex items-center gap-2 flex-shrink-0">
<!-- Configurações da agenda abre /configuracoes/agenda -->
<button
class="ma-head-btn w-8 h-8 grid place-items-center bg-[var(--m-bg-soft)] border border-[var(--m-border)] text-[var(--m-text)] rounded-[9px] cursor-pointer transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] [&>i]:text-[0.85rem]"
v-tooltip.bottom="'Configurações da agenda'"
@click="goSettings"
>
<i class="pi pi-cog" />
</button>
<!-- Fechar (Esc) volta pro resumo -->
<button
class="ma-close w-8 h-8 grid place-items-center bg-[var(--m-bg-soft)] border border-[var(--m-border)] text-[var(--m-text)] rounded-[9px] cursor-pointer transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)]"
title="Voltar ao resumo (Esc)"
@click="emit('close')"
>
<i class="pi pi-times" />
</button>
</div>
</header>
<div class="ma-body flex-1 flex min-h-0 relative">
<!-- COL 1: Hoje + Pacientes
Em desktop renderiza in-place. Em mobile (<lg) o Teleport
move pra dentro de #ma-mobile-drawer (fora do .ma-page,
fullheight, single-scroll). -->
<Teleport to="#ma-mobile-drawer-target" :disabled="!isMobile">
<aside class="ma-side w-[280px] flex-shrink-0 flex flex-col border-r border-[var(--m-border)] bg-[var(--m-bg-soft)]">
<!-- Hoje (stats + lista de sessões) movido da col 3 -->
<div class="ma-w ma-w--side">
<div class="ma-w__head">
<span class="ma-w__title"><i class="pi pi-chart-bar" /> Hoje</span>
<span v-if="sessoesHoje.length > 0" class="ma-w__count">{{ sessoesHoje.length }}</span>
</div>
<div class="ma-stats grid grid-cols-4 gap-1.5">
<!-- Skeleton dos 4 stats enquanto carrega o range pela 1ª vez -->
<template v-if="eventosCarregandoInicial">
<div v-for="i in 4" :key="`stsk-${i}`" class="ma-stat ma-stat--skeleton bg-[var(--m-bg-soft)] border border-[var(--m-border)] rounded-[10px] px-1 py-2 text-center" aria-busy="true">
<div class="ma-stat__val text-[1.1rem] font-semibold text-[var(--m-text)] leading-none melissa-skeleton melissa-skeleton--number" />
<div class="ma-stat__lbl text-[0.58rem] uppercase tracking-[0.06em] text-[var(--m-text-muted)] mt-1 font-semibold melissa-skeleton melissa-skeleton--text" style="width: 60%; margin-top: 6px;" />
</div>
</template>
<button
v-for="s in statsHoje"
v-else
:key="s.key"
class="ma-stat bg-[var(--m-bg-soft)] border border-[var(--m-border)] rounded-[10px] px-1 py-2 text-center cursor-pointer [font-family:inherit] transition-[background-color,border-color,transform,opacity] duration-[140ms] [&:not(:disabled)]:hover:bg-[var(--m-bg-soft-hover)] [&:not(:disabled)]:hover:-translate-y-px focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2 disabled:opacity-40 disabled:cursor-not-allowed"
: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 text-[1.1rem] font-semibold text-[var(--m-text)] leading-none">{{ s.value }}</div>
<div class="ma-stat__lbl text-[0.58rem] uppercase tracking-[0.06em] text-[var(--m-text-muted)] mt-1 font-semibold">{{ s.label }}</div>
</button>
</div>
<div class="ma-w__divider" />
<div class="ma-w__list">
<!-- Skeleton de 3 sessões enquanto carrega -->
<template v-if="eventosCarregandoInicial">
<div v-for="i in 3" :key="`sesk-${i}`" class="ma-sess ma-sess--skeleton w-full flex items-center gap-2.5 bg-[var(--m-bg-soft)] border border-[var(--m-border)] border-l-[3px] rounded-[10px] px-2.5 py-2 text-[var(--m-text)]" aria-busy="true">
<div class="ma-sess__time flex flex-col items-end min-w-10 flex-shrink-0 tabular-nums">
<span class="melissa-skeleton melissa-skeleton--text" style="width: 32px;" />
<span class="melissa-skeleton melissa-skeleton--text" style="width: 28px; margin-top: 4px;" />
</div>
<div class="ma-sess__main flex-1 min-w-0" style="flex:1;">
<span class="melissa-skeleton melissa-skeleton--text" style="width: 70%; display:block;" />
<span class="melissa-skeleton melissa-skeleton--text" style="width: 40%; display:block; margin-top: 4px;" />
</div>
</div>
</template>
<button
v-for="ev in sessoesHoje"
v-else
:key="ev.id"
class="ma-sess w-full flex items-center gap-2.5 bg-[var(--m-bg-soft)] border border-[var(--m-border)] border-l-[3px] rounded-[10px] px-2.5 py-2 text-[var(--m-text)] text-left cursor-pointer [font-family:inherit] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)]"
:style="{ borderLeftColor: ev.color }"
@click="clicarEvento(ev)"
>
<div class="ma-sess__time flex flex-col items-end min-w-10 flex-shrink-0 tabular-nums [&>strong]:text-[0.78rem] [&>strong]:text-[var(--m-text)] [&>strong]:font-semibold [&>span]:text-[0.6rem] [&>span]:text-[var(--m-text-muted)]">
<strong>{{ fmtHora(ev.startH) }}</strong>
<span>{{ Math.round((ev.endH - ev.startH) * 60) }}min</span>
</div>
<div class="ma-sess__main flex-1 min-w-0">
<div class="ma-sess__name text-[0.82rem] font-medium overflow-hidden text-ellipsis whitespace-nowrap">
{{ ev.tipo === 'sessao' ? ev.pacienteNome : ev.label }}
</div>
<div class="ma-sess__meta text-[0.65rem] text-[var(--m-text-muted)] mt-px">
<span v-if="ev.modalidade">{{ ev.modalidade }}</span>
<span v-else-if="ev.local">{{ ev.local }}</span>
</div>
</div>
</button>
<div v-if="!eventosCarregandoInicial && sessoesHoje.length === 0" class="ma-w__empty">
<i class="pi pi-sun" />
<span>Nenhuma sessão hoje</span>
</div>
</div>
</div>
<!-- Pacientes card com mesma estrutura visual de Hoje/MiniCal/Feriados.
Surface mais opaca (`--m-bg-medium`) pra diferenciar do dialog/glass
dos outros cards e do background da .ma-side. -->
<div class="ma-w ma-w--side ma-w--pacientes">
<div class="ma-w__head">
<span class="ma-w__title"><i class="pi pi-users" /> Pacientes</span>
<span class="ma-w__count">{{ pacientesAsideTotal }}</span>
</div>
<div class="ma-search relative mx-3 mb-2">
<i class="pi pi-search ma-search__icon absolute left-3 top-1/2 -translate-y-1/2 text-[var(--m-text-muted)] text-[0.8rem] pointer-events-none" />
<input
v-model="busca"
type="text"
placeholder="Buscar paciente…"
class="ma-search__input w-full bg-[var(--m-bg-soft)] border border-[var(--m-border)] text-[var(--m-text)] py-2 pl-8 pr-3 rounded-[9px] text-[0.82rem] [font-family:inherit] outline-none transition-[background-color,border-color] duration-[140ms] placeholder:text-[var(--m-text-faint)] focus:bg-[var(--m-bg-soft-hover)] focus:border-[var(--m-border-strong)]"
/>
</div>
<!-- Cluster de ações primárias Paciente abre popover
com 3 opções (rápido, completo, link); Agendar abre
AgendaEventDialog vazio (M.onCreateEvento). 50/50. -->
<div class="ma-side__actions flex gap-2 mb-2">
<button
class="ma-act-btn ma-act-btn--primary flex-1 min-w-0 inline-flex items-center justify-center gap-1.5 px-2.5 py-2.5 rounded-[10px] text-[0.78rem] font-semibold text-white [font-family:inherit] cursor-pointer bg-[var(--m-accent)] border border-[var(--m-accent)] transition-[background-color,border-color,transform] duration-[140ms] disabled:opacity-50 disabled:cursor-not-allowed [&:not(:disabled)]:hover:bg-[color-mix(in_srgb,var(--m-accent)_88%,white)] [&:not(:disabled)]:hover:-translate-y-px [&:not(:disabled)]:active:translate-y-0 focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2 [&>i]:text-[0.78rem]"
v-tooltip.top="'Adicionar paciente'"
:disabled="pacientesLoading"
@click="openCreatePopover($event)"
>
<i :class="pacientesLoading ? 'pi pi-spin pi-spinner' : 'pi pi-plus'" />
<span>Paciente</span>
</button>
<button
class="ma-act-btn ma-act-btn--primary flex-1 min-w-0 inline-flex items-center justify-center gap-1.5 px-2.5 py-2.5 rounded-[10px] text-[0.78rem] font-semibold text-white [font-family:inherit] cursor-pointer bg-[var(--m-accent)] border border-[var(--m-accent)] transition-[background-color,border-color,transform] duration-[140ms] disabled:opacity-50 disabled:cursor-not-allowed [&:not(:disabled)]:hover:bg-[color-mix(in_srgb,var(--m-accent)_88%,white)] [&:not(:disabled)]:hover:-translate-y-px [&:not(:disabled)]:active:translate-y-0 focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2 [&>i]:text-[0.78rem]"
v-tooltip.top="'Agendar evento'"
:disabled="!M || agendarBusy"
@click="onAgendar"
>
<i :class="agendarBusy ? 'pi pi-spin pi-spinner' : 'pi pi-plus'" />
<span>Agendar</span>
</button>
</div>
<div class="ma-w__list">
<!-- Skeleton de 6 pacientes enquanto carrega pela 1ª vez -->
<template v-if="pacientesCarregandoInicial">
<div v-for="i in 6" :key="`psk-${i}`" class="ma-pat ma-pat--skeleton" aria-busy="true">
<span class="ma-pat__avatar melissa-skeleton melissa-skeleton--avatar" />
<span class="ma-pat__info" style="display:flex; flex-direction:column; gap:6px;">
<span class="melissa-skeleton melissa-skeleton--text" :style="{ width: `${55 + (i * 7) % 30}%` }" />
<span class="melissa-skeleton melissa-skeleton--text" :style="{ width: `${30 + (i * 11) % 25}%` }" />
</span>
</div>
</template>
<div
v-for="p in pacientesAside"
v-else
:key="p.id"
class="ma-pat w-full flex items-center gap-2.5 px-2.5 py-2 mb-0.5 last:mb-0 bg-transparent border-0 rounded-[10px] text-[var(--m-text)] text-left cursor-pointer [font-family:inherit] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft)]"
:class="{
'is-active': pacienteSelecionadoId === p.id,
'is-novo': isPacienteNovo(p)
}"
@click="pacienteRowClick(p.id)"
@dblclick="pacienteRowDblclick(p.id)"
>
<span class="ma-pat__avatar w-8 h-8 rounded-full bg-[var(--m-accent)] border border-[var(--m-accent)] grid place-items-center text-[var(--p-primary-contrast-color,white)] text-[0.78rem] font-semibold flex-shrink-0 overflow-hidden [&>img]:w-full [&>img]:h-full [&>img]:object-cover">
<img v-if="p.avatar_url" :src="p.avatar_url" :alt="p.nome" />
<template v-else>{{ pacienteIniciais(p.nome) }}</template>
</span>
<span class="ma-pat__info flex-1 min-w-0 flex flex-col gap-px">
<span class="ma-pat__name text-[0.85rem] font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ p.nome }}</span>
<span class="ma-pat__sub text-[0.68rem] text-[var(--m-text-muted)]">{{ fmtUltimaSessao(p.last_attended_at) }}</span>
</span>
<span v-if="isPacienteNovo(p)" class="ma-pat__novo text-[0.55rem] font-bold uppercase tracking-[0.08em] px-1.5 py-0.5 rounded-full bg-[var(--m-accent)] text-[var(--p-primary-contrast-color,white)] flex-shrink-0 leading-tight">novo</span>
<!-- Kebab mobile-only escondido em desktop, visível em <=1024px -->
<button
class="ma-pat__kebab hidden max-[1024px]:grid place-items-center w-7 h-7 bg-transparent border-0 text-[var(--m-text-muted)] rounded-md cursor-pointer flex-shrink-0 transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] hover:text-[var(--m-text)]"
type="button"
title="Ações"
@click="toggleKebab(p, $event)"
>
<i class="pi pi-ellipsis-v" />
</button>
</div>
<div
v-if="!pacientesCarregandoInicial && pacientesAsideTotal === 0"
class="ma-w__empty"
:class="{ 'ma-w__empty--search': !!busca.trim() }"
>
<i :class="busca.trim() ? 'pi pi-search-minus' : 'pi pi-users'" />
<span class="ma-w__empty-title">
{{ busca.trim() ? 'Busca não encontrada…' : 'Nenhum paciente cadastrado' }}
</span>
<span v-if="busca.trim()" class="ma-w__empty-sub">
Nada para "<strong>{{ busca.trim() }}</strong>"
</span>
</div>
</div>
<Paginator
v-if="pacientesAsideTotal > PACIENTES_POR_PAGINA"
class="ma-side__paginator"
:rows="PACIENTES_POR_PAGINA"
:totalRecords="pacientesAsideTotal"
:first="(pacientesPagina - 1) * PACIENTES_POR_PAGINA"
template="PrevPageLink CurrentPageReport NextPageLink"
currentPageReportTemplate="{currentPage} / {totalPages}"
@page="onPacientesPageChange"
/>
</div>
</aside>
</Teleport>
<!-- ════ COL 2: Calendar central ════ -->
<div class="ma-cal flex-1 min-w-0 flex flex-col border-r border-[var(--m-border)]">
<!-- Toolbar — cluster nav (Hoje + chevrons) à esquerda, título
com flex+ellipsis no centro, view-switcher à direita. -->
<div class="ma-cal__toolbar flex items-center justify-between px-3.5 py-3 border-b border-[var(--m-border)] flex-shrink-0 gap-3.5 min-w-0">
<div class="ma-cal__nav flex items-center gap-2.5 min-w-0 flex-1">
<div class="ma-cal__nav-cluster flex items-center gap-0.5 bg-[var(--m-bg-soft)] border border-[var(--m-border)] rounded-[10px] p-0.5 flex-shrink-0">
<button
class="ma-cal__btn inline-flex items-center gap-1.5 h-8 px-3 py-[5px] bg-transparent border-0 text-[var(--m-text)] rounded-lg [font-family:inherit] text-[0.78rem] font-medium cursor-pointer transition-[background-color,opacity] duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2 [&>i]:text-[0.85rem]"
:class="{ 'is-today-active': refDateIsToday }"
:disabled="refDateIsToday"
@click="goToday"
>
Hoje
</button>
<span class="ma-cal__nav-divider w-px h-[18px] bg-[var(--m-border)] mx-0.5 flex-shrink-0" />
<button class="ma-cal__icon w-7 h-7 grid place-items-center bg-transparent border-0 text-[var(--m-text)] rounded-md cursor-pointer text-[0.78rem] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2" v-tooltip.top="'Anterior'" @click="goPrev">
<i class="pi pi-chevron-left" />
</button>
<button class="ma-cal__icon w-7 h-7 grid place-items-center bg-transparent border-0 text-[var(--m-text)] rounded-md cursor-pointer text-[0.78rem] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2" v-tooltip.top="'Próximo'" @click="goNext">
<i class="pi pi-chevron-right" />
</button>
</div>
<div class="ma-cal__period flex-1 min-w-0 px-1 py-1 text-[var(--m-text)] text-[0.92rem] font-semibold tracking-[0.01em] whitespace-nowrap overflow-hidden text-ellipsis capitalize" :title="currentTitle">{{ currentTitle }}</div>
</div>
<div class="ma-cal__right flex items-center gap-2 flex-shrink-0">
<!-- Filtros desktop — escondem em <xl onde vão pra dentro do botão "Ações".
SelectButton é auto-resolvido via PrimeVueResolver. -->
<div class="ma-cal__filters flex gap-1.5 max-[1279px]:hidden">
<SelectButton
v-model="timeMode"
:options="timeModeOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
size="small"
/>
<SelectButton
v-model="onlySessions"
:options="onlySessionsOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
size="small"
/>
</div>
<!-- Busca — sempre visível. Abre popover com input + lista de
resultados. Suporta data (20/04, hoje) e texto (paciente/
título). Ctrl/Cmd+K abre via hotkey global. -->
<button
class="ma-cal__icon ma-cal__search-btn w-7 h-7 grid place-items-center bg-transparent border-0 text-[var(--m-text)] rounded-md cursor-pointer text-[0.78rem] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2"
v-tooltip.top="'Buscar (Ctrl+K)'"
@click="searchPopover?.toggle($event)"
>
<i class="pi pi-search" />
</button>
<MelissaAgendaSearchPopover
ref="searchPopover"
@goto-date="onBuscaGotoDate"
@select-evento="onBuscaSelectEvento"
/>
<!-- Bloquear: ícone-only com Menu popup. Visível só
em ≥xl. Em <xl vai pra dentro de "Ações". -->
<button
class="ma-cal__icon ma-cal__btn--xl-only w-7 h-7 grid place-items-center bg-transparent border-0 text-[var(--m-text)] rounded-md cursor-pointer text-[0.78rem] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2 max-[1279px]:hidden"
:disabled="!M"
v-tooltip.top="'Bloquear horário/dia'"
@click="openBloqueioMenu"
>
<i class="pi pi-ban" />
</button>
<Menu ref="bloqueioMenuRef" :model="bloqueioMenuItems" :popup="true" />
<!-- Ações — aparece em <xl (1279px). Concentra
filtros (timeMode/onlySessions) + bloquear
quando a toolbar fica apertada. Texto "Ações"
pra dar affordance, não só ícone. -->
<button
class="ma-cal__btn ma-cal__btn--compact-only hidden max-[1279px]:inline-flex items-center gap-1.5 h-8 px-3 py-[5px] bg-transparent border-0 text-[var(--m-text)] rounded-lg [font-family:inherit] text-[0.78rem] font-medium cursor-pointer transition-[background-color,opacity] duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2 [&>i]:text-[0.85rem]"
v-tooltip.top="'Ações da agenda'"
@click="actionsPopover?.toggle($event)"
>
<i class="pi pi-ellipsis-v" />
<span>Ações</span>
</button>
<MelissaAgendaActionsPopover
ref="actionsPopover"
v-model:calendar-view="calendarView"
v-model:only-sessions="onlySessions"
v-model:time-mode="timeMode"
:time-mode-options="timeModeOptions"
:only-sessions-options="onlySessionsOptions"
:bloqueio-disabled="!M"
@bloqueio="onActionsBloqueio"
/>
<div class="ma-cal__view ma-cal__view--xl-only flex-shrink-0 max-[1279px]:hidden">
<SelectButton
:model-value="calendarView"
:options="[
{ v: 'dia', l: 'Dia' },
{ v: 'semana', l: 'Semana' },
{ v: 'mes', l: 'Mês' },
{ v: 'lista', l: 'Lista' }
]"
option-value="v"
option-label="l"
:allow-empty="false"
size="small"
@update:model-value="(v) => v && setView(v)"
/>
</div>
</div>
</div>
<!-- Banner do paciente filtrado — entre toolbar e FC.
Mostra count das sessões do paciente no range visível
do FC + ação pra ver todas (vai pra view 'lista', que
reaproveita o filtro) e pra exibir todos os pacientes
(desseleciona). Some quando não há paciente selecionado. -->
<Transition name="ma-patient-banner">
<div
v-if="pacienteSelecionado"
class="ma-cal__patient-banner flex items-center gap-2.5 px-3.5 py-2 border-b border-[var(--m-border)] bg-[color-mix(in_srgb,var(--p-primary-color)_8%,var(--m-bg-soft))] text-[var(--m-text)] text-[0.8rem] flex-shrink-0 flex-wrap"
role="status"
>
<i class="pi pi-filter-fill ma-cal__patient-banner__icon text-[var(--p-primary-color)] text-[0.78rem] flex-shrink-0" />
<span class="ma-cal__patient-banner__text min-w-0 flex-1 leading-[1.3] [&>strong]:font-semibold [&>strong]:text-[var(--m-text)]">
<!-- Modo "todas" — histórico completo (lista substitui o FC) -->
<template v-if="verTodasSessoes">
<template v-if="todasSessoesLoading">
Carregando histórico de
<strong>{{ pacienteSelecionado.nome }}</strong>…
</template>
<template v-else-if="todasSessoesPaciente.length > 0">
Exibindo <strong>{{ todasSessoesPaciente.length }}</strong>
{{ todasSessoesPaciente.length === 1 ? 'sessão' : 'sessões' }}
de <strong>{{ pacienteSelecionado.nome }}</strong>
<span class="ma-cal__patient-banner__sub text-[var(--m-text-muted)] text-[0.72rem] ml-1">histórico completo</span>
</template>
<template v-else>
Nenhuma sessão foi encontrada para
<strong>{{ pacienteSelecionado.nome }}</strong>
</template>
</template>
<!-- Modo padrão — count do range visível do FC -->
<template v-else>
<template v-if="pacienteSelecionadoSessoesRange > 0">
Exibindo <strong>{{ pacienteSelecionadoSessoesRange }}</strong>
{{ pacienteSelecionadoSessoesRange === 1 ? 'sessão' : 'sessões' }}
de <strong>{{ pacienteSelecionado.nome }}</strong>
<span class="ma-cal__patient-banner__sub text-[var(--m-text-muted)] text-[0.72rem] ml-1">no período visível</span>
</template>
<template v-else>
Nenhuma sessão foi encontrada para
<strong>{{ pacienteSelecionado.nome }}</strong>
<span class="ma-cal__patient-banner__sub text-[var(--m-text-muted)] text-[0.72rem] ml-1">no período visível</span>
</template>
</template>
</span>
<span class="ma-cal__patient-banner__spacer flex-none" />
<button
v-if="verTodasSessoes"
class="ma-cal__patient-banner__btn 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.75rem] 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="'Voltar à agenda (período visível)'"
@click="voltarParaPeriodo"
>
<i class="pi pi-arrow-left" />
<span>Voltar ao período</span>
</button>
<button
v-else-if="pacienteSelecionadoCount && pacienteSelecionadoCount > 0"
class="ma-cal__patient-banner__btn 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.75rem] 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="'Ver histórico completo do paciente'"
@click="abrirSessoesPaciente"
>
<i class="pi pi-history" />
<span>Ver todas ({{ pacienteSelecionadoCount }})</span>
</button>
<button
class="ma-cal__patient-banner__btn ma-cal__patient-banner__btn--primary inline-flex items-center gap-1.5 px-2.5 py-[5px] rounded-lg border border-[var(--p-primary-color)] bg-[var(--p-primary-color)] text-[var(--p-primary-contrast-color,white)] text-[0.75rem] font-medium [font-family:inherit] cursor-pointer transition-[background-color,border-color,color] duration-[140ms] flex-shrink-0 hover:bg-[color-mix(in_srgb,var(--p-primary-color)_88%,black)] hover:border-[color-mix(in_srgb,var(--p-primary-color)_88%,black)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2"
@click="desselecionarPaciente"
>
<i class="pi pi-times" />
<span>Exibir todos</span>
</button>
</div>
</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 -->
<div class="ma-cal__fc">
<!-- Overlay leve enquanto eventos carregam. Não bloqueia interação,
só dá feedback visual. Aparece sempre que M.loading é true
(inclui refetches por mudança de range/filtro). -->
<Transition name="ma-loading-fade">
<div
v-if="M?.loading?.value"
class="ma-cal__loading absolute top-2 right-2 z-[5] pointer-events-none flex items-center justify-center w-8 h-8 rounded-full bg-[color-mix(in_srgb,var(--m-bg-medium)_80%,transparent)] backdrop-blur-[8px] border border-[var(--m-border)] text-[var(--m-text-muted)] text-[0.85rem]"
aria-busy="true"
>
<i class="pi pi-spin pi-spinner" />
</div>
</Transition>
<!-- Chip flutuante quando há filtro de stat ativo. Click limpa. -->
<Transition name="ma-filter-chip">
<button
v-if="statFiltroAtivo"
class="ma-cal__filter-chip absolute top-2 right-2 z-[5] inline-flex items-center gap-2 px-3 py-1.5 bg-[color-mix(in_srgb,var(--m-accent)_22%,var(--m-bg-medium))] border border-[var(--m-accent)] rounded-full text-[var(--m-text)] text-[0.72rem] font-semibold [font-family:inherit] tracking-[0.02em] cursor-pointer backdrop-blur-[8px] shadow-[0_4px_12px_rgba(0,0,0,0.15)] transition-[background-color,transform] duration-[140ms] hover:bg-[color-mix(in_srgb,var(--m-accent)_32%,var(--m-bg-medium))] hover:-translate-y-px focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2"
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" />
<!-- Lista de TODAS as sessões do paciente (modo "Ver todas").
IMPORTANTE: o FC fica rodando intacto por baixo (sem v-show /
v-if), só é COBERTO pelo overlay opaco. Esconder o FC com
display:none quebra as medidas internas (linhas e eventos
vão pra esquerda, gridlines somem) — bug conhecido do FC
que normalmente exigiria fcApi.updateSize() ao reaparecer. -->
<div
v-if="verTodasSessoes"
class="ma-cal__all-sessions absolute inset-0 z-[6] overflow-y-auto bg-[var(--p-content-background)] px-3.5 py-3 flex flex-col gap-3.5"
>
<template v-if="todasSessoesLoading">
<div class="ma-cal__all-sessions__loading flex items-center justify-center flex-col gap-2.5 text-[var(--m-text-muted)] py-10 px-5 text-[0.9rem] m-auto [&>i]:text-[1.6rem] [&>i]:text-[var(--m-text-muted)]">
<i class="pi pi-spin pi-spinner" />
<span>Carregando histórico…</span>
</div>
</template>
<template v-else-if="todasSessoesPaciente.length === 0">
<div class="ma-cal__all-sessions__empty flex items-center justify-center flex-col gap-2.5 text-[var(--m-text-muted)] py-10 px-5 text-[0.9rem] m-auto [&>i]:text-[1.6rem] [&>i]:text-[var(--m-text-muted)]">
<i class="pi pi-calendar-times" />
<span>Nenhuma sessão encontrada para este paciente.</span>
</div>
</template>
<template v-else>
<div
v-for="grupo in todasSessoesAgrupadas"
:key="grupo.chave"
class="ma-cal__all-sessions__group flex flex-col gap-1"
>
<div class="ma-cal__all-sessions__month text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-[var(--m-text-muted)] px-1.5 py-1 border-b border-[var(--m-border)] mb-1">{{ grupo.label }}</div>
<button
v-for="ev in grupo.itens"
:key="ev.id"
class="ma-cal__all-sessions__item grid grid-cols-[8px_130px_56px_1fr_auto] max-[640px]:grid-cols-[8px_1fr_auto] max-[640px]:grid-rows-[auto_auto_auto] items-center gap-3 max-[640px]:gap-x-2.5 max-[640px]:gap-y-1.5 px-3 py-2.5 rounded-[10px] bg-[var(--m-bg-medium)] border border-[var(--m-border)] text-[var(--m-text)] [font-family:inherit] text-[0.82rem] cursor-pointer text-left transition-[background-color,border-color,transform] duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] hover:border-[var(--m-border-strong)] hover:-translate-y-px focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2"
type="button"
@click="emit('select-evento', ev)"
>
<span class="ma-cal__all-sessions__dot w-2 h-2 rounded-full flex-shrink-0" :style="{ background: ev.color }" />
<span class="ma-cal__all-sessions__date font-medium text-[var(--m-text)] capitalize whitespace-nowrap max-[640px]:[grid-column:2] max-[640px]:[grid-row:1]">
{{ new Date(ev.inicio_em).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', weekday: 'short' }) }}
</span>
<span class="ma-cal__all-sessions__time tabular-nums text-[var(--m-text-muted)] whitespace-nowrap max-[640px]:[grid-column:3] max-[640px]:[grid-row:1] max-[640px]:justify-self-end">
{{ new Date(ev.inicio_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) }}
</span>
<span class="ma-cal__all-sessions__title text-[var(--m-text)] overflow-hidden text-ellipsis whitespace-nowrap min-w-0 max-[640px]:[grid-column:2/-1] max-[640px]:[grid-row:2]">
{{ ev.label }}
<span v-if="ev.modalidade" class="ma-cal__all-sessions__meta text-[var(--m-text-muted)] text-[0.78rem]">— {{ ev.modalidade }}</span>
</span>
<span
v-if="ev.status"
class="ma-cal__all-sessions__status text-[0.68rem] px-2 py-0.5 rounded-full bg-[var(--m-bg-soft)] border border-[var(--m-border)] text-[var(--m-text-muted)] capitalize whitespace-nowrap flex-shrink-0 max-[640px]:[grid-column:1/-1] max-[640px]:[grid-row:3] max-[640px]:justify-self-start max-[640px]:mt-1"
:class="`is-${(ev.status || '').toLowerCase().replace(/\s+/g, '-')}`"
>
{{ ev.status }}
</span>
</button>
</div>
</template>
</div>
</div>
</div>
<!-- ════ COL 3: Widgets direita ════
Mesma lógica da COL 1 — Teleport pro drawer em mobile. -->
<Teleport to="#ma-mobile-drawer-target" :disabled="!isMobile">
<aside class="ma-widgets">
<!-- Mini calendar -->
<div class="ma-w">
<div class="ma-w__head">
<span class="ma-w__title"><i class="pi pi-calendar" /> {{ miniMesAno }}</span>
<div class="ma-w__nav">
<button class="ma-w__icon" @click="miniPrev"><i class="pi pi-chevron-left" /></button>
<button class="ma-w__icon" @click="miniNext"><i class="pi pi-chevron-right" /></button>
</div>
</div>
<div class="mini-cal">
<div class="mini-cal__weekdays grid grid-cols-7 gap-0.5 mb-1 [&>span]:text-center [&>span]:text-[0.62rem] [&>span]:text-[var(--m-text-faint)] [&>span]:font-semibold [&>span]:py-1">
<span v-for="d in ['D','S','T','Q','Q','S','S']" :key="d">{{ d }}</span>
</div>
<div class="mini-cal__grid grid grid-cols-7 gap-0.5">
<button
v-for="(d, i) in miniDias"
:key="i"
class="mini-cal__day relative bg-transparent border-0 text-[var(--m-text)] [font-family:inherit] text-[0.72rem] py-1.5 px-0 rounded-md cursor-pointer transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)]"
:class="[
{
'is-outro': d.outro,
'is-hoje': d.isHoje,
'is-fechado': d.fechado && !d.feriado,
'is-in-range': isInFcRange(d.date) && !d.isHoje,
'has-evento': d.count > 0,
'is-feriado': !!d.feriado
},
d.feriado ? `is-feriado--${d.feriado.tipo}` : null
]"
:title="dayTooltip(d)"
@click="selecionarDiaMini(d)"
>
{{ d.dia }}
<span v-if="d.count > 0" class="mini-cal__dots absolute bottom-[3px] left-1/2 -translate-x-1/2 flex gap-0.5 leading-none">
<span class="mini-cal__dot inline-block w-[3px] h-[3px] rounded-full bg-[var(--m-accent)]" />
<span v-if="d.count >= 2" class="mini-cal__dot inline-block w-[3px] h-[3px] rounded-full bg-[var(--m-accent)]" />
<span v-if="d.count >= 3" class="mini-cal__dot inline-block w-[3px] h-[3px] rounded-full bg-[var(--m-accent)]" />
</span>
</button>
</div>
</div>
</div>
<!-- Próximos feriados (mês atual) — reaproveita componente
existente da AgendaTerapeutaPage. ownerId fica null pra
ele bootar via supabase auth (preview funciona com login). -->
<ProximosFeriadosCard
class="ma-w-feriados"
:owner-id="null"
:tenant-id="currentTenantId"
:work-rules="workRules"
@bloqueado="onFeriadoBloqueado"
/>
<!-- Histórico de ações na agenda (audit_logs) — útil pra
rastrear movimentações recentes. Click na entrada
abre o evento (se ainda existe). -->
<MelissaAgendaHistoricoCard
ref="historicoCardRef"
@open-evento="onHistoricoOpen"
/>
</aside>
</Teleport>
</div>
<!-- Popover + dialogs de cadastro (montados fora do scroll/aside
pra ficarem por cima do .ma-page sem clipping). -->
<PatientCreatePopover
ref="createPopoverRef"
@quick-create="openQuickCreate"
@go-complete="goCreateFull"
/>
<PatientCadastroDialog
v-model="cadastroFullDialog"
:patient-id="editPatientId"
@created="onPatientCreated"
/>
<ComponentCadastroRapido
v-model="quickDialog"
title="Cadastro Rápido"
table-name="patients"
name-field="nome_completo"
email-field="email_principal"
phone-field="telefone"
@created="onPatientCreated"
/>
<!-- Dock contextual — Teleport pro .melissa-dock no MelissaLayout.
Aparece quando há paciente selecionado. Em mobile, fica oculto
via CSS pq as ações vivem no kebab da própria lista.
Transition envolve Teleport (pattern oficial Vue) pra evitar
comment placeholder no target compartilhado com o chip do
cronômetro — sem isso, "emitsOptions: null" no diff. -->
<!-- v-if inclui M: dock target (.melissa-dock) so existe quando dentro
do MelissaLayout. Em standalone, M=null e o Teleport nao tenta
apontar pra um elemento inexistente (que dispararia warn do Vue). -->
<Transition name="ma-dock-pop">
<Teleport v-if="pacienteSelecionado && M" to=".melissa-dock">
<div class="ma-dock-actions inline-flex items-center gap-2 pl-2.5 pr-2 py-1.5 bg-[var(--m-bg-medium)] backdrop-blur-[24px] backdrop-saturate-[160%] border border-[var(--m-border-strong)] rounded-full text-[var(--m-text)] shadow-[0_8px_24px_rgba(0,0,0,0.25)] [font-family:'Segoe_UI',system-ui,-apple-system,sans-serif] max-w-[min(560px,calc(100vw-8rem))] max-[1024px]:hidden">
<span class="ma-dock-actions__avatar w-7 h-7 rounded-full bg-[var(--m-accent)] text-[var(--p-primary-contrast-color,white)] grid place-items-center text-[0.7rem] font-semibold flex-shrink-0 overflow-hidden [&>img]:w-full [&>img]:h-full [&>img]:object-cover">
<img v-if="pacienteSelecionado.avatar_url" :src="pacienteSelecionado.avatar_url" :alt="pacienteSelecionado.nome" />
<template v-else>{{ pacienteSelecionadoIniciais() }}</template>
</span>
<span class="ma-dock-actions__name text-[0.85rem] font-medium max-w-[180px] overflow-hidden text-ellipsis whitespace-nowrap" :title="pacienteSelecionado.nome">
{{ pacienteSelecionado.nome }}
</span>
<span
v-if="pacienteSelecionadoCount !== null"
class="ma-dock-actions__count text-[0.68rem] text-[var(--m-text-muted)] px-2 py-0.5 rounded-full bg-[var(--m-bg-soft)] border border-[var(--m-border)] flex-shrink-0 transition-[color,background-color,border-color] duration-[160ms]"
:class="{ 'is-active': pacienteSelecionadoCount > 0 }"
>
{{ pacienteSelecionadoCount }} {{ pacienteSelecionadoCount === 1 ? 'sessão' : 'sessões' }}
</span>
<span class="ma-dock-actions__divider w-px h-[22px] bg-[var(--m-border-strong)] mx-0.5" />
<button class="ma-dock-actions__btn w-8 h-8 grid place-items-center bg-transparent border-0 text-[var(--m-text)] rounded-full cursor-pointer transition-[background-color,color] duration-[140ms] text-[0.85rem] hover:bg-[var(--m-bg-soft-hover)]" v-tooltip.top="'Ver sessões'" @click="abrirSessoesPaciente">
<i class="pi pi-history" />
</button>
<button class="ma-dock-actions__btn w-8 h-8 grid place-items-center bg-transparent border-0 text-[var(--m-text)] rounded-full cursor-pointer transition-[background-color,color] duration-[140ms] text-[0.85rem] hover:bg-[var(--m-bg-soft-hover)]" v-tooltip.top="'Conversar (WhatsApp)'" @click="abrirWhatsappPaciente">
<i class="pi pi-whatsapp" />
</button>
<button class="ma-dock-actions__btn w-8 h-8 grid place-items-center bg-transparent border-0 text-[var(--m-text)] rounded-full cursor-pointer transition-[background-color,color] duration-[140ms] text-[0.85rem] hover:bg-[var(--m-bg-soft-hover)]" v-tooltip.top="'Prontuário'" @click="abrirProntuarioPaciente">
<i class="pi pi-file" />
</button>
<button class="ma-dock-actions__btn w-8 h-8 grid place-items-center bg-transparent border-0 text-[var(--m-text)] rounded-full cursor-pointer transition-[background-color,color] duration-[140ms] text-[0.85rem] hover:bg-[var(--m-bg-soft-hover)]" v-tooltip.top="'Editar paciente'" @click="editarPacienteSelecionado">
<i class="pi pi-pencil" />
</button>
<button class="ma-dock-actions__btn ma-dock-actions__btn--close w-8 h-8 grid place-items-center bg-transparent border-0 text-[var(--m-text-muted)] rounded-full cursor-pointer transition-[background-color,color] duration-[140ms] text-[0.85rem] hover:bg-[var(--m-bg-soft-hover)] hover:text-[rgb(239,68,68)]" v-tooltip.top="'Fechar (desselecionar)'" @click="desselecionarPaciente">
<i class="pi pi-times" />
</button>
</div>
</Teleport>
</Transition>
<!-- Menu kebab (mobile) — abre via toggleKebab a partir de qualquer .ma-pat -->
<Menu ref="kebabMenu" :model="kebabItems" popup append-to="body" />
<!-- Prontuario migrado pra MelissaPaciente nativo (Fase 8 wire-up).
abrirProntuarioPorId(id) navega pra /melissa/paciente?id=X. -->
</section>
</template>
<style scoped>
/* ─── Convenção "Melissa Page" ────────────────────────────────
Apenas keyframes da animação de enter — restante migrou pra
utilities Tailwind no template (.ma-page e filhos). */
.ma-page {
animation: ma-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes ma-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
/* .ma-tsearch* migrou inteiro pra MelissaAgendaSearchPopover.vue (componente
autocontido). Cmd+K hotkey global continua aqui no parent — chama
`searchPopover.value?.toggle(...)` apontando pro botao .ma-cal__search-btn. */
/* .ma-actions* migrou inteiro pra MelissaAgendaActionsPopover.vue
(componente autocontido, com utilities Tailwind no template). */
/* .ma-menu-btn migrado pra Tailwind utilities no template
(hidden + max-[1023px]:inline-flex + bg-accent + hover/active). */
/* .ma-cal__filters, .ma-cal__btn (e a versao "primary filled" duplicada que
nao prevalecia no cascade), .ma-cal__btn--compact-only e --xl-only: TODOS
migraram pra Tailwind utilities no template (max-[1279px]:hidden /
hidden max-[1279px]:inline-flex / etc). */
/* ═══ COL 1: Aside Pacientes ═════════════════════════════════ */
/* .ma-side e .ma-search* migraram pra Tailwind no template.
.ma-side__head/title/count eram CSS orfaos (nao tem template) — removidos. */
.ma-side__list {
flex: 1;
overflow-y: auto;
padding: 4px 8px 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.ma-side__list::-webkit-scrollbar { width: 5px; }
.ma-side__list::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.ma-side__empty {
text-align: center;
color: var(--m-text-faint);
font-size: 0.78rem;
padding: 20px;
}
/* Paginator da lista de pacientes — compacto, alinhado ao tema Melissa.
PrimeVue renderiza .p-paginator com seus próprios botões; sobrescrevo
só o essencial pra ele caber na coluna estreita (~280px). */
.ma-side__paginator.p-paginator {
background: transparent;
border: none;
padding: 6px 4px 4px;
justify-content: center;
gap: 2px;
}
.ma-side__paginator.p-paginator .p-paginator-current {
color: var(--m-text-muted);
font-size: 0.74rem;
font-weight: 600;
min-width: auto;
padding: 0 8px;
height: 28px;
background: transparent;
border: none;
}
.ma-side__paginator.p-paginator .p-paginator-prev,
.ma-side__paginator.p-paginator .p-paginator-next {
min-width: 28px;
height: 28px;
color: var(--m-text);
background: transparent;
border: 1px solid var(--m-border);
border-radius: 8px;
transition: background-color 140ms ease, border-color 140ms ease;
}
.ma-side__paginator.p-paginator .p-paginator-prev:not(.p-disabled):hover,
.ma-side__paginator.p-paginator .p-paginator-next:not(.p-disabled):hover {
background: var(--m-bg-soft);
border-color: var(--m-border-strong);
}
.ma-side__paginator.p-paginator .p-paginator-prev.p-disabled,
.ma-side__paginator.p-paginator .p-paginator-next.p-disabled {
opacity: 0.35;
}
/* .ma-pat e filhos (.avatar/.info/.name/.sub/.novo/.kebab) e botoes
.ma-act-btn migraram pra Tailwind no template. Aqui ficam SO os
state modifiers (is-active, is-novo) que usam vars + tem ajustes
de padding/border quando ativados — mais limpo em CSS. */
.ma-pat.is-active {
background: var(--m-accent-soft);
border: 1px solid var(--m-accent-strong);
padding: 7px 9px;
}
.ma-pat.is-novo {
background: var(--m-accent-soft);
}
.ma-pat.is-novo:hover {
background: var(--m-accent-strong);
}
.ma-pat.is-novo.is-active {
background: var(--m-accent-soft);
border-color: var(--m-accent);
}
/* ═══ COL 2: Calendar central ════════════════════════════════ */
/* .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*
migraram pra Tailwind no template. Aqui fica SO o state modifier
.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 {
opacity: 0.45;
cursor: default;
}
.ma-cal__btn.is-today-active:hover { background: transparent; }
/* .ma-cal__loading migrou pra Tailwind utilities no template. */
.ma-loading-fade-enter-active,
.ma-loading-fade-leave-active { transition: opacity 200ms ease; }
.ma-loading-fade-enter-from,
.ma-loading-fade-leave-to { opacity: 0; }
/* Skeletons — variantes de .ma-stat / .ma-sess / .ma-pat sem hover/cursor */
.ma-stat--skeleton,
.ma-sess--skeleton,
.ma-pat--skeleton {
cursor: default;
pointer-events: none;
opacity: 0.95;
}
.ma-stat--skeleton:hover,
.ma-sess--skeleton:hover,
.ma-pat--skeleton:hover {
background: inherit;
transform: none;
}
/* .ma-cal__patient-banner (base + __icon/__text/__sub/__spacer/__btn/__btn--primary)
migraram pra Tailwind no template. Aqui ficam só as Vue Transitions. */
.ma-patient-banner-enter-active,
.ma-patient-banner-leave-active {
transition: opacity 200ms ease, transform 200ms cubic-bezier(0.2, 0.7, 0.3, 1), max-height 200ms ease;
overflow: hidden;
}
.ma-patient-banner-enter-from,
.ma-patient-banner-leave-to {
opacity: 0;
transform: translateY(-4px);
max-height: 0;
}
.ma-patient-banner-enter-to,
.ma-patient-banner-leave-from {
opacity: 1;
max-height: 80px;
}
/* .ma-cal__all-sessions (overlay + grid items + responsive grid mobile)
migraram pra Tailwind no template. Aqui ficam SO os state modifiers
.is-realizada/.is-faltou/.is-cancelada do __status (cores fixas rgb). */
.ma-cal__all-sessions__status.is-realizada,
.ma-cal__all-sessions__status.is-realizado {
background: rgba(16, 185, 129, 0.12);
border-color: rgba(16, 185, 129, 0.4);
color: rgb(5, 150, 105);
}
.ma-cal__all-sessions__status.is-faltou {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.4);
color: rgb(220, 38, 38);
}
.ma-cal__all-sessions__status.is-cancelada,
.ma-cal__all-sessions__status.is-cancelado {
background: rgba(148, 163, 184, 0.18);
border-color: rgba(148, 163, 184, 0.4);
color: rgb(100, 116, 139);
}
/* ─── FullCalendar wrapper ─────────────────────────────────── */
.ma-cal__fc {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative; /* ancora .ma-cal__filter-chip + .ma-cal__all-sessions */
/* Vars CSS do próprio FC sobrescritas pra glass aesthetic adaptativo */
--fc-border-color: var(--m-border);
--fc-page-bg-color: transparent;
--fc-neutral-bg-color: var(--m-bg-soft);
--fc-list-event-hover-bg-color: var(--m-bg-soft);
--fc-today-bg-color: var(--m-accent-soft);
--fc-now-indicator-color: rgb(239, 68, 68);
--fc-event-text-color: var(--m-text);
color: var(--m-text);
font-family: inherit;
}
/* .ma-cal__filter-chip migrou pra Tailwind utilities no template. */
/* 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%;
color: var(--m-text);
font-family: inherit;
}
.ma-cal__fc :deep(.fc-scrollgrid),
.ma-cal__fc :deep(.fc-scrollgrid-section > *),
.ma-cal__fc :deep(.fc-theme-standard td),
.ma-cal__fc :deep(.fc-theme-standard th) {
border-color: var(--m-border);
}
.ma-cal__fc :deep(.fc-col-header-cell-cushion),
.ma-cal__fc :deep(.fc-timegrid-axis-cushion),
.ma-cal__fc :deep(.fc-timegrid-slot-label-cushion),
.ma-cal__fc :deep(.fc-list-day-cushion) {
color: var(--m-text-muted);
font-weight: 500;
text-transform: lowercase;
text-decoration: none;
}
.ma-cal__fc :deep(.fc-col-header-cell-cushion) {
color: var(--m-text-muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 8px 4px;
/* navLinks ativo — header do dia vira clicável (vai pra view dia).
Cursor + transição suave indicam interatividade sem underline. */
cursor: pointer;
transition: color 140ms ease;
}
.ma-cal__fc :deep(.fc-col-header-cell-cushion:hover) {
color: var(--m-text);
}
.ma-cal__fc :deep(.fc-day-today) {
background: var(--m-accent-soft) !important;
}
.ma-cal__fc :deep(.fc-day-today .fc-col-header-cell-cushion),
.ma-cal__fc :deep(.fc-day-today .fc-daygrid-day-number) {
color: var(--m-accent);
}
.ma-cal__fc :deep(.fc-daygrid-day-number) {
color: var(--m-text-muted);
font-size: 0.78rem;
padding: 4px 6px;
text-decoration: none;
}
.ma-cal__fc :deep(.fc-list-day-cushion) {
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) {
background: var(--m-bg-soft);
}
.ma-cal__fc :deep(.fc-list-empty) {
background: transparent;
color: var(--m-text-muted);
}
/* Eventos */
.ma-cal__fc :deep(.fc-event) {
border-radius: 6px;
border: none;
border-left: 3px solid;
padding: 0;
cursor: pointer;
overflow: hidden;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: filter 140ms ease, transform 140ms ease;
}
.ma-cal__fc :deep(.fc-event:hover) {
filter: brightness(1.2);
}
.ma-cal__fc :deep(.fc-event-main) {
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) {
padding: 4px 6px;
color: var(--m-text);
font-family: inherit;
}
.ma-cal__fc :deep(.mc-fc-event__title) {
/* Mesmo tamanho/peso da .ma-pat__name (lista de pacientes) pra
alinhar a hierarquia visual entre aside e calendário.
Nome + hora em linha única; ellipsis corta o nome antes da hora. */
font-size: 0.85rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.ma-cal__fc :deep(.mc-fc-event__name) {
font-weight: 500;
}
.ma-cal__fc :deep(.mc-fc-event__hour) {
font-size: 0.7rem;
font-weight: 400;
color: var(--m-text-muted);
font-variant-numeric: tabular-nums;
margin-left: 2px;
}
.ma-cal__fc :deep(.mc-fc-event__meta) {
font-size: 0.6rem;
color: var(--m-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 1px;
}
/* Badges no evento do FC — espelham as da lista "Ver todas" pra
coerência visual. Wrap pra que em eventos pequenos (slots de
30min) elas quebrem em linha sem clipping. */
.ma-cal__fc :deep(.mc-fc-event__badges) {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 2px;
align-items: center;
}
.ma-cal__fc :deep(.mc-fc-event__badge) {
font-size: 0.58rem;
font-weight: 600;
line-height: 1;
padding: 2px 6px;
border-radius: 999px;
border: 1px solid;
white-space: nowrap;
letter-spacing: 0.01em;
text-transform: capitalize;
}
/* Status — paleta espelha .ma-cal__all-sessions__status (mesmas cores
semânticas usadas no histórico). Default = neutro (Agendado). */
.ma-cal__fc :deep(.mc-fc-event__badge--status) {
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.35);
color: white;
}
.ma-cal__fc :deep(.mc-fc-event__badge--status.is-realizado),
.ma-cal__fc :deep(.mc-fc-event__badge--status.is-realizada) {
background: rgba(16, 185, 129, 0.22);
border-color: rgba(16, 185, 129, 0.6);
color: rgb(220, 252, 231);
}
.ma-cal__fc :deep(.mc-fc-event__badge--status.is-faltou) {
background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.65);
color: rgb(254, 226, 226);
}
.ma-cal__fc :deep(.mc-fc-event__badge--status.is-cancelado),
.ma-cal__fc :deep(.mc-fc-event__badge--status.is-cancelada) {
background: rgba(148, 163, 184, 0.3);
border-color: rgba(148, 163, 184, 0.6);
color: rgb(241, 245, 249);
}
.ma-cal__fc :deep(.mc-fc-event__badge--status.is-confirmado),
.ma-cal__fc :deep(.mc-fc-event__badge--status.is-confirmada) {
background: rgba(59, 130, 246, 0.25);
border-color: rgba(59, 130, 246, 0.6);
color: rgb(219, 234, 254);
}
.ma-cal__fc :deep(.mc-fc-event__badge--status.is-remarcado),
.ma-cal__fc :deep(.mc-fc-event__badge--status.is-remarcada) {
background: rgba(245, 158, 11, 0.25);
border-color: rgba(245, 158, 11, 0.6);
color: rgb(254, 243, 199);
}
/* Modalidade — visual diferente das de status pra distinguir tipo de info */
.ma-cal__fc :deep(.mc-fc-event__badge--modal) {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.3);
color: white;
}
.ma-cal__fc :deep(.mc-fc-event__badge--modal.is-online) {
background: rgba(168, 85, 247, 0.28);
border-color: rgba(168, 85, 247, 0.65);
color: rgb(243, 232, 255);
}
.ma-cal__fc :deep(.mc-fc-event__badge--modal.is-presencial) {
background: rgba(14, 165, 233, 0.28);
border-color: rgba(14, 165, 233, 0.65);
color: rgb(224, 242, 254);
}
/* Light mode — fundo do FC event tem alpha baixo, então as cores
semânticas precisam de mais saturação pra ler bem. */
html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--status.is-realizado),
html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--status.is-realizada) {
background: rgba(16, 185, 129, 0.18);
color: rgb(5, 95, 70);
}
html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--status.is-faltou) {
background: rgba(239, 68, 68, 0.18);
color: rgb(153, 27, 27);
}
html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--status.is-cancelado),
html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--status.is-cancelada) {
background: rgba(100, 116, 139, 0.18);
color: rgb(51, 65, 85);
}
html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--status.is-confirmado),
html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--status.is-confirmada) {
background: rgba(59, 130, 246, 0.18);
color: rgb(30, 64, 175);
}
html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--status.is-remarcado),
html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--status.is-remarcada) {
background: rgba(245, 158, 11, 0.2);
color: rgb(146, 64, 14);
}
html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--status:not(.is-realizado):not(.is-realizada):not(.is-faltou):not(.is-cancelado):not(.is-cancelada):not(.is-confirmado):not(.is-confirmada):not(.is-remarcado):not(.is-remarcada)) {
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.4);
color: rgb(55, 48, 163);
}
html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--modal.is-online) {
background: rgba(168, 85, 247, 0.18);
color: rgb(88, 28, 135);
}
html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--modal.is-presencial) {
background: rgba(14, 165, 233, 0.18);
color: rgb(7, 89, 133);
}
/* Cursor "agora" do FC */
.ma-cal__fc :deep(.fc-timegrid-now-indicator-line) {
border-color: rgb(239, 68, 68);
border-width: 2px;
}
.ma-cal__fc :deep(.fc-timegrid-now-indicator-arrow) {
border-color: rgb(239, 68, 68);
}
/* Grid de horas — 24 slots de 30min em 12h cabem sem scroll com altura confortável */
.ma-cal__fc :deep(.fc-timegrid-slot) {
height: 1.6em;
border-color: var(--m-border);
}
.ma-cal__fc :deep(.fc-timegrid-slot-minor) {
border-top-style: dashed;
border-top-color: var(--m-border);
}
/* Label customizado: HH:00 mais forte, :30 menor/translúcido (pattern AgendaTerapeutaPage) */
.ma-cal__fc :deep(.mc-slot-hour) {
font-size: 0.72rem;
font-weight: 600;
color: var(--m-text-muted);
font-variant-numeric: tabular-nums;
}
.ma-cal__fc :deep(.mc-slot-half) {
font-size: 0.6rem;
color: var(--m-text-faint);
font-variant-numeric: tabular-nums;
}
.ma-cal__fc :deep(.fc-timegrid-axis),
.ma-cal__fc :deep(.fc-timegrid-slot-label) {
border-color: transparent;
}
/* Mês: célula */
.ma-cal__fc :deep(.fc-daygrid-day-frame) {
min-height: 5rem;
}
.ma-cal__fc :deep(.fc-daygrid-event) {
margin: 1px 2px;
}
.ma-cal__fc :deep(.fc-daygrid-more-link) {
color: var(--m-text-muted);
font-size: 0.7rem;
}
/* Lista (listAll — view custom 2 anos centrada em hoje) */
.ma-cal__fc :deep(.fc-list-day-text),
.ma-cal__fc :deep(.fc-list-day-side-text) {
color: var(--m-text);
font-weight: 600;
}
.ma-cal__fc :deep(.fc-list-event-time),
.ma-cal__fc :deep(.fc-list-event-title) {
color: var(--m-text);
}
.ma-cal__fc :deep(.fc-list-event-graphic .fc-list-event-dot) {
border-color: currentColor;
}
/* Scrollbar do FC interno */
.ma-cal__fc :deep(.fc-scroller) {
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.ma-cal__fc :deep(.fc-scroller::-webkit-scrollbar) {
width: 6px;
height: 6px;
}
.ma-cal__fc :deep(.fc-scroller::-webkit-scrollbar-thumb) {
background: var(--m-border-strong);
border-radius: 3px;
}
/* ═══ COL 3: Widgets direita ═════════════════════════════════ */
.ma-widgets {
width: 320px;
flex-shrink: 0;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
/* Mesmo bg/borda da .ma-side (col 1) pra simetria visual entre as
colunas laterais. Border fica na esquerda já que esta é a col 3. */
background: var(--m-bg-soft);
border-left: 1px solid var(--m-border);
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.ma-widgets::-webkit-scrollbar { width: 5px; }
.ma-widgets::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.ma-w {
/* Surface mais opaca que --m-bg-soft pra os cards se destacarem das
colunas laterais (.ma-side e .ma-widgets já usam --m-bg-soft). E
diferente do dialog (que também usa --m-bg-soft). */
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 12px;
}
/* Modificador pra usar o card .ma-w dentro da sidebar (col 1) — adiciona
margem nas bordas (a .ma-side não tem padding interno; head/search/list
têm padding próprio) e cap de altura interno da lista pra não estourar
toda a sidebar. */
.ma-w--side {
margin: 12px 12px 8px;
flex-shrink: 0;
max-height: 50vh;
display: flex;
flex-direction: column;
}
.ma-w--side .ma-w__list {
overflow-y: auto;
min-height: 0;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.ma-w--side .ma-w__list::-webkit-scrollbar { width: 5px; }
.ma-w--side .ma-w__list::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* ProximosFeriadosCard reusado da AgendaTerapeutaPage. Override visual
pra alinhar com a estética dos outros .ma-w (mesma surface, mesmo
radius, mesma borda).
Notas técnicas:
- `:deep()` é necessário porque o filho usa `defineOptions({ inheritAttrs:
false })`, então o data-v scoped do parent não chega no root do filho.
- `!important` é necessário no background porque o root do filho vem com
`class="bg-[var(--surface-card)]"` (Tailwind arbitrary value), e o
comportamento da cascata com util classes arbitrárias é frágil — em
dev/prod, dependendo da ordem do CSS no bundle, ele venceria nossa
regra. !important fecha a porta. */
:deep(.ma-w-feriados.rounded-3xl) {
background: var(--m-bg-medium) !important;
border-color: var(--m-border) !important;
border-radius: 12px !important;
min-height: 158px;
/* flex: 0 0 auto — toma o tamanho natural do conteúdo (incluindo
expansão da confirmação inline) e NÃO encolhe quando o histórico
crescer. O histórico (flex: 1 abaixo) absorve o restante. */
flex: 0 0 auto;
}
:deep(.ma-w-feriados .border-b) {
border-color: var(--m-border);
}
/* Card "Pacientes" — herda .ma-w (background --m-bg-medium). Apenas
ajustes finos pros children caberem bem dentro do card. */
/* Search dentro do card — zera margin lateral (o card já tem padding 12px) */
.ma-w--pacientes .ma-search {
margin: 0 0 8px;
}
/* Cluster de ações dentro do card */
.ma-w--pacientes .ma-side__actions {
margin-bottom: 8px;
}
/* Lista scrollável: zera padding lateral pro item ocupar a largura toda
do card (paddings horizontais ficam no .ma-pat). */
.ma-w--pacientes .ma-w__list {
padding: 0;
margin: 0 -2px;
}
/* Paginator visualmente "rodapé" do card — divider sutil no topo. */
.ma-w--pacientes .ma-side__paginator.p-paginator {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--m-border);
}
.ma-w__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.ma-w__title {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--m-text-muted);
font-weight: 600;
}
.ma-w__count {
font-size: 0.65rem;
font-weight: 600;
color: var(--m-accent);
background: var(--m-accent-soft);
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
padding: 1px 7px;
border-radius: 999px;
}
.ma-w__nav { display: flex; gap: 2px; }
.ma-w__icon {
width: 24px; height: 24px;
display: grid; place-items: center;
background: transparent;
border: none;
color: var(--m-text-muted);
border-radius: 6px;
cursor: pointer;
font-size: 0.65rem;
transition: background-color 140ms ease, color 140ms ease;
}
.ma-w__icon:hover {
background: var(--m-bg-soft-hover);
color: var(--m-text);
}
/* Mini calendar — bases (.weekdays, .grid, .day, .dots, .dot) migraram pra
Tailwind utilities no template. Aqui ficam os state modifiers com lógica
de cor (color-mix por tipo de feriado, is-hoje, is-in-range, is-fechado). */
.mini-cal__day.is-outro { color: var(--m-text-faint); }
/* Range visível no FC central — destaque sutil pra indicar "esses dias
estão na view atual do calendário grande". Mais leve que is-hoje pra
não competir visualmente. is-hoje sempre vence (regra mais específica). */
.mini-cal__day.is-in-range {
background: var(--m-accent-soft);
color: var(--m-accent);
font-weight: 600;
}
.mini-cal__day.is-in-range:hover {
background: var(--m-accent-strong);
}
/* Dia fechado (fora da jornada semanal — workRules) — cinza apagado.
Mais forte que is-outro (mês passado) pra distinguir as duas razões.
Aplicado SÓ quando não há feriado (mutex no template) — feriado em
dia fechado vira a cor do feriado, não cinza. */
.mini-cal__day.is-fechado {
color: var(--m-text-faint);
background: color-mix(in srgb, var(--p-text-color) 5%, transparent);
}
.mini-cal__day.is-fechado:hover {
background: color-mix(in srgb, var(--p-text-color) 10%, transparent);
}
/* Feriados — uma cor por tipo. Aplicado ANTES do is-hoje pra que o dia
atual sempre vença (bg accent), mas o número herda a cor do feriado
se não for hoje. Tooltip mostra nome do feriado. */
.mini-cal__day.is-feriado {
font-weight: 600;
}
.mini-cal__day.is-feriado--nacional {
color: rgb(244, 63, 94); /* rose-500 */
background: color-mix(in srgb, rgb(244, 63, 94) 12%, transparent);
}
.mini-cal__day.is-feriado--nacional:hover {
background: color-mix(in srgb, rgb(244, 63, 94) 22%, transparent);
}
.mini-cal__day.is-feriado--municipal {
color: rgb(217, 119, 6); /* amber-600 */
background: color-mix(in srgb, rgb(245, 158, 11) 14%, transparent);
}
.mini-cal__day.is-feriado--municipal:hover {
background: color-mix(in srgb, rgb(245, 158, 11) 24%, transparent);
}
.mini-cal__day.is-feriado--personalizado {
color: rgb(139, 92, 246); /* violet-500 */
background: color-mix(in srgb, rgb(139, 92, 246) 14%, transparent);
}
.mini-cal__day.is-feriado--personalizado:hover {
background: color-mix(in srgb, rgb(139, 92, 246) 24%, transparent);
}
.mini-cal__day.is-hoje {
background: var(--m-accent);
color: var(--p-primary-contrast-color, white);
font-weight: 700;
}
/* Hoje E feriado: mantém bg accent saturado mas o ring externo da cor
do feriado indica a dupla condição (avoid color collision). */
.mini-cal__day.is-hoje.is-feriado--nacional { box-shadow: 0 0 0 2px rgba(244, 63, 94, 0.55); }
.mini-cal__day.is-hoje.is-feriado--municipal { box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.55); }
.mini-cal__day.is-hoje.is-feriado--personalizado { box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.55); }
/* .mini-cal__dots e .mini-cal__dot bases migraram pra Tailwind no template.
Aqui só os overrides condicionais de cor do dot por estado. */
.mini-cal__day.is-hoje .mini-cal__dot { background: var(--p-primary-contrast-color, white); }
.mini-cal__day.is-in-range .mini-cal__dot { background: var(--m-accent); }
/* .ma-stats e .ma-stat (base + __val + __lbl) migraram pra Tailwind no
template. Aqui ficam os state modifiers .is-active / .is-ok / .is-warn
(cores derivadas via var() e color-mix). */
.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.is-ok .ma-stat__val { color: var(--m-accent); }
.ma-stat.is-warn .ma-stat__val { color: rgb(239, 68, 68); }
/* Divider entre stats e lista de sessões dentro do mesmo card "Hoje".
Linha sutil + respiro acima/abaixo pra criar separação visual sem
precisar de header próprio pra lista. */
.ma-w__divider {
height: 1px;
background: var(--m-border);
margin: 12px -2px 10px;
}
/* Sessões hoje (lista) */
.ma-w__list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ma-w__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 16px;
margin: 8px 4px;
color: var(--m-text-muted);
font-size: 0.82rem;
border: 1.5px dashed var(--m-border);
border-radius: 14px;
background: color-mix(in srgb, var(--m-bg-soft) 50%, transparent);
text-align: center;
}
.ma-w__empty i {
font-size: 2.2rem;
opacity: 0.45;
margin-bottom: 2px;
}
.ma-w__empty-title {
font-size: 0.88rem;
font-weight: 600;
color: var(--m-text);
}
.ma-w__empty-sub {
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.35;
}
.ma-w__empty-sub strong { color: var(--m-text); font-weight: 600; }
/* Variante "search" — quando há termo digitado, ícone com tom primary suave
pra distinguir do empty puro de "nenhum cadastrado". */
.ma-w__empty--search {
border-color: color-mix(in srgb, var(--p-primary-color) 22%, var(--m-border));
}
.ma-w__empty--search i {
color: var(--p-primary-color);
opacity: 0.7;
}
/* .ma-sess (base + __time/__main/__name/__meta + child strong/span)
migrou pra Tailwind no template. Sem state modifiers — bloco vazio. */
/* .ma-dock-actions (base + __avatar/__name/__count/__divider/__btn/__btn--close)
migraram pra Tailwind no template. Mobile-only hide tambem (max-[1024px]:hidden).
Aqui fica SO o state modifier .is-active do __count (cores accent). */
.ma-dock-actions__count.is-active {
color: var(--p-primary-contrast-color, white);
background: var(--p-primary-color);
border-color: var(--p-primary-color);
font-weight: 600;
}
/* Pop in/out do dock-actions */
.ma-dock-pop-enter-active,
.ma-dock-pop-leave-active {
transition: opacity 220ms ease, transform 220ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
.ma-dock-pop-enter-from,
.ma-dock-pop-leave-to {
opacity: 0;
transform: translateY(8px) scale(0.96);
}
/* ─── Light mode tweaks ──────────────────────────────────────
Cores principais já flipam via var(--m-text). Aqui suavizamos
shadows escuras agressivas e ajustamos contraste de eventos. */
html:not(.app-dark) .ma-page {
box-shadow:
0 12px 36px rgba(15, 23, 42, 0.10),
0 2px 6px rgba(15, 23, 42, 0.06);
}
/* Eventos do FC: backdrop-filter blur + bg colorido translúcido fica
muito lavado em light. Reforça a saturação pra eventos manterem
identidade da cor (sessão verde, supervisão azul, reunião amarela). */
html:not(.app-dark) .ma-cal__fc :deep(.fc-event) {
backdrop-filter: blur(2px) saturate(140%);
-webkit-backdrop-filter: blur(2px) saturate(140%);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
/* Cursor "agora" — vermelho saturado já funciona em ambos modos,
mas em light o background do slot é claro, então o cursor precisa
de um pouco mais de presença. */
html:not(.app-dark) .ma-cal__fc :deep(.fc-timegrid-now-indicator-line) {
border-width: 2px;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.35);
}
/* ═══════════════════════════════════════════════════════════════
Mobile drawer (off-canvas)
───────────────────────────────────────────────────────────────
Renderizado fora do .ma-page (multi-root template) → ocupa 100vh
real, single-scroll. Recebe .ma-side e .ma-widgets via Teleport
quando isMobile (<lg). Em desktop fica display:none via v-show.
═══════════════════════════════════════════════════════════════ */
/* .ma-mobile-drawer* migrado pra Tailwind utilities no template.
Aqui ficam só:
- scrollbar styling (sem utility Tailwind nativa)
- overrides cross-teleport (.ma-side / .ma-widgets ganham
resets quando teleportadas pra dentro do drawer; impossível
fazer puro no template porque depende de runtime parent). */
.ma-mobile-drawer__scroll {
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.ma-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.ma-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Quando teleportadas pra dentro do drawer, .ma-side e .ma-widgets
perdem scroll/border/padding próprio — o pai .__scroll cuida. */
.ma-mobile-drawer__scroll .ma-side,
.ma-mobile-drawer__scroll .ma-widgets {
width: 100%;
flex-shrink: 0;
height: auto;
overflow: visible;
border-right: none;
background: transparent;
padding: 0;
}
.ma-drawer-fade-enter-active,
.ma-drawer-fade-leave-active {
transition: opacity 200ms ease;
}
.ma-drawer-fade-enter-from,
.ma-drawer-fade-leave-to {
opacity: 0;
}
/* Responsivo <xl: filters/view--xl-only/btn--xl-only/btn--compact-only
migraram pras utilities `max-[1279px]:hidden` / `hidden max-[1279px]:inline-flex`
no template. */
/* ═══════════════════════════════════════════════════════════════
Responsivo <lg (≤1023px) — "mobile"
───────────────────────────────────────────────────────────────
- .ma-side e .ma-widgets somem do .ma-body (Teleport os move pro
.ma-mobile-drawer fora do layout)
- .ma-cal vira fullwidth
- Botão "Menu" aparece à esquerda do header
- Toolbar pode wrap
═══════════════════════════════════════════════════════════════ */
@media (max-width: 1023px) {
.ma-body {
flex-direction: column;
}
.ma-cal {
width: 100%;
border-right: none;
}
/* Título some — "Menu Agenda" carrega o nome da seção, evita
redundância e libera espaço pra Configurações + Fechar. */
.ma-page__title { display: none; }
/* Menu button (header esquerda) aparece */
/* .ma-menu-btn--mobile-only migrou pra `hidden max-[1023px]:inline-flex` no template */
/* Toolbar pode estourar com tantos elementos — permite wrap. */
.ma-cal__toolbar {
flex-wrap: wrap;
gap: 8px;
}
}
</style>