melissa/busca-global: 'Ir para [data]' + Ctrl+K unificado

MelissaBusca ganha parser de data ('hoje', 'amanha', 'ontem',
DD/MM/YYYY) e card destacado azul "Ir para [data]" como primeiro
item do flatList. Quando query parseia como data, pula a RPC
search_global (nao busca paciente com nome '20/06'). Enter sem
selecao explicita pega o primeiro item — UX spotlight padrao.

Novo emit goto-date(date) capturado em MelissaLayout via helper
_callOnAgenda que abre a agenda se fechada e chama gotoDate exposto
pela MelissaAgenda (alias pro onBuscaGotoDate existente).

MelissaAgenda perde o popover proprio (MelissaAgendaSearchPopover
deletado), o ref searchPopover, o hotkey Ctrl+K local e
onBuscaSelectEvento. Ctrl+K agora vive so na MelissaBusca — evita
dois listeners no mesmo atalho. MelissaBusca expoe openDialog via
defineExpose pra a lupa do tray chamar.

MelissaPacientes: comment update mencionando o tray.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-22 11:41:12 -03:00
parent fa2b431a56
commit 8bf992910d
4 changed files with 142 additions and 382 deletions
+22 -50
View File
@@ -31,7 +31,6 @@ import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosC
import MelissaAgendaHistoricoCard from './MelissaAgendaHistoricoCard.vue'; import MelissaAgendaHistoricoCard from './MelissaAgendaHistoricoCard.vue';
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue'; import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
import Popover from 'primevue/popover'; import Popover from 'primevue/popover';
import MelissaAgendaSearchPopover from './MelissaAgendaSearchPopover.vue';
import MelissaAgendaActionsPopover from './MelissaAgendaActionsPopover.vue'; import MelissaAgendaActionsPopover from './MelissaAgendaActionsPopover.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue'; import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'; import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
@@ -691,29 +690,15 @@ const fcOptions = computed(() => ({
})); }));
// ── Busca da toolbar (datas + paciente/título) ──────────────── // ── Busca da toolbar (datas + paciente/título) ────────────────
// Componente MelissaAgendaSearchPopover (autocontido) gerencia o Cmd+K // Busca agora vive na MelissaBusca (global, Ctrl+K em qualquer tela ou
// search inteiro — input, debounce, parsing de datas, resultados. // botao na .melissa-tray). Aqui fica so a fn gotoDate exposta pro
// Aqui só ficam o ref pro toggle (botão da toolbar + hotkey global) e // MelissaLayout chamar quando o usuario escolhe "Ir para [data]" na
// os 2 handlers que decidem o que fazer com a escolha (gotoDate + // busca global — vide defineExpose mais abaixo.
// auto-select de paciente quando há patient_id no evento).
const searchPopover = ref(null);
function onBuscaGotoDate(date) { function onBuscaGotoDate(date) {
fcApi()?.gotoDate(date); fcApi()?.gotoDate(date);
refDate.value = new Date(date); refDate.value = new Date(date);
} }
function onBuscaSelectEvento(ev) {
if (!ev?.inicio_em) return;
fcApi()?.gotoDate(ev.inicio_em);
refDate.value = new Date(ev.inicio_em);
// Auto-seleciona o paciente se o evento tiver um — assim a agenda já
// fica filtrada por ele e o dock contextual aparece.
if (ev.patient_id) {
pacienteSelecionadoId.value = ev.patient_id;
}
}
// Card de histórico (audit_logs) — ref pra disparar refetch após // Card de histórico (audit_logs) — ref pra disparar refetch após
// mutações; handler que abre o evento clicado pelo id. // mutações; handler que abre o evento clicado pelo id.
const historicoCardRef = ref(null); const historicoCardRef = ref(null);
@@ -760,18 +745,8 @@ async function onHistoricoOpen({ id }) {
} }
} }
// Atalho global Ctrl/Cmd+K abre a busca via ref do popover. // Ctrl+K e' tratado pela propria MelissaBusca (listener global no
function _onSearchHotkey(e) { // window) — removido o handler local pra nao disparar 2 vezes.
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
// Anchor virtual no botão da toolbar — necessário pra Popover do
// PrimeVue posicionar corretamente.
const btn = document.querySelector('.ma-cal__search-btn');
if (btn) searchPopover.value?.toggle({ currentTarget: btn, target: btn });
}
}
onMounted(() => { window.addEventListener('keydown', _onSearchHotkey); });
onBeforeUnmount(() => { window.removeEventListener('keydown', _onSearchHotkey); });
// Toolbar — atalhos pra FC API // Toolbar — atalhos pra FC API
function fcApi() { function fcApi() {
@@ -1321,12 +1296,20 @@ function openProntuario(patient) {
if (!patient?.id) return; if (!patient?.id) return;
abrirProntuarioPorId(patient.id); abrirProntuarioPorId(patient.id);
} }
// gotoDate exposto pro MelissaLayout chamar quando o usuario escolhe
// "Ir para [data]" na MelissaBusca (busca global). Reusa onBuscaGotoDate
// que ja atualiza fcApi + refDate.
function gotoDateExternal(date) {
onBuscaGotoDate(date);
}
defineExpose({ defineExpose({
refetch: refetchEventosFc, refetch: refetchEventosFc,
openProntuario, openProntuario,
setView, setView,
openSessoesPaciente, openSessoesPaciente,
openEditPatient openEditPatient,
gotoDate: gotoDateExternal
}); });
</script> </script>
@@ -1629,21 +1612,10 @@ defineExpose({
/> />
</div> </div>
<!-- Busca sempre visível. Abre popover com input + lista de <!-- Busca migrou pra .melissa-tray (sempre visivel).
resultados. Suporta data (20/04, hoje) e texto (paciente/ Ctrl+K em qualquer tela abre o mesmo spotlight,
título). Ctrl/Cmd+K abre via hotkey global. --> que ja entende data (20/04, hoje, amanha) e
<button paciente/sessao via RPC search_global. -->
class="ma-cal__icon ma-cal__search-btn w-7 h-7 grid place-items-center bg-transparent border-0 text-[var(--m-text)] rounded-md cursor-pointer text-[0.78rem] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2"
v-tooltip.top="'Buscar (Ctrl+K)'"
@click="searchPopover?.toggle($event)"
>
<i class="pi pi-search" />
</button>
<MelissaAgendaSearchPopover
ref="searchPopover"
@goto-date="onBuscaGotoDate"
@select-evento="onBuscaSelectEvento"
/>
<!-- Bloquear: ícone-only com Menu popup. Visível só <!-- Bloquear: ícone-only com Menu popup. Visível só
em ≥xl. Em <xl vai pra dentro de "Ações". --> em ≥xl. Em <xl vai pra dentro de "Ações". -->
@@ -2051,9 +2023,9 @@ defineExpose({
to { opacity: 1; transform: scale(1); } to { opacity: 1; transform: scale(1); }
} }
/* .ma-tsearch* migrou inteiro pra MelissaAgendaSearchPopover.vue (componente /* Busca da agenda migrou inteira pra MelissaBusca (componente global,
autocontido). Cmd+K hotkey global continua aqui no parent — chama no MelissaLayout). Acesso por Ctrl+K em qualquer tela ou pela lupa na
`searchPopover.value?.toggle(...)` apontando pro botao .ma-cal__search-btn. */ .melissa-tray (canto inferior direito). Toolbar nao tem mais botao. */
/* .ma-actions* migrou inteiro pra MelissaAgendaActionsPopover.vue /* .ma-actions* migrou inteiro pra MelissaAgendaActionsPopover.vue
(componente autocontido, com utilities Tailwind no template). */ (componente autocontido, com utilities Tailwind no template). */
@@ -1,327 +0,0 @@
<script setup>
/*
* MelissaAgendaSearchPopover — busca da toolbar da Agenda Melissa
* --------------------------------------------------------------
* Pattern Cmd+K: input + lista de resultados num Popover. Suporta:
* - Datas: "20/04", "20/04/2026", "hoje", "amanhã", "ontem"
* → emit('goto-date', date) — pai navega o FullCalendar
* - Texto livre: pesquisa server-side em patients.nome_completo + titulo
* (via searchEventosByText) com debounce de 300ms. Limite 20 resultados.
* → emit('select-evento', ev) — pai aciona gotoDate + auto-select patient
*
* Componente autocontido — owns todo o state, debounce, parsing.
* Pai expõe um botão âncora (`.ma-cal__search-btn`) e chama
* `popoverRef.value.toggle($event)` no click. Hotkey Cmd+K também
* vive no pai (acha o botão via querySelector e chama toggle).
*
* Emit:
* - goto-date(date: Date) — escolheu uma data do parser
* - select-evento(ev) — escolheu um evento da lista de busca
*
* Exposto via defineExpose:
* - toggle(event) — abre/fecha o popover, foca input quando abre
*/
import { ref, onBeforeUnmount } from 'vue';
import Popover from 'primevue/popover';
import { searchEventosByText } from './composables/useMelissaEventos';
const emit = defineEmits(['goto-date', 'select-evento']);
const popRef = ref(null);
const inputRef = ref(null);
const searchQuery = ref('');
const searchResults = ref([]);
const searchLoading = ref(false);
const searchDateMatch = ref(null); // Date | null — preenchido se query parsear como data
let _debounceTimer = null;
// Token monotonico — protege contra race condition: se o user digita "ab"
// e depois "abc", o request "ab" pode resolver DEPOIS do "abc" em conexao
// lenta. Cada request guarda seu token; ao voltar, so aplica se ainda eh
// o mais recente. Cancelamento via AbortController seria ideal mas exigiria
// searchEventosByText aceitar signal — por ora token resolve sem mexer no API.
let _searchToken = 0;
function parseSearchAsDate(str) {
const t = String(str || '').trim().toLowerCase();
if (!t) return null;
if (t === 'hoje') { const d = new Date(); d.setHours(0,0,0,0); return d; }
if (t === 'amanha' || t === 'amanhã') {
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + 1); return d;
}
if (t === 'ontem') {
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() - 1); return d;
}
// DD/MM ou DD/MM/YYYY (também aceita - e .)
const m = t.match(/^(\d{1,2})[/\-.](\d{1,2})(?:[/\-.](\d{2,4}))?$/);
if (m) {
const day = parseInt(m[1], 10);
const month = parseInt(m[2], 10);
let year = parseInt(m[3] || '', 10);
if (!year || Number.isNaN(year)) year = new Date().getFullYear();
if (year < 100) year += 2000;
if (day < 1 || day > 31 || month < 1 || month > 12) return null;
const d = new Date(year, month - 1, day);
if (Number.isNaN(d.getTime())) return null;
return d;
}
return null;
}
function toggle(event) {
popRef.value?.toggle(event);
// Foco no input quando abrir (Popover anima ~150ms). PrimeVue InputText
// renderiza `<input>` direto, então `$el` já é o elemento focável.
// Tentamos algumas vezes pra cobrir mount async + transição do Popover.
let tries = 0;
const tick = () => {
const el = inputRef.value?.$el;
if (el && typeof el.focus === 'function') { el.focus(); el.select?.(); return; }
if (tries++ < 8) setTimeout(tick, 30);
};
setTimeout(tick, 80);
}
function fechar() {
try { popRef.value?.hide(); } catch {}
}
// Toda vez que o popover fecha (via ESC, click-fora, ou submit/select),
// reseta o state pra proxima abertura comecar limpa. Antes, ESC mantinha
// query+resultados "fantasmas" — UX confusa: reabrir mostrava busca
// antiga que talvez nao fizesse mais sentido.
function onPopoverHide() {
searchQuery.value = '';
searchResults.value = [];
searchDateMatch.value = null;
searchLoading.value = false;
++_searchToken; // invalida requests em flight
}
function onSearchInput() {
if (_debounceTimer) clearTimeout(_debounceTimer);
const q = searchQuery.value;
searchDateMatch.value = parseSearchAsDate(q);
// Se digitou data, mostra resultado imediato (sem hit no DB)
if (searchDateMatch.value) {
// Invalida tokens em flight pra eles nao voltarem e sobrescreverem [].
++_searchToken;
searchResults.value = [];
searchLoading.value = false;
return;
}
if (String(q || '').trim().length < 2) {
++_searchToken;
searchResults.value = [];
searchLoading.value = false;
return;
}
searchLoading.value = true;
_debounceTimer = setTimeout(async () => {
const myToken = ++_searchToken;
try {
const results = await searchEventosByText(q);
// Race guard: se outro request foi disparado depois deste,
// descarta este (out-of-order resolution).
if (myToken !== _searchToken) return;
searchResults.value = results;
} finally {
// So zera loading se este eh o ultimo request — senao deixa o
// proximo controlar o estado (evita flicker de loading=false
// entre requests sequenciais rapidos).
if (myToken === _searchToken) searchLoading.value = false;
}
}, 300);
}
function onSearchSubmit() {
// Enter: prioriza data, senão pega o primeiro resultado
if (searchDateMatch.value) {
irParaData(searchDateMatch.value);
return;
}
if (searchResults.value.length > 0) {
selecionarResultado(searchResults.value[0]);
}
}
function irParaData(date) {
emit('goto-date', date);
fechar(); // @hide handler limpa state
}
function selecionarResultado(ev) {
if (!ev?.inicio_em) return;
emit('select-evento', ev);
fechar(); // @hide handler limpa state
}
function fmtDataResultado(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', weekday: 'short' });
}
function fmtHoraResultado(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
onBeforeUnmount(() => {
if (_debounceTimer) clearTimeout(_debounceTimer);
});
defineExpose({ toggle, close: fechar });
</script>
<template>
<Popover ref="popRef" class="ma-tsearch-pop" @hide="onPopoverHide">
<div class="ma-tsearch flex flex-col w-[min(440px,calc(100vw-32px))] max-h-[500px] overflow-hidden">
<div class="ma-tsearch__field relative flex items-center gap-2 px-2.5 py-1.5 mx-2 mt-2 mb-1.5 bg-[var(--m-bg-soft)] border border-[var(--m-border)] rounded-[10px] flex-shrink-0 transition-[border-color,background-color] duration-[140ms] focus-within:border-[var(--p-primary-color)] focus-within:bg-[var(--m-bg-soft-hover)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--p-primary-color)_12%,transparent)]">
<i class="pi pi-search ma-tsearch__field-icon text-[var(--m-text-muted)] text-[0.85rem] flex-shrink-0" />
<InputText
ref="inputRef"
v-model="searchQuery"
placeholder="Data (20/04) ou nome do paciente…"
class="ma-tsearch__input"
@input="onSearchInput"
@keydown.enter="onSearchSubmit"
@keydown.esc="fechar"
/>
<button
v-if="searchQuery"
class="ma-tsearch__clear w-[22px] h-[22px] grid place-items-center border-0 bg-[var(--m-bg-medium)] text-[var(--m-text-muted)] rounded-full cursor-pointer flex-shrink-0 transition-colors duration-[140ms] hover:bg-[var(--m-border-strong)] hover:text-[var(--m-text)]"
v-tooltip.top="'Limpar'"
@click="searchQuery = ''; onSearchInput()"
>
<i class="pi pi-times text-xs" />
</button>
</div>
<!-- Resultado data (sem hit no DB) -->
<button
v-if="searchDateMatch"
class="ma-tsearch__result ma-tsearch__result--date w-auto self-stretch flex items-center gap-2.5 px-2.5 py-2 mx-2 mb-1.5 rounded-lg cursor-pointer text-left [font-family:inherit] transition-colors duration-[140ms]"
@click="irParaData(searchDateMatch)"
>
<span class="ma-tsearch__result-icon w-8 h-8 grid place-items-center rounded-lg flex-shrink-0 text-[0.78rem]"><i class="pi pi-calendar" /></span>
<span class="ma-tsearch__result-main flex-1 min-w-0 flex flex-col gap-0.5">
<span class="ma-tsearch__result-title text-[0.85rem] font-medium whitespace-nowrap overflow-hidden text-ellipsis capitalize">Ir para {{ searchDateMatch.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', weekday: 'long' }) }}</span>
<span class="ma-tsearch__result-sub text-[0.72rem] text-[var(--m-text-muted)] whitespace-nowrap overflow-hidden text-ellipsis">Pular para essa data no calendário</span>
</span>
<i class="pi pi-arrow-right text-xs opacity-50" />
</button>
<!-- Loading -->
<div v-else-if="searchLoading" class="ma-tsearch__loading flex items-center gap-2 px-3.5 py-[18px] text-[var(--m-text-muted)] text-[0.85rem]">
<i class="pi pi-spin pi-spinner" /> <span>Buscando</span>
</div>
<!-- Resultados (eventos) -->
<div v-else-if="searchResults.length > 0" class="ma-tsearch__results flex-1 min-h-0 overflow-y-auto p-1">
<button
v-for="ev in searchResults"
:key="ev.id"
class="ma-tsearch__result w-full flex items-center gap-2.5 px-2.5 py-2 border-0 bg-transparent text-[var(--m-text)] rounded-lg cursor-pointer text-left [font-family:inherit] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:bg-[var(--m-bg-soft-hover)] focus-visible:outline-none"
@click="selecionarResultado(ev)"
>
<span class="ma-tsearch__result-icon w-8 h-8 grid place-items-center rounded-lg text-white flex-shrink-0 text-[0.78rem]" :style="{ background: ev.color }">
<i :class="ev.tipo === 'sessao' ? 'pi pi-user' : 'pi pi-calendar'" />
</span>
<span class="ma-tsearch__result-main flex-1 min-w-0 flex flex-col gap-0.5">
<span class="ma-tsearch__result-title text-[0.85rem] font-medium text-[var(--m-text)] whitespace-nowrap overflow-hidden text-ellipsis">{{ ev.label }}</span>
<span class="ma-tsearch__result-sub text-[0.72rem] text-[var(--m-text-muted)] whitespace-nowrap overflow-hidden text-ellipsis">
{{ fmtDataResultado(ev.inicio_em) }} · {{ fmtHoraResultado(ev.inicio_em) }}
<span v-if="ev.modalidade"> · {{ ev.modalidade }}</span>
</span>
</span>
</button>
</div>
<!-- Vazio ( buscou mas nada encontrou) -->
<div v-else-if="String(searchQuery || '').trim().length >= 2" class="ma-tsearch__empty flex flex-col items-center gap-2 px-4 py-[26px] mx-2 mb-2.5 mt-1 text-[var(--m-text-muted)] text-[0.82rem] border-[1.5px] border-dashed border-[color-mix(in_srgb,var(--p-primary-color)_22%,var(--m-border))] rounded-[14px] bg-[color-mix(in_srgb,var(--m-bg-soft)_50%,transparent)] text-center [&>i]:text-[2.2rem] [&>i]:opacity-70 [&>i]:text-[var(--p-primary-color)] [&>i]:mb-0.5">
<i class="pi pi-search-minus" />
<span class="ma-tsearch__empty-title text-[0.88rem] font-semibold text-[var(--m-text)]">Busca não encontrada</span>
<span class="ma-tsearch__empty-sub text-[0.78rem] text-[var(--m-text-muted)] leading-[1.35] [&_strong]:text-[var(--m-text)] [&_strong]:font-semibold">
Nada para "<strong>{{ searchQuery }}</strong>"
</span>
</div>
<!-- Hint inicial Message PrimeVue (auto-resolve via PrimeVueResolver) -->
<Message
v-else
severity="info"
:closable="false"
icon="pi pi-info-circle"
class="ma-tsearch__hint mx-2 mb-2.5"
>
Digite uma data (<strong>20/04</strong>, <strong>hoje</strong>, <strong>amanhã</strong>) ou
o nome do paciente (<strong>André</strong>).
</Message>
</div>
</Popover>
</template>
<style scoped>
/* Estilos que NAO migram pra utilities Tailwind:
- .ma-tsearch__input.p-inputtext: override do PrimeVue InputText pra ele
viver dentro do "field box" (zera bordas, bg, shadow padrao). Selector
composto com classe externa do PrimeVue.
- .ma-tsearch__hint :deep(.p-message-text): override de typography dentro
do componente filho Message (isolado por scope).
- .ma-tsearch__result--date: a versao "Ir para data" tem cores azul fixas
(rgb diretas, sem var()) com !important pra blindar contra hover do
base .ma-tsearch__result. State modifier mais limpo em CSS. */
.ma-tsearch__input.p-inputtext {
flex: 1;
border: none;
background: transparent;
outline: none;
box-shadow: none;
padding: 8px 0;
font-size: 0.9rem;
color: var(--m-text);
min-width: 0;
}
.ma-tsearch__input.p-inputtext:enabled:focus,
.ma-tsearch__input.p-inputtext:enabled:hover {
box-shadow: none;
border-color: transparent;
background: transparent;
}
.ma-tsearch__input.p-inputtext::placeholder { color: var(--m-text-muted); }
.ma-tsearch__hint :deep(.p-message-text) {
font-size: 0.82rem;
line-height: 1.4;
}
.ma-tsearch__hint strong { font-weight: 600; }
/* "Ir para data" — destaque azul claro independente da primary do tenant.
Cores diretas (sem color-mix com bg-soft) pra garantir contraste em
dark mode onde --m-bg-soft tem alpha 50% e diluiria o tint demais.
!important pra blindar contra hover do .ma-tsearch__result base. */
.ma-tsearch__result--date {
background: rgba(59, 130, 246, 0.16) !important;
border: 1.5px solid rgba(59, 130, 246, 0.55) !important;
}
.ma-tsearch__result--date:hover,
.ma-tsearch__result--date:focus-visible {
background: rgba(59, 130, 246, 0.26) !important;
border-color: rgba(59, 130, 246, 0.75) !important;
}
.ma-tsearch__result--date .ma-tsearch__result-icon {
background: #3b82f6 !important;
color: white !important;
}
.ma-tsearch__result--date .ma-tsearch__result-title {
color: #2563eb !important; /* blue-600 — boa leitura em ambos os modos */
}
/* Dark mode — clareia o título pra contrastar com o fundo escuro */
html.app-dark .ma-tsearch__result--date .ma-tsearch__result-title {
color: #93c5fd !important; /* blue-300 */
}
html.app-dark .ma-tsearch__result--date .ma-tsearch__result-sub {
color: rgba(147, 197, 253, 0.7) !important;
}
</style>
+115 -3
View File
@@ -33,7 +33,7 @@ const props = defineProps({
} }
}); });
const emit = defineEmits(['acao', 'paciente', 'evento', 'documento', 'intake']); const emit = defineEmits(['acao', 'paciente', 'evento', 'documento', 'intake', 'goto-date']);
const rootEl = ref(null); const rootEl = ref(null);
const inputEl = ref(null); const inputEl = ref(null);
@@ -62,12 +62,59 @@ function normalize(s) {
.trim(); .trim();
} }
// Parser de data — portado de MelissaAgendaSearchPopover.
// Aceita: "hoje", "amanha"/"amanhã", "ontem", "DD/MM", "DD/MM/YYYY"
// (separadores /, - ou .). Retorna Date|null. Acao "Ir para esta data"
// so se torna visivel quando ha match (vide dateMatch computed).
function parseSearchAsDate(str) {
const t = String(str || '').trim().toLowerCase();
if (!t) return null;
if (t === 'hoje') { const d = new Date(); d.setHours(0, 0, 0, 0); return d; }
if (t === 'amanha' || t === 'amanhã') {
const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + 1); return d;
}
if (t === 'ontem') {
const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - 1); return d;
}
const m = t.match(/^(\d{1,2})[/\-.](\d{1,2})(?:[/\-.](\d{2,4}))?$/);
if (m) {
const day = parseInt(m[1], 10);
const month = parseInt(m[2], 10);
let year = parseInt(m[3] || '', 10);
if (!year || Number.isNaN(year)) year = new Date().getFullYear();
if (year < 100) year += 2000;
if (day < 1 || day > 31 || month < 1 || month > 12) return null;
const d = new Date(year, month - 1, day);
if (Number.isNaN(d.getTime())) return null;
return d;
}
return null;
}
function fmtHora(h) { function fmtHora(h) {
const horas = Math.floor(h); const horas = Math.floor(h);
const mins = Math.round((h - horas) * 60); const mins = Math.round((h - horas) * 60);
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
} }
// Match de data — se a query parseia como data, a primeira "linha" do
// painel vira um card destacado "Ir para [data]" (igual ao popover da
// agenda). Click/Enter dispara emit('goto-date', date) e o MelissaLayout
// abre a agenda + navega o calendario.
const dateMatch = computed(() => parseSearchAsDate(query.value));
function fmtDataLonga(d) {
if (!(d instanceof Date) || Number.isNaN(d.getTime())) return '';
// "Sábado, 20/06/2026" — primeira letra maiuscula no weekday
const s = d.toLocaleDateString('pt-BR', {
weekday: 'long',
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
return s.charAt(0).toUpperCase() + s.slice(1);
}
const filteredAtalhos = computed(() => { const filteredAtalhos = computed(() => {
const q = normalize(query.value); const q = normalize(query.value);
if (!q) return props.atalhos.slice(0, 4); // 4 defaults quando vazio if (!q) return props.atalhos.slice(0, 4); // 4 defaults quando vazio
@@ -122,6 +169,9 @@ const rpcIntakes = computed(() => rpcResults.value.intakes || []);
const flatList = computed(() => { const flatList = computed(() => {
const out = []; const out = [];
// "Ir para [data]" sempre no topo quando query parseia como data —
// acao predominante (Enter direto seleciona ela).
if (dateMatch.value) out.push({ group: 'goto-date', item: dateMatch.value, idx: 0 });
recentItems.value.forEach((p, i) => out.push({ group: 'recent', item: p, idx: i })); recentItems.value.forEach((p, i) => out.push({ group: 'recent', item: p, idx: i }));
filteredAtalhos.value.forEach((a, i) => out.push({ group: 'atalhos', item: a, idx: i })); filteredAtalhos.value.forEach((a, i) => out.push({ group: 'atalhos', item: a, idx: i }));
filteredPacientes.value.forEach((p, i) => out.push({ group: 'pacientes', item: p, idx: i })); filteredPacientes.value.forEach((p, i) => out.push({ group: 'pacientes', item: p, idx: i }));
@@ -139,7 +189,8 @@ function findFlatIndex(group, idx) {
} }
function selectEntry(entry) { function selectEntry(entry) {
if (entry.group === 'atalhos') emit('acao', entry.item.id); if (entry.group === 'goto-date') emit('goto-date', entry.item);
else if (entry.group === 'atalhos') emit('acao', entry.item.id);
else if (entry.group === 'pacientes') emit('paciente', entry.item); else if (entry.group === 'pacientes') emit('paciente', entry.item);
else if (entry.group === 'recent') emit('paciente', { id: entry.item.id, nome: entry.item.nome, ...entry.item.extras }); else if (entry.group === 'recent') emit('paciente', { id: entry.item.id, nome: entry.item.nome, ...entry.item.extras });
else if (entry.group === 'eventos') emit('evento', entry.item); else if (entry.group === 'eventos') emit('evento', entry.item);
@@ -176,9 +227,17 @@ function onKeydown(e) {
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0); activeIndex.value = Math.max(activeIndex.value - 1, 0);
} else if (e.key === 'Enter' && activeIndex.value >= 0) { } else if (e.key === 'Enter') {
// Enter sem selecao explicita: pega o primeiro item do flatList
// (UX spotlight padrao — usuario digita "hoje" + Enter deve ir
// direto pra hoje sem precisar ArrowDown).
if (activeIndex.value >= 0) {
e.preventDefault(); e.preventDefault();
selectEntry(flatList.value[activeIndex.value]); selectEntry(flatList.value[activeIndex.value]);
} else if (flatList.value.length > 0) {
e.preventDefault();
selectEntry(flatList.value[0]);
}
} }
// Escape é tratado pelo Dialog (dismissableMask + closable) // Escape é tratado pelo Dialog (dismissableMask + closable)
} }
@@ -206,6 +265,14 @@ watch(query, (v) => {
searching.value = false; searching.value = false;
return; return;
} }
// Query parseou como data: pula RPC (nao faz sentido buscar paciente
// chamado "20/06"). Card "Ir para data" cobre o caso sozinho.
if (dateMatch.value) {
++searchSeq; // invalida requests em flight
resetRpcResults();
searching.value = false;
return;
}
searching.value = true; searching.value = true;
const mySeq = ++searchSeq; const mySeq = ++searchSeq;
debounceT = setTimeout(async () => { debounceT = setTimeout(async () => {
@@ -241,6 +308,12 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', onGlobalKeydown); window.removeEventListener('keydown', onGlobalKeydown);
if (debounceT) clearTimeout(debounceT); if (debounceT) clearTimeout(debounceT);
}); });
// Exposto pro MelissaLayout — a lupa unica na .melissa-tray chama
// melissaBuscaRef.openDialog() direto, e o provide('openMelissaBusca')
// reusa o mesmo metodo pra qualquer descendente que queira abrir o
// spotlight programaticamente. closeDialog alias do closePanel.
defineExpose({ openDialog, closeDialog: closePanel });
</script> </script>
<template> <template>
@@ -291,6 +364,25 @@ onBeforeUnmount(() => {
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>" Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
</div> </div>
<!-- "Ir para [data]" quando query parseia como data
(hoje/amanha/ontem/DD/MM/YYYY). Predominante: vai pra
primeira linha do flatList e Enter direto seleciona. -->
<div v-if="dateMatch" class="mb-group">
<button
class="mb-item mb-item--gotodate"
:class="{ 'is-active': findFlatIndex('goto-date', 0) === activeIndex }"
@click="selectEntry({ group: 'goto-date', item: dateMatch })"
@mouseenter="activeIndex = findFlatIndex('goto-date', 0)"
>
<span class="mb-item__icon mb-item__icon--gotodate"><i class="pi pi-calendar" /></span>
<span class="mb-item__main">
<span class="mb-item__label">Ir para {{ fmtDataLonga(dateMatch) }}</span>
<span class="mb-item__sub">Pular para essa data no calendário</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Acessados recentemente ( quando query vazia) --> <!-- Acessados recentemente ( quando query vazia) -->
<div v-if="showRecent" class="mb-group"> <div v-if="showRecent" class="mb-group">
<div class="mb-group__title">Acessados recentemente</div> <div class="mb-group__title">Acessados recentemente</div>
@@ -660,6 +752,26 @@ onBeforeUnmount(() => {
:root.app-dark .mb-item__icon--doc { color: #7dd3fc; } :root.app-dark .mb-item__icon--doc { color: #7dd3fc; }
:root.app-dark .mb-item__icon--intake { color: #fdba74; } :root.app-dark .mb-item__icon--intake { color: #fdba74; }
/* "Ir para [data]" — card azul predominante, mesmo padrao visual do
popover da agenda (MelissaAgendaSearchPopover). Cores diretas (sem
var/color-mix) pra garantir contraste em ambos os modos. */
.mb-item--gotodate {
background: rgba(59, 130, 246, 0.16);
border: 1.5px solid rgba(59, 130, 246, 0.55);
}
.mb-item--gotodate:hover,
.mb-item--gotodate.is-active {
background: rgba(59, 130, 246, 0.26);
border-color: rgba(59, 130, 246, 0.75);
}
.mb-item__icon--gotodate {
background: #3b82f6;
color: white;
}
.mb-item--gotodate .mb-item__label { color: #2563eb; font-weight: 600; }
:root.app-dark .mb-item--gotodate .mb-item__label { color: #93c5fd; }
:root.app-dark .mb-item--gotodate .mb-item__sub { color: rgba(147, 197, 253, 0.7); }
.mb-item__main { .mb-item__main {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
+3
View File
@@ -313,6 +313,9 @@ async function refetchTudo() {
} }
// ── Estado de UI ─────────────────────────────────────────────── // ── Estado de UI ───────────────────────────────────────────────
// Busca local — filtra a lista visivel combinada com filtros de
// status/grupo/tag. Busca global (Ctrl+K) tem botao dedicado no
// .melissa-tray, fora desta seccao.
const busca = ref(''); const busca = ref('');
const statusFiltro = ref('ativos'); // 'todos' | 'ativos' | 'inativos' | 'arquivados' const statusFiltro = ref('ativos'); // 'todos' | 'ativos' | 'inativos' | 'arquivados'
const grupoFiltroId = ref(null); // null = todos const grupoFiltroId = ref(null); // null = todos