Files
agenciapsilmno/src/layout/melissa/MelissaBusca.vue
T
Leonardo 1bcb969f72 Layout Melissa (Direção B): preview, /profile, Agenda, dock, cadastro
Sandbox completo do novo layout Win11 lockscreen-style. Não troca o
AppLayout atual — Fase 5 (router wire-up) fica pra sessão dedicada.

Estrutura
- src/layout/melissa/ — MelissaLayout (bg+ψ+overlays), MelissaCronometro,
  MelissaAgenda (fullscreen), MelissaCard, MelissaMenu, MelissaBusca
- composables/useMelissaEventos.js — semana real do FC + range mensal
  pros dots do mini-cal
- composables/useMelissaPacientes.js — agora retorna created_at p/ "novo"
- melissaToques.js — toques Web Audio do término

Rota e persistência
- /preview/melissa (sem auth, sem AppLayout)
- /account/profile ganha 3º card "Melissa" com badge "Em construção"
- bootstrapUserSettings + layout composable aceitam variant='melissa'
- Migration: CHECK constraint user_settings.layout_variant aceita 'melissa'

Light mode
- Gradiente Bloom flipa via CSS vars (--bloom-c1/c2/base-1/base-2)
  Dark: 400/300/950 · Light: 200/100/0
- Cronômetro/Personalização: color: white → var(--m-text)
- Pílula psi-kbd ganha tokens --m-kbd-bg/--m-kbd-text
- Override mapeia text-X-200/300/400 → text-X-600 (17 cores Tailwind)

Agenda fullscreen
- Mini-cal funcional: click pula FC, range visível destacado, dots reais
- Feriados nacional/municipal/personalizado (rose/amber/violet)
- Dias fechados (workRules) cinza apagado, mutex feriado vence
- Card "Hoje" (stats+sessões) mesclado e movido pra sidebar esquerda
- ProximosFeriadosCard reaproveitado entre mini-cal e Hoje
- Avatar paciente: bg --m-accent-strong → --m-accent (saturado em light)
- Cores light: 12 substituições color:white → var(--m-text)

Dock taskbar Win11-style
- .melissa-dock 76px fixed bottom (CSS global, não scoped — Vue static
  hoisting perderia data-v-{hash})
- ψ centralizado vertical na faixa (bottom:10px)
- Chip cronômetro teleportado pro dock + animação minimize macOS
  (dialog encolhe + voa pro canto bottom-left, 340ms cubic-bezier)
- transform-origin: 96px calc(100% - 38px) (posição do chip no dock)

Pacientes na sidebar
- Botão fake "+" no topo abre PatientCreatePopover (rápido/completo/link)
- Reaproveita PatientCadastroDialog + ComponentCadastroRapido
- Pacientes criados nos últimos 7d sobem pro topo + badge "novo"

Dock contextual (ações do paciente selecionado)
- Avatar + nome + count + 5 ações (sessões/whatsapp/prontuário/editar/fechar)
- Teleportado pro .melissa-dock quando há paciente selecionado
- Em mobile, ações vivem em <Menu> kebab por linha
- Pattern <Transition><Teleport v-if> obrigatório (NUNCA o contrário)
  pra evitar comment placeholder + emitsOptions:null no reconciler

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:10:53 -03:00

