957e912a7f
Sprints B (05-03) e C (05-04) acumulados: - NotificationDrawer/Item redesign (visual mais limpo, ações inline) - Dock pins compose (useMelissaDockPins) + cache store global (melissaCacheStore) - MelissaAgenda: timeline FullCalendar parity + cards resumo, histórico card com useMelissaAgendaHistorico, MelissaEventoPanel ajustado - useFeriados: cache opt-in pra evitar fetch redundante de feriados - PatientProntuario: aba Visão Geral nova; PatientConversationsTab polish - AgendaClinicMosaic / AgendaTerapeutaPage / useAgendaSettings: ajustes de paridade com Melissa - DocumentsListPage: pequenos ajustes - DB migration 20260504000001: fix do trigger pra status 'excluido' nas cancel_notifications Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
388 lines
15 KiB
JavaScript
388 lines
15 KiB
JavaScript
/*
|
|
* 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<Date>)
|
|
* - 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 })
|
|
};
|
|
}
|