Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras
This commit is contained in:
+403
-383
@@ -16,463 +16,483 @@
|
||||
-->
|
||||
<!-- Painel expansível do Layout 2 -->
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
import { useLayout } from './composables/layout'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useMenuBadges } from '@/composables/useMenuBadges'
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { useLayout } from './composables/layout';
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore';
|
||||
import { useMenuBadges } from '@/composables/useMenuBadges';
|
||||
|
||||
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue'
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
|
||||
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
|
||||
|
||||
const menuStore = useMenuStore()
|
||||
const { layoutState } = useLayout()
|
||||
const entitlements = useEntitlementsStore()
|
||||
const menuBadges = useMenuBadges()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const menuStore = useMenuStore();
|
||||
const { layoutState } = useLayout();
|
||||
const entitlements = useEntitlementsStore();
|
||||
const menuBadges = useMenuBadges();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
function menuBadgeLabel (item) {
|
||||
const key = item?.badgeKey
|
||||
if (!key) return null
|
||||
const val = menuBadges[key]?.value || 0
|
||||
if (!val) return null
|
||||
return key === 'agendaHoje' ? `${val} hoje` : String(val)
|
||||
function menuBadgeLabel(item) {
|
||||
const key = item?.badgeKey;
|
||||
if (!key) return null;
|
||||
const val = menuBadges[key]?.value || 0;
|
||||
if (!val) return null;
|
||||
return key === 'agendaHoje' ? `${val} hoje` : String(val);
|
||||
}
|
||||
|
||||
// ── Seção ativa ──────────────────────────────────────────────
|
||||
const currentSection = computed(() => {
|
||||
const model = menuStore.model || []
|
||||
return model.find(s => s.label === layoutState.railSectionKey) || null
|
||||
})
|
||||
const model = menuStore.model || [];
|
||||
return model.find((s) => s.label === layoutState.railSectionKey) || null;
|
||||
});
|
||||
|
||||
// Todos os grupos do menu
|
||||
const allSections = computed(() => {
|
||||
const model = menuStore.model || []
|
||||
return model.filter(s => s.label && Array.isArray(s.items) && s.items.length)
|
||||
})
|
||||
const model = menuStore.model || [];
|
||||
return model.filter((s) => s.label && Array.isArray(s.items) && s.items.length);
|
||||
});
|
||||
|
||||
// "Início" = chave especial __home__
|
||||
const isHome = computed(() => layoutState.railSectionKey === '__home__')
|
||||
const isHome = computed(() => layoutState.railSectionKey === '__home__');
|
||||
|
||||
// Seções visíveis: tudo em Início, só a selecionada nos demais
|
||||
const visibleSections = computed(() =>
|
||||
isHome.value ? allSections.value : (currentSection.value ? [currentSection.value] : [])
|
||||
)
|
||||
const visibleSections = computed(() => (isHome.value ? allSections.value : currentSection.value ? [currentSection.value] : []));
|
||||
|
||||
const panelTitle = computed(() =>
|
||||
isHome.value ? 'Início' : currentSection.value?.label || 'Menu'
|
||||
)
|
||||
const panelTitle = computed(() => (isHome.value ? 'Início' : currentSection.value?.label || 'Menu'));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
function isLocked (item) {
|
||||
if (!item.proBadge || !item.feature) return false
|
||||
try { return !entitlements.has(item.feature) } catch { return false }
|
||||
function isLocked(item) {
|
||||
if (!item.proBadge || !item.feature) return false;
|
||||
try {
|
||||
return !entitlements.has(item.feature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function toPath (to) {
|
||||
if (!to) return ''
|
||||
if (typeof to === 'string') return to
|
||||
try { return router.resolve(to).path || '' } catch { return '' }
|
||||
function toPath(to) {
|
||||
if (!to) return '';
|
||||
if (typeof to === 'string') return to;
|
||||
try {
|
||||
return router.resolve(to).path || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isActive (item) {
|
||||
const active = String(layoutState.activePath || route.path || '')
|
||||
if (!item.to) return false
|
||||
const p = toPath(item.to)
|
||||
if (!p) return false
|
||||
if (active === p) return true
|
||||
const segments = p.split('/').filter(Boolean)
|
||||
return segments.length >= 2 && active.startsWith(p + '/')
|
||||
function isActive(item) {
|
||||
const active = String(layoutState.activePath || route.path || '');
|
||||
if (!item.to) return false;
|
||||
const p = toPath(item.to);
|
||||
if (!p) return false;
|
||||
if (active === p) return true;
|
||||
const segments = p.split('/').filter(Boolean);
|
||||
return segments.length >= 2 && active.startsWith(p + '/');
|
||||
}
|
||||
|
||||
function navigate (item) {
|
||||
if (isLocked(item)) {
|
||||
router.push({ name: 'upgrade', query: { feature: item.feature || '' } })
|
||||
return
|
||||
}
|
||||
if (item.to) {
|
||||
layoutState.activePath = typeof item.to === 'string' ? item.to : router.resolve(item.to).path
|
||||
router.push(item.to)
|
||||
}
|
||||
function navigate(item) {
|
||||
if (isLocked(item)) {
|
||||
router.push({ name: 'upgrade', query: { feature: item.feature || '' } });
|
||||
return;
|
||||
}
|
||||
if (item.to) {
|
||||
layoutState.activePath = typeof item.to === 'string' ? item.to : router.resolve(item.to).path;
|
||||
router.push(item.to);
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel () {
|
||||
layoutState.railPanelOpen = false
|
||||
function closePanel() {
|
||||
layoutState.railPanelOpen = false;
|
||||
}
|
||||
|
||||
// ── QuickCreate (Pacientes) ───────────────────────────────
|
||||
const createPopover = ref(null)
|
||||
const quickDialog = ref(false)
|
||||
const createPopover = ref(null);
|
||||
const quickDialog = ref(false);
|
||||
|
||||
function openQuickCreate (event, item) {
|
||||
createPopover.value?.toggle(event)
|
||||
function openQuickCreate(event, item) {
|
||||
createPopover.value?.toggle(event);
|
||||
}
|
||||
function onQuickCreate() {
|
||||
quickDialog.value = true;
|
||||
}
|
||||
function onQuickCreate () { quickDialog.value = true }
|
||||
|
||||
// ── Busca (todo o menu) ──────────────────────────────────────
|
||||
const query = ref('')
|
||||
const showResults = ref(false)
|
||||
const activeIndex = ref(-1)
|
||||
const forcedOpen = ref(false)
|
||||
const searchEl = ref(null)
|
||||
const searchWrapEl = ref(null)
|
||||
const query = ref('');
|
||||
const showResults = ref(false);
|
||||
const activeIndex = ref(-1);
|
||||
const forcedOpen = ref(false);
|
||||
const searchEl = ref(null);
|
||||
const searchWrapEl = ref(null);
|
||||
|
||||
const RECENT_KEY = 'menu_search_recent'
|
||||
const recent = ref([])
|
||||
function loadRecent () {
|
||||
try { recent.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') } catch { recent.value = [] }
|
||||
const RECENT_KEY = 'menu_search_recent';
|
||||
const recent = ref([]);
|
||||
function loadRecent() {
|
||||
try {
|
||||
recent.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
|
||||
} catch {
|
||||
recent.value = [];
|
||||
}
|
||||
}
|
||||
function saveRecent (q) {
|
||||
const v = String(q || '').trim()
|
||||
if (!v) return
|
||||
const list = [v, ...recent.value.filter(x => x !== v)].slice(0, 8)
|
||||
recent.value = list
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(list))
|
||||
function saveRecent(q) {
|
||||
const v = String(q || '').trim();
|
||||
if (!v) return;
|
||||
const list = [v, ...recent.value.filter((x) => x !== v)].slice(0, 8);
|
||||
recent.value = list;
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(list));
|
||||
}
|
||||
function clearRecent () {
|
||||
recent.value = []
|
||||
try { localStorage.removeItem(RECENT_KEY) } catch {}
|
||||
function clearRecent() {
|
||||
recent.value = [];
|
||||
try {
|
||||
localStorage.removeItem(RECENT_KEY);
|
||||
} catch {}
|
||||
}
|
||||
loadRecent()
|
||||
loadRecent();
|
||||
|
||||
watch(query, (v) => {
|
||||
const hasText = !!v?.trim()
|
||||
if (hasText) { forcedOpen.value = false; showResults.value = true; return }
|
||||
showResults.value = forcedOpen.value
|
||||
})
|
||||
|
||||
function clearSearch () {
|
||||
query.value = ''
|
||||
activeIndex.value = -1
|
||||
showResults.value = false
|
||||
forcedOpen.value = false
|
||||
}
|
||||
|
||||
function norm (s) {
|
||||
return String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').trim()
|
||||
}
|
||||
|
||||
function isVisibleItem (it) {
|
||||
const v = it?.visible
|
||||
if (typeof v === 'function') return !!v()
|
||||
if (v === undefined || v === null) return true
|
||||
return v !== false
|
||||
}
|
||||
|
||||
function flattenMenu (items, trail = []) {
|
||||
const out = []
|
||||
for (const it of (items || [])) {
|
||||
if (!isVisibleItem(it)) continue
|
||||
const nextTrail = [...trail, it?.label].filter(Boolean)
|
||||
if (it?.to && !it?.items?.length) {
|
||||
out.push({ label: it.label || it.to, to: it.to, icon: it.icon, trail: nextTrail, proBadge: !!(it.__showProBadge ?? it.proBadge), feature: it.feature || null })
|
||||
const hasText = !!v?.trim();
|
||||
if (hasText) {
|
||||
forcedOpen.value = false;
|
||||
showResults.value = true;
|
||||
return;
|
||||
}
|
||||
if (it?.items?.length) out.push(...flattenMenu(it.items, nextTrail))
|
||||
}
|
||||
return out
|
||||
showResults.value = forcedOpen.value;
|
||||
});
|
||||
|
||||
function clearSearch() {
|
||||
query.value = '';
|
||||
activeIndex.value = -1;
|
||||
showResults.value = false;
|
||||
forcedOpen.value = false;
|
||||
}
|
||||
|
||||
const allLinks = computed(() => flattenMenu(menuStore.model || []))
|
||||
function norm(s) {
|
||||
return String(s || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isVisibleItem(it) {
|
||||
const v = it?.visible;
|
||||
if (typeof v === 'function') return !!v();
|
||||
if (v === undefined || v === null) return true;
|
||||
return v !== false;
|
||||
}
|
||||
|
||||
function flattenMenu(items, trail = []) {
|
||||
const out = [];
|
||||
for (const it of items || []) {
|
||||
if (!isVisibleItem(it)) continue;
|
||||
const nextTrail = [...trail, it?.label].filter(Boolean);
|
||||
if (it?.to && !it?.items?.length) {
|
||||
out.push({ label: it.label || it.to, to: it.to, icon: it.icon, trail: nextTrail, proBadge: !!(it.__showProBadge ?? it.proBadge), feature: it.feature || null });
|
||||
}
|
||||
if (it?.items?.length) out.push(...flattenMenu(it.items, nextTrail));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const allLinks = computed(() => flattenMenu(menuStore.model || []));
|
||||
|
||||
const results = computed(() => {
|
||||
const q = norm(query.value)
|
||||
if (!q) return []
|
||||
const wantPro = q === 'pro' || q.startsWith('pro ') || q.includes(' pro')
|
||||
return allLinks.value
|
||||
.filter(r => {
|
||||
const hay = `${norm(r.label)} ${norm(r.trail.join(' > '))} ${norm(r.to)}`
|
||||
if (hay.includes(q)) return true
|
||||
if (wantPro && (r.proBadge || r.feature)) return true
|
||||
return false
|
||||
})
|
||||
.slice(0, 12)
|
||||
})
|
||||
const q = norm(query.value);
|
||||
if (!q) return [];
|
||||
const wantPro = q === 'pro' || q.startsWith('pro ') || q.includes(' pro');
|
||||
return allLinks.value
|
||||
.filter((r) => {
|
||||
const hay = `${norm(r.label)} ${norm(r.trail.join(' > '))} ${norm(r.to)}`;
|
||||
if (hay.includes(q)) return true;
|
||||
if (wantPro && (r.proBadge || r.feature)) return true;
|
||||
return false;
|
||||
})
|
||||
.slice(0, 12);
|
||||
});
|
||||
|
||||
watch(results, (list) => { activeIndex.value = list.length ? 0 : -1 })
|
||||
watch(results, (list) => {
|
||||
activeIndex.value = list.length ? 0 : -1;
|
||||
});
|
||||
|
||||
function escapeHtml (s) {
|
||||
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
||||
function escapeHtml(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
function highlight (text, q) {
|
||||
const queryNorm = norm(q)
|
||||
const raw = String(text || '')
|
||||
if (!queryNorm) return escapeHtml(raw)
|
||||
const rawNorm = norm(raw)
|
||||
const idx = rawNorm.indexOf(queryNorm)
|
||||
if (idx < 0) return escapeHtml(raw)
|
||||
const before = escapeHtml(raw.slice(0, idx))
|
||||
const mid = escapeHtml(raw.slice(idx, idx + queryNorm.length))
|
||||
const after = escapeHtml(raw.slice(idx + queryNorm.length))
|
||||
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`
|
||||
function highlight(text, q) {
|
||||
const queryNorm = norm(q);
|
||||
const raw = String(text || '');
|
||||
if (!queryNorm) return escapeHtml(raw);
|
||||
const rawNorm = norm(raw);
|
||||
const idx = rawNorm.indexOf(queryNorm);
|
||||
if (idx < 0) return escapeHtml(raw);
|
||||
const before = escapeHtml(raw.slice(0, idx));
|
||||
const mid = escapeHtml(raw.slice(idx, idx + queryNorm.length));
|
||||
const after = escapeHtml(raw.slice(idx + queryNorm.length));
|
||||
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`;
|
||||
}
|
||||
|
||||
function onSearchKeydown (e) {
|
||||
if (e.key === 'Escape') { showResults.value = false; forcedOpen.value = false; return }
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (!results.value.length) return
|
||||
showResults.value = true
|
||||
activeIndex.value = (activeIndex.value + 1) % results.value.length
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (!results.value.length) return
|
||||
showResults.value = true
|
||||
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' && showResults.value && results.value.length && activeIndex.value >= 0) {
|
||||
e.preventDefault()
|
||||
goToResult(results.value[activeIndex.value])
|
||||
}
|
||||
function onSearchKeydown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
showResults.value = false;
|
||||
forcedOpen.value = false;
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (!results.value.length) return;
|
||||
showResults.value = true;
|
||||
activeIndex.value = (activeIndex.value + 1) % results.value.length;
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (!results.value.length) return;
|
||||
showResults.value = true;
|
||||
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length;
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && showResults.value && results.value.length && activeIndex.value >= 0) {
|
||||
e.preventDefault();
|
||||
goToResult(results.value[activeIndex.value]);
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchFocus () {
|
||||
if (!query.value?.trim()) { forcedOpen.value = true; showResults.value = true }
|
||||
function onSearchFocus() {
|
||||
if (!query.value?.trim()) {
|
||||
forcedOpen.value = true;
|
||||
showResults.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function applyRecent (q) {
|
||||
query.value = q
|
||||
forcedOpen.value = true
|
||||
showResults.value = true
|
||||
activeIndex.value = 0
|
||||
nextTick(() => searchEl.value?.$el?.querySelector?.('input')?.focus?.())
|
||||
function applyRecent(q) {
|
||||
query.value = q;
|
||||
forcedOpen.value = true;
|
||||
showResults.value = true;
|
||||
activeIndex.value = 0;
|
||||
nextTick(() => searchEl.value?.$el?.querySelector?.('input')?.focus?.());
|
||||
}
|
||||
|
||||
function onDocMouseDown (e) {
|
||||
if (!showResults.value) return
|
||||
if (!searchWrapEl.value?.contains(e.target)) { showResults.value = false; forcedOpen.value = false }
|
||||
function onDocMouseDown(e) {
|
||||
if (!showResults.value) return;
|
||||
if (!searchWrapEl.value?.contains(e.target)) {
|
||||
showResults.value = false;
|
||||
forcedOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', onDocMouseDown))
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocMouseDown))
|
||||
onMounted(() => document.addEventListener('mousedown', onDocMouseDown));
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocMouseDown));
|
||||
|
||||
async function goToResult (r) {
|
||||
saveRecent(query.value)
|
||||
query.value = ''
|
||||
showResults.value = false
|
||||
activeIndex.value = -1
|
||||
forcedOpen.value = false
|
||||
layoutState.activePath = typeof r.to === 'string' ? r.to : router.resolve(r.to).path
|
||||
await router.push(r.to)
|
||||
async function goToResult(r) {
|
||||
saveRecent(query.value);
|
||||
query.value = '';
|
||||
showResults.value = false;
|
||||
activeIndex.value = -1;
|
||||
forcedOpen.value = false;
|
||||
layoutState.activePath = typeof r.to === 'string' ? r.to : router.resolve(r.to).path;
|
||||
await router.push(r.to);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="panel-slide">
|
||||
<aside
|
||||
v-if="layoutState.railPanelOpen"
|
||||
class="w-[260px] shrink-0 h-screen flex flex-col border-r border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden"
|
||||
aria-label="Menu lateral"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="h-14 shrink-0 flex items-center justify-between px-4 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.9rem] font-bold tracking-tight text-[var(--text-color)]">
|
||||
{{ panelTitle }}
|
||||
</span>
|
||||
<button
|
||||
class="w-7 h-7 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-xs transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
|
||||
aria-label="Fechar painel"
|
||||
@click="closePanel"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Busca — só no Início -->
|
||||
<div v-if="isHome" ref="searchWrapEl" class="shrink-0 px-2.5 pt-2.5 border-b border-[var(--surface-border)]">
|
||||
<!-- Campo -->
|
||||
<div class="relative">
|
||||
<div aria-hidden="true" style="position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden;">
|
||||
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
|
||||
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
|
||||
</div>
|
||||
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
ref="searchEl"
|
||||
id="rp_menu_search"
|
||||
name="rp_menu_search"
|
||||
type="text"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
v-model="query"
|
||||
class="w-full pr-8"
|
||||
variant="filled"
|
||||
@focus="onSearchFocus"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="rp_menu_search">Encontrar menu...</label>
|
||||
</FloatLabel>
|
||||
|
||||
<button
|
||||
v-if="query.trim()"
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none cursor-pointer opacity-60 hover:opacity-100 text-[var(--text-color-secondary)] text-xs grid place-items-center p-0.5"
|
||||
@mousedown.prevent="clearSearch"
|
||||
aria-label="Limpar busca"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Recentes -->
|
||||
<div
|
||||
v-if="showResults && !query.trim() && recent.length"
|
||||
class="mt-1.5 mb-2.5 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
|
||||
>
|
||||
<div class="flex items-center justify-between px-2.5 py-1.5 text-[1rem] opacity-65 text-[var(--text-color-secondary)]">
|
||||
<span>Recentes</span>
|
||||
<button type="button" class="bg-transparent border-none cursor-pointer opacity-70 hover:opacity-100 text-inherit text-[1rem]" @mousedown.prevent="clearRecent" aria-label="Limpar recentes">
|
||||
<i class="pi pi-trash" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-for="q in recent" :key="q"
|
||||
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-transparent border-none cursor-pointer text-[var(--text-color)] text-[1rem] text-left transition-colors hover:bg-[var(--surface-hover)]"
|
||||
type="button"
|
||||
@click.stop.prevent="applyRecent(q)"
|
||||
>
|
||||
<i class="pi pi-history text-[0.8rem] opacity-70 shrink-0" />
|
||||
<span class="flex-1">{{ q }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Resultados de busca -->
|
||||
<div
|
||||
v-else-if="showResults && results.length"
|
||||
class="mt-1.5 mb-2.5 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
|
||||
>
|
||||
<button
|
||||
v-for="(r, i) in results" :key="String(r.to)"
|
||||
type="button"
|
||||
@mousedown.prevent="goToResult(r)"
|
||||
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-transparent border-none cursor-pointer text-[var(--text-color)] text-[1rem] text-left transition-colors"
|
||||
:class="i === activeIndex ? 'bg-[var(--surface-hover)]' : 'hover:bg-[var(--surface-hover)]'"
|
||||
>
|
||||
<i v-if="r.icon" :class="r.icon" class="text-[0.8rem] opacity-70 shrink-0" />
|
||||
<div class="flex flex-col flex-1">
|
||||
<span class="font-medium leading-tight" v-html="highlight(r.label, query)" />
|
||||
<small class="text-[0.68rem] opacity-60">{{ r.trail.join(' > ') }}</small>
|
||||
</div>
|
||||
<span v-if="r.proBadge" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="showResults && query && !results.length" class="py-2 pb-2.5 text-[1rem] opacity-60 text-[var(--text-color-secondary)]">
|
||||
Nenhum item encontrado.
|
||||
</div>
|
||||
|
||||
<div v-else class="pb-2.5" />
|
||||
</div>
|
||||
|
||||
<!-- Nav: todo o menu -->
|
||||
<nav class="flex-1 overflow-y-auto px-2 py-2.5 flex flex-col gap-0.5 [scrollbar-width:thin] [scrollbar-color:var(--surface-border)_transparent]">
|
||||
<template v-for="section in visibleSections" :key="section.label">
|
||||
|
||||
<!-- Label da seção — só exibe quando mostrando múltiplas seções -->
|
||||
<div
|
||||
v-if="visibleSections.length > 1"
|
||||
class="text-[0.62rem] font-extrabold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-55 px-2.5 pb-1.5 pt-3 first:pt-1"
|
||||
>
|
||||
{{ section.label }}
|
||||
</div>
|
||||
|
||||
<template v-for="item in section.items" :key="item.to || item.label">
|
||||
|
||||
<!-- Sub-grupo -->
|
||||
<div v-if="item.items?.length" class="flex flex-col gap-px">
|
||||
<div class="text-[0.6rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-40 px-2.5 pb-1 pt-2">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<button
|
||||
v-for="child in item.items"
|
||||
:key="child.to || child.label"
|
||||
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[9px] border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-left text-[0.83rem] font-medium transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
|
||||
:class="{
|
||||
'!bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] !text-[var(--primary-color)] !font-semibold': isActive(child),
|
||||
'opacity-55': isLocked(child)
|
||||
}"
|
||||
@click="navigate(child)"
|
||||
>
|
||||
<i v-if="child.icon" :class="child.icon" class="text-[1rem] shrink-0 opacity-75" />
|
||||
<span class="flex-1">{{ child.label }}</span>
|
||||
<span v-if="isLocked(child)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
|
||||
<span v-if="menuBadgeLabel(child)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(child) }}</span>
|
||||
</button>
|
||||
<Transition name="panel-slide">
|
||||
<aside v-if="layoutState.railPanelOpen" class="w-[260px] shrink-0 h-screen flex flex-col border-r border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden" aria-label="Menu lateral">
|
||||
<!-- Header -->
|
||||
<div class="h-14 shrink-0 flex items-center justify-between px-4 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.9rem] font-bold tracking-tight text-[var(--text-color)]">
|
||||
{{ panelTitle }}
|
||||
</span>
|
||||
<button
|
||||
class="w-7 h-7 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-xs transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
|
||||
aria-label="Fechar painel"
|
||||
@click="closePanel"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Item folha -->
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<button
|
||||
class="flex-1 flex items-center gap-2.5 px-2.5 py-2 rounded-[9px] border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-left text-[0.83rem] font-medium transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
|
||||
:class="{
|
||||
'!bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] !text-[var(--primary-color)] !font-semibold': isActive(item),
|
||||
'opacity-55': isLocked(item)
|
||||
}"
|
||||
@click="navigate(item)"
|
||||
>
|
||||
<i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<span v-if="isLocked(item)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
|
||||
<span v-if="menuBadgeLabel(item)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(item) }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.quickCreate"
|
||||
class="w-6 h-6 shrink-0 rounded-md border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-xs transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
|
||||
@click.stop="openQuickCreate($event, item)"
|
||||
title="Novo paciente"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
</button>
|
||||
<!-- Busca — só no Início -->
|
||||
<div v-if="isHome" ref="searchWrapEl" class="shrink-0 px-2.5 pt-2.5 border-b border-[var(--surface-border)]">
|
||||
<!-- Campo -->
|
||||
<div class="relative">
|
||||
<div aria-hidden="true" style="position: absolute; left: -9999px; top: -9999px; width: 1px; height: 1px; overflow: hidden">
|
||||
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
|
||||
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
|
||||
</div>
|
||||
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
ref="searchEl"
|
||||
id="rp_menu_search"
|
||||
name="rp_menu_search"
|
||||
type="text"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
v-model="query"
|
||||
class="w-full pr-8"
|
||||
variant="filled"
|
||||
@focus="onSearchFocus"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="rp_menu_search">Encontrar menu...</label>
|
||||
</FloatLabel>
|
||||
|
||||
<button
|
||||
v-if="query.trim()"
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none cursor-pointer opacity-60 hover:opacity-100 text-[var(--text-color-secondary)] text-xs grid place-items-center p-0.5"
|
||||
@mousedown.prevent="clearSearch"
|
||||
aria-label="Limpar busca"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Recentes -->
|
||||
<div v-if="showResults && !query.trim() && recent.length" class="mt-1.5 mb-2.5 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden">
|
||||
<div class="flex items-center justify-between px-2.5 py-1.5 text-[1rem] opacity-65 text-[var(--text-color-secondary)]">
|
||||
<span>Recentes</span>
|
||||
<button type="button" class="bg-transparent border-none cursor-pointer opacity-70 hover:opacity-100 text-inherit text-[1rem]" @mousedown.prevent="clearRecent" aria-label="Limpar recentes">
|
||||
<i class="pi pi-trash" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-for="q in recent"
|
||||
:key="q"
|
||||
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-transparent border-none cursor-pointer text-[var(--text-color)] text-[1rem] text-left transition-colors hover:bg-[var(--surface-hover)]"
|
||||
type="button"
|
||||
@click.stop.prevent="applyRecent(q)"
|
||||
>
|
||||
<i class="pi pi-history text-[0.8rem] opacity-70 shrink-0" />
|
||||
<span class="flex-1">{{ q }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Resultados de busca -->
|
||||
<div v-else-if="showResults && results.length" class="mt-1.5 mb-2.5 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden">
|
||||
<button
|
||||
v-for="(r, i) in results"
|
||||
:key="String(r.to)"
|
||||
type="button"
|
||||
@mousedown.prevent="goToResult(r)"
|
||||
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-transparent border-none cursor-pointer text-[var(--text-color)] text-[1rem] text-left transition-colors"
|
||||
:class="i === activeIndex ? 'bg-[var(--surface-hover)]' : 'hover:bg-[var(--surface-hover)]'"
|
||||
>
|
||||
<i v-if="r.icon" :class="r.icon" class="text-[0.8rem] opacity-70 shrink-0" />
|
||||
<div class="flex flex-col flex-1">
|
||||
<span class="font-medium leading-tight" v-html="highlight(r.label, query)" />
|
||||
<small class="text-[0.68rem] opacity-60">{{ r.trail.join(' > ') }}</small>
|
||||
</div>
|
||||
<span v-if="r.proBadge" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="showResults && query && !results.length" class="py-2 pb-2.5 text-[1rem] opacity-60 text-[var(--text-color-secondary)]">Nenhum item encontrado.</div>
|
||||
|
||||
<div v-else class="pb-2.5" />
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</template>
|
||||
</nav>
|
||||
<!-- Nav: todo o menu -->
|
||||
<nav class="flex-1 overflow-y-auto px-2 py-2.5 flex flex-col gap-0.5 [scrollbar-width:thin] [scrollbar-color:var(--surface-border)_transparent]">
|
||||
<template v-for="section in visibleSections" :key="section.label">
|
||||
<!-- Label da seção — só exibe quando mostrando múltiplas seções -->
|
||||
<div v-if="visibleSections.length > 1" class="text-[0.62rem] font-extrabold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-55 px-2.5 pb-1.5 pt-3 first:pt-1">
|
||||
{{ section.label }}
|
||||
</div>
|
||||
|
||||
<!-- PatientCreatePopover (shared) -->
|
||||
<PatientCreatePopover
|
||||
ref="createPopover"
|
||||
@quick-create="onQuickCreate"
|
||||
/>
|
||||
<template v-for="item in section.items" :key="item.to || item.label">
|
||||
<!-- Sub-grupo -->
|
||||
<div v-if="item.items?.length" class="flex flex-col gap-px">
|
||||
<div class="text-[0.6rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-40 px-2.5 pb-1 pt-2">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<button
|
||||
v-for="child in item.items"
|
||||
:key="child.to || child.label"
|
||||
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[9px] border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-left text-[0.83rem] font-medium transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
|
||||
:class="{
|
||||
'!bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] !text-[var(--primary-color)] !font-semibold': isActive(child),
|
||||
'opacity-55': isLocked(child)
|
||||
}"
|
||||
@click="navigate(child)"
|
||||
>
|
||||
<i v-if="child.icon" :class="child.icon" class="text-[1rem] shrink-0 opacity-75" />
|
||||
<span class="flex-1">{{ child.label }}</span>
|
||||
<span v-if="isLocked(child)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
|
||||
<span v-if="menuBadgeLabel(child)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(child) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Cadastro Rápido Dialog -->
|
||||
<ComponentCadastroRapido
|
||||
v-model="quickDialog"
|
||||
title="Cadastro Rápido"
|
||||
table-name="patients"
|
||||
name-field="nome_completo"
|
||||
email-field="email_principal"
|
||||
phone-field="telefone"
|
||||
:extra-payload="{ status: 'Ativo' }"
|
||||
@created="quickDialog = false"
|
||||
/>
|
||||
</aside>
|
||||
</Transition>
|
||||
<!-- Item folha -->
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<button
|
||||
class="flex-1 flex items-center gap-2.5 px-2.5 py-2 rounded-[9px] border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-left text-[0.83rem] font-medium transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
|
||||
:class="{
|
||||
'!bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] !text-[var(--primary-color)] !font-semibold': isActive(item),
|
||||
'opacity-55': isLocked(item)
|
||||
}"
|
||||
@click="navigate(item)"
|
||||
>
|
||||
<i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<span v-if="isLocked(item)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
|
||||
<span v-if="menuBadgeLabel(item)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(item) }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.quickCreate"
|
||||
class="w-6 h-6 shrink-0 rounded-md border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-xs transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
|
||||
@click.stop="openQuickCreate($event, item)"
|
||||
title="Novo paciente"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- PatientCreatePopover (shared) -->
|
||||
<PatientCreatePopover ref="createPopover" @quick-create="onQuickCreate" />
|
||||
|
||||
<!-- Cadastro Rápido Dialog -->
|
||||
<ComponentCadastroRapido
|
||||
v-model="quickDialog"
|
||||
title="Cadastro Rápido"
|
||||
table-name="patients"
|
||||
name-field="nome_completo"
|
||||
email-field="email_principal"
|
||||
phone-field="telefone"
|
||||
:extra-payload="{ status: 'Ativo' }"
|
||||
@created="quickDialog = false"
|
||||
/>
|
||||
</aside>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel-slide-enter-active,
|
||||
.panel-slide-leave-active {
|
||||
transition: width 0.22s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.18s ease;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
width 0.22s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.18s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-slide-enter-from,
|
||||
.panel-slide-leave-to {
|
||||
width: 0 !important;
|
||||
opacity: 0;
|
||||
width: 0 !important;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user