Files
ppr-ng/web/atc.html
T
2026-06-15 15:45:58 -04:00

1853 lines
89 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ATC Management Interface</title>
<link rel="stylesheet" href="admin.css">
<script src="lookups.js"></script>
<style>
/* ATC-specific styles */
.atc-container {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: auto auto;
gap: 1rem;
padding: 1rem;
height: calc(100vh - 80px);
}
.atc-section {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 1rem;
overflow: hidden;
display: flex;
flex-direction: column;
}
.atc-section h2 {
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid #3498db;
font-size: 1.2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.atc-section .count {
background: #3498db;
color: white;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: bold;
}
.aircraft-list {
flex: 1;
overflow-y: auto;
max-height: calc(50vh - 100px);
}
.aircraft-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #eee;
transition: background-color 0.2s ease;
cursor: pointer;
}
.aircraft-item:hover {
background-color: #f8f9fa;
}
.aircraft-item:last-child {
border-bottom: none;
}
.aircraft-item.local-flight,
.aircraft-item.circuit {
background-color: #ffcccc; /* light red */
}
.aircraft-item.departure {
background-color: #ccccff; /* light blue */
}
.aircraft-item.inbound {
background-color: #ffffcc; /* light yellow */
}
.aircraft-item.overflight {
background-color: #ccffcc; /* light green */
}
.aircraft-info {
display: flex;
flex-direction: row;
gap: 0.75rem;
flex: 1;
align-items: center;
}
.aircraft-reg {
font-weight: bold;
font-size: 0.95rem;
color: #2c3e50;
min-width: 70px;
}
.aircraft-details {
font-size: 0.85rem;
color: #666;
flex: 1;
}
.aircraft-time {
font-size: 0.8rem;
color: #999;
min-width: 50px;
text-align: right;
}
.aircraft-status {
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: bold;
text-align: center;
min-width: 65px;
margin-left: 0.5rem;
white-space: nowrap;
}
.status-active {
background: #27ae60;
color: white;
}
.status-inbound {
background: #f39c12;
color: white;
}
.status-departing {
background: #e74c3c;
color: white;
}
.status-circuit {
background: #9b59b6;
color: white;
}
.status-pending {
background: #3498db;
color: white;
}
.no-aircraft {
text-align: center;
color: #999;
font-style: italic;
padding: 2rem;
}
.status-btn {
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
border: none;
border-radius: 3px;
cursor: pointer;
font-weight: bold;
transition: all 0.2s ease;
background: #2c3e50;
color: white;
}
.status-btn:hover {
background: #34495e;
transform: scale(1.05);
}
.status-btn.small-btn {
padding: 0.2rem 0.4rem;
font-size: 0.7rem;
min-width: 24px;
height: 24px;
}
.status-btn.active-position {
background-color: #28a745;
color: white;
border-color: #28a745;
}
.status-btn.active-position:hover {
background-color: #218838;
border-color: #1e7e34;
}
/* Responsive adjustments */
@media (max-width: 1400px) {
.atc-container {
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto auto;
}
}
@media (max-width: 900px) {
.atc-container {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto auto auto auto;
}
}
</style>
</head>
<body>
<div class="top-bar">
<div class="title">
<h1 id="tower-title">✈️ Swansea Tower - ATC View</h1>
</div>
<div class="menu-buttons">
<div class="dropdown">
<button class="btn btn-success dropdown-toggle" id="actionsDropdownBtn">
📋 Actions
</button>
<div class="dropdown-menu" id="actionsDropdownMenu">
<a href="#" onclick="openNewPPRModal(); closeActionsDropdown()"> New PPR <span class="shortcut">(N)</span></a>
<a href="#" onclick="openLocalFlightModal('LOCAL'); closeActionsDropdown()">🛫 Book Out <span class="shortcut">(L)</span></a>
<a href="#" onclick="openBookInModal(); closeActionsDropdown()">🛬 Book In <span class="shortcut">(I)</span></a>
<a href="#" onclick="openOverflightModal(); closeActionsDropdown()">🔄 Overflight <span class="shortcut">(O)</span></a>
</div>
</div>
<div class="dropdown">
<button class="btn btn-warning dropdown-toggle" id="adminDropdownBtn">
⚙️ Admin
</button>
<div class="dropdown-menu" id="adminDropdownMenu">
<a href="#" onclick="window.location.href = '/admin'">🏠 Admin View</a>
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
<a href="#" onclick="window.location.href = '/bulk-log'">🧾 Bulk Flight Log</a>
<a href="#" onclick="window.location.href = '/journal'">📔 Journal Log</a>
<a href="#" onclick="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a>
</div>
</div>
</div>
<div class="user-info">
Logged in as: <span id="current-user">Loading...</span> |
<a href="#" onclick="logout()" style="color: white;">Logout</a>
</div>
</div>
<div class="atc-container">
<!-- Row 1: Apron -->
<div class="atc-section">
<h2>🛫 Apron <span class="count" id="departing-count">0</span></h2>
<div class="aircraft-list" id="departing-list">
<div class="no-aircraft">No departing aircraft</div>
</div>
</div>
<!-- Row 1: Local Area -->
<div class="atc-section">
<h2>📍 Local Area <span class="count" id="local-count">0</span></h2>
<div class="aircraft-list" id="local-list">
<div class="no-aircraft">No aircraft in local area</div>
</div>
</div>
<!-- Row 1: Inbound -->
<div class="atc-section">
<h2>🛬 Inbound <span class="count" id="inbound-count">0</span></h2>
<div class="aircraft-list" id="inbound-list">
<div class="no-aircraft">No inbound aircraft</div>
</div>
</div>
<!-- Row 2: Booked Out -->
<div class="atc-section">
<h2>📋 Booked Out <span class="count" id="parked-count">0</span></h2>
<div class="aircraft-list" id="parked-list">
<div class="no-aircraft">No booked out aircraft</div>
</div>
</div>
<!-- Row 2: Circuit Traffic -->
<div class="atc-section">
<h2>🔄 Circuit Traffic <span class="count" id="circuit-count">0</span></h2>
<div class="aircraft-list" id="circuit-list">
<div class="no-aircraft">No aircraft in circuit</div>
</div>
</div>
<!-- Row 2: Today's PPRs -->
<div class="atc-section">
<h2>📝 Today's PPRs <span class="count" id="pending-ppr-count">0</span></h2>
<div class="aircraft-list" id="pending-ppr-list">
<div class="no-aircraft">No pending PPRs</div>
</div>
</div>
</div>
<!--#include virtual="/shared-modals.html" -->
<script src="shared.js"></script>
<script>
async function loadPPRs() {
if (!accessToken) return;
// Debounce: prevent duplicate refreshes if called multiple times in quick succession
// (e.g., from form submission + WebSocket update at the same time)
if (loadPPRsTimeout) {
clearTimeout(loadPPRsTimeout);
}
loadPPRsTimeout = setTimeout(async () => {
// Load all tables simultaneously
await Promise.all([loadArrivals(), loadDepartures(), loadOverflights(), loadDeparted(), loadParked(), loadUpcoming()]);
loadPPRsTimeout = null;
}, 100); // Wait 100ms before executing to batch multiple calls
}
// Load arrivals (NEW and CONFIRMED status for PPR, DEPARTED for local flights)
async function loadArrivals() {
document.getElementById('arrivals-loading').style.display = 'block';
document.getElementById('arrivals-table-content').style.display = 'none';
document.getElementById('arrivals-no-data').style.display = 'none';
try {
// Load PPRs, local flights, and booked-in arrivals
const [pprResponse, localResponse, bookInResponse] = await Promise.all([
authenticatedFetch('/api/v1/pprs/?limit=1000'),
authenticatedFetch('/api/v1/local-flights/?status=DEPARTED&limit=1000'),
authenticatedFetch('/api/v1/arrivals/?limit=1000')
]);
if (!pprResponse.ok) {
throw new Error('Failed to fetch arrivals');
}
const allPPRs = await pprResponse.json();
const today = new Date().toISOString().split('T')[0];
// Filter for arrivals with ETA today and NEW or CONFIRMED status
const arrivals = allPPRs.filter(ppr => {
if (!ppr.eta || (ppr.status !== 'NEW' && ppr.status !== 'CONFIRMED')) {
return false;
}
// Extract date from ETA (UTC)
const etaDate = ppr.eta.split('T')[0];
return etaDate === today;
});
// Add local flights in DEPARTED status (in the air, heading back) - only those booked out today
if (localResponse.ok) {
const localFlights = await localResponse.json();
const today = new Date().toISOString().split('T')[0];
const localInAir = localFlights
.filter(flight => {
// Only include flights booked out today (created_dt)
if (!flight.created_dt) return false;
const createdDate = flight.created_dt.split('T')[0];
return createdDate === today;
})
.map(flight => ({
...flight,
isLocalFlight: true // Flag to distinguish from PPR
}));
arrivals.push(...localInAir);
}
// Add booked-in arrivals from the arrivals table
if (bookInResponse.ok) {
const bookedInArrivals = await bookInResponse.json();
const today = new Date().toISOString().split('T')[0];
const bookedInToday = bookedInArrivals
.filter(arrival => {
// Only include arrivals booked in today (created_dt) with INBOUND, LOCAL, or CIRCUIT status
if (!arrival.created_dt || !['INBOUND', 'LOCAL', 'CIRCUIT'].includes(arrival.status)) return false;
const bookedDate = arrival.created_dt.split('T')[0];
return bookedDate === today;
})
.map(arrival => ({
...arrival,
isBookedIn: true // Flag to distinguish from PPR and local
}));
arrivals.push(...bookedInToday);
}
displayArrivals(arrivals);
} catch (error) {
console.error('Error loading arrivals:', error);
if (error.message !== 'Session expired. Please log in again.') {
showNotification('Error loading arrivals', true);
}
}
document.getElementById('arrivals-loading').style.display = 'none';
}
// Load departures (LANDED status for PPR, GROUND/LOCAL for local flights)
async function loadDepartures() {
document.getElementById('departures-loading').style.display = 'block';
document.getElementById('departures-table-content').style.display = 'none';
document.getElementById('departures-no-data').style.display = 'none';
try {
// Load PPR departures, local flight departures, and airport departures simultaneously
const [pprResponse, localBookedOutResponse, localOutGroundResponse, localLocalResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([
authenticatedFetch('/api/v1/pprs/?limit=1000'),
authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&limit=1000'),
authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000'),
authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'),
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'),
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'),
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000')
]);
if (!pprResponse.ok) {
throw new Error('Failed to fetch PPR departures');
}
const allPPRs = await pprResponse.json();
const localBookedOut = localBookedOutResponse.ok ? await localBookedOutResponse.json() : [];
const localOutGround = localOutGroundResponse.ok ? await localOutGroundResponse.json() : [];
const localLocal = localLocalResponse.ok ? await localLocalResponse.json() : [];
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : [];
const depLocal = depLocalResponse.ok ? await depLocalResponse.json() : [];
// Combine local flights
const allLocalFlights = [...localBookedOut, ...localOutGround, ...localLocal];
// Combine departures
const allDepartures = [...depBookedOut, ...depOutGround, ...depLocal];
const today = new Date().toISOString().split('T')[0];
// Filter for PPR departures with ETD today and LANDED status only
const departures = allPPRs.filter(ppr => {
if (!ppr.etd || ppr.status !== 'LANDED') {
return false;
}
// Extract date from ETD (UTC)
const etdDate = ppr.etd.split('T')[0];
return etdDate === today;
});
// Add local flights (GROUND and LOCAL status - ready to go) - only those booked out today
const localDepartures = allLocalFlights
.filter(flight => {
// Only include flights booked out today (created_dt)
if (!flight.created_dt) return false;
const createdDate = flight.created_dt.split('T')[0];
return createdDate === today;
})
.map(flight => ({
...flight,
isLocalFlight: true // Flag to distinguish from PPR
}));
departures.push(...localDepartures);
// Add departures to other airports (BOOKED_OUT, GROUND, and LOCAL status)
const depDepartures = allDepartures.map(flight => ({
...flight,
isDeparture: true // Flag to distinguish from PPR
}));
departures.push(...depDepartures);
displayDepartures(departures);
} catch (error) {
console.error('Error loading departures:', error);
if (error.message !== 'Session expired. Please log in again.') {
showNotification('Error loading departures', true);
}
}
document.getElementById('departures-loading').style.display = 'none';
}
// Load overflights (ACTIVE status only)
async function loadOverflights() {
document.getElementById('overflights-loading').style.display = 'block';
document.getElementById('overflights-table-content').style.display = 'none';
document.getElementById('overflights-no-data').style.display = 'none';
try {
const response = await authenticatedFetch('/api/v1/overflights/?status=ACTIVE&limit=100');
if (!response.ok) {
throw new Error('Failed to fetch overflights');
}
const overflights = await response.json();
displayOverflights(overflights);
} catch (error) {
console.error('Error loading overflights:', error);
if (error.message !== 'Session expired. Please log in again.') {
showNotification('Error loading overflights', true);
}
}
document.getElementById('overflights-loading').style.display = 'none';
}
function displayOverflights(overflights) {
const tbody = document.getElementById('overflights-table-body');
document.getElementById('overflights-count').textContent = overflights.length;
if (overflights.length === 0) {
document.getElementById('overflights-no-data').style.display = 'block';
return;
}
// Sort by call_dt most recent
overflights.sort((a, b) => new Date(b.call_dt) - new Date(a.call_dt));
tbody.innerHTML = '';
for (const flight of overflights) {
const row = document.createElement('tr');
row.style.cursor = 'pointer';
row.style.backgroundColor = '#ccffcc';
row.onclick = () => {
openOverflightEditModal(flight.id);
};
const statusBadge = flight.status === 'ACTIVE' ?
'<span style="background-color: #28a745; color: white; padding: 2px 6px; border-radius: 3px; font-size: 0.8rem;">ACTIVE</span>' :
'<span style="background-color: #6c757d; color: white; padding: 2px 6px; border-radius: 3px; font-size: 0.8rem;">QSY\'D</span>';
// Action buttons for overflight
let actionButtons = '';
if (flight.status === 'ACTIVE') {
actionButtons = `
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentOverflightId = ${flight.id}; showOverflightQSYModal()" title="Mark QSY">
QSY
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); cancelOverflightFromTable(${flight.id})" title="Cancel">
CANCEL
</button>
`;
} else {
actionButtons = '<span style="color: #999;">-</span>';
}
row.innerHTML = `
<td>${flight.registration || '-'}</td>
<td style="width: 30px; text-align: center;"><span style="color: #ff6b6b; font-weight: bold;" title="Overflight">🔄</span></td>
<td>${flight.callsign || '-'}</td>
<td>${flight.type || '-'}</td>
<td>${flight.departure_airfield || '-'}</td>
<td>${flight.destination_airfield || '-'}</td>
<td>${formatTimeOnly(flight.call_dt)}</td>
<td>${flight.pob || '-'}</td>
<td>${statusBadge}</td>
<td style="white-space: nowrap;">${actionButtons}</td>
`;
tbody.appendChild(row);
}
document.getElementById('overflights-table-content').style.display = 'block';
}
// Load departed aircraft (DEPARTED status with departed_dt today)
async function loadDeparted() {
document.getElementById('departed-loading').style.display = 'block';
document.getElementById('departed-table-content').style.display = 'none';
document.getElementById('departed-no-data').style.display = 'none';
try {
const [pprResponse, depResponse] = await Promise.all([
authenticatedFetch('/api/v1/pprs/?limit=1000'),
authenticatedFetch('/api/v1/departures/?status=DEPARTED&limit=1000')
]);
if (!pprResponse.ok) {
throw new Error('Failed to fetch departed aircraft');
}
const allPPRs = await pprResponse.json();
const today = new Date().toISOString().split('T')[0];
// Filter for PPRs departed today (only PPR'd departures, exclude local/circuits)
const departed = allPPRs.filter(ppr => {
if (!ppr.departed_dt || ppr.status !== 'DEPARTED') {
return false;
}
const departedDate = ppr.departed_dt.split('T')[0];
return departedDate === today;
});
// Add departures to other airports that departed today (booked out departures)
if (depResponse.ok) {
const depFlights = await depResponse.json();
const depDeparted = depFlights.filter(flight => {
if (!flight.departed_dt) return false;
const departedDate = flight.departed_dt.split('T')[0];
return departedDate === today;
}).map(flight => ({
...flight,
isDeparture: true
}));
departed.push(...depDeparted);
}
displayDeparted(departed);
} catch (error) {
console.error('Error loading departed aircraft:', error);
if (error.message !== 'Session expired. Please log in again.') {
showNotification('Error loading departed aircraft', true);
}
}
document.getElementById('departed-loading').style.display = 'none';
}
function displayDeparted(departed) {
const tbody = document.getElementById('departed-table-body');
document.getElementById('departed-count').textContent = departed.length;
if (departed.length === 0) {
document.getElementById('departed-no-data').style.display = 'block';
return;
}
// Sort by departed time
departed.sort((a, b) => {
const aTime = a.departed_dt;
const bTime = b.departed_dt;
return new Date(aTime) - new Date(bTime);
});
tbody.innerHTML = '';
document.getElementById('departed-table-content').style.display = 'block';
for (const flight of departed) {
const row = document.createElement('tr');
const isLocal = flight.isLocalFlight;
const isDeparture = flight.isDeparture;
row.onclick = () => {
if (isLocal) {
openLocalFlightEditModal(flight.id);
} else if (isDeparture) {
openDepartureEditModal(flight.id);
} else {
openPPRModal(flight.id);
}
};
row.style.cssText = 'font-size: 0.85rem !important; font-style: italic;';
if (isLocal) {
row.innerHTML = `
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.registration || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;">${flight.submitted_via === 'PUBLIC' ? '<span style="color: #b8860b; font-weight: bold;" title="Submitted by Pilot Online">O</span>' : ''}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.callsign || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">-</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
`;
} else if (isDeparture) {
row.innerHTML = `
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.registration || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;">${flight.submitted_via === 'PUBLIC' ? '<span style="color: #b8860b; font-weight: bold;" title="Submitted by Pilot Online">O</span>' : ''}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.callsign || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
`;
} else {
row.innerHTML = `
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.ac_reg || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"><span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span></td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.ac_call || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
`;
}
tbody.appendChild(row);
}
}
// Load booked out aircraft (BOOKED_OUT status for departures only)
async function loadParked() {
document.getElementById('parked-loading').style.display = 'block';
document.getElementById('parked-table-content').style.display = 'none';
document.getElementById('parked-no-data').style.display = 'none';
try {
const pprResponse = await authenticatedFetch('/api/v1/pprs/?limit=1000');
if (!pprResponse.ok) {
throw new Error('Failed to fetch parked visitors');
}
const allPPRs = await pprResponse.json();
const today = new Date().toISOString().split('T')[0];
// Filter for parked PPR visitors: LANDED status and ETD on a different day
const parked = allPPRs.filter(ppr => {
if (ppr.status !== 'LANDED') {
return false;
}
// Only show if ETD exists and is not today
if (!ppr.etd) {
return false;
}
const etdDate = ppr.etd.split('T')[0];
return etdDate !== today;
});
displayParked(parked);
} catch (error) {
console.error('Error loading parked visitors:', error);
if (error.message !== 'Session expired. Please log in again.') {
showNotification('Error loading parked visitors', true);
}
}
document.getElementById('parked-loading').style.display = 'none';
}
function displayParked(parked) {
const tbody = document.getElementById('parked-table-body');
document.getElementById('parked-count').textContent = parked.length;
if (parked.length === 0) {
document.getElementById('parked-no-data').style.display = 'block';
return;
}
// Sort by landed time
parked.sort((a, b) => {
if (!a.landed_dt) return 1;
if (!b.landed_dt) return -1;
return new Date(a.landed_dt) - new Date(b.landed_dt);
});
tbody.innerHTML = '';
document.getElementById('parked-table-content').style.display = 'block';
for (const ppr of parked) {
const row = document.createElement('tr');
// All rows are PPR, so make them clickable
row.onclick = () => openPPRModal(ppr.id);
row.style.cssText = 'font-size: 0.85rem !important; font-style: italic;';
// Get registration
const registration = ppr.ac_reg || '-';
const typeIconParked = '<span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span>';
// Get aircraft type
const acType = ppr.ac_type || '-';
// Get from airport
const fromAirport = ppr.in_from || '-';
// Format arrival: time if today, date if not
const today = new Date().toISOString().split('T')[0];
let arrivedDisplay = '-';
if (ppr.landed_dt) {
const landedDate = ppr.landed_dt.split('T')[0];
if (landedDate === today) {
// Today - show time only
arrivedDisplay = formatTimeOnly(ppr.landed_dt);
} else {
// Not today - show date (DD/MM)
const date = new Date(ppr.landed_dt);
arrivedDisplay = date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
}
}
// Format ETD as just the date (DD/MM)
let etdDisplay = '-';
if (ppr.etd) {
const etdDate = new Date(ppr.etd);
etdDisplay = etdDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
}
row.innerHTML = `
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${registration}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;">${typeIconParked}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${acType}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${fromAirport}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${arrivedDisplay}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${etdDisplay}</td>
`;
tbody.appendChild(row);
}
}
// 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() ?
`<span class="notes-tooltip">
<span class="notes-indicator">📝</span>
<span class="tooltip-text">${ppr.notes}</span>
</span>` : '';
row.innerHTML = `
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${dateDisplay}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.ac_reg || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"><span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span></td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.ac_type || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.in_from || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(ppr.eta)}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${notesIndicator}</td>
`;
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 = {};
async function getAirportDisplay(code) {
if (!code || code.length !== 4 || !/^[A-Z]{4}$/.test(code)) return code;
if (airportNameCache[code]) return `${code}<br><span style="font-size:0.8em;color:#666;font-style:italic;">${airportNameCache[code]}</span>`;
try {
const resp = await authenticatedFetch(`/api/v1/airport/lookup/${code}`);
if (resp.ok) {
const data = await resp.json();
if (data && data.length && data[0].name) {
airportNameCache[code] = data[0].name;
return `${code}<br><span style="font-size:0.8em;color:#666;font-style:italic;">${data[0].name}</span>`;
}
}
} catch {}
return code;
}
async function displayArrivals(arrivals) {
const tbody = document.getElementById('arrivals-table-body');
const recordCount = document.getElementById('arrivals-count');
recordCount.textContent = arrivals.length;
if (arrivals.length === 0) {
document.getElementById('arrivals-no-data').style.display = 'block';
return;
}
// Sort arrivals by ETA/departure time (ascending)
arrivals.sort((a, b) => {
const aTime = a.eta || a.departure_dt;
const bTime = b.eta || b.departure_dt;
if (!aTime) return 1;
if (!bTime) return -1;
return new Date(aTime) - new Date(bTime);
});
tbody.innerHTML = '';
document.getElementById('arrivals-table-content').style.display = 'block';
for (const flight of arrivals) {
const row = document.createElement('tr');
const isLocal = flight.isLocalFlight;
const isBookedIn = flight.isBookedIn;
// Click handler that routes to correct modal
row.onclick = () => {
if (isLocal) {
openLocalFlightEditModal(flight.id);
} else if (isBookedIn) {
openArrivalEditModal(flight.id);
} else {
openPPRModal(flight.id);
}
};
// Create notes indicator if notes exist
const notesIndicator = flight.notes && flight.notes.trim() ?
`<span class="notes-tooltip">
<span class="notes-indicator">📝</span>
<span class="tooltip-text">${flight.notes}</span>
</span>` : '';
let aircraftDisplay, acType, fromDisplay, eta, pob, fuel, actionButtons, typeIcon;
if (isLocal) {
// Local flight display
if (flight.callsign && flight.callsign.trim()) {
aircraftDisplay = `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`;
} else {
aircraftDisplay = `<strong>${flight.registration}</strong>`;
}
acType = flight.type;
typeIcon = flight.submitted_via === 'PUBLIC' ? '<span style="color: #b8860b; font-weight: bold; font-size: 0.9em;" title="Submitted by Pilot Online">O</span>' : '';
fromDisplay = `<i>${flight.flight_type === 'CIRCUITS' ? 'Circuits' : flight.flight_type === 'LOCAL' ? 'Local Flight' : 'Departure'}</i>`;
// Calculate ETA: use departed_dt (actual departure) if available, otherwise etd (planned departure)
// Then add duration to get ETA
let departureTime = flight.departed_dt || flight.etd;
let etaTime = departureTime;
if (departureTime && flight.duration) {
const departTime = new Date(departureTime);
etaTime = new Date(departTime.getTime() + flight.duration * 60000).toISOString(); // duration is in minutes
}
eta = etaTime ? formatTimeOnly(etaTime) : '-';
pob = flight.pob || '-';
fuel = '-';
// Allow touch and go for all local flight types
let circuitButton = '';
circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
T&G
</button>`;
actionButtons = `
${circuitButton}
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, true)" title="Mark as Landed">
LAND
</button>
`;
} else if (isBookedIn) {
// Booked-in arrival display
if (flight.callsign && flight.callsign.trim()) {
aircraftDisplay = `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`;
} else {
aircraftDisplay = `<strong>${flight.registration}</strong>`;
}
acType = flight.type;
typeIcon = flight.submitted_via === 'PUBLIC' ? '<span style="color: #b8860b; font-weight: bold; font-size: 0.9em;" title="Submitted by Pilot Online">O</span>' : '';
// Lookup airport name for in_from
let fromDisplay_temp = flight.in_from;
if (flight.in_from && flight.in_from.length === 4 && /^[A-Z]{4}$/.test(flight.in_from)) {
fromDisplay_temp = await getAirportDisplay(flight.in_from);
}
fromDisplay = fromDisplay_temp;
// Show ETA if available, otherwise show landed_dt
eta = flight.eta ? formatTimeOnly(flight.eta) : (flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-');
pob = flight.pob || '-';
fuel = '-';
// Different action buttons based on status
if (flight.status === 'INBOUND') {
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Mark as Local">
LOCAL
</button>
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentBookedInArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
LAND
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel Arrival">
CANCEL
</button>
`;
} else if (flight.status === 'LOCAL') {
// Arrival in local area - show circuit and land buttons
let circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
T&G
</button>`;
actionButtons = `
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'CIRCUIT')" title="Join Circuit">
CIRCUIT
</button>
${circuitButton}
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
LAND
</button>
`;
} else if (flight.status === 'CIRCUIT') {
// Arrival in circuit - show local, T&G and land buttons
let circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
T&G
</button>`;
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Area">
LOCAL
</button>
${circuitButton}
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
LAND
</button>
`;
} else {
actionButtons = `
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentBookedInArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
LAND
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel Arrival">
CANCEL
</button>
`;
}
} else {
// PPR display
if (flight.ac_call && flight.ac_call.trim()) {
aircraftDisplay = `<strong>${flight.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.ac_reg}</span>`;
} else {
aircraftDisplay = `<strong>${flight.ac_reg}</strong>`;
}
acType = flight.ac_type;
typeIcon = '<span style="color: #032cfc; font-weight: bold; font-size: 0.9em;" title="From PPR">P</span>';
// Lookup airport name for in_from
let fromDisplay_temp = flight.in_from;
if (flight.in_from && flight.in_from.length === 4 && /^[A-Z]{4}$/.test(flight.in_from)) {
fromDisplay_temp = await getAirportDisplay(flight.in_from);
}
fromDisplay = fromDisplay_temp;
eta = formatTimeOnly(flight.eta);
pob = flight.pob_in;
fuel = flight.fuel || '-';
actionButtons = `
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); showTimestampModal('LANDED', ${flight.id})" title="Mark as Landed">
LAND
</button>
`;
}
row.innerHTML = `
<td>${aircraftDisplay}${notesIndicator}</td>
<td style="text-align: center; width: 30px;">${typeIcon}</td>
<td>${acType}</td>
<td>${fromDisplay}</td>
<td>${eta}</td>
<td>${pob}</td>
<td>${fuel}</td>
<td>${actionButtons}</td>
`;
tbody.appendChild(row);
}
// Setup tooltips after rendering
setupTooltips();
}
async function displayDepartures(departures) {
const tbody = document.getElementById('departures-table-body');
const recordCount = document.getElementById('departures-count');
recordCount.textContent = departures.length;
if (departures.length === 0) {
document.getElementById('departures-no-data').style.display = 'block';
return;
}
// Sort departures by ETD (ascending), nulls last
departures.sort((a, b) => {
const aTime = a.etd || a.created_dt;
const bTime = b.etd || b.created_dt;
if (!aTime) return 1;
if (!bTime) return -1;
return new Date(aTime) - new Date(bTime);
});
tbody.innerHTML = '';
document.getElementById('departures-table-content').style.display = 'block';
for (const flight of departures) {
const row = document.createElement('tr');
const isLocal = flight.isLocalFlight;
const isDeparture = flight.isDeparture;
// Click handler that routes to correct modal
row.onclick = () => {
if (isLocal) {
openLocalFlightEditModal(flight.id);
} else if (isDeparture) {
openDepartureEditModal(flight.id);
} else {
openPPRModal(flight.id);
}
};
// Create notes indicator if notes exist
const notesIndicator = flight.notes && flight.notes.trim() ?
`<span class="notes-tooltip">
<span class="notes-indicator">📝</span>
<span class="tooltip-text">${flight.notes}</span>
</span>` : '';
let aircraftDisplay, toDisplay, etd, pob, fuel, landedDt, actionButtons, typeIcon;
if (isLocal) {
// Local flight display
if (flight.callsign && flight.callsign.trim()) {
aircraftDisplay = `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`;
} else {
aircraftDisplay = `<strong>${flight.registration}</strong>`;
}
typeIcon = flight.submitted_via === 'PUBLIC' ? '<span style="color: #b8860b; font-weight: bold; font-size: 0.9em;" title="Submitted by Pilot Online">O</span>' : '';
toDisplay = `<i>${flight.flight_type === 'CIRCUITS' ? 'Circuits' : flight.flight_type === 'LOCAL' ? 'Local Flight' : 'Departure'}</i>`;
etd = flight.etd ? formatTimeOnly(flight.etd) : (flight.created_dt ? formatTimeOnly(flight.created_dt) : '-');
pob = flight.pob || '-';
fuel = '-';
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
// Action buttons for local flight
if (flight.status === 'BOOKED_OUT') {
actionButtons = `
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('GROUND', ${flight.id}, true)" title="Contact Pilot">
CONTACT
</button>
`;
} else if (flight.status === 'GROUND') {
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('DEPARTED', ${flight.id}, true)" title="Mark as Departed">
TAKE OFF
</button>
`;
} else if (flight.status === 'DEPARTED') {
// Allow touch and go for all local flight types
let circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
T&G
</button>`;
actionButtons = `
${circuitButton}
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, true)" title="Mark as Landed">
LAND
</button>
`;
} else {
actionButtons = '<span style="color: #999;">-</span>';
}
} else if (isDeparture) {
// Departure to other airport display
if (flight.callsign && flight.callsign.trim()) {
aircraftDisplay = `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`;
} else {
aircraftDisplay = `<strong>${flight.registration}</strong>`;
}
typeIcon = flight.submitted_via === 'PUBLIC' ? '<span style="color: #b8860b; font-weight: bold; font-size: 0.9em;" title="Submitted by Pilot Online">O</span>' : '';
toDisplay = flight.out_to || '-';
if (flight.out_to && flight.out_to.length === 4 && /^[A-Z]{4}$/.test(flight.out_to)) {
toDisplay = await getAirportDisplay(flight.out_to);
}
etd = flight.etd ? formatTimeOnly(flight.etd) : (flight.created_dt ? formatTimeOnly(flight.created_dt) : '-');
pob = flight.pob || '-';
fuel = '-';
landedDt = flight.departed_dt ? formatTimeOnly(flight.departed_dt) : '-';
// Action buttons for departure
if (flight.status === 'BOOKED_OUT') {
actionButtons = `
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentDepartureId = ${flight.id}; showTimestampModal('GROUND', ${flight.id}, false, true)" title="Contact Pilot">
CONTACT
</button>
`;
} else if (flight.status === 'GROUND') {
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentDepartureId = ${flight.id}; showTimestampModal('LOCAL', ${flight.id}, false, true)" title="Mark as Local">
TAKE OFF
</button>
`;
} else if (flight.status === 'LOCAL') {
actionButtons = `
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentDepartureId = ${flight.id}; showTimestampModal('DEPARTED', ${flight.id}, false, true)" title="Mark as Departed">
QSY
</button>
`;
} else if (flight.status === 'DEPARTED') {
actionButtons = '<span style="color: #999;">Departed</span>';
} else {
actionButtons = '<span style="color: #999;">-</span>';
}
} else {
// PPR display
if (flight.ac_call && flight.ac_call.trim()) {
aircraftDisplay = `<strong>${flight.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.ac_reg}</span>`;
} else {
aircraftDisplay = `<strong>${flight.ac_reg}</strong>`;
}
typeIcon = '<span style="color: #032cfc; font-weight: bold; font-size: 0.9em;" title="From PPR">P</span>';
toDisplay = flight.out_to || '-';
if (flight.out_to && flight.out_to.length === 4 && /^[A-Z]{4}$/.test(flight.out_to)) {
toDisplay = await getAirportDisplay(flight.out_to);
}
etd = flight.etd ? formatTimeOnly(flight.etd) : '-';
pob = flight.pob_out || flight.pob_in;
fuel = flight.fuel || '-';
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
TAKE OFF
</button>
`;
}
row.innerHTML = `
<td>${aircraftDisplay}${notesIndicator}</td>
<td style="text-align: center; width: 30px;">${typeIcon}</td>
<td>${isLocal ? flight.type : isDeparture ? flight.type : flight.ac_type}</td>
<td>${toDisplay}</td>
<td>${etd}</td>
<td>${pob}</td>
<td>${fuel}</td>
<td>${landedDt}</td>
<td>${actionButtons}</td>
`;
tbody.appendChild(row);
}
}
async function updateLocalFlightStatusFromTable(flightId, status) {
if (!accessToken) return;
// Show confirmation for cancel actions
if (status === 'CANCELLED') {
if (!confirm('Are you sure you want to cancel this flight? This action cannot be easily undone.')) {
return;
}
}
try {
const response = await fetch(`/api/v1/local-flights/${flightId}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({ status: status })
});
if (!response.ok) throw new Error('Failed to update status');
loadATCAircraft(); // Refresh display
showNotification(`Flight marked as ${status.toLowerCase()}`);
} catch (error) {
console.error('Error updating status:', error);
showNotification('Error updating flight status', true);
}
}
// Update status from table for arrivals
async function updateArrivalStatusFromTable(arrivalId, status) {
if (!accessToken) return;
try {
const response = await fetch(`/api/v1/arrivals/${arrivalId}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({ status: status })
});
if (!response.ok) throw new Error('Failed to update status');
loadATCAircraft(); // Refresh display
showNotification(`Arrival marked as ${status.toLowerCase()}`);
} catch (error) {
console.error('Error updating status:', error);
showNotification('Error updating arrival status', true);
}
}
// Override: ATC updates flight status to CIRCUIT then refreshes ATC display
async function afterCircuitSaved() {
if (currentLocalFlightId) {
await updateLocalFlightStatusFromTable(currentLocalFlightId, 'CIRCUIT');
} else if (currentArrivalId) {
await updateArrivalStatusFromTable(currentArrivalId, 'CIRCUIT');
}
closeCircuitModal();
loadATCAircraft();
}
async function loadATCAircraft() {
try {
await Promise.all([
loadDepartingAircraft(),
loadLocalAircraft(),
loadInboundAircraft(),
loadCircuitAircraft(),
loadPendingPPRs(),
loadParkedVisitors()
]);
} catch (error) {
console.error('Error loading ATC aircraft:', error);
}
}
// Load departing aircraft (ready to take off)
async function loadDepartingAircraft() {
try {
const [groundDeparturesResponse, groundLocalResponse] = await Promise.all([
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'),
authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000')
]);
let groundAircraft = [];
if (groundDeparturesResponse.ok) groundAircraft = await groundDeparturesResponse.json();
if (groundLocalResponse.ok) groundAircraft = groundAircraft.concat((await groundLocalResponse.json()).map(l => ({ ...l, isLocalFlight: true })));
displayDepartingAircraft(groundAircraft.map(ac => ({
...ac,
isDeparture: !ac.isLocalFlight
})));
} catch (error) {
console.error('Error loading departing aircraft:', error);
}
}
function displayDepartingAircraft(aircraft) {
const container = document.getElementById('departing-list');
const countEl = document.getElementById('departing-count');
countEl.textContent = aircraft.length;
if (aircraft.length === 0) {
container.innerHTML = '<div class="no-aircraft">No departing aircraft</div>';
return;
}
container.innerHTML = aircraft.map(ac => {
const reg = ac.ac_reg || ac.registration;
const type = ac.ac_type || ac.type;
const dest = ac.out_to;
const isLocal = ac.isLocalFlight;
// All aircraft in awaiting departure are in GROUND status
let takeoffOnclick, buttonText, buttonTitle, clickType;
if (isLocal) {
const takeoffStatus = ac.flight_type === 'CIRCUITS' ? 'CIRCUIT' : 'LOCAL';
const takeoffTitle = ac.flight_type === 'CIRCUITS' ? 'Mark as Circuit' : 'Mark as Local';
takeoffOnclick = `event.stopPropagation(); currentLocalFlightId = '${ac.id}'; showTimestampModal('${takeoffStatus}', ${ac.id}, true)`;
buttonText = 'TAKE OFF';
buttonTitle = takeoffTitle;
clickType = 'local';
} else {
takeoffOnclick = `event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('LOCAL', ${ac.id}, false, true)`;
buttonText = 'TAKE OFF';
buttonTitle = 'Mark as Local';
clickType = 'departure';
}
const itemClass = isLocal ? 'local-flight' : 'departure';
return `
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${clickType}')">
<div class="aircraft-info">
<div class="aircraft-reg">${reg}</div>
<div class="aircraft-details">${type}${dest ? ` → ${dest}` : ` Local Flight`}</div>
</div>
<button class="status-btn" onclick="${takeoffOnclick}" title="${buttonTitle}">${buttonText}</button>
</div>
`;
}).join('');
}
// Load local area aircraft
async function loadLocalAircraft() {
try {
const response = await Promise.all([
authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'),
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000'),
authenticatedFetch('/api/v1/arrivals/?status=LOCAL&limit=1000'),
authenticatedFetch('/api/v1/overflights/?status=ACTIVE&limit=1000')
]);
let locals = [];
if (response[0].ok) locals = (await response[0].json()).map(l => ({ ...l, isLocalFlight: true }));
if (response[1].ok) locals = locals.concat((await response[1].json()).map(d => ({ ...d, isDeparture: true })));
if (response[2].ok) locals = locals.concat((await response[2].json()).map(a => ({ ...a, isArrival: true })));
if (response[3].ok) locals = locals.concat((await response[3].json()).map(o => ({ ...o, isOverflight: true })));
displayLocalAircraft(locals);
} catch (error) {
console.error('Error loading local aircraft:', error);
}
}
function displayLocalAircraft(aircraft) {
const container = document.getElementById('local-list');
const countEl = document.getElementById('local-count');
countEl.textContent = aircraft.length;
if (aircraft.length === 0) {
container.innerHTML = '<div class="no-aircraft">No aircraft in local area</div>';
return;
}
container.innerHTML = aircraft.map(ac => {
const reg = ac.registration || ac.ac_reg || ac.callsign || '-';
const type = ac.type || ac.ac_type || ac.aircraft_type || '';
const dest = ac.out_to;
const isDeparture = ac.isDeparture;
let buttons;
if (isDeparture) {
// Departure in LOCAL status - show QSY and REJOIN buttons
buttons = `
<button class="status-btn" onclick="event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('DEPARTED', ${ac.id}, false, true)">QSY</button>
<button class="status-btn" onclick="event.stopPropagation(); updateDepartureStatusFromTable('${ac.id}', 'CIRCUIT')">REJOIN</button>
`;
} else if (ac.isLocalFlight || ac.isArrival) {
// Local flight or arrival in LOCAL status - show REJOIN button
buttons = `<button class="status-btn" onclick="event.stopPropagation(); ${ac.isArrival ? `updateArrivalStatusFromTable('${ac.id}', 'CIRCUIT')` : `updateLocalFlightStatusFromTable('${ac.id}', 'CIRCUIT')`}">REJOIN</button>`;
} else if (ac.isOverflight) {
// Overflight in ACTIVE status - show QSY button
buttons = `<button class="status-btn" onclick="event.stopPropagation(); currentOverflightId = '${ac.id}'; showOverflightQSYModal()">QSY</button>`;
}
const itemClass = isDeparture ? 'departure' : (ac.isArrival ? 'inbound' : (ac.isOverflight ? 'overflight' : 'local-flight'));
const detailsText = isDeparture ? `${type}${dest ? ` → ${dest}` : ` (Local)`}` : (ac.isOverflight ? `${ac.departure_airfield || '?'}${ac.destination_airfield || '?'}` : (ac.isArrival ? `${type} from ${ac.in_from || '?'}` : `${type}${dest ? ` → ${dest}` : ` Local Flight`}`));
const entityType = isDeparture ? 'departure' : (ac.isArrival ? 'arrival' : (ac.isOverflight ? 'overflight' : 'local'));
return `
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${entityType}')">
<div class="aircraft-info">
<div class="aircraft-reg">${reg}</div>
<div class="aircraft-details">${detailsText}</div>
</div>
<div style="display: flex; gap: 0.5rem;">
${buttons}
</div>
</div>
`;
}).join('');
}
// Load inbound aircraft
async function loadInboundAircraft() {
try {
const response = await Promise.all([
authenticatedFetch('/api/v1/pprs/?limit=1000'),
authenticatedFetch('/api/v1/arrivals/?status=INBOUND&limit=1000')
]);
const pprs = response[0].ok ? await response[0].json() : [];
const arrivals = response[1].ok ? await response[1].json() : [];
const today = new Date().toISOString().split('T')[0];
let inbound = pprs.filter(p => p.status === 'CONFIRMED' && p.eta && p.eta.split('T')[0] === today);
inbound = inbound.concat(arrivals.map(a => ({ ...a, isArrival: true })));
displayInboundAircraft(inbound);
} catch (error) {
console.error('Error loading inbound aircraft:', error);
}
}
function displayInboundAircraft(aircraft) {
const container = document.getElementById('inbound-list');
const countEl = document.getElementById('inbound-count');
countEl.textContent = aircraft.length;
if (aircraft.length === 0) {
container.innerHTML = '<div class="no-aircraft">No inbound aircraft</div>';
return;
}
container.innerHTML = aircraft.map(ac => {
const reg = ac.ac_reg || ac.registration;
const type = ac.ac_type || ac.type;
const from = ac.in_from;
const eta = ac.eta;
let buttons = '';
if (ac.isArrival) {
// Arrival in INBOUND status - show LOCAL button
buttons = `<button class="status-btn" onclick="event.stopPropagation(); updateArrivalStatusFromTable('${ac.id}', 'LOCAL')">→ LOCAL</button>`;
}
return `
<div class="aircraft-item inbound" onclick="handleATCClick('${ac.id}', '${ac.isArrival ? 'arrival' : 'ppr'}')">
<div class="aircraft-info">
<div class="aircraft-reg">${reg}</div>
<div class="aircraft-details">${type} from ${from || 'Local Flight'}</div>
</div>
${buttons ? `<div style="display: flex; gap: 0.5rem;">${buttons}</div>` : '<div class="aircraft-status status-inbound">IB</div>'}
</div>
`;
}).join('');
}
// Load circuit traffic
async function loadCircuitAircraft() {
try {
const response = await Promise.all([
authenticatedFetch('/api/v1/local-flights/?status=DEPARTED&limit=1000&flight_type=CIRCUITS'),
authenticatedFetch('/api/v1/local-flights/?status=CIRCUIT&limit=1000'),
authenticatedFetch('/api/v1/arrivals/?status=CIRCUIT&limit=1000')
]);
let circuits = [];
if (response[0].ok) circuits = circuits.concat(await response[0].json());
if (response[1].ok) circuits = circuits.concat((await response[1].json()).map(l => ({ ...l, circuitStatus: getCircuitStatus(l.status) })));
if (response[2].ok) circuits = circuits.concat((await response[2].json()).map(a => ({ ...a, isArrival: true, circuitStatus: getCircuitStatus(a.status) })));
displayCircuitAircraft(circuits);
} catch (error) {
console.error('Error loading circuit aircraft:', error);
}
}
// Helper function to determine circuit status from API response
function getCircuitStatus(status) {
if (status === 'CIRCUIT_DOWNWIND') return 'DOWNWIND';
if (status === 'CIRCUIT_BASE') return 'BASE';
if (status === 'CIRCUIT_FINAL') return 'FINAL';
return 'CIRCUIT';
}
function displayCircuitAircraft(aircraft) {
const container = document.getElementById('circuit-list');
const countEl = document.getElementById('circuit-count');
// Define status order for sorting
const statusOrder = {
'CIRCUIT': 1,
'DOWNWIND': 2,
'BASE': 3,
'FINAL': 4
};
// Sort aircraft by circuit status
aircraft.sort((a, b) => {
const aStatus = a.circuitStatus || 'CIRCUIT';
const bStatus = b.circuitStatus || 'CIRCUIT';
return statusOrder[aStatus] - statusOrder[bStatus];
});
countEl.textContent = aircraft.length;
if (aircraft.length === 0) {
container.innerHTML = '<div class="no-aircraft">No aircraft in circuit</div>';
return;
}
container.innerHTML = aircraft.map(ac => {
const isArrival = ac.isArrival;
const entityType = isArrival ? 'arrival' : 'local';
const updateFunction = isArrival ? 'updateArrivalStatusFromTable' : 'updateLocalFlightStatusFromTable';
const landFunction = isArrival ? `${updateFunction}('${ac.id}', 'LANDED')` : `showTimestampModal('LANDED', ${ac.id}, true)`;
const circuitStatus = ac.circuitStatus || 'CIRCUIT';
let buttons = `
<button class="status-btn" onclick="event.stopPropagation(); ${updateFunction}('${ac.id}', 'LOCAL')">LOCAL</button>
`;
// Circuit position buttons - show all, highlight current
const downwindClass = circuitStatus === 'DOWNWIND' ? 'active-position' : '';
const baseClass = circuitStatus === 'BASE' ? 'active-position' : '';
const finalClass = circuitStatus === 'FINAL' ? 'active-position' : '';
buttons += `<button class="status-btn small-btn ${downwindClass}" onclick="event.stopPropagation(); ${updateFunction}('${ac.id}', 'CIRCUIT_DOWNWIND')" title="Downwind">D</button>`;
buttons += `<button class="status-btn small-btn ${baseClass}" onclick="event.stopPropagation(); ${updateFunction}('${ac.id}', 'CIRCUIT_BASE')" title="Base">B</button>`;
buttons += `<button class="status-btn small-btn ${finalClass}" onclick="event.stopPropagation(); ${updateFunction}('${ac.id}', 'CIRCUIT_FINAL')" title="Final">F</button>`;
// Show T&G for both local flights and arrivals - resets to CIRCUIT
const tgFunction = isArrival
? `currentArrivalId = '${ac.id}'; showCircuitModal(null, '${ac.id}')`
: `currentLocalFlightId = '${ac.id}'; showCircuitModal('${ac.id}')`;
buttons += `<button class="status-btn" onclick="event.stopPropagation(); ${tgFunction}">T&G</button>`;
buttons += `<button class="status-btn" onclick="event.stopPropagation(); ${landFunction}">LAND</button>`;
const itemClass = isArrival ? 'inbound' : 'circuit';
// Display text: for arrivals show origin, for local flights show type
const displayText = isArrival ? `${ac.type} from ${ac.in_from || '?'}` : `${ac.type}`;
return `
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${entityType}')">
<div class="aircraft-info">
<div class="aircraft-reg">${ac.registration}</div>
<div class="aircraft-details">${displayText}</div>
</div>
<div style="display: flex; gap: 0.25rem;">
${buttons}
</div>
</div>
`;
}).join('');
}
// Load pending PPRs
async function loadPendingPPRs() {
try {
const response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
const pprs = response.ok ? await response.json() : [];
const pending = pprs.filter(p => p.status === 'NEW' || p.status === 'CONFIRMED');
displayPendingPPRs(pending);
} catch (error) {
console.error('Error loading pending PPRs:', error);
}
}
function displayPendingPPRs(pprs) {
const container = document.getElementById('pending-ppr-list');
const countEl = document.getElementById('pending-ppr-count');
countEl.textContent = pprs.length;
if (pprs.length === 0) {
container.innerHTML = '<div class="no-aircraft">No pending PPRs</div>';
return;
}
container.innerHTML = pprs.map(ppr => `
<div class="aircraft-item" onclick="handleATCClick('${ppr.id}', 'ppr')">
<div class="aircraft-info">
<div class="aircraft-reg">${ppr.ac_reg}</div>
<div class="aircraft-details">${ppr.ac_type} from ${ppr.in_from}${ppr.eta ? ' ETA ' + formatTimeOnly(ppr.eta) : ''}</div>
</div>
<div style="display: flex; gap: 0.25rem; align-items: center;">
<div class="aircraft-status status-pending">PPR</div>
<button class="status-btn" onclick="event.stopPropagation(); activatePPR(${ppr.id}, '${ppr.ac_reg}', ${ppr.out_to ? 'true' : 'false'})">ACTIVATE</button>
</div>
</div>
`).join('');
}
async function activatePPR(pprId, acReg, hasDeparture) {
const msg = `Activate PPR for ${acReg}?\nThis will create an INBOUND arrival.`
+ (hasDeparture ? '\nThe outbound departure will appear automatically when the aircraft lands.' : '');
if (!confirm(msg)) return;
try {
const response = await authenticatedFetch(`/api/v1/pprs/${pprId}/activate`, { method: 'POST' });
if (!response.ok) {
const err = await response.json().catch(() => ({}));
showNotification(err.detail || 'Failed to activate PPR', true);
return;
}
const result = await response.json();
showNotification(result.message || 'PPR activated');
await loadPPRs();
} catch (error) {
console.error('Error activating PPR:', error);
showNotification('Error activating PPR', true);
}
}
// Load parked visitors
async function loadParkedVisitors() {
try {
const [localBookedOutResponse, depBookedOutResponse] = await Promise.all([
authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&limit=1000'),
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000')
]);
const localBookedOut = localBookedOutResponse.ok ? await localBookedOutResponse.json() : [];
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
// Filter for today's bookings
const today = new Date().toISOString().split('T')[0];
const bookedOutAircraft = [
...localBookedOut.filter(flight => {
const createdDate = flight.created_dt.split('T')[0];
return createdDate === today;
}).map(flight => ({
...flight,
isLocalFlight: true
})),
...depBookedOut.filter(flight => {
const createdDate = flight.created_dt.split('T')[0];
return createdDate === today;
}).map(flight => ({
...flight,
isDeparture: true
}))
];
displayParkedVisitors(bookedOutAircraft);
} catch (error) {
console.error('Error loading booked out aircraft:', error);
}
}
function displayParkedVisitors(bookedOutAircraft) {
const container = document.getElementById('parked-list');
const countEl = document.getElementById('parked-count');
countEl.textContent = bookedOutAircraft.length;
if (bookedOutAircraft.length === 0) {
container.innerHTML = '<div class="no-aircraft">No booked out aircraft</div>';
return;
}
container.innerHTML = bookedOutAircraft.map(flight => {
const reg = flight.registration || flight.ac_reg;
const type = flight.type || flight.ac_type;
const dest = flight.out_to;
const createdTime = flight.created_dt ? formatTimeOnly(flight.created_dt) : '';
// Determine the entity type and display details
let entityType, displayDetails, clickHandler, cssClass;
if (flight.isLocalFlight) {
entityType = 'local';
displayDetails = `${type} - ${flight.flight_type || 'Local Flight'}`;
clickHandler = `handleATCClick('${flight.id}', 'local')`;
cssClass = 'local-flight';
} else {
entityType = 'departure';
displayDetails = `${type}${dest || 'Other Airport'}`;
clickHandler = `handleATCClick('${flight.id}', 'departure')`;
cssClass = 'departure';
}
return `
<div class="aircraft-item ${cssClass}" onclick="${clickHandler}">
<div class="aircraft-info">
<div class="aircraft-reg">${reg}</div>
<div class="aircraft-details">${displayDetails}</div>
</div>
<button class="status-btn" onclick="event.stopPropagation(); ${flight.isLocalFlight ? `currentLocalFlightId = '${flight.id}'; showTimestampModal('GROUND', '${flight.id}', true, false)` : `currentDepartureId = ${flight.id}; showTimestampModal('GROUND', ${flight.id}, false, true)`}" title="Contact Pilot">CONTACT</button>
</div>
`;
}).join('');
}
// Handle ATC aircraft item click - open appropriate modal
function handleATCClick(entityId, entityType) {
switch(entityType) {
case 'ppr':
openPPRModal(entityId);
break;
case 'local':
openLocalFlightEditModal(entityId);
break;
case 'departure':
openDepartureEditModal(entityId);
break;
case 'arrival':
openArrivalEditModal(entityId);
break;
case 'overflight':
openOverflightEditModal(entityId);
break;
}
}
// Override authentication to also load ATC data
const originalInitializeAuth = initializeAuth;
initializeAuth = async function() {
await originalInitializeAuth();
if (accessToken) {
loadATCAircraft();
// Refresh every 30 seconds
setInterval(loadATCAircraft, 30000);
}
};
// Override loadPPRs to use ATC functions instead (fixes WebSocket and other event triggers)
const originalLoadPPRs = loadPPRs;
loadPPRs = async function() {
try {
await loadATCAircraft();
} catch (error) {
console.error('Error loading ATC aircraft:', error);
}
};
// Override loadDepartures to use ATC functions instead (fixes circuit recording refresh)
const originalLoadDepartures = loadDepartures;
loadDepartures = async function() {
try {
await loadATCAircraft();
} catch (error) {
console.error('Error loading ATC aircraft:', error);
}
};
</script>
<!-- Footer Bar -->
<div class="footer-bar">
Please contact James Pattinson if you have any ideas about or problems with this system
</div>
</body>
</html>