Files
ppr-ng/web/admin.html
James Pattinson e8bd30aadc Future PPRs
2025-12-10 15:44:26 +00:00

3023 lines
120 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>PPR Admin Interface</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f5f5;
color: #333;
}
.top-bar {
background: linear-gradient(135deg, #2c3e50, #3498db);
color: white;
padding: 0.5rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
position: relative;
z-index: 100;
}
.title h1 {
margin: 0;
font-size: 1.5rem;
}
.menu-buttons {
display: flex;
gap: 1rem;
align-items: center;
}
.top-bar .user-info {
font-size: 0.9rem;
opacity: 0.9;
display: flex;
align-items: center;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.btn {
padding: 0.7rem 1.5rem;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover {
background-color: #2980b9;
}
.btn-success {
background-color: #27ae60;
color: white;
}
.btn-success:hover {
background-color: #229954;
}
.btn-warning {
background-color: #f39c12;
color: white;
}
.btn-warning:hover {
background-color: #e67e22;
}
.btn-danger {
background-color: #e74c3c;
color: white;
}
.btn-danger:hover {
background-color: #c0392b;
}
.btn-icon {
padding: 0.3rem 0.6rem;
font-size: 0.8rem;
min-width: auto;
}
.btn-icon:hover {
transform: scale(1.05);
}
.filter-group {
display: flex;
gap: 0.5rem;
align-items: center;
}
.filter-group label {
font-weight: 500;
color: #555;
}
.filter-group select, .filter-group input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.ppr-table {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.table-header {
background: #34495e;
color: white;
padding: 1rem;
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;
color: #666;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid #eee;
font-size: 1.4rem;
}
th {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
position: sticky;
top: 0;
}
tbody tr {
cursor: pointer;
transition: background-color 0.2s ease;
}
tbody tr:hover {
background-color: #f8f9fa;
}
.status {
display: inline-block;
padding: 0.3rem 0.6rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.status.new { background: #e3f2fd; color: #1565c0; }
.status.confirmed { background: #e8f5e8; color: #2e7d32; }
.status.landed { background: #fff3e0; color: #ef6c00; }
.status.departed { background: #fce4ec; color: #c2185b; }
.status.canceled { background: #ffebee; color: #d32f2f; }
.status.deleted { background: #f3e5f5; color: #7b1fa2; }
.no-data {
text-align: center;
padding: 3rem;
color: #666;
}
.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;
}
.notes-tooltip {
position: relative;
}
.notes-tooltip .tooltip-text {
visibility: hidden;
width: 300px;
background-color: #333;
color: #fff;
text-align: left;
border-radius: 6px;
padding: 8px;
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 {
content: "";
position: absolute;
top: 50%;
left: -5px;
margin-top: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent #333 transparent transparent;
}
.notes-tooltip:hover .tooltip-text {
visibility: visible;
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;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 0;
border-radius: 8px;
width: 90%;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.modal-header {
background: #34495e;
color: white;
padding: 1rem 1.5rem;
border-radius: 8px 8px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
font-size: 1.3rem;
}
.close {
color: white;
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
border: none;
background: none;
}
.close:hover {
opacity: 0.7;
}
.modal-body {
padding: 1.5rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
font-weight: 600;
margin-bottom: 0.3rem;
color: #555;
}
.form-group input, .form-group select, .form-group textarea {
padding: 0.6rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
#login-form .form-group {
margin-bottom: 1rem;
}
#login-form .form-group input {
width: 100%;
}
#login-error {
background-color: #ffebee;
border: 1px solid #ffcdd2;
border-radius: 4px;
padding: 0.8rem;
font-size: 0.9rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.journal-section {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.journal-entries {
max-height: 200px;
overflow-y: auto;
border: 1px solid #eee;
border-radius: 4px;
padding: 1rem;
background-color: #f9f9f9;
}
.journal-entry {
margin-bottom: 0.8rem;
padding-bottom: 0.8rem;
border-bottom: 1px solid #ddd;
}
.journal-entry:last-child {
border-bottom: none;
margin-bottom: 0;
}
.journal-meta {
font-size: 0.8rem;
color: #666;
margin-bottom: 0.3rem;
}
.journal-text {
font-size: 0.9rem;
color: #333;
}
.quick-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
/* Aircraft Lookup Styles */
#aircraft-lookup-results {
margin-top: 0.5rem;
padding: 0.5rem;
background-color: #f8f9fa;
border-radius: 4px;
font-size: 0.9rem;
min-height: 20px;
border: 1px solid #e9ecef;
}
.aircraft-match {
padding: 0.3rem;
background-color: #e8f5e8;
border: 1px solid #c3e6c3;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-weight: bold;
}
.aircraft-no-match {
color: #6c757d;
font-style: italic;
}
.aircraft-searching {
color: #007bff;
}
/* Airport Lookup Styles */
#arrival-airport-lookup-results, #departure-airport-lookup-results {
margin-top: 0.5rem;
padding: 0.5rem;
background-color: #f8f9fa;
border-radius: 4px;
font-size: 0.9rem;
min-height: 20px;
border: 1px solid #e9ecef;
}
.airport-match {
padding: 0.3rem;
background-color: #e8f5e8;
border: 1px solid #c3e6c3;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-weight: bold;
}
.airport-no-match {
color: #6c757d;
font-style: italic;
}
.airport-searching {
color: #007bff;
}
.airport-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: white;
}
.airport-option {
padding: 0.5rem;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.2s ease;
display: flex;
justify-content: space-between;
align-items: center;
}
.airport-option:hover {
background-color: #f8f9fa;
}
.airport-option:last-child {
border-bottom: none;
}
.airport-code {
font-family: 'Courier New', monospace;
font-weight: bold;
color: #495057;
}
.airport-name {
color: #6c757d;
font-size: 0.85rem;
}
.airport-location {
color: #868e96;
font-size: 0.8rem;
font-style: italic;
}
.notification {
position: fixed;
top: 20px;
right: 20px;
background-color: #27ae60;
color: white;
padding: 12px 20px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 10000;
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s ease;
font-weight: 500;
pointer-events: none;
}
.notification.show {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.notification.error {
background-color: #e74c3c;
}
</style>
</head>
<body>
<div class="top-bar">
<div class="title">
<h1>✈️ Swansea PPR</h1>
</div>
<div class="menu-buttons">
<button class="btn btn-success" onclick="openNewPPRModal()">
New PPR
</button>
<button class="btn btn-primary" onclick="window.open('reports.html', '_blank')">
📊 Reports
</button>
<button class="btn btn-warning" onclick="openUserManagementModal()" id="user-management-btn" style="display: none;">
👥 User Management
</button>
<button class="btn btn-primary" onclick="loadPPRs()">
🔄 Refresh
</button>
</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="container">
<!-- Arrivals Table -->
<div class="ppr-table">
<div class="table-header">
🛬 Today's Pending Arrivals - <span id="arrivals-count">0</span>
</div>
<div id="arrivals-loading" class="loading">
<div class="spinner"></div>
Loading arrivals...
</div>
<div id="arrivals-table-content" style="display: none;">
<table>
<thead>
<tr>
<th>Registration</th>
<th>Type</th>
<th>From</th>
<th>ETA</th>
<th>POB</th>
<th>Fuel</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="arrivals-table-body">
</tbody>
</table>
</div>
<div id="arrivals-no-data" class="no-data" style="display: none;">
<h3>No Pending Arrivals</h3>
</div>
</div>
<!-- Departures Table -->
<div class="ppr-table" style="margin-top: 2rem;">
<div class="table-header">
🛫 Today's Pending Departures - <span id="departures-count">0</span>
</div>
<div id="departures-loading" class="loading">
<div class="spinner"></div>
Loading departures...
</div>
<div id="departures-table-content" style="display: none;">
<table>
<thead>
<tr>
<th>Registration</th>
<th>Type</th>
<th>To</th>
<th>ETD</th>
<th>POB</th>
<th>Fuel</th>
<th>Landed</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="departures-table-body">
</tbody>
</table>
</div>
<div id="departures-no-data" class="no-data" style="display: none;">
<h3>No Pending Departures</h3>
<p>No aircraft currently landed and ready to depart.</p>
</div>
</div>
<br>
<!-- Departed and Parked Tables -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem;">
<!-- Departed Today -->
<div class="ppr-table">
<div class="table-header" style="padding: 0.3rem 0.5rem; font-size: 0.85rem;">
✈️ Departed Today - <span id="departed-count">0</span>
</div>
<div id="departed-loading" class="loading" style="display: none;">
<div class="spinner"></div>
Loading departed aircraft...
</div>
<div id="departed-table-content" style="display: none;">
<table>
<thead>
<tr style="font-size: 0.85rem !important;">
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Registration</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Callsign</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Destination</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Departed</th>
</tr>
</thead>
<tbody id="departed-table-body">
</tbody>
</table>
</div>
<div id="departed-no-data" class="no-data" style="display: none; padding: 1rem; font-size: 0.9rem;">
<p>No departures today.</p>
</div>
</div>
<!-- Parked Visitors -->
<div class="ppr-table">
<div class="table-header" style="padding: 0.3rem 0.5rem; font-size: 0.85rem;">
🅿️ Parked Visitors - <span id="parked-count">0</span>
</div>
<div id="parked-loading" class="loading" style="display: none;">
<div class="spinner"></div>
Loading parked visitors...
</div>
<div id="parked-table-content" style="display: none;">
<table>
<thead>
<tr style="font-size: 0.85rem !important;">
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Registration</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Type</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">From</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Arrived</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">ETD</th>
</tr>
</thead>
<tbody id="parked-table-body">
</tbody>
</table>
</div>
<div id="parked-no-data" class="no-data" style="display: none; padding: 1rem; font-size: 0.9rem;">
<p>No parked visitors.</p>
</div>
</div>
<!-- Upcoming PPRs (Future Days) -->
<div class="ppr-table" style="margin-top: 1rem;">
<div class="table-header-collapsible" style="padding: 0.3rem 0.5rem; font-size: 0.85rem;" onclick="toggleUpcomingTable()">
<span>📅 Future PPRs - <span id="upcoming-count">0</span></span>
<span class="collapse-icon collapsed" id="upcoming-collapse-icon"></span>
</div>
<div id="upcoming-loading" class="loading" style="display: none;">
<div class="spinner"></div>
Loading upcoming PPRs...
</div>
<div id="upcoming-table-content" style="display: none;">
<table>
<thead>
<tr style="font-size: 0.85rem !important;">
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Date</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Registration</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Type</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">From</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">ETA</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Notes</th>
</tr>
</thead>
<tbody id="upcoming-table-body">
</tbody>
</table>
</div>
<div id="upcoming-no-data" class="no-data" style="display: none; padding: 1rem; font-size: 0.9rem;">
<p>No upcoming PPRs.</p>
</div>
</div>
</div>
</div>
<!-- Login Modal -->
<div id="loginModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2>PPR Admin Login</h2>
</div>
<div class="modal-body">
<form id="login-form">
<div class="form-group">
<label for="login-username">Username:</label>
<input type="text" id="login-username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="login-password">Password:</label>
<input type="password" id="login-password" name="password" required>
</div>
<div id="login-error" style="color: #dc3545; margin: 1rem 0; display: none;"></div>
<div class="form-actions" style="border-top: none; padding-top: 0;">
<button type="submit" class="btn btn-success" id="login-btn">
🔐 Login
</button>
</div>
</form>
</div>
</div>
</div>
<!-- PPR Detail/Edit Modal -->
<div id="pprModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-title">PPR Details</h2>
<button class="close" onclick="closePPRModal()">&times;</button>
</div>
<div class="modal-body">
<div class="quick-actions">
<button id="btn-confirm" class="btn btn-success btn-sm" onclick="updateStatus('CONFIRMED')">
✓ Confirm
</button>
<button id="btn-landed" class="btn btn-warning btn-sm" onclick="showTimestampModal('LANDED')">
🛬 Land
</button>
<button id="btn-departed" class="btn btn-primary btn-sm" onclick="showTimestampModal('DEPARTED')">
🛫 Depart
</button>
<button id="btn-cancel" class="btn btn-danger btn-sm" onclick="updateStatus('CANCELED')">
❌ Cancel
</button>
</div>
<form id="ppr-form">
<input type="hidden" id="ppr-id" name="id">
<div class="form-grid">
<div class="form-group">
<label for="ac_reg">Aircraft Registration *</label>
<input type="text" id="ac_reg" name="ac_reg" required oninput="handleAircraftLookup(this.value)">
<div id="aircraft-lookup-results"></div>
</div>
<div class="form-group">
<label for="ac_type">Aircraft Type *</label>
<input type="text" id="ac_type" name="ac_type" required tabindex="-1">
</div>
<div class="form-group">
<label for="ac_call">Callsign</label>
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration" tabindex="-1">
</div>
<div class="form-group">
<label for="captain">Captain *</label>
<input type="text" id="captain" name="captain" required>
</div>
<div class="form-group">
<label for="in_from">Arriving From *</label>
<input type="text" id="in_from" name="in_from" required placeholder="ICAO Code or Airport Name" oninput="handleArrivalAirportLookup(this.value)">
<div id="arrival-airport-lookup-results"></div>
</div>
<div class="form-group">
<label for="eta">ETA (Local Time) *</label>
<div style="display: flex; gap: 0.5rem;">
<input type="date" id="eta-date" name="eta-date" required style="flex: 1;">
<select id="eta-time" name="eta-time" required style="flex: 1;">
<option value="">Select Time</option>
</select>
</div>
</div>
<div class="form-group">
<label for="pob_in">POB Inbound *</label>
<input type="number" id="pob_in" name="pob_in" required min="1">
</div>
<div class="form-group">
<label for="fuel">Fuel Required</label>
<select id="fuel" name="fuel" tabindex="-1">
<option value="">None</option>
<option value="100LL">100LL</option>
<option value="JET A1">JET A1</option>
<option value="FULL">Full Tanks</option>
</select>
</div>
<div class="form-group">
<label for="out_to">Departing To</label>
<input type="text" id="out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleDepartureAirportLookup(this.value)" tabindex="-1">
<div id="departure-airport-lookup-results"></div>
</div>
<div class="form-group">
<label for="etd">ETD (Local Time)</label>
<div style="display: flex; gap: 0.5rem;">
<input type="date" id="etd-date" name="etd-date" tabindex="-1" style="flex: 1;">
<select id="etd-time" name="etd-time" tabindex="-1" style="flex: 1;">
<option value="">Select Time</option>
</select>
</div>
</div>
<div class="form-group">
<label for="pob_out">POB Outbound</label>
<input type="number" id="pob_out" name="pob_out" min="1" tabindex="-1">
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" tabindex="-1">
</div>
<div class="form-group">
<label for="phone">Phone</label>
<input type="tel" id="phone" name="phone" tabindex="-1">
</div>
<div class="form-group full-width">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" rows="3" tabindex="-1"></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-danger" id="delete-btn" onclick="deletePPR()" style="display: none;">
🗑️ Delete
</button>
<button type="button" class="btn btn-primary" onclick="closePPRModal()">
Cancel
</button>
<button type="submit" class="btn btn-success">
💾 Save Changes
</button>
</div>
</form>
<div class="journal-section" id="journal-section">
<h3>Activity Journal</h3>
<div id="journal-entries" class="journal-entries">
Loading journal...
</div>
</div>
</div>
</div>
</div>
<!-- User Management Modal -->
<div id="userManagementModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>User Management</h2>
<button class="close" onclick="closeUserManagementModal()">&times;</button>
</div>
<div class="modal-body">
<div class="quick-actions" style="margin-bottom: 1rem;">
<button class="btn btn-success" onclick="openUserCreateModal()">
Create New User
</button>
</div>
<div id="users-loading" class="loading">
<div class="spinner"></div>
Loading users...
</div>
<div id="users-table-content" style="display: none;">
<table>
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="users-table-body">
</tbody>
</table>
</div>
<div id="users-no-data" class="no-data" style="display: none;">
<h3>No users found</h3>
<p>No users are configured in the system.</p>
</div>
</div>
</div>
</div>
<!-- User Create/Edit Modal -->
<div id="userModal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2 id="user-modal-title">Create User</h2>
<button class="close" onclick="closeUserModal()">&times;</button>
</div>
<div class="modal-body">
<form id="user-form">
<input type="hidden" id="user-id" name="id">
<div class="form-grid">
<div class="form-group full-width">
<label for="user-username">Username *</label>
<input type="text" id="user-username" name="username" required>
</div>
<div class="form-group full-width">
<label for="user-password">Password *</label>
<input type="password" id="user-password" name="password" required>
<small style="color: #666; font-size: 0.8rem;">Leave blank when editing to keep current password</small>
</div>
<div class="form-group full-width">
<label for="user-role">Role *</label>
<select id="user-role" name="role" required>
<option value="READ_ONLY">Read Only - View only access</option>
<option value="OPERATOR">Operator - PPR management access</option>
<option value="ADMINISTRATOR">Administrator - Full access</option>
</select>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="closeUserModal()">
Cancel
</button>
<button type="submit" class="btn btn-success">
💾 Save User
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Success Notification -->
<div id="notification" class="notification"></div>
<!-- Timestamp Modal for Landing/Departure -->
<div id="timestampModal" class="modal">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2 id="timestamp-modal-title">Confirm Landing Time</h2>
<button class="close" onclick="closeTimestampModal()">&times;</button>
</div>
<div class="modal-body">
<form id="timestamp-form">
<div class="form-group">
<label for="event-timestamp">Event Time (Local Time) *</label>
<input type="datetime-local" id="event-timestamp" name="timestamp" required>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="closeTimestampModal()">
Cancel
</button>
<button type="submit" class="btn btn-success" id="timestamp-submit-btn">
Confirm
</button>
</div>
</form>
</div>
</div>
</div>
<script>
let currentUser = null;
let accessToken = null;
let currentPPRId = null;
let isNewPPR = false;
let wsConnection = null;
let pendingStatusUpdate = null; // Track pending status update for timestamp modal
let wsHeartbeatInterval = null;
let wsReconnectTimeout = null;
let lastHeartbeatResponse = null;
let sessionExpiryWarningShown = false;
let sessionExpiryCheckInterval = null;
let etdManuallyEdited = false; // Track if user has manually edited ETD
// WebSocket connection for real-time updates
function connectWebSocket() {
if (wsConnection && wsConnection.readyState === WebSocket.OPEN) {
return; // Already connected
}
// Clear any existing reconnect timeout
if (wsReconnectTimeout) {
clearTimeout(wsReconnectTimeout);
wsReconnectTimeout = null;
}
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');
};
wsConnection.onmessage = function(event) {
try {
// 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');
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
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);
}
};
wsConnection.onerror = function(error) {
console.error('❌ WebSocket error:', error);
};
}
function startHeartbeat() {
// Clear any existing heartbeat
stopHeartbeat();
// Send ping every 30 seconds
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)) {
console.warn('⚠️ No heartbeat response for 60 seconds, reconnecting...');
wsConnection.close();
}
} else {
console.warn('⚠️ WebSocket not open, stopping heartbeat');
stopHeartbeat();
}
}, 30000);
}
function stopHeartbeat() {
if (wsHeartbeatInterval) {
clearInterval(wsHeartbeatInterval);
wsHeartbeatInterval = null;
}
}
function disconnectWebSocket() {
stopHeartbeat();
if (wsReconnectTimeout) {
clearTimeout(wsReconnectTimeout);
wsReconnectTimeout = null;
}
if (wsConnection) {
wsConnection.close();
wsConnection = null;
}
}
// Notification system
function showNotification(message, isError = false) {
const notification = document.getElementById('notification');
notification.textContent = message;
notification.className = 'notification' + (isError ? ' error' : '');
// Show notification
setTimeout(() => {
notification.classList.add('show');
}, 10);
// Hide after 3 seconds
setTimeout(() => {
notification.classList.remove('show');
}, 3000);
}
// Initialize time dropdowns
function initializeTimeDropdowns() {
const timeSelects = ['eta-time', 'etd-time'];
timeSelects.forEach(selectId => {
const select = document.getElementById(selectId);
// Clear existing options except the first one
select.innerHTML = '<option value="">Select Time</option>';
// Add time options in 15-minute intervals
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
const timeString = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
const option = document.createElement('option');
option.value = timeString;
option.textContent = timeString;
select.appendChild(option);
}
}
});
}
// Session expiry monitoring
function startSessionExpiryCheck() {
// Clear any existing interval
if (sessionExpiryCheckInterval) {
clearInterval(sessionExpiryCheckInterval);
}
sessionExpiryWarningShown = false;
// Check every 60 seconds
sessionExpiryCheckInterval = setInterval(() => {
const tokenExpiry = localStorage.getItem('ppr_token_expiry');
if (!tokenExpiry || !accessToken) {
stopSessionExpiryCheck();
return;
}
const now = Date.now();
const expiryTime = parseInt(tokenExpiry);
const timeUntilExpiry = expiryTime - now;
// Warn if less than 5 minutes remaining
if (timeUntilExpiry < 5 * 60 * 1000 && !sessionExpiryWarningShown) {
sessionExpiryWarningShown = true;
const minutesLeft = Math.ceil(timeUntilExpiry / 60000);
showNotification(`⚠️ Session expires in ${minutesLeft} minute${minutesLeft !== 1 ? 's' : ''}. Please save your work.`, true);
console.warn(`Session expires in ${minutesLeft} minutes`);
}
// Force logout if expired
if (timeUntilExpiry <= 0) {
console.error('Session has expired');
showNotification('Session expired. Please log in again.', true);
logout();
}
}, 60000); // Check every minute
}
function stopSessionExpiryCheck() {
if (sessionExpiryCheckInterval) {
clearInterval(sessionExpiryCheckInterval);
sessionExpiryCheckInterval = null;
}
sessionExpiryWarningShown = false;
}
// Authentication management
async function initializeAuth() {
// Try to get cached token
const cachedToken = localStorage.getItem('ppr_access_token');
const cachedUser = localStorage.getItem('ppr_username');
const tokenExpiry = localStorage.getItem('ppr_token_expiry');
if (cachedToken && cachedUser && tokenExpiry) {
const now = new Date().getTime();
if (now < parseInt(tokenExpiry)) {
// Token is still valid
accessToken = cachedToken;
currentUser = cachedUser;
document.getElementById('current-user').textContent = cachedUser;
await updateUserRole(); // Update role-based UI
startSessionExpiryCheck(); // Start monitoring session expiry
connectWebSocket(); // Connect WebSocket for real-time updates
loadPPRs();
return;
}
}
// No valid cached token, show login
showLogin();
}
function setupLoginForm() {
document.getElementById('login-form').addEventListener('submit', async function(e) {
e.preventDefault();
await handleLogin();
});
}
function setupKeyboardShortcuts() {
document.addEventListener('keydown', function(e) {
// Press 'Escape' to close PPR modal if it's open (allow even when typing in inputs)
if (e.key === 'Escape' && document.getElementById('pprModal').style.display === 'block') {
e.preventDefault();
closePPRModal();
return;
}
// Press 'Escape' to close timestamp modal if it's open (allow even when typing in inputs)
if (e.key === 'Escape' && document.getElementById('timestampModal').style.display === 'block') {
e.preventDefault();
closeTimestampModal();
return;
}
// Only trigger other shortcuts when not typing in input fields
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
return;
}
// Press 'n' to open new PPR modal
if (e.key === 'n' || e.key === 'N') {
e.preventDefault();
openNewPPRModal();
}
});
}
function showLogin() {
document.getElementById('loginModal').style.display = 'block';
document.getElementById('login-username').focus();
}
function hideLogin() {
document.getElementById('loginModal').style.display = 'none';
document.getElementById('login-error').style.display = 'none';
document.getElementById('login-form').reset();
}
async function handleLogin() {
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
const loginBtn = document.getElementById('login-btn');
const errorDiv = document.getElementById('login-error');
// Show loading state
loginBtn.disabled = true;
loginBtn.textContent = '🔄 Logging in...';
errorDiv.style.display = 'none';
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`
});
const data = await response.json();
if (response.ok && data.access_token) {
// Store token and user info with expiry from server response
const expiresInMs = (data.expires_in || 1800) * 1000; // Use server value or default to 30 min
const expiryTime = new Date().getTime() + expiresInMs;
localStorage.setItem('ppr_access_token', data.access_token);
localStorage.setItem('ppr_username', username);
localStorage.setItem('ppr_token_expiry', expiryTime.toString());
accessToken = data.access_token;
currentUser = username;
document.getElementById('current-user').textContent = username;
hideLogin();
await updateUserRole(); // Update role-based UI
startSessionExpiryCheck(); // Start monitoring session expiry
connectWebSocket(); // Connect WebSocket for real-time updates
loadPPRs();
} else {
throw new Error(data.detail || 'Authentication failed');
}
} catch (error) {
console.error('Login error:', error);
errorDiv.textContent = error.message || 'Login failed. Please check your credentials.';
errorDiv.style.display = 'block';
} finally {
// Reset button state
loginBtn.disabled = false;
loginBtn.textContent = '🔐 Login';
}
}
function logout() {
// Clear stored token and user info
localStorage.removeItem('ppr_access_token');
localStorage.removeItem('ppr_username');
localStorage.removeItem('ppr_token_expiry');
accessToken = null;
currentUser = null;
stopSessionExpiryCheck(); // Stop monitoring session
disconnectWebSocket(); // Disconnect WebSocket
// Close any open modals
closePPRModal();
closeTimestampModal();
// Show login again
showLogin();
}
// Legacy authenticate function - now redirects to new login
function authenticate() {
showLogin();
}
// Enhanced fetch wrapper with token expiry handling
async function authenticatedFetch(url, options = {}) {
if (!accessToken) {
showLogin();
throw new Error('No access token available');
}
// Add authorization header
const headers = {
...options.headers,
'Authorization': `Bearer ${accessToken}`
};
const response = await fetch(url, {
...options,
headers
});
// Handle 401 - token expired
if (response.status === 401) {
logout();
throw new Error('Session expired. Please log in again.');
}
return response;
}
// Load PPR records - now loads all tables
async function loadPPRs() {
if (!accessToken) return;
// Load all tables simultaneously
await Promise.all([loadArrivals(), loadDepartures(), loadDeparted(), loadParked(), loadUpcoming()]);
}
// Load arrivals (NEW and CONFIRMED status)
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 all PPRs and filter client-side for today's arrivals
// We filter by ETA date (not ETD) and NEW/CONFIRMED status
const response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
if (!response.ok) {
throw new Error('Failed to fetch arrivals');
}
const allPPRs = await response.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;
});
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)
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 all PPRs and filter client-side for today's departures
// We filter by ETD date and LANDED status only (exclude DEPARTED)
const response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
if (!response.ok) {
throw new Error('Failed to fetch departures');
}
const allPPRs = await response.json();
const today = new Date().toISOString().split('T')[0];
// Filter for 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;
});
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 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 response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
if (!response.ok) {
throw new Error('Failed to fetch departed aircraft');
}
const allPPRs = await response.json();
const today = new Date().toISOString().split('T')[0];
// Filter for aircraft departed today
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;
});
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) => new Date(a.departed_dt) - new Date(b.departed_dt));
tbody.innerHTML = '';
document.getElementById('departed-table-content').style.display = 'block';
for (const ppr of departed) {
const row = document.createElement('tr');
row.onclick = () => openPPRModal(ppr.id);
row.style.cssText = 'font-size: 0.85rem !important; font-style: italic;';
row.innerHTML = `
<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;">${ppr.ac_call || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.out_to || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(ppr.departed_dt)}</td>
`;
tbody.appendChild(row);
}
}
// Load parked visitors (LANDED status with no ETD today or ETD not today)
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 response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
if (!response.ok) {
throw new Error('Failed to fetch parked visitors');
}
const allPPRs = await response.json();
const today = new Date().toISOString().split('T')[0];
// Filter for parked visitors: LANDED status and (no ETD or ETD not today)
// Show all parked aircraft regardless of when they arrived
const parked = allPPRs.filter(ppr => {
if (ppr.status !== 'LANDED') {
return false;
}
// No ETD means parked
if (!ppr.etd) {
return true;
}
// ETD exists but is not today
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');
row.onclick = () => openPPRModal(ppr.id);
row.style.cssText = 'font-size: 0.85rem !important; font-style: italic;';
// 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;">${ppr.ac_reg || '-'}</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;">${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;">${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 (ascending)
arrivals.sort((a, b) => {
if (!a.eta) return 1;
if (!b.eta) return -1;
return new Date(a.eta) - new Date(b.eta);
});
tbody.innerHTML = '';
document.getElementById('arrivals-table-content').style.display = 'block';
for (const ppr of arrivals) {
const row = document.createElement('tr');
row.onclick = () => openPPRModal(ppr.id);
// 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>` : '';
// Display callsign as main item if present, registration below; otherwise show registration
const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ?
`<strong>${ppr.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${ppr.ac_reg}</span>` :
`<strong>${ppr.ac_reg}</strong>`;
// Lookup airport name for in_from
let fromDisplay = ppr.in_from;
if (ppr.in_from && ppr.in_from.length === 4 && /^[A-Z]{4}$/.test(ppr.in_from)) {
fromDisplay = await getAirportDisplay(ppr.in_from);
}
row.innerHTML = `
<td>${aircraftDisplay}${notesIndicator}</td>
<td>${ppr.ac_type}</td>
<td>${fromDisplay}</td>
<td>${formatTimeOnly(ppr.eta)}</td>
<td>${ppr.pob_in}</td>
<td>${ppr.fuel || '-'}</td>
<td>
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); showTimestampModal('LANDED', ${ppr.id})" title="Mark as Landed">
LAND
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateStatusFromTable(${ppr.id}, 'CANCELED')" title="Cancel Arrival">
CANCEL
</button>
</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) => {
if (!a.etd) return 1;
if (!b.etd) return -1;
return new Date(a.etd) - new Date(b.etd);
});
tbody.innerHTML = '';
document.getElementById('departures-table-content').style.display = 'block';
for (const ppr of departures) {
const row = document.createElement('tr');
row.onclick = () => openPPRModal(ppr.id);
// 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>` : '';
// Display callsign as main item if present, registration below; otherwise show registration
const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ?
`<strong>${ppr.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${ppr.ac_reg}</span>` :
`<strong>${ppr.ac_reg}</strong>`;
// Lookup airport name for out_to
let toDisplay = ppr.out_to || '-';
if (ppr.out_to && ppr.out_to.length === 4 && /^[A-Z]{4}$/.test(ppr.out_to)) {
toDisplay = await getAirportDisplay(ppr.out_to);
}
row.innerHTML = `
<td>${aircraftDisplay}${notesIndicator}</td>
<td>${ppr.ac_type}</td>
<td>${toDisplay}</td>
<td>${ppr.etd ? formatTimeOnly(ppr.etd) : '-'}</td>
<td>${ppr.pob_out || ppr.pob_in}</td>
<td>${ppr.fuel || '-'}</td>
<td>${ppr.landed_dt ? formatTimeOnly(ppr.landed_dt) : '-'}</td>
<td>
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${ppr.id})" title="Mark as Departed">
TAKE OFF
</button>
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateStatusFromTable(${ppr.id}, 'CANCELED')" title="Cancel Departure">
CANCEL
</button>
</td>
`;
tbody.appendChild(row);
}
}
function formatTimeOnly(dateStr) {
if (!dateStr) return '-';
// Ensure the datetime string is treated as UTC
let utcDateStr = dateStr;
if (!utcDateStr.includes('T')) {
utcDateStr = utcDateStr.replace(' ', 'T');
}
if (!utcDateStr.includes('Z')) {
utcDateStr += 'Z';
}
const date = new Date(utcDateStr);
return date.toISOString().slice(11, 16);
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
// Ensure the datetime string is treated as UTC
let utcDateStr = dateStr;
if (!utcDateStr.includes('T')) {
utcDateStr = utcDateStr.replace(' ', 'T');
}
if (!utcDateStr.includes('Z')) {
utcDateStr += 'Z';
}
const date = new Date(utcDateStr);
return date.toISOString().slice(0, 10) + ' ' + date.toISOString().slice(11, 16);
}
// Modal functions
function openNewPPRModal() {
isNewPPR = true;
currentPPRId = null;
etdManuallyEdited = false; // Reset the manual edit flag for new PPR
document.getElementById('modal-title').textContent = 'New PPR';
document.getElementById('delete-btn').style.display = 'none';
document.getElementById('journal-section').style.display = 'none';
document.querySelector('.quick-actions').style.display = 'none';
// Clear form
document.getElementById('ppr-form').reset();
document.getElementById('ppr-id').value = '';
// Set default ETA and ETD
const now = new Date();
const eta = new Date(now.getTime() + 60 * 60 * 1000); // +1 hour
const etd = new Date(now.getTime() + 2 * 60 * 60 * 1000); // +2 hours
// Format date and time for separate inputs
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatTime(date) {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(Math.ceil(date.getMinutes() / 15) * 15 % 60).padStart(2, '0'); // Round up to next 15-minute interval
return `${hours}:${minutes}`;
}
document.getElementById('eta-date').value = formatDate(eta);
document.getElementById('eta-time').value = formatTime(eta);
document.getElementById('etd-date').value = formatDate(etd);
document.getElementById('etd-time').value = formatTime(etd);
// Clear aircraft lookup results
clearAircraftLookup();
clearArrivalAirportLookup();
clearDepartureAirportLookup();
document.getElementById('pprModal').style.display = 'block';
// Auto-focus on aircraft registration field
setTimeout(() => {
document.getElementById('ac_reg').focus();
}, 100);
}
// Function to update ETD based on ETA (2 hours later)
function updateETDFromETA() {
// Only auto-update if user hasn't manually edited ETD
if (etdManuallyEdited) {
return;
}
const etaDate = document.getElementById('eta-date').value;
const etaTime = document.getElementById('eta-time').value;
if (etaDate && etaTime) {
// Parse ETA
const eta = new Date(`${etaDate}T${etaTime}`);
// Calculate ETD (2 hours after ETA)
const etd = new Date(eta.getTime() + 2 * 60 * 60 * 1000);
// Format ETD
const etdDateStr = `${etd.getFullYear()}-${String(etd.getMonth() + 1).padStart(2, '0')}-${String(etd.getDate()).padStart(2, '0')}`;
const etdTimeStr = `${String(etd.getHours()).padStart(2, '0')}:${String(etd.getMinutes()).padStart(2, '0')}`;
// Update ETD fields
document.getElementById('etd-date').value = etdDateStr;
document.getElementById('etd-time').value = etdTimeStr;
}
}
// Function to mark ETD as manually edited
function markETDAsManuallyEdited() {
etdManuallyEdited = true;
}
async function openPPRModal(pprId) {
if (!accessToken) return;
isNewPPR = false;
currentPPRId = pprId;
document.getElementById('modal-title').textContent = 'Edit PPR Entry';
document.getElementById('delete-btn').style.display = 'inline-block';
document.querySelector('.quick-actions').style.display = 'flex';
try {
const response = await authenticatedFetch(`/api/v1/pprs/${pprId}`);
if (!response.ok) {
throw new Error('Failed to fetch PPR details');
}
const ppr = await response.json();
populateForm(ppr);
// Show/hide quick action buttons based on current status
if (ppr.status === 'NEW') {
document.getElementById('btn-confirm').style.display = 'inline-block';
document.getElementById('btn-landed').style.display = 'none';
document.getElementById('btn-departed').style.display = 'none';
document.getElementById('btn-cancel').style.display = 'inline-block';
} else if (ppr.status === 'CONFIRMED') {
document.getElementById('btn-confirm').style.display = 'none';
document.getElementById('btn-landed').style.display = 'inline-block';
document.getElementById('btn-departed').style.display = 'none';
document.getElementById('btn-cancel').style.display = 'inline-block';
} else if (ppr.status === 'LANDED') {
document.getElementById('btn-confirm').style.display = 'none';
document.getElementById('btn-landed').style.display = 'none';
document.getElementById('btn-departed').style.display = 'inline-block';
document.getElementById('btn-cancel').style.display = 'inline-block';
} else {
// DEPARTED, CANCELED, DELETED - hide all quick actions
document.querySelector('.quick-actions').style.display = 'none';
}
await loadJournal(pprId); // Always load journal when opening a PPR
document.getElementById('pprModal').style.display = 'block';
} catch (error) {
console.error('Error loading PPR details:', error);
showNotification('Error loading PPR details', true);
}
}
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')) {
utcDateStr = utcDateStr.replace(' ', 'T');
}
if (!utcDateStr.includes('Z')) {
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`);
const timeField = document.getElementById(`${key}-time`);
if (dateField && timeField) {
// Format date
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
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');
const rawMinutes = date.getMinutes();
const roundedMinutes = Math.round(rawMinutes / 15) * 15 % 60;
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}`);
}
}
});
}
async function loadJournal(pprId) {
try {
const response = await fetch(`/api/v1/pprs/${pprId}/journal`, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch journal');
}
const entries = await response.json();
displayJournal(entries);
} catch (error) {
console.error('Error loading journal:', error);
document.getElementById('journal-entries').innerHTML = 'Error loading journal entries';
}
}
function displayJournal(entries) {
const container = document.getElementById('journal-entries');
if (entries.length === 0) {
container.innerHTML = '<p>No journal entries yet.</p>';
} else {
container.innerHTML = entries.map(entry => `
<div class="journal-entry">
<div class="journal-meta">
${formatDateTime(entry.entry_dt)} by ${entry.user}
</div>
<div class="journal-text">${entry.entry}</div>
</div>
`).join('');
}
// Always show journal section when displaying entries
document.getElementById('journal-section').style.display = 'block';
}
function closePPRModal() {
document.getElementById('pprModal').style.display = 'none';
currentPPRId = null;
isNewPPR = false;
}
// Timestamp modal functions
function showTimestampModal(status, pprId = null) {
const targetPprId = pprId || currentPPRId;
if (!targetPprId) return;
pendingStatusUpdate = { status: status, pprId: targetPprId };
const modalTitle = document.getElementById('timestamp-modal-title');
const submitBtn = document.getElementById('timestamp-submit-btn');
if (status === 'LANDED') {
modalTitle.textContent = 'Confirm Landing Time';
submitBtn.textContent = '🛬 Confirm Landing';
} else if (status === 'DEPARTED') {
modalTitle.textContent = 'Confirm Departure Time';
submitBtn.textContent = '🛫 Confirm Departure';
}
// Set default timestamp to current time
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
document.getElementById('event-timestamp').value = `${year}-${month}-${day}T${hours}:${minutes}`;
document.getElementById('timestampModal').style.display = 'block';
}
function closeTimestampModal() {
document.getElementById('timestampModal').style.display = 'none';
pendingStatusUpdate = null;
document.getElementById('timestamp-form').reset();
}
// Timestamp form submission
document.getElementById('timestamp-form').addEventListener('submit', async function(e) {
e.preventDefault();
if (!pendingStatusUpdate || !accessToken) return;
const timestampInput = document.getElementById('event-timestamp').value;
let timestamp = null;
if (timestampInput.trim()) {
// Convert local datetime-local to UTC ISO string
timestamp = new Date(timestampInput).toISOString();
}
try {
const response = await fetch(`/api/v1/pprs/${pendingStatusUpdate.pprId}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({
status: pendingStatusUpdate.status,
timestamp: timestamp
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Failed to update status: ${response.status} ${response.statusText} - ${errorData.detail || 'Unknown error'}`);
}
const updatedStatus = pendingStatusUpdate.status;
closeTimestampModal();
loadPPRs(); // Refresh both tables
showNotification(`Status updated to ${updatedStatus}`);
closePPRModal(); // Close PPR modal after successful status update
} catch (error) {
console.error('Error updating status:', error);
showNotification(`Error updating status: ${error.message}`, true);
}
});
// Form submission
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
e.preventDefault();
if (!accessToken) return;
const formData = new FormData(this);
const pprData = {};
formData.forEach((value, key) => {
if (key !== 'id' && value.trim() !== '') {
if (key === 'pob_in' || key === 'pob_out') {
pprData[key] = parseInt(value);
} else if (key === 'eta-date' && formData.get('eta-time')) {
// Combine date and time for ETA
const dateStr = formData.get('eta-date');
const timeStr = formData.get('eta-time');
pprData.eta = new Date(`${dateStr}T${timeStr}`).toISOString();
} else if (key === 'etd-date' && formData.get('etd-time')) {
// Combine date and time for ETD
const dateStr = formData.get('etd-date');
const timeStr = formData.get('etd-time');
pprData.etd = new Date(`${dateStr}T${timeStr}`).toISOString();
} else if (key !== 'eta-time' && key !== 'etd-time') {
// Skip the time fields as they're handled above
pprData[key] = value;
}
}
});
try {
let response;
if (isNewPPR) {
response = await fetch('/api/v1/pprs/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify(pprData)
});
} else {
response = await fetch(`/api/v1/pprs/${currentPPRId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify(pprData)
});
}
if (!response.ok) {
throw new Error('Failed to save PPR');
}
const wasNewPPR = isNewPPR;
closePPRModal();
loadPPRs(); // Refresh both tables
showNotification(wasNewPPR ? 'PPR created successfully!' : 'PPR updated successfully!');
} catch (error) {
console.error('Error saving PPR:', error);
showNotification('Error saving PPR', true);
}
});
// Status update functions
async function updateStatus(status) {
if (!currentPPRId || !accessToken) return;
// Show confirmation for cancel actions
if (status === 'CANCELED') {
if (!confirm('Are you sure you want to cancel this PPR? This action cannot be easily undone.')) {
return;
}
}
try {
const response = await fetch(`/api/v1/pprs/${currentPPRId}/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');
}
await loadJournal(currentPPRId); // Refresh journal
loadPPRs(); // Refresh both tables
showNotification(`Status updated to ${status}`);
closePPRModal(); // Close modal after successful status update
} catch (error) {
console.error('Error updating status:', error);
showNotification('Error updating status', true);
}
}
async function updateStatusFromTable(pprId, status) {
if (!accessToken) return;
// Show confirmation for cancel actions
if (status === 'CANCELED') {
if (!confirm('Are you sure you want to cancel this PPR? This action cannot be easily undone.')) {
return;
}
}
try {
const response = await fetch(`/api/v1/pprs/${pprId}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({ status: status })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Failed to update status: ${response.status} ${response.statusText} - ${errorData.detail || 'Unknown error'}`);
}
loadPPRs(); // Refresh both tables
showNotification(`Status updated to ${status}`);
} catch (error) {
console.error('Error updating status:', error);
showNotification(`Error updating status: ${error.message}`, true);
}
}
async function deletePPR() {
if (!currentPPRId || !accessToken) return;
if (!confirm('Are you sure you want to delete this PPR? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`/api/v1/pprs/${currentPPRId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (!response.ok) {
throw new Error('Failed to delete PPR');
}
closePPRModal();
loadPPRs(); // Refresh both tables
showNotification('PPR deleted successfully!');
} catch (error) {
console.error('Error deleting PPR:', error);
showNotification('Error deleting PPR', true);
}
}
// User Management Functions
let currentUserRole = null;
let isNewUser = false;
let currentUserId = null;
async function openUserManagementModal() {
if (!accessToken) return;
document.getElementById('userManagementModal').style.display = 'block';
await loadUsers();
}
function closeUserManagementModal() {
document.getElementById('userManagementModal').style.display = 'none';
}
async function loadUsers() {
if (!accessToken) return;
document.getElementById('users-loading').style.display = 'block';
document.getElementById('users-table-content').style.display = 'none';
document.getElementById('users-no-data').style.display = 'none';
try {
const response = await authenticatedFetch('/api/v1/auth/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const users = await response.json();
displayUsers(users);
} catch (error) {
console.error('Error loading users:', error);
if (error.message !== 'Session expired. Please log in again.') {
showNotification('Error loading users', true);
}
}
document.getElementById('users-loading').style.display = 'none';
}
function displayUsers(users) {
const tbody = document.getElementById('users-table-body');
if (users.length === 0) {
document.getElementById('users-no-data').style.display = 'block';
return;
}
tbody.innerHTML = '';
document.getElementById('users-table-content').style.display = 'block';
users.forEach(user => {
const row = document.createElement('tr');
// Format role for display
const roleDisplay = {
'ADMINISTRATOR': 'Administrator',
'OPERATOR': 'Operator',
'READ_ONLY': 'Read Only'
}[user.role] || user.role;
// Format created date
const createdDate = user.created_at ? formatDateTime(user.created_at) : '-';
row.innerHTML = `
<td><strong>${user.username}</strong></td>
<td>${roleDisplay}</td>
<td>${createdDate}</td>
<td>
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); openUserEditModal(${user.id})" title="Edit User">
✏️
</button>
</td>
`;
tbody.appendChild(row);
});
}
function openUserCreateModal() {
isNewUser = true;
currentUserId = null;
document.getElementById('user-modal-title').textContent = 'Create New User';
// Clear form
document.getElementById('user-form').reset();
document.getElementById('user-id').value = '';
document.getElementById('user-password').required = true;
// Show password help text
const passwordHelp = document.querySelector('#user-password + small');
if (passwordHelp) passwordHelp.style.display = 'none';
document.getElementById('userModal').style.display = 'block';
// Auto-focus on username field
setTimeout(() => {
document.getElementById('user-username').focus();
}, 100);
}
async function openUserEditModal(userId) {
if (!accessToken) return;
isNewUser = false;
currentUserId = userId;
document.getElementById('user-modal-title').textContent = 'Edit User';
try {
const response = await authenticatedFetch(`/api/v1/auth/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user details');
}
const user = await response.json();
populateUserForm(user);
document.getElementById('userModal').style.display = 'block';
} catch (error) {
console.error('Error loading user details:', error);
showNotification('Error loading user details', true);
}
}
function populateUserForm(user) {
document.getElementById('user-id').value = user.id;
document.getElementById('user-username').value = user.username;
document.getElementById('user-password').value = ''; // Don't populate password
document.getElementById('user-role').value = user.role;
// Make password optional for editing
document.getElementById('user-password').required = false;
// Show password help text
const passwordHelp = document.querySelector('#user-password + small');
if (passwordHelp) passwordHelp.style.display = 'block';
}
function closeUserModal() {
document.getElementById('userModal').style.display = 'none';
currentUserId = null;
isNewUser = false;
}
// User form submission
document.getElementById('user-form').addEventListener('submit', async function(e) {
e.preventDefault();
if (!accessToken) return;
const formData = new FormData(this);
const userData = {};
formData.forEach((value, key) => {
if (key !== 'id' && value.trim() !== '') {
userData[key] = value;
}
});
try {
let response;
if (isNewUser) {
response = await fetch('/api/v1/auth/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify(userData)
});
} else {
response = await fetch(`/api/v1/auth/users/${currentUserId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify(userData)
});
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Failed to save user');
}
const wasNewUser = isNewUser;
closeUserModal();
await loadUsers(); // Refresh user list
showNotification(wasNewUser ? 'User created successfully!' : 'User updated successfully!');
} catch (error) {
console.error('Error saving user:', error);
showNotification(`Error saving user: ${error.message}`, true);
}
});
// 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;
}
try {
const response = await authenticatedFetch('/api/v1/auth/test-token', {
method: 'POST'
});
if (response.ok) {
const userData = await response.json();
currentUserRole = userData.role;
console.log('User role from API:', currentUserRole); // Debug log
// Show user management button only for administrators
const userManagementBtn = document.getElementById('user-management-btn');
if (currentUserRole && currentUserRole.toUpperCase() === 'ADMINISTRATOR') {
userManagementBtn.style.display = 'inline-block';
console.log('Showing user management button'); // Debug log
} else {
userManagementBtn.style.display = 'none';
console.log('Hiding user management button, current role:', currentUserRole); // Debug log
}
}
} catch (error) {
console.error('Error updating user role:', error);
// Hide user management by default on error
document.getElementById('user-management-btn').style.display = 'none';
}
}
// Close modal when clicking outside
window.onclick = function(event) {
const pprModal = document.getElementById('pprModal');
const timestampModal = document.getElementById('timestampModal');
const userManagementModal = document.getElementById('userManagementModal');
const userModal = document.getElementById('userModal');
if (event.target === pprModal) {
closePPRModal();
}
if (event.target === timestampModal) {
closeTimestampModal();
}
if (event.target === userManagementModal) {
closeUserManagementModal();
}
if (event.target === userModal) {
closeUserModal();
}
}
// Aircraft Lookup Functions
let aircraftLookupTimeout;
function handleAircraftLookup(registration) {
// Clear previous timeout
if (aircraftLookupTimeout) {
clearTimeout(aircraftLookupTimeout);
}
// Clear results if input is too short
if (registration.length < 4) {
clearAircraftLookup();
return;
}
// Show searching indicator
document.getElementById('aircraft-lookup-results').innerHTML =
'<div class="aircraft-searching">Searching...</div>';
// Debounce the search - wait 300ms after user stops typing
aircraftLookupTimeout = setTimeout(() => {
performAircraftLookup(registration);
}, 300);
}
async function performAircraftLookup(registration) {
try {
// Clean the input (remove non-alphanumeric characters and make uppercase)
const cleanInput = registration.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
if (cleanInput.length < 4) {
clearAircraftLookup();
return;
}
// Call the real API
const response = await authenticatedFetch(`/api/v1/aircraft/lookup/${cleanInput}`);
if (!response.ok) {
throw new Error('Failed to fetch aircraft data');
}
const matches = await response.json();
displayAircraftLookupResults(matches, cleanInput);
} catch (error) {
console.error('Aircraft lookup error:', error);
document.getElementById('aircraft-lookup-results').innerHTML =
'<div class="aircraft-no-match">Lookup failed - please enter manually</div>';
}
}
function displayAircraftLookupResults(matches, searchTerm) {
const resultsDiv = document.getElementById('aircraft-lookup-results');
if (matches.length === 0) {
resultsDiv.innerHTML = '<div class="aircraft-no-match">No matches found</div>';
} else if (matches.length === 1) {
// Unique match found - auto-populate
const aircraft = matches[0];
resultsDiv.innerHTML = `
<div class="aircraft-match">
${aircraft.manufacturer_name} ${aircraft.model} (${aircraft.type_code})
</div>
`;
// Auto-populate the form fields
document.getElementById('ac_reg').value = aircraft.registration;
document.getElementById('ac_type').value = aircraft.type_code;
} else {
// Multiple matches - show list but don't auto-populate
resultsDiv.innerHTML = `
<div class="aircraft-no-match">
Multiple matches found (${matches.length}) - please be more specific
</div>
`;
}
}
function clearAircraftLookup() {
document.getElementById('aircraft-lookup-results').innerHTML = '';
}
function clearArrivalAirportLookup() {
document.getElementById('arrival-airport-lookup-results').innerHTML = '';
}
function clearDepartureAirportLookup() {
document.getElementById('departure-airport-lookup-results').innerHTML = '';
}
// Airport Lookup Functions
let arrivalAirportLookupTimeout;
let departureAirportLookupTimeout;
function handleArrivalAirportLookup(codeOrName) {
// Clear previous timeout
if (arrivalAirportLookupTimeout) {
clearTimeout(arrivalAirportLookupTimeout);
}
// Clear results if input is too short
if (codeOrName.length < 2) {
clearArrivalAirportLookup();
return;
}
// Show searching indicator
document.getElementById('arrival-airport-lookup-results').innerHTML =
'<div class="airport-searching">Searching...</div>';
// Debounce the search - wait 300ms after user stops typing
arrivalAirportLookupTimeout = setTimeout(() => {
performArrivalAirportLookup(codeOrName);
}, 300);
}
function handleDepartureAirportLookup(codeOrName) {
// Clear previous timeout
if (departureAirportLookupTimeout) {
clearTimeout(departureAirportLookupTimeout);
}
// Clear results if input is too short
if (codeOrName.length < 2) {
clearDepartureAirportLookup();
return;
}
// Show searching indicator
document.getElementById('departure-airport-lookup-results').innerHTML =
'<div class="airport-searching">Searching...</div>';
// Debounce the search - wait 300ms after user stops typing
departureAirportLookupTimeout = setTimeout(() => {
performDepartureAirportLookup(codeOrName);
}, 300);
}
async function performArrivalAirportLookup(codeOrName) {
try {
const cleanInput = codeOrName.trim();
if (cleanInput.length < 2) {
clearArrivalAirportLookup();
return;
}
// Call the airport lookup API
const response = await authenticatedFetch(`/api/v1/airport/lookup/${encodeURIComponent(cleanInput)}`);
if (!response.ok) {
throw new Error('Failed to fetch airport data');
}
const matches = await response.json();
displayArrivalAirportLookupResults(matches, cleanInput);
} catch (error) {
console.error('Arrival airport lookup error:', error);
document.getElementById('arrival-airport-lookup-results').innerHTML =
'<div class="airport-no-match">Lookup failed - will use as entered</div>';
}
}
async function performDepartureAirportLookup(codeOrName) {
try {
const cleanInput = codeOrName.trim();
if (cleanInput.length < 2) {
clearDepartureAirportLookup();
return;
}
// Call the airport lookup API
const response = await authenticatedFetch(`/api/v1/airport/lookup/${encodeURIComponent(cleanInput)}`);
if (!response.ok) {
throw new Error('Failed to fetch airport data');
}
const matches = await response.json();
displayDepartureAirportLookupResults(matches, cleanInput);
} catch (error) {
console.error('Departure airport lookup error:', error);
document.getElementById('departure-airport-lookup-results').innerHTML =
'<div class="airport-no-match">Lookup failed - will use as entered</div>';
}
}
function displayArrivalAirportLookupResults(matches, searchTerm) {
const resultsDiv = document.getElementById('arrival-airport-lookup-results');
if (matches.length === 0) {
resultsDiv.innerHTML = '<div class="airport-no-match">No matches found - will use as entered</div>';
} else {
// Show matches as clickable options (single or multiple)
const matchText = matches.length === 1 ? 'Match found - click to select:' : 'Multiple matches found - select one:';
const listHtml = matches.map(airport => `
<div class="airport-option" onclick="selectArrivalAirport('${airport.icao}')">
<div>
<div class="airport-code">${airport.icao}</div>
<div class="airport-name">${airport.name}</div>
${airport.city ? `<div class="airport-location">${airport.city}, ${airport.country}</div>` : ''}
</div>
</div>
`).join('');
resultsDiv.innerHTML = `
<div class="airport-no-match" style="margin-bottom: 0.5rem;">
${matchText}
</div>
<div class="airport-list">
${listHtml}
</div>
`;
}
}
function displayDepartureAirportLookupResults(matches, searchTerm) {
const resultsDiv = document.getElementById('departure-airport-lookup-results');
if (matches.length === 0) {
resultsDiv.innerHTML = '<div class="airport-no-match">No matches found - will use as entered</div>';
} else {
// Show matches as clickable options (single or multiple)
const matchText = matches.length === 1 ? 'Match found - click to select:' : 'Multiple matches found - select one:';
const listHtml = matches.map(airport => `
<div class="airport-option" onclick="selectDepartureAirport('${airport.icao}')">
<div>
<div class="airport-code">${airport.icao}</div>
<div class="airport-name">${airport.name}</div>
${airport.city ? `<div class="airport-location">${airport.city}, ${airport.country}</div>` : ''}
</div>
</div>
`).join('');
resultsDiv.innerHTML = `
<div class="airport-no-match" style="margin-bottom: 0.5rem;">
${matchText}
</div>
<div class="airport-list">
${listHtml}
</div>
`;
}
}
function clearArrivalAirportLookup() {
document.getElementById('arrival-airport-lookup-results').innerHTML = '';
}
function clearDepartureAirportLookup() {
document.getElementById('departure-airport-lookup-results').innerHTML = '';
}
// Airport selection functions
function selectArrivalAirport(icaoCode) {
document.getElementById('in_from').value = icaoCode;
clearArrivalAirportLookup();
}
function selectDepartureAirport(icaoCode) {
document.getElementById('out_to').value = icaoCode;
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();
setupKeyboardShortcuts();
initializeTimeDropdowns(); // Initialize time dropdowns
initializeAuth(); // Start authentication process
// Add event listeners to ETA fields to auto-update ETD
document.getElementById('eta-date').addEventListener('change', updateETDFromETA);
document.getElementById('eta-time').addEventListener('change', updateETDFromETA);
// Add event listeners to ETD fields to mark as manually edited
document.getElementById('etd-date').addEventListener('change', markETDAsManuallyEdited);
document.getElementById('etd-time').addEventListener('change', markETDAsManuallyEdited);
});
</script>
</body>
</html>