/* * useMelissaEventos — composables que carregam eventos reais da agenda * -------------------------------------------------- * Pattern espelhado de `src/features/agenda/pages/AgendaTerapeutaPage.vue` * (loadMonthSearchRows ~ linha 539). * * Exporta dois composables: * - useMelissaEventosSemana(refDateRef) — semana de refDate (ref) * - useMelissaEventosHoje() — apenas o dia atual * * Forma normalizada do evento: * { * id, tipo, status, titulo, * pacienteNome, modalidade, descricao, * color, label, * inicio_em, fim_em, * startH, endH, // decimais (9.5 = 09:30) — usado pelo layout * dateKey // 'YYYY-MM-DD' pra agrupar por dia * } * * Sem auth/tenant: retorna [] silencioso (UI segue funcional). * * NÃO inclui ocorrências virtuais de recorrência (loadAndExpand) pra simplificar * o preview. Adicionar quando promover Melissa pra produção. */ import { ref, watch, onMounted, computed } from 'vue'; import { supabase } from '@/lib/supabase/client'; import { useTenantStore } from '@/stores/tenantStore'; import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore'; // ── Cores por tipo/status (consistente com o resto do Melissa) ── function pickColor(tipo, status) { const s = String(status || '').toLowerCase(); if (s === 'realizado' || s === 'realizada') return '#10b981'; if (s === 'faltou') return '#ef4444'; if (s === 'cancelado' || s === 'cancelada') return '#94a3b8'; const t = String(tipo || '').toLowerCase(); if (t === 'supervisao' || t === 'supervisão') return '#a855f7'; if (t === 'reuniao' || t === 'reunião') return '#0ea5e9'; if (t === 'bloqueio') return '#64748b'; return '#6366f1'; // sessao default } function isoToDecimalHour(iso) { const d = new Date(iso); return d.getHours() + d.getMinutes() / 60; } function normalizeEvent(r) { const pacNome = r.patients?.nome_completo || ''; return { id: r.id, tipo: r.tipo || 'sessao', status: r.status || '', titulo: r.titulo || '', patient_id: r.patient_id || null, pacienteNome: pacNome, modalidade: r.modalidade || '', descricao: r.observacoes || '', color: pickColor(r.tipo, r.status), label: pacNome || r.titulo || '—', inicio_em: r.inicio_em, fim_em: r.fim_em, startH: isoToDecimalHour(r.inicio_em), endH: isoToDecimalHour(r.fim_em), dateKey: String(r.inicio_em || '').slice(0, 10), price: r.price != null ? Number(r.price) : 0, billed: !!r.billed }; } // ── Helper interno: garante uid + tenant + faz a query ── async function _fetchRange(start, end) { const tenantStore = useTenantStore(); const { data: userData } = await supabase.auth.getUser(); const userId = userData?.user?.id || null; if (typeof tenantStore.ensureLoaded === 'function') { await tenantStore.ensureLoaded(); } const tid = tenantStore.activeTenantId || tenantStore.tenantId || null; if (!userId || !tid) return []; const { data, error } = await supabase .from('agenda_eventos') .select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)') .eq('owner_id', userId) .is('mirror_of_event_id', null) .gte('inicio_em', start.toISOString()) .lt('inicio_em', end.toISOString()) .order('inicio_em', { ascending: true }); if (error) throw error; return (data || []).map(normalizeEvent); } // ── Range helpers ────────────────────────────────────────────── function rangeSemana(refDate) { const ref = new Date(refDate); const dow = ref.getDay(); // 0=dom, 1=seg const diff = dow === 0 ? -6 : 1 - dow; const segunda = new Date(ref); segunda.setDate(ref.getDate() + diff); segunda.setHours(0, 0, 0, 0); // domingo final → usa dia seguinte 00:00 com `lt` pra incluir tudo até 23:59:59.999 const apósDomingo = new Date(segunda); apósDomingo.setDate(segunda.getDate() + 7); return { start: segunda, end: apósDomingo }; } function rangeHoje() { const inicio = new Date(); inicio.setHours(0, 0, 0, 0); const fim = new Date(inicio); fim.setDate(inicio.getDate() + 1); // amanhã 00:00 return { start: inicio, end: fim }; } // ── COMPOSABLE 1: semana visível (MelissaAgenda) ────────────── export function useMelissaEventosSemana(refDateRef) { const eventos = ref([]); const loading = ref(false); const error = ref(null); async function fetch() { loading.value = true; error.value = null; try { const { start, end } = rangeSemana(refDateRef.value); eventos.value = await _fetchRange(start, end); } catch (e) { error.value = e?.message || 'Erro ao carregar agenda'; eventos.value = []; // eslint-disable-next-line no-console console.warn('[useMelissaEventosSemana]', e); } finally { loading.value = false; } } onMounted(fetch); watch(refDateRef, fetch); // Helper computado: agrupa por dateKey ('YYYY-MM-DD') const eventosPorDia = computed(() => { const map = {}; for (const ev of eventos.value) { (map[ev.dateKey] ||= []).push(ev); } return map; }); return { eventos, eventosPorDia, loading, error, refetch: fetch }; } // ── COMPOSABLE 3: range arbitrário (FullCalendar passa via datesSet) ── // Usado pelo MelissaAgenda — refetcha sempre que start/end mudam (mudança // de view, navegação prev/next/today). Cobre Day/Week/Month/List sem custo. export function useMelissaEventosRange(startRef, endRef) { const eventos = ref([]); const loading = ref(false); const error = ref(null); async function fetch() { const s = startRef.value; const e = endRef.value; if (!s || !e) { eventos.value = []; return; } loading.value = true; error.value = null; try { eventos.value = await _fetchRange(new Date(s), new Date(e)); } catch (err) { error.value = err?.message || 'Erro ao carregar agenda'; eventos.value = []; // eslint-disable-next-line no-console console.warn('[useMelissaEventosRange]', err); } finally { loading.value = false; } } onMounted(fetch); watch([startRef, endRef], fetch); return { eventos, loading, error, refetch: fetch }; } // ── Busca server-side: por nome de paciente ou título do evento ── // Usado pela busca da toolbar do MelissaAgenda. Procura sem range temporal // (acha sessões fora do que está visível no FC). Limite de 20 resultados, // ordenados por inicio_em DESC (mais recente primeiro). // // Acento-insensitive: troca cada vogal/c do termo por um character class // que casa com todas as variantes ("andre" vira "[aáàâã]ndr[eéèê]") e usa // `imatch` (operador POSIX `~*` do Postgres — case-insensitive regex). // Estratégia evita depender da extensão `unaccent` no DB. function _buildAccentInsensitivePattern(term) { // Escapa regex specials primeiro pra não quebrar o pattern. const escaped = String(term || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const map = { a: '[aáàâãAÁÀÂÃ]', e: '[eéèêEÉÈÊ]', i: '[iíìîIÍÌÎ]', o: '[oóòôõOÓÒÔÕ]', u: '[uúùûUÚÙÛ]', c: '[cçCÇ]', A: '[aáàâãAÁÀÂÃ]', E: '[eéèêEÉÈÊ]', I: '[iíìîIÍÌÎ]', O: '[oóòôõOÓÒÔÕ]', U: '[uúùûUÚÙÛ]', C: '[cçCÇ]' }; return escaped.split('').map((ch) => map[ch] || ch).join(''); } export async function searchEventosByText(termo) { const term = String(termo || '').trim(); if (term.length < 2) return []; const tenantStore = useTenantStore(); const { data: userData } = await supabase.auth.getUser(); const userId = userData?.user?.id || null; if (typeof tenantStore.ensureLoaded === 'function') { await tenantStore.ensureLoaded(); } const tid = tenantStore.activeTenantId || tenantStore.tenantId || null; if (!userId || !tid) return []; const SELECT = 'id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)'; const pattern = _buildAccentInsensitivePattern(term); try { const [byPatient, byTitle] = await Promise.all([ supabase .from('agenda_eventos') .select(SELECT.replace('patients!agenda_eventos_patient_id_fkey', 'patients!inner!agenda_eventos_patient_id_fkey')) .eq('owner_id', userId) .is('mirror_of_event_id', null) .filter('patients.nome_completo', 'imatch', pattern) .order('inicio_em', { ascending: false }) .limit(20), supabase .from('agenda_eventos') .select(SELECT) .eq('owner_id', userId) .is('mirror_of_event_id', null) .filter('titulo', 'imatch', pattern) .order('inicio_em', { ascending: false }) .limit(20) ]); if (byPatient.error) throw byPatient.error; if (byTitle.error) throw byTitle.error; const merged = [...(byPatient.data || []), ...(byTitle.data || [])]; const seen = new Set(); const unique = []; for (const r of merged) { if (seen.has(r.id)) continue; seen.add(r.id); unique.push(r); } unique.sort((a, b) => String(b.inicio_em).localeCompare(String(a.inicio_em))); return unique.slice(0, 20).map(normalizeEvent); } catch (e) { // eslint-disable-next-line no-console console.warn('[searchEventosByText]', e); return []; } } // ── COMPOSABLE 4: todas as sessões de um paciente (sem range) ── // Usado pelo banner "Ver todas" da MelissaAgenda quando o usuário // quer escapar do range visível e ver o histórico completo do // paciente selecionado. Diferente do useMelissaEventosRange, aqui // filtramos por patient_id e ignoramos qualquer range temporal. // // Retorna eventos ordenados por inicio_em DESC (mais recente primeiro) // — coerente com listas de "histórico" no resto do sistema. export function useMelissaTodasSessoesPaciente() { const eventos = ref([]); const loading = ref(false); const error = ref(null); async function fetch(patientId) { if (!patientId) { eventos.value = []; return; } loading.value = true; error.value = null; try { const tenantStore = useTenantStore(); const { data: userData } = await supabase.auth.getUser(); const userId = userData?.user?.id || null; if (typeof tenantStore.ensureLoaded === 'function') { await tenantStore.ensureLoaded(); } const tid = tenantStore.activeTenantId || tenantStore.tenantId || null; if (!userId || !tid) { eventos.value = []; return; } const { data, error: err } = await supabase .from('agenda_eventos') .select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)') .eq('owner_id', userId) .eq('patient_id', patientId) .is('mirror_of_event_id', null) .order('inicio_em', { ascending: false }); if (err) throw err; eventos.value = (data || []).map(normalizeEvent); } catch (e) { error.value = e?.message || 'Erro ao carregar sessões'; eventos.value = []; // eslint-disable-next-line no-console console.warn('[useMelissaTodasSessoesPaciente]', e); } finally { loading.value = false; } } function reset() { eventos.value = []; error.value = null; } return { eventos, loading, error, fetch, reset }; } // ── COMPOSABLE 2: apenas hoje (MelissaLayout) ────────────────── // opts: { autoFetch=true } — passar false pra adiar o fetch inicial // (MelissaLayout faz isso quando a URL inicial já tem uma seção, pra // não competir com o fetch da seção que vai cobrir o resumo). export function useMelissaEventosHoje(opts = {}) { const autoFetch = opts.autoFetch !== false; const cache = useMelissaCacheStore(); const eventos = ref([]); const loading = ref(false); const error = ref(null); async function _doFetch(cacheKey) { const { start, end } = rangeHoje(); const data = await _fetchRange(start, end); cache.set('eventosHoje', data, cacheKey); eventos.value = data; return data; } // useCache=true (boot/auto): stale-while-revalidate. // useCache=false (refetch pós-mutation: status sessão, etc): força. async function _fetch({ useCache = true } = {}) { const today = new Date(); // Cache key amarra ao dia — depois de 00:00 vira automaticamente outro slot. const cacheKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`; if (useCache) { const cached = cache.get('eventosHoje', cacheKey, MELISSA_CACHE_TTL.eventosHoje); if (cached) { eventos.value = cached; _doFetch(cacheKey).catch((e) => { // eslint-disable-next-line no-console console.warn('[useMelissaEventosHoje] revalidate', e); }); return; } } else { cache.invalidate('eventosHoje'); } loading.value = true; error.value = null; try { await _doFetch(cacheKey); } catch (e) { error.value = e?.message || 'Erro ao carregar agenda'; eventos.value = []; // eslint-disable-next-line no-console console.warn('[useMelissaEventosHoje]', e); } finally { loading.value = false; } } if (autoFetch) onMounted(() => _fetch({ useCache: true })); return { eventos, loading, error, // refetch força query nova (após status update etc). refetch: () => _fetch({ useCache: false }), // fetchCached é stale-while-revalidate (idle/defer). fetchCached: () => _fetch({ useCache: true }) }; }