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 @@
+
+
+
+
+
+
+
+
+ Histórico
+
+
+
+
+
+ Carregando…
+
+
+
+ Sem ações hoje.
+
+
+
+ -
+
+
+
+
+
+ {{ e.paciente }}
+ —
+ {{ fmtRelative(e.when) }}
+
+
{{ e.label }}
+
+
+
+
+
+
+
+
+
+
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) {
-
+
@@ -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);
+ }
+ }
+});