Melissa: blueprint tabular + Cadastros/Agendamentos/Pacientes + restore

Sprint E (05-05). Blueprint tabular oficial pras paginas Melissa de
listagem (DataTable + sidebar com stats e filtros coloridos, view
toggle list/grade, subheader explicativo, mobile pencil+popover).

Novo arquivo:
- blueprints/melissa-table-page-blueprint.md (~530L, 18 secoes) —
  referencia canonica MelissaCadastrosRecebidos

Paginas refatoradas/criadas:
- MelissaCadastrosRecebidos: refator pra blueprint (DataTable + frozen
  action + view toggle + subheader)
- MelissaAgendamentosRecebidos (NOVO): substitui o embed via
  MelissaEmbed; 4 status coloridos (Pendente/Autorizado/Convertido/
  Recusado), 3 acoes condicionais (Recusar/Autorizar/Converter em
  sessao), wired com AgendaEventDialog
- MelissaPacientes: refator parcial (subheader, sombras, status pills
  coloridas, email/phone colunas proprias, mobile pencil+popover, fix
  scroll mobile com min-height:0 na .mp-list, view toggle persistido,
  tags/grupos color fix g.cor->g.color, restore de arquivados)
- MelissaEmbed: agendamentos-recebidos removido do EMBED_MAP
- MelissaLayout: wire-up MelissaAgendamentosRecebidos nativo
- composables/useMelissaPacientes + useMelissaPacientesAside ajustes

Restore de pacientes arquivados:
- patientsRepository: novo restorePatient(id, { tenantId })
- PatientsCadastroPage statusOpts: +Arquivado (fecha gap de
  inconsistencia ao editar paciente arquivado)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-06 09:13:53 -03:00
