Ajuste rotas, Menus, Layout, Permissãoes UserRoleGuard
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
Reference in New Issue
Block a user