Ajuste rotas, Menus, Layout, Permissãoes UserRoleGuard

This commit is contained in:
Leonardo
2026-02-24 12:04:59 -03:00
parent b1c0cb47c0
commit d58dc21297
15 changed files with 1925 additions and 259 deletions
File diff suppressed because it is too large Load Diff
+286 -21
View File
@@ -47,10 +47,37 @@
v-model="search"
class="w-full"
autocomplete="off"
@keyup.enter="openSearchModal"
/>
</IconField>
<label for="agendaSearch">Buscar paciente...</label>
</FloatLabel>
<!-- Mobile: botão para abrir resultados (não duplica sidebar) -->
<div class="sm:hidden mt-2">
<Button
v-if="searchTrim"
:label="`Resultados (${searchResults.length})`"
icon="pi pi-list"
severity="secondary"
outlined
class="w-full rounded-full"
@click="openSearchModal"
/>
</div>
<!-- Chip/atalho rápido (desktop + mobile) -->
<div v-if="searchTrim" class="mt-2 flex items-center gap-2 flex-wrap">
<Tag :value="`Busca: ${searchTrim}`" severity="secondary" />
<Button
label="Limpar"
icon="pi pi-times"
text
severity="secondary"
class="p-0"
@click="clearSearch"
/>
</div>
</div>
<div class="flex items-center gap-2">
@@ -131,10 +158,10 @@
</div>
<!-- Layout: 2 colunas -->
<div class="grid">
<div class="flex flex-col lg:flex-row gap-4">
<!-- Coluna maior: FullCalendar -->
<div class="col-12 lg:col-8 xl:col-9">
<div class="overflow-hidden rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<div class="w-full lg:flex-1 lg:order-2 min-w-0">
<div class="overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<div class="p-2">
<!-- key força reaplicar slotMin/Max quando trocar modo -->
<FullCalendar ref="fcRef" :key="fcKey" :options="fcOptions" />
@@ -143,7 +170,70 @@
</div>
<!-- Coluna menor -->
<div class="col-12 lg:col-4 xl:col-3">
<div class="w-full lg:basis-[24%] lg:max-w-[24%] lg:order-1">
<!-- Resultados (DESKTOP): não aparece no mobile para evitar duplicação -->
<div
v-if="searchTrim"
class="hidden sm:block mb-3 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm"
>
<div class="mb-2 flex items-center justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold truncate">Resultados</div>
<small class="text-color-secondary truncate">
para {{ searchTrim }}
</small>
</div>
<div class="flex items-center gap-2">
<Tag
v-if="!searchLoading"
:value="`${searchResults.length}`"
severity="secondary"
/>
<Button
icon="pi pi-times"
severity="secondary"
text
class="h-9 w-9 rounded-full"
v-tooltip.top="'Limpar busca'"
@click="clearSearch"
/>
</div>
</div>
<div v-if="searchLoading" class="text-color-secondary text-sm">
Buscando
</div>
<div v-else-if="searchResults.length === 0" class="text-color-secondary text-sm">
Nenhum resultado encontrado.
</div>
<div v-else class="flex flex-col gap-2 max-h-[360px] overflow-auto pr-1">
<button
v-for="r in searchResults"
:key="r.id"
class="text-left rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-0)]/40 p-3 transition hover:shadow-sm"
@click="gotoResult(r)"
>
<div class="font-medium truncate">
{{ r.titulo || 'Sem título' }}
</div>
<div class="mt-1 flex items-center justify-between gap-2 text-xs opacity-70">
<span class="truncate">
{{ fmtDateTime(r.inicio_em) }}
</span>
<Tag :value="labelTipo(r.tipo)" severity="info" />
</div>
<div v-if="r.observacoes" class="mt-1 text-xs text-color-secondary truncate">
{{ r.observacoes }}
</div>
</button>
</div>
</div>
<div class="mb-3 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm">
<div class="mb-2 flex items-center justify-between">
<span class="font-semibold">Calendário</span>
@@ -227,6 +317,83 @@
</div>
</div>
<!-- Dialog Resultados (MOBILE) -->
<Dialog
v-model:visible="searchModalOpen"
modal
header="Resultados da busca"
:style="{ width: '96vw', maxWidth: '720px' }"
:breakpoints="{ '960px': '92vw', '640px': '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold truncate">Para {{ searchTrim }}</div>
<small class="text-color-secondary">
{{ searchResults.length }} resultado(s)
</small>
</div>
<Button
icon="pi pi-eraser"
severity="secondary"
text
class="h-9 w-9 rounded-full"
v-tooltip.top="'Limpar busca'"
@click="clearSearchAndClose"
/>
</div>
<Divider />
<div v-if="searchLoading" class="text-color-secondary text-sm">
Buscando
</div>
<div v-else-if="searchResults.length === 0" class="text-color-secondary text-sm">
Nenhum resultado encontrado.
</div>
<div v-else class="flex flex-col gap-2 max-h-[65vh] overflow-auto pr-1">
<button
v-for="r in searchResults"
:key="r.id"
class="text-left rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-0)]/40 p-3 transition hover:shadow-sm"
@click="gotoResultFromModal(r)"
>
<div class="font-medium truncate">
{{ r.titulo || 'Sem título' }}
</div>
<div class="mt-1 flex items-center justify-between gap-2 text-xs opacity-70">
<span class="truncate">
{{ fmtDateTime(r.inicio_em) }}
</span>
<Tag :value="labelTipo(r.tipo)" severity="info" />
</div>
<div v-if="r.observacoes" class="mt-1 text-xs text-color-secondary truncate">
{{ r.observacoes }}
</div>
</button>
</div>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="searchModalOpen = false" />
<Button
v-if="searchTrim"
label="Limpar"
icon="pi pi-eraser"
severity="secondary"
outlined
class="rounded-full"
@click="clearSearchAndClose"
/>
</template>
</Dialog>
<!-- Month Picker -->
<Dialog v-model:visible="monthPickerVisible" modal header="Escolher mês" :style="{ width: '420px' }">
<div class="p-2">
@@ -302,6 +469,8 @@ import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import SelectButton from 'primevue/selectbutton'
import SplitButton from 'primevue/splitbutton'
import Tag from 'primevue/tag'
import Divider from 'primevue/divider'
import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid'
@@ -348,6 +517,9 @@ const calendarView = ref('day') // day | week | month
const timeMode = ref('my') // 24 | 12 | my
const search = ref('')
// Modal resultados (mobile)
const searchModalOpen = ref(false)
// Mini calendário
const miniDate = ref(new Date())
@@ -440,23 +612,50 @@ const fcViewName = computed(() => {
return 'dayGridMonth'
})
const filteredRows = computed(() => {
/* -------------------------------------------------
✅ Correção:
- calendário NÃO filtra por search
- search vira lista de resultados (desktop sidebar / mobile dialog)
-------------------------------------------------- */
const calendarRows = computed(() => {
const list = rows.value || []
const q = (search.value || '').trim().toLowerCase()
return list.filter(r => {
const tipo = String(r.tipo || '').toLowerCase()
const titulo = String(r.titulo || '').toLowerCase()
const obs = String(r.observacoes || '').toLowerCase()
if (onlySessions.value && !tipo.includes('sess')) return false
if (q && !(titulo.includes(q) || obs.includes(q))) return false
return true
})
})
const searchTrim = computed(() => String(search.value || '').trim())
const searchLoading = computed(() => false) // placeholder (se quiser debounce/async)
const searchResults = computed(() => {
const q = searchTrim.value.toLowerCase()
if (!q) return []
return (calendarRows.value || []).filter(r => {
const tipo = String(r.tipo || '').toLowerCase()
const titulo = String(r.titulo || '').toLowerCase()
const obs = String(r.observacoes || '').toLowerCase()
// Se seu row tiver campos do paciente, plugue aqui:
const pacienteNome = String(r.paciente_nome || r.patient_name || r.nome_paciente || '').toLowerCase()
const pacienteEmail = String(r.paciente_email || r.patient_email || '').toLowerCase()
const pacienteTel = String(r.paciente_phone || r.patient_phone || '').toLowerCase()
return (
titulo.includes(q) ||
obs.includes(q) ||
tipo.includes(q) ||
pacienteNome.includes(q) ||
pacienteEmail.includes(q) ||
pacienteTel.includes(q)
)
})
})
const calendarEvents = computed(() => {
const base = mapAgendaEventosToCalendarEvents(filteredRows.value || [])
const base = mapAgendaEventosToCalendarEvents(calendarRows.value || [])
const breaks =
settings.value && currentRange.value.start && currentRange.value.end
@@ -537,11 +736,21 @@ const fcOptions = computed(() => ({
eventDrop: (info) => persistMoveOrResize(info, 'Movido'),
eventResize: (info) => persistMoveOrResize(info, 'Redimensionado'),
// ✅ destaque da busca sem remover eventos (melhoria UX)
eventClassNames: (arg) => {
const tipo = String(arg?.event?.extendedProps?.tipo || arg?.event?.extendedProps?.kind || '').toLowerCase()
if (tipo.includes('sess')) return ['evt-session']
if (tipo.includes('bloq')) return ['evt-block']
return []
const title = String(arg?.event?.title || '').toLowerCase()
const obs = String(arg?.event?.extendedProps?.observacoes || '').toLowerCase()
const q = searchTrim.value.toLowerCase()
const hit = q && (title.includes(q) || obs.includes(q) || tipo.includes(q))
const classes = []
if (tipo.includes('sess')) classes.push('evt-session')
if (tipo.includes('bloq')) classes.push('evt-block')
if (q && hit) classes.push('evt-hit')
if (q && !hit) classes.push('evt-dim')
return classes
}
}))
@@ -554,6 +763,11 @@ watch(calendarView, async () => {
getApi()?.changeView?.(fcViewName.value)
})
// se limpou a busca, fecha modal mobile automaticamente
watch(searchTrim, (v) => {
if (!v) searchModalOpen.value = false
})
// -----------------------------
// Ações Topbar
// -----------------------------
@@ -561,6 +775,17 @@ function goToday () { getApi()?.today?.() }
function goPrev () { getApi()?.prev?.() }
function goNext () { getApi()?.next?.() }
function clearSearch () { search.value = '' }
function clearSearchAndClose () {
search.value = ''
searchModalOpen.value = false
}
function openSearchModal () {
if (!searchTrim.value) return
searchModalOpen.value = true
}
function toggleMonthPicker () {
monthPickerDate.value = new Date(currentDate.value)
monthPickerVisible.value = true
@@ -580,6 +805,40 @@ function onMiniPick (d) {
function miniPrevMonth () { miniDate.value = shiftMonth(miniDate.value, -1) }
function miniNextMonth () { miniDate.value = shiftMonth(miniDate.value, +1) }
/* -----------------------------
Clique no resultado:
- vai para o dia do evento
- abre o dialog (edit)
------------------------------ */
function gotoResult (row) {
const api = getApi()
if (api && row?.inicio_em) api.gotoDate(new Date(row.inicio_em))
dialogEventRow.value = row
dialogStartISO.value = ''
dialogEndISO.value = ''
dialogOpen.value = true
}
function gotoResultFromModal (row) {
searchModalOpen.value = false
nextTick(() => gotoResult(row))
}
function fmtDateTime (iso) {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' })
}
function labelTipo (tipo) {
const t = String(tipo || '').toLowerCase()
if (t.includes('sess')) return 'Sessão'
if (t.includes('bloq')) return 'Bloqueio'
if (t.includes('avali')) return 'Avaliação'
return (tipo || 'Evento')
}
function onCreateFromButton () {
if (!ownerId.value) {
toast.add({ severity: 'warn', summary: 'Agenda', detail: 'Aguarde carregar as configurações da agenda.', life: 3000 })
@@ -776,10 +1035,16 @@ onMounted(async () => {
if (settingsError.value) {
toast.add({ severity: 'warn', summary: 'Agenda', detail: settingsError.value, life: 4500 })
}
// opcional: refletir modo salvo no banco
// if (settings.value?.agenda_view_mode) {
// timeMode.value = settings.value.agenda_view_mode === 'full_24h' ? '24' : 'my'
// }
})
</script>
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active { transition: opacity .14s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
/* ✅ destaque da busca sem remover eventos */
.evt-dim { opacity: .25; }
.evt-hit { opacity: 1; }
</style>