The link had a custom hover tooltip that took the hidden events with dayEvents.slice(3) - correct while dayMaxEvents was a fixed 3, wrong the moment that cap started varying with the row height. On a short row showing one chip, '+4 more' sliced from index 3 and listed the wrong events; on a day with three events it sliced to nothing, hit the empty-list guard and rendered no tooltip at all. A link with nothing behind it. FullCalendar's own popover replaces it rather than the arithmetic being fixed: no index to drift out of step with the cap, a header and a close button, every event for that day listed, and it works on a touch screen - which a hover tooltip never did on a kiosk. Clicking an event in the popover still opens the detail modal. That takes the hover handlers, the container mouseenter/mouseleave delegation, the tooltip markup and styles, and the by-date index that existed only to feed them: 108 lines out, 25 in. The popover is themed through the CSS variables, since FullCalendar ships it light.
381 lines
11 KiB
Vue
381 lines
11 KiB
Vue
<template>
|
|
<div class="page-header">
|
|
<h1>Calendar</h1>
|
|
</div>
|
|
|
|
<div ref="calendarContainer" class="calendar-container card">
|
|
<FullCalendar :options="calendarOptions" />
|
|
</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)
|
|
|
|
// Store events by date for hover lookup
|
|
|
|
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,
|
|
// FullCalendar's own popover: click '+N more' and it lists that day's events,
|
|
// each still opening the detail modal through eventClick. The custom hover
|
|
// tooltip this replaces sliced the hidden events from a hardcoded index of 3,
|
|
// which was the fixed dayMaxEvents at the time. Now the cap varies with the
|
|
// row height, so that slice pointed at the wrong events - and when a short
|
|
// row showed one chip, at an empty slice, leaving '+2 more' with nothing
|
|
// behind it. A popover has no such bookkeeping, and works on a touch screen.
|
|
moreLinkClick: 'popover'
|
|
})
|
|
|
|
// 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()
|
|
|
|
await nextTick()
|
|
fitHeight()
|
|
window.addEventListener('resize', fitHeight)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', fitHeight)
|
|
})
|
|
|
|
onMounted(async () => {
|
|
await loadEvents()
|
|
})
|
|
|
|
async function loadEvents() {
|
|
try {
|
|
const response = await notificationsApi.getCalendar()
|
|
events.value = response.data.data
|
|
calendarOptions.value.events = events.value
|
|
} catch (error) {
|
|
console.error('Error loading calendar events:', error)
|
|
}
|
|
}
|
|
|
|
function closeEventModal() {
|
|
selectedEvent.value = null
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/* The popover is FullCalendar's own element, so it ships light-themed. */
|
|
:deep(.fc-popover) {
|
|
background: var(--bg-card-solid);
|
|
border: 1px solid var(--border);
|
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
|
z-index: 1000;
|
|
}
|
|
:deep(.fc-popover-header) {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
padding: 8px 10px;
|
|
font-weight: 600;
|
|
}
|
|
:deep(.fc-popover-body) {
|
|
color: var(--text);
|
|
}
|
|
|
|
/* 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>
|