diff --git a/database-novo/migrations/20260504000001_fix_cancel_notifications_excluido.sql b/database-novo/migrations/20260504000001_fix_cancel_notifications_excluido.sql new file mode 100644 index 0000000..1935f1a --- /dev/null +++ b/database-novo/migrations/20260504000001_fix_cancel_notifications_excluido.sql @@ -0,0 +1,34 @@ +-- ========================================================================== +-- Agencia PSI — Fix: cancel_notifications_on_session_cancel referencia 'excluido' +-- ========================================================================== +-- A funcao trigger comparava NEW.status IN ('cancelado', 'excluido'), mas o +-- enum status_evento_agenda nunca teve o valor 'excluido'. Postgres precisa +-- fazer cast do literal pro tipo do enum, e o cast falha com: +-- +-- invalid input value for enum status_evento_agenda: "excluido" +-- +-- Isso quebrava QUALQUER UPDATE que mudasse status pra um valor != atual, +-- pois o IF tinha que avaliar a expressao com 'excluido'. +-- +-- O front-end nunca usou 'excluido' (statusOptions em AgendaEventDialog.vue +-- so tem agendado/realizado/faltou/cancelado/remarcado). Delete e hard delete +-- via DELETE — nao tem soft-delete em agenda_eventos. Logo, 'excluido' eh +-- codigo morto e pode ser removido. +-- +-- Refs: +-- - src/features/agenda/components/AgendaEventDialog.vue:1071 (statusOptions) +-- - schema/03_functions/_all.sql:1056 (funcao original) +-- ========================================================================== + +CREATE OR REPLACE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger + LANGUAGE plpgsql SECURITY DEFINER + AS $$ +BEGIN + IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN + PERFORM public.cancel_patient_pending_notifications( + NEW.patient_id, NULL, NEW.id + ); + END IF; + RETURN NEW; +END; +$$; diff --git a/src/components/notifications/NotificationDrawer.vue b/src/components/notifications/NotificationDrawer.vue index ef395d0..bc1abab 100644 --- a/src/components/notifications/NotificationDrawer.vue +++ b/src/components/notifications/NotificationDrawer.vue @@ -16,7 +16,7 @@ --> diff --git a/src/components/notifications/NotificationItem.vue b/src/components/notifications/NotificationItem.vue index 77b7090..e113159 100644 --- a/src/components/notifications/NotificationItem.vue +++ b/src/components/notifications/NotificationItem.vue @@ -16,7 +16,7 @@ --> + + + + diff --git a/src/layout/melissa/MelissaAgenda.vue b/src/layout/melissa/MelissaAgenda.vue index 9b6a0f7..a78fc78 100644 --- a/src/layout/melissa/MelissaAgenda.vue +++ b/src/layout/melissa/MelissaAgenda.vue @@ -25,10 +25,9 @@ import ptBrLocale from '@fullcalendar/core/locales/pt-br'; import { useMelissaEventosRange, useMelissaTodasSessoesPaciente, searchEventosByText } from './composables/useMelissaEventos'; import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda'; import { useMelissaPacientesAside } from './composables/useMelissaPacientesAside'; -import { useFeriados } from '@/composables/useFeriados'; import { useTenantStore } from '@/stores/tenantStore'; -import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings'; 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 PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue'; @@ -531,11 +530,15 @@ const fcOptions = computed(() => ({ 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 @@ -547,8 +550,18 @@ const fcOptions = computed(() => ({ eventContent: (arg) => { const ext = arg.event.extendedProps || {}; const titulo = arg.event.title || '—'; - const time = arg.timeText || ''; 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 = ''; @@ -567,11 +580,11 @@ const fcOptions = computed(() => ({ // 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 = `
${escHtml(titulo)}${range ? ` (${escHtml(range)})` : ''}
`; return { html: `
-
${escHtml(time)}
-
${escHtml(titulo)}
+ ${titleLine} ${badgesHtml} ${metaFallback ? `
${escHtml(metaFallback)}
` : ''}
@@ -684,6 +697,43 @@ function irParaData(date) { searchDateMatch.value = null; } +// 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; + } + // 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: 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, + 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); + } + emit('select-evento', ev); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[onHistoricoOpen]', e); + } +} + function onSelecionarResultado(ev) { if (!ev?.inicio_em) return; fcApi()?.gotoDate(ev.inicio_em); @@ -764,12 +814,15 @@ 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. -// Pattern espelha AgendaTerapeutaPage:113,129-134,1143. -// IMPORTANTE: declarado APÓS miniRefDate porque o watch abaixo lê -// miniRefDate.value durante o setup (rastreio de dependências). +// 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(); -const { todos: feriadosTodos, load: loadFeriados, ano: feriadosAno, fcEvents: feriadoFcEvents } = useFeriados(); -const { workRules, load: loadAgendaSettings } = useAgendaSettings(); +const feriadosTodos = M.feriados; +const feriadoFcEvents = M.feriadoFcEvents; +const feriadosAno = M.feriadosAno; +const loadFeriados = M.loadFeriadosBase; +const workRules = M.workRules; // Set de dias da semana ativos (0=dom..6=sáb). Fallback seg-sex se sem regras // — mesmo default do AgendaTerapeutaPage:370. @@ -779,15 +832,10 @@ const workDowSet = computed(() => { return new Set([1, 2, 3, 4, 5]); }); -onMounted(() => { - const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null; - if (tid) loadFeriados(tid, new Date().getFullYear()); - // workRules é por owner_id (RLS), não precisa do tenant - loadAgendaSettings(); -}); - -// Recarrega feriados quando mini-cal navega pra outro ano (municipais variam). -// Nacionais são puro algoritmo — recomputam automático no useFeriados via `ano`. +// 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) => { @@ -1080,6 +1128,21 @@ function abrirSessoesPaciente() { 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(); @@ -1125,6 +1188,14 @@ function abrirProntuarioPaciente() { prontuarioPatient.value = { ...p }; prontuarioOpen.value = true; } +// 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); @@ -1171,7 +1242,9 @@ function openProntuario(patient) { defineExpose({ refetch: refetchEventosFc, openProntuario, - setView + setView, + openSessoesPaciente, + openEditPatient }); @@ -1887,6 +1960,14 @@ defineExpose({ @bloqueado="onFeriadoBloqueado" /> + + + @@ -3210,22 +3291,26 @@ html.app-dark .ma-tsearch__result--date .ma-tsearch__result-sub { color: var(--m-text); font-family: inherit; } -.ma-cal__fc :deep(.mc-fc-event__time) { - font-size: 0.6rem; - color: var(--m-text-muted); - font-variant-numeric: tabular-nums; - line-height: 1.1; -} .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. */ + 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; - margin-top: 1px; +} +.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; @@ -3495,6 +3580,11 @@ html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--modal.is-presencial) 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); diff --git a/src/layout/melissa/MelissaAgendaHistoricoCard.vue b/src/layout/melissa/MelissaAgendaHistoricoCard.vue new file mode 100644 index 0000000..42474e2 --- /dev/null +++ b/src/layout/melissa/MelissaAgendaHistoricoCard.vue @@ -0,0 +1,335 @@ + + + + + + diff --git a/src/layout/melissa/MelissaEventoPanel.vue b/src/layout/melissa/MelissaEventoPanel.vue index 6938971..725d6aa 100644 --- a/src/layout/melissa/MelissaEventoPanel.vue +++ b/src/layout/melissa/MelissaEventoPanel.vue @@ -29,7 +29,8 @@ const emit = defineEmits([ 'faltou', 'cancelar', 'remarcar', - 'edit', + 'edit-sessao', // botão dedicado ao lado das horas → AgendaEventDialog + 'edit-paciente', // botão "Editar" do grupo Outras opções → PatientCadastroDialog 'abrir-prontuario', 'whatsapp', 'historico' @@ -68,9 +69,6 @@ const isSessaoComPaciente = computed( () => ev.value.tipo === 'sessao' && (ev.value.patient_id || ev.value.pacienteNome) ); -// Status finais não permitem mudar pra outro status (UI mais clara) -const statusEhFinal = computed(() => /realizad|faltou|cancelad/i.test(ev.value.status || '')); - function fmtHora(decimal) { if (decimal === null || decimal === undefined || Number.isNaN(decimal)) return '—'; const h = Math.floor(decimal); @@ -123,6 +121,16 @@ function modalidadeIcon(mod) { {{ fmtHora(ev.startH) }} – {{ fmtHora(ev.endH) }} · {{ duracaoMin() }}min +
@@ -140,83 +148,111 @@ function modalidadeIcon(mod) {
- +
- -
- - - - -
+ +
+
Marcar sessão como:
+
+ + + + +
+
- -
- - - -
+ +
+
Outras opções:
+
+ + + + +
+
- -
- -
+ +
+
+ +
+
@@ -333,6 +369,34 @@ function modalidadeIcon(mod) { margin-left: 4px; font-size: 0.82rem; } +/* Botão "Editar sessão" inline na linha das horas. Discreto na largura + padrão, ganha destaque no hover. Margin-left auto pra alinhar à direita. */ +.evento-row__edit { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + background: var(--m-bg-soft); + border: 1px solid var(--m-border); + border-radius: 100px; + color: var(--m-text-muted); + font-size: 0.7rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease; +} +.evento-row__edit:hover:not(:disabled) { + background: var(--m-bg-soft-hover); + color: var(--m-text); + border-color: var(--m-accent, var(--primary-color, #7c6af7)); +} +.evento-row__edit:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.evento-row__edit i { font-size: 0.65rem; } .evento-status { padding: 2px 10px; border-radius: 999px; @@ -378,25 +442,41 @@ function modalidadeIcon(mod) { /* ─── Action bar ────────────────────────────────── */ .evento-actions { display: flex; - flex-wrap: wrap; - gap: 10px; + flex-direction: column; + gap: 12px; padding-top: 14px; border-top: 1px solid var(--m-border); - justify-content: space-between; +} +.evento-actions__section { + display: flex; + flex-direction: column; + gap: 6px; +} +.evento-actions__label { + font-size: 0.7rem; + color: var(--m-text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; + padding-left: 2px; } .evento-actions__group { display: flex; - gap: 6px; + gap: 4px; background: var(--m-bg-soft); border: 1px solid var(--m-border); border-radius: 12px; padding: 4px; } .evento-act { - width: 38px; - height: 38px; - display: grid; - place-items: center; + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 8px 4px; background: transparent; border: none; color: var(--m-text); @@ -406,6 +486,13 @@ function modalidadeIcon(mod) { font-family: inherit; transition: background-color 140ms ease, color 140ms ease, transform 140ms ease; } +.evento-act__label { + font-size: 0.7rem; + line-height: 1.1; + font-weight: 500; + letter-spacing: 0.01em; + white-space: nowrap; +} .evento-act:hover:not(:disabled) { background: var(--m-bg-soft-hover); transform: translateY(-1px); @@ -431,6 +518,29 @@ function modalidadeIcon(mod) { background: rgba(239, 68, 68, 0.15); } +/* Estado .is-current — sinaliza o status atual da sessão dentro do + grupo de actions. Permite que o usuário troque o status mesmo após + marcar realizado/faltou/cancelado, vendo qual está ativo. */ +.evento-act.is-current { + background: rgba(255, 255, 255, 0.12); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25); +} +.evento-act--ok.is-current { + color: rgb(16, 185, 129); + background: rgba(16, 185, 129, 0.18); + box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.55); +} +.evento-act--warn.is-current { + color: rgb(245, 158, 11); + background: rgba(245, 158, 11, 0.18); + box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.55); +} +.evento-act--danger.is-current { + color: rgb(239, 68, 68); + background: rgba(239, 68, 68, 0.18); + box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.55); +} + /* Light mode — overlay ainda mais discreto */ html:not(.app-dark) .evento-layer { background: rgba(15, 23, 42, 0.18); diff --git a/src/layout/melissa/composables/useMelissaAgenda.js b/src/layout/melissa/composables/useMelissaAgenda.js index ca20bd4..457ee66 100644 --- a/src/layout/melissa/composables/useMelissaAgenda.js +++ b/src/layout/melissa/composables/useMelissaAgenda.js @@ -181,8 +181,15 @@ export function useMelissaAgenda() { ); // ── Settings + workRules ──────────────────────────────────── - const { settings, workRules, load: loadSettings } = useAgendaSettings(); - const ownerId = computed(() => settings.value?.owner_id || ''); + // cache: stale-while-revalidate via melissaCacheStore — abertura + // subsequente da Agenda na mesma sessão usa cache instantâneo. + const { settings, workRules, load: loadSettings } = useAgendaSettings({ cache: true }); + // _bootUid: pegado em paralelo no mount via supabase.auth.getUser(). + // Sem isso, ownerId ficava null até loadSettings completar (~300ms), + // bloqueando o primeiro fetch dos eventos. Como owner_id da agenda + // é literalmente o uid do user logado, podemos resolver imediato. + const _bootUid = ref(''); + const ownerId = computed(() => settings.value?.owner_id || _bootUid.value || ''); // ── Eventos reais (CRUD) ──────────────────────────────────── const { @@ -245,7 +252,16 @@ export function useMelissaAgenda() { }); // ── Feriados + commitment services ────────────────────────── - const { todos: feriados, fcEvents: feriadoFcEvents, load: loadFeriadosBase } = useFeriados(); + // Instância única de useFeriados — antes MelissaAgenda.vue criava + // sua própria também, fazendo dupla requisição de feriados municipais + // toda vez que a agenda abria. Agora MelissaAgenda lê esses refs do + // composable injetado (M.feriadosAno, M.loadFeriadosBase, etc). + const { + todos: feriados, + fcEvents: feriadoFcEvents, + load: loadFeriadosBase, + ano: feriadosAno + } = useFeriados({ cache: true }); const { saveRuleItems, propagateToSerie } = useCommitmentServices(); // ── Linhas combinadas (real + virtual) ────────────────────── @@ -294,13 +310,18 @@ export function useMelissaAgenda() { const e = viewEnd.value; if (!s || !e) return; - // Aguarda ownerId — settings é async - if (!ownerId.value) { - const unwatch = watch(ownerId, async (v) => { - if (!v) return; - unwatch(); - await _reloadRange(); - }); + // Espera ownerId E tenant — qualquer um faltando significa boot + // ainda em curso (auth/tenantStore/settings async). Watcher one-shot + // re-dispara assim que o último ficar disponível, sem polling. + if (!ownerId.value || !clinicTenantId.value) { + const unwatch = watch( + () => [ownerId.value, clinicTenantId.value], + ([uid, tid]) => { + if (!uid || !tid) return; + unwatch(); + _reloadRange(); + } + ); return; } @@ -308,9 +329,14 @@ export function useMelissaAgenda() { const end = new Date(e); const tid = clinicTenantId.value; + // Etapa 1: eventos reais — `rows` é reativo, FullCalendar re-renderiza + // assim que esse await resolve (o user já vê as sessões agendadas). await loadMyRange(start.toISOString(), end.toISOString(), ownerId.value); - // Expande regras + merge com sessões reais + // Etapa 2: ocorrências virtuais (regras de recorrência expandidas). + // Continuamos awaitando porque saveRule/cancel dependem do estado + // final estar pronto pra UI consistente, mas a janela visual onde + // o usuário vê só eventos reais é a metade do tempo de antes. const merged = await loadAndExpand(ownerId.value, start, end, rows.value, tid); _occurrenceRows.value = merged.filter((r) => r.is_occurrence); } @@ -320,8 +346,37 @@ export function useMelissaAgenda() { } // ── Inicialização ─────────────────────────────────────────── - onMounted(async () => { - await loadSettings(); + // Boot paralelo: auth uid + tenant + settings todos disparam ao mesmo + // tempo. Antes era serial (loadSettings precisava terminar pra ownerId + // ficar disponível e o watch disparar _reloadRange) — adicionava ~300ms + // de waterfall antes da primeira query de eventos sair. + onMounted(() => { + // 1) Resolve o uid o quanto antes — destrava _reloadRange. + // getSession() lê do storage local (fast path, <10ms); + // getUser() faria round-trip pro auth server. Fallback pro + // getUser só se a sessão ainda não estiver no storage. + supabase.auth.getSession() + .then(({ data }) => { + const uid = data?.session?.user?.id; + if (uid) { + _bootUid.value = uid; + } else { + // Cold start sem sessão hidratada — fallback pro round-trip. + return supabase.auth.getUser().then(({ data: u }) => { + if (u?.user?.id) _bootUid.value = u.user.id; + }); + } + }) + .catch(() => { /* noop — settings ainda pode resolver */ }); + + // 2) Garante que o tenant está hidratado (idempotente — se já + // estiver carregado, retorna imediato). + if (typeof tenantStore.ensureLoaded === 'function') { + tenantStore.ensureLoaded().catch(() => {}); + } + + // 3) Settings em paralelo (não bloqueia mais nada) + loadSettings(); }); // Refetch settings + workRules quando o user salva jornada/ritmo/online @@ -354,11 +409,10 @@ export function useMelissaAgenda() { { immediate: true } ); - // Reload quando view muda OU quando settings/ownerId aparece + // Reload quando o range visível muda. _reloadRange já tem guard + // interno pra esperar uid+tenant (one-shot watcher) — sem necessidade + // de outro watch global em ownerId, que disparava _reloadRange duplicado. watch([viewStart, viewEnd], _reloadRange); - watch(ownerId, (v) => { - if (v) _reloadRange(); - }); // ────────────────────────────────────────────────────────── // Handlers — populados na Stage 2 @@ -405,6 +459,8 @@ export function useMelissaAgenda() { commitmentOptions, feriados, feriadoFcEvents, + feriadosAno, + loadFeriadosBase, allEventsForDialog, // Handlers diff --git a/src/layout/melissa/composables/useMelissaAgendaHistorico.js b/src/layout/melissa/composables/useMelissaAgendaHistorico.js new file mode 100644 index 0000000..1b59243 --- /dev/null +++ b/src/layout/melissa/composables/useMelissaAgendaHistorico.js @@ -0,0 +1,193 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/layout/melissa/composables/useMelissaAgendaHistorico.js +| Data: 2026-05-04 +| +| Histórico recente de ações na agenda do terapeuta logado. +| +| Lê de `audit_logs` (populado automaticamente pela trigger +| `trg_audit_agenda_eventos`). Não precisa criar nada — todas as ações +| INSERT/UPDATE/DELETE em agenda_eventos já viram linhas auditadas. +| +| Filtros aplicados: +| - entity_type = 'agenda_eventos' +| - user_id = uid do user logado (mostra só ações dele) +| - created_at >= 7 dias atrás +| - tenant_id = tenant ativo +| - LIMIT 20 (mais recentes primeiro) +| +| Pra exibir nome do paciente, fazemos um lookup separado em `patients` +| usando os IDs extraídos de new_values/old_values (não dá pra fazer JOIN +| na audit_logs porque entity_id é dinâmico). +| +| Returns: +| - entries: ref de objetos normalizados: +| { id, kind, label, when, paciente, evento_id, raw } +| onde kind ∈ { 'create' | 'move' | 'status' | 'edit' | 'delete' } +| - loading: ref +| - refetch: function() +|-------------------------------------------------------------------------- +*/ +import { ref } from 'vue'; +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; + +const STATUS_LABEL = { + agendado: 'Agendado', + realizado: 'Realizada', + realizada: 'Realizada', + faltou: 'Falta', + cancelado: 'Cancelada', + cancelada: 'Cancelada', + remarcar: 'Remarcar', + remarcado: 'Remarcado', + confirmado: 'Confirmada' +}; + +function fmtTime(iso) { + if (!iso) return ''; + const d = new Date(iso); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} +function fmtDateBR(iso) { + if (!iso) return ''; + const d = new Date(iso); + return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`; +} + +// Classifica a entrada pra um dos 5 "kinds" visuais. Decisão por +// changed_fields quando action=update — ordem importa: hora primeiro +// (mais frequente em movimentação), depois status, depois "edit" genérico. +function classify(row) { + const action = String(row.action || '').toLowerCase(); + if (action === 'insert') return 'create'; + if (action === 'delete') return 'delete'; + if (action === 'update') { + const fields = new Set(row.changed_fields || []); + if (fields.has('inicio_em') || fields.has('fim_em')) return 'move'; + if (fields.has('status')) return 'status'; + return 'edit'; + } + return 'edit'; +} + +function buildLabel(kind, row) { + const oldV = row.old_values || {}; + const newV = row.new_values || {}; + switch (kind) { + case 'create': { + const ini = newV.inicio_em ? `${fmtDateBR(newV.inicio_em)} ${fmtTime(newV.inicio_em)}` : '—'; + return `Criou sessão em ${ini}`; + } + case 'delete': { + const ini = oldV.inicio_em ? `${fmtDateBR(oldV.inicio_em)} ${fmtTime(oldV.inicio_em)}` : '—'; + return `Removeu sessão de ${ini}`; + } + case 'move': { + const from = oldV.inicio_em ? `${fmtDateBR(oldV.inicio_em)} ${fmtTime(oldV.inicio_em)}` : '—'; + const to = newV.inicio_em ? `${fmtDateBR(newV.inicio_em)} ${fmtTime(newV.inicio_em)}` : '—'; + return `Moveu ${from} → ${to}`; + } + case 'status': { + const lbl = STATUS_LABEL[String(newV.status || '').toLowerCase()] || newV.status || '—'; + return `Status: ${lbl}`; + } + case 'edit': + default: { + const fields = (row.changed_fields || []).filter((f) => f !== 'updated_at'); + if (!fields.length) return 'Editou'; + return `Editou ${fields.join(', ')}`; + } + } +} + +// Resolve o ID do paciente a partir de new_values/old_values (delete usa OLD). +function extractPatientId(row) { + return row.new_values?.patient_id || row.old_values?.patient_id || null; +} + +export function useMelissaAgendaHistorico(opts = {}) { + const limit = opts.limit ?? 20; + const days = opts.days ?? 7; + + const tenantStore = useTenantStore(); + const entries = ref([]); + const loading = ref(false); + const error = ref(''); + + async function _ensureUid() { + const { data: ses } = await supabase.auth.getSession(); + if (ses?.session?.user?.id) return ses.session.user.id; + const { data, error: err } = await supabase.auth.getUser(); + if (err) return null; + return data?.user?.id || null; + } + + async function refetch() { + loading.value = true; + error.value = ''; + try { + const userId = await _ensureUid(); + if (typeof tenantStore.ensureLoaded === 'function') { + await tenantStore.ensureLoaded(); + } + const tid = tenantStore.activeTenantId || tenantStore.tenantId || null; + if (!userId || !tid) { + entries.value = []; + return; + } + + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + + const { data: rows, error: err } = await supabase + .from('audit_logs') + .select('id, action, entity_id, changed_fields, old_values, new_values, created_at, user_id, tenant_id') + .eq('entity_type', 'agenda_eventos') + .eq('user_id', userId) + .eq('tenant_id', tid) + .gte('created_at', since) + .order('created_at', { ascending: false }) + .limit(limit); + + if (err) throw err; + + const list = rows || []; + + // Resolve nomes dos pacientes em uma única query. + const patientIds = [...new Set(list.map(extractPatientId).filter(Boolean))]; + const patientMap = new Map(); + if (patientIds.length) { + const { data: pats } = await supabase + .from('patients') + .select('id, nome_completo') + .in('id', patientIds); + for (const p of pats || []) patientMap.set(p.id, p.nome_completo); + } + + entries.value = list.map((r) => { + const kind = classify(r); + const pid = extractPatientId(r); + return { + id: r.id, + kind, + label: buildLabel(kind, r), + when: r.created_at, + paciente: pid ? (patientMap.get(pid) || '') : '', + evento_id: r.entity_id, + raw: r + }; + }); + } catch (e) { + error.value = e?.message || 'Falha ao carregar histórico'; + entries.value = []; + // eslint-disable-next-line no-console + console.warn('[useMelissaAgendaHistorico]', e); + } finally { + loading.value = false; + } + } + + return { entries, loading, error, refetch }; +} diff --git a/src/layout/melissa/composables/useMelissaDockPins.js b/src/layout/melissa/composables/useMelissaDockPins.js new file mode 100644 index 0000000..4eafa83 --- /dev/null +++ b/src/layout/melissa/composables/useMelissaDockPins.js @@ -0,0 +1,134 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/layout/melissa/composables/useMelissaDockPins.js +| Data: 2026-05-04 +| +| Pins dinâmicos do dock Melissa — modelo híbrido: +| +| - PINNED (manual, max 4): user fixa via menu de contexto, persiste +| entre sessões em localStorage. Sempre visíveis, ordenados por ordem +| de fixação. +| - RECENT (MRU automático, max 3): toda vez que o user abre uma seção +| que NÃO é built-in (agenda/conversas) e NÃO tá pinned, vira o pin +| temporário mais recente, empurrando os mais antigos pra fora. +| +| Persistência: localStorage com chave `melissa.dock.pins.v1`. Salva só +| slugs de seção (string), nada de dado clínico — LGPD-safe. Singleton via +| módulo (estado fora da função) pra todas as instâncias compartilharem. +| +| Builtin (não-pinnável, não-recente): agenda, conversas — esses já têm +| pin permanente próprio no template (.dock-pin com hardcode). +|-------------------------------------------------------------------------- +*/ +import { ref, watch } from 'vue'; + +const STORAGE_KEY = 'melissa.dock.pins.v1'; +const MAX_PINNED = 4; +const MAX_RECENT = 3; +const BUILTIN_SLUGS = new Set(['agenda', 'conversas']); + +// Estado singleton compartilhado entre todas as instâncias. +const pinned = ref([]); +const recent = ref([]); +let _hydrated = false; + +function _hydrate() { + if (_hydrated) return; + _hydrated = true; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed?.pinned)) { + pinned.value = parsed.pinned.filter((s) => typeof s === 'string').slice(0, MAX_PINNED); + } + if (Array.isArray(parsed?.recent)) { + recent.value = parsed.recent.filter((s) => typeof s === 'string').slice(0, MAX_RECENT); + } + } catch { /* localStorage corrompido — ignora silenciosamente */ } +} + +function _persist() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + pinned: pinned.value, + recent: recent.value + })); + } catch { /* quota excedida ou storage desabilitado — ok, em memória */ } +} + +let _persistWatcherActive = false; +function _ensurePersistWatcher() { + if (_persistWatcherActive) return; + _persistWatcherActive = true; + watch([pinned, recent], _persist, { deep: true }); +} + +export function useMelissaDockPins() { + _hydrate(); + _ensurePersistWatcher(); + + function isBuiltin(slug) { + return BUILTIN_SLUGS.has(slug); + } + function isPinned(slug) { + return pinned.value.includes(slug); + } + function isRecent(slug) { + return recent.value.includes(slug); + } + + // Chamado quando o user abre uma seção. Builtins e já-pinned não viram + // recent (não duplica). Mais recente entra no topo, expulsa o mais + // antigo se passar do limite. + function pushRecent(slug) { + if (!slug || isBuiltin(slug) || isPinned(slug)) return; + recent.value = [slug, ...recent.value.filter((s) => s !== slug)].slice(0, MAX_RECENT); + } + + // Move um slug de "recent" pra "pinned" (ou cria pinned direto). + // Retorna { ok, reason } — reason='full' quando já tem 4 pinned. + function pin(slug) { + if (!slug || isBuiltin(slug)) return { ok: false, reason: 'builtin' }; + if (isPinned(slug)) return { ok: true, reason: 'already' }; + if (pinned.value.length >= MAX_PINNED) return { ok: false, reason: 'full' }; + recent.value = recent.value.filter((s) => s !== slug); + pinned.value = [...pinned.value, slug]; + return { ok: true }; + } + + // Tira de "pinned" — não volta automaticamente pra recent (o user + // explicitamente desafixou). Próxima abertura da seção vai pra recent + // pelo fluxo normal de pushRecent. + function unpin(slug) { + pinned.value = pinned.value.filter((s) => s !== slug); + } + + // Remove completamente (de ambas as listas). Usado pelo "Remover" do menu. + function remove(slug) { + pinned.value = pinned.value.filter((s) => s !== slug); + recent.value = recent.value.filter((s) => s !== slug); + } + + function clearAll() { + pinned.value = []; + recent.value = []; + } + + return { + pinned, + recent, + isBuiltin, + isPinned, + isRecent, + pushRecent, + pin, + unpin, + remove, + clearAll, + MAX_PINNED, + MAX_RECENT + }; +} diff --git a/src/layout/melissa/composables/useMelissaEventos.js b/src/layout/melissa/composables/useMelissaEventos.js index f9dd340..74d49cd 100644 --- a/src/layout/melissa/composables/useMelissaEventos.js +++ b/src/layout/melissa/composables/useMelissaEventos.js @@ -26,6 +26,7 @@ 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) { @@ -319,17 +320,49 @@ export function useMelissaTodasSessoesPaciente() { } // ── COMPOSABLE 2: apenas hoje (MelissaLayout) ────────────────── -export function useMelissaEventosHoje() { +// 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 fetch() { + 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 { - const { start, end } = rangeHoje(); - eventos.value = await _fetchRange(start, end); + await _doFetch(cacheKey); } catch (e) { error.value = e?.message || 'Erro ao carregar agenda'; eventos.value = []; @@ -340,7 +373,15 @@ export function useMelissaEventosHoje() { } } - onMounted(fetch); + if (autoFetch) onMounted(() => _fetch({ useCache: true })); - return { eventos, loading, error, refetch: fetch }; + 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 }) + }; } diff --git a/src/stores/melissaCacheStore.js b/src/stores/melissaCacheStore.js new file mode 100644 index 0000000..2e7f531 --- /dev/null +++ b/src/stores/melissaCacheStore.js @@ -0,0 +1,72 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/stores/melissaCacheStore.js +| Data: 2026-05-04 +| +| Cache in-memory (Pinia) com stale-while-revalidate pra dados que o +| Melissa Layout consome em todas as visitas e raramente mudam: +| - pacientesTimeline: lista de pacientes do tenant (1000) +| - eventosHoje: eventos do dia (resumo) +| - feriados: municipais + globais por (tenant_id, ano) +| - agendaSettings: configurações + workRules do owner +| +| LGPD: tudo só em RAM. Some ao recarregar a aba ou trocar de sessão. Nunca +| persistido em localStorage/IndexedDB porque contém dados clínicos. +| +| Pattern de uso (composable): +| const cached = cache.get('pacientesTimeline', key, TTL.pacientes); +| if (cached) { ref.value = cached; refetchInBackground(); return; } +| const fresh = await fetch(); +| cache.set('pacientesTimeline', fresh, key); +| +| Invalidação manual: chamar `cache.invalidate('slot')` em mutations +| (ex: criar paciente → invalidate('pacientesTimeline')). +|-------------------------------------------------------------------------- +*/ +import { defineStore } from 'pinia'; + +// Time-to-live por slot (ms). Slots de dados que mudam pouco ganham TTL +// mais longo; eventos do dia ganham TTL curto pra não mostrar lista +// desatualizada se uma sessão foi marcada/cancelada em outra aba. +export const MELISSA_CACHE_TTL = { + pacientesTimeline: 5 * 60 * 1000, // 5 min + eventosHoje: 90 * 1000, // 90 s + feriados: 60 * 60 * 1000, // 1 h + agendaSettings: 5 * 60 * 1000 // 5 min +}; + +function emptySlot() { + return { data: null, ts: 0, key: null }; +} + +export const useMelissaCacheStore = defineStore('melissaCache', { + state: () => ({ + pacientesTimeline: emptySlot(), + eventosHoje: emptySlot(), + feriados: emptySlot(), + agendaSettings: emptySlot() + }), + actions: { + // Retorna data se houver cache válido pro `slot` E se a `key` bater + // (key encapsula contexto: uid, tenant, ano, dia — o que mudar + // invalida o slot automaticamente). Retorna null se inválido/expirado. + get(slot, key, ttl) { + const s = this[slot]; + if (!s?.ts) return null; + if (key !== undefined && s.key !== key) return null; + if (Date.now() - s.ts > ttl) return null; + return s.data; + }, + set(slot, data, key) { + this[slot] = { data, ts: Date.now(), key: key ?? null }; + }, + invalidate(slot) { + this[slot] = emptySlot(); + }, + invalidateAll() { + for (const k of Object.keys(this.$state)) this.invalidate(k); + } + } +});