Give the calendar room, and put new notifications on the shopfloor board

The calendar was set to height 'auto', which sizes each week row to its own
content, so a month of mostly empty days collapsed into thin strips. It now
takes a viewport-relative height and expandRows shares that evenly across the
weeks, with a floor under each day cell so a short window squeezes the grid back
down rather than the rows vanishing.

New notifications now have "Show on Shopfloor Dashboard" ticked. The board is
where these are meant to be read, and starting unticked meant most were written
and then never appeared on it.

Only the default for a NEW notification. Editing an existing one still loads its
stored value, so nothing that was deliberately turned off gets flipped back on,
and the column default is left alone so an API or import caller that omits the
field keeps the behaviour it has today.
This commit is contained in:
cproudlock
2026-08-05 15:37:46 -04:00
parent 959db2922b
commit 658e5c2224
2 changed files with 409 additions and 385 deletions

View File

@@ -1,384 +1,403 @@
<template> <template>
<div class="page-header"> <div class="page-header">
<h1>Calendar</h1> <h1>Calendar</h1>
</div> </div>
<div class="calendar-container card"> <div class="calendar-container card">
<FullCalendar :options="calendarOptions" /> <FullCalendar :options="calendarOptions" />
</div> </div>
<!-- More events tooltip --> <!-- More events tooltip -->
<div <div
v-if="moreTooltipData.length" v-if="moreTooltipData.length"
ref="moreTooltip" ref="moreTooltip"
class="fc-more-tooltip" class="fc-more-tooltip"
:style="{ left: tooltipPosition.left + 'px', top: tooltipPosition.top + 'px' }" :style="{ left: tooltipPosition.left + 'px', top: tooltipPosition.top + 'px' }"
@mouseleave="hideMoreTooltip" @mouseleave="hideMoreTooltip"
> >
<div <div
v-for="(evt, idx) in moreTooltipData" v-for="(evt, idx) in moreTooltipData"
:key="idx" :key="idx"
class="fc-more-tooltip-event" class="fc-more-tooltip-event"
:style="{ borderLeftColor: evt.color }" :style="{ borderLeftColor: evt.color }"
@click="openEventFromTooltip(evt)" @click="openEventFromTooltip(evt)"
> >
{{ evt.title }} {{ evt.title }}
</div> </div>
</div> </div>
<!-- Event details modal --> <!-- Event details modal -->
<div v-if="selectedEvent" class="modal-overlay" @click.self="closeEventModal"> <div v-if="selectedEvent" class="modal-overlay" @click.self="closeEventModal">
<div class="modal"> <div class="modal">
<!-- Employee-photo event (recognition/recertification) with highlight --> <!-- Employee-photo event (recognition/recertification) with highlight -->
<div v-if="selectedEvent.extendedProps?.showemployeephoto" class="recognition-header"> <div v-if="selectedEvent.extendedProps?.showemployeephoto" class="recognition-header">
<div class="recognition-badge"> <div class="recognition-badge">
<span class="recognition-icon"><Trophy :size="24" /></span> <span class="recognition-icon"><Trophy :size="24" /></span>
</div> </div>
<div class="recognition-info"> <div class="recognition-info">
<div class="recognition-label">{{ selectedEvent.extendedProps?.typename || 'Recognition' }}</div> <div class="recognition-label">{{ selectedEvent.extendedProps?.typename || 'Recognition' }}</div>
<h2 class="recognition-title">{{ selectedEvent.extendedProps?.message || selectedEvent.title }}</h2> <h2 class="recognition-title">{{ selectedEvent.extendedProps?.message || selectedEvent.title }}</h2>
<div v-if="selectedEvent.extendedProps?.employeename || selectedEvent.extendedProps?.employeesso" class="recognition-employee"> <div v-if="selectedEvent.extendedProps?.employeename || selectedEvent.extendedProps?.employeesso" class="recognition-employee">
<span class="employee-icon"><User :size="16" /></span> <span class="employee-icon"><User :size="16" /></span>
<span class="employee-name">{{ selectedEvent.extendedProps.employeename || selectedEvent.extendedProps.employeesso }}</span> <span class="employee-name">{{ selectedEvent.extendedProps.employeename || selectedEvent.extendedProps.employeesso }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Regular event header --> <!-- Regular event header -->
<h2 v-else>{{ selectedEvent.title }}</h2> <h2 v-else>{{ selectedEvent.title }}</h2>
<div class="event-details"> <div class="event-details">
<p v-if="selectedEvent.extendedProps?.typename && !selectedEvent.extendedProps?.showemployeephoto"> <p v-if="selectedEvent.extendedProps?.typename && !selectedEvent.extendedProps?.showemployeephoto">
<strong>Type:</strong> {{ selectedEvent.extendedProps.typename }} <strong>Type:</strong> {{ selectedEvent.extendedProps.typename }}
</p> </p>
<p> <p>
<strong>Start:</strong> {{ formatDate(selectedEvent.start) }} <strong>Start:</strong> {{ formatDate(selectedEvent.start) }}
</p> </p>
<p v-if="selectedEvent.end"> <p v-if="selectedEvent.end">
<strong>End:</strong> {{ formatDate(selectedEvent.end) }} <strong>End:</strong> {{ formatDate(selectedEvent.end) }}
</p> </p>
<p v-if="selectedEvent.extendedProps?.message && !selectedEvent.extendedProps?.showemployeephoto" class="message-block"> <p v-if="selectedEvent.extendedProps?.message && !selectedEvent.extendedProps?.showemployeephoto" class="message-block">
<strong>Details:</strong> <strong>Details:</strong>
<span class="message-text">{{ selectedEvent.extendedProps.message }}</span> <span class="message-text">{{ selectedEvent.extendedProps.message }}</span>
</p> </p>
<p v-if="selectedEvent.extendedProps?.ticketnumber"> <p v-if="selectedEvent.extendedProps?.ticketnumber">
<strong>Ticket:</strong> {{ selectedEvent.extendedProps.ticketnumber }} <strong>Ticket:</strong> {{ selectedEvent.extendedProps.ticketnumber }}
</p> </p>
<p v-if="selectedEvent.extendedProps?.linkurl"> <p v-if="selectedEvent.extendedProps?.linkurl">
<a :href="selectedEvent.extendedProps.linkurl" target="_blank" class="btn btn-link"> <a :href="selectedEvent.extendedProps.linkurl" target="_blank" class="btn btn-link">
More Info More Info
</a> </a>
</p> </p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<router-link <router-link
v-if="selectedEvent.extendedProps?.notificationid" v-if="selectedEvent.extendedProps?.notificationid"
:to="`/notifications/${selectedEvent.extendedProps.notificationid}`" :to="`/notifications/${selectedEvent.extendedProps.notificationid}`"
class="btn btn-secondary" class="btn btn-secondary"
> >
View Notification View Notification
</router-link> </router-link>
<button class="btn btn-primary" @click="closeEventModal">Close</button> <button class="btn btn-primary" @click="closeEventModal">Close</button>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue' import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import FullCalendar from '@fullcalendar/vue3' import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid' import dayGridPlugin from '@fullcalendar/daygrid'
import { Trophy, User } from 'lucide-vue-next' import { Trophy, User } from 'lucide-vue-next'
import { notificationsApi } from '@/api' import { notificationsApi } from '@/api'
const events = ref([]) const events = ref([])
const selectedEvent = ref(null) const selectedEvent = ref(null)
const calendarRef = ref(null) const calendarRef = ref(null)
const moreTooltip = ref(null) const moreTooltip = ref(null)
const moreTooltipData = ref([]) const moreTooltipData = ref([])
const tooltipPosition = ref({ left: 0, top: 0 }) const tooltipPosition = ref({ left: 0, top: 0 })
// Store events by date for hover lookup // Store events by date for hover lookup
const eventsByDate = ref({}) const eventsByDate = ref({})
const calendarOptions = ref({ const calendarOptions = ref({
plugins: [dayGridPlugin], plugins: [dayGridPlugin],
initialView: 'dayGridMonth', initialView: 'dayGridMonth',
events: [], events: [],
eventClick: (info) => { eventClick: (info) => {
selectedEvent.value = info.event selectedEvent.value = info.event
}, },
headerToolbar: { headerToolbar: {
left: 'prev,next today', left: 'prev,next today',
center: 'title', center: 'title',
right: 'dayGridMonth,dayGridWeek' right: 'dayGridMonth,dayGridWeek'
}, },
height: 'auto', // 'auto' sized every row to its own content, so a month of mostly empty days
dayMaxEvents: 3, // collapsed into thin strips and the grid looked squashed. A viewport-relative
moreLinkClick: () => 'none' // Disable click, we use hover // height makes the calendar fill the space it has been given, and expandRows
}) // shares that height evenly across the weeks rather than leaving a gap under
// the last one.
function hideMoreTooltip() { height: 'calc(100vh - 230px)',
moreTooltipData.value = [] expandRows: true,
} dayMaxEvents: 3,
moreLinkClick: () => 'none' // Disable click, we use hover
function handleMoreLinkHover(e) { })
const moreLink = e.target.closest('.fc-daygrid-more-link')
if (!moreLink) { function hideMoreTooltip() {
return moreTooltipData.value = []
} }
// Find the day cell and get its date function handleMoreLinkHover(e) {
const dayCell = moreLink.closest('.fc-daygrid-day') const moreLink = e.target.closest('.fc-daygrid-more-link')
if (!dayCell) return if (!moreLink) {
return
const dateStr = dayCell.getAttribute('data-date') }
if (!dateStr || !eventsByDate.value[dateStr]) return
// Find the day cell and get its date
// Get events for this date that would be hidden (after first 3) const dayCell = moreLink.closest('.fc-daygrid-day')
const dayEvents = eventsByDate.value[dateStr] if (!dayCell) return
const hiddenEvents = dayEvents.slice(3).map(evt => ({
title: evt.title, const dateStr = dayCell.getAttribute('data-date')
color: evt.backgroundColor || '#14abef', if (!dateStr || !eventsByDate.value[dateStr]) return
// Include full event data for modal
start: evt.start, // Get events for this date that would be hidden (after first 3)
end: evt.end, const dayEvents = eventsByDate.value[dateStr]
extendedProps: evt.extendedProps || {} const hiddenEvents = dayEvents.slice(3).map(evt => ({
})) title: evt.title,
color: evt.backgroundColor || '#14abef',
if (hiddenEvents.length === 0) return // Include full event data for modal
start: evt.start,
moreTooltipData.value = hiddenEvents end: evt.end,
extendedProps: evt.extendedProps || {}
// Position tooltip }))
const rect = moreLink.getBoundingClientRect()
tooltipPosition.value = { if (hiddenEvents.length === 0) return
left: rect.left,
top: rect.bottom + 5 moreTooltipData.value = hiddenEvents
}
} // Position tooltip
const rect = moreLink.getBoundingClientRect()
function handleMoreLinkLeave(e) { tooltipPosition.value = {
const related = e.relatedTarget left: rect.left,
// Don't hide if moving to the tooltip itself top: rect.bottom + 5
if (related && (related.closest('.fc-more-tooltip') || related.closest('.fc-daygrid-more-link'))) { }
return }
}
hideMoreTooltip() function handleMoreLinkLeave(e) {
} const related = e.relatedTarget
// Don't hide if moving to the tooltip itself
function buildEventsByDate() { if (related && (related.closest('.fc-more-tooltip') || related.closest('.fc-daygrid-more-link'))) {
const byDate = {} return
for (const evt of events.value) { }
const dateStr = evt.start ? evt.start.split('T')[0] : null hideMoreTooltip()
if (dateStr) { }
if (!byDate[dateStr]) byDate[dateStr] = []
byDate[dateStr].push(evt) function buildEventsByDate() {
} const byDate = {}
} for (const evt of events.value) {
eventsByDate.value = byDate const dateStr = evt.start ? evt.start.split('T')[0] : null
} if (dateStr) {
if (!byDate[dateStr]) byDate[dateStr] = []
onMounted(async () => { byDate[dateStr].push(evt)
await loadEvents() }
}
// Add event delegation for more links eventsByDate.value = byDate
await nextTick() }
const container = document.querySelector('.calendar-container')
if (container) { onMounted(async () => {
container.addEventListener('mouseenter', handleMoreLinkHover, true) await loadEvents()
container.addEventListener('mouseleave', handleMoreLinkLeave, true)
} // Add event delegation for more links
}) await nextTick()
const container = document.querySelector('.calendar-container')
onUnmounted(() => { if (container) {
const container = document.querySelector('.calendar-container') container.addEventListener('mouseenter', handleMoreLinkHover, true)
if (container) { container.addEventListener('mouseleave', handleMoreLinkLeave, true)
container.removeEventListener('mouseenter', handleMoreLinkHover, true) }
container.removeEventListener('mouseleave', handleMoreLinkLeave, true) })
}
}) onUnmounted(() => {
const container = document.querySelector('.calendar-container')
onMounted(async () => { if (container) {
await loadEvents() container.removeEventListener('mouseenter', handleMoreLinkHover, true)
}) container.removeEventListener('mouseleave', handleMoreLinkLeave, true)
}
async function loadEvents() { })
try {
const response = await notificationsApi.getCalendar() onMounted(async () => {
events.value = response.data.data await loadEvents()
calendarOptions.value.events = events.value })
buildEventsByDate()
} catch (error) { async function loadEvents() {
console.error('Error loading calendar events:', error) try {
} const response = await notificationsApi.getCalendar()
} events.value = response.data.data
calendarOptions.value.events = events.value
function closeEventModal() { buildEventsByDate()
selectedEvent.value = null } catch (error) {
} console.error('Error loading calendar events:', error)
}
function openEventFromTooltip(evt) { }
// Create an event-like object that matches FullCalendar's event structure
selectedEvent.value = { function closeEventModal() {
title: evt.title, selectedEvent.value = null
start: evt.start, }
end: evt.end,
extendedProps: evt.extendedProps function openEventFromTooltip(evt) {
} // Create an event-like object that matches FullCalendar's event structure
hideMoreTooltip() selectedEvent.value = {
} title: evt.title,
start: evt.start,
function formatDate(dateStr) { end: evt.end,
if (!dateStr) return '' extendedProps: evt.extendedProps
// 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. hideMoreTooltip()
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])) function formatDate(dateStr) {
: new Date(dateStr) if (!dateStr) return ''
return date.toLocaleDateString('en-US', { // allDay events carry a site-local date-only value (YYYY-MM-DD). Build the
weekday: 'short', // Date from local parts so it is not shifted a day by UTC-midnight parsing.
year: 'numeric', const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr)
month: 'short', const date = parts
day: 'numeric' ? new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))
}) : new Date(dateStr)
} return date.toLocaleDateString('en-US', {
</script> weekday: 'short',
year: 'numeric',
<style scoped> month: 'short',
.calendar-container { day: 'numeric'
min-height: 500px; })
} }
</script>
.modal {
padding: 1.5rem; <style scoped>
} .calendar-container {
min-height: 640px;
.modal h2 { }
margin: 0 0 1rem 0;
font-size: 18px; /* :deep, because these are FullCalendar's own elements and scoped styles do not
color: var(--text); reach them. A floor under each day cell keeps the grid readable on a short
padding-bottom: 0.75rem; window, where the viewport-relative height above would otherwise squeeze the
border-bottom: 1px solid var(--border); rows back down. */
} :deep(.fc-daygrid-day-frame) {
min-height: 110px;
.event-details { }
padding: 0.5rem 0;
} /* The day number was tight against the top edge once the cells grew. */
:deep(.fc-daygrid-day-top) {
.event-details p { padding: 2px 4px;
margin-bottom: 0.75rem; }
font-size: 14px;
line-height: 1.5; .modal {
} padding: 1.5rem;
}
.event-details p:last-child {
margin-bottom: 0; .modal h2 {
} margin: 0 0 1rem 0;
font-size: 18px;
.event-details strong { color: var(--text);
color: var(--text-light); padding-bottom: 0.75rem;
display: inline-block; border-bottom: 1px solid var(--border);
min-width: 70px; }
}
.event-details {
.message-block { padding: 0.5rem 0;
display: flex; }
flex-direction: column;
gap: 0.25rem; .event-details p {
} margin-bottom: 0.75rem;
font-size: 14px;
.message-block strong { line-height: 1.5;
min-width: auto; }
}
.event-details p:last-child {
.message-text { margin-bottom: 0;
display: block; }
padding: 0.5rem 0.75rem;
background: var(--bg); .event-details strong {
border-radius: 0.25rem; color: var(--text-light);
white-space: pre-wrap; display: inline-block;
line-height: 1.5; min-width: 70px;
} }
.modal-actions { .message-block {
display: flex; display: flex;
gap: 0.5rem; flex-direction: column;
justify-content: flex-end; gap: 0.25rem;
margin-top: 1.25rem; }
padding-top: 1rem;
border-top: 1px solid var(--border); .message-block strong {
} min-width: auto;
}
/* Recognition event styling */
.recognition-header { .message-text {
display: flex; display: block;
gap: 1rem; padding: 0.5rem 0.75rem;
padding-bottom: 1rem; background: var(--bg);
margin-bottom: 1rem; border-radius: 0.25rem;
border-bottom: 1px solid var(--border); white-space: pre-wrap;
} line-height: 1.5;
}
.recognition-badge {
flex-shrink: 0; .modal-actions {
width: 60px; display: flex;
height: 60px; gap: 0.5rem;
background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%); justify-content: flex-end;
border-radius: 50%; margin-top: 1.25rem;
display: flex; padding-top: 1rem;
align-items: center; border-top: 1px solid var(--border);
justify-content: center; }
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
} /* Recognition event styling */
.recognition-header {
.recognition-icon { display: flex;
font-size: 28px; gap: 1rem;
} padding-bottom: 1rem;
margin-bottom: 1rem;
.recognition-info { border-bottom: 1px solid var(--border);
flex: 1; }
display: flex;
flex-direction: column; .recognition-badge {
justify-content: center; flex-shrink: 0;
} width: 60px;
height: 60px;
.recognition-label { background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
font-size: 11px; border-radius: 50%;
text-transform: uppercase; display: flex;
letter-spacing: 1px; align-items: center;
color: var(--primary); justify-content: center;
font-weight: 600; box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
margin-bottom: 0.25rem; }
}
.recognition-icon {
.recognition-title { font-size: 28px;
margin: 0 !important; }
padding: 0 !important;
border: none !important; .recognition-info {
font-size: 16px !important; flex: 1;
line-height: 1.4; display: flex;
color: var(--text) !important; flex-direction: column;
} justify-content: center;
}
.recognition-employee {
display: flex; .recognition-label {
align-items: center; font-size: 11px;
gap: 0.5rem; text-transform: uppercase;
margin-top: 0.5rem; letter-spacing: 1px;
padding: 0.5rem 0.75rem; color: var(--primary);
background: rgba(65, 129, 255, 0.1); font-weight: 600;
border-radius: 0.25rem; margin-bottom: 0.25rem;
border-left: 3px solid var(--primary); }
}
.recognition-title {
.employee-icon { margin: 0 !important;
font-size: 18px; padding: 0 !important;
} border: none !important;
font-size: 16px !important;
.employee-name { line-height: 1.4;
font-weight: 600; color: var(--text) !important;
color: var(--text); }
font-size: 15px;
} .recognition-employee {
</style> 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>

View File

@@ -275,7 +275,12 @@ const form = ref({
starttime: '', starttime: '',
endtime: '', endtime: '',
isactive: true, isactive: true,
isshopfloor: false, // Ticked for a NEW notification: the shopfloor board is where these are
// meant to be seen, and leaving it off by default meant most were written
// and then never appeared there. Editing an EXISTING notification still
// loads its own stored value (see loadNotification), so this never flips a
// setting somebody deliberately turned off.
isshopfloor: true,
employeesso: '' employeesso: ''
}) })