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:
@@ -746,7 +746,7 @@ const escolaridadeOpts = [
|
||||
{ label:'Prefere não informar', value:'Prefere não informar' },
|
||||
]
|
||||
const canalOpts = [{ label:'WhatsApp',value:'WhatsApp' },{ label:'Telefone',value:'Telefone' },{ label:'E-mail',value:'E-mail' },{ label:'SMS',value:'SMS' }]
|
||||
const statusOpts = [{ label:'Ativo',value:'Ativo' },{ label:'Em espera',value:'Em espera' },{ label:'Inativo',value:'Inativo' },{ label:'Alta',value:'Alta' }]
|
||||
const statusOpts = [{ label:'Ativo',value:'Ativo' },{ label:'Em espera',value:'Em espera' },{ label:'Inativo',value:'Inativo' },{ label:'Alta',value:'Alta' },{ label:'Arquivado',value:'Arquivado' }]
|
||||
const scopeOpts = [{ label:'Clínica',value:'Clínica' },{ label:'Particular',value:'Particular' },{ label:'Online',value:'Online' },{ label:'Híbrido',value:'Híbrido' }]
|
||||
const tipoContatoOpts = [{ label:'Emergência',value:'emergencia' },{ label:'Familiar',value:'familiar' },{ label:'Profissional de saúde',value:'profissional_saude' },{ label:'Amigo(a)',value:'amigo' },{ label:'Outro',value:'outro' }]
|
||||
|
||||
|
||||
@@ -95,6 +95,22 @@ export async function softDeletePatient(id, { tenantId } = {}) {
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaura um paciente arquivado — volta status pra 'Ativo'.
|
||||
* Inverso explícito do softDeletePatient. Uso: botão "Restaurar"
|
||||
* que aparece nas ações quando p.status === 'Arquivado'.
|
||||
*/
|
||||
export async function restorePatient(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('id obrigatório');
|
||||
assertTenantId(tenantId);
|
||||
const { error } = await supabase
|
||||
.from('patients')
|
||||
.update({ status: 'Ativo' })
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Groups
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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.',
|
||||
|
||||
@@ -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. Só 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;
|
||||
|
||||
Reference in New Issue
Block a user