1feb7112ff
Cenário 7 (Pacote UPFRONT — Ana Souza Ferreira 4×R$ 200 = R$ 800)
- Testado e passou. User criou Ana, pagou os R$ 800 em dinheiro pelo
Financeiro. Borda verde + popover "Pago R$ 800" funcionando.
Fase 6 (lock-edit cobrada) ativada em Melissa
- Removido guard `if (!props.occurrenceMode) return;` em
loadOccFinancialRecord (useAgendaEventLifecycle.js:217+). Agora ele
carrega em ambos modos (Rail/Clínica E Melissa)
- loadOccFinancialRecord SINTETIZA record paid/pending pra siblings de
contrato upfront ativo — assim TODAS as ocorrências da série mostram
"Cobrança paga R$ 800 do pacote" no AgendaEventDialog
- AgendaEventDialog card Sessão/Honorários (flow Melissa) ganhou lock
template: Tag em vez de Select billingType quando occFinancialRecord
existe; Message com cadeado "Cobrança de R$ X já emitida"
- AgendaEventoFinanceiroPanel só renderiza dentro do lock quando record
é REAL (não sintetizado) — evita "Gerar cobrança" indevido em sibling
- paymentSummary do Resumo lateral unificado pra usar occFinancialRecord
(em vez do sessionPaymentRecord paralelo de antes)
Cross-week propagation de pacote upfront
- BUG: ao navegar pra semana só com virtuais (sem reais), bulk-load
caía no else `_rulePaymentMap.value = {}` — virtuais perdiam estado
paid herdado
- FIX em useMelissaAgenda._reloadRange:
* Maps (payment/amount/rule) inicializados SEMPRE no início
* Propagação roda independente de realIds.length (depende só de
ruleIdsInView.size>0, considera reais E virtuais com recurrence_id)
* Query cross-week: pra cada rule em view, busca QUALQUER evento
sibling em qualquer semana + seus records pra determinar estado do
contrato. Encontra o record do pacote mesmo em outra semana
- Saldo NÃO propaga (filter: charging_style='upfront' || NULL); cada
sessão de saldo gera cobrança individual ao realizar
- Memória durável: memory/project_cross_week_propagation.md
Visualização de virtuais cobertas
- MelissaEventoPanel.showPaymentRow: virtuais só escondem quando state
='none'. Com paid/pending herdado, exibem linha colorida
- MelissaAgenda fcEvents: isPaidSession e badge $ pendente removeram
exigência de !is_occurrence. Virtuais herdadas via propagação mostram
borda verde / badge amber
Atalho "Gerar fatura" no popover
- Pill amber pequeno ao lado de "A cobrar R$ X" quando paymentVariant
='none' && !is_occurrence. Click → gerarCobrancaManual direto, fecha
popover pra impedir double-click. Tooltip: "Gerar fatura agora"
- Wire em MelissaLayout via novo emit gerar-cobranca + handler
onGerarCobrancaQuick
Info de pacote no popover
- Header agora mostra "Sessão · Pacote · N sessões" (computed
seriesLabel lê de _raw do rule)
Botão "Excluir série inteira"
- Novo emit delete-series em MelissaEventoPanel + botão ao lado de
"Excluir sessão" quando evento tem recurrence_id
- Handler onDeleteSeries em MelissaLayout: hard delete em 3 etapas
(financial_records pendentes → agenda_eventos materializados →
recurrence_rules CASCADE leva exceptions). Bloqueia se algum record
paid (estorno via Financeiro primeiro)
cancel_session some da agenda
- useRecurrence.expandRules agora pula occurrence com exception.type=
'cancel_session' (era visível com status cancelado; doc dizia que
some). patient_missed/therapist_canceled/holiday_block permanecem
como histórico
recurrence_exceptions cancel idempotente
- MelissaLayout onDeleteEvento usa upsert com onConflict pra exception
cancel — não quebra mais com unique violation em re-cancel
billing_contract_id na 1ª materializada
- _createPackageContract agora .select() o contrato após insert e seta
billing_contract_id no insert da 1ª agenda_eventos materializada
onVerLancamentos cobre virtual de upfront
- Antes virtual sempre toast "Sem lançamentos". Agora busca records via
siblings da série pra encontrar o do pacote. Saldo/sem pacote continua
com toast
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3024 lines
143 KiB
Vue
3024 lines
143 KiB
Vue
<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' (00–24h) | '12' (06–18h) | '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';
|
||
// Sessão paga → barra esquerda verde (override do border-left que o
|
||
// FC pinta com a cor do commitment). Aplica a virtuais TAMBÉM quando
|
||
// herdam 'paid' de pacote upfront pago via propagação no bulk-load
|
||
// (sem essa propagação, virtuais ficam paymentState='none' e o
|
||
// check abaixo já as exclui).
|
||
const isPaidSession =
|
||
String(ev.tipo || '').toLowerCase() === 'sessao' &&
|
||
(ev.patient_id || ev.paciente_id) &&
|
||
ev.paymentState === 'paid';
|
||
const cls = [];
|
||
if (isInactivePatient) cls.push('ma-evt--inactive-patient');
|
||
if (isPaidSession) cls.push('ma-evt--paid');
|
||
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: cls.length ? cls : 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);
|
||
}
|
||
// Bloqueios (background events cinza) — concat direto sem filtro.
|
||
const blqs = bloqueioFcEvents.value;
|
||
if (blqs.length) {
|
||
for (const b of blqs) out.push(b);
|
||
}
|
||
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: 00–24h. 12: 06–18h. 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// 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) return;
|
||
// Bloqueios e pausas semanais são background events não-clicáveis —
|
||
// o painel lateral é só pra sessões/compromissos reais.
|
||
if (ev.kind === 'bloqueio' || ev.kind === 'break') return;
|
||
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>
|
||
`;
|
||
}
|
||
|
||
// Badge "$ a receber" — sessão com paciente, ainda não paga.
|
||
// Cobre Cenário 2 (sem cobrança, sem record) e Cenário 3 (cobrança
|
||
// pendente). Esconde quando paid ou quando não é sessão com paciente.
|
||
// Ocorrências virtuais sempre 'none' até serem materializadas — pra
|
||
// não poluir séries recorrentes com pacote upfront/saldo (cobertas
|
||
// pelo contrato, não por record-por-sessão).
|
||
let payBadgeHtml = '';
|
||
// Badge $ amber pendente: aparece pra sessão com paciente quando há
|
||
// cobrança pendente (paymentState='pending'/'overdue') OU quando é
|
||
// REAL sem cobrança ainda ('none'). Virtuais com 'none' (saldo,
|
||
// sem pacote, ou virtuais limpas) ficam SEM badge — só virtuais
|
||
// herdando 'pending' de pacote upfront mostram o badge.
|
||
const wantBadge = isSessao && ext.patient_id && ext.paymentState !== 'paid' && (
|
||
ext.paymentState === 'pending' || ext.paymentState === 'overdue' ||
|
||
(!ext.is_occurrence && (ext.paymentState === 'none' || !ext.paymentState))
|
||
);
|
||
if (wantBadge) {
|
||
payBadgeHtml = `<span class="mc-fc-event__paybadge" title="Cobrança pendente"><i class="pi pi-dollar"></i></span>`;
|
||
}
|
||
|
||
// 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">
|
||
${payBadgeHtml}
|
||
${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) {
|
||
// Detecta saida da view 'lista' antes de trocar — se o user veio de
|
||
// lista, o refDate atual ta em (hoje - 1 ano) e ao mudar pra week/month
|
||
// o FullCalendar mantem esse refDate, fazendo a agenda parecer estar
|
||
// no ano passado. Snap pra hoje resolve. 2026-05-12.
|
||
const leavingLista = calendarView.value === 'lista' && v !== 'lista';
|
||
|
||
calendarView.value = v;
|
||
fcApi()?.changeView(VIEW_MAP[v]);
|
||
|
||
if (v === 'lista') {
|
||
// Lista cobre 2 anos — abrimos centrado: pula pra (hoje - 1 ano) pra
|
||
// mostrar passado + presente + futuro de uma vez.
|
||
const umAnoAtras = new Date();
|
||
umAnoAtras.setFullYear(umAnoAtras.getFullYear() - 1);
|
||
fcApi()?.gotoDate(umAnoAtras);
|
||
} else if (leavingLista) {
|
||
fcApi()?.today();
|
||
}
|
||
}
|
||
|
||
// ── 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 _bloqueioFcEventsFallback = ref([]);
|
||
const _feriadosAnoFallback = ref(new Date().getFullYear());
|
||
const _workRulesFallback = ref([]);
|
||
const feriadosTodos = M?.feriados ?? _feriadosFallback;
|
||
const feriadoFcEvents = M?.feriadoFcEvents ?? _feriadoFcEventsFallback;
|
||
const bloqueioFcEvents = M?.bloqueioFcEvents ?? _bloqueioFcEventsFallback;
|
||
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 já 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 só 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;
|
||
}
|
||
|
||
/* Sessão paga — barra esquerda verde no lugar da cor do commitment.
|
||
Espelho positivo do badge $ amber: pago = canal visual esquerdo,
|
||
pendente = canal direito, sem cobrança = neutro. !important porque
|
||
o FC seta border-color inline a partir do borderColor do evento. */
|
||
.ma-cal__fc :deep(.fc-event.ma-evt--paid) {
|
||
border-left-color: #10b981 !important; /* emerald-500 */
|
||
border-left-width: 4px !important;
|
||
}
|
||
.ma-cal__fc :deep(.fc-list-event.ma-evt--paid .fc-list-event-dot) {
|
||
border-color: #10b981 !important;
|
||
}
|
||
|
||
.ma-cal__fc :deep(.mc-fc-event) {
|
||
padding: 4px 6px;
|
||
color: var(--m-text);
|
||
font-family: inherit;
|
||
position: relative;
|
||
}
|
||
|
||
/* Badge "$ a receber" — canto superior direito do evento. Amarelo
|
||
amber, pequeno, sinaliza cobrança pendente sem competir com os
|
||
badges de status/modalidade. Renderizado só pra sessão+paciente
|
||
com paymentState !== 'paid'. */
|
||
.ma-cal__fc :deep(.mc-fc-event__paybadge) {
|
||
position: absolute;
|
||
top: 2px;
|
||
right: 3px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 9999px;
|
||
background: #f59e0b;
|
||
color: #fff;
|
||
font-size: 0.6rem;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||
z-index: 2;
|
||
}
|
||
.ma-cal__fc :deep(.mc-fc-event__paybadge .pi) {
|
||
font-size: 0.62rem;
|
||
font-weight: 700;
|
||
}
|
||
.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" já 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>
|