diff --git a/web/admin.html b/web/admin.html
index e4d5d13..37b9038 100644
--- a/web/admin.html
+++ b/web/admin.html
@@ -145,6 +145,31 @@
font-weight: 500;
}
+ .table-header-collapsible {
+ background: #34495e;
+ color: white;
+ padding: 1rem;
+ font-weight: 500;
+ cursor: pointer;
+ user-select: none;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .table-header-collapsible:hover {
+ background: #3d5a6e;
+ }
+
+ .collapse-icon {
+ transition: transform 0.3s ease;
+ font-size: 1.2rem;
+ }
+
+ .collapse-icon.collapsed {
+ transform: rotate(-90deg);
+ }
+
.loading {
text-align: center;
padding: 2rem;
@@ -241,17 +266,14 @@
text-align: left;
border-radius: 6px;
padding: 8px;
- position: absolute;
- z-index: 1000;
- bottom: 50%;
- left: 100%;
- margin-left: 10px;
- margin-bottom: -20px;
+ position: fixed;
+ z-index: 10000;
opacity: 0;
transition: opacity 0.3s;
font-size: 0.9rem;
line-height: 1.4;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
+ pointer-events: none;
}
.notes-tooltip .tooltip-text::after {
@@ -270,6 +292,22 @@
opacity: 1;
}
+ .notes-tooltip {
+ position: relative;
+ }
+
+ .notes-indicator {
+ display: inline-block;
+ background-color: #ffc107;
+ color: #856404;
+ font-size: 0.8rem;
+ padding: 2px 6px;
+ border-radius: 10px;
+ margin-left: 5px;
+ cursor: help;
+ font-weight: 600;
+ }
+
/* Modal Styles */
.modal {
display: none;
@@ -587,7 +625,7 @@
@@ -621,7 +659,7 @@
@@ -718,6 +756,40 @@
No parked visitors.
+
+
+
+
+
+
+
+ Loading upcoming PPRs...
+
+
+
+
+
+
+ | Date |
+ Registration |
+ Type |
+ From |
+ ETA |
+ Notes |
+
+
+
+
+
+
+
+
+
@@ -1380,7 +1452,7 @@
if (!accessToken) return;
// Load all tables simultaneously
- await Promise.all([loadArrivals(), loadDepartures(), loadDeparted(), loadParked()]);
+ await Promise.all([loadArrivals(), loadDepartures(), loadDeparted(), loadParked(), loadUpcoming()]);
}
// Load arrivals (NEW and CONFIRMED status)
@@ -1626,6 +1698,112 @@
}
}
+ // Load upcoming PPRs (future days with NEW or CONFIRMED status)
+ async function loadUpcoming() {
+ document.getElementById('upcoming-loading').style.display = 'block';
+ document.getElementById('upcoming-table-content').style.display = 'none';
+ document.getElementById('upcoming-no-data').style.display = 'none';
+
+ try {
+ const response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch upcoming PPRs');
+ }
+
+ const allPPRs = await response.json();
+ const today = new Date().toISOString().split('T')[0];
+
+ // Filter for PPRs with ETA in the future (not today) and NEW or CONFIRMED status
+ const upcoming = allPPRs.filter(ppr => {
+ if (!ppr.eta || (ppr.status !== 'NEW' && ppr.status !== 'CONFIRMED')) {
+ return false;
+ }
+ const etaDate = ppr.eta.split('T')[0];
+ return etaDate > today;
+ });
+
+ displayUpcoming(upcoming);
+ } catch (error) {
+ console.error('Error loading upcoming PPRs:', error);
+ if (error.message !== 'Session expired. Please log in again.') {
+ showNotification('Error loading upcoming PPRs', true);
+ }
+ }
+
+ document.getElementById('upcoming-loading').style.display = 'none';
+ }
+
+ function displayUpcoming(upcoming) {
+ const tbody = document.getElementById('upcoming-table-body');
+ document.getElementById('upcoming-count').textContent = upcoming.length;
+
+ if (upcoming.length === 0) {
+ // Don't show anything if collapsed by default
+ return;
+ }
+
+ // Sort by ETA date and time
+ upcoming.sort((a, b) => new Date(a.eta) - new Date(b.eta));
+
+ tbody.innerHTML = '';
+ // Don't auto-expand, keep collapsed by default
+
+ for (const ppr of upcoming) {
+ const row = document.createElement('tr');
+ row.onclick = () => openPPRModal(ppr.id);
+ row.style.cssText = 'font-size: 0.85rem !important;';
+
+ // Format date as Day DD/MM (e.g., Wed 11/12)
+ const etaDate = new Date(ppr.eta);
+ const dayName = etaDate.toLocaleDateString('en-GB', { weekday: 'short' });
+ const dateStr = etaDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
+ const dateDisplay = `${dayName} ${dateStr}`;
+
+ // Create notes indicator if notes exist
+ const notesIndicator = ppr.notes && ppr.notes.trim() ?
+ `
+ 📝
+ ${ppr.notes}
+ ` : '';
+
+ row.innerHTML = `
+ ${dateDisplay} |
+ ${ppr.ac_reg || '-'} |
+ ${ppr.ac_type || '-'} |
+ ${ppr.in_from || '-'} |
+ ${formatTimeOnly(ppr.eta)} |
+ ${notesIndicator} |
+ `;
+ tbody.appendChild(row);
+ }
+ // Setup tooltips after rendering
+ setupTooltips();
+ }
+
+ // Toggle upcoming table collapse/expand
+ function toggleUpcomingTable() {
+ const content = document.getElementById('upcoming-table-content');
+ const noData = document.getElementById('upcoming-no-data');
+ const icon = document.getElementById('upcoming-collapse-icon');
+
+ const isVisible = content.style.display === 'block' || noData.style.display === 'block';
+
+ if (isVisible) {
+ content.style.display = 'none';
+ noData.style.display = 'none';
+ icon.classList.add('collapsed');
+ } else {
+ const count = parseInt(document.getElementById('upcoming-count').textContent);
+ if (count > 0) {
+ content.style.display = 'block';
+ } else {
+ noData.style.display = 'block';
+ }
+ icon.classList.remove('collapsed');
+ }
+ }
+
// ICAO code to airport name cache
const airportNameCache = {};
@@ -1698,6 +1876,8 @@
`;
tbody.appendChild(row);
}
+ // Setup tooltips after rendering
+ setupTooltips();
}
async function displayDepartures(departures) {
@@ -2782,6 +2962,47 @@
clearDepartureAirportLookup();
}
+ // Position tooltip dynamically to avoid being cut off
+ function positionTooltip(event) {
+ const indicator = event.currentTarget;
+ const tooltip = indicator.querySelector('.tooltip-text');
+ if (!tooltip) return;
+
+ const rect = indicator.getBoundingClientRect();
+ const tooltipWidth = 300;
+ const tooltipHeight = tooltip.offsetHeight || 100;
+
+ // Position to the right of the indicator by default
+ let left = rect.right + 10;
+ let top = rect.top + (rect.height / 2) - (tooltipHeight / 2);
+
+ // Check if tooltip would go off the right edge
+ if (left + tooltipWidth > window.innerWidth) {
+ // Position to the left instead
+ left = rect.left - tooltipWidth - 10;
+ }
+
+ // Check if tooltip would go off the bottom
+ if (top + tooltipHeight > window.innerHeight) {
+ top = window.innerHeight - tooltipHeight - 10;
+ }
+
+ // Check if tooltip would go off the top
+ if (top < 10) {
+ top = 10;
+ }
+
+ tooltip.style.left = left + 'px';
+ tooltip.style.top = top + 'px';
+ }
+
+ // Add hover listeners to all notes tooltips
+ function setupTooltips() {
+ document.querySelectorAll('.notes-tooltip').forEach(tooltip => {
+ tooltip.addEventListener('mouseenter', positionTooltip);
+ });
+ }
+
// Initialize the page when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
setupLoginForm();
diff --git a/web/index.html b/web/index.html
index b6a4bad..e4b9733 100644
--- a/web/index.html
+++ b/web/index.html
@@ -304,7 +304,9 @@
// Build rows asynchronously to lookup airport names
const rows = await Promise.all(arrivals.map(async (arrival) => {
- const aircraftDisplay = `${escapeHtml(arrival.ac_reg || '')} (${escapeHtml(arrival.ac_type || '')})`;
+ // Show callsign if available, otherwise registration
+ const aircraftId = arrival.ac_call || arrival.ac_reg || '';
+ const aircraftDisplay = `${escapeHtml(aircraftId)} (${escapeHtml(arrival.ac_type || '')})`;
const fromDisplay = await getAirportName(arrival.in_from || '');
// Show landed time if available, otherwise ETA
@@ -353,7 +355,9 @@
// Build rows asynchronously to lookup airport names
const rows = await Promise.all(departures.map(async (departure) => {
- const aircraftDisplay = `${escapeHtml(departure.ac_reg || '')} (${escapeHtml(departure.ac_type || '')})`;
+ // Show callsign if available, otherwise registration
+ const aircraftId = departure.ac_call || departure.ac_reg || '';
+ const aircraftDisplay = `${escapeHtml(aircraftId)} (${escapeHtml(departure.ac_type || '')})`;
const toDisplay = await getAirportName(departure.out_to || '');
// Show departed time if available, otherwise ETD