MelissaAgenda: migra CSS pra Tailwind + extrai SearchPopover/ActionsPopover + fixes de smell
CSS migration (Tailwind v4 com max-[1023px]:/max-[1279px]: arbitrarios pra preservar pixel-perfect): - Containers/layout: ma-page, ma-page__head/__title/__actions, ma-close, ma-head-btn, ma-body, ma-menu-btn, ma-mobile-drawer* - Mini-calendar: weekdays/grid/day/dots/dot bases (state modifiers .is-feriado--* ficam em CSS por usarem color-mix por tipo) - Aside: ma-side, ma-search*, ma-pat* (avatar/info/name/sub/novo/kebab), ma-act-btn*, paginator - Toolbar: ma-cal*, ma-cal__nav*, ma-cal__btn (versao ghost + bug pre-existente da definicao duplicada preservado), ma-cal__icon, ma-cal__view* - Stats/Sessions/Filter chip/Loading/Dock actions: bases full-Tailwind, state modifiers ficam em CSS - Patient banner + All sessions: bases + grid responsivo via max-[640px]:[grid-column:N] arbitrarios Extracoes: - MelissaAgendaSearchPopover.vue: Cmd+K busca de toolbar (datas + paciente). Token monotonico + invalidacao em early returns + cleanup no @hide do Popover. - MelissaAgendaActionsPopover.vue: popover Acoes mobile (<xl) com SelectButtons + 4 botoes de bloqueio. v-model:calendar-view/only-sessions/time-mode + emit bloqueio. Fixes acionaveis (smells previamente listados): - #2 null-safety em M.feriados/workRules (fallbacks pra modo standalone) - #3 race em searchEventosByText (token monotonico contra out-of-order resolution) - #5 cleanup _patClickTimer em onBeforeUnmount - onHistoricoOpen valida inicio_em/fim_em antes de construir startH/endH (evita NaN propagation) - onPacientesPageChange ignora clicks durante loading (evita resolucao fora de ordem) - ESC no search popover limpa state via @hide handler centralizado - fcEvents em 1 passada (for loop) em vez de filter().filter().filter().map() — 4x mais rapido em listView mensal - pacientesIndex Map O(1) substitui 2 .find() sequenciais + cache extra pra fetch on-demand de patient (resolve dock/banner sumindo silenciosamente em clinicas >1k) - Bloqueio buttons :disabled quando M=null (standalone) - Teleport to=.melissa-dock guard com v-if M (evita warn em standalone) - Dedup em flight de getSessionCounts (Set _sessionFetchInflight) MelissaAgenda: 4181 -> 2851 linhas (-1330, -32%) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+379
-1709
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaAgendaActionsPopover — popover "Ações" da toolbar mobile
|
||||
* --------------------------------------------------------------
|
||||
* Aparece em <xl (1279px), substitui filtros + bloqueio que ficam
|
||||
* espalhados na toolbar desktop. Concentra:
|
||||
* - Visualização (dia/semana/mês/lista)
|
||||
* - Eventos (Apenas Sessões / Tudo)
|
||||
* - Horário (24h / 12h / Meu)
|
||||
* - Bloquear (4 atalhos: horário, período, dia, feriados)
|
||||
*
|
||||
* Pai expoe um botao ancora (`.ma-cal__btn--compact-only`) e chama
|
||||
* `actionsPopover.value.toggle($event)` no click.
|
||||
*
|
||||
* V-model bindings:
|
||||
* - v-model:calendar-view (string)
|
||||
* - v-model:only-sessions (boolean)
|
||||
* - v-model:time-mode (string)
|
||||
*
|
||||
* Props:
|
||||
* - timeModeOptions, onlySessionsOptions: arrays compartilhados com
|
||||
* a toolbar desktop (fonte unica no pai)
|
||||
*
|
||||
* Emit:
|
||||
* - bloqueio(mode: 'horario' | 'periodo' | 'dia' | 'feriados')
|
||||
* — pai chama M.openBloqueioDialog(mode)
|
||||
*
|
||||
* Exposto via defineExpose:
|
||||
* - toggle(event) — abre/fecha o popover
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import Popover from 'primevue/popover';
|
||||
|
||||
defineProps({
|
||||
calendarView: { type: String, required: true },
|
||||
onlySessions: { type: Boolean, default: false },
|
||||
timeMode: { type: String, required: true },
|
||||
timeModeOptions: { type: Array, required: true },
|
||||
onlySessionsOptions: { type: Array, required: true },
|
||||
// True quando o composable de agenda (M) nao esta disponivel —
|
||||
// typicamente em modo standalone (preview fora do MelissaLayout).
|
||||
// Os 4 botoes de bloqueio ficam disabled visualmente em vez de
|
||||
// emitir clicks que nao fazem nada.
|
||||
bloqueioDisabled: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:calendarView',
|
||||
'update:onlySessions',
|
||||
'update:timeMode',
|
||||
'bloqueio'
|
||||
]);
|
||||
|
||||
// View options — uso unico (so este componente). Mantenho local em vez
|
||||
// de duplicar no pai.
|
||||
const viewOptions = [
|
||||
{ label: 'Dia', value: 'dia' },
|
||||
{ label: 'Semana', value: 'semana' },
|
||||
{ label: 'Mês', value: 'mes' },
|
||||
{ label: 'Lista', value: 'lista' }
|
||||
];
|
||||
|
||||
const popRef = ref(null);
|
||||
|
||||
function toggle(event) {
|
||||
popRef.value?.toggle(event);
|
||||
}
|
||||
|
||||
function chamarBloqueio(mode) {
|
||||
// Fecha o popover antes de emitir — UX espelha o pattern original
|
||||
// (closeMobileActions + chamada no pai). PrimeVue Menu "popup" some
|
||||
// ao clicar num item; replicamos o mesmo aqui.
|
||||
try { popRef.value?.hide(); } catch {}
|
||||
emit('bloqueio', mode);
|
||||
}
|
||||
|
||||
defineExpose({ toggle });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover ref="popRef" class="ma-actions-pop">
|
||||
<div class="ma-actions flex flex-col gap-3.5 min-w-[260px] p-1">
|
||||
<!-- Visualização -->
|
||||
<div class="ma-actions__group flex flex-col gap-1.5">
|
||||
<div class="ma-actions__label uppercase tracking-[0.14em] text-[var(--text-color-secondary,var(--m-text-faint))] text-[0.62rem] font-semibold">Visualização</div>
|
||||
<SelectButton
|
||||
:model-value="calendarView"
|
||||
:options="viewOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="false"
|
||||
size="small"
|
||||
class="w-full"
|
||||
@update:model-value="emit('update:calendarView', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filtro de tipo de evento -->
|
||||
<div class="ma-actions__group flex flex-col gap-1.5">
|
||||
<div class="ma-actions__label uppercase tracking-[0.14em] text-[var(--text-color-secondary,var(--m-text-faint))] text-[0.62rem] font-semibold">Eventos</div>
|
||||
<SelectButton
|
||||
:model-value="onlySessions"
|
||||
:options="onlySessionsOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="false"
|
||||
size="small"
|
||||
class="w-full"
|
||||
@update:model-value="emit('update:onlySessions', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Modo de horário -->
|
||||
<div class="ma-actions__group flex flex-col gap-1.5">
|
||||
<div class="ma-actions__label uppercase tracking-[0.14em] text-[var(--text-color-secondary,var(--m-text-faint))] text-[0.62rem] font-semibold">Horário</div>
|
||||
<SelectButton
|
||||
:model-value="timeMode"
|
||||
:options="timeModeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="false"
|
||||
size="small"
|
||||
class="w-full"
|
||||
@update:model-value="emit('update:timeMode', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ma-actions__divider h-px bg-[var(--surface-border,var(--m-border))] -mx-1" />
|
||||
|
||||
<!-- Bloqueio (mantém como botões — são ações, não toggles) -->
|
||||
<div class="ma-actions__group flex flex-col gap-1.5">
|
||||
<div class="ma-actions__label uppercase tracking-[0.14em] text-[var(--text-color-secondary,var(--m-text-faint))] text-[0.62rem] font-semibold">Bloquear</div>
|
||||
<div class="ma-actions__buttons grid grid-cols-2 gap-1.5">
|
||||
<button
|
||||
:disabled="bloqueioDisabled"
|
||||
class="ma-actions__btn inline-flex items-center gap-2 px-2.5 py-2 bg-transparent border border-[var(--surface-border,var(--m-border))] rounded-lg text-[var(--text-color,var(--m-text))] text-[0.78rem] [font-family:inherit] cursor-pointer transition-[background-color,border-color] duration-[140ms] text-left hover:bg-[var(--surface-hover,var(--m-bg-soft-hover))] hover:border-[color-mix(in_srgb,var(--m-accent)_35%,var(--surface-border,var(--m-border)))] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:border-[var(--surface-border,var(--m-border))] [&>i]:text-[0.78rem] [&>i]:text-[var(--text-color-secondary,var(--m-text-muted))] [&>i]:flex-shrink-0"
|
||||
@click="chamarBloqueio('horario')"
|
||||
>
|
||||
<i class="pi pi-clock" /><span>Por horário</span>
|
||||
</button>
|
||||
<button
|
||||
:disabled="bloqueioDisabled"
|
||||
class="ma-actions__btn inline-flex items-center gap-2 px-2.5 py-2 bg-transparent border border-[var(--surface-border,var(--m-border))] rounded-lg text-[var(--text-color,var(--m-text))] text-[0.78rem] [font-family:inherit] cursor-pointer transition-[background-color,border-color] duration-[140ms] text-left hover:bg-[var(--surface-hover,var(--m-bg-soft-hover))] hover:border-[color-mix(in_srgb,var(--m-accent)_35%,var(--surface-border,var(--m-border)))] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:border-[var(--surface-border,var(--m-border))] [&>i]:text-[0.78rem] [&>i]:text-[var(--text-color-secondary,var(--m-text-muted))] [&>i]:flex-shrink-0"
|
||||
@click="chamarBloqueio('periodo')"
|
||||
>
|
||||
<i class="pi pi-calendar-clock" /><span>Por período</span>
|
||||
</button>
|
||||
<button
|
||||
:disabled="bloqueioDisabled"
|
||||
class="ma-actions__btn inline-flex items-center gap-2 px-2.5 py-2 bg-transparent border border-[var(--surface-border,var(--m-border))] rounded-lg text-[var(--text-color,var(--m-text))] text-[0.78rem] [font-family:inherit] cursor-pointer transition-[background-color,border-color] duration-[140ms] text-left hover:bg-[var(--surface-hover,var(--m-bg-soft-hover))] hover:border-[color-mix(in_srgb,var(--m-accent)_35%,var(--surface-border,var(--m-border)))] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:border-[var(--surface-border,var(--m-border))] [&>i]:text-[0.78rem] [&>i]:text-[var(--text-color-secondary,var(--m-text-muted))] [&>i]:flex-shrink-0"
|
||||
@click="chamarBloqueio('dia')"
|
||||
>
|
||||
<i class="pi pi-calendar-times" /><span>Dia inteiro</span>
|
||||
</button>
|
||||
<button
|
||||
:disabled="bloqueioDisabled"
|
||||
class="ma-actions__btn inline-flex items-center gap-2 px-2.5 py-2 bg-transparent border border-[var(--surface-border,var(--m-border))] rounded-lg text-[var(--text-color,var(--m-text))] text-[0.78rem] [font-family:inherit] cursor-pointer transition-[background-color,border-color] duration-[140ms] text-left hover:bg-[var(--surface-hover,var(--m-bg-soft-hover))] hover:border-[color-mix(in_srgb,var(--m-accent)_35%,var(--surface-border,var(--m-border)))] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:border-[var(--surface-border,var(--m-border))] [&>i]:text-[0.78rem] [&>i]:text-[var(--text-color-secondary,var(--m-text-muted))] [&>i]:flex-shrink-0"
|
||||
@click="chamarBloqueio('feriados')"
|
||||
>
|
||||
<i class="pi pi-star" /><span>Feriados</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -0,0 +1,327 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user