3145 lines
124 KiB
HTML
3145 lines
124 KiB
HTML
<!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;
|
||
gap: 0.3rem;
|
||
}
|
||
|
||
.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-info {
|
||
background-color: #3498db;
|
||
color: white;
|
||
}
|
||
|
||
.btn-info:hover {
|
||
background-color: #2980b9;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.footer-bar {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: #34495e;
|
||
color: white;
|
||
padding: 0.5rem 2rem;
|
||
text-align: center;
|
||
font-size: 0.85rem;
|
||
z-index: 50;
|
||
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
body {
|
||
padding-bottom: 40px; /* Make room for footer */
|
||
}
|
||
|
||
.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: space-between;
|
||
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()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="quick-actions">
|
||
<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>
|
||
</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>
|
||
</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="btn-cancel" onclick="updateStatus('CANCELED')">
|
||
❌ Cancel PPR
|
||
</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()">×</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()">×</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>
|
||
|
||
<!-- Change User Password Modal -->
|
||
<div id="changePasswordModal" class="modal">
|
||
<div class="modal-content" style="max-width: 500px;">
|
||
<div class="modal-header">
|
||
<h2 id="change-password-title">Change User Password</h2>
|
||
<button class="close" onclick="closeChangePasswordModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="change-password-form">
|
||
<div class="form-group full-width">
|
||
<label for="change-password-username" style="font-weight: bold;">Username</label>
|
||
<input type="text" id="change-password-username" name="username" readonly style="background-color: #f5f5f5; cursor: not-allowed;">
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label for="change-password-new">New Password *</label>
|
||
<input type="password" id="change-password-new" name="new_password" required>
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label for="change-password-confirm">Confirm New Password *</label>
|
||
<input type="password" id="change-password-confirm" name="confirm_password" required>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-primary" onclick="closeChangePasswordModal()">
|
||
Cancel
|
||
</button>
|
||
<button type="submit" class="btn btn-warning">
|
||
🔐 Change Password
|
||
</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()">×</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('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.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-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-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-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 and cancel button
|
||
document.querySelector('.quick-actions').style.display = 'none';
|
||
document.getElementById('btn-cancel').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>
|
||
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); openChangePasswordModal(${user.id}, '${user.username}')" title="Change Password">
|
||
🔐
|
||
</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;
|
||
}
|
||
|
||
let currentChangePasswordUserId = null;
|
||
|
||
function openChangePasswordModal(userId, username) {
|
||
if (!accessToken) return;
|
||
|
||
currentChangePasswordUserId = userId;
|
||
document.getElementById('change-password-username').value = username;
|
||
document.getElementById('change-password-new').value = '';
|
||
document.getElementById('change-password-confirm').value = '';
|
||
document.getElementById('changePasswordModal').style.display = 'block';
|
||
|
||
// Auto-focus on new password field
|
||
setTimeout(() => {
|
||
document.getElementById('change-password-new').focus();
|
||
}, 100);
|
||
}
|
||
|
||
function closeChangePasswordModal() {
|
||
document.getElementById('changePasswordModal').style.display = 'none';
|
||
currentChangePasswordUserId = null;
|
||
}
|
||
|
||
// Change password form submission
|
||
document.getElementById('change-password-form').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
|
||
if (!accessToken || !currentChangePasswordUserId) return;
|
||
|
||
const newPassword = document.getElementById('change-password-new').value.trim();
|
||
const confirmPassword = document.getElementById('change-password-confirm').value.trim();
|
||
|
||
// Validate passwords match
|
||
if (newPassword !== confirmPassword) {
|
||
showNotification('Passwords do not match!', true);
|
||
return;
|
||
}
|
||
|
||
// Validate password length
|
||
if (newPassword.length < 6) {
|
||
showNotification('Password must be at least 6 characters long!', true);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/auth/users/${currentChangePasswordUserId}/change-password`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify({ password: newPassword })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({}));
|
||
throw new Error(errorData.detail || 'Failed to change password');
|
||
}
|
||
|
||
closeChangePasswordModal();
|
||
showNotification('Password changed successfully!');
|
||
} catch (error) {
|
||
console.error('Error changing password:', error);
|
||
showNotification(`Error changing password: ${error.message}`, true);
|
||
}
|
||
});
|
||
|
||
// 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>
|
||
|
||
<!-- Footer Bar -->
|
||
<div class="footer-bar">
|
||
Please contact James Pattinson if you have any ideas about or problems with this system
|
||
</div>
|
||
</body>
|
||
</html> |