diff --git a/src/layout/melissa/MelissaAgenda.vue b/src/layout/melissa/MelissaAgenda.vue index a78fc78..e698fbb 100644 --- a/src/layout/melissa/MelissaAgenda.vue +++ b/src/layout/melissa/MelissaAgenda.vue @@ -22,7 +22,7 @@ import dayGridPlugin from '@fullcalendar/daygrid'; import listPlugin from '@fullcalendar/list'; import interactionPlugin from '@fullcalendar/interaction'; import ptBrLocale from '@fullcalendar/core/locales/pt-br'; -import { useMelissaEventosRange, useMelissaTodasSessoesPaciente, searchEventosByText } from './composables/useMelissaEventos'; +import { useMelissaEventosRange, useMelissaTodasSessoesPaciente } from './composables/useMelissaEventos'; import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda'; import { useMelissaPacientesAside } from './composables/useMelissaPacientesAside'; import { useTenantStore } from '@/stores/tenantStore'; @@ -30,6 +30,8 @@ import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosC 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 PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'; @@ -104,6 +106,14 @@ onMounted(() => { 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() { @@ -119,24 +129,11 @@ function goSettings() { } // ── "Ações" (popover) — toolbar compacta em popup por um Popover com SelectButtons -// inline (mais ergonômico que listas longas). Concentra view-switcher -// + timeMode + onlySessions + ações de bloqueio. -const mobileActionsPopRef = ref(null); -function openMobileActions(event) { - mobileActionsPopRef.value?.toggle(event); -} -function closeMobileActions() { - try { mobileActionsPopRef.value?.hide(); } catch {} -} -const viewOptions = [ - { label: 'Dia', value: 'dia' }, - { label: 'Semana', value: 'semana' }, - { label: 'Mês', value: 'mes' }, - { label: 'Lista', value: 'lista' } -]; -function chamarBloqueio(mode) { - closeMobileActions(); +// 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); } @@ -176,6 +173,11 @@ const { }); 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). @@ -360,38 +362,47 @@ function statLabel(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 pred = statFiltroAtivo.value ? STAT_FILTERS[statFiltroAtivo.value] : () => true; - // onlySessions filtra eventos sem paciente vinculado (compromissos - // pessoais como "Análise" ainda usam tipo='sessao' no banco — patient_id - // é o discriminador real). Espelha AgendaTerapeutaPage:446. - const sessionPred = onlySessions.value - ? (ev) => !!(ev.patient_id || ev.paciente_id) - : () => true; - // Quando há paciente selecionado, restringe aos eventos dele. Feriados - // (background events) não passam por esse filtro — vêm direto do array - // de feriados abaixo, mantendo contexto visual do dia. + const statKey = statFiltroAtivo.value; + const statPred = statKey ? STAT_FILTERS[statKey] : null; + const onlySess = onlySessions.value; const pacienteId = pacienteSelecionadoId.value; - const pacientePred = pacienteId - ? (ev) => (ev.patient_id || ev.paciente_id) === pacienteId - : () => true; - return [ - ...eventosSemana.value - .filter(pred) - .filter(sessionPred) - .filter(pacientePred) - .map((ev) => ({ - id: ev.id, - title: ev.label, - start: ev.inicio_em, - end: ev.fim_em, - backgroundColor: `${ev.color}26`, // ~15% opacity - borderColor: ev.color, - textColor: 'white', - extendedProps: ev - })), - ...feriadoFcEvents.value - ]; + + const 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; + 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', + extendedProps: ev + }); + } + // Feriados (background events) — concat direto sem filtro. + const feriados = feriadoFcEvents.value; + if (feriados.length) { + for (const f of feriados) out.push(f); + } + return out; }); // ── slotMinTime / slotMaxTime baseado em timeMode ───────────── @@ -594,107 +605,27 @@ const fcOptions = computed(() => ({ })); // ── Busca da toolbar (datas + paciente/título) ──────────────── -// Pattern Cmd+K: botão na toolbar abre Popover com input + resultados. -// Suporta: -// - Datas: "20/04", "20/04/2026", "hoje", "amanhã" → fcApi.gotoDate -// - Texto livre: pesquisa server-side em patients.nome_completo + titulo -// (via searchEventosByText) com dedup. Limite 20 resultados. -const searchPopRef = ref(null); -const searchInputRef = ref(null); -const searchQuery = ref(''); -const searchResults = ref([]); -const searchLoading = ref(false); -const searchDateMatch = ref(null); // Date | null — preenchido se query parsear como data -let _searchDebounceTimer = null; +// 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 parseSearchAsDate(str) { - const t = String(str || '').trim().toLowerCase(); - if (!t) return null; - if (t === 'hoje') { const d = new Date(); d.setHours(0,0,0,0); return d; } - if (t === 'amanha' || t === 'amanhã') { - const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + 1); return d; - } - if (t === 'ontem') { - const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() - 1); return d; - } - // DD/MM ou DD/MM/YYYY (também aceita - e .) - const m = t.match(/^(\d{1,2})[/\-.](\d{1,2})(?:[/\-.](\d{2,4}))?$/); - if (m) { - const day = parseInt(m[1], 10); - const month = parseInt(m[2], 10); - let year = parseInt(m[3] || '', 10); - if (!year || Number.isNaN(year)) year = new Date().getFullYear(); - if (year < 100) year += 2000; - if (day < 1 || day > 31 || month < 1 || month > 12) return null; - const d = new Date(year, month - 1, day); - if (Number.isNaN(d.getTime())) return null; - return d; - } - return null; -} - -function abrirBusca(event) { - searchPopRef.value?.toggle(event); - // Foco no input quando abrir (Popover anima ~150ms). PrimeVue InputText - // renderiza `` direto, então `$el` já é o elemento focável. - // Tentamos algumas vezes pra cobrir mount async + transição do Popover. - let tries = 0; - const tick = () => { - const el = searchInputRef.value?.$el; - if (el && typeof el.focus === 'function') { el.focus(); el.select?.(); return; } - if (tries++ < 8) setTimeout(tick, 30); - }; - setTimeout(tick, 80); -} - -function fecharBusca() { - try { searchPopRef.value?.hide(); } catch {} -} - -function onSearchInput() { - if (_searchDebounceTimer) clearTimeout(_searchDebounceTimer); - const q = searchQuery.value; - searchDateMatch.value = parseSearchAsDate(q); - // Se digitou data, mostra resultado imediato (sem hit no DB) - if (searchDateMatch.value) { - searchResults.value = []; - searchLoading.value = false; - return; - } - if (String(q || '').trim().length < 2) { - searchResults.value = []; - searchLoading.value = false; - return; - } - searchLoading.value = true; - _searchDebounceTimer = setTimeout(async () => { - try { - searchResults.value = await searchEventosByText(q); - } finally { - searchLoading.value = false; - } - }, 300); -} - -function onSearchSubmit() { - // Enter: prioriza data, senão pega o primeiro resultado - if (searchDateMatch.value) { - irParaData(searchDateMatch.value); - return; - } - if (searchResults.value.length > 0) { - onSelecionarResultado(searchResults.value[0]); - } -} - -function irParaData(date) { +function onBuscaGotoDate(date) { fcApi()?.gotoDate(date); refDate.value = new Date(date); - fecharBusca(); - // Limpa pra próxima busca começar zerada - searchQuery.value = ''; - searchResults.value = []; - searchDateMatch.value = null; +} + +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 @@ -712,6 +643,17 @@ async function onHistoricoOpen({ id }) { // 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, @@ -719,14 +661,12 @@ async function onHistoricoOpen({ id }) { paciente_nome: data.patients?.nome_completo || '', paciente_status: data.patients?.status || '', paciente_avatar: data.patients?.avatar_url || '', - startH: new Date(data.inicio_em).getHours() + new Date(data.inicio_em).getMinutes() / 60, - endH: new Date(data.fim_em).getHours() + new Date(data.fim_em).getMinutes() / 60, + startH: inicioDate.getHours() + inicioDate.getMinutes() / 60, + endH: fimDate.getHours() + fimDate.getMinutes() / 60, label: data.patients?.nome_completo || data.titulo || data.titulo_custom || '—' }; - if (data.inicio_em) { - fcApi()?.gotoDate(data.inicio_em); - refDate.value = new Date(data.inicio_em); - } + fcApi()?.gotoDate(data.inicio_em); + refDate.value = inicioDate; emit('select-evento', ev); } catch (e) { // eslint-disable-next-line no-console @@ -734,44 +674,19 @@ async function onHistoricoOpen({ id }) { } } -function onSelecionarResultado(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; - } - fecharBusca(); - searchQuery.value = ''; - searchResults.value = []; - searchDateMatch.value = null; -} - -// Atalho global Ctrl/Cmd+K abre a busca. +// 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 (se existir) + // 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) abrirBusca({ currentTarget: btn, target: btn }); + if (btn) searchPopover.value?.toggle({ currentTarget: btn, target: btn }); } } onMounted(() => { window.addEventListener('keydown', _onSearchHotkey); }); onBeforeUnmount(() => { window.removeEventListener('keydown', _onSearchHotkey); }); -function fmtDataResultado(iso) { - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return ''; - return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', weekday: 'short' }); -} -function fmtHoraResultado(iso) { - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return ''; - return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); -} - // Toolbar — atalhos pra FC API function fcApi() { return fcRef.value?.getApi?.() || null; @@ -818,11 +733,18 @@ const miniRefDate = ref(new Date()); // novamente useFeriados() e useAgendaSettings(), gerando duplicação de // queries (feriados municipais + agenda_configuracoes + agenda_regras). const tenantStore = useTenantStore(); -const feriadosTodos = M.feriados; -const feriadoFcEvents = M.feriadoFcEvents; -const feriadosAno = M.feriadosAno; -const loadFeriados = M.loadFeriadosBase; -const workRules = M.workRules; +// Fallbacks pra modo standalone (sem MelissaLayout parent / M=null) — +// mesmo pattern de viewStart/viewEnd/onCreateEvento. Antes esses 5 +// acessos diretos a M.x dispararam TypeError ao montar fora do layout. +const _feriadosFallback = ref([]); +const _feriadoFcEventsFallback = ref([]); +const _feriadosAnoFallback = ref(new Date().getFullYear()); +const _workRulesFallback = ref([]); +const feriadosTodos = M?.feriados ?? _feriadosFallback; +const feriadoFcEvents = M?.feriadoFcEvents ?? _feriadoFcEventsFallback; +const feriadosAno = M?.feriadosAno ?? _feriadosAnoFallback; +const loadFeriados = M?.loadFeriadosBase ?? (async () => {}); +const workRules = M?.workRules ?? _workRulesFallback; // Set de dias da semana ativos (0=dom..6=sáb). Fallback seg-sex se sem regras // — mesmo default do AgendaTerapeutaPage:370. @@ -1071,17 +993,62 @@ const prontuarioOpen = ref(false); const prontuarioPatient = ref(null); 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(() => { - if (!pacienteSelecionadoId.value) return null; - // Tenta primeiro na prop (lista completa do useMelissaPacientes); cai - // pra lista do aside como fallback — necessário em clínicas onde a - // prop principal está capada (1000) e o paciente clicado está além - // disso, mas veio normalmente pela query paginada. - return ( - props.pacientes.find((p) => p.id === pacienteSelecionadoId.value) || - pacientesAside.value.find((p) => p.id === pacienteSelecionadoId.value) || - null - ); + const id = pacienteSelecionadoId.value; + if (!id) return null; + return pacientesIndex.value.get(id) || null; }); const pacienteSelecionadoCount = computed(() => { @@ -1099,15 +1066,30 @@ const pacienteSelecionadoSessoesRange = computed(() => { }); // Carrega session count via RPC quando seleciona um paciente novo. -// Cache simples — não refaz se já tem o count na memória. +// 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 || sessionCountsMap.value.has(id)) return; + 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); } }); @@ -1254,88 +1236,90 @@ defineExpose({ pra ser um Teleport target válido em todo momento. O backdrop fica logo depois pra ficar entre drawer e .ma-page. -->
-
-
- +
+
+ -
+
Agenda
-
+
-
-
+
-