Compare commits

..

8 Commits

Author SHA1 Message Date
211db514dd Merge pull request 'local-flights' (#5) from local-flights into main
Reviewed-on: #5
2025-12-20 12:29:31 -05:00
24971ac5fc Merge pull request 'Xmas silliness' (#4) from main into local-flights
Reviewed-on: #4
2025-12-19 12:07:44 -05:00
James Pattinson
a1a5f90f00 Xmas silliness 2025-12-19 17:06:48 +00:00
97995fa58e Help text 2025-12-19 09:07:36 -05:00
bcd582aee5 Filtering enhancements 2025-12-19 08:53:47 -05:00
dc6b551325 Correct missing funcs 2025-12-19 08:40:06 -05:00
ac29b6e929 UI config 2025-12-19 08:33:42 -05:00
0149f45893 AI tweaking 2025-12-19 08:06:47 -05:00
15 changed files with 995 additions and 180 deletions

View File

@@ -24,6 +24,11 @@ MAIL_FROM_NAME=your_mail_from_name_here
# Application settings
BASE_URL=your_base_url_here
# UI Configuration
TAG=
TOP_BAR_BASE_COLOR=#2c3e50
ENVIRONMENT=development
# Redis (optional)
REDIS_URL=

View File

@@ -11,10 +11,35 @@ from app.models.local_flight import LocalFlightStatus
from app.models.departure import DepartureStatus
from app.models.arrival import ArrivalStatus
from datetime import date, datetime, timedelta
import re
router = APIRouter()
def lighten_color(hex_color, factor=0.3):
"""Lighten a hex color by a factor (0-1)"""
hex_color = hex_color.lstrip('#')
if len(hex_color) != 6:
return hex_color # Invalid, return as is
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
r = min(255, int(r + (255 - r) * factor))
g = min(255, int(g + (255 - g) * factor))
b = min(255, int(b + (255 - b) * factor))
return f"#{r:02x}{g:02x}{b:02x}"
def darken_color(hex_color, factor=0.3):
"""Darken a hex color by a factor (0-1)"""
hex_color = hex_color.lstrip('#')
if len(hex_color) != 6:
return hex_color # Invalid, return as is
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
r = max(0, int(r * (1 - factor)))
g = max(0, int(g * (1 - factor)))
b = max(0, int(b * (1 - factor)))
return f"#{r:02x}{g:02x}{b:02x}"
@router.get("/arrivals")
async def get_public_arrivals(db: Session = Depends(get_db)):
"""Get today's arrivals for public display (PPR and local flights)"""
@@ -200,4 +225,18 @@ async def get_public_departures(db: Session = Depends(get_db)):
'isDeparture': True
})
return departures_list
return departures_list
@router.get("/config")
async def get_ui_config():
"""Get UI configuration for client-side rendering"""
from app.core.config import settings
base_color = settings.top_bar_base_color
return {
"tag": settings.tag,
"top_bar_gradient_start": base_color,
"top_bar_gradient_end": lighten_color(base_color, 0.4), # Lighten for gradient end
"footer_color": darken_color(base_color, 0.2), # Darken for footer
"environment": settings.environment
}

View File

@@ -28,6 +28,11 @@ class Settings(BaseSettings):
project_name: str = "Airfield PPR API"
base_url: str
# UI Configuration
tag: str = ""
top_bar_base_color: str = "#2c3e50"
environment: str = "production" # production, development, staging, etc.
# Redis settings (for future use)
redis_url: Optional[str] = None

View File

@@ -26,6 +26,8 @@ services:
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
BASE_URL: ${BASE_URL}
REDIS_URL: ${REDIS_URL}
TAG: ${TAG}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
ENVIRONMENT: production
WORKERS: "4"
ports:

View File

@@ -38,6 +38,9 @@ services:
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
BASE_URL: ${BASE_URL}
REDIS_URL: ${REDIS_URL}
TOWER_NAME: ${TOWER_NAME}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
ENVIRONMENT: ${ENVIRONMENT}
ports:
- "${API_PORT_EXTERNAL}:8000" # Use different port to avoid conflicts with existing system
depends_on:

View File

