Every time on the board was wrong, from two faults stacked. The shopfloor feed serialised starttime/endtime with a bare isoformat(). Those columns are stored NAIVE but hold UTC, so an untagged string is read by the browser as LOCAL and every card shifted by the tz offset. The model's to_dict already learned this - its _utc_iso helper documents the exact symptom, a 14:34 notification showing 18:34 - but the feed had not, so the feed now uses it too. The dashboard then formatted with toLocaleString, i.e. the VIEWER's zone. A board hangs on a wall in the plant: it has to read plant time whatever the machine driving it is set to, and a kiosk with a wrong system timezone would otherwise show wrong times to the floor with nothing to reveal it. It now loads site_timezone and formats through formatInZone, the wall clock included - a header disagreeing with the cards beneath it is worse than either being wrong alone. startsWhen was worse still: it decided TODAY/TOMORROW from browser-local calendar days, so the wording itself could differ between the board and a remote admin looking at the same card. That arithmetic now runs on the site's calendar day. Separately, the type chip carried a margin-bottom while the state chip beside it did not. .chip-row centres each item's MARGIN box, so that margin lifted the type chip about 4px and left "Starts Thu, Aug 13 8:00 PM" looking low. The row already provides the spacing, so the chip's own margin is gone.
1139 lines
35 KiB
Vue
1139 lines
35 KiB
Vue
<template>
|
|
<div class="shopfloor-dashboard">
|
|
<div class="dash-fit" ref="fitEl">
|
|
<header class="dashboard-header">
|
|
<div class="logo-container">
|
|
<img :src="siteLogo" alt="Site logo" class="logo" />
|
|
</div>
|
|
|
|
<div class="header-center">
|
|
<div class="location-title">{{ facilityName }}</div>
|
|
<h1>Shopfloor Dashboard</h1>
|
|
</div>
|
|
|
|
<div class="header-right">
|
|
<div class="clock">{{ currentTime }}</div>
|
|
<select v-model="businessUnit" class="filter-select" @change="loadData">
|
|
<option value="">All Locations</option>
|
|
<option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
|
|
{{ bu.businessunit }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="dashboard-content">
|
|
<!--
|
|
One section per TYPE, in style order (banner, carousel, grid). Grouping
|
|
by display style alone merged every grid type into Recertification's
|
|
row and every carousel type into Recognition's, under that type's
|
|
heading. Each type now carries its own row titled by its own name.
|
|
-->
|
|
<template v-for="group in styledGroups" :key="group.key">
|
|
|
|
<!-- Banner - full-width strips, one per message -->
|
|
<section v-if="group.displaystyle === 'banner'" class="banner-section">
|
|
<div
|
|
v-for="n in group.items"
|
|
:key="n.notificationid"
|
|
class="banner-strip"
|
|
:class="[stateKind(n) ? 'pending' : '', stateKind(n)]"
|
|
:style="{ backgroundColor: getTypeColor(n.typecolor) }"
|
|
>
|
|
<span v-if="stateLabel(n)" class="state-chip" :class="stateKind(n)">{{ stateLabel(n) }}</span>
|
|
{{ n.notification }}
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Carousel - one card at a time, rotating -->
|
|
<section v-else-if="group.displaystyle === 'carousel'" class="recognition-section">
|
|
<div
|
|
class="section-title recognition"
|
|
:style="headingStyle(group)"
|
|
>{{ group.typename }}</div>
|
|
<div class="recognition-carousel">
|
|
<div
|
|
v-for="(rec, idx) in group.items"
|
|
:key="`${rec.notificationid}-${rec.employeesso}`"
|
|
class="recognition-card"
|
|
:class="[{ active: idx === carouselIndex(group) }, stateKind(rec) ? 'pending' : '', stateKind(rec)]"
|
|
:style="{ '--accent': getTypeColor(rec.typecolor) }"
|
|
>
|
|
<!-- A type that names no employee is a message, not a person:
|
|
no photo frame, no empty name line. -->
|
|
<div v-if="hasEmployee(rec)" class="recognition-photo-container">
|
|
<img
|
|
v-if="rec.employeepicture"
|
|
:src="rec.employeepicture"
|
|
:alt="rec.employeename"
|
|
class="recognition-photo"
|
|
@error="handlePhotoError"
|
|
/>
|
|
<img
|
|
v-else
|
|
:src="employeePhotoFallback"
|
|
alt="No photo"
|
|
class="recognition-photo ge-logo-fallback"
|
|
/>
|
|
</div>
|
|
<div class="recognition-content">
|
|
<div v-if="hasEmployee(rec)" class="recognition-header">
|
|
<div class="recognition-name">{{ rec.employeename }}</div>
|
|
</div>
|
|
<!-- On a shared category row the heading names the category, so
|
|
the card has to say which type it is. Named, not just
|
|
coloured: colour alone is no use to a colourblind reader
|
|
across a 1920 board. -->
|
|
<div class="chip-row">
|
|
<span
|
|
v-if="isMixed(group)"
|
|
class="type-chip"
|
|
:style="{ backgroundColor: getTypeColor(rec.typecolor), color: textOn(rec.typecolor) }"
|
|
>{{ rec.typename }}</span>
|
|
<span v-if="stateLabel(rec)" class="state-chip" :class="stateKind(rec)">{{ stateLabel(rec) }}</span>
|
|
</div>
|
|
<div class="recognition-message">{{ rec.notification }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- One pip per card, so a reader knows how many are in the rotation
|
|
and how long until theirs comes back round. -->
|
|
<div v-if="group.items.length > 1" class="carousel-pips">
|
|
<span
|
|
v-for="(rec, idx) in group.items"
|
|
:key="`pip-${rec.notificationid}-${rec.employeesso}`"
|
|
class="pip"
|
|
:class="{ on: idx === carouselIndex(group), scheduled: rec.upcoming }"
|
|
:style="{ backgroundColor: getTypeColor(rec.typecolor) }"
|
|
></span>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Grid - a row of tiles, cycling a page at a time -->
|
|
<section v-else class="recert-section">
|
|
<div
|
|
class="section-title recert-title"
|
|
:style="headingStyle(group)"
|
|
>
|
|
<span>{{ group.typename }} ({{ group.items.length }})</span>
|
|
<span v-if="gridRangeLabel(group)" class="recert-range">{{ gridRangeLabel(group) }}</span>
|
|
</div>
|
|
<div
|
|
v-for="msg in groupDescriptions(group)"
|
|
:key="msg"
|
|
class="recert-description"
|
|
>
|
|
{{ msg }}
|
|
</div>
|
|
<div class="recert-row">
|
|
<div
|
|
v-for="rec in gridPageItems(group)"
|
|
:key="`grid-${rec.notificationid}-${rec.employeesso}`"
|
|
class="recert-tile"
|
|
:class="[{ 'message-tile': !hasEmployee(rec) }, stateKind(rec) ? 'pending' : '', stateKind(rec)]"
|
|
:style="{ '--accent': getTypeColor(rec.typecolor) }"
|
|
>
|
|
<template v-if="hasEmployee(rec)">
|
|
<img
|
|
v-if="rec.employeepicture"
|
|
:src="rec.employeepicture"
|
|
:alt="rec.employeename"
|
|
class="recert-photo"
|
|
@error="handlePhotoError"
|
|
/>
|
|
<img
|
|
v-else
|
|
:src="employeePhotoFallback"
|
|
alt="No photo"
|
|
class="recert-photo ge-logo-fallback"
|
|
/>
|
|
<div class="recert-name">{{ rec.employeename || rec.employeesso }}</div>
|
|
</template>
|
|
<!-- No employee: the message IS the tile, so a grid type like
|
|
Awareness does not render rows of blank placeholder faces. -->
|
|
<div v-else class="recert-message">{{ rec.notification }}</div>
|
|
<span v-if="stateLabel(rec)" class="state-chip tile-state" :class="stateKind(rec)">{{ stateLabel(rec) }}</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</template>
|
|
|
|
<!-- Current Notifications -->
|
|
<section v-if="currentNotifications.length" class="notifications-section">
|
|
<div class="section-title" :style="itemsHeadingStyle(currentNotifications)">
|
|
Current Notifications
|
|
</div>
|
|
<div class="events-list">
|
|
<div
|
|
v-for="n in currentNotifications"
|
|
:key="n.notificationid"
|
|
class="event-card"
|
|
:class="{ resolved: n.resolved }"
|
|
>
|
|
<div class="event-indicator" :style="{ backgroundColor: getTypeColor(n.typecolor) }"></div>
|
|
<div class="event-content">
|
|
<div class="event-title">{{ n.notification }}</div>
|
|
<div class="event-time">
|
|
<template v-if="n.resolved">
|
|
<strong>RESOLVED</strong>
|
|
</template>
|
|
<template v-else>
|
|
<strong>{{ formatTime(n.starttime) }}</strong>
|
|
<span v-if="n.endtime"> - {{ formatTime(n.endtime) }}</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<template v-if="n.ticketnumber">
|
|
<a v-if="getTicketUrl(n.ticketnumber)" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
|
|
{{ n.ticketnumber }}
|
|
</a>
|
|
<span v-else class="event-ticket">{{ n.ticketnumber }}</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Upcoming Notifications -->
|
|
<section v-if="upcomingNotifications.length" class="notifications-section">
|
|
<div class="section-title upcoming" :style="itemsHeadingStyle(upcomingNotifications)">Upcoming</div>
|
|
<div class="events-list">
|
|
<div
|
|
v-for="n in upcomingNotifications"
|
|
:key="n.notificationid"
|
|
class="event-card"
|
|
>
|
|
<div class="event-indicator" :style="{ backgroundColor: getTypeColor(n.typecolor) }"></div>
|
|
<div class="event-content">
|
|
<div class="event-title">{{ n.notification }}</div>
|
|
<div class="event-time">
|
|
<strong>{{ formatDateTime(n.starttime) }}</strong>
|
|
</div>
|
|
</div>
|
|
<template v-if="n.ticketnumber">
|
|
<a v-if="getTicketUrl(n.ticketnumber)" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
|
|
{{ n.ticketnumber }}
|
|
</a>
|
|
<span v-else class="event-ticket">{{ n.ticketnumber }}</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- No notifications -->
|
|
<div v-if="!loading && !currentNotifications.length && !upcomingNotifications.length && !styledGroups.length" class="no-events">
|
|
No active notifications
|
|
</div>
|
|
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
</main>
|
|
|
|
<footer class="dashboard-footer">
|
|
Auto-refreshes every 30 seconds
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
|
import { notificationsApi, businessUnitsApi, dashboardDefaultsApi, settingsApi } from '@/api'
|
|
import { formatInZone, zonedInputFromUtc, DEFAULT_TZ } from '@/utils/datetime'
|
|
import { getFacilityName, getSiteLogo, getServicenowUrls } from '@/utils/siteSettings'
|
|
import { withBase } from '@/utils/basePath'
|
|
|
|
const loading = ref(true)
|
|
const facilityName = ref('ShopDB')
|
|
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
|
|
// Employee photo placeholder: the shipped GE monogram (square avatar). Kept
|
|
// separate from siteLogo so a blank/broken site_logo setting never leaves an
|
|
// employee tile with no image.
|
|
const employeePhotoFallback = ref(withBase('/ge-monogram.svg'))
|
|
// ServiceNow ticket-link config; loaded on mount. Empty/disabled = plain text.
|
|
const servicenowConfig = ref({ enabled: true, incidentUrl: '', changeUrl: '' })
|
|
const businessUnit = ref('')
|
|
const businessUnits = ref([])
|
|
const notifications = ref({ current: [], upcoming: [] })
|
|
// Layout-config fingerprint from the feed; when it changes the kiosk reloads.
|
|
const loadedConfigVersion = ref(null)
|
|
|
|
let refreshInterval = null
|
|
let recognitionInterval = null
|
|
let recertPageInterval = null
|
|
|
|
// Fit-to-viewport: the board is a fixed 1920-wide design; scale the whole thing
|
|
// to fill the screen so a TV shows everything with no scrolling, at any size.
|
|
const fitEl = ref(null)
|
|
let resizeObserver = null
|
|
function fitToScreen() {
|
|
const el = fitEl.value
|
|
if (!el) return
|
|
const w = el.offsetWidth || 1
|
|
const h = el.offsetHeight || 1
|
|
const scale = Math.min(window.innerWidth / w, window.innerHeight / h)
|
|
el.style.transform = `scale(${scale})`
|
|
el.style.left = `${Math.max(0, (window.innerWidth - w * scale) / 2)}px`
|
|
}
|
|
|
|
// The board groups cards by each type's configured display style, so any custom
|
|
// type set to carousel/grid/banner renders that way - not just the built-ins.
|
|
const SPECIAL_STYLES = ['carousel', 'grid', 'banner']
|
|
|
|
// Rows run in the order each type is given (boardorder, low first), so a site
|
|
// decides what leads the screen. Style is only the tie-break, for types left on
|
|
// the same number: strips, then rotating cards, then tile rows.
|
|
const STYLE_ORDER = { banner: 0, carousel: 1, grid: 2 }
|
|
const DEFAULT_BOARD_ORDER = 100
|
|
|
|
// One row per TYPE, unless types opt into a shared boardcategory - Change,
|
|
// Awareness and Incident under one "Alerts" heading, say, while Recognition and
|
|
// Recertification keep rows of their own. Grouping by display style alone
|
|
// folded every grid type into one row under someone else's name.
|
|
// The style is part of the key: a category cannot merge a banner with a tile
|
|
// row, so same-category types of different styles still render separately.
|
|
const styledGroups = computed(() => {
|
|
const groups = new Map()
|
|
// Upcoming cards belong here too. They used to be filtered into the standard
|
|
// Upcoming list, which drops anything with a special style - so a scheduled
|
|
// banner or carousel card rendered nowhere at all until it went live.
|
|
const tagged = [
|
|
...notifications.value.current.map(n => ({ ...n, upcoming: false })),
|
|
...notifications.value.upcoming.map(n => ({ ...n, upcoming: true }))
|
|
]
|
|
for (const n of tagged) {
|
|
if (!SPECIAL_STYLES.includes(n.displaystyle)) continue
|
|
const heading = (n.boardcategory || '').trim() || n.typename || n.displaystyle
|
|
const key = `${n.displaystyle}|${heading}`
|
|
if (!groups.has(key)) {
|
|
groups.set(key, {
|
|
key,
|
|
typename: heading,
|
|
displaystyle: n.displaystyle,
|
|
// Heading colour comes from the first type in the row. A shared
|
|
// category is one heading, so it can only carry one colour; the
|
|
// individual cards below still wear their own type's colour.
|
|
typecolor: n.typecolor,
|
|
// A shared row sits wherever its earliest-ordered type puts it.
|
|
boardorder: DEFAULT_BOARD_ORDER,
|
|
items: []
|
|
})
|
|
}
|
|
const group = groups.get(key)
|
|
group.items.push(n)
|
|
const order = Number.isFinite(n.boardorder) ? n.boardorder : DEFAULT_BOARD_ORDER
|
|
group.boardorder = Math.min(group.boardorder, order)
|
|
}
|
|
// Live cards lead each row; what has not started yet follows.
|
|
for (const group of groups.values()) {
|
|
group.items.sort((a, b) => Number(a.upcoming) - Number(b.upcoming))
|
|
}
|
|
return [...groups.values()].sort((a, b) =>
|
|
(a.boardorder - b.boardorder) ||
|
|
(STYLE_ORDER[a.displaystyle] - STYLE_ORDER[b.displaystyle]) ||
|
|
a.typename.localeCompare(b.typename)
|
|
)
|
|
})
|
|
|
|
// A card belongs to a person only if it names one; a plain message type set to
|
|
// carousel or grid has no SSO and must not render a placeholder face.
|
|
function hasEmployee(n) {
|
|
return !!(n.employeesso || n.employeename || n.employeepicture)
|
|
}
|
|
|
|
// Distinct messages shown once above a grid row (the per-person tiles carry
|
|
// only photo + name, so the message would otherwise have nowhere to go).
|
|
function groupDescriptions(group) {
|
|
if (!group.items.some(hasEmployee)) return []
|
|
const seen = new Set()
|
|
const out = []
|
|
for (const item of group.items) {
|
|
const msg = (item.notification || '').trim()
|
|
if (msg && !seen.has(msg)) { seen.add(msg); out.push(msg) }
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Rotation state is per group, keyed by type: two grid rows page independently
|
|
// instead of sharing one index.
|
|
const GRID_PAGE_SIZE = 8
|
|
const carouselIndexes = ref({})
|
|
const gridPages = ref({})
|
|
|
|
function carouselIndex(group) {
|
|
return (carouselIndexes.value[group.key] || 0) % Math.max(1, group.items.length)
|
|
}
|
|
function gridPageCount(group) {
|
|
return Math.max(1, Math.ceil(group.items.length / GRID_PAGE_SIZE))
|
|
}
|
|
function gridPageItems(group) {
|
|
const page = (gridPages.value[group.key] || 0) % gridPageCount(group)
|
|
const start = page * GRID_PAGE_SIZE
|
|
return group.items.slice(start, start + GRID_PAGE_SIZE)
|
|
}
|
|
function gridRangeLabel(group) {
|
|
if (gridPageCount(group) <= 1) return ''
|
|
const page = (gridPages.value[group.key] || 0) % gridPageCount(group)
|
|
const start = page * GRID_PAGE_SIZE
|
|
const end = Math.min(start + GRID_PAGE_SIZE, group.items.length)
|
|
return `${start + 1}-${end} of ${group.items.length}`
|
|
}
|
|
|
|
const currentNotifications = computed(() =>
|
|
notifications.value.current.filter(n => !SPECIAL_STYLES.includes(n.displaystyle))
|
|
)
|
|
|
|
const upcomingNotifications = computed(() =>
|
|
notifications.value.upcoming.filter(n => !SPECIAL_STYLES.includes(n.displaystyle))
|
|
)
|
|
|
|
// Clock
|
|
// Site timezone drives every time on the board. Defaults until the setting
|
|
// loads, so a slow settings call shows plausible time rather than blank.
|
|
const siteTimezone = ref(DEFAULT_TZ)
|
|
|
|
const currentTime = ref('')
|
|
function updateClock() {
|
|
// The wall clock too: a board showing a different time to the cards beneath
|
|
// it is worse than either being wrong on its own.
|
|
currentTime.value = formatInZone(new Date(), siteTimezone.value, {
|
|
year: undefined, month: undefined, day: undefined,
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
|
})
|
|
}
|
|
|
|
onMounted(async () => {
|
|
// Before the first clock tick, so the board never renders viewer-zone time.
|
|
try {
|
|
const response = await settingsApi.get('site_timezone')
|
|
const value = response?.data?.data?.value
|
|
if (value) siteTimezone.value = value
|
|
} catch (e) {
|
|
// Keep the default zone; the board must still come up.
|
|
}
|
|
|
|
updateClock()
|
|
setInterval(updateClock, 1000)
|
|
|
|
getFacilityName().then(name => { facilityName.value = name })
|
|
getSiteLogo().then(logo => { siteLogo.value = logo })
|
|
getServicenowUrls().then(config => { servicenowConfig.value = config })
|
|
|
|
// Load business units
|
|
try {
|
|
const response = await businessUnitsApi.list()
|
|
businessUnits.value = response.data.data || []
|
|
} catch (err) {
|
|
console.error('Error loading business units:', err)
|
|
}
|
|
|
|
// Auto-select this kiosk's business unit from its IP (visitor location),
|
|
// unless one was already chosen. Falls back to "all" when the IP is unmapped.
|
|
if (!businessUnit.value) {
|
|
try {
|
|
const response = await dashboardDefaultsApi.visitorLocation()
|
|
if (response.data.data?.businessunitid) {
|
|
businessUnit.value = response.data.data.businessunitid
|
|
}
|
|
} catch (err) {
|
|
console.error('Error resolving visitor location:', err)
|
|
}
|
|
}
|
|
|
|
await loadData()
|
|
|
|
// Auto-refresh every 30 seconds
|
|
refreshInterval = setInterval(loadData, 30000)
|
|
|
|
// Advance every carousel row every 8 seconds. Each row keeps its own index,
|
|
// so a row of three cards does not drag a row of ten along with it.
|
|
recognitionInterval = setInterval(() => {
|
|
for (const group of styledGroups.value) {
|
|
if (group.displaystyle !== 'carousel' || group.items.length < 2) continue
|
|
const next = (carouselIndexes.value[group.key] || 0) + 1
|
|
carouselIndexes.value[group.key] = next % group.items.length
|
|
}
|
|
}, 8000)
|
|
|
|
// Page every grid row every 7 seconds, each on its own page count.
|
|
recertPageInterval = setInterval(() => {
|
|
for (const group of styledGroups.value) {
|
|
if (group.displaystyle !== 'grid') continue
|
|
const pages = gridPageCount(group)
|
|
if (pages < 2) continue
|
|
gridPages.value[group.key] = ((gridPages.value[group.key] || 0) + 1) % pages
|
|
}
|
|
}, 7000)
|
|
|
|
// Scale to fit once rendered, and re-fit whenever the content or window
|
|
// changes size (new data, banner cycling, resolution change).
|
|
await nextTick()
|
|
fitToScreen()
|
|
resizeObserver = new ResizeObserver(() => fitToScreen())
|
|
if (fitEl.value) resizeObserver.observe(fitEl.value)
|
|
window.addEventListener('resize', fitToScreen)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (refreshInterval) clearInterval(refreshInterval)
|
|
if (recognitionInterval) clearInterval(recognitionInterval)
|
|
if (recertPageInterval) clearInterval(recertPageInterval)
|
|
resizeObserver?.disconnect()
|
|
window.removeEventListener('resize', fitToScreen)
|
|
})
|
|
|
|
async function loadData() {
|
|
try {
|
|
const params = {}
|
|
if (businessUnit.value) {
|
|
params.businessunit = businessUnit.value
|
|
}
|
|
const response = await notificationsApi.getShopfloor(params)
|
|
const data = response.data.data || { current: [], upcoming: [] }
|
|
|
|
// Reload the kiosk when the board's layout config changes, so type/style
|
|
// edits (and deploys, via SHOPFLOOR_BUILD) reach already-open pages without
|
|
// anyone touching the machine.
|
|
const version = data.configversion
|
|
if (version) {
|
|
if (loadedConfigVersion.value && loadedConfigVersion.value !== version) {
|
|
window.location.reload()
|
|
return
|
|
}
|
|
loadedConfigVersion.value = version
|
|
}
|
|
|
|
notifications.value = { current: data.current || [], upcoming: data.upcoming || [] }
|
|
} catch (err) {
|
|
console.error('Error loading shopfloor data:', err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function getTypeColor(typecolor) {
|
|
// Types store a hex color, used directly. Only legacy Bootstrap color names
|
|
// still need aliasing; anything else (a hex) passes straight through.
|
|
const aliases = {
|
|
success: '#04b962',
|
|
warning: '#ff8800',
|
|
danger: '#f5365c',
|
|
info: '#14abef',
|
|
primary: '#7934f3',
|
|
secondary: '#94614f'
|
|
}
|
|
return aliases[typecolor] || typecolor || '#14abef'
|
|
}
|
|
|
|
// What a card's state chip says, or '' while it is simply live. Resolved is the
|
|
// tail a type's grace window buys it; upcoming is scheduled and not yet showing.
|
|
function stateLabel(item) {
|
|
if (item.upcoming) return `STARTS ${startsWhen(item.starttime)}`
|
|
return item.resolved ? 'RESOLVED' : ''
|
|
}
|
|
|
|
// '' while live, else which chip treatment the card wears.
|
|
function stateKind(item) {
|
|
if (item.upcoming) return 'upcoming'
|
|
return item.resolved ? 'resolved' : ''
|
|
}
|
|
|
|
// When an upcoming card starts, phrased for someone walking past: today and
|
|
// tomorrow by name, anything further out by weekday and date. An absolute
|
|
// timestamp alone makes a reader do the arithmetic.
|
|
function startsWhen(dateStr) {
|
|
if (!dateStr) return 'SOON'
|
|
const time = formatInZone(dateStr, siteTimezone.value, {
|
|
year: undefined, month: undefined, day: undefined,
|
|
hour: 'numeric', minute: '2-digit'
|
|
})
|
|
// TODAY/TOMORROW has to be decided on the SITE's calendar day. Doing the
|
|
// arithmetic on browser-local days makes the wording itself wrong for a
|
|
// viewer in another zone - a card can read TOMORROW to a remote admin and
|
|
// TODAY on the board, from the same data.
|
|
const startday = zonedInputFromUtc(dateStr, siteTimezone.value).slice(0, 10)
|
|
const todayday = zonedInputFromUtc(new Date(), siteTimezone.value).slice(0, 10)
|
|
const days = Math.round(
|
|
(new Date(`${startday}T00:00:00`) - new Date(`${todayday}T00:00:00`)) / 86400000)
|
|
if (days <= 0) return `TODAY ${time}`
|
|
if (days === 1) return `TOMORROW ${time}`
|
|
const when = formatInZone(dateStr, siteTimezone.value, {
|
|
weekday: 'short', month: 'short', day: 'numeric',
|
|
hour: undefined, minute: undefined
|
|
})
|
|
return `${when} ${time}`
|
|
}
|
|
|
|
// A row holding more than one type: the heading names a category, so it cannot
|
|
// speak for any single type's colour or name.
|
|
function isMixed(group) {
|
|
return new Set(group.items.map(item => item.typename)).size > 1
|
|
}
|
|
|
|
// The colour a set of cards weighs most heavily towards: whichever type has
|
|
// the most cards, ties going to the type that sorts first on the board. A blend
|
|
// of several type colours just makes mud, and picking the first arbitrarily
|
|
// mislabels the rest - a plurality is at least true of the row.
|
|
//
|
|
// Live cards decide it. Only when nothing on the row has started yet does the
|
|
// upcoming set get a say, so a heading always describes what is happening now
|
|
// rather than what is scheduled.
|
|
function dominantColor(items) {
|
|
const live = items.filter(item => !item.upcoming && !item.resolved)
|
|
const scheduled = items.filter(item => item.upcoming)
|
|
const pool = live.length ? live : (scheduled.length ? scheduled : items)
|
|
const weights = new Map()
|
|
for (const item of pool) {
|
|
const key = item.typecolor || ''
|
|
const seen = weights.get(key) || { count: 0, order: Number.MAX_SAFE_INTEGER }
|
|
seen.count += 1
|
|
seen.order = Math.min(seen.order, Number.isFinite(item.boardorder)
|
|
? item.boardorder : DEFAULT_BOARD_ORDER)
|
|
weights.set(key, seen)
|
|
}
|
|
let winner = null
|
|
for (const [typecolor, seen] of weights) {
|
|
if (!winner || seen.count > winner.count ||
|
|
(seen.count === winner.count && seen.order < winner.order)) {
|
|
winner = { typecolor, ...seen }
|
|
}
|
|
}
|
|
return winner ? winner.typecolor : null
|
|
}
|
|
|
|
// Every heading on the board takes its colour from the types beneath it; none
|
|
// is hardcoded per row or per display style.
|
|
function headingStyle(group) {
|
|
return itemsHeadingStyle(group.items)
|
|
}
|
|
|
|
function itemsHeadingStyle(items) {
|
|
const typecolor = dominantColor(items)
|
|
return {
|
|
backgroundColor: getTypeColor(typecolor),
|
|
color: textOn(typecolor)
|
|
}
|
|
}
|
|
|
|
// Text colour for a heading painted an arbitrary type colour. Gold needs dark
|
|
// text, navy needs white, and a site picks its own hexes - so this is computed
|
|
// rather than a per-colour rule.
|
|
function textOn(backgroundcolor) {
|
|
const hex = getTypeColor(backgroundcolor).replace('#', '')
|
|
if (hex.length < 6) return '#fff'
|
|
const r = parseInt(hex.slice(0, 2), 16)
|
|
const g = parseInt(hex.slice(2, 4), 16)
|
|
const b = parseInt(hex.slice(4, 6), 16)
|
|
// Rec. 709 luma, the usual quick contrast test.
|
|
return (0.2126 * r + 0.7152 * g + 0.0722 * b) > 150 ? '#231b00' : '#fff'
|
|
}
|
|
|
|
// SITE zone, not the viewer's. A board hangs on a wall in the plant: it must
|
|
// read plant time whatever the machine driving it is set to, and a kiosk with a
|
|
// wrong system timezone would otherwise silently show wrong times to the floor.
|
|
function formatTime(dateStr) {
|
|
if (!dateStr) return ''
|
|
return formatInZone(dateStr, siteTimezone.value, {
|
|
year: undefined, month: undefined, day: undefined,
|
|
hour: '2-digit', minute: '2-digit'
|
|
})
|
|
}
|
|
|
|
function formatDateTime(dateStr) {
|
|
if (!dateStr) return ''
|
|
return formatInZone(dateStr, siteTimezone.value, {
|
|
weekday: 'short',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})
|
|
}
|
|
|
|
function getTicketUrl(ticketnumber) {
|
|
// Return a URL only when ServiceNow is enabled and a matching template is set.
|
|
// Null means "render the ticket number as plain text, no link".
|
|
if (!ticketnumber) return null
|
|
const config = servicenowConfig.value
|
|
if (!config.enabled) return null
|
|
if (ticketnumber.startsWith('GEINC') && config.incidentUrl) {
|
|
return config.incidentUrl.replace('{ticket}', ticketnumber)
|
|
}
|
|
if (ticketnumber.startsWith('GECHG') && config.changeUrl) {
|
|
return config.changeUrl.replace('{ticket}', ticketnumber)
|
|
}
|
|
return null
|
|
}
|
|
|
|
function handlePhotoError(e) {
|
|
// Photo URL 404'd (e.g. no photo file for this SSO): fall back to the GE
|
|
// monogram. Guard against a loop if the fallback itself fails to load.
|
|
if (e.target.dataset.fellback) { return }
|
|
e.target.dataset.fellback = '1'
|
|
e.target.src = employeePhotoFallback.value
|
|
e.target.classList.add('ge-logo-fallback')
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.shopfloor-dashboard {
|
|
width: 100vw;
|
|
height: 100vh;
|
|
position: relative;
|
|
background: #00003d;
|
|
color: #fff;
|
|
font-family: Verdana, Geneva, Tahoma, 'Inter Variable', sans-serif;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Fixed 1920-wide design surface; JS scales it to fill the viewport (TV) so
|
|
the whole board is visible with no scrolling. */
|
|
.dash-fit {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 1600px;
|
|
min-height: 900px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
transform-origin: top left;
|
|
}
|
|
|
|
.dashboard-header {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr auto;
|
|
align-items: center;
|
|
gap: 30px;
|
|
padding: 20px 40px;
|
|
border-bottom: 3px solid #4181ff;
|
|
}
|
|
|
|
.logo {
|
|
height: 90px;
|
|
width: auto;
|
|
}
|
|
|
|
.header-center {
|
|
text-align: center;
|
|
}
|
|
|
|
.location-title {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #b8c4d8;
|
|
text-transform: uppercase;
|
|
letter-spacing: 2px;
|
|
}
|
|
|
|
.header-center h1 {
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
color: #ffffff;
|
|
text-transform: uppercase;
|
|
letter-spacing: 2px;
|
|
margin: 0;
|
|
}
|
|
|
|
.header-right {
|
|
text-align: right;
|
|
}
|
|
|
|
.clock {
|
|
font-size: 28px;
|
|
font-weight: 600;
|
|
color: #4181ff;
|
|
font-variant-numeric: tabular-nums;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.filter-select {
|
|
padding: 10px 16px;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
background: #1a1a5e;
|
|
color: #fff;
|
|
border: 2px solid #4181ff;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
min-width: 200px;
|
|
}
|
|
|
|
.dashboard-content {
|
|
padding: 20px 40px;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
padding: 12px 20px;
|
|
border-radius: 8px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 2px;
|
|
margin-bottom: 15px;
|
|
/* Neutral only until the inline, type-derived colour lands - every heading
|
|
on this board is coloured by the types beneath it. */
|
|
background: #4181ff;
|
|
}
|
|
|
|
.section-title.recognition {
|
|
}
|
|
|
|
/* Recertification grid - blue, compact tiles so 20-30 people are all visible
|
|
at once (no carousel to wait through) */
|
|
.recert-section {
|
|
margin-bottom: 25px;
|
|
}
|
|
.section-title.recert-title {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.recert-range {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
letter-spacing: 1px;
|
|
opacity: 0.85;
|
|
}
|
|
.recert-description {
|
|
font-size: 22px;
|
|
font-weight: 600;
|
|
color: #cbd5e1;
|
|
margin: -4px 0 14px;
|
|
line-height: 1.35;
|
|
}
|
|
/* Single row that cycles through pages of employees */
|
|
.recert-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(8, 1fr);
|
|
gap: 14px;
|
|
}
|
|
.recert-tile {
|
|
position: relative;
|
|
overflow: hidden;
|
|
background: linear-gradient(135deg, #16233b 0%, #0d2137 100%);
|
|
border: 2px solid var(--accent, #0d6efd);
|
|
border-radius: 10px;
|
|
padding: 14px 10px 14px 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 10px;
|
|
text-align: center;
|
|
}
|
|
.recert-photo {
|
|
width: 90px;
|
|
height: 90px;
|
|
border-radius: 50%;
|
|
object-fit: cover;
|
|
border: 3px solid var(--accent, #0d6efd);
|
|
background: #1a1a2e;
|
|
}
|
|
.recert-photo.ge-logo-fallback {
|
|
object-fit: contain;
|
|
padding: 12px;
|
|
background: #fff;
|
|
}
|
|
.recert-name {
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
line-height: 1.15;
|
|
/* Always reserve two lines so a name that wraps does not make its tile
|
|
taller and shift the whole grid. Longer names clamp with an ellipsis. */
|
|
height: 2.3em;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.section-title.upcoming {
|
|
}
|
|
|
|
/* Recognition carousel */
|
|
.banner-section {
|
|
margin-bottom: 25px;
|
|
}
|
|
|
|
.banner-strip {
|
|
padding: 22px 30px;
|
|
border-radius: 10px;
|
|
margin-bottom: 12px;
|
|
color: #fff;
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
text-align: center;
|
|
text-wrap: balance;
|
|
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
|
|
}
|
|
|
|
.recognition-section {
|
|
margin-bottom: 25px;
|
|
}
|
|
|
|
.recognition-carousel {
|
|
position: relative;
|
|
/* Enough for a photo card; a message-only card is shorter and leaves the
|
|
rest as breathing room rather than resizing the board every 8 seconds. */
|
|
min-height: 170px;
|
|
}
|
|
|
|
.carousel-pips {
|
|
display: flex;
|
|
gap: 8px;
|
|
justify-content: center;
|
|
margin-top: 10px;
|
|
}
|
|
.pip {
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 50%;
|
|
opacity: 0.35;
|
|
transition: opacity 0.4s ease, transform 0.4s ease;
|
|
}
|
|
.pip.on {
|
|
opacity: 1;
|
|
transform: scale(1.25);
|
|
}
|
|
|
|
.chip-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
/* Scheduled or just-ended cards stay legible but visibly quieter than live
|
|
ones, so a glance at the board never mistakes one for the other. Carousel
|
|
cards are dimmed only while ACTIVE: the inactive ones are held at opacity 0
|
|
by .recognition-card, and a plain .pending rule outranks it, which brought
|
|
the whole stack back into view on top of each other. */
|
|
.recognition-card.active.pending,
|
|
.recert-tile.pending,
|
|
.banner-strip.pending {
|
|
opacity: 0.72;
|
|
}
|
|
|
|
/* An upcoming card is dashed, not solid: shape carries the "not yet" even for
|
|
a reader who cannot pick the amber chip out at distance, or who is looking
|
|
at the board side-on. */
|
|
.recognition-card.upcoming,
|
|
.recert-tile.upcoming,
|
|
.banner-strip.upcoming {
|
|
border-style: dashed;
|
|
}
|
|
|
|
.state-chip.upcoming {
|
|
background: #ffc107;
|
|
border-color: #ffc107;
|
|
color: #231b00;
|
|
}
|
|
.state-chip.resolved {
|
|
background: rgba(255, 255, 255, 0.12);
|
|
border-color: rgba(255, 255, 255, 0.4);
|
|
color: #fff;
|
|
}
|
|
.pip.scheduled {
|
|
background: transparent !important;
|
|
box-shadow: inset 0 0 0 3px currentcolor;
|
|
}
|
|
|
|
.state-chip {
|
|
padding: 4px 14px;
|
|
border-radius: 999px;
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
letter-spacing: 1px;
|
|
text-transform: uppercase;
|
|
background: rgba(255, 255, 255, 0.16);
|
|
border: 2px solid rgba(255, 255, 255, 0.45);
|
|
color: #fff;
|
|
white-space: nowrap;
|
|
}
|
|
.state-chip.tile-state {
|
|
font-size: 14px;
|
|
padding: 2px 10px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.type-chip {
|
|
padding: 4px 14px;
|
|
border-radius: 999px;
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
letter-spacing: 1px;
|
|
text-transform: uppercase;
|
|
/* No margin-bottom. .chip-row centres each item's MARGIN box, so a bottom
|
|
margin here lifted this chip ~4px and left the state chip beside it
|
|
looking low. The row already provides the spacing underneath. */
|
|
}
|
|
|
|
/* Type colour as a bar down the left edge, the way the legacy board marked a
|
|
card. The border alone reads as chrome at distance; a solid edge reads as
|
|
the type. Same idea as .event-indicator on the standard cards. */
|
|
.recognition-card::before,
|
|
.recert-tile::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 14px;
|
|
background: var(--accent, #ffc107);
|
|
}
|
|
.recert-tile::before {
|
|
width: 10px;
|
|
}
|
|
|
|
.recognition-card {
|
|
overflow: hidden;
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
background: linear-gradient(135deg, #2b2718 0%, #1d1a10 100%);
|
|
border: 3px solid var(--accent, #ffc107);
|
|
border-radius: 12px;
|
|
padding: 20px 25px 20px 39px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 25px;
|
|
opacity: 0;
|
|
transform: translateY(20px);
|
|
transition: opacity 0.8s ease, transform 0.8s ease;
|
|
}
|
|
|
|
.recognition-card.active {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
position: relative;
|
|
}
|
|
|
|
.recognition-photo {
|
|
width: 140px;
|
|
height: 140px;
|
|
border-radius: 50%;
|
|
object-fit: cover;
|
|
border: 4px solid var(--accent, #ffc107);
|
|
background: #1a1a2e;
|
|
}
|
|
|
|
.recognition-photo.ge-logo-fallback {
|
|
object-fit: contain;
|
|
padding: 20px;
|
|
background: #fff;
|
|
}
|
|
|
|
.recognition-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
}
|
|
|
|
.recognition-header {
|
|
display: flex;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
|
|
.recognition-name {
|
|
font-size: 36px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.recognition-message {
|
|
font-size: 24px;
|
|
color: #ccc;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* Event cards */
|
|
.notifications-section {
|
|
margin-bottom: 25px;
|
|
}
|
|
|
|
.events-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.event-card {
|
|
position: relative;
|
|
overflow: hidden;
|
|
display: flex;
|
|
align-items: center;
|
|
background: #fff;
|
|
color: #00003d;
|
|
border-radius: 8px;
|
|
padding: 15px 20px 15px 30px;
|
|
gap: 15px;
|
|
}
|
|
|
|
.event-card.resolved {
|
|
opacity: 0.6;
|
|
background: #e9ecef;
|
|
}
|
|
|
|
.event-indicator {
|
|
/* Type color spanning the whole left edge of the card (old-site style). */
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 12px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.event-content {
|
|
flex: 1;
|
|
}
|
|
|
|
.event-title {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.event-time {
|
|
font-size: 18px;
|
|
color: #666;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.event-time strong {
|
|
color: #00003d;
|
|
}
|
|
|
|
.event-ticket {
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
background: #00003d;
|
|
color: #fff;
|
|
padding: 8px 16px;
|
|
border-radius: 6px;
|
|
text-decoration: none;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.event-ticket:hover {
|
|
background: #4181ff;
|
|
}
|
|
|
|
.no-events, .loading {
|
|
text-align: center;
|
|
font-size: 28px;
|
|
font-weight: 600;
|
|
padding: 40px;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.dashboard-footer {
|
|
margin-top: auto;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
text-align: center;
|
|
padding: 12px;
|
|
font-size: 18px;
|
|
}
|
|
</style>
|