1bcb969f72
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>
428 lines
14 KiB
Vue
428 lines
14 KiB
Vue
<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>
|