Melissa polish + Prontuario Visao Geral + agenda historico

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>
This commit is contained in:
Leonardo
2026-05-06 09:11:55 -03:00
parent 86311ef305
commit 957e912a7f
19 changed files with 5203 additions and 285 deletions
+118 -28
View File
@@ -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 = `<div class="mc-fc-event__title"><span class="mc-fc-event__name">${escHtml(titulo)}</span>${range ? ` <span class="mc-fc-event__hour">(${escHtml(range)})</span>` : ''}</div>`;
return {
html: `
<div class="mc-fc-event">
<div class="mc-fc-event__time">${escHtml(time)}</div>
<div class="mc-fc-event__title">${escHtml(titulo)}</div>
${titleLine}
${badgesHtml}
${metaFallback ? `<div class="mc-fc-event__meta">${escHtml(metaFallback)}</div>` : ''}
</div>
@@ -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
});
</script>
@@ -1887,6 +1960,14 @@ defineExpose({
@bloqueado="onFeriadoBloqueado"
/>
<!-- Histórico de ações na agenda (audit_logs) — útil pra
rastrear movimentações recentes. Click na entrada
abre o evento (se ainda existe). -->
<MelissaAgendaHistoricoCard
ref="historicoCardRef"
@open-evento="onHistoricoOpen"
/>
</aside>
</Teleport>
</div>
@@ -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);
@@ -0,0 +1,335 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/layout/melissa/MelissaAgendaHistoricoCard.vue
| Data: 2026-05-04
|
| Card de histórico de ações na agenda mostra movimentações, criações,
| status e edições recentes do owner_id logado. Útil quando o user move
| várias sessões e quer revisar o que fez.
|
| de audit_logs via useMelissaAgendaHistorico ( agrega + classifica).
| Agrupa por dia (Hoje, Ontem, X dias atrás) pra leitura cronológica.
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useMelissaAgendaHistorico } from './composables/useMelissaAgendaHistorico';
const emit = defineEmits(['open-evento']);
// days=1 já cobre "hoje" no servidor (last 24h); o filtro abaixo refina
// pra início do dia local — entradas das últimas 24h que cruzam meia-noite
// não devem aparecer no card "de hoje".
const { entries, loading, refetch } = useMelissaAgendaHistorico({ limit: 30, days: 1 });
const router = useRouter();
const route = useRoute();
onMounted(refetch);
// Filtra estritamente pro dia atual (00:00 → agora). Audit logs vêm em UTC,
// new Date(iso) já normaliza pra timezone local — comparar com início do
// dia local (setHours 0,0,0,0) garante consistência.
const todaysEntries = computed(() => {
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const startMs = startOfDay.getTime();
return entries.value.filter((e) => new Date(e.when).getTime() >= startMs);
});
// Sem agrupamento por dia — só exibe entradas de hoje (sem header de grupo).
const items = computed(() => todaysEntries.value);
function goToHistoricoCompleto() {
const target = route.path?.startsWith('/melissa') ? '/melissa/cfg-auditoria' : '/configuracoes/auditoria';
router.push(target);
}
const KIND_META = {
create: { icon: 'pi pi-plus-circle', color: '#4ade80', label: 'Criou' },
move: { icon: 'pi pi-arrows-alt', color: '#60a5fa', label: 'Moveu' },
status: { icon: 'pi pi-tag', color: '#f59e0b', label: 'Status' },
edit: { icon: 'pi pi-pencil', color: '#a78bfa', label: 'Editou' },
delete: { icon: 'pi pi-trash', color: '#f87171', label: 'Removeu' }
};
function fmtRelative(iso) {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const min = Math.round(diff / 60000);
if (min < 1) return 'agora';
if (min < 60) return `${min} min`;
const h = Math.round(min / 60);
if (h < 24) return `${h}h`;
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function onClickEntry(entry) {
if (entry.kind === 'delete') return; // evento não existe mais
if (entry.evento_id) emit('open-evento', { id: entry.evento_id });
}
defineExpose({ refetch });
</script>
<template>
<section class="hist-card">
<header class="hist-card__head">
<div class="hist-card__title">
<i class="pi pi-history" />
<span>Histórico</span>
</div>
<button
type="button"
class="hist-card__refresh"
:disabled="loading"
v-tooltip.top="'Atualizar'"
@click="refetch"
>
<i class="pi pi-refresh" :class="{ 'pi-spin': loading }" />
</button>
</header>
<div v-if="loading && !items.length" class="hist-card__empty">
<i class="pi pi-spin pi-spinner" /> Carregando
</div>
<div v-else-if="!items.length" class="hist-card__empty">
<i class="pi pi-inbox" />
<span>Sem ações hoje.</span>
</div>
<ol v-else class="hist-card__list">
<li
v-for="e in items" :key="e.id"
class="hist-card__item"
:class="{ 'is-clickable': e.kind !== 'delete' }"
:data-kind="e.kind"
@click="onClickEntry(e)"
>
<span class="hist-card__icon" :style="{ color: KIND_META[e.kind]?.color, background: `${KIND_META[e.kind]?.color}1f` }">
<i :class="KIND_META[e.kind]?.icon" />
</span>
<div class="hist-card__body">
<div class="hist-card__row1">
<span class="hist-card__paciente" v-if="e.paciente">{{ e.paciente }}</span>
<span class="hist-card__paciente hist-card__paciente--anon" v-else></span>
<span class="hist-card__when">{{ fmtRelative(e.when) }}</span>
</div>
<div class="hist-card__label">{{ e.label }}</div>
</div>
</li>
</ol>
<!-- Atalho pra página de Auditoria completa (todas as entidades,
não agenda documentos, pacientes, financeiro, etc). -->
<button
type="button"
class="hist-card__more"
v-tooltip.top="'Ver auditoria completa'"
@click="goToHistoricoCompleto"
>
<i class="pi pi-external-link" />
<span>Ver histórico completo</span>
</button>
</section>
</template>
<style scoped>
.hist-card {
background: var(--m-bg-soft, rgba(255, 255, 255, 0.04));
border: 1px solid var(--m-border, rgba(255, 255, 255, 0.08));
border-radius: 14px;
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
padding: 12px 14px;
color: var(--m-text, white);
font-family: 'Segoe UI', system-ui, sans-serif;
/* Toma o espaço restante do aside (.ma-widgets é flex column).
min-height: 0 é necessário pra flex calcular shrink corretamente
quando feriados expande (confirmação inline) e empurra o histórico. */
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.hist-card__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.hist-card__title {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--m-text-muted, rgba(255, 255, 255, 0.7));
}
.hist-card__title i { font-size: 0.78rem; opacity: 0.8; }
.hist-card__refresh {
width: 24px;
height: 24px;
display: grid;
place-items: center;
background: transparent;
border: none;
border-radius: 6px;
color: var(--m-text-muted, rgba(255, 255, 255, 0.55));
cursor: pointer;
font-size: 0.7rem;
transition: background-color 140ms ease, color 140ms ease;
}
.hist-card__refresh:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.06);
color: var(--m-text, white);
}
.hist-card__refresh:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hist-card__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 18px 8px;
color: var(--m-text-muted, rgba(255, 255, 255, 0.5));
font-size: 0.78rem;
text-align: center;
}
.hist-card__empty .pi { font-size: 1.2rem; opacity: 0.5; }
.hist-card__list {
list-style: none;
margin: 0;
padding: 0;
/* Toma todo o restante do card. min-height:0 destrava shrink dentro
de flex column. Sem max-height fixo: a lista cresce/encolhe junto
com o card (que por sua vez balanceia com o feriados acima). */
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
}
.hist-card__list::-webkit-scrollbar { width: 4px; }
.hist-card__list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
.hist-card__item {
display: flex;
gap: 10px;
padding: 8px 4px;
border-radius: 8px;
transition: background-color 140ms ease, transform 140ms ease;
overflow: hidden;
min-width: 0;
}
.hist-card__item.is-clickable {
cursor: pointer;
}
.hist-card__item.is-clickable:hover {
background: rgba(255, 255, 255, 0.05);
transform: translateX(1px);
}
.hist-card__item[data-kind="delete"] {
opacity: 0.7;
}
.hist-card__icon {
flex-shrink: 0;
width: 26px;
height: 26px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.72rem;
}
.hist-card__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.hist-card__row1 {
display: flex;
align-items: baseline;
gap: 6px;
}
.hist-card__paciente {
font-size: 0.78rem;
font-weight: 600;
color: var(--m-text, white);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.hist-card__paciente--anon { font-style: italic; opacity: 0.55; font-weight: 400; }
.hist-card__when {
font-size: 0.68rem;
color: var(--m-text-muted, rgba(255, 255, 255, 0.5));
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.hist-card__label {
font-size: 0.7rem;
color: var(--m-text-muted, rgba(255, 255, 255, 0.7));
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Botão "Ver histórico completo" — vai pra AuditoriaPage central
(mesmo destino em /melissa/cfg-auditoria ou /configuracoes/auditoria). */
.hist-card__more {
margin-top: 10px;
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 10px;
background: transparent;
border: 1px dashed var(--m-border, rgba(255, 255, 255, 0.18));
border-radius: 10px;
color: var(--m-text-muted, rgba(255, 255, 255, 0.7));
font-family: inherit;
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.01em;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
}
.hist-card__more:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--m-text, white);
border-color: var(--m-border-strong, rgba(255, 255, 255, 0.25));
border-style: solid;
}
.hist-card__more i { font-size: 0.7rem; opacity: 0.85; }
/* Light mode adjusts contrast */
html:not(.app-dark) .hist-card {
background: rgba(255, 255, 255, 0.7);
border-color: rgba(15, 23, 42, 0.08);
}
html:not(.app-dark) .hist-card__group-label {
background: rgba(248, 250, 252, 0.95);
}
html:not(.app-dark) .hist-card__item.is-clickable:hover {
background: rgba(15, 23, 42, 0.04);
}
</style>
+196 -86
View File
@@ -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) }}
<span v-if="duracaoMin() !== null" class="evento-row__sub">· {{ duracaoMin() }}min</span>
</span>
<button
type="button"
class="evento-row__edit"
:disabled="busy"
v-tooltip.top="'Editar sessão (data, hora, recorrência)'"
@click="emit('edit-sessao')"
>
<i class="pi pi-pencil" />
<span>Editar sessão</span>
</button>
</div>
<div v-if="ev.modalidade" class="evento-row">
@@ -140,83 +148,111 @@ function modalidadeIcon(mod) {
</div>
</div>
<!-- Action bar agrupada por contexto -->
<!-- Action bar agrupada por contexto.
Cada botão tem ícone + label textual empilhados pra reduzir
ambiguidade (tooltip sozinho não é descobrível em touch). -->
<footer class="evento-actions">
<!-- Grupo Status pra sessão e quando ainda não é status final -->
<div v-if="isSessaoComPaciente && !statusEhFinal" class="evento-actions__group">
<button
class="evento-act evento-act--ok"
:disabled="busy"
v-tooltip.top="'Marcar como realizada'"
@click="emit('concluir')"
>
<i class="pi pi-check-circle" />
</button>
<button
class="evento-act evento-act--warn"
:disabled="busy"
v-tooltip.top="'Marcar como falta'"
@click="emit('faltou')"
>
<i class="pi pi-user-minus" />
</button>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Remarcar'"
@click="emit('remarcar')"
>
<i class="pi pi-calendar-clock" />
</button>
<button
class="evento-act evento-act--danger"
:disabled="busy"
v-tooltip.top="'Cancelar'"
@click="emit('cancelar')"
>
<i class="pi pi-ban" />
</button>
</div>
<!-- Grupo Status sempre visível pra sessão (permite trocar
de status mesmo após marcar realizado/faltou/cancelado).
Status atual fica destacado via .is-current. -->
<section v-if="isSessaoComPaciente" class="evento-actions__section">
<div class="evento-actions__label">Marcar sessão como:</div>
<div class="evento-actions__group">
<button
class="evento-act evento-act--ok"
:class="{ 'is-current': statusSlug === 'realizado' }"
:disabled="busy"
@click="emit('concluir')"
>
<i class="pi pi-check-circle" />
<span class="evento-act__label">Realizada</span>
</button>
<button
class="evento-act evento-act--warn"
:class="{ 'is-current': statusSlug === 'faltou' }"
:disabled="busy"
@click="emit('faltou')"
>
<i class="pi pi-user-minus" />
<span class="evento-act__label">Falta</span>
</button>
<button
class="evento-act"
:class="{ 'is-current': statusSlug === 'remarcar' || statusSlug === 'remarcado' }"
:disabled="busy"
@click="emit('remarcar')"
>
<i class="pi pi-calendar-clock" />
<span class="evento-act__label">Reagendar</span>
</button>
<button
class="evento-act evento-act--danger"
:class="{ 'is-current': statusSlug === 'cancelado' }"
:disabled="busy"
@click="emit('cancelar')"
>
<i class="pi pi-ban" />
<span class="evento-act__label">Cancelar</span>
</button>
</div>
</section>
<!-- Grupo Paciente pra sessão com paciente vinculado -->
<div v-if="isSessaoComPaciente" class="evento-actions__group">
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Abrir prontuário'"
@click="emit('abrir-prontuario')"
>
<i class="pi pi-file" />
</button>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Conversar (WhatsApp)'"
@click="emit('whatsapp')"
>
<i class="pi pi-whatsapp" />
</button>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Histórico de sessões'"
@click="emit('historico')"
>
<i class="pi pi-history" />
</button>
</div>
<!-- Grupo Outras opções pra sessão com paciente.
"Editar" abre o cadastro do paciente (não a sessão);
pra editar a sessão, usar o botão ao lado das horas. -->
<section v-if="isSessaoComPaciente" class="evento-actions__section">
<div class="evento-actions__label">Outras opções:</div>
<div class="evento-actions__group">
<button
class="evento-act"
:disabled="busy"
@click="emit('abrir-prontuario')"
>
<i class="pi pi-file" />
<span class="evento-act__label">Prontuário</span>
</button>
<button
class="evento-act"
:disabled="busy"
@click="emit('historico')"
>
<i class="pi pi-history" />
<span class="evento-act__label">Sessões</span>
</button>
<button
class="evento-act"
:disabled="busy"
@click="emit('whatsapp')"
>
<i class="pi pi-whatsapp" />
<span class="evento-act__label">Conversar</span>
</button>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Editar cadastro do paciente'"
@click="emit('edit-paciente')"
>
<i class="pi pi-user-edit" />
<span class="evento-act__label">Editar</span>
</button>
</div>
</section>
<!-- Grupo Geral Editar sempre disponível -->
<div class="evento-actions__group">
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Editar evento'"
@click="emit('edit')"
>
<i class="pi pi-pencil" />
</button>
</div>
<!-- Grupo Geral (não-sessão: bloqueio/compromisso/etc).
Aqui "Editar" abre o evento em si (não tem paciente). -->
<section v-else class="evento-actions__section">
<div class="evento-actions__group">
<button
class="evento-act"
:disabled="busy"
@click="emit('edit-sessao')"
>
<i class="pi pi-pencil" />
<span class="evento-act__label">Editar</span>
</button>
</div>
</section>
</footer>
</div>
</div>
@@ -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);
@@ -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
@@ -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<boolean>
| - 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 };
}
@@ -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
};
}
@@ -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 })
};
}