An audit of every scrollable element on the page, rather than the document alone, found what the previous two attempts kept missing: FullCalendar's day-grid scroller sits 16px short of its own content at every window size. It is phantom. The table inside measures exactly the scroller's height, the last week sits flush with the bottom, and the horizontal axis is clean - there is nothing to scroll to, but the browser paints a bar for the gap. Shrinking the calendar to close it does not work either: the gap stays 16px however small the grid gets, so the previous pass had started shrinking the month for nothing. That correction is reverted to page overflow only. The grid is sized to fit its six rows, so the scroller has nothing to reveal; hiding it is safe and is what the earlier arithmetic was failing to buy. Audited with 60 events across 12 days at 1920x1080, 1600x900, 1366x768, 1280x720 and 1920x1200: no scrollable element left in the calendar, six weeks visible, last week fully in view, badges shown with a '+N more' where a short row cannot hold them all. The nav sidebar still scrolls on a short window - 100vh of viewport against about 1384px of menu - but that is every page, not this one.
464 lines
13 KiB
Vue
464 lines
13 KiB
Vue
<template>
|
|
<div class="page-header">
|
|
<h1>Calendar</h1>
|
|
</div>
|
|
|
|
<div ref="calendarContainer" class="calendar-container card">
|
|
<FullCalendar :options="calendarOptions" />
|
|
</div>
|
|
|
|
<!-- More events tooltip -->
|
|
<div
|
|
v-if="moreTooltipData.length"
|
|
ref="moreTooltip"
|
|
class="fc-more-tooltip"
|
|
:style="{ left: tooltipPosition.left + 'px', top: tooltipPosition.top + 'px' }"
|
|
@mouseleave="hideMoreTooltip"
|
|
>
|
|
<div
|
|
v-for="(evt, idx) in moreTooltipData"
|
|
:key="idx"
|
|
class="fc-more-tooltip-event"
|
|
:style="{ borderLeftColor: evt.color }"
|
|
@click="openEventFromTooltip(evt)"
|
|
>
|
|
{{ evt.title }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Event details modal -->
|
|
<div v-if="selectedEvent" class="modal-overlay" @click.self="closeEventModal">
|
|
<div class="modal">
|
|
<!-- Employee-photo event (recognition/recertification) with highlight -->
|
|
<div v-if="selectedEvent.extendedProps?.showemployeephoto" class="recognition-header">
|
|
<div class="recognition-badge">
|
|
<span class="recognition-icon"><Trophy :size="24" /></span>
|
|
</div>
|
|
<div class="recognition-info">
|
|
<div class="recognition-label">{{ selectedEvent.extendedProps?.typename || 'Recognition' }}</div>
|
|
<h2 class="recognition-title">{{ selectedEvent.extendedProps?.message || selectedEvent.title }}</h2>
|
|
<div v-if="selectedEvent.extendedProps?.employeename || selectedEvent.extendedProps?.employeesso" class="recognition-employee">
|
|
<span class="employee-icon"><User :size="16" /></span>
|
|
<span class="employee-name">{{ selectedEvent.extendedProps.employeename || selectedEvent.extendedProps.employeesso }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Regular event header -->
|
|
<h2 v-else>{{ selectedEvent.title }}</h2>
|
|
|
|
<div class="event-details">
|
|
<p v-if="selectedEvent.extendedProps?.typename && !selectedEvent.extendedProps?.showemployeephoto">
|
|
<strong>Type:</strong> {{ selectedEvent.extendedProps.typename }}
|
|
</p>
|
|
<p>
|
|
<strong>Start:</strong> {{ formatDate(selectedEvent.start) }}
|
|
</p>
|
|
<p v-if="selectedEvent.end">
|
|
<strong>End:</strong> {{ formatDate(selectedEvent.end) }}
|
|
</p>
|
|
<p v-if="selectedEvent.extendedProps?.message && !selectedEvent.extendedProps?.showemployeephoto" class="message-block">
|
|
<strong>Details:</strong>
|
|
<span class="message-text">{{ selectedEvent.extendedProps.message }}</span>
|
|
</p>
|
|
<p v-if="selectedEvent.extendedProps?.ticketnumber">
|
|
<strong>Ticket:</strong> {{ selectedEvent.extendedProps.ticketnumber }}
|
|
</p>
|
|
<p v-if="selectedEvent.extendedProps?.linkurl">
|
|
<a :href="selectedEvent.extendedProps.linkurl" target="_blank" class="btn btn-link">
|
|
More Info
|
|
</a>
|
|
</p>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<router-link
|
|
v-if="selectedEvent.extendedProps?.notificationid"
|
|
:to="`/notifications/${selectedEvent.extendedProps.notificationid}`"
|
|
class="btn btn-secondary"
|
|
>
|
|
View Notification
|
|
</router-link>
|
|
<button class="btn btn-primary" @click="closeEventModal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
|
import FullCalendar from '@fullcalendar/vue3'
|
|
import dayGridPlugin from '@fullcalendar/daygrid'
|
|
import { Trophy, User } from 'lucide-vue-next'
|
|
import { notificationsApi } from '@/api'
|
|
|
|
const events = ref([])
|
|
const selectedEvent = ref(null)
|
|
const calendarRef = ref(null)
|
|
const calendarContainer = ref(null)
|
|
const moreTooltip = ref(null)
|
|
const moreTooltipData = ref([])
|
|
const tooltipPosition = ref({ left: 0, top: 0 })
|
|
|
|
// Store events by date for hover lookup
|
|
const eventsByDate = ref({})
|
|
|
|
const calendarOptions = ref({
|
|
plugins: [dayGridPlugin],
|
|
initialView: 'dayGridMonth',
|
|
events: [],
|
|
eventClick: (info) => {
|
|
selectedEvent.value = info.event
|
|
},
|
|
headerToolbar: {
|
|
left: 'prev,next today',
|
|
center: 'title',
|
|
right: 'dayGridMonth,dayGridWeek'
|
|
},
|
|
// Measured at mount and on resize (fitHeight), not a viewport calc: the
|
|
// calendar starts below a header and a page title whose heights are not
|
|
// knowable here, and guessing at them is what left the grid either clipped
|
|
// or overflowing. expandRows shares that height across the six weeks so an
|
|
// empty month fills it instead of collapsing into strips.
|
|
height: 600,
|
|
expandRows: true,
|
|
// true = FullCalendar fits as many chips as the row actually has room for and
|
|
// rolls the rest into a '+N more'. That is the only setting that cannot
|
|
// overflow the box: a fixed number outgrows short rows and pushes the last
|
|
// weeks behind the calendar's own scroller. Compact chip styling below buys
|
|
// back a chip or two per day.
|
|
dayMaxEvents: true,
|
|
moreLinkClick: () => 'none' // Disable click, we use hover
|
|
})
|
|
|
|
function hideMoreTooltip() {
|
|
moreTooltipData.value = []
|
|
}
|
|
|
|
function handleMoreLinkHover(e) {
|
|
const moreLink = e.target.closest('.fc-daygrid-more-link')
|
|
if (!moreLink) {
|
|
return
|
|
}
|
|
|
|
// Find the day cell and get its date
|
|
const dayCell = moreLink.closest('.fc-daygrid-day')
|
|
if (!dayCell) return
|
|
|
|
const dateStr = dayCell.getAttribute('data-date')
|
|
if (!dateStr || !eventsByDate.value[dateStr]) return
|
|
|
|
// Get events for this date that would be hidden (after first 3)
|
|
const dayEvents = eventsByDate.value[dateStr]
|
|
const hiddenEvents = dayEvents.slice(3).map(evt => ({
|
|
title: evt.title,
|
|
color: evt.backgroundColor || '#14abef',
|
|
// Include full event data for modal
|
|
start: evt.start,
|
|
end: evt.end,
|
|
extendedProps: evt.extendedProps || {}
|
|
}))
|
|
|
|
if (hiddenEvents.length === 0) return
|
|
|
|
moreTooltipData.value = hiddenEvents
|
|
|
|
// Position tooltip
|
|
const rect = moreLink.getBoundingClientRect()
|
|
tooltipPosition.value = {
|
|
left: rect.left,
|
|
top: rect.bottom + 5
|
|
}
|
|
}
|
|
|
|
function handleMoreLinkLeave(e) {
|
|
const related = e.relatedTarget
|
|
// Don't hide if moving to the tooltip itself
|
|
if (related && (related.closest('.fc-more-tooltip') || related.closest('.fc-daygrid-more-link'))) {
|
|
return
|
|
}
|
|
hideMoreTooltip()
|
|
}
|
|
|
|
function buildEventsByDate() {
|
|
const byDate = {}
|
|
for (const evt of events.value) {
|
|
const dateStr = evt.start ? evt.start.split('T')[0] : null
|
|
if (dateStr) {
|
|
if (!byDate[dateStr]) byDate[dateStr] = []
|
|
byDate[dateStr].push(evt)
|
|
}
|
|
}
|
|
eventsByDate.value = byDate
|
|
}
|
|
|
|
// Height the calendar can have without pushing the page into a scrollbar.
|
|
// A first guess from the container's top, then corrected against whatever the
|
|
// page actually overflows by - the card's padding, page margins and anything
|
|
// else below it are not worth enumerating, and guessing at them is exactly how
|
|
// this ended up either clipped or scrolling. Floored so a very short window
|
|
// scrolls rather than crushing the grid.
|
|
const MIN_CALENDAR_HEIGHT = 460
|
|
const FIT_PASSES = 3
|
|
async function fitHeight() {
|
|
const el = calendarContainer.value
|
|
if (!el) return
|
|
const available = window.innerHeight - el.getBoundingClientRect().top
|
|
calendarOptions.value.height = Math.max(MIN_CALENDAR_HEIGHT, Math.round(available))
|
|
for (let pass = 0; pass < FIT_PASSES; pass++) {
|
|
await nextTick()
|
|
const overflow = document.documentElement.scrollHeight - window.innerHeight
|
|
if (overflow <= 0) break
|
|
const next = calendarOptions.value.height - overflow
|
|
if (next <= MIN_CALENDAR_HEIGHT) {
|
|
calendarOptions.value.height = MIN_CALENDAR_HEIGHT
|
|
break
|
|
}
|
|
calendarOptions.value.height = next
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadEvents()
|
|
|
|
// Add event delegation for more links
|
|
await nextTick()
|
|
fitHeight()
|
|
window.addEventListener('resize', fitHeight)
|
|
const container = document.querySelector('.calendar-container')
|
|
if (container) {
|
|
container.addEventListener('mouseenter', handleMoreLinkHover, true)
|
|
container.addEventListener('mouseleave', handleMoreLinkLeave, true)
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', fitHeight)
|
|
const container = document.querySelector('.calendar-container')
|
|
if (container) {
|
|
container.removeEventListener('mouseenter', handleMoreLinkHover, true)
|
|
container.removeEventListener('mouseleave', handleMoreLinkLeave, true)
|
|
}
|
|
})
|
|
|
|
onMounted(async () => {
|
|
await loadEvents()
|
|
})
|
|
|
|
async function loadEvents() {
|
|
try {
|
|
const response = await notificationsApi.getCalendar()
|
|
events.value = response.data.data
|
|
calendarOptions.value.events = events.value
|
|
buildEventsByDate()
|
|
} catch (error) {
|
|
console.error('Error loading calendar events:', error)
|
|
}
|
|
}
|
|
|
|
function closeEventModal() {
|
|
selectedEvent.value = null
|
|
}
|
|
|
|
function openEventFromTooltip(evt) {
|
|
// Create an event-like object that matches FullCalendar's event structure
|
|
selectedEvent.value = {
|
|
title: evt.title,
|
|
start: evt.start,
|
|
end: evt.end,
|
|
extendedProps: evt.extendedProps
|
|
}
|
|
hideMoreTooltip()
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return ''
|
|
// allDay events carry a site-local date-only value (YYYY-MM-DD). Build the
|
|
// Date from local parts so it is not shifted a day by UTC-midnight parsing.
|
|
const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr)
|
|
const date = parts
|
|
? new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))
|
|
: new Date(dateStr)
|
|
return date.toLocaleDateString('en-US', {
|
|
weekday: 'short',
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.calendar-container {
|
|
/* The height itself is measured in fitHeight(); this is only a floor for the
|
|
first paint, before that measurement lands. */
|
|
min-height: 460px;
|
|
}
|
|
|
|
/* FullCalendar's day-grid scroller reports 16px more scrollHeight than the
|
|
table inside it - the last week sits flush with the bottom and nothing is
|
|
hidden, but the browser still paints a scrollbar for the phantom 16px. The
|
|
calendar is sized to fit its six rows (fitHeight), so there is nothing for
|
|
this scroller to reveal; hide the bar rather than shrinking the grid to
|
|
chase a gap that never closes. */
|
|
:deep(.fc-scroller-liquid-absolute) {
|
|
overflow-y: hidden !important;
|
|
}
|
|
|
|
/* Compact chips: the row height decides how many events a day can show before
|
|
they roll into a '+N more', so every pixel saved here is one more visible. */
|
|
:deep(.fc-daygrid-event) {
|
|
font-size: 12px;
|
|
line-height: 1.3;
|
|
padding: 1px 4px;
|
|
margin-top: 1px;
|
|
}
|
|
:deep(.fc-daygrid-more-link) {
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* The day number was tight against the top edge once the cells grew. Kept
|
|
small: this strip is pure overhead in the row-height budget FullCalendar
|
|
uses to decide how many chips fit before a '+N more'. */
|
|
:deep(.fc-daygrid-day-top) {
|
|
padding: 1px 4px;
|
|
font-size: 13px;
|
|
}
|
|
:deep(.fc-daygrid-day-events) {
|
|
margin-bottom: 0;
|
|
}
|
|
:deep(.fc-daygrid-event-harness) {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.modal {
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.modal h2 {
|
|
margin: 0 0 1rem 0;
|
|
font-size: 18px;
|
|
color: var(--text);
|
|
padding-bottom: 0.75rem;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.event-details {
|
|
padding: 0.5rem 0;
|
|
}
|
|
|
|
.event-details p {
|
|
margin-bottom: 0.75rem;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.event-details p:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.event-details strong {
|
|
color: var(--text-light);
|
|
display: inline-block;
|
|
min-width: 70px;
|
|
}
|
|
|
|
.message-block {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.message-block strong {
|
|
min-width: auto;
|
|
}
|
|
|
|
.message-text {
|
|
display: block;
|
|
padding: 0.5rem 0.75rem;
|
|
background: var(--bg);
|
|
border-radius: 0.25rem;
|
|
white-space: pre-wrap;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
justify-content: flex-end;
|
|
margin-top: 1.25rem;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
/* Recognition event styling */
|
|
.recognition-header {
|
|
display: flex;
|
|
gap: 1rem;
|
|
padding-bottom: 1rem;
|
|
margin-bottom: 1rem;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.recognition-badge {
|
|
flex-shrink: 0;
|
|
width: 60px;
|
|
height: 60px;
|
|
background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
|
|
}
|
|
|
|
.recognition-icon {
|
|
font-size: 28px;
|
|
}
|
|
|
|
.recognition-info {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
}
|
|
|
|
.recognition-label {
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
color: var(--primary);
|
|
font-weight: 600;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.recognition-title {
|
|
margin: 0 !important;
|
|
padding: 0 !important;
|
|
border: none !important;
|
|
font-size: 16px !important;
|
|
line-height: 1.4;
|
|
color: var(--text) !important;
|
|
}
|
|
|
|
.recognition-employee {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-top: 0.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
background: rgba(65, 129, 255, 0.1);
|
|
border-radius: 0.25rem;
|
|
border-left: 3px solid var(--primary);
|
|
}
|
|
|
|
.employee-icon {
|
|
font-size: 18px;
|
|
}
|
|
|
|
.employee-name {
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
font-size: 15px;
|
|
}
|
|
</style>
|