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:
@@ -31,7 +31,6 @@ import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosC
|
||||
import MelissaAgendaHistoricoCard from './MelissaAgendaHistoricoCard.vue';
|
||||
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
|
||||
import Popover from 'primevue/popover';
|
||||
import MelissaAgendaSearchPopover from './MelissaAgendaSearchPopover.vue';
|
||||
import MelissaAgendaActionsPopover from './MelissaAgendaActionsPopover.vue';
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
|
||||
@@ -691,29 +690,15 @@ const fcOptions = computed(() => ({
|
||||
}));
|
||||
|
||||
// ── Busca da toolbar (datas + paciente/título) ────────────────
|
||||
// Componente MelissaAgendaSearchPopover (autocontido) gerencia o Cmd+K
|
||||
// search inteiro — input, debounce, parsing de datas, resultados.
|
||||
// Aqui só ficam o ref pro toggle (botão da toolbar + hotkey global) e
|
||||
// os 2 handlers que decidem o que fazer com a escolha (gotoDate +
|
||||
// auto-select de paciente quando há patient_id no evento).
|
||||
const searchPopover = ref(null);
|
||||
|
||||
// Busca agora vive na MelissaBusca (global, Ctrl+K em qualquer tela ou
|
||||
// botao na .melissa-tray). Aqui fica so a fn gotoDate exposta pro
|
||||
// MelissaLayout chamar quando o usuario escolhe "Ir para [data]" na
|
||||
// busca global — vide defineExpose mais abaixo.
|
||||
function onBuscaGotoDate(date) {
|
||||
fcApi()?.gotoDate(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
|
||||
// mutações; handler que abre o evento clicado pelo id.
|
||||
const historicoCardRef = ref(null);
|
||||
@@ -760,18 +745,8 @@ async function onHistoricoOpen({ id }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Atalho global Ctrl/Cmd+K abre a busca via ref do popover.
|
||||
function _onSearchHotkey(e) {
|
||||
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); });
|
||||
// Ctrl+K e' tratado pela propria MelissaBusca (listener global no
|
||||
// window) — removido o handler local pra nao disparar 2 vezes.
|
||||
|
||||
// Toolbar — atalhos pra FC API
|
||||
function fcApi() {
|
||||
@@ -1321,12 +1296,20 @@ function openProntuario(patient) {
|
||||
if (!patient?.id) return;
|
||||
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({
|
||||
refetch: refetchEventosFc,
|
||||
openProntuario,
|
||||
setView,
|
||||
openSessoesPaciente,
|
||||
openEditPatient
|
||||
openEditPatient,
|
||||
gotoDate: gotoDateExternal
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1629,21 +1612,10 @@ defineExpose({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Busca — sempre visível. Abre popover com input + lista de
|
||||
resultados. Suporta data (20/04, hoje) e texto (paciente/
|
||||
título). Ctrl/Cmd+K abre via hotkey global. -->
|
||||
<button
|
||||
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"
|
||||
/>
|
||||
<!-- Busca migrou pra .melissa-tray (sempre visivel).
|
||||
Ctrl+K em qualquer tela abre o mesmo spotlight,
|
||||
que ja entende data (20/04, hoje, amanha) e
|
||||
paciente/sessao via RPC search_global. -->
|
||||
|
||||
<!-- Bloquear: ícone-only com Menu popup. Visível só
|
||||
em ≥xl. Em <xl vai pra dentro de "Ações". -->
|
||||
@@ -2051,9 +2023,9 @@ defineExpose({
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* .ma-tsearch* migrou inteiro pra MelissaAgendaSearchPopover.vue (componente
|
||||
autocontido). Cmd+K hotkey global continua aqui no parent — chama
|
||||
`searchPopover.value?.toggle(...)` apontando pro botao .ma-cal__search-btn. */
|
||||
/* Busca da agenda migrou inteira pra MelissaBusca (componente global,
|
||||
no MelissaLayout). Acesso por Ctrl+K em qualquer tela ou pela lupa na
|
||||
.melissa-tray (canto inferior direito). Toolbar nao tem mais botao. */
|
||||
|
||||
/* .ma-actions* migrou inteiro pra MelissaAgendaActionsPopover.vue
|
||||
(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 (já 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>
|
||||
@@ -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 inputEl = ref(null);
|
||||
@@ -62,12 +62,59 @@ function normalize(s) {
|
||||
.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) {
|
||||
const horas = Math.floor(h);
|
||||
const mins = Math.round((h - horas) * 60);
|
||||
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 q = normalize(query.value);
|
||||
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 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 }));
|
||||
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 }));
|
||||
@@ -139,7 +189,8 @@ function findFlatIndex(group, idx) {
|
||||
}
|
||||
|
||||
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 === 'recent') emit('paciente', { id: entry.item.id, nome: entry.item.nome, ...entry.item.extras });
|
||||
else if (entry.group === 'eventos') emit('evento', entry.item);
|
||||
@@ -176,9 +227,17 @@ function onKeydown(e) {
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
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();
|
||||
selectEntry(flatList.value[activeIndex.value]);
|
||||
} else if (flatList.value.length > 0) {
|
||||
e.preventDefault();
|
||||
selectEntry(flatList.value[0]);
|
||||
}
|
||||
}
|
||||
// Escape é tratado pelo Dialog (dismissableMask + closable)
|
||||
}
|
||||
@@ -206,6 +265,14 @@ watch(query, (v) => {
|
||||
searching.value = false;
|
||||
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;
|
||||
const mySeq = ++searchSeq;
|
||||
debounceT = setTimeout(async () => {
|
||||
@@ -241,6 +308,12 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onGlobalKeydown);
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -291,6 +364,25 @@ onBeforeUnmount(() => {
|
||||
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
|
||||
</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 (só quando query vazia) -->
|
||||
<div v-if="showRecent" class="mb-group">
|
||||
<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--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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -313,6 +313,9 @@ async function refetchTudo() {
|
||||
}
|
||||
|
||||
// ── 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 statusFiltro = ref('ativos'); // 'todos' | 'ativos' | 'inativos' | 'arquivados'
|
||||
const grupoFiltroId = ref(null); // null = todos
|
||||
|
||||
Reference in New Issue
Block a user