Files
agenciapsilmno/src/layout/melissa/MelissaBusca.vue
T
Leonardo 30367392ff busca melissa: troca popover por Dialog Spotlight (CMD+K pattern)
Refatora MelissaBusca pra usar PrimeVue Dialog em vez de popover
absolute. Resolve definitivamente o bug do panel estourar viewport
quando ha muitos resultados.

Mudancas:

1. Trigger no dock: input -> <button> com aparencia de input. Clica
   ou Ctrl+K abre Dialog. Mantem placeholder + Ctrl+K kbd hint.

2. Dialog Spotlight: 640px max-width, posicionado 10vh do topo
   (estilo Spotlight macOS / Linear / GitHub). Backdrop blur escuro,
   dismissable mask, sem header, sem closable button (Esc cobre).

3. Input REAL dentro do Dialog: autofocus on open via nextTick.
   Mantem v-model="query" + @keydown="onKeydown" (Arrow/Enter).

4. Panel de resultados: era position:absolute com max-height:60vh
   (estourava em layouts com input perto do bottom). Agora vive
   DENTRO do Dialog (flex:1, max-height:70vh no content), scroll
   interno garantido por design — conteudo NUNCA passa do bottom
   da pagina.

5. Remove: onClickOutside (dismissableMask cobre), Transition
   mb-fade (Dialog tem sua animacao).

Comportamento end-user identico (Ctrl+K, navegacao com setas, Enter
seleciona, Esc fecha) mas visual + manutencao muito melhor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:52:19 -03:00

