Committed as-is on 2026-08-26 while retiring this repo, so the working tree that existed at reformat time is preserved in history rather than only in the NAS backup.
669 lines
20 KiB
HTML
669 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Shopfloor Dashboard - Events & Notifications</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
background: #00003d; /* GE Aerospace Deep Navy */
|
|
color: #fff;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.container {
|
|
max-width: 100%;
|
|
margin: 0 auto;
|
|
padding: 20px 40px;
|
|
padding-bottom: 80px;
|
|
}
|
|
|
|
.header {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr auto;
|
|
align-items: center;
|
|
gap: 30px;
|
|
margin-bottom: 25px;
|
|
border-bottom: 3px solid #4181ff; /* GE Sky Blue */
|
|
padding-bottom: 15px;
|
|
}
|
|
|
|
.logo-container {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.logo-container img {
|
|
height: 90px;
|
|
width: auto;
|
|
}
|
|
|
|
.header-center {
|
|
text-align: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
|
|
.location-title {
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: #eaeaea; /* GE Tungsten */
|
|
text-transform: uppercase;
|
|
letter-spacing: 2px;
|
|
}
|
|
|
|
.header-center h1 {
|
|
font-size: 42px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 2px;
|
|
color: #fff;
|
|
}
|
|
|
|
.clock {
|
|
font-size: 28px;
|
|
font-weight: 600;
|
|
letter-spacing: 1px;
|
|
color: #4181ff; /* GE Sky Blue */
|
|
text-align: right;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.connection-status {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
padding: 12px 20px;
|
|
border-radius: 0 0 0 8px;
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
z-index: 1000;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.connection-status.connected {
|
|
background: #0ad64f; /* GE Avionics Green */
|
|
color: #00003d;
|
|
}
|
|
|
|
.connection-status.disconnected {
|
|
background: #dc3545;
|
|
animation: blink 1s infinite;
|
|
}
|
|
|
|
@keyframes blink {
|
|
0%, 50% { opacity: 1; }
|
|
51%, 100% { opacity: 0.5; }
|
|
}
|
|
|
|
.status-dot {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: #00003d;
|
|
}
|
|
|
|
.events-section {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.events-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 15px;
|
|
}
|
|
|
|
/* Single event - full width */
|
|
.events-grid.single {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
/* 3 or more events - use 2 columns */
|
|
.events-grid.multi {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 36px;
|
|
font-weight: 800;
|
|
margin-bottom: 15px;
|
|
padding: 12px 25px;
|
|
border-radius: 8px;
|
|
display: inline-block;
|
|
text-transform: uppercase;
|
|
letter-spacing: 2px;
|
|
}
|
|
|
|
.section-title.current {
|
|
background: #dc3545;
|
|
color: #fff;
|
|
box-shadow: 0 4px 20px rgba(220, 53, 69, 0.4);
|
|
}
|
|
|
|
/* Dynamic severity colors for current events */
|
|
.section-title.current.severity-danger {
|
|
background: #dc3545; /* Red - Incident */
|
|
box-shadow: 0 4px 20px rgba(220, 53, 69, 0.4);
|
|
}
|
|
|
|
.section-title.current.severity-warning {
|
|
background: #ffc107; /* Yellow - Change */
|
|
color: #000;
|
|
box-shadow: 0 4px 20px rgba(255, 193, 7, 0.4);
|
|
}
|
|
|
|
.section-title.current.severity-success {
|
|
background: #0ad64f; /* Green - Awareness/TBD */
|
|
color: #00003d;
|
|
box-shadow: 0 4px 20px rgba(10, 214, 79, 0.4);
|
|
}
|
|
|
|
.section-title.upcoming {
|
|
background: #4181ff; /* GE Sky Blue */
|
|
color: #fff;
|
|
box-shadow: 0 4px 20px rgba(65, 129, 255, 0.4);
|
|
}
|
|
|
|
.event-card {
|
|
background: #fff;
|
|
color: #000;
|
|
padding: 30px 40px;
|
|
margin-bottom: 15px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
|
border-left: 60px solid;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.event-card.current {
|
|
border-left-color: #dc3545;
|
|
animation: pulse 2s infinite;
|
|
}
|
|
|
|
.event-card.upcoming {
|
|
border-left-color: #4181ff; /* GE Sky Blue */
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% {
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
|
}
|
|
50% {
|
|
box-shadow: 0 6px 30px rgba(220, 53, 69, 0.5);
|
|
}
|
|
}
|
|
|
|
.event-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
gap: 20px;
|
|
min-height: 45px;
|
|
}
|
|
|
|
.event-title {
|
|
font-size: 32px;
|
|
font-weight: 700;
|
|
flex: 1;
|
|
color: #00003d; /* GE Deep Navy for text */
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.event-ticket {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
background: #00003d; /* GE Deep Navy */
|
|
color: #fff;
|
|
padding: 8px 20px;
|
|
border-radius: 6px;
|
|
white-space: nowrap;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.event-time {
|
|
font-size: 22px;
|
|
color: #666;
|
|
font-weight: 400;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.event-time strong {
|
|
color: #00003d; /* GE Deep Navy */
|
|
font-weight: 700;
|
|
}
|
|
|
|
.no-events {
|
|
text-align: center;
|
|
font-size: 32px;
|
|
font-weight: 600;
|
|
padding: 40px 30px;
|
|
background: rgba(234, 234, 234, 0.1);
|
|
border-radius: 8px;
|
|
margin-top: 20px;
|
|
color: #eaeaea; /* GE Tungsten */
|
|
}
|
|
|
|
.footer {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
text-align: center;
|
|
padding: 12px;
|
|
font-size: 18px;
|
|
z-index: 999;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
font-size: 32px;
|
|
font-weight: 600;
|
|
padding: 40px 30px;
|
|
background: rgba(234, 234, 234, 0.1);
|
|
border-radius: 8px;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.error-message {
|
|
background: #dc3545;
|
|
color: #fff;
|
|
padding: 30px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
margin: 20px 0;
|
|
}
|
|
|
|
.more-events {
|
|
text-align: center;
|
|
font-size: 24px;
|
|
font-weight: 600;
|
|
padding: 15px;
|
|
margin-top: 10px;
|
|
background: rgba(255, 255, 255, 0.15);
|
|
border-radius: 6px;
|
|
color: #eaeaea;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* ============================================ */
|
|
/* RESPONSIVE SIZING FOR 4K DISPLAYS */
|
|
/* ============================================ */
|
|
@media screen and (min-width: 2560px) {
|
|
.container {
|
|
padding: 40px 80px;
|
|
padding-bottom: 120px;
|
|
}
|
|
|
|
.header {
|
|
gap: 60px;
|
|
margin-bottom: 50px;
|
|
padding-bottom: 30px;
|
|
}
|
|
|
|
.logo-container img {
|
|
height: 180px;
|
|
}
|
|
|
|
.location-title {
|
|
font-size: 40px;
|
|
}
|
|
|
|
.header-center h1 {
|
|
font-size: 84px;
|
|
}
|
|
|
|
.clock {
|
|
font-size: 56px;
|
|
}
|
|
|
|
.connection-status {
|
|
padding: 20px 35px;
|
|
font-size: 32px;
|
|
gap: 15px;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 72px;
|
|
margin-bottom: 30px;
|
|
padding: 24px 50px;
|
|
}
|
|
|
|
.events-grid {
|
|
gap: 30px;
|
|
}
|
|
|
|
.event-card {
|
|
padding: 60px 80px;
|
|
margin-bottom: 0;
|
|
border-left: 100px solid;
|
|
}
|
|
|
|
.event-header {
|
|
min-height: 90px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.event-title {
|
|
font-size: 64px;
|
|
}
|
|
|
|
.event-ticket {
|
|
font-size: 48px;
|
|
padding: 16px 40px;
|
|
}
|
|
|
|
.event-time {
|
|
font-size: 44px;
|
|
}
|
|
|
|
.footer {
|
|
padding: 24px;
|
|
font-size: 36px;
|
|
}
|
|
|
|
.no-events {
|
|
font-size: 64px;
|
|
padding: 80px 60px;
|
|
}
|
|
|
|
.loading {
|
|
font-size: 64px;
|
|
padding: 80px 60px;
|
|
}
|
|
|
|
.error-message {
|
|
font-size: 56px;
|
|
padding: 60px;
|
|
}
|
|
|
|
.more-events {
|
|
font-size: 48px;
|
|
padding: 30px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="connection-status connected" id="connectionStatus">
|
|
<div class="status-dot"></div>
|
|
<span>LIVE</span>
|
|
</div>
|
|
|
|
<div class="container">
|
|
<div class="header">
|
|
<div class="logo-container">
|
|
<img src="ge-aerospace-logo.svg" alt="GE Aerospace">
|
|
</div>
|
|
<div class="header-center">
|
|
<div class="location-title">West Jefferson</div>
|
|
<h1>Shopfloor Events</h1>
|
|
</div>
|
|
<div class="clock" id="clock"></div>
|
|
</div>
|
|
|
|
<div id="eventsContainer">
|
|
<div class="loading">Loading events...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
Last updated: <span id="lastUpdate">--:--:--</span> | Refreshing every 10 seconds
|
|
</div>
|
|
|
|
<script>
|
|
let isConnected = true;
|
|
let updateInterval;
|
|
let clockInterval;
|
|
|
|
// Update clock
|
|
function updateClock() {
|
|
const now = new Date();
|
|
const timeString = now.toLocaleString('en-US', {
|
|
weekday: 'short',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
});
|
|
document.getElementById('clock').textContent = timeString;
|
|
}
|
|
|
|
// Update connection status indicator
|
|
function setConnectionStatus(connected) {
|
|
isConnected = connected;
|
|
const statusEl = document.getElementById('connectionStatus');
|
|
if (connected) {
|
|
statusEl.className = 'connection-status connected';
|
|
statusEl.innerHTML = '<div class="status-dot"></div><span>LIVE</span>';
|
|
} else {
|
|
statusEl.className = 'connection-status disconnected';
|
|
statusEl.innerHTML = '<div class="status-dot"></div><span>RECONNECTING...</span>';
|
|
}
|
|
}
|
|
|
|
// Format date/time for display
|
|
function formatDateTime(datetime) {
|
|
const date = new Date(datetime);
|
|
return date.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
});
|
|
}
|
|
|
|
// Render events on the page
|
|
function renderEvents(data) {
|
|
const container = document.getElementById('eventsContainer');
|
|
let html = '';
|
|
|
|
// Current Events - show all in grid layout
|
|
if (data.current && data.current.length > 0) {
|
|
// Determine badge color based on highest severity
|
|
const severity = getHighestSeverity(data.current);
|
|
|
|
html += '<div class="events-section">';
|
|
html += `<div class="section-title current severity-${severity}">CURRENT EVENTS</div>`;
|
|
|
|
// Determine grid class based on number of events
|
|
const gridClass = data.current.length === 1 ? 'single' : 'multi';
|
|
html += `<div class="events-grid ${gridClass}">`;
|
|
|
|
data.current.forEach(event => {
|
|
const borderColor = getColorFromType(event.typecolor);
|
|
html += `
|
|
<div class="event-card current" style="border-left-color: ${borderColor};">
|
|
<div class="event-header">
|
|
<div class="event-title">${escapeHtml(event.notification)}</div>
|
|
${event.ticketnumber ? `<div class="event-ticket">Ticket: ${escapeHtml(event.ticketnumber)}</div>` : ''}
|
|
</div>
|
|
<div class="event-time">
|
|
<strong>Started:</strong> ${formatDateTime(event.starttime)}
|
|
${event.endtime ? `<br><strong>Ends:</strong> ${formatDateTime(event.endtime)}` : '<br><strong>Status:</strong> Ongoing (no end time)'}
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += '</div>'; // Close grid
|
|
html += '</div>'; // Close section
|
|
}
|
|
|
|
// Upcoming Events - show all in grid layout
|
|
if (data.upcoming && data.upcoming.length > 0) {
|
|
html += '<div class="events-section">';
|
|
html += '<div class="section-title upcoming">UPCOMING (Next 72 Hours)</div>';
|
|
|
|
// Determine grid class based on number of events
|
|
const gridClass = data.upcoming.length === 1 ? 'single' : 'multi';
|
|
html += `<div class="events-grid ${gridClass}">`;
|
|
|
|
data.upcoming.forEach(event => {
|
|
const borderColor = getColorFromType(event.typecolor);
|
|
html += `
|
|
<div class="event-card upcoming" style="border-left-color: ${borderColor};">
|
|
<div class="event-header">
|
|
<div class="event-title">${escapeHtml(event.notification)}</div>
|
|
${event.ticketnumber ? `<div class="event-ticket">Ticket: ${escapeHtml(event.ticketnumber)}</div>` : ''}
|
|
</div>
|
|
<div class="event-time">
|
|
<strong>Starts:</strong> ${formatDateTime(event.starttime)}
|
|
${event.endtime ? `<br><strong>Ends:</strong> ${formatDateTime(event.endtime)}` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += '</div>'; // Close grid
|
|
html += '</div>'; // Close section
|
|
}
|
|
|
|
// No events message
|
|
if ((!data.current || data.current.length === 0) && (!data.upcoming || data.upcoming.length === 0)) {
|
|
html += '<div class="no-events">No scheduled events for the next 72 hours</div>';
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// Escape HTML to prevent XSS
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Map Bootstrap color classes to hex colors
|
|
function getColorFromType(typecolor) {
|
|
const colorMap = {
|
|
'success': '#0ad64f', // Green (GE Avionics Green) - Awareness, TBD
|
|
'warning': '#ffc107', // Yellow - Change
|
|
'danger': '#dc3545', // Red - Incident
|
|
'secondary': '#6c757d' // Gray - fallback
|
|
};
|
|
return colorMap[typecolor] || colorMap['secondary'];
|
|
}
|
|
|
|
// Determine highest severity level from events (for badge color)
|
|
function getHighestSeverity(events) {
|
|
// Severity hierarchy: danger > warning > success > secondary
|
|
let highestSeverity = 'secondary';
|
|
|
|
events.forEach(event => {
|
|
const severity = event.typecolor;
|
|
|
|
if (severity === 'danger') {
|
|
highestSeverity = 'danger'; // Highest priority
|
|
} else if (severity === 'warning' && highestSeverity !== 'danger') {
|
|
highestSeverity = 'warning';
|
|
} else if (severity === 'success' && highestSeverity !== 'danger' && highestSeverity !== 'warning') {
|
|
highestSeverity = 'success';
|
|
}
|
|
});
|
|
|
|
return highestSeverity;
|
|
}
|
|
|
|
// Fetch notifications from API
|
|
async function fetchNotifications() {
|
|
try {
|
|
const response = await fetch('/api_notifications_safe.asp');
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
renderEvents(data);
|
|
updateLastUpdateTime();
|
|
setConnectionStatus(true);
|
|
} else {
|
|
throw new Error(data.error || 'Unknown error');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching notifications:', error);
|
|
setConnectionStatus(false);
|
|
|
|
// Show error message if first load
|
|
const container = document.getElementById('eventsContainer');
|
|
if (container.querySelector('.loading')) {
|
|
container.innerHTML = `
|
|
<div class="error-message">
|
|
Unable to load events<br>
|
|
<span style="font-size: 20px; margin-top: 10px; display: block;">Retrying...</span>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update last update time
|
|
function updateLastUpdateTime() {
|
|
const now = new Date();
|
|
const timeString = now.toLocaleTimeString('en-US');
|
|
document.getElementById('lastUpdate').textContent = timeString;
|
|
}
|
|
|
|
// Initialize dashboard
|
|
function init() {
|
|
// Start clock
|
|
updateClock();
|
|
clockInterval = setInterval(updateClock, 1000);
|
|
|
|
// Initial data fetch
|
|
fetchNotifications();
|
|
|
|
// Set up automatic refresh every 10 seconds
|
|
updateInterval = setInterval(fetchNotifications, 10000);
|
|
}
|
|
|
|
// Handle page visibility changes (pause updates when hidden)
|
|
document.addEventListener('visibilitychange', function() {
|
|
if (document.hidden) {
|
|
clearInterval(updateInterval);
|
|
clearInterval(clockInterval);
|
|
} else {
|
|
init();
|
|
}
|
|
});
|
|
|
|
// Start the dashboard
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|