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>
<div class="page-header">
<h1>Calendar</h1>
</div>
<div 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 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'
},
height: 'auto',
dayMaxEvents: 3,
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
}
onMounted(async () => {
await loadEvents()
// Add event delegation for more links
await nextTick()
const container = document.querySelector('.calendar-container')
if (container) {
container.addEventListener('mouseenter', handleMoreLinkHover, true)
container.addEventListener('mouseleave', handleMoreLinkLeave, true)
}
})
onUnmounted(() => {
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 {
min-height: 500px;
}
.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>
<template>
<div class="page-header">
<h1>Calendar</h1>
</div>
<div 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 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'
},
// 'auto' sized every row to its own content, so a month of mostly empty days
// collapsed into thin strips and the grid looked squashed. A viewport-relative
// 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.
height: 'calc(100vh - 230px)',
expandRows: true,
dayMaxEvents: 3,
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
}
onMounted(async () => {
await loadEvents()
// Add event delegation for more links
await nextTick()
const container = document.querySelector('.calendar-container')
if (container) {
container.addEventListener('mouseenter', handleMoreLinkHover, true)
container.addEventListener('mouseleave', handleMoreLinkLeave, true)
}
})
onUnmounted(() => {
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 {
min-height: 640px;
}
/* :deep, because these are FullCalendar's own elements and scoped styles do not
reach them. A floor under each day cell keeps the grid readable on a short
window, where the viewport-relative height above would otherwise squeeze the
rows back down. */
:deep(.fc-daygrid-day-frame) {
min-height: 110px;
}
/* The day number was tight against the top edge once the cells grew. */
:deep(.fc-daygrid-day-top) {
padding: 2px 4px;
}
.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>

View File

@@ -275,7 +275,12 @@ const form = ref({
starttime: '',
endtime: '',
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: ''
})