@@ -397,10 +397,10 @@ tbody tr:hover {
border-color: transparent #333 transparent transparent;
}
.notes-tooltip:hover .tooltip-text {
/* .notes-tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
}
} */
/* Modal Styles */
.modal {

View File

@@ -10,7 +10,7 @@
<body>
<div class="top-bar">
<div class="title">
<h1>✈️ Swansea Tower</h1>
<h1 id="tower-title">✈️ Swansea Tower</h1>
</div>
<div class="menu-buttons">
<div class="dropdown">
@@ -421,7 +421,7 @@
<div class="modal-content">
<div class="modal-header">
<h2 id="local-flight-modal-title">Book Out</h2>
<button class="close" onclick="closeLocalFlightModal()">&times;</button>
<button class="close" onclick="closeModal('localFlightModal')">&times;</button>
</div>
<div class="modal-body">
<form id="local-flight-form">
@@ -475,7 +475,7 @@
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeLocalFlightModal()">
<button type="button" class="btn btn-info" onclick="closeModal('localFlightModal')">
Close
</button>
<button type="submit" class="btn btn-success">
@@ -586,7 +586,7 @@
<div class="modal-content">
<div class="modal-header">
<h2>Book In</h2>
<button class="close" onclick="closeBookInModal()">&times;</button>
<button class="close" onclick="closeModal('bookInModal')">&times;</button>
</div>
<div class="modal-body">
<form id="book-in-form">
@@ -628,7 +628,7 @@
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeBookInModal()">
<button type="button" class="btn btn-info" onclick="closeModal('bookInModal')">
Close
</button>
<button type="submit" class="btn btn-success">
@@ -645,7 +645,7 @@
<div class="modal-content">
<div class="modal-header">
<h2>Register Overflight</h2>
<button class="close" onclick="closeOverflightModal()">&times;</button>
<button class="close" onclick="closeModal('overflightModal')">&times;</button>
</div>
<div class="modal-body">
<form id="overflight-form">
@@ -686,7 +686,7 @@
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeOverflightModal()">
<button type="button" class="btn btn-info" onclick="closeModal('overflightModal')">
Close
</button>
<button type="submit" class="btn btn-success">
@@ -703,7 +703,7 @@
<div class="modal-content">
<div class="modal-header">
<h2 id="overflight-edit-title">Overflight Details</h2>
<button class="close" onclick="closeOverflightEditModal()">&times;</button>
<button class="close" onclick="closeModal('overflightEditModal')">&times;</button>
</div>
<div class="modal-body">
<div class="quick-actions">
@@ -758,7 +758,7 @@
</div>
<div class="form-actions">
<button type="button" class="btn btn-info" onclick="closeOverflightEditModal()">
<button type="button" class="btn btn-info" onclick="closeModal('overflightEditModal')">
Close
</button>
<button type="submit" class="btn btn-success">
@@ -914,13 +914,13 @@
<div class="modal-content">
<div class="modal-header">
<h2>Table Information</h2>
<button class="close" onclick="closeTableHelp()">&times;</button>
<button class="close" onclick="closeModal('tableHelpModal')">&times;</button>
</div>
<div class="modal-body" id="tableHelpContent">
<!-- Content will be populated by JavaScript -->
</div>
<div class="modal-footer">
<button class="btn btn-info" onclick="closeTableHelp()">Close</button>
<button class="btn btn-info" onclick="closeModal('tableHelpModal')">Close</button>
</div>
</div>
</div>
@@ -930,7 +930,7 @@
<div class="modal-content">
<div class="modal-header">
<h2>User Management</h2>
<button class="close" onclick="closeUserManagementModal()">&times;</button>
<button class="close" onclick="closeModal('userManagementModal')">&times;</button>
</div>
<div class="modal-body">
<div class="quick-actions" style="margin-bottom: 1rem;">
@@ -1114,6 +1114,66 @@
let sessionExpiryCheckInterval = null;
let etdManuallyEdited = false; // Track if user has manually edited ETD
let loadPPRsTimeout = null; // Debounce timer for loadPPRs to prevent duplicate refreshes
// Modal state variables
let currentLocalFlightId = null;
let currentBookedInArrivalId = null;
let currentDepartureId = null;
let currentArrivalId = null;
let currentOverflightId = null;
let isOverflightQSYMode = false; // Track if we're in overflight QSY mode
// User management variables
let currentUserRole = null;
let isNewUser = false;
let currentUserId = null;
let currentChangePasswordUserId = null;
// ==================== GENERIC MODAL HELPER ====================
function closeModal(modalId, additionalCleanup = null) {
document.getElementById(modalId).style.display = 'none';
if (additionalCleanup) {
additionalCleanup();
}
}
// Load UI configuration from API
async function loadUIConfig() {
try {
const response = await fetch('/api/v1/public/config');
if (response.ok) {
const config = await response.json();
// Update tower title
const titleElement = document.getElementById('tower-title');
if (titleElement && config.tag) {
titleElement.innerHTML = `✈️ Tower Ops ${config.tag}`;
}
// Update top bar gradient
const topBar = document.querySelector('.top-bar');
if (topBar && config.top_bar_gradient_start && config.top_bar_gradient_end) {
topBar.style.background = `linear-gradient(135deg, ${config.top_bar_gradient_start}, ${config.top_bar_gradient_end})`;
}
// Update footer color
const footerBar = document.querySelector('.footer-bar');
if (footerBar && config.footer_color) {
footerBar.style.background = config.footer_color;
}
// Optionally indicate environment (e.g., add to title if not production)
if (config.environment && config.environment !== 'production') {
const envIndicator = ` (${config.environment.toUpperCase()})`;
if (titleElement) {
titleElement.innerHTML += envIndicator;
}
}
}
} catch (error) {
console.warn('Failed to load UI config:', error);
}
}
// WebSocket connection for real-time updates
function connectWebSocket() {
@@ -1130,11 +1190,9 @@
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/tower-updates`;
console.log('Connecting to WebSocket:', wsUrl);
wsConnection = new WebSocket(wsUrl);
wsConnection.onopen = function(event) {
console.log('✅ WebSocket connected for real-time updates');
lastHeartbeatResponse = Date.now();
startHeartbeat();
showNotification('Real-time updates connected');
@@ -1145,37 +1203,31 @@
// Check if it's a heartbeat response
if (event.data.startsWith('Heartbeat:')) {
lastHeartbeatResponse = Date.now();
console.log('💓 WebSocket heartbeat received');
return;
}
const data = JSON.parse(event.data);
console.log('WebSocket message received:', data);
// Refresh PPRs when any PPR-related event occurs
if (data.type && (data.type.includes('ppr_') || data.type === 'status_update')) {
console.log('PPR update detected, refreshing...');
loadPPRs();
showNotification('Data updated');
}
// Refresh local flights when any local flight event occurs
if (data.type && (data.type.includes('local_flight_'))) {
console.log('Local flight update detected, refreshing...');
loadPPRs();
showNotification('Local flight updated');
}
// Refresh departures when any departure event occurs
if (data.type && (data.type.includes('departure_'))) {
console.log('Departure update detected, refreshing...');
loadDepartures();
showNotification('Departure updated');
}
// Refresh arrivals when any arrival event occurs
if (data.type && (data.type.includes('arrival_'))) {
console.log('Arrival update detected, refreshing...');
loadArrivals();
showNotification('Arrival updated');
}
@@ -1185,14 +1237,12 @@
};
wsConnection.onclose = function(event) {
console.log('⚠️ WebSocket disconnected', event.code, event.reason);
stopHeartbeat();
// Attempt to reconnect after 5 seconds if still logged in
if (accessToken) {
showNotification('Real-time updates disconnected, reconnecting...', true);
wsReconnectTimeout = setTimeout(() => {
console.log('Attempting to reconnect WebSocket...');
connectWebSocket();
}, 5000);
}
@@ -1211,7 +1261,6 @@
wsHeartbeatInterval = setInterval(() => {
if (wsConnection && wsConnection.readyState === WebSocket.OPEN) {
wsConnection.send('ping');
console.log('💓 Sending WebSocket heartbeat');
// Check if last heartbeat was more than 60 seconds ago
if (lastHeartbeatResponse && (Date.now() - lastHeartbeatResponse > 60000)) {
@@ -1388,21 +1437,21 @@
// Press 'Escape' to close Book Out modal if it's open (allow even when typing in inputs)
if (e.key === 'Escape' && document.getElementById('localFlightModal').style.display === 'block') {
e.preventDefault();
closeLocalFlightModal();
closeModal('localFlightModal');
return;
}
// Press 'Escape' to close Book In modal if it's open (allow even when typing in inputs)
if (e.key === 'Escape' && document.getElementById('bookInModal').style.display === 'block') {
e.preventDefault();
closeBookInModal();
closeModal('bookInModal');
return;
}
// Press 'Escape' to close Overflight modal if it's open (allow even when typing in inputs)
if (e.key === 'Escape' && document.getElementById('overflightModal').style.display === 'block') {
e.preventDefault();
closeOverflightModal();
closeModal('overflightModal');
return;
}
@@ -1430,7 +1479,7 @@
// Press 'Escape' to close overflight edit modal if it's open (allow even when typing in inputs)
if (e.key === 'Escape' && document.getElementById('overflightEditModal').style.display === 'block') {
e.preventDefault();
closeOverflightEditModal();
closeModal('overflightEditModal');
return;
}
@@ -2714,11 +2763,9 @@
}
function populateForm(ppr) {
console.log('populateForm called with:', ppr);
Object.keys(ppr).forEach(key => {
if (key === 'eta' || key === 'etd') {
if (ppr[key]) {
console.log(`Processing ${key}:`, ppr[key]);
// ppr[key] is UTC datetime string from API (naive, assume UTC)
let utcDateStr = ppr[key];
if (!utcDateStr.includes('T')) {
@@ -2728,7 +2775,6 @@
utcDateStr += 'Z';
}
const date = new Date(utcDateStr); // Now correctly parsed as UTC
console.log(`Parsed date for ${key}:`, date);
// Split into date and time components for separate inputs
const dateField = document.getElementById(`${key}-date`);
@@ -2741,7 +2787,6 @@
const day = String(date.getDate()).padStart(2, '0');
const dateValue = `${year}-${month}-${day}`;
dateField.value = dateValue;
console.log(`Set ${key}-date to:`, dateValue);
// Format time (round to nearest 15-minute interval)
const hours = String(date.getHours()).padStart(2, '0');
@@ -2750,19 +2795,12 @@
const minutes = String(roundedMinutes).padStart(2, '0');
const timeValue = `${hours}:${minutes}`;
timeField.value = timeValue;
console.log(`Set ${key}-time to:`, timeValue, `(from ${rawMinutes} minutes)`);
} else {
console.log(`Date/time fields not found for ${key}: dateField=${dateField}, timeField=${timeField}`);
}
} else {
console.log(`${key} is empty`);
}
} else {
const field = document.getElementById(key);
if (field) {
field.value = ppr[key] || '';
} else {
console.log(`Field not found for key: ${key}`);
}
}
});
@@ -2838,9 +2876,10 @@
}
function closePPRModal() {
document.getElementById('pprModal').style.display = 'none';
currentPPRId = null;
isNewPPR = false;
closeModal('pprModal', () => {
currentPPRId = null;
isNewPPR = false;
});
}
// Timestamp modal functions
@@ -3181,10 +3220,6 @@
}
// User Management Functions
let currentUserRole = null;
let isNewUser = false;
let currentUserId = null;
async function openUserManagementModal() {
if (!accessToken) return;
@@ -3192,10 +3227,6 @@
await loadUsers();
}
function closeUserManagementModal() {
document.getElementById('userManagementModal').style.display = 'none';
}
async function loadUsers() {
if (!accessToken) return;
@@ -3325,13 +3356,12 @@
}
function closeUserModal() {
document.getElementById('userModal').style.display = 'none';
currentUserId = null;
isNewUser = false;
closeModal('userModal', () => {
currentUserId = null;
isNewUser = false;
});
}
let currentChangePasswordUserId = null;
function openChangePasswordModal(userId, username) {
if (!accessToken) return;
@@ -3348,8 +3378,9 @@
}
function closeChangePasswordModal() {
document.getElementById('changePasswordModal').style.display = 'none';
currentChangePasswordUserId = null;
closeModal('changePasswordModal', () => {
currentChangePasswordUserId = null;
});
}
// Change password form submission
@@ -3450,9 +3481,7 @@
// Update user role detection and UI visibility
async function updateUserRole() {
console.log('updateUserRole called'); // Debug log
if (!accessToken) {
console.log('No access token, skipping role update'); // Debug log
return;
}
@@ -3464,16 +3493,13 @@
if (response.ok) {
const userData = await response.json();
currentUserRole = userData.role;
console.log('User role from API:', currentUserRole); // Debug log
// Show user management in dropdown only for administrators
const userManagementDropdown = document.getElementById('user-management-dropdown');
if (currentUserRole && currentUserRole.toUpperCase() === 'ADMINISTRATOR') {
userManagementDropdown.style.display = 'block';
console.log('Showing user management in dropdown'); // Debug log
} else {
userManagementDropdown.style.display = 'none';
console.log('Hiding user management, current role:', currentUserRole); // Debug log
}
}
} catch (error) {
@@ -3499,16 +3525,16 @@
closeTimestampModal();
}
if (event.target === userManagementModal) {
closeUserManagementModal();
closeModal('userManagementModal');
}
if (event.target === userModal) {
closeUserModal();
}
if (event.target === tableHelpModal) {
closeTableHelp();
closeModal('tableHelpModal');
}
if (event.target === bookInModal) {
closeBookInModal();
closeModal('bookInModal');
}
}
@@ -3547,10 +3573,6 @@
}, 100);
}
function closeLocalFlightModal() {
document.getElementById('localFlightModal').style.display = 'none';
}
function openBookInModal() {
document.getElementById('book-in-form').reset();
document.getElementById('book-in-id').value = '';
@@ -3569,10 +3591,6 @@
}, 100);
}
function closeBookInModal() {
document.getElementById('bookInModal').style.display = 'none';
}
function openOverflightModal() {
document.getElementById('overflight-form').reset();
document.getElementById('overflight-id').value = '';
@@ -3598,13 +3616,6 @@
}, 100);
}
function closeOverflightModal() {
document.getElementById('overflightModal').style.display = 'none';
}
let currentOverflightId = null;
let isOverflightQSYMode = false; // Track if we're in overflight QSY mode
async function openOverflightEditModal(overflightId) {
if (!accessToken) return;
@@ -3658,8 +3669,7 @@
}
function closeOverflightEditModal() {
document.getElementById('overflightEditModal').style.display = 'none';
currentOverflightId = null;
closeModal('overflightEditModal');
}
async function updateOverflightStatus(newStatus, qsyTime = null) {
@@ -3941,9 +3951,6 @@
}
// Local Flight Edit Modal Functions
let currentLocalFlightId = null;
let currentBookedInArrivalId = null;
async function openLocalFlightEditModal(flightId) {
if (!accessToken) return;
@@ -4092,8 +4099,9 @@
}
function closeDepartureEditModal() {
document.getElementById('departureEditModal').style.display = 'none';
currentDepartureId = null;
closeModal('departureEditModal', () => {
currentDepartureId = null;
});
}
// Departure edit form submission
@@ -4150,8 +4158,6 @@
});
// Arrival Edit Modal Functions
let currentArrivalId = null;
async function openArrivalEditModal(arrivalId) {
if (!accessToken) return;
@@ -4196,8 +4202,9 @@
}
function closeArrivalEditModal() {
document.getElementById('arrivalEditModal').style.display = 'none';
currentArrivalId = null;
closeModal('arrivalEditModal', () => {
currentArrivalId = null;
});
}
// Arrival edit form submission
@@ -4434,6 +4441,10 @@
title: "Today's Pending Departures",
text: "Displays aircraft that are ready to depart from Swansea today. This includes flights with approved PPRs awaiting departure, local flights that have been booked out, and departures to other airfields. These aircraft will depart today."
},
overflights: {
title: "Active Overflights",
text: "Displays aircraft that are currently in contact with Air / Ground. Once marked a QSY (changed frequency), they are no longer considered active overflights."
},
departed: {
title: "Departed Today",
text: "Displays the flights which have departed today to other airfields, either by way of a PPR or by booking out."
@@ -4457,10 +4468,6 @@
modal.style.display = 'block';
}
function closeTableHelp() {
document.getElementById('tableHelpModal').style.display = 'none';
}
// Local flight edit form submission
document.getElementById('local-flight-edit-form').addEventListener('submit', async function(e) {
e.preventDefault();
@@ -4563,8 +4570,6 @@
delete flightData.flight_type;
}
console.log(`Submitting ${endpoint} data:`, flightData);
try {
const response = await fetch(endpoint, {
method: 'POST',
@@ -4593,7 +4598,7 @@
}
const result = await response.json();
closeLocalFlightModal();
closeModal('localFlightModal');
loadPPRs(); // Refresh tables
showNotification(`Aircraft ${result.registration} booked out successfully!`);
} catch (error) {
@@ -4643,8 +4648,6 @@
// Book In uses LANDED status (they're arriving now)
arrivalData.status = 'LANDED';
console.log('Submitting arrivals data:', arrivalData);
try {
const response = await fetch('/api/v1/arrivals/', {
method: 'POST',
@@ -4673,7 +4676,7 @@
}
const result = await response.json();
closeBookInModal();
closeModal('bookInModal');
loadPPRs(); // Refresh tables
showNotification(`Aircraft ${result.registration} booked in successfully!`);
} catch (error) {
@@ -4710,8 +4713,6 @@
}
});
console.log('Submitting overflight data:', overflightData);
try {
const response = await fetch('/api/v1/overflights/', {
method: 'POST',
@@ -4740,7 +4741,7 @@
}
const result = await response.json();
closeOverflightModal();
closeModal('overflightModal');
loadPPRs();
showNotification(`Overflight ${result.registration} registered successfully!`);
} catch (error) {
@@ -4780,15 +4781,53 @@
if (lookup) lookup.clear();
}
// Position tooltip near mouse cursor
function positionTooltip(event) {
const tooltip = event.currentTarget.querySelector('.tooltip-text');
if (tooltip) {
const rect = tooltip.getBoundingClientRect();
const tooltipWidth = 300; // matches CSS width
const tooltipHeight = rect.height || 100; // estimate if not yet rendered
let left = event.pageX + 10;
let top = event.pageY + 10;
// Adjust if tooltip would go off screen
if (left + tooltipWidth > window.innerWidth) {
left = event.pageX - tooltipWidth - 10;
}
if (top + tooltipHeight > window.innerHeight) {
top = event.pageY - tooltipHeight - 10;
}
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
tooltip.style.visibility = 'visible';
tooltip.style.opacity = '1';
}
}
// Add hover listeners to all notes tooltips
function setupTooltips() {
document.querySelectorAll('.notes-tooltip').forEach(tooltip => {
tooltip.addEventListener('mouseenter', positionTooltip);
tooltip.addEventListener('mouseleave', hideTooltip);
});
}
// Hide tooltip when mouse leaves
function hideTooltip(event) {
const tooltip = event.currentTarget.querySelector('.tooltip-text');
if (tooltip) {
tooltip.style.visibility = 'hidden';
tooltip.style.opacity = '0';
}
}
// Initialize the page when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
loadUIConfig(); // Load UI configuration first
setupLoginForm();
setupKeyboardShortcuts();
initializeTimeDropdowns(); // Initialize time dropdowns
@@ -4802,6 +4841,7 @@
document.getElementById('etd-date').addEventListener('change', markETDAsManuallyEdited);
document.getElementById('etd-time').addEventListener('change', markETDAsManuallyEdited);
});
</script>
<!-- Footer Bar -->

27
web/assets/bell.svg Normal file
View File

@@ -0,0 +1,27 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Bell body -->
<path d="M 30,40 Q 30,35 35,35 L 65,35 Q 70,35 70,40 Q 70,60 50,70 Q 30,60 30,40" fill="#FFD700" stroke="#DAA520" stroke-width="2"/>
<!-- Bell shine/highlight -->
<ellipse cx="45" cy="45" rx="8" ry="6" fill="#FFED4E" opacity="0.6"/>
<!-- Bell clapper -->
<circle cx="50" cy="65" r="4" fill="#8B4513"/>
<path d="M 50,65 Q 48,75 47,85" stroke="#8B4513" stroke-width="2" fill="none"/>
<!-- Top of bell (rope/hanging part) -->
<rect x="47" y="25" width="6" height="10" fill="#DAA520" rx="2"/>
<!-- Loop -->
<path d="M 48,25 Q 40,20 50,15 Q 60,20 52,25" stroke="#DAA520" stroke-width="2" fill="none"/>
<!-- Decorative berries around bell -->
<circle cx="25" cy="50" r="2" fill="#E74C3C"/>
<circle cx="75" cy="50" r="2" fill="#E74C3C"/>
<circle cx="28" cy="60" r="2" fill="#E74C3C"/>
<circle cx="72" cy="60" r="2" fill="#E74C3C"/>
<!-- Holly leaves -->
<path d="M 20,45 L 18,48 L 20,50 L 18,52 L 20,55" stroke="#228B22" stroke-width="1.5" fill="none"/>
<path d="M 80,45 L 82,48 L 80,50 L 82,52 L 80,55" stroke="#228B22" stroke-width="1.5" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

10
web/assets/candycane.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Candy cane curve -->
<path d="M 50,10 Q 30,30 30,60 Q 30,90 50,100" stroke="#E74C3C" stroke-width="12" fill="none" stroke-linecap="round"/>
<!-- White stripe -->
<path d="M 50,10 Q 30,30 30,60 Q 30,90 50,100" stroke="#FFFFFF" stroke-width="6" fill="none" stroke-linecap="round" stroke-dasharray="8,8"/>
<!-- Highlight -->
<path d="M 48,15 Q 32,32 32,60 Q 32,88 48,98" stroke="#FFFFFF" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 546 B

25
web/assets/gift.svg Normal file
View File

@@ -0,0 +1,25 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Box -->
<rect x="15" y="30" width="70" height="70" fill="#E74C3C" stroke="#C0392B" stroke-width="2"/>
<!-- Box lid/3D effect -->
<polygon points="15,30 25,20 85,20 75,30" fill="#C0392B"/>
<polygon points="75,30 85,20 85,90 75,100" fill="#A93226"/>
<!-- Ribbon vertical -->
<rect x="42" y="20" width="16" height="85" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
<!-- Ribbon horizontal -->
<rect x="10" y="57" width="80" height="16" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
<!-- Bow on top -->
<ellipse cx="35" cy="18" rx="10" ry="8" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
<ellipse cx="65" cy="18" rx="10" ry="8" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
<circle cx="50" cy="20" r="5" fill="#DAA520"/>
<!-- Pattern on box -->
<circle cx="30" cy="50" r="3" fill="#FFFFFF" opacity="0.5"/>
<circle cx="70" cy="60" r="3" fill="#FFFFFF" opacity="0.5"/>
<circle cx="50" cy="75" r="3" fill="#FFFFFF" opacity="0.5"/>
<circle cx="35" cy="80" r="3" fill="#FFFFFF" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

39
web/assets/reindeer.svg Normal file
View File

@@ -0,0 +1,39 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Antlers -->
<path d="M 35,25 Q 25,10 20,5" stroke="#8B4513" stroke-width="3" fill="none"/>
<path d="M 65,25 Q 75,10 80,5" stroke="#8B4513" stroke-width="3" fill="none"/>
<path d="M 33,22 Q 22,15 15,8" stroke="#8B4513" stroke-width="2" fill="none"/>
<path d="M 67,22 Q 78,15 85,8" stroke="#8B4513" stroke-width="2" fill="none"/>
<!-- Head -->
<circle cx="50" cy="35" r="15" fill="#8B4513"/>
<!-- Ears -->
<ellipse cx="38" cy="22" rx="5" ry="8" fill="#8B4513"/>
<ellipse cx="62" cy="22" rx="5" ry="8" fill="#8B4513"/>
<!-- Eyes -->
<circle cx="45" cy="32" r="2" fill="#000000"/>
<circle cx="55" cy="32" r="2" fill="#000000"/>
<!-- Nose (red) -->
<circle cx="50" cy="40" r="4" fill="#E74C3C"/>
<!-- Mouth -->
<path d="M 48,45 Q 50,47 52,45" stroke="#000000" stroke-width="1" fill="none"/>
<!-- Neck -->
<rect x="43" y="48" width="14" height="8" fill="#8B4513"/>
<!-- Body -->
<ellipse cx="50" cy="70" rx="20" ry="25" fill="#8B4513"/>
<!-- Legs -->
<rect x="35" y="90" width="6" height="25" fill="#8B4513"/>
<rect x="45" y="90" width="6" height="25" fill="#8B4513"/>
<rect x="55" y="90" width="6" height="25" fill="#8B4513"/>
<rect x="65" y="90" width="6" height="25" fill="#8B4513"/>
<!-- Tail -->
<circle cx="68" cy="65" r="5" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

37
web/assets/santa.svg Normal file
View File

@@ -0,0 +1,37 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Santa hat -->
<polygon points="20,20 80,20 75,35 25,35" fill="#E74C3C"/>
<circle cx="50" cy="18" r="8" fill="#E74C3C"/>
<circle cx="77" cy="28" r="6" fill="#FFFFFF"/>
<!-- Face -->
<circle cx="50" cy="50" r="18" fill="#F5DEB3"/>
<!-- Eyes -->
<circle cx="42" cy="45" r="2.5" fill="#000000"/>
<circle cx="58" cy="45" r="2.5" fill="#000000"/>
<!-- Nose -->
<circle cx="50" cy="52" r="2" fill="#E74C3C"/>
<!-- Beard -->
<path d="M 35,58 Q 35,65 50,68 Q 65,65 65,58" fill="#FFFFFF"/>
<!-- Mouth -->
<path d="M 42,60 Q 50,63 58,60" stroke="#000000" stroke-width="1" fill="none"/>
<!-- Body -->
<rect x="35" y="68" width="30" height="25" rx="5" fill="#E74C3C"/>
<!-- Belt -->
<rect x="32" y="85" width="36" height="4" fill="#000000"/>
<circle cx="68" cy="87" r="2.5" fill="#FFD700"/>
<!-- Arms -->
<rect x="15" y="75" width="20" height="6" rx="3" fill="#F5DEB3"/>
<rect x="65" y="75" width="20" height="6" rx="3" fill="#F5DEB3"/>
<!-- Legs -->
<rect x="40" y="93" width="8" height="20" fill="#000000"/>
<rect x="52" y="93" width="8" height="20" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

17
web/assets/tree.svg Normal file
View File

@@ -0,0 +1,17 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Tree trunk -->
<rect x="40" y="80" width="20" height="25" fill="#8B4513"/>
<!-- Tree layers -->
<polygon points="50,10 20,50 30,50 10,80 40,80 5,110 50,110 95,110 60,80 90,80 70,50 80,50" fill="#228B22"/>
<!-- Tree highlights -->
<circle cx="50" cy="35" r="4" fill="#FFD700" opacity="0.7"/>
<circle cx="35" cy="55" r="3" fill="#FFD700" opacity="0.7"/>
<circle cx="65" cy="60" r="3" fill="#FFD700" opacity="0.7"/>
<circle cx="45" cy="80" r="3" fill="#FFD700" opacity="0.7"/>
<circle cx="55" cy="90" r="3" fill="#FFD700" opacity="0.7"/>
<!-- Star on top -->
<polygon points="50,5 55,15 65,15 57,20 60,30 50,25 40,30 43,20 35,15 45,15" fill="#FFD700"/>
</svg>

After

Width:  |  Height:  |  Size: 758 B

View File

@@ -132,6 +132,226 @@
grid-template-columns: 1fr; /* Stack columns on smaller screens */
}
}
/* Christmas toggle switch */
.christmas-toggle {
position: absolute;
right: 20px;
top: 20px;
display: flex;
align-items: center;
gap: 10px;
color: white;
font-size: 14px;
}
.toggle-checkbox {
width: 50px;
height: 24px;
cursor: pointer;
appearance: none;
background-color: #555;
border-radius: 12px;
border: none;
outline: none;
transition: background-color 0.3s;
position: relative;
}
.toggle-checkbox:checked {
background-color: #27ae60;
}
.toggle-checkbox::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: white;
top: 2px;
left: 2px;
transition: left 0.3s;
}
.toggle-checkbox:checked::before {
left: 28px;
}
/* Santa hat styles */
.santa-hat {
position: absolute;
width: 60px;
height: 50px;
top: -20px;
transform: rotate(-20deg);
z-index: 10;
}
.santa-hat::before {
content: '';
position: absolute;
width: 100%;
height: 70%;
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
clip-path: polygon(0 0, 100% 0, 90% 100%, 10% 100%);
}
.santa-hat::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
bottom: -5px;
right: -8px;
box-shadow: -15px 5px 0 -5px white;
}
/* Jingle bell styles */
.jingle-bell {
display: inline-block;
position: relative;
width: 12px;
height: 14px;
margin: 0 2px;
animation: jingle 0.4s ease-in-out infinite;
}
.jingle-bell::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
background: #f1c40f;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.jingle-bell::after {
content: '';
position: absolute;
width: 3px;
height: 6px;
background: #d4a500;
top: -6px;
left: 50%;
transform: translateX(-50%);
}
@keyframes jingle {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(5deg); }
75% { transform: rotate(-5deg); }
}
/* Snow animation */
.snowflake {
position: fixed;
top: -10px;
color: white;
font-size: 1em;
font-weight: bold;
text-shadow: 0 0 5px rgba(255,255,255,0.8);
z-index: 1;
user-select: none;
pointer-events: none;
animation: snowfall linear infinite;
opacity: 0.8;
}
@keyframes snowfall {
to {
transform: translateY(100vh) translateX(100px);
opacity: 0;
}
}
body.christmas-active .snowflake {
animation: snowfall linear infinite;
}
/* Festive header when active */
body.christmas-active header {
background: linear-gradient(90deg, #27ae60 0%, #e74c3c 50%, #27ae60 100%);
background-size: 200% 100%;
animation: festive-pulse 3s ease-in-out infinite;
}
@keyframes festive-pulse {
0%, 100% { background-position: 0% 0%; }
50% { background-position: 100% 0%; }
}
/* Jingle bells in header when active */
body.christmas-active h1::before {
content: '🔔 ';
animation: jingle 0.4s ease-in-out infinite;
display: inline-block;
font-size: 30px;
margin-right: 15px;
}
body.christmas-active h1::after {
content: ' 🔔';
animation: jingle 0.4s ease-in-out infinite;
display: inline-block;
font-size: 30px;
margin-left: 15px;
}
/* Corner decorations */
.corner-decoration {
position: fixed;
font-size: 80px;
z-index: 5;
pointer-events: none;
opacity: 0.9;
width: 100px;
height: 100px;
}
.corner-decoration img {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Bottom decorations */
.bottom-decoration {
position: fixed;
bottom: 20px;
width: 80px;
height: 80px;
z-index: 5;
pointer-events: none;
opacity: 0.85;
}
.bottom-decoration img {
width: 100%;
height: 100%;
object-fit: contain;
}
.corner-decoration.bottom-left {
bottom: 10px;
left: 10px;
animation: sway 3s ease-in-out infinite;
}
.corner-decoration.bottom-right {
bottom: 10px;
right: 10px;
animation: sway 3s ease-in-out infinite reverse;
}
@keyframes sway {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(-5deg); }
}
</style>
</head>
<body>
@@ -191,6 +411,118 @@
</footer>
<script>
// Christmas mode toggle functionality
function initChristmasMode() {
// Check URL parameter first for override
const urlParams = new URLSearchParams(window.location.search);
const christmasParam = urlParams.get('christmas');
let shouldEnable = false;
if (christmasParam === 'on') {
shouldEnable = true;
} else if (christmasParam === 'off') {
shouldEnable = false;
} else {
// Auto-enable for December
const now = new Date();
shouldEnable = now.getMonth() === 11; // December is month 11 (0-indexed)
}
if (shouldEnable) {
enableChristmasMode();
}
}
function enableChristmasMode() {
document.body.classList.add('christmas-active');
// Create falling snowflakes
function createSnowflake() {
const snowflake = document.createElement('div');
snowflake.classList.add('snowflake');
snowflake.textContent = '❄';
snowflake.style.left = Math.random() * window.innerWidth + 'px';
snowflake.style.animationDuration = (Math.random() * 5 + 8) + 's';
snowflake.style.animationDelay = Math.random() * 2 + 's';
document.body.appendChild(snowflake);
setTimeout(() => snowflake.remove(), 13000);
}
// Create snowflakes periodically
const snowInterval = setInterval(() => {
if (!document.body.classList.contains('christmas-active')) {
clearInterval(snowInterval);
return;
}
createSnowflake();
}, 300);
// Add corner decorations
const leftCorner = document.createElement('div');
leftCorner.classList.add('corner-decoration', 'bottom-left');
const treeImg = document.createElement('img');
treeImg.src = 'assets/tree.svg';
treeImg.alt = 'Christmas Tree';
leftCorner.appendChild(treeImg);
leftCorner.id = 'corner-left';
document.body.appendChild(leftCorner);
const rightCorner = document.createElement('div');
rightCorner.classList.add('corner-decoration', 'bottom-right');
const santaImg = document.createElement('img');
santaImg.src = 'assets/santa.svg';
santaImg.alt = 'Santa';
rightCorner.appendChild(santaImg);
rightCorner.id = 'corner-right';
document.body.appendChild(rightCorner);
// Add bottom decorations in a row
const bottomDecorations = [
{ src: 'assets/reindeer.svg', alt: 'Reindeer' },
{ src: 'assets/bell.svg', alt: 'Bell' },
{ src: 'assets/gift.svg', alt: 'Gift' },
{ src: 'assets/candycane.svg', alt: 'Candy Cane' },
{ src: 'assets/bell.svg', alt: 'Bell' },
{ src: 'assets/gift.svg', alt: 'Gift' }
];
const screenWidth = window.innerWidth;
const totalDecorations = bottomDecorations.length;
const spacing = screenWidth / (totalDecorations + 1);
bottomDecorations.forEach((deco, index) => {
const div = document.createElement('div');
div.classList.add('bottom-decoration');
div.style.left = (spacing * (index + 1) - 40) + 'px'; // 40 is half the width
div.style.animation = `sway ${3 + index * 0.5}s ease-in-out infinite`;
const img = document.createElement('img');
img.src = deco.src;
img.alt = deco.alt;
div.appendChild(img);
div.id = `bottom-deco-${index}`;
document.body.appendChild(div);
});
}
function disableChristmasMode() {
document.body.classList.remove('christmas-active');
// Remove corner decorations
document.getElementById('corner-left')?.remove();
document.getElementById('corner-right')?.remove();
// Remove bottom decorations
document.querySelectorAll('[id^="bottom-deco-"]').forEach(deco => deco.remove());
// Remove snowflakes
document.querySelectorAll('.snowflake').forEach(flake => flake.remove());
}
let wsConnection = null;
// ICAO code to airport name cache
@@ -517,6 +849,9 @@
// Load data on page load
window.addEventListener('load', function() {
// Initialize Christmas mode
initChristmasMode();
loadArrivals();
loadDepartures();

View File

@@ -367,7 +367,7 @@
</button>
</div>
<div class="title">
<h1>📊 PPR Reports</h1>
<h1 id="tower-title">📊 PPR Reports</h1>
</div>
<div class="user-info">
Logged in as: <span id="current-user">Loading...</span> |
@@ -378,18 +378,26 @@
<div class="container">
<!-- Filters Section -->
<div class="filters-section">
<div class="filters-grid">
<div class="filter-group">
<label for="date-from">Date From:</label>
<input type="date" id="date-from">
<div style="display: flex; gap: 0.5rem; align-items: flex-end; flex-wrap: wrap;">
<!-- Quick Filter Buttons -->
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary" id="filter-today" onclick="setDateRangeToday()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📅 Today</button>
<button class="btn btn-secondary" id="filter-week" onclick="setDateRangeThisWeek()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📆 This Week</button>
<button class="btn btn-secondary" id="filter-month" onclick="setDateRangeThisMonth()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📊 This Month</button>
<button class="btn btn-secondary" id="filter-custom" onclick="toggleCustomRange()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📋 Custom Range</button>
</div>
<div class="filter-group">
<label for="date-to">Date To:</label>
<input type="date" id="date-to">
<!-- Custom Date Range (hidden by default) -->
<div id="custom-range-container" style="display: none; display: flex; gap: 0.5rem; align-items: center;">
<input type="date" id="date-from" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
<span style="font-weight: 600; color: #666;">to</span>
<input type="date" id="date-to" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
</div>
<div class="filter-group">
<label for="status-filter">Status:</label>
<select id="status-filter">
<!-- Status Filter -->
<div style="display: flex; flex-direction: column; gap: 0.3rem;">
<label for="status-filter" style="font-weight: 600; font-size: 0.85rem; color: #555;">Status:</label>
<select id="status-filter" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.9rem;">
<option value="">All Statuses</option>
<option value="NEW">New</option>
<option value="CONFIRMED">Confirmed</option>
@@ -399,15 +407,19 @@
<option value="DELETED">Deleted</option>
</select>
</div>
<div class="filter-group">
<label for="search-input">Search:</label>
<input type="text" id="search-input" placeholder="Aircraft reg, callsign, captain, or airport...">
<!-- Search Input -->
<div style="flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 0.3rem;">
<label for="search-input" style="font-weight: 600; font-size: 0.85rem; color: #555;">Search:</label>
<input type="text" id="search-input" placeholder="Aircraft reg, callsign, captain, or airport..." style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.9rem;">
</div>
<div class="filter-actions">
<button class="btn btn-primary" onclick="loadReports()">
<!-- Action Buttons -->
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary" onclick="loadReports()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">
🔍 Search
</button>
<button class="btn btn-secondary" onclick="clearFilters()">
<button class="btn btn-secondary" onclick="clearFilters()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">
🗑️ Clear
</button>
</div>
@@ -417,67 +429,69 @@
<!-- Summary Box -->
<div class="summary-box">
<div class="summary-title">📊 Movements Summary</div>
<div class="summary-grid">
<!-- PPR Section -->
<div style="grid-column: 1/-1; padding-bottom: 1rem; border-bottom: 2px solid rgba(255,255,255,0.3);">
<div style="font-size: 0.95rem; font-weight: 600; margin-bottom: 0.6rem;">PPR Movements</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;">
<div class="summary-item" style="padding: 0.6rem;">
<div class="summary-item-label" style="font-size: 0.75rem; margin-bottom: 0.2rem;">Arrivals (Landings)</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="ppr-arrivals">0</div>
<div style="display: grid; grid-template-columns: 1fr auto; gap: 2rem; align-items: center;">
<div class="summary-grid">
<!-- PPR Section -->
<div style="grid-column: 1/-1; padding-bottom: 0.8rem; border-bottom: 2px solid rgba(255,255,255,0.3);">
<div style="font-size: 0.85rem; font-weight: 600; margin-bottom: 0.4rem;">PPR Movements</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.8rem;">
<div class="summary-item" style="padding: 0.4rem;">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Arrivals</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="ppr-arrivals">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem;">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Departures</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="ppr-departures">0</div>
</div>
<div class="summary-item" style="border-left-color: #ffd700; background: rgba(255,215,0,0.1); padding: 0.4rem;">
<div class="summary-item-label" style="font-weight: 600; font-size: 0.7rem; margin-bottom: 0.1rem;">Total</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="ppr-total">0</div>
</div>
</div>
<div class="summary-item" style="padding: 0.6rem;">
<div class="summary-item-label" style="font-size: 0.75rem; margin-bottom: 0.2rem;">Departures (Takeoffs)</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="ppr-departures">0</div>
</div>
<div class="summary-item" style="border-left-color: #ffd700; background: rgba(255,215,0,0.1); padding: 0.6rem;">
<div class="summary-item-label" style="font-weight: 600; font-size: 0.75rem; margin-bottom: 0.2rem;">PPR Total</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="ppr-total">0</div>
</div>
<!-- Non-PPR Section -->
<div style="grid-column: 1/-1; padding-top: 0.8rem;">
<div style="font-size: 0.85rem; font-weight: 600; margin-bottom: 0.4rem;">Non-PPR Movements</div>
<div style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.8rem;">
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('LOCAL')">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Local</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="local-flights-movements">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('CIRCUIT')">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Circuits</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="circuits-movements">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('ARRIVAL')">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Arrivals</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-arrivals">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('DEPARTURE')">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Departures</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-departures">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('OVERFLIGHT')">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Overflights</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="overflights-count">0</div>
</div>
<div class="summary-item" style="border-left-color: #ffd700; background: rgba(255,215,0,0.1); padding: 0.4rem;">
<div class="summary-item-label" style="font-weight: 600; font-size: 0.7rem; margin-bottom: 0.1rem;">Total</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-total">0</div>
</div>
</div>
</div>
</div>
<!-- Non-PPR Section -->
<div style="grid-column: 1/-1; padding-bottom: 1rem; border-bottom: 2px solid rgba(255,255,255,0.3);">
<div style="font-size: 0.95rem; font-weight: 600; margin-bottom: 0.6rem;">Non-PPR Movements</div>
<div style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 1rem;">
<div class="summary-item" style="padding: 0.6rem;">
<div class="summary-item-label" style="font-size: 0.75rem; margin-bottom: 0.2rem;">Local Flights</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="local-flights-movements">0</div>
</div>
<div class="summary-item" style="padding: 0.6rem;">
<div class="summary-item-label" style="font-size: 0.75rem; margin-bottom: 0.2rem;">Circuits</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="circuits-movements">0</div>
</div>
<div class="summary-item" style="padding: 0.6rem;">
<div class="summary-item-label" style="font-size: 0.75rem; margin-bottom: 0.2rem;">Arrivals</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="non-ppr-arrivals">0</div>
</div>
<div class="summary-item" style="padding: 0.6rem;">
<div class="summary-item-label" style="font-size: 0.75rem; margin-bottom: 0.2rem;">Departures</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="non-ppr-departures">0</div>
</div>
<div class="summary-item" style="padding: 0.6rem;">
<div class="summary-item-label" style="font-size: 0.75rem; margin-bottom: 0.2rem;">Overflights</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="overflights-count">0</div>
</div>
<div class="summary-item" style="border-left-color: #ffd700; background: rgba(255,215,0,0.1); padding: 0.6rem;">
<div class="summary-item-label" style="font-weight: 600; font-size: 0.75rem; margin-bottom: 0.2rem;">Non-PPR Total</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="non-ppr-total">0</div>
</div>
</div>
</div>
<!-- Grand Total -->
<div style="grid-column: 1/-1; text-align: center;">
<div style="font-size: 0.85rem; opacity: 0.9; margin-bottom: 0.3rem;">Grand Total Movements</div>
<div style="font-size: 2rem; font-weight: 700;" id="grand-total-movements">0</div>
<!-- Grand Total - positioned on the right -->
<div style="text-align: center; padding: 1rem; background: rgba(255,215,0,0.05); border-radius: 8px; border-left: 4px solid #ffd700; min-width: 120px;">
<div style="font-size: 0.75rem; opacity: 0.85; margin-bottom: 0.2rem;">GRAND TOTAL</div>
<div style="font-size: 2.2rem; font-weight: 700;" id="grand-total-movements">0</div>
</div>
</div>
</div>
<!-- Reports Table -->
<div class="reports-table">
<div class="reports-table" id="ppr-reports-section">
<div class="table-header">
<div>
<strong>PPR Records</strong>
@@ -590,22 +604,155 @@
let accessToken = null;
let currentPPRs = []; // Store current results for export
let currentOtherFlights = []; // Store other flights for export
let otherFlightsFilterType = null; // Track which non-PPR flight type is selected for filtering
// Load UI configuration from API
async function loadUIConfig() {
try {
const response = await fetch('/api/v1/public/config');
if (response.ok) {
const config = await response.json();
// Update tower title
const titleElement = document.getElementById('tower-title');
if (titleElement && config.tag) {
titleElement.innerHTML = `📊 Reports ${config.tag}`;
}
// Update top bar gradient
const topBar = document.querySelector('.top-bar');
if (topBar && config.top_bar_gradient_start && config.top_bar_gradient_end) {
topBar.style.background = `linear-gradient(135deg, ${config.top_bar_gradient_start}, ${config.top_bar_gradient_end})`;
}
// Update page title
if (config.tag) {
document.title = `PPR Reports - ${config.tag}`;
}
// Optionally indicate environment (e.g., add to title if not production)
if (config.environment && config.environment !== 'production') {
const envIndicator = ` (${config.environment.toUpperCase()})`;
if (titleElement) {
titleElement.innerHTML += envIndicator;
}
if (document.title) {
document.title += envIndicator;
}
}
}
} catch (error) {
console.warn('Failed to load UI config:', error);
}
}
// Initialize the page
async function initializePage() {
loadUIConfig(); // Load UI configuration first
await initializeAuth();
setupDefaultDateRange();
await loadReports();
}
// Set default date range to current month
// Set default date range to today
function setupDefaultDateRange() {
setDateRangeToday();
}
// Toggle custom date range picker
function toggleCustomRange() {
const container = document.getElementById('custom-range-container');
const customBtn = document.getElementById('filter-custom');
const isVisible = container.style.display !== 'none';
container.style.display = isVisible ? 'none' : 'flex';
// Update button style
if (isVisible) {
customBtn.classList.remove('btn-primary');
customBtn.classList.add('btn-secondary');
} else {
customBtn.classList.remove('btn-secondary');
customBtn.classList.add('btn-primary');
// Focus on the first date input when opening
document.getElementById('date-from').focus();
}
}
// Set date range to today
function setDateRangeToday() {
const today = new Date().toISOString().split('T')[0];
document.getElementById('date-from').value = today;
document.getElementById('date-to').value = today;
// Hide custom range picker if it's open
document.getElementById('custom-range-container').style.display = 'none';
updateFilterButtonStyles('today');
loadReports();
}
// Set date range to this week (Monday to Sunday)
function setDateRangeThisWeek() {
const now = new Date();
const dayOfWeek = now.getDay();
const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
const monday = new Date(now.setDate(diff));
const sunday = new Date(now.setDate(diff + 6));
document.getElementById('date-from').value = monday.toISOString().split('T')[0];
document.getElementById('date-to').value = sunday.toISOString().split('T')[0];
// Hide custom range picker if it's open
document.getElementById('custom-range-container').style.display = 'none';
updateFilterButtonStyles('week');
loadReports();
}
// Set date range to this month
function setDateRangeThisMonth() {
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
document.getElementById('date-from').value = firstDay.toISOString().split('T')[0];
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
// Hide custom range picker if it's open
document.getElementById('custom-range-container').style.display = 'none';
updateFilterButtonStyles('month');
loadReports();
}
// Update button styles to show which filter is active
function updateFilterButtonStyles(activeFilter) {
const todayBtn = document.getElementById('filter-today');
const weekBtn = document.getElementById('filter-week');
const monthBtn = document.getElementById('filter-month');
// Reset all buttons
[todayBtn, weekBtn, monthBtn].forEach(btn => {
btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary');
});
// Highlight active button
switch(activeFilter) {
case 'today':
todayBtn.classList.remove('btn-secondary');
todayBtn.classList.add('btn-primary');
break;
case 'week':
weekBtn.classList.remove('btn-secondary');
weekBtn.classList.add('btn-primary');
break;
case 'month':
monthBtn.classList.remove('btn-secondary');
monthBtn.classList.add('btn-primary');
break;
}
}
// Authentication management
@@ -944,20 +1091,103 @@
}
}
// Filter other flights by type
function filterOtherFlights(flightType) {
// Toggle filter if clicking the same type
if (otherFlightsFilterType === flightType) {
otherFlightsFilterType = null;
} else {
otherFlightsFilterType = flightType;
}
// Show/hide PPR section based on filter
const pprSection = document.getElementById('ppr-reports-section');
if (pprSection) {
pprSection.style.display = otherFlightsFilterType ? 'none' : 'block';
}
// Update visual indication of active filter
updateFilterIndicators();
// Re-display flights with new filter
displayOtherFlights(currentOtherFlights);
}
// Update visual indicators for active filter
function updateFilterIndicators() {
// Select all clickable non-PPR summary items (those with onclick attribute)
const summaryItems = document.querySelectorAll('.summary-item[onclick*="filterOtherFlights"]');
summaryItems.forEach(item => {
item.style.opacity = '1';
item.style.borderLeftColor = '';
item.style.borderLeftWidth = '0';
});
if (otherFlightsFilterType) {
// Get the ID of the selected filter's summary item
let selectedId = '';
switch(otherFlightsFilterType) {
case 'LOCAL':
selectedId = 'local-flights-movements';
break;
case 'CIRCUIT':
selectedId = 'circuits-movements';
break;
case 'ARRIVAL':
selectedId = 'non-ppr-arrivals';
break;
case 'DEPARTURE':
selectedId = 'non-ppr-departures';
break;
case 'OVERFLIGHT':
selectedId = 'overflights-count';
break;
}
// Find and highlight the selected item
if (selectedId) {
const selectedElement = document.getElementById(selectedId);
if (selectedElement) {
const summaryItem = selectedElement.closest('.summary-item');
if (summaryItem) {
summaryItem.style.borderLeftColor = '#4CAF50';
summaryItem.style.borderLeftWidth = '4px';
summaryItem.style.opacity = '1';
}
}
}
// Dim other items that are clickable (non-PPR items)
const allSummaryItems = document.querySelectorAll('.summary-item[onclick]');
allSummaryItems.forEach(item => {
if (item.querySelector('#' + selectedId) === null) {
item.style.opacity = '0.5';
}
});
}
}
// Display other flights in table
function displayOtherFlights(flights) {
const tbody = document.getElementById('other-flights-table-body');
const tableInfo = document.getElementById('other-flights-info');
tableInfo.textContent = `${flights.length} flights found`;
// Apply filter if one is selected
let filteredFlights = flights;
if (otherFlightsFilterType) {
filteredFlights = flights.filter(flight => flight.flightType === otherFlightsFilterType);
}
if (flights.length === 0) {
tableInfo.textContent = `${filteredFlights.length} flights found` + (otherFlightsFilterType ? ` (filtered by ${otherFlightsFilterType})` : '');
if (filteredFlights.length === 0) {
document.getElementById('other-flights-no-data').style.display = 'block';
document.getElementById('other-flights-table-content').style.display = 'none';
return;
}
// Sort by time field (ascending)
flights.sort((a, b) => {
filteredFlights.sort((a, b) => {
const aTime = a.timeField;
const bTime = b.timeField;
if (!aTime) return 1;
@@ -967,8 +1197,9 @@
tbody.innerHTML = '';
document.getElementById('other-flights-table-content').style.display = 'block';
document.getElementById('other-flights-no-data').style.display = 'none';
for (const flight of flights) {
for (const flight of filteredFlights) {
const row = document.createElement('tr');
const typeLabel = flight.flightType;