Files
shopdb-flask/frontend/src/views/ShopfloorDashboard.vue

873 lines
24 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"
:style="{ backgroundColor: getTypeColor(n.typecolor) }"
>
{{ 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">{{ 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) }"
>
<!-- 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>
<div class="recognition-message">{{ rec.notification }}</div>
</div>
</div>
</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">
<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) }"
>
<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>
</div>
</div>
</section>
</template>
<!-- Current Notifications -->
<section v-if="currentNotifications.length" class="notifications-section">
<div class="section-title" :class="getSectionClass(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">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 } from '@/api'
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']
// Sections render in style order, so the board keeps its shape as types come
// and go: strips at the top, then rotating cards, then tile rows.
const STYLE_ORDER = { banner: 0, carousel: 1, grid: 2 }
// 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()
for (const n of notifications.value.current) {
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,
items: []
})
}
groups.get(key).items.push(n)
}
return [...groups.values()].sort((a, b) =>
(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
const currentTime = ref('')
function updateClock() {
const now = new Date()
currentTime.value = now.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
onMounted(async () => {
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'
}
function getSectionClass(notifications) {
// Use danger color if any active incidents
const hasIncident = notifications.some(n => n.typecolor === 'danger' && !n.resolved)
return hasIncident ? 'danger' : ''
}
function formatTime(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})
}
function formatDateTime(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString('en-US', {
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;
background: #4181ff;
}
.section-title.recognition {
background: #ffc107;
color: #3a2e00;
}
/* 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 {
background: #0d6efd;
color: #fff;
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 {
background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%);
border: 2px solid #0d6efd;
border-radius: 10px;
padding: 14px 10px;
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 #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.danger {
background: #f5365c;
}
.section-title.upcoming {
background: #6c757d;
}
/* 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;
min-height: 170px;
}
.recognition-card {
position: absolute;
top: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, #4a3a0a 0%, #2a2200 100%);
border: 3px solid #ffc107;
border-radius: 12px;
padding: 20px 25px;
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 #ffc107;
background: #1a1a2e;
}
.recognition-photo.ge-logo-fallback {
object-fit: contain;
padding: 20px;
background: #fff;
}
.recognition-content {
flex: 1;
}
.recognition-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.recognition-star {
font-size: 48px;
color: #ffc107;
margin-right: 20px;
animation: starPulse 2s ease-in-out infinite;
}
@keyframes starPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.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>