693 lines
26 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, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useRecentPatients } from '@/composables/useRecentPatients';
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', 'documento', 'intake']);
const rootEl = ref(null);
const inputEl = ref(null);
const query = ref('');
const showPanel = ref(false);
const activeIndex = ref(-1);
// RPC search_global results — preenchidos por debounce conforme o usuário digita.
// Cliente-side (props.pacientes / props.eventos) continua sendo fallback rápido
// pra digitação curta (1 char) e como complemento aos primeiros resultados.
const rpcResults = ref({ patients: [], appointments: [], documents: [], services: [], intakes: [] });
const searching = ref(false);
let debounceT = null;
let searchSeq = 0;
// Recently viewed (localStorage) — só aparece quando o input está vazio
const { items: recentPatients, hasItems: hasRecentPatients } = useRecentPatients();
const showRecent = computed(() => !query.value.trim() && hasRecentPatients.value);
const recentItems = computed(() => showRecent.value ? (recentPatients.value || []).slice(0, 5) : []);
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);
});
// Pacientes — combina RPC (autoritativo, todos os pacientes) com props (preview de hoje).
// RPC tem prioridade; props complementa quando RPC ainda não trouxe nada.
//
// Shape do RPC search_global (patients): { id, label, sublabel, avatar_url, deeplink, score }
// label = nome_completo; sublabel = email_principal ou telefone.
const filteredPacientes = computed(() => {
const q = normalize(query.value);
if (q.length < 2) return [];
const rpc = rpcResults.value.patients || [];
if (rpc.length) {
return rpc.slice(0, 5).map(p => ({
id: p.id,
nome: p.label || '(sem nome)',
sub: p.sublabel || '',
avatar_url: p.avatar_url || null
}));
}
// Fallback client-side (props.pacientes vem do MelissaLayout — shape diferente)
return props.pacientes
.filter((p) => normalize(p.nome).includes(q))
.slice(0, 5)
.map(p => ({ id: p.id, nome: p.nome, sub: '', avatar_url: null }));
});
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);
});
// Resultados exclusivos da RPC (não há fallback client-side)
const rpcAppointments = computed(() => rpcResults.value.appointments || []);
const rpcDocuments = computed(() => rpcResults.value.documents || []);
const rpcIntakes = computed(() => rpcResults.value.intakes || []);
const flatList = computed(() => {
const out = [];
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 }));
filteredEventos.value.forEach((e, i) => out.push({ group: 'eventos', item: e, idx: i }));
rpcAppointments.value.forEach((a, i) => out.push({ group: 'rpc-appointments', item: a, idx: i }));
rpcDocuments.value.forEach((d, i) => out.push({ group: 'rpc-documents', item: d, idx: i }));
rpcIntakes.value.forEach((r, i) => out.push({ group: 'rpc-intakes', item: r, 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 === '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 === 'rpc-appointments') {
// Sessão da RPC: deeplink pra agenda com evento focado
emit('evento', { id: entry.item.id, deeplink: entry.item.deeplink });
} else if (entry.group === 'rpc-documents') {
// Documento da RPC: extrai patient_id da deeplink se possível
const dl = entry.item.deeplink || '';
const m = dl.match(/patients\/([0-9a-f-]+)/i);
emit('documento', { id: entry.item.id, patient_id: m?.[1] || null, label: entry.item.label });
} else if (entry.group === 'rpc-intakes') {
emit('intake', entry.item);
}
closePanel();
}
function closePanel() {
showPanel.value = false;
query.value = '';
activeIndex.value = -1;
}
function openDialog() {
showPanel.value = true;
// Foca input do Dialog após ele montar
nextTick(() => inputEl.value?.focus());
}
function onKeydown(e) {
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]);
}
// Escape é tratado pelo Dialog (dismissableMask + closable)
}
function onGlobalKeydown(e) {
// Ctrl+K / ⌘+K → abre dialog
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
openDialog();
}
}
// ── RPC search_global debounced ───────────────────────────────────────────
// Mesmo padrão do GlobalSearch.vue: query >= 2 chars dispara em 200ms,
// controla ordem via searchSeq pra ignorar respostas obsoletas.
function resetRpcResults() {
rpcResults.value = { patients: [], appointments: [], documents: [], services: [], intakes: [] };
}
watch(query, (v) => {
if (debounceT) clearTimeout(debounceT);
const q = String(v || '').trim();
if (q.length < 2) {
resetRpcResults();
searching.value = false;
return;
}
searching.value = true;
const mySeq = ++searchSeq;
debounceT = setTimeout(async () => {
try {
const { data, error } = await supabase.rpc('search_global', { p_q: q, p_limit: 6 });
if (mySeq !== searchSeq) return;
if (error) {
console.error('[MelissaBusca search_global]', error);
resetRpcResults();
} else {
rpcResults.value = {
patients: Array.isArray(data?.patients) ? data.patients : [],
appointments: Array.isArray(data?.appointments) ? data.appointments : [],
documents: Array.isArray(data?.documents) ? data.documents : [],
services: Array.isArray(data?.services) ? data.services : [],
intakes: Array.isArray(data?.intakes) ? data.intakes : []
};
}
} catch (e) {
if (mySeq !== searchSeq) return;
console.error('[MelissaBusca search_global exception]', e);
resetRpcResults();
} finally {
if (mySeq === searchSeq) searching.value = false;
}
}, 200);
});
onMounted(() => {
window.addEventListener('keydown', onGlobalKeydown);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', onGlobalKeydown);
if (debounceT) clearTimeout(debounceT);
});
</script>
<template>
<div ref="rootEl" class="mb-search">
<!-- Trigger: aparência de input, mas é botão (abre Dialog) -->
<button type="button" class="mb-field" @click="openDialog" :aria-label="'Buscar (Ctrl+K)'">
<i class="pi pi-search mb-field__icon" />
<span class="mb-field__placeholder">Buscar paciente, agenda, atalho</span>
<span class="mb-field__kbd" aria-hidden="true">Ctrl K</span>
</button>
<!-- Dialog Spotlight: input grande no topo + resultados em coluna -->
<Dialog
v-model:visible="showPanel"
modal
:draggable="false"
:closable="false"
:dismissableMask="true"
:showHeader="false"
class="mb-dialog"
:style="{ width: '640px', maxWidth: '94vw' }"
pt:mask:class="mb-dialog__mask"
pt:content:class="mb-dialog__content"
@hide="closePanel"
>
<!-- Field do Dialog (input real, autofocus) -->
<div class="mb-dialog__field">
<i class="pi pi-search mb-dialog__field-icon" />
<input
ref="inputEl"
v-model="query"
type="text"
placeholder="Buscar paciente, agenda, atalho…"
class="mb-dialog__input"
@keydown="onKeydown"
autocomplete="off"
spellcheck="false"
/>
<span class="mb-dialog__esc" aria-hidden="true">Esc</span>
</div>
<!-- Painel de resultados (scroll interno) -->
<div 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>
<!-- Acessados recentemente ( quando query vazia) -->
<div v-if="showRecent" class="mb-group">
<div class="mb-group__title">Acessados recentemente</div>
<button
v-for="(p, i) in recentItems"
:key="'mr-' + p.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('recent', i) === activeIndex }"
@click="selectEntry({ group: 'recent', item: p })"
@mouseenter="activeIndex = findFlatIndex('recent', i)"
>
<span class="mb-item__icon"><i class="pi pi-history" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ p.nome }}</span>
<span class="mb-item__sub">{{ p.extras?.telefone || p.extras?.email || 'Abrir prontuário' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</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 mb-item__icon--patient"><i class="pi pi-user" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ p.nome }}</span>
<span class="mb-item__sub">{{ p.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>
<!-- RPC: Sessões/agendamentos (qualquer data)
RPC retorna { id, label, sublabel, deeplink }. Sublabel ja vem
com "Paciente · dd/mm/yyyy HH:MM". Cor do icone = cor de sessao
(indigo-500, igual ao pickColor() padrao). -->
<div v-if="rpcAppointments.length" class="mb-group">
<div class="mb-group__title">Sessões</div>
<button
v-for="(e, i) in rpcAppointments"
:key="'rpc-a-' + e.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('rpc-appointments', i) === activeIndex }"
@click="selectEntry({ group: 'rpc-appointments', item: e })"
@mouseenter="activeIndex = findFlatIndex('rpc-appointments', i)"
>
<span class="mb-item__icon mb-item__icon--sessao"><i class="pi pi-calendar" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ e.label || 'Sessão' }}</span>
<span class="mb-item__sub">{{ e.sublabel || 'Sem detalhes' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- RPC: Documentos -->
<div v-if="rpcDocuments.length" class="mb-group">
<div class="mb-group__title">Documentos</div>
<button
v-for="(d, i) in rpcDocuments"
:key="'rpc-d-' + d.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('rpc-documents', i) === activeIndex }"
@click="selectEntry({ group: 'rpc-documents', item: d })"
@mouseenter="activeIndex = findFlatIndex('rpc-documents', i)"
>
<span class="mb-item__icon mb-item__icon--doc"><i class="pi pi-file" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ d.label || 'Documento' }}</span>
<span class="mb-item__sub">{{ d.sublabel || '' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- RPC: Cadastros recebidos (intakes) -->
<div v-if="rpcIntakes.length" class="mb-group">
<div class="mb-group__title">Cadastros recebidos</div>
<button
v-for="(r, i) in rpcIntakes"
:key="'rpc-i-' + r.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('rpc-intakes', i) === activeIndex }"
@click="selectEntry({ group: 'rpc-intakes', item: r })"
@mouseenter="activeIndex = findFlatIndex('rpc-intakes', i)"
>
<span class="mb-item__icon mb-item__icon--intake"><i class="pi pi-inbox" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ r.label || 'Cadastro' }}</span>
<span class="mb-item__sub">{{ r.sublabel || '' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
</div>
</Dialog>
</div>
</template>
<style scoped>
.mb-search {
position: relative;
width: 100%;
max-width: 560px;
margin-left: auto;
margin-right: auto;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
/* ─── Trigger no dock (visual de input, mas é botão que abre Dialog) ─── */
.mb-field {
position: relative;
display: flex;
align-items: center;
width: 100%;
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;
cursor: pointer;
text-align: left;
transition: background-color 160ms ease, border-color 160ms ease;
}
.mb-field:hover {
background: var(--m-bg-soft-hover);
}
.mb-field__icon {
color: var(--m-text-muted);
font-size: 0.95rem;
margin-right: 10px;
flex-shrink: 0;
}
.mb-field__placeholder {
flex: 1;
color: var(--m-text-muted);
font-size: 0.9rem;
font-family: inherit;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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;
}
/* ─── Dialog Spotlight (PrimeVue Dialog customizado) ─── */
:global(.mb-dialog__mask) {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(0, 0, 0, 0.55) !important;
}
:global(.mb-dialog) {
border-radius: 14px !important;
overflow: hidden;
/* Posiciona mais alto que o centro (estilo Spotlight) */
margin-top: 10vh !important;
align-self: flex-start;
}
:global(.mb-dialog .mb-dialog__content) {
padding: 0 !important;
background: var(--surface-card) !important;
border-radius: 14px !important;
overflow: hidden;
max-height: 70vh;
display: flex;
flex-direction: column;
}
.mb-dialog__field {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-card);
flex-shrink: 0;
}
.mb-dialog__field-icon {
color: var(--text-color-secondary);
font-size: 1.05rem;
flex-shrink: 0;
}
.mb-dialog__input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-color);
font-size: 1.05rem;
font-family: inherit;
min-width: 0;
}
.mb-dialog__input::placeholder {
color: var(--text-color-secondary);
opacity: 0.7;
}
.mb-dialog__esc {
color: var(--text-color-secondary);
font-size: 0.65rem;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
background: var(--surface-ground);
border: 1px solid var(--surface-border);
flex-shrink: 0;
letter-spacing: 0.05em;
opacity: 0.8;
}
/* Panel agora vive DENTRO do Dialog (não mais absolute). Scroll interno
resolve o bug de overflow — o conteúdo nunca passa do bottom porque
o Dialog tem max-height e o panel é flex:1. */
.mb-panel {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
padding: 6px;
background: var(--surface-card);
scrollbar-width: thin;
scrollbar-color: var(--surface-border) transparent;
min-height: 0; /* permite shrink no flex */
}
.mb-panel::-webkit-scrollbar { width: 6px; }
.mb-panel::-webkit-scrollbar-thumb {
background: var(--surface-border);
border-radius: 3px;
}
.mb-empty {
padding: 18px 14px;
text-align: center;
color: var(--text-color-secondary);
font-size: 0.85rem;
}
.mb-group + .mb-group {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--surface-border);
}
.mb-group__title {
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--text-color-secondary);
font-size: 0.62rem;
font-weight: 700;
padding: 8px 10px 4px;
opacity: 0.75;
}
.mb-item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
background: transparent;
border: none;
border-radius: 8px;
color: var(--text-color);
text-align: left;
cursor: pointer;
font-family: inherit;
transition: background-color 100ms ease;
}
.mb-item:hover,
.mb-item.is-active {
background: color-mix(in srgb, var(--p-primary-color) 8%, transparent);
}
.mb-item__icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
background: var(--surface-ground);
border-radius: 7px;
color: var(--text-color-secondary);
flex-shrink: 0;
font-size: 0.9rem;
}
/* Cores por tipo — semântica fixa (não depende do tema, é categoria). */
.mb-item__icon--patient {
background: rgba(244, 114, 182, 0.18);
color: #ec4899;
}
.mb-item__icon--sessao {
background: rgba(99, 102, 241, 0.20);
color: #6366f1;
}
.mb-item__icon--doc {
background: rgba(14, 165, 233, 0.18);
color: #0ea5e9;
}
.mb-item__icon--intake {
background: rgba(251, 146, 60, 0.18);
color: #f97316;
}
/* Dark mode: clareia as cores semânticas pra manter contraste */
:root.app-dark .mb-item__icon--patient { color: #f9a8d4; }
:root.app-dark .mb-item__icon--sessao { color: #a5b4fc; }
:root.app-dark .mb-item__icon--doc { color: #7dd3fc; }
:root.app-dark .mb-item__icon--intake { color: #fdba74; }
.mb-item__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.mb-item__label {
font-size: 0.88rem;
font-weight: 500;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-item__sub {
font-size: 0.74rem;
color: var(--text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-item__go {
color: var(--text-color-secondary);
opacity: 0.5;
font-size: 0.75rem;
flex-shrink: 0;
}
</style>