parent 6d9b36d592
commit 269b531158
10 changed files with 4839 additions and 316 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -6
View File
@@ -53,12 +53,9 @@ const EMBED_MAP = {
icon: 'pi pi-file-edit',
comp: defineAsyncComponent(() => import('@/features/documents/DocumentTemplatesPage.vue'))
},
'agendamentos-recebidos': {
label: 'Agendamentos recebidos',
desc: 'Solicitações vindas do agendador online à espera de confirmação.',
icon: 'pi pi-inbox',
comp: defineAsyncComponent(() => import('@/features/agenda/pages/AgendamentosRecebidosPage.vue'))
},
// 'agendamentos-recebidos' migrou pra Melissa Page nativa
// (MelissaAgendamentosRecebidos.vue) — segue o blueprint
// melissa-table-page-blueprint.md. Removido do embed map.
'online-scheduling': {
label: 'Agendador online',
desc: 'Configure o link público pra pacientes solicitarem horários.',
+262 -12
View File
@@ -35,6 +35,7 @@ import MelissaGrupos from './MelissaGrupos.vue';
import MelissaConfiguracoes from './MelissaConfiguracoes.vue';
import MelissaEmbed from './MelissaEmbed.vue';
import MelissaCadastrosRecebidos from './MelissaCadastrosRecebidos.vue';
import MelissaAgendamentosRecebidos from './MelissaAgendamentosRecebidos.vue';
import MelissaMedicos from './MelissaMedicos.vue';
import MelissaEventoPanel from './MelissaEventoPanel.vue';
import { TOQUES, playToque } from './melissaToques';
@@ -42,6 +43,7 @@ import { useMelissaPacientes } from './composables/useMelissaPacientes';
import { useMelissaEventosHoje } from './composables/useMelissaEventos';
import { useMelissaWhatsapp } from './composables/useMelissaWhatsapp';
import { useMelissaAgenda, MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
import { useMelissaDockPins } from './composables/useMelissaDockPins';
import { supabase } from '@/lib/supabase/client';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
@@ -56,10 +58,53 @@ import { useNotificationStore } from '@/stores/notificationStore';
import { useAjuda } from '@/composables/useAjuda';
import { useTopbarPlanMenu } from '@/composables/useTopbarPlanMenu';
// Pacientes ativos do tenant (real, via Supabase)
const { pacientes: pacientesReais, loading: pacientesLoading, refetch: refetchPacientes } = useMelissaPacientes();
// Eventos reais de hoje — alimenta timeline + cards + busca + "Hoje há"
const { eventos: eventosHojeReais, refetch: refetchEventosHoje } = useMelissaEventosHoje();
// Pacientes + eventos do dia.
//
// PERF: quando o usuário entra direto numa seção (`/melissa/agenda`,
// `/melissa/pacientes`...), o resumo fica tapado pelo conteúdo da seção.
// Carregar os dois loaders na hora competiria com as queries da seção
// (que é o que o user efetivamente vai ver). Adiamos os loaders do
// resumo pra rodarem em background via `requestIdleCallback` quando a
// rota inicial já tem seção. Quando o user fecha a seção (volta pro
// resumo), o cache provavelmente já tá quente — se não estiver, o
// loading aparece naturalmente.
//
// CACHE: composables usam stale-while-revalidate via melissaCacheStore.
// Reabertura do Melissa na mesma sessão SPA é instantânea.
// Snapshot da rota no setup pra detectar deep-link com seção já no boot.
// (`route` reativo é declarado mais abaixo, mas só precisamos do params
// inicial aqui — `setup` roda 1× por mount, params do router já estão
// resolvidos nesse ponto.)
const _hasInitialSecao = !!useRoute().params?.secao;
const {
pacientes: pacientesReais,
loading: pacientesLoading,
refetch: refetchPacientes,
fetchCached: fetchPacientesCached
} = useMelissaPacientes({ autoFetch: !_hasInitialSecao });
const {
eventos: eventosHojeReais,
refetch: refetchEventosHoje,
fetchCached: fetchEventosHojeCached
} = useMelissaEventosHoje({ autoFetch: !_hasInitialSecao });
// Defer manual quando a rota inicial é uma seção: agenda os fetches do
// resumo pra rodarem após a seção montar (idle callback) — fetchCached
// usa stale-while-revalidate, então não tomba o cache. setTimeout 200
// como fallback pra navegadores sem requestIdleCallback (Safari < 16).
if (_hasInitialSecao) {
const _idleFetch = () => {
fetchPacientesCached();
fetchEventosHojeCached();
};
if (typeof window !== 'undefined') {
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(_idleFetch, { timeout: 1500 });
} else {
setTimeout(_idleFetch, 200);
}
}
}
// ───────────────────────────────────────────────────────────────
// Catálogo de cards do resumo (extensível — novos cards entram aqui)
@@ -129,14 +174,14 @@ const SECOES = {
};
// Set de keys que renderizam via MelissaEmbed (Onda 1 — pages 1-coluna)
const MELISSA_EMBED_KEYS = ['financeiro', 'financeiro-lancamentos', 'documentos', 'documentos-templates', 'agendamentos-recebidos', 'online-scheduling', 'relatorios', 'notificacoes', 'link-externo'];
const MELISSA_EMBED_KEYS = ['financeiro', 'financeiro-lancamentos', 'documentos', 'documentos-templates', 'online-scheduling', 'relatorios', 'notificacoes', 'link-externo'];
// Slugs reservados pra páginas dedicadas (não-config) — agenda, pacientes,
// conversas etc. + as MELISSA_EMBED_KEYS. Usado no isConfigRoute pra
// evitar colisão (ex: /melissa/agenda → MelissaAgenda, não config).
const MELISSA_NON_CONFIG_SLUGS = new Set([
'agenda', 'pacientes', 'compromissos', 'recorrencias', 'conversas',
'tags', 'grupos', 'cadastros-recebidos', 'medicos',
'tags', 'grupos', 'cadastros-recebidos', 'medicos', 'agendamentos-recebidos',
...MELISSA_EMBED_KEYS
]);
// Aliases "bonitos" + INLINE_KEYS reconhecidos pelo MelissaConfiguracoes.
@@ -171,6 +216,17 @@ const secaoAberta = computed(() => {
return null;
});
// Quando o usuário fecha a seção e volta pro resumo, garante que os
// dados estão prontos (caso o idle callback ainda não tenha disparado
// no fluxo deep-link). fetchCached é idempotente: cache hit → instant,
// cache miss → fetch real. Sem cache, não dispara nada estranho.
watch(secaoAberta, (newVal, oldVal) => {
if (oldVal && !newVal) {
fetchPacientesCached();
fetchEventosHojeCached();
}
});
function abrirSecao(key) {
// Fecha overlays paralelos pra evitar empilhamento
workspaceOpen.value = false;
@@ -183,6 +239,74 @@ function fecharSecao() {
router.push({ name: 'Melissa', params: {} });
}
// ── Pins dinâmicos do dock (híbrido: 4 fixos + 3 MRU) ──────────
const dockPins = useMelissaDockPins();
const pinContextMenu = ref(null);
const pinContextSlug = ref('');
// Toda vez que a seção muda, registra como "recente" no dock (se não
// for builtin nem pinned). Slugs de configuração (cfg-*) também viram
// recent — útil quando o user fica navegando entre páginas de config.
watch(secaoAberta, (slug) => {
if (slug) dockPins.pushRecent(slug);
});
function openPinContextMenu(event, slug) {
event.preventDefault();
event.stopPropagation();
pinContextSlug.value = slug;
pinContextMenu.value?.show(event);
}
const pinContextMenuItems = computed(() => {
const slug = pinContextSlug.value;
if (!slug) return [];
const isPinned = dockPins.isPinned(slug);
const items = [];
if (isPinned) {
items.push({
label: 'Desafixar',
icon: 'pi pi-bookmark',
command: () => dockPins.unpin(slug)
});
} else {
items.push({
label: 'Fixar no dock',
icon: 'pi pi-bookmark-fill',
command: () => {
const result = dockPins.pin(slug);
if (!result.ok && result.reason === 'full') {
toast.add({
severity: 'warn',
summary: 'Limite atingido',
detail: `Você pode fixar até ${dockPins.MAX_PINNED} atalhos. Desafixe um pra liberar espaço.`,
life: 3500
});
}
}
});
}
items.push({ separator: true });
items.push({
label: 'Remover do dock',
icon: 'pi pi-times',
command: () => dockPins.remove(slug)
});
return items;
});
// Resolve label/ícone a partir do slug pra renderizar no pin.
// Usa o catálogo SECOES (já existente) — fallback genérico se slug
// for de uma rota cfg-* (configurações embeds).
function pinMeta(slug) {
const fromSecoes = SECOES[slug];
if (fromSecoes) return { label: fromSecoes.label, icon: fromSecoes.icon };
if (slug?.startsWith('cfg-')) {
return { label: 'Configuração', icon: 'pi pi-cog' };
}
return { label: slug, icon: 'pi pi-bookmark' };
}
// Prefs de layout/UI (toque, fundo, opacidade, formato hora)
// TODO: migrar pra configs do tenant — hoje só localStorage pra survive refresh
const LAYOUT_STORAGE_KEY = 'melissa.layout.v1';
@@ -619,14 +743,23 @@ function onConcluir() { updateEventoStatus('realizado', 'Sessão marcada como r
function onFaltou() { updateEventoStatus('faltou', 'Marcada como falta'); }
function onCancelar() { updateEventoStatus('cancelado', 'Evento cancelado'); }
function onWhatsapp() {
async function onWhatsapp() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
toast.add({ severity: 'warn', summary: 'Paciente sem id', life: 2200 });
return;
}
conversationDrawerStore.openForPatient(String(ev.patient_id));
const patientId = String(ev.patient_id);
fecharEvento();
// openForPatient é async — busca thread existente ou cria stub.
// Se paciente não tem telefone (ou outro erro), o store seta `error`
// e mantém `isOpen=false` silenciosamente. Aguardamos pra dar feedback.
await conversationDrawerStore.openForPatient(patientId);
if (!conversationDrawerStore.isOpen) {
const detail = conversationDrawerStore.error?.message || 'Não foi possível abrir a conversa.';
toast.add({ severity: 'warn', summary: 'WhatsApp', detail, life: 3500 });
conversationDrawerStore.error = null;
}
}
// ── Pending agenda actions ────────────────────────────────────
@@ -666,8 +799,31 @@ function onAbrirProntuario() {
}
function onHistoricoSessoes() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
toast.add({ severity: 'warn', summary: 'Paciente não vinculado', life: 2200 });
return;
}
const patientId = ev.patient_id;
fecharEvento();
_callOnAgenda((agenda) => agenda.setView?.('lista'));
// Abre a Agenda (se não estiver) e dispara o overlay "Todas as sessões"
// filtrado pelo paciente — mesmo fluxo do botão Sessões em .ma-dock-actions.
_callOnAgenda((agenda) => agenda.openSessoesPaciente?.(patientId));
}
// Editar cadastro do paciente vinculado à sessão. Difere de onEditEvento
// (que abre o AgendaEventDialog pra mexer na sessão em si). Reusa o
// PatientCadastroDialog já montado dentro do MelissaAgenda via método
// exposto openEditPatient.
function onEditPaciente() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
toast.add({ severity: 'warn', summary: 'Paciente não vinculado', life: 2200 });
return;
}
const patientId = ev.patient_id;
fecharEvento();
_callOnAgenda((agenda) => agenda.openEditPatient?.(patientId));
}
async function onEditEvento() {
@@ -1178,10 +1334,20 @@ function onKeydown(e) {
}
if (e.key !== 'Escape') return;
// Bail-out: se há um overlay PrimeVue (Dialog/Drawer) aberto, deixa
// o componente cuidar do ESC pelo seu próprio closeOnEscape. Sem
// este guard, o ESC fechava o overlay E uma camada do cascade —
// o usuário via "duas janelas fechando" (drawer WhatsApp + agenda,
// AgendaEventDialog + evento panel, ConfirmDialog + workspace, etc).
if (document.querySelector('.p-dialog-mask, .p-drawer-mask')) return;
// Cascata top-down do z-order — ESC fecha SOMENTE a camada do topo.
// Ordem (mais sobreposto → menos): central modal > evento panel >
// cronômetro > seção (agenda/pacientes/etc) > workspace > settings.
if (centralOpen.value) centralOpen.value = false;
else if (secaoAberta.value) fecharSecao();
else if (eventoSelecionado.value) fecharEvento();
else if (cronoVisible.value) fecharCronometro();
else if (secaoAberta.value) fecharSecao();
else if (workspaceOpen.value) closeWorkspace();
else if (settingsOpen.value) settingsOpen.value = false;
}
@@ -1774,7 +1940,7 @@ function onKeydown(e) {
type="button"
class="dock-pin"
v-tooltip.top="'WhatsApp'"
:class="{ 'dock-pin--active': secaoAtual === 'conversas' }"
:class="{ 'dock-pin--active': secaoAberta === 'conversas' }"
@click="abrirSecao('conversas')"
>
<i class="pi pi-whatsapp" />
@@ -1784,6 +1950,46 @@ function onKeydown(e) {
:title="`${whatsappPendente.count} mensagens não lidas`"
>{{ whatsappPendente.count > 99 ? '99+' : whatsappPendente.count }}</span>
</button>
<!-- Divisor entre builtins e pins dinâmicos. aparece se
o user tem pelo menos 1 pin (fixo ou recente). -->
<div
v-if="dockPins.pinned.value.length || dockPins.recent.value.length"
class="dock-divider"
aria-hidden="true"
/>
<!-- Pins fixados pelo user (max 4). Click direito menu
desafixar/remover. Hover mostra subtle ring. -->
<button
v-for="slug in dockPins.pinned.value" :key="`p-${slug}`"
type="button"
class="dock-pin dock-pin--user"
:class="{ 'dock-pin--active': secaoAberta === slug }"
v-tooltip.top="pinMeta(slug).label + ' (fixado)'"
@click="abrirSecao(slug)"
@contextmenu="openPinContextMenu($event, slug)"
>
<i :class="pinMeta(slug).icon" />
<span class="dock-pin__pinned-mark" aria-hidden="true" />
</button>
<!-- Pins MRU (max 3) empurrados pelas últimas seções abertas.
Visual mais leve (opacity menor) pra destacar dos fixos. -->
<button
v-for="slug in dockPins.recent.value" :key="`r-${slug}`"
type="button"
class="dock-pin dock-pin--recent"
:class="{ 'dock-pin--active': secaoAberta === slug }"
v-tooltip.top="pinMeta(slug).label + ' (recente clique direito pra fixar)'"
@click="abrirSecao(slug)"
@contextmenu="openPinContextMenu($event, slug)"
>
<i :class="pinMeta(slug).icon" />
</button>
<!-- Menu de contexto dos pins dinâmicos (popup global) -->
<Menu ref="pinContextMenu" :model="pinContextMenuItems" :popup="true" />
</div>
@@ -1812,7 +2018,8 @@ function onKeydown(e) {
@faltou="onFaltou"
@cancelar="onCancelar"
@remarcar="onRemarcar"
@edit="onEditEvento"
@edit-sessao="onEditEvento"
@edit-paciente="onEditPaciente"
@abrir-prontuario="onAbrirProntuario"
@whatsapp="onWhatsapp"
@historico="onHistoricoSessoes"
@@ -1910,6 +2117,8 @@ function onKeydown(e) {
@close="fecharSecao"
@patient-created="refetchPacientes"
@goto-agenda="abrirSecao('agenda')"
@goto-grupos="abrirSecao('grupos')"
@goto-tags="abrirSecao('tags')"
/>
<MelissaCompromissos
@@ -1942,6 +2151,11 @@ function onKeydown(e) {
@close="fecharSecao"
/>
<MelissaAgendamentosRecebidos
v-if="layoutReady && secaoAberta === 'agendamentos-recebidos'"
@close="fecharSecao"
/>
<MelissaMedicos
v-if="layoutReady && secaoAberta === 'medicos'"
@close="fecharSecao"
@@ -3177,6 +3391,42 @@ html:not(.app-dark) .melissa-dock .dock-pin:hover {
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.16);
}
/* ─── Pins dinâmicos do dock (híbrido fixo + recente) ───────── */
/* Divisor entre builtins e dinâmicos. Fininho, atravessa o gap. */
.dock-divider {
width: 1px;
height: 24px;
align-self: center;
background: var(--m-border, rgba(255, 255, 255, 0.18));
opacity: 0.7;
}
html:not(.app-dark) .dock-divider {
background: rgba(15, 23, 42, 0.18);
}
/* Pin fixado pelo user — pequena marca no canto pra diferenciar do recente. */
.dock-pin--user .dock-pin__pinned-mark {
position: absolute;
top: 4px;
right: 4px;
width: 6px;
height: 6px;
border-radius: 50%;
background: color-mix(in srgb, var(--p-primary-color) 70%, white);
box-shadow: 0 0 6px color-mix(in srgb, var(--p-primary-color) 50%, transparent);
pointer-events: none;
}
/* Pin recente (MRU) — visualmente mais leve pra denotar transitoriedade.
Fica entre opacity total (active/hover) e ~70% no estado normal. */
.dock-pin--recent {
opacity: 0.78;
}
.dock-pin--recent:hover,
.dock-pin--recent.dock-pin--active {
opacity: 1;
}
/* ─── Skeleton loading utilitário (global pra atravessar scoped CSS dos
componentes filhos). Use a classe .melissa-skeleton em qualquer
container — vira um placeholder com shimmer suave.
File diff suppressed because it is too large Load Diff
@@ -17,6 +17,7 @@
import { ref, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
// Mesma lógica da PatientsListPage.vue — DB pode ter valores variados
function normalizeStatus(s) {
@@ -37,7 +38,9 @@ function normalizeStatus(s) {
*/
export function useMelissaPacientes(opts = {}) {
const onlyActive = opts.onlyActive !== false; // default true (compat)
const autoFetch = opts.autoFetch !== false; // default true (compat)
const tenantStore = useTenantStore();
const cache = useMelissaCacheStore();
const pacientes = ref([]);
const loading = ref(false);
@@ -46,17 +49,51 @@ export function useMelissaPacientes(opts = {}) {
async function ensureUid() {
if (uid.value) return uid.value;
// Fast path: session do storage local (<10ms vs ~80ms do getUser)
const { data: ses } = await supabase.auth.getSession();
if (ses?.session?.user?.id) {
uid.value = ses.session.user.id;
return uid.value;
}
const { data, error: err } = await supabase.auth.getUser();
if (err) return null;
uid.value = data?.user?.id || null;
return uid.value;
}
async function fetchPacientes() {
const userId = await ensureUid();
async function _doFetch(userId, tid, cacheKey) {
const { data, error: err } = await supabase
.from('patients')
.select('id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at, data_nascimento')
.eq('owner_id', userId)
.eq('tenant_id', tid)
.order('nome_completo', { ascending: true })
.limit(1000);
// Garante que o tenantStore foi hidratado (preview misc não passa por
// guard de auth, então o store pode estar vazio mesmo com user logado)
if (err) throw err;
const todos = (data || []).map((r) => ({
id: r.id,
nome: r.nome_completo || '',
email: r.email_principal || '',
telefone: r.telefone || '',
avatar_url: r.avatar_url || null,
status: normalizeStatus(r.status),
last_attended_at: r.last_attended_at || null,
created_at: r.created_at || null,
data_nascimento: r.data_nascimento || null
}));
const finalList = onlyActive ? todos.filter((p) => p.status === 'Ativo') : todos;
cache.set('pacientesTimeline', finalList, cacheKey);
pacientes.value = finalList;
return finalList;
}
// useCache=true (boot/auto): hidrata do cache se válido + revalida em background.
// useCache=false (refetch após mutation): força query nova, descarta cache.
async function _fetch({ useCache = true } = {}) {
const userId = await ensureUid();
if (typeof tenantStore.ensureLoaded === 'function') {
await tenantStore.ensureLoaded();
}
@@ -67,35 +104,28 @@ export function useMelissaPacientes(opts = {}) {
return;
}
const cacheKey = `${userId}:${tid}:${onlyActive ? 'a' : 'all'}`;
if (useCache) {
const cached = cache.get('pacientesTimeline', cacheKey, MELISSA_CACHE_TTL.pacientesTimeline);
if (cached) {
pacientes.value = cached;
_doFetch(userId, tid, cacheKey).catch((e) => {
// eslint-disable-next-line no-console
console.warn('[useMelissaPacientes] revalidate', e);
});
return;
}
} else {
// Force: invalida o slot pra outras instâncias (se houver) também
// pegarem fresh na próxima leitura.
cache.invalidate('pacientesTimeline');
}
loading.value = true;
error.value = null;
try {
// Não filtra status no SQL — DB tem valores inconsistentes
// ('ativo', 'Ativo', 'active', null...). Normaliza e filtra no client.
const { data, error: err } = await supabase
.from('patients')
.select('id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at, data_nascimento')
.eq('owner_id', userId)
.eq('tenant_id', tid)
.order('nome_completo', { ascending: true })
.limit(1000);
if (err) throw err;
const todos = (data || []).map((r) => ({
id: r.id,
nome: r.nome_completo || '',
email: r.email_principal || '',
telefone: r.telefone || '',
avatar_url: r.avatar_url || null,
status: normalizeStatus(r.status),
last_attended_at: r.last_attended_at || null,
created_at: r.created_at || null,
data_nascimento: r.data_nascimento || null
}));
pacientes.value = onlyActive ? todos.filter((p) => p.status === 'Ativo') : todos;
await _doFetch(userId, tid, cacheKey);
} catch (e) {
error.value = e?.message || 'Erro ao carregar pacientes';
pacientes.value = [];
@@ -106,12 +136,15 @@ export function useMelissaPacientes(opts = {}) {
}
}
onMounted(fetchPacientes);
if (autoFetch) onMounted(() => _fetch({ useCache: true }));
return {
pacientes,
loading,
error,
refetch: fetchPacientes
// refetch força query nova (uso em handlers pós-mutation: criar/editar/deletar).
refetch: () => _fetch({ useCache: false }),
// fetchCached usa stale-while-revalidate (uso em defer/idle callback).
fetchCached: () => _fetch({ useCache: true })
};
}
@@ -66,6 +66,14 @@ export function useMelissaPacientesAside(opts) {
async function _ensureUid() {
if (_uid.value) return _uid.value;
// Fast path: session já hidratada no storage (<10ms).
const { data: ses } = await supabase.auth.getSession();
const uid = ses?.session?.user?.id;
if (uid) {
_uid.value = uid;
return uid;
}
// Fallback: round-trip pro auth server (cold start).
const { data, error: err } = await supabase.auth.getUser();
if (err) return null;
_uid.value = data?.user?.id || null;