428 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
/*
* MelissaBusca
* --------------------------------------------------
* Busca rápida glass-style. Adaptação do GlobalSearch da topbar
* (`src/components/search/GlobalSearch.vue`) pro layout Melissa.
*
* Diferenças vs. GlobalSearch:
* - Não chama Supabase — recebe pacientes/eventos via prop (preview)
* - Visual glass (white-on-glass) ao invés de surface-card
* - Sem loading state (busca client-side, instantâneo)
* - Emite ações pro parent decidir o que fazer
*
* Quando promover pra produção: trocar a busca por chamada à RPC
* `search_global` + manter a mesma estrutura de panel/items.
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
pacientes: { type: Array, default: () => [] },
eventos: { type: Array, default: () => [] },
atalhos: {
type: Array,
default: () => [
{ id: 'agenda', label: 'Agenda', icon: 'pi pi-calendar', sublabel: 'Sessões e compromissos', keywords: ['agenda', 'calendario', 'sessoes', 'hoje'] },
{ id: 'pacientes', label: 'Pacientes', icon: 'pi pi-users', sublabel: 'Cadastro e prontuários', keywords: ['pacientes', 'lista', 'cadastro'] },
{ id: 'conversas', label: 'WhatsApp', icon: 'pi pi-whatsapp', sublabel: 'Conversas em andamento', keywords: ['whatsapp', 'conversas', 'mensagens', 'chat'] },
{ id: 'financeiro', label: 'Financeiro', icon: 'pi pi-wallet', sublabel: 'Recebíveis e lançamentos', keywords: ['financeiro', 'pagamentos', 'cobrancas', 'dinheiro'] },
{ id: 'configuracoes', label: 'Configurações', icon: 'pi pi-cog', sublabel: 'Preferências e equipe', keywords: ['configuracoes', 'ajustes', 'preferencias', 'settings'] }
]
}
});
const emit = defineEmits(['acao', 'paciente', 'evento']);
const rootEl = ref(null);
const inputEl = ref(null);
const query = ref('');
const showPanel = ref(false);
const activeIndex = ref(-1);
function normalize(s) {
return String(s || '')
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.trim();
}
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')}`;
}
const filteredAtalhos = computed(() => {
const q = normalize(query.value);
if (!q) return props.atalhos.slice(0, 4); // 4 defaults quando vazio
return props.atalhos.filter((a) => {
const hay = normalize(a.label + ' ' + (a.keywords || []).join(' '));
return hay.includes(q);
}).slice(0, 5);
});
const filteredPacientes = computed(() => {
const q = normalize(query.value);
if (q.length < 2) return [];
return props.pacientes
.filter((p) => normalize(p.nome).includes(q))
.slice(0, 5);
});
const filteredEventos = computed(() => {
const q = normalize(query.value);
if (q.length < 2) return [];
return props.eventos
.filter((e) => {
const hay = normalize(
(e.label || '') + ' ' + (e.pacienteNome || '') + ' ' + (e.descricao || '')
);
return hay.includes(q);
})
.slice(0, 5);
});
const flatList = computed(() => {
const out = [];
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 }));
filteredEventos.value.forEach((e, i) => out.push({ group: 'eventos', item: e, idx: i }));
return out;
});
const hasAnyResult = computed(() => flatList.value.length > 0);
function findFlatIndex(group, idx) {
return flatList.value.findIndex((x) => x.group === group && x.idx === idx);
}
function selectEntry(entry) {
if (entry.group === 'atalhos') emit('acao', entry.item.id);
else if (entry.group === 'pacientes') emit('paciente', entry.item);
else if (entry.group === 'eventos') emit('evento', entry.item);
closePanel();
}
function closePanel() {
showPanel.value = false;
query.value = '';
activeIndex.value = -1;
inputEl.value?.blur();
}
function onFocus() {
showPanel.value = true;
}
function onClickOutside(e) {
if (rootEl.value && !rootEl.value.contains(e.target)) {
showPanel.value = false;
activeIndex.value = -1;
}
}
function onKeydown(e) {
if (!showPanel.value) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, flatList.value.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0);
} else if (e.key === 'Enter' && activeIndex.value >= 0) {
e.preventDefault();
selectEntry(flatList.value[activeIndex.value]);
} else if (e.key === 'Escape') {
// Stop bubbling pra ESC do parent não fechar overlay aleatório
e.stopPropagation();
closePanel();
}
}
function onGlobalKeydown(e) {
// Ctrl+K / ⌘+K → foca input
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
showPanel.value = true;
inputEl.value?.focus();
}
}
onMounted(() => {
document.addEventListener('mousedown', onClickOutside);
window.addEventListener('keydown', onGlobalKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onClickOutside);
window.removeEventListener('keydown', onGlobalKeydown);
});
</script>
<template>
<div ref="rootEl" class="mb-search">
<div class="mb-field">
<i class="pi pi-search mb-field__icon" />
<input
ref="inputEl"
v-model="query"
type="text"
placeholder="Buscar paciente, agenda, atalho…"
class="mb-field__input"
@focus="onFocus"
@keydown="onKeydown"
/>
<span class="mb-field__kbd" aria-hidden="true">Ctrl K</span>
</div>
<Transition name="mb-fade">
<div v-if="showPanel" class="mb-panel" role="listbox">
<div
v-if="query.trim().length >= 2 && !hasAnyResult"
class="mb-empty"
>
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
</div>
<!-- Atalhos -->
<div v-if="filteredAtalhos.length" class="mb-group">
<div class="mb-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
<button
v-for="(a, i) in filteredAtalhos"
:key="'a-' + a.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('atalhos', i) === activeIndex }"
@click="selectEntry({ group: 'atalhos', item: a })"
@mouseenter="activeIndex = findFlatIndex('atalhos', i)"
>
<span class="mb-item__icon"><i :class="a.icon" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ a.label }}</span>
<span v-if="a.sublabel" class="mb-item__sub">{{ a.sublabel }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Pacientes -->
<div v-if="filteredPacientes.length" class="mb-group">
<div class="mb-group__title">Pacientes</div>
<button
v-for="(p, i) in filteredPacientes"
:key="'p-' + p.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('pacientes', i) === activeIndex }"
@click="selectEntry({ group: 'pacientes', item: p })"
@mouseenter="activeIndex = findFlatIndex('pacientes', i)"
>
<span class="mb-item__icon"><i class="pi pi-user" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ p.nome }}</span>
<span class="mb-item__sub">Abrir prontuário</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Eventos -->
<div v-if="filteredEventos.length" class="mb-group">
<div class="mb-group__title">Agenda de hoje</div>
<button
v-for="(e, i) in filteredEventos"
:key="'e-' + e.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('eventos', i) === activeIndex }"
@click="selectEntry({ group: 'eventos', item: e })"
@mouseenter="activeIndex = findFlatIndex('eventos', i)"
>
<span
class="mb-item__icon"
:style="{ backgroundColor: `${e.color}33`, color: e.color }"
>
<i class="pi pi-clock" />
</span>
<span class="mb-item__main">
<span class="mb-item__label">{{ e.label }}</span>
<span class="mb-item__sub">{{ fmtHora(e.startH) }} {{ fmtHora(e.endH) }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.mb-search {
position: relative;
width: 100%;
max-width: 560px;
/* Só horizontal — top/bottom ficam livres pro parent (mt-X do Tailwind) */
margin-left: auto;
margin-right: auto;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mb-field {
position: relative;
display: flex;
align-items: center;
background: var(--m-bg-soft);
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
border: 1px solid var(--m-border-strong);
border-radius: 12px;
padding: 0 14px;
height: 44px;
transition: background-color 160ms ease, border-color 160ms ease;
}
.mb-field:focus-within {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mb-field__icon {
color: var(--m-text-muted);
font-size: 0.95rem;
margin-right: 10px;
flex-shrink: 0;
}
.mb-field__input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: white;
font-size: 0.9rem;
font-family: inherit;
min-width: 0;
}
.mb-field__input::placeholder {
color: var(--m-text-muted);
}
.mb-field__kbd {
color: var(--m-text-muted);
font-size: 0.62rem;
font-weight: 500;
padding: 2px 7px;
border-radius: 4px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
margin-left: 8px;
flex-shrink: 0;
letter-spacing: 0.05em;
}
.mb-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 30;
max-height: 60vh;
overflow-y: auto;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--m-border-strong);
border-radius: 12px;
padding: 6px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mb-panel::-webkit-scrollbar { width: 6px; }
.mb-panel::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mb-empty {
padding: 18px 14px;
text-align: center;
color: var(--m-text-muted);
font-size: 0.85rem;
}
.mb-group + .mb-group {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--m-border);
}
.mb-group__title {
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--m-text-faint);
font-size: 0.6rem;
font-weight: 600;
padding: 8px 10px 4px;
}
.mb-item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: transparent;
border: none;
border-radius: 8px;
color: white;
text-align: left;
cursor: pointer;
font-family: inherit;
transition: background-color 100ms ease;
}
.mb-item:hover,
.mb-item.is-active {
background: var(--m-bg-soft);
}
.mb-item__icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
background: var(--m-bg-soft);
border-radius: 7px;
color: var(--m-text-muted);
flex-shrink: 0;
font-size: 0.85rem;
}
.mb-item__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.mb-item__label {
font-size: 0.85rem;
color: white;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-item__sub {
font-size: 0.7rem;
color: var(--m-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-item__go {
color: var(--m-text-faint);
font-size: 0.7rem;
flex-shrink: 0;
}
.mb-fade-enter-active,
.mb-fade-leave-active {
transition: opacity 140ms ease, transform 160ms ease;
}
.mb-fade-enter-from,
.mb-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>