4133 lines
190 KiB
HTML
4133 lines
190 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>
|
||
<link rel="stylesheet" href="admin.css">
|
||
<script src="lookups.js"></script>
|
||
</head>
|
||
<body>
|
||
<div class="top-bar">
|
||
<div class="title">
|
||
<h1>✈️ Swansea Tower</h1>
|
||
</div>
|
||
<div class="menu-buttons">
|
||
<button class="btn btn-success" onclick="openNewPPRModal()">
|
||
➕ New PPR
|
||
</button>
|
||
<button class="btn btn-info" onclick="openLocalFlightModal()">
|
||
🛫 Book Out
|
||
</button>
|
||
<button class="btn btn-info" onclick="openBookInModal()">
|
||
🛬 Book In
|
||
</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">
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span>🛬 Today's Pending Arrivals - <span id="arrivals-count">0</span></span>
|
||
<span class="info-icon" onclick="showTableHelp('arrivals')" title="What is this?">ℹ️</span>
|
||
</div>
|
||
</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 style="width: 30px; text-align: center;"></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">
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span>🛫 Today's Pending Departures - <span id="departures-count">0</span></span>
|
||
<span class="info-icon" onclick="showTableHelp('departures')" title="What is this?">ℹ️</span>
|
||
</div>
|
||
</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 style="width: 30px; text-align: center;"></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;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span>✈️ Departed Today - <span id="departed-count">0</span></span>
|
||
<span class="info-icon" onclick="showTableHelp('departed')" title="What is this?" style="font-size: 1.1rem; cursor: pointer;">ℹ️</span>
|
||
</div>
|
||
</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; width: 30px; text-align: center;"></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;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span>🅿️ Parked Visitors - <span id="parked-count">0</span></span>
|
||
<span class="info-icon" onclick="showTableHelp('parked')" title="What is this?" style="font-size: 1.1rem; cursor: pointer;">ℹ️</span>
|
||
</div>
|
||
</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; width: 30px; text-align: center;"></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; width: 30px; text-align: center;"></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>
|
||
<option value="FULL">Full Tanks</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="out_to">Departing To</label>
|
||
<input type="text" id="out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleDepartureAirportLookup(this.value)" tabindex="-1">
|
||
<div id="departure-airport-lookup-results"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="etd">ETD (Local Time)</label>
|
||
<div style="display: flex; gap: 0.5rem;">
|
||
<input type="date" id="etd-date" name="etd-date" tabindex="-1" style="flex: 1;">
|
||
<select id="etd-time" name="etd-time" tabindex="-1" style="flex: 1;">
|
||
<option value="">Select Time</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="pob_out">POB Outbound</label>
|
||
<input type="number" id="pob_out" name="pob_out" min="1" tabindex="-1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="email">Email</label>
|
||
<input type="email" id="email" name="email" tabindex="-1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="phone">Phone</label>
|
||
<input type="tel" id="phone" name="phone" tabindex="-1">
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label for="notes">Notes</label>
|
||
<textarea id="notes" name="notes" rows="3" tabindex="-1"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-info" onclick="closePPRModal()">
|
||
Close
|
||
</button>
|
||
<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>
|
||
|
||
<!-- Local Flight (Book Out) Modal -->
|
||
<div id="localFlightModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2 id="local-flight-modal-title">Book Out</h2>
|
||
<button class="close" onclick="closeLocalFlightModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="local-flight-form">
|
||
<input type="hidden" id="local-flight-id" name="id">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="local_registration">Aircraft Registration *</label>
|
||
<input type="text" id="local_registration" name="registration" required oninput="handleLocalAircraftLookup(this.value)" tabindex="1">
|
||
<div id="local-aircraft-lookup-results"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="local_type">Aircraft Type</label>
|
||
<input type="text" id="local_type" name="type" tabindex="4" placeholder="e.g., C172, PA34, AA5">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="local_callsign">Callsign (optional)</label>
|
||
<input type="text" id="local_callsign" name="callsign" placeholder="If different from registration" tabindex="6">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="local_pob">Persons on Board *</label>
|
||
<input type="number" id="local_pob" name="pob" required min="1" tabindex="2">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="local_flight_type">Flight Type *</label>
|
||
<select id="local_flight_type" name="flight_type" required tabindex="5" onchange="handleFlightTypeChange(this.value)">
|
||
<option value="LOCAL">Local Flight</option>
|
||
<option value="CIRCUITS">Circuits</option>
|
||
<option value="DEPARTURE">Departure to Other Airport</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="local_duration">Duration (minutes)</label>
|
||
<input type="number" id="local_duration" name="duration" min="5" max="480" value="45" placeholder="Duration in minutes" tabindex="7">
|
||
</div>
|
||
<div class="form-group" id="departure-destination-group" style="display: none;">
|
||
<label for="local_out_to" id="departure-destination-label">Destination Airport</label>
|
||
<input type="text" id="local_out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleLocalOutToAirportLookup(this.value)" tabindex="3">
|
||
<div id="local-out-to-lookup-results"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="local_etd_time">ETD (Estimated Time of Departure) *</label>
|
||
<select id="local_etd_time" name="etd_time" required>
|
||
<option value="">Select Time</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label for="local_notes">Notes</label>
|
||
<textarea id="local_notes" name="notes" rows="3" placeholder="e.g., destination, any special requirements"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-info" onclick="closeLocalFlightModal()">
|
||
Close
|
||
</button>
|
||
<button type="submit" class="btn btn-success">
|
||
🛫 Book Out
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Local Flight Edit Modal -->
|
||
<div id="localFlightEditModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2 id="local-flight-edit-title">Local Flight Details</h2>
|
||
<button class="close" onclick="closeLocalFlightEditModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="quick-actions">
|
||
<button id="local-btn-departed" class="btn btn-primary btn-sm" onclick="updateLocalFlightStatus('DEPARTED')" style="display: none;">
|
||
🛫 Mark Departed
|
||
</button>
|
||
<button id="local-btn-landed" class="btn btn-warning btn-sm" onclick="updateLocalFlightStatus('LANDED')" style="display: none;">
|
||
🛬 Land
|
||
</button>
|
||
<button id="local-btn-cancel" class="btn btn-danger btn-sm" onclick="updateLocalFlightStatus('CANCELLED')" style="display: none;">
|
||
❌ Cancel Flight
|
||
</button>
|
||
</div>
|
||
|
||
<form id="local-flight-edit-form">
|
||
<input type="hidden" id="local-edit-flight-id" name="id">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="local_edit_registration">Aircraft Registration</label>
|
||
<input type="text" id="local_edit_registration" name="registration" readonly style="background-color: #f5f5f5; cursor: not-allowed;">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="local_edit_type">Aircraft Type</label>
|
||
<input type="text" id="local_edit_type" name="type" readonly style="background-color: #f5f5f5; cursor: not-allowed;">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="local_edit_callsign">Callsign</label>
|
||
<input type="text" id="local_edit_callsign" name="callsign">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="local_edit_pob">POB</label>
|
||
<input type="number" id="local_edit_pob" name="pob" min="1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="local_edit_flight_type">Flight Type</label>
|
||
<select id="local_edit_flight_type" name="flight_type">
|
||
<option value="LOCAL">Local Flight</option>
|
||
<option value="CIRCUITS">Circuits</option>
|
||
<option value="DEPARTURE">Departure</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="local_edit_duration">Duration (minutes)</label>
|
||
<input type="number" id="local_edit_duration" name="duration" min="5" max="480">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="local_edit_departure_dt">Departure Time</label>
|
||
<div style="display: flex; gap: 0.5rem;">
|
||
<input type="date" id="local_edit_departure_date" name="departure_date" style="flex: 1;">
|
||
<input type="time" id="local_edit_departure_time" name="departure_time" style="flex: 1;">
|
||
</div>
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label for="local_edit_notes">Notes</label>
|
||
<textarea id="local_edit_notes" name="notes" rows="3"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-info" onclick="closeLocalFlightEditModal()">
|
||
Close
|
||
</button>
|
||
<button type="submit" class="btn btn-success">
|
||
💾 Save Changes
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<!-- Circuits Section (for CIRCUITS flights only) -->
|
||
<div id="circuits-section" style="display: none; margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
|
||
<h3>✈️ Touch & Go Records</h3>
|
||
<div id="circuits-list" style="margin-top: 1rem;">
|
||
<p style="color: #666; font-style: italic;">Loading circuits...</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Journal Section -->
|
||
<div id="local-flight-journal-section" style="margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
|
||
<h3>📋 Activity Journal</h3>
|
||
<div id="local-flight-journal-entries" class="journal-entries">
|
||
Loading journal...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Book In Modal -->
|
||
<div id="bookInModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>Book In</h2>
|
||
<button class="close" onclick="closeBookInModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="book-in-form">
|
||
<input type="hidden" id="book-in-id" name="id">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="book_in_registration">Aircraft Registration *</label>
|
||
<input type="text" id="book_in_registration" name="registration" required oninput="handleBookInAircraftLookup(this.value)" tabindex="1">
|
||
<div id="book-in-aircraft-lookup-results"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="book_in_type">Aircraft Type</label>
|
||
<input type="text" id="book_in_type" name="type" tabindex="4" placeholder="e.g., C172, PA34, AA5">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="book_in_callsign">Callsign (optional)</label>
|
||
<input type="text" id="book_in_callsign" name="callsign" placeholder="If different from registration" tabindex="5">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="book_in_pob">Persons on Board *</label>
|
||
<input type="number" id="book_in_pob" name="pob" required min="1" tabindex="2">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="book_in_from">Coming From (Airport) *</label>
|
||
<input type="text" id="book_in_from" name="in_from" placeholder="ICAO Code or Airport Name" required oninput="handleBookInArrivalAirportLookup(this.value)" tabindex="3">
|
||
<div id="book-in-arrival-airport-lookup-results"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="book_in_eta_time">ETA (Estimated Time of Arrival) *</label>
|
||
<select id="book_in_eta_time" name="eta_time" required>
|
||
<option value="">Select Time</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label for="book_in_notes">Notes</label>
|
||
<textarea id="book_in_notes" name="notes" rows="3" placeholder="e.g., any special requirements"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-info" onclick="closeBookInModal()">
|
||
Close
|
||
</button>
|
||
<button type="submit" class="btn btn-success">
|
||
🛬 Book In
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Departure Edit Modal -->
|
||
<div id="departureEditModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2 id="departure-edit-title">Departure Details</h2>
|
||
<button class="close" onclick="closeDepartureEditModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="quick-actions">
|
||
<button id="departure-btn-departed" class="btn btn-primary btn-sm" onclick="updateDepartureStatus('DEPARTED')" style="display: none;">
|
||
🛫 Mark Departed
|
||
</button>
|
||
<button id="departure-btn-cancel" class="btn btn-danger btn-sm" onclick="updateDepartureStatus('CANCELLED')" style="display: none;">
|
||
❌ Cancel Departure
|
||
</button>
|
||
</div>
|
||
|
||
<form id="departure-edit-form">
|
||
<input type="hidden" id="departure-edit-id" name="id">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="departure_edit_registration">Aircraft Registration</label>
|
||
<input type="text" id="departure_edit_registration" name="registration">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="departure_edit_type">Aircraft Type</label>
|
||
<input type="text" id="departure_edit_type" name="type">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="departure_edit_callsign">Callsign</label>
|
||
<input type="text" id="departure_edit_callsign" name="callsign">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="departure_edit_out_to">Destination</label>
|
||
<input type="text" id="departure_edit_out_to" name="out_to">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="departure_edit_etd">ETD (Estimated Time of Departure)</label>
|
||
<div style="display: flex; gap: 0.5rem;">
|
||
<input type="date" id="departure_edit_etd_date" name="etd_date" style="flex: 1;">
|
||
<input type="time" id="departure_edit_etd_time" name="etd_time" style="flex: 1;">
|
||
</div>
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label for="departure_edit_notes">Notes</label>
|
||
<textarea id="departure_edit_notes" name="notes" rows="3"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-info" onclick="closeDepartureEditModal()">
|
||
Close
|
||
</button>
|
||
<button type="submit" class="btn btn-success">
|
||
Save Changes
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<!-- Journal Section -->
|
||
<div id="departure-journal-section" style="margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
|
||
<h3>📋 Activity Journal</h3>
|
||
<div id="departure-journal-entries" class="journal-entries">
|
||
Loading journal...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Arrival Edit Modal -->
|
||
<div id="arrivalEditModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2 id="arrival-edit-title">Arrival Details</h2>
|
||
<button class="close" onclick="closeArrivalEditModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="quick-actions">
|
||
<button id="arrival-btn-landed" class="btn btn-primary btn-sm" onclick="updateArrivalStatus('LANDED')" style="display: none;">
|
||
🛬 Mark Landed
|
||
</button>
|
||
<button id="arrival-btn-cancel" class="btn btn-danger btn-sm" onclick="updateArrivalStatus('CANCELLED')" style="display: none;">
|
||
❌ Cancel Arrival
|
||
</button>
|
||
</div>
|
||
|
||
<form id="arrival-edit-form">
|
||
<input type="hidden" id="arrival-edit-id" name="id">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="arrival_edit_registration">Aircraft Registration</label>
|
||
<input type="text" id="arrival_edit_registration" name="registration">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="arrival_edit_type">Aircraft Type</label>
|
||
<input type="text" id="arrival_edit_type" name="type">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="arrival_edit_callsign">Callsign</label>
|
||
<input type="text" id="arrival_edit_callsign" name="callsign">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="arrival_edit_in_from">Origin Airport</label>
|
||
<input type="text" id="arrival_edit_in_from" name="in_from">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="arrival_edit_pob">POB (Persons on Board)</label>
|
||
<input type="number" id="arrival_edit_pob" name="pob" min="1">
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label for="arrival_edit_notes">Notes</label>
|
||
<textarea id="arrival_edit_notes" name="notes" rows="3"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-info" onclick="closeArrivalEditModal()">
|
||
Close
|
||
</button>
|
||
<button type="submit" class="btn btn-success">
|
||
Save Changes
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<!-- Journal Section -->
|
||
<div id="arrival-journal-section" style="margin-top: 2rem; border-top: 1px solid #ddd; padding-top: 1rem;">
|
||
<h3>📋 Activity Journal</h3>
|
||
<div id="arrival-journal-entries" class="journal-entries">
|
||
Loading journal...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Table Help Modal -->
|
||
<div id="tableHelpModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>Table Information</h2>
|
||
<button class="close" onclick="closeTableHelp()">×</button>
|
||
</div>
|
||
<div class="modal-body" id="tableHelpContent">
|
||
<!-- Content will be populated by JavaScript -->
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-info" onclick="closeTableHelp()">Close</button>
|
||
</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-info" onclick="closeUserModal()">
|
||
Close
|
||
</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-info" onclick="closeChangePasswordModal()">
|
||
Close
|
||
</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-info" onclick="closeTimestampModal()">
|
||
Close
|
||
</button>
|
||
<button type="submit" class="btn btn-success" id="timestamp-submit-btn">
|
||
Confirm
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Circuit Modal for recording touch-and-go events -->
|
||
<div id="circuitModal" class="modal">
|
||
<div class="modal-content" style="max-width: 400px;">
|
||
<div class="modal-header">
|
||
<h2>Record Circuit (Touch & Go)</h2>
|
||
<button class="close" onclick="closeCircuitModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="circuit-form">
|
||
<div class="form-group">
|
||
<label for="circuit-timestamp">Circuit Time (Local Time) *</label>
|
||
<input type="datetime-local" id="circuit-timestamp" name="timestamp" required>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-info" onclick="closeCircuitModal()">
|
||
Close
|
||
</button>
|
||
<button type="submit" class="btn btn-success">
|
||
Record Circuit
|
||
</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
|
||
let loadPPRsTimeout = null; // Debounce timer for loadPPRs to prevent duplicate refreshes
|
||
|
||
// 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');
|
||
}
|
||
|
||
// Refresh local flights when any local flight event occurs
|
||
if (data.type && (data.type.includes('local_flight_'))) {
|
||
console.log('Local flight update detected, refreshing...');
|
||
loadPPRs();
|
||
showNotification('Local flight updated');
|
||
}
|
||
|
||
// Refresh departures when any departure event occurs
|
||
if (data.type && (data.type.includes('departure_'))) {
|
||
console.log('Departure update detected, refreshing...');
|
||
loadDepartures();
|
||
showNotification('Departure updated');
|
||
}
|
||
|
||
// Refresh arrivals when any arrival event occurs
|
||
if (data.type && (data.type.includes('arrival_'))) {
|
||
console.log('Arrival update detected, refreshing...');
|
||
loadArrivals();
|
||
showNotification('Arrival updated');
|
||
}
|
||
} 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;
|
||
}
|
||
|
||
// Press 'Escape' to close circuit modal if it's open (allow even when typing in inputs)
|
||
if (e.key === 'Escape' && document.getElementById('circuitModal').style.display === 'block') {
|
||
e.preventDefault();
|
||
closeCircuitModal();
|
||
return;
|
||
}
|
||
|
||
// Press 'Escape' to close Book Out modal if it's open (allow even when typing in inputs)
|
||
if (e.key === 'Escape' && document.getElementById('localFlightModal').style.display === 'block') {
|
||
e.preventDefault();
|
||
closeLocalFlightModal();
|
||
return;
|
||
}
|
||
|
||
// Press 'Escape' to close Book In modal if it's open (allow even when typing in inputs)
|
||
if (e.key === 'Escape' && document.getElementById('bookInModal').style.display === 'block') {
|
||
e.preventDefault();
|
||
closeBookInModal();
|
||
return;
|
||
}
|
||
|
||
// Press 'Escape' to close local flight edit modal if it's open (allow even when typing in inputs)
|
||
if (e.key === 'Escape' && document.getElementById('localFlightEditModal').style.display === 'block') {
|
||
e.preventDefault();
|
||
closeLocalFlightEditModal();
|
||
return;
|
||
}
|
||
|
||
// Press 'Escape' to close departure edit modal if it's open (allow even when typing in inputs)
|
||
if (e.key === 'Escape' && document.getElementById('departureEditModal').style.display === 'block') {
|
||
e.preventDefault();
|
||
closeDepartureEditModal();
|
||
return;
|
||
}
|
||
|
||
// Press 'Escape' to close arrival edit modal if it's open (allow even when typing in inputs)
|
||
if (e.key === 'Escape' && document.getElementById('arrivalEditModal').style.display === 'block') {
|
||
e.preventDefault();
|
||
closeArrivalEditModal();
|
||
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();
|
||
}
|
||
|
||
// Press 'o' to book out local flight (LOCAL type)
|
||
if (e.key === 'o' || e.key === 'O') {
|
||
e.preventDefault();
|
||
openLocalFlightModal('LOCAL');
|
||
}
|
||
|
||
// Press 'c' to book out circuits
|
||
if (e.key === 'c' || e.key === 'C') {
|
||
e.preventDefault();
|
||
openLocalFlightModal('CIRCUITS');
|
||
}
|
||
|
||
// Press 'd' to book out departure
|
||
if (e.key === 'd' || e.key === 'D') {
|
||
e.preventDefault();
|
||
openLocalFlightModal('DEPARTURE');
|
||
}
|
||
|
||
// Press 'i' to book in arrival
|
||
if (e.key === 'i' || e.key === 'I') {
|
||
e.preventDefault();
|
||
openBookInModal();
|
||
}
|
||
});
|
||
}
|
||
|
||
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;
|
||
|
||
// Debounce: prevent duplicate refreshes if called multiple times in quick succession
|
||
// (e.g., from form submission + WebSocket update at the same time)
|
||
if (loadPPRsTimeout) {
|
||
clearTimeout(loadPPRsTimeout);
|
||
}
|
||
|
||
loadPPRsTimeout = setTimeout(async () => {
|
||
// Load all tables simultaneously
|
||
await Promise.all([loadArrivals(), loadDepartures(), loadDeparted(), loadParked(), loadUpcoming()]);
|
||
loadPPRsTimeout = null;
|
||
}, 100); // Wait 100ms before executing to batch multiple calls
|
||
}
|
||
|
||
// Load arrivals (NEW and CONFIRMED status for PPR, DEPARTED for local flights)
|
||
async function loadArrivals() {
|
||
document.getElementById('arrivals-loading').style.display = 'block';
|
||
document.getElementById('arrivals-table-content').style.display = 'none';
|
||
document.getElementById('arrivals-no-data').style.display = 'none';
|
||
|
||
try {
|
||
// Load PPRs, local flights, and booked-in arrivals
|
||
const [pprResponse, localResponse, bookInResponse] = await Promise.all([
|
||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||
authenticatedFetch('/api/v1/local-flights/?status=DEPARTED&limit=1000'),
|
||
authenticatedFetch('/api/v1/arrivals/?limit=1000')
|
||
]);
|
||
|
||
if (!pprResponse.ok) {
|
||
throw new Error('Failed to fetch arrivals');
|
||
}
|
||
|
||
const allPPRs = await pprResponse.json();
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
// Filter for arrivals with ETA today and NEW or CONFIRMED status
|
||
const arrivals = allPPRs.filter(ppr => {
|
||
if (!ppr.eta || (ppr.status !== 'NEW' && ppr.status !== 'CONFIRMED')) {
|
||
return false;
|
||
}
|
||
// Extract date from ETA (UTC)
|
||
const etaDate = ppr.eta.split('T')[0];
|
||
return etaDate === today;
|
||
});
|
||
|
||
// Add local flights in DEPARTED status (in the air, heading back) - only those booked out today
|
||
if (localResponse.ok) {
|
||
const localFlights = await localResponse.json();
|
||
const today = new Date().toISOString().split('T')[0];
|
||
const localInAir = localFlights
|
||
.filter(flight => {
|
||
// Only include flights booked out today (created_dt)
|
||
if (!flight.created_dt) return false;
|
||
const createdDate = flight.created_dt.split('T')[0];
|
||
return createdDate === today;
|
||
})
|
||
.map(flight => ({
|
||
...flight,
|
||
isLocalFlight: true // Flag to distinguish from PPR
|
||
}));
|
||
arrivals.push(...localInAir);
|
||
}
|
||
|
||
// Add booked-in arrivals from the arrivals table
|
||
if (bookInResponse.ok) {
|
||
const bookedInArrivals = await bookInResponse.json();
|
||
const today = new Date().toISOString().split('T')[0];
|
||
const bookedInToday = bookedInArrivals
|
||
.filter(arrival => {
|
||
// Only include arrivals booked in today (created_dt) with BOOKED_IN status
|
||
if (!arrival.created_dt || arrival.status !== 'BOOKED_IN') return false;
|
||
const bookedDate = arrival.created_dt.split('T')[0];
|
||
return bookedDate === today;
|
||
})
|
||
.map(arrival => ({
|
||
...arrival,
|
||
isBookedIn: true // Flag to distinguish from PPR and local
|
||
}));
|
||
arrivals.push(...bookedInToday);
|
||
}
|
||
|
||
displayArrivals(arrivals);
|
||
} catch (error) {
|
||
console.error('Error loading arrivals:', error);
|
||
if (error.message !== 'Session expired. Please log in again.') {
|
||
showNotification('Error loading arrivals', true);
|
||
}
|
||
}
|
||
|
||
document.getElementById('arrivals-loading').style.display = 'none';
|
||
}
|
||
|
||
// Load departures (LANDED status for PPR, BOOKED_OUT only for local flights)
|
||
async function loadDepartures() {
|
||
document.getElementById('departures-loading').style.display = 'block';
|
||
document.getElementById('departures-table-content').style.display = 'none';
|
||
document.getElementById('departures-no-data').style.display = 'none';
|
||
|
||
try {
|
||
// Load PPR departures, local flight departures, and airport departures simultaneously
|
||
const [pprResponse, localResponse, depResponse] = await Promise.all([
|
||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||
authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&limit=1000'),
|
||
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000')
|
||
]);
|
||
|
||
if (!pprResponse.ok) {
|
||
throw new Error('Failed to fetch PPR departures');
|
||
}
|
||
|
||
const allPPRs = await pprResponse.json();
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
// Filter for PPR departures with ETD today and LANDED status only
|
||
const departures = allPPRs.filter(ppr => {
|
||
if (!ppr.etd || ppr.status !== 'LANDED') {
|
||
return false;
|
||
}
|
||
// Extract date from ETD (UTC)
|
||
const etdDate = ppr.etd.split('T')[0];
|
||
return etdDate === today;
|
||
});
|
||
|
||
// Add local flights (BOOKED_OUT status - ready to go) - only those booked out today
|
||
if (localResponse.ok) {
|
||
const localFlights = await localResponse.json();
|
||
const today = new Date().toISOString().split('T')[0];
|
||
const localDepartures = localFlights
|
||
.filter(flight => {
|
||
// Only include flights booked out today (created_dt)
|
||
if (!flight.created_dt) return false;
|
||
const createdDate = flight.created_dt.split('T')[0];
|
||
return createdDate === today;
|
||
})
|
||
.map(flight => ({
|
||
...flight,
|
||
isLocalFlight: true // Flag to distinguish from PPR
|
||
}));
|
||
departures.push(...localDepartures);
|
||
}
|
||
|
||
// Add departures to other airports (BOOKED_OUT status)
|
||
if (depResponse.ok) {
|
||
const depFlights = await depResponse.json();
|
||
const depDepartures = depFlights.map(flight => ({
|
||
...flight,
|
||
isDeparture: true // Flag to distinguish from PPR
|
||
}));
|
||
departures.push(...depDepartures);
|
||
}
|
||
|
||
displayDepartures(departures);
|
||
} catch (error) {
|
||
console.error('Error loading departures:', error);
|
||
if (error.message !== 'Session expired. Please log in again.') {
|
||
showNotification('Error loading departures', true);
|
||
}
|
||
}
|
||
|
||
document.getElementById('departures-loading').style.display = 'none';
|
||
}
|
||
|
||
// Load departed aircraft (DEPARTED status with departed_dt today)
|
||
async function loadDeparted() {
|
||
document.getElementById('departed-loading').style.display = 'block';
|
||
document.getElementById('departed-table-content').style.display = 'none';
|
||
document.getElementById('departed-no-data').style.display = 'none';
|
||
|
||
try {
|
||
const [pprResponse, depResponse] = await Promise.all([
|
||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||
authenticatedFetch('/api/v1/departures/?status=DEPARTED&limit=1000')
|
||
]);
|
||
|
||
if (!pprResponse.ok) {
|
||
throw new Error('Failed to fetch departed aircraft');
|
||
}
|
||
|
||
const allPPRs = await pprResponse.json();
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
// Filter for PPRs departed today (only PPR'd departures, exclude local/circuits)
|
||
const departed = allPPRs.filter(ppr => {
|
||
if (!ppr.departed_dt || ppr.status !== 'DEPARTED') {
|
||
return false;
|
||
}
|
||
const departedDate = ppr.departed_dt.split('T')[0];
|
||
return departedDate === today;
|
||
});
|
||
|
||
// Add departures to other airports that departed today (booked out departures)
|
||
if (depResponse.ok) {
|
||
const depFlights = await depResponse.json();
|
||
const depDeparted = depFlights.filter(flight => {
|
||
if (!flight.departed_dt) return false;
|
||
const departedDate = flight.departed_dt.split('T')[0];
|
||
return departedDate === today;
|
||
}).map(flight => ({
|
||
...flight,
|
||
isDeparture: true
|
||
}));
|
||
departed.push(...depDeparted);
|
||
}
|
||
|
||
displayDeparted(departed);
|
||
} catch (error) {
|
||
console.error('Error loading departed aircraft:', error);
|
||
if (error.message !== 'Session expired. Please log in again.') {
|
||
showNotification('Error loading departed aircraft', true);
|
||
}
|
||
}
|
||
|
||
document.getElementById('departed-loading').style.display = 'none';
|
||
}
|
||
|
||
function displayDeparted(departed) {
|
||
const tbody = document.getElementById('departed-table-body');
|
||
|
||
document.getElementById('departed-count').textContent = departed.length;
|
||
|
||
if (departed.length === 0) {
|
||
document.getElementById('departed-no-data').style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// Sort by departed time
|
||
departed.sort((a, b) => {
|
||
const aTime = a.departed_dt;
|
||
const bTime = b.departed_dt;
|
||
return new Date(aTime) - new Date(bTime);
|
||
});
|
||
|
||
tbody.innerHTML = '';
|
||
document.getElementById('departed-table-content').style.display = 'block';
|
||
|
||
for (const flight of departed) {
|
||
const row = document.createElement('tr');
|
||
const isLocal = flight.isLocalFlight;
|
||
const isDeparture = flight.isDeparture;
|
||
|
||
row.onclick = () => {
|
||
if (isLocal) {
|
||
openLocalFlightEditModal(flight.id);
|
||
} else if (isDeparture) {
|
||
openDepartureEditModal(flight.id);
|
||
} else {
|
||
openPPRModal(flight.id);
|
||
}
|
||
};
|
||
row.style.cssText = 'font-size: 0.85rem !important; font-style: italic;';
|
||
|
||
if (isLocal) {
|
||
row.innerHTML = `
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.registration || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"></td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.callsign || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">-</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
|
||
`;
|
||
} else if (isDeparture) {
|
||
row.innerHTML = `
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.registration || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"></td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.callsign || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
|
||
`;
|
||
} else {
|
||
row.innerHTML = `
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.ac_reg || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"><span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span></td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.ac_call || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
|
||
`;
|
||
}
|
||
tbody.appendChild(row);
|
||
}
|
||
}
|
||
|
||
// Load 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 pprResponse = await authenticatedFetch('/api/v1/pprs/?limit=1000');
|
||
|
||
if (!pprResponse.ok) {
|
||
throw new Error('Failed to fetch parked visitors');
|
||
}
|
||
|
||
const allPPRs = await pprResponse.json();
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
// Filter for parked PPR visitors: LANDED status and ETD on a different day
|
||
const parked = allPPRs.filter(ppr => {
|
||
if (ppr.status !== 'LANDED') {
|
||
return false;
|
||
}
|
||
// Only show if ETD exists and is not today
|
||
if (!ppr.etd) {
|
||
return false;
|
||
}
|
||
const etdDate = ppr.etd.split('T')[0];
|
||
return etdDate !== today;
|
||
});
|
||
|
||
displayParked(parked);
|
||
} catch (error) {
|
||
console.error('Error loading parked visitors:', error);
|
||
if (error.message !== 'Session expired. Please log in again.') {
|
||
showNotification('Error loading parked visitors', true);
|
||
}
|
||
}
|
||
|
||
document.getElementById('parked-loading').style.display = 'none';
|
||
}
|
||
|
||
function displayParked(parked) {
|
||
const tbody = document.getElementById('parked-table-body');
|
||
|
||
document.getElementById('parked-count').textContent = parked.length;
|
||
|
||
if (parked.length === 0) {
|
||
document.getElementById('parked-no-data').style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// Sort by landed time
|
||
parked.sort((a, b) => {
|
||
if (!a.landed_dt) return 1;
|
||
if (!b.landed_dt) return -1;
|
||
return new Date(a.landed_dt) - new Date(b.landed_dt);
|
||
});
|
||
|
||
tbody.innerHTML = '';
|
||
document.getElementById('parked-table-content').style.display = 'block';
|
||
|
||
for (const ppr of parked) {
|
||
const row = document.createElement('tr');
|
||
|
||
// All rows are PPR, so make them clickable
|
||
row.onclick = () => openPPRModal(ppr.id);
|
||
row.style.cssText = 'font-size: 0.85rem !important; font-style: italic;';
|
||
|
||
// Get registration
|
||
const registration = ppr.ac_reg || '-';
|
||
const typeIconParked = '<span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span>';
|
||
|
||
// Get aircraft type
|
||
const acType = ppr.ac_type || '-';
|
||
|
||
// Get from airport
|
||
const fromAirport = ppr.in_from || '-';
|
||
|
||
// Format arrival: time if today, date if not
|
||
const today = new Date().toISOString().split('T')[0];
|
||
let arrivedDisplay = '-';
|
||
if (ppr.landed_dt) {
|
||
const landedDate = ppr.landed_dt.split('T')[0];
|
||
if (landedDate === today) {
|
||
// Today - show time only
|
||
arrivedDisplay = formatTimeOnly(ppr.landed_dt);
|
||
} else {
|
||
// Not today - show date (DD/MM)
|
||
const date = new Date(ppr.landed_dt);
|
||
arrivedDisplay = date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||
}
|
||
}
|
||
|
||
// Format ETD as just the date (DD/MM)
|
||
let etdDisplay = '-';
|
||
if (ppr.etd) {
|
||
const etdDate = new Date(ppr.etd);
|
||
etdDisplay = etdDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||
}
|
||
|
||
row.innerHTML = `
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${registration}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;">${typeIconParked}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${acType}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${fromAirport}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${arrivedDisplay}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${etdDisplay}</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
}
|
||
}
|
||
|
||
// Load upcoming PPRs (future days with NEW or CONFIRMED status)
|
||
async function loadUpcoming() {
|
||
document.getElementById('upcoming-loading').style.display = 'block';
|
||
document.getElementById('upcoming-table-content').style.display = 'none';
|
||
document.getElementById('upcoming-no-data').style.display = 'none';
|
||
|
||
try {
|
||
const response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch upcoming PPRs');
|
||
}
|
||
|
||
const allPPRs = await response.json();
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
// Filter for PPRs with ETA in the future (not today) and NEW or CONFIRMED status
|
||
const upcoming = allPPRs.filter(ppr => {
|
||
if (!ppr.eta || (ppr.status !== 'NEW' && ppr.status !== 'CONFIRMED')) {
|
||
return false;
|
||
}
|
||
const etaDate = ppr.eta.split('T')[0];
|
||
return etaDate > today;
|
||
});
|
||
|
||
displayUpcoming(upcoming);
|
||
} catch (error) {
|
||
console.error('Error loading upcoming PPRs:', error);
|
||
if (error.message !== 'Session expired. Please log in again.') {
|
||
showNotification('Error loading upcoming PPRs', true);
|
||
}
|
||
}
|
||
|
||
document.getElementById('upcoming-loading').style.display = 'none';
|
||
}
|
||
|
||
function displayUpcoming(upcoming) {
|
||
const tbody = document.getElementById('upcoming-table-body');
|
||
|
||
document.getElementById('upcoming-count').textContent = upcoming.length;
|
||
|
||
if (upcoming.length === 0) {
|
||
// Don't show anything if collapsed by default
|
||
return;
|
||
}
|
||
|
||
// Sort by ETA date and time
|
||
upcoming.sort((a, b) => new Date(a.eta) - new Date(b.eta));
|
||
|
||
tbody.innerHTML = '';
|
||
// Don't auto-expand, keep collapsed by default
|
||
|
||
for (const ppr of upcoming) {
|
||
const row = document.createElement('tr');
|
||
row.onclick = () => openPPRModal(ppr.id);
|
||
row.style.cssText = 'font-size: 0.85rem !important;';
|
||
|
||
// Format date as Day DD/MM (e.g., Wed 11/12)
|
||
const etaDate = new Date(ppr.eta);
|
||
const dayName = etaDate.toLocaleDateString('en-GB', { weekday: 'short' });
|
||
const dateStr = etaDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||
const dateDisplay = `${dayName} ${dateStr}`;
|
||
|
||
// Create notes indicator if notes exist
|
||
const notesIndicator = ppr.notes && ppr.notes.trim() ?
|
||
`<span class="notes-tooltip">
|
||
<span class="notes-indicator">📝</span>
|
||
<span class="tooltip-text">${ppr.notes}</span>
|
||
</span>` : '';
|
||
|
||
row.innerHTML = `
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${dateDisplay}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.ac_reg || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"><span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span></td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.ac_type || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.in_from || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(ppr.eta)}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${notesIndicator}</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
}
|
||
// Setup tooltips after rendering
|
||
setupTooltips();
|
||
}
|
||
|
||
// Toggle upcoming table collapse/expand
|
||
function toggleUpcomingTable() {
|
||
const content = document.getElementById('upcoming-table-content');
|
||
const noData = document.getElementById('upcoming-no-data');
|
||
const icon = document.getElementById('upcoming-collapse-icon');
|
||
|
||
const isVisible = content.style.display === 'block' || noData.style.display === 'block';
|
||
|
||
if (isVisible) {
|
||
content.style.display = 'none';
|
||
noData.style.display = 'none';
|
||
icon.classList.add('collapsed');
|
||
} else {
|
||
const count = parseInt(document.getElementById('upcoming-count').textContent);
|
||
if (count > 0) {
|
||
content.style.display = 'block';
|
||
} else {
|
||
noData.style.display = 'block';
|
||
}
|
||
icon.classList.remove('collapsed');
|
||
}
|
||
}
|
||
|
||
// ICAO code to airport name cache
|
||
const airportNameCache = {};
|
||
|
||
async function getAirportDisplay(code) {
|
||
if (!code || code.length !== 4 || !/^[A-Z]{4}$/.test(code)) return code;
|
||
if (airportNameCache[code]) return `${code}<br><span style="font-size:0.8em;color:#666;font-style:italic;">${airportNameCache[code]}</span>`;
|
||
try {
|
||
const resp = await authenticatedFetch(`/api/v1/airport/lookup/${code}`);
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
if (data && data.length && data[0].name) {
|
||
airportNameCache[code] = data[0].name;
|
||
return `${code}<br><span style="font-size:0.8em;color:#666;font-style:italic;">${data[0].name}</span>`;
|
||
}
|
||
}
|
||
} catch {}
|
||
return code;
|
||
}
|
||
|
||
async function displayArrivals(arrivals) {
|
||
const tbody = document.getElementById('arrivals-table-body');
|
||
const recordCount = document.getElementById('arrivals-count');
|
||
|
||
recordCount.textContent = arrivals.length;
|
||
if (arrivals.length === 0) {
|
||
document.getElementById('arrivals-no-data').style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// Sort arrivals by ETA/departure time (ascending)
|
||
arrivals.sort((a, b) => {
|
||
const aTime = a.eta || a.departure_dt;
|
||
const bTime = b.eta || b.departure_dt;
|
||
if (!aTime) return 1;
|
||
if (!bTime) return -1;
|
||
return new Date(aTime) - new Date(bTime);
|
||
});
|
||
tbody.innerHTML = '';
|
||
document.getElementById('arrivals-table-content').style.display = 'block';
|
||
for (const flight of arrivals) {
|
||
const row = document.createElement('tr');
|
||
const isLocal = flight.isLocalFlight;
|
||
const isBookedIn = flight.isBookedIn;
|
||
|
||
// Click handler that routes to correct modal
|
||
row.onclick = () => {
|
||
if (isLocal) {
|
||
openLocalFlightEditModal(flight.id);
|
||
} else if (isBookedIn) {
|
||
openArrivalEditModal(flight.id);
|
||
} else {
|
||
openPPRModal(flight.id);
|
||
}
|
||
};
|
||
|
||
// Create notes indicator if notes exist
|
||
const notesIndicator = flight.notes && flight.notes.trim() ?
|
||
`<span class="notes-tooltip">
|
||
<span class="notes-indicator">📝</span>
|
||
<span class="tooltip-text">${flight.notes}</span>
|
||
</span>` : '';
|
||
|
||
let aircraftDisplay, acType, fromDisplay, eta, pob, fuel, actionButtons, typeIcon;
|
||
|
||
if (isLocal) {
|
||
// Local flight display
|
||
if (flight.callsign && flight.callsign.trim()) {
|
||
aircraftDisplay = `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`;
|
||
} else {
|
||
aircraftDisplay = `<strong>${flight.registration}</strong>`;
|
||
}
|
||
acType = flight.type;
|
||
typeIcon = '';
|
||
fromDisplay = `<i>${flight.flight_type === 'CIRCUITS' ? 'Circuits' : flight.flight_type === 'LOCAL' ? 'Local Flight' : 'Departure'}</i>`;
|
||
|
||
// Calculate ETA: use departed_dt (actual departure) if available, otherwise etd (planned departure)
|
||
// Then add duration to get ETA
|
||
let departureTime = flight.departed_dt || flight.etd;
|
||
let etaTime = departureTime;
|
||
if (departureTime && flight.duration) {
|
||
const departTime = new Date(departureTime);
|
||
etaTime = new Date(departTime.getTime() + flight.duration * 60000).toISOString(); // duration is in minutes
|
||
}
|
||
eta = etaTime ? formatTimeOnly(etaTime) : '-';
|
||
pob = flight.pob || '-';
|
||
fuel = '-';
|
||
|
||
// For circuits, add a circuit button
|
||
let circuitButton = '';
|
||
if (flight.flight_type === 'CIRCUITS') {
|
||
circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
|
||
T&G
|
||
</button>`;
|
||
}
|
||
|
||
actionButtons = `
|
||
${circuitButton}
|
||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, true)" title="Mark as Landed">
|
||
LAND
|
||
</button>
|
||
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateLocalFlightStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel Flight">
|
||
CANCEL
|
||
</button>
|
||
`;
|
||
} else if (isBookedIn) {
|
||
// Booked-in arrival display
|
||
if (flight.callsign && flight.callsign.trim()) {
|
||
aircraftDisplay = `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`;
|
||
} else {
|
||
aircraftDisplay = `<strong>${flight.registration}</strong>`;
|
||
}
|
||
acType = flight.type;
|
||
typeIcon = '';
|
||
|
||
// Lookup airport name for in_from
|
||
let fromDisplay_temp = flight.in_from;
|
||
if (flight.in_from && flight.in_from.length === 4 && /^[A-Z]{4}$/.test(flight.in_from)) {
|
||
fromDisplay_temp = await getAirportDisplay(flight.in_from);
|
||
}
|
||
fromDisplay = fromDisplay_temp;
|
||
|
||
// Show ETA if available, otherwise show landed_dt
|
||
eta = flight.eta ? formatTimeOnly(flight.eta) : (flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-');
|
||
pob = flight.pob || '-';
|
||
fuel = '-';
|
||
actionButtons = `
|
||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentBookedInArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
|
||
LAND
|
||
</button>
|
||
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel Arrival">
|
||
CANCEL
|
||
</button>
|
||
`;
|
||
} else {
|
||
// PPR display
|
||
if (flight.ac_call && flight.ac_call.trim()) {
|
||
aircraftDisplay = `<strong>${flight.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.ac_reg}</span>`;
|
||
} else {
|
||
aircraftDisplay = `<strong>${flight.ac_reg}</strong>`;
|
||
}
|
||
acType = flight.ac_type;
|
||
typeIcon = '<span style="color: #032cfc; font-weight: bold; font-size: 0.9em;" title="From PPR">P</span>';
|
||
|
||
// Lookup airport name for in_from
|
||
let fromDisplay_temp = flight.in_from;
|
||
if (flight.in_from && flight.in_from.length === 4 && /^[A-Z]{4}$/.test(flight.in_from)) {
|
||
fromDisplay_temp = await getAirportDisplay(flight.in_from);
|
||
}
|
||
fromDisplay = fromDisplay_temp;
|
||
|
||
eta = formatTimeOnly(flight.eta);
|
||
pob = flight.pob_in;
|
||
fuel = flight.fuel || '-';
|
||
actionButtons = `
|
||
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); showTimestampModal('LANDED', ${flight.id})" title="Mark as Landed">
|
||
LAND
|
||
</button>
|
||
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateStatusFromTable(${flight.id}, 'CANCELED')" title="Cancel Arrival">
|
||
CANCEL
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
row.innerHTML = `
|
||
<td>${aircraftDisplay}${notesIndicator}</td>
|
||
<td style="text-align: center; width: 30px;">${typeIcon}</td>
|
||
<td>${acType}</td>
|
||
<td>${fromDisplay}</td>
|
||
<td>${eta}</td>
|
||
<td>${pob}</td>
|
||
<td>${fuel}</td>
|
||
<td>${actionButtons}</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
}
|
||
// Setup tooltips after rendering
|
||
setupTooltips();
|
||
}
|
||
|
||
async function displayDepartures(departures) {
|
||
const tbody = document.getElementById('departures-table-body');
|
||
const recordCount = document.getElementById('departures-count');
|
||
|
||
recordCount.textContent = departures.length;
|
||
if (departures.length === 0) {
|
||
document.getElementById('departures-no-data').style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// Sort departures by ETD (ascending), nulls last
|
||
departures.sort((a, b) => {
|
||
const aTime = a.etd || a.created_dt;
|
||
const bTime = b.etd || b.created_dt;
|
||
if (!aTime) return 1;
|
||
if (!bTime) return -1;
|
||
return new Date(aTime) - new Date(bTime);
|
||
});
|
||
tbody.innerHTML = '';
|
||
document.getElementById('departures-table-content').style.display = 'block';
|
||
for (const flight of departures) {
|
||
const row = document.createElement('tr');
|
||
const isLocal = flight.isLocalFlight;
|
||
const isDeparture = flight.isDeparture;
|
||
|
||
// Click handler that routes to correct modal
|
||
row.onclick = () => {
|
||
if (isLocal) {
|
||
openLocalFlightEditModal(flight.id);
|
||
} else if (isDeparture) {
|
||
openDepartureEditModal(flight.id);
|
||
} else {
|
||
openPPRModal(flight.id);
|
||
}
|
||
};
|
||
|
||
// Create notes indicator if notes exist
|
||
const notesIndicator = flight.notes && flight.notes.trim() ?
|
||
`<span class="notes-tooltip">
|
||
<span class="notes-indicator">📝</span>
|
||
<span class="tooltip-text">${flight.notes}</span>
|
||
</span>` : '';
|
||
|
||
let aircraftDisplay, toDisplay, etd, pob, fuel, landedDt, actionButtons, typeIcon;
|
||
|
||
if (isLocal) {
|
||
// Local flight display
|
||
if (flight.callsign && flight.callsign.trim()) {
|
||
aircraftDisplay = `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`;
|
||
} else {
|
||
aircraftDisplay = `<strong>${flight.registration}</strong>`;
|
||
}
|
||
typeIcon = '';
|
||
toDisplay = `<i>${flight.flight_type === 'CIRCUITS' ? 'Circuits' : flight.flight_type === 'LOCAL' ? 'Local Flight' : 'Departure'}</i>`;
|
||
etd = flight.etd ? formatTimeOnly(flight.etd) : (flight.created_dt ? formatTimeOnly(flight.created_dt) : '-');
|
||
pob = flight.pob || '-';
|
||
fuel = '-';
|
||
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
|
||
|
||
// Action buttons for local flight
|
||
if (flight.status === 'BOOKED_OUT') {
|
||
actionButtons = `
|
||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('DEPARTED', ${flight.id}, true)" title="Mark as Departed">
|
||
TAKE OFF
|
||
</button>
|
||
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateLocalFlightStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel Flight">
|
||
CANCEL
|
||
</button>
|
||
`;
|
||
} else if (flight.status === 'DEPARTED') {
|
||
// For circuits, add a circuit button; for other flights, just show land button
|
||
let circuitButton = '';
|
||
if (flight.flight_type === 'CIRCUITS') {
|
||
circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
|
||
T&G
|
||
</button>`;
|
||
}
|
||
actionButtons = `
|
||
${circuitButton}
|
||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, true)" title="Mark as Landed">
|
||
LAND
|
||
</button>
|
||
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateLocalFlightStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel Flight">
|
||
CANCEL
|
||
</button>
|
||
`;
|
||
} else {
|
||
actionButtons = '<span style="color: #999;">-</span>';
|
||
}
|
||
} else if (isDeparture) {
|
||
// Departure to other airport display
|
||
if (flight.callsign && flight.callsign.trim()) {
|
||
aircraftDisplay = `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`;
|
||
} else {
|
||
aircraftDisplay = `<strong>${flight.registration}</strong>`;
|
||
}
|
||
typeIcon = '';
|
||
toDisplay = flight.out_to || '-';
|
||
if (flight.out_to && flight.out_to.length === 4 && /^[A-Z]{4}$/.test(flight.out_to)) {
|
||
toDisplay = await getAirportDisplay(flight.out_to);
|
||
}
|
||
etd = flight.etd ? formatTimeOnly(flight.etd) : (flight.created_dt ? formatTimeOnly(flight.created_dt) : '-');
|
||
pob = flight.pob || '-';
|
||
fuel = '-';
|
||
landedDt = flight.departed_dt ? formatTimeOnly(flight.departed_dt) : '-';
|
||
|
||
// Action buttons for departure
|
||
if (flight.status === 'BOOKED_OUT') {
|
||
actionButtons = `
|
||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentDepartureId = ${flight.id}; showTimestampModal('DEPARTED', ${flight.id}, false, true)" title="Mark as Departed">
|
||
TAKE OFF
|
||
</button>
|
||
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateDepartureStatusFromTable(${flight.id}, 'CANCELLED')" title="Cancel">
|
||
CANCEL
|
||
</button>
|
||
`;
|
||
} else if (flight.status === 'DEPARTED') {
|
||
actionButtons = '<span style="color: #999;">Departed</span>';
|
||
} else {
|
||
actionButtons = '<span style="color: #999;">-</span>';
|
||
}
|
||
} else {
|
||
// PPR display
|
||
if (flight.ac_call && flight.ac_call.trim()) {
|
||
aircraftDisplay = `<strong>${flight.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.ac_reg}</span>`;
|
||
} else {
|
||
aircraftDisplay = `<strong>${flight.ac_reg}</strong>`;
|
||
}
|
||
typeIcon = '<span style="color: #032cfc; font-weight: bold; font-size: 0.9em;" title="From PPR">P</span>';
|
||
toDisplay = flight.out_to || '-';
|
||
if (flight.out_to && flight.out_to.length === 4 && /^[A-Z]{4}$/.test(flight.out_to)) {
|
||
toDisplay = await getAirportDisplay(flight.out_to);
|
||
}
|
||
etd = flight.etd ? formatTimeOnly(flight.etd) : '-';
|
||
pob = flight.pob_out || flight.pob_in;
|
||
fuel = flight.fuel || '-';
|
||
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
|
||
|
||
actionButtons = `
|
||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
||
TAKE OFF
|
||
</button>
|
||
<button class="btn btn-danger btn-icon" onclick="event.stopPropagation(); updateStatusFromTable(${flight.id}, 'CANCELED')" title="Cancel Departure">
|
||
CANCEL
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
row.innerHTML = `
|
||
<td>${aircraftDisplay}${notesIndicator}</td>
|
||
<td style="text-align: center; width: 30px;">${typeIcon}</td>
|
||
<td>${isLocal ? flight.type : isDeparture ? flight.type : flight.ac_type}</td>
|
||
<td>${toDisplay}</td>
|
||
<td>${etd}</td>
|
||
<td>${pob}</td>
|
||
<td>${fuel}</td>
|
||
<td>${landedDt}</td>
|
||
<td>${actionButtons}</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
}
|
||
}
|
||
|
||
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}`);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Generic function to load journal for any entity type
|
||
async function loadJournalForEntity(entityType, entityId, containerElementId) {
|
||
try {
|
||
const response = await fetch(`/api/v1/journal/${entityType}/${entityId}`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${accessToken}`
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch journal');
|
||
}
|
||
|
||
const data = await response.json();
|
||
// The new API returns { entity_type, entity_id, entries, total_entries }
|
||
displayJournalForContainer(data.entries || [], containerElementId);
|
||
} catch (error) {
|
||
console.error('Error loading journal:', error);
|
||
const container = document.getElementById(containerElementId);
|
||
if (container) container.innerHTML = 'Error loading journal entries';
|
||
}
|
||
}
|
||
|
||
// PPR-specific journal loader (backward compatible)
|
||
async function loadJournal(pprId) {
|
||
await loadJournalForEntity('PPR', pprId, 'journal-entries');
|
||
}
|
||
|
||
// Local Flight specific journal loader
|
||
async function loadLocalFlightJournal(flightId) {
|
||
await loadJournalForEntity('LOCAL_FLIGHT', flightId, 'local-flight-journal-entries');
|
||
}
|
||
|
||
// Departure specific journal loader
|
||
async function loadDepartureJournal(departureId) {
|
||
await loadJournalForEntity('DEPARTURE', departureId, 'departure-journal-entries');
|
||
}
|
||
|
||
// Arrival specific journal loader
|
||
async function loadArrivalJournal(arrivalId) {
|
||
await loadJournalForEntity('ARRIVAL', arrivalId, 'arrival-journal-entries');
|
||
}
|
||
|
||
// Display journal in a specific container
|
||
function displayJournalForContainer(entries, containerElementId) {
|
||
const container = document.getElementById(containerElementId);
|
||
if (!container) return;
|
||
|
||
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('');
|
||
}
|
||
}
|
||
|
||
function displayJournal(entries) {
|
||
displayJournalForContainer(entries, 'journal-entries');
|
||
|
||
// 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, isLocalFlight = false, isDeparture = false, isBookedIn = false) {
|
||
const targetId = pprId || (isLocalFlight ? currentLocalFlightId : (isBookedIn ? currentBookedInArrivalId : currentPPRId));
|
||
if (!targetId) return;
|
||
|
||
pendingStatusUpdate = { status: status, pprId: targetId, isLocalFlight: isLocalFlight, isDeparture: isDeparture, isBookedIn: isBookedIn };
|
||
|
||
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();
|
||
}
|
||
|
||
// Circuit modal functions
|
||
function showCircuitModal() {
|
||
if (!currentLocalFlightId) return;
|
||
|
||
// 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('circuit-timestamp').value = `${year}-${month}-${day}T${hours}:${minutes}`;
|
||
|
||
document.getElementById('circuitModal').style.display = 'block';
|
||
}
|
||
|
||
function closeCircuitModal() {
|
||
document.getElementById('circuitModal').style.display = 'none';
|
||
document.getElementById('circuit-form').reset();
|
||
}
|
||
|
||
// Circuit form submission
|
||
document.getElementById('circuit-form').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
|
||
if (!currentLocalFlightId || !accessToken) return;
|
||
|
||
const circuitTimestampInput = document.getElementById('circuit-timestamp').value;
|
||
if (!circuitTimestampInput) {
|
||
showNotification('Please select a circuit time', true);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Convert local datetime to UTC ISO format
|
||
const localDate = new Date(circuitTimestampInput);
|
||
const circuitTimestamp = localDate.toISOString();
|
||
|
||
const response = await authenticatedFetch('/api/v1/circuits/', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
local_flight_id: currentLocalFlightId,
|
||
circuit_timestamp: circuitTimestamp
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to record circuit');
|
||
}
|
||
|
||
const circuit = await response.json();
|
||
showNotification(`✈️ Circuit recorded at ${formatTimeOnly(circuit.circuit_timestamp)}`);
|
||
closeCircuitModal();
|
||
|
||
// Refresh departures to show updated circuit count
|
||
loadDepartures();
|
||
} catch (error) {
|
||
console.error('Error recording circuit:', error);
|
||
showNotification('Error recording circuit: ' + error.message, true);
|
||
}
|
||
});
|
||
|
||
// 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 {
|
||
// Determine the correct API endpoint based on flight type
|
||
const isLocal = pendingStatusUpdate.isLocalFlight;
|
||
const isDeparture = pendingStatusUpdate.isDeparture;
|
||
const isBookedIn = pendingStatusUpdate.isBookedIn;
|
||
let endpoint;
|
||
|
||
if (isLocal) {
|
||
endpoint = `/api/v1/local-flights/${pendingStatusUpdate.pprId}/status`;
|
||
} else if (isDeparture) {
|
||
endpoint = `/api/v1/departures/${pendingStatusUpdate.pprId}/status`;
|
||
} else if (isBookedIn) {
|
||
endpoint = `/api/v1/arrivals/${pendingStatusUpdate.pprId}/status`;
|
||
} else {
|
||
endpoint = `/api/v1/pprs/${pendingStatusUpdate.pprId}/status`;
|
||
}
|
||
|
||
const response = await fetch(endpoint, {
|
||
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();
|
||
// Refresh appropriate table based on flight type
|
||
if (isBookedIn) {
|
||
loadArrivals(); // Refresh arrivals table
|
||
} else {
|
||
loadPPRs(); // Refresh all tables (PPR, local, departures)
|
||
}
|
||
showNotification(`Status updated to ${updatedStatus}`);
|
||
if (!isLocal && !isBookedIn) {
|
||
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');
|
||
const tableHelpModal = document.getElementById('tableHelpModal');
|
||
const bookInModal = document.getElementById('bookInModal');
|
||
|
||
if (event.target === pprModal) {
|
||
closePPRModal();
|
||
}
|
||
if (event.target === timestampModal) {
|
||
closeTimestampModal();
|
||
}
|
||
if (event.target === userManagementModal) {
|
||
closeUserManagementModal();
|
||
}
|
||
if (event.target === userModal) {
|
||
closeUserModal();
|
||
}
|
||
if (event.target === tableHelpModal) {
|
||
closeTableHelp();
|
||
}
|
||
if (event.target === bookInModal) {
|
||
closeBookInModal();
|
||
}
|
||
}
|
||
|
||
function clearArrivalAirportLookup() {
|
||
document.getElementById('arrival-airport-lookup-results').innerHTML = '';
|
||
}
|
||
|
||
function clearDepartureAirportLookup() {
|
||
document.getElementById('departure-airport-lookup-results').innerHTML = '';
|
||
}
|
||
|
||
// Add listener for ETD date changes
|
||
document.addEventListener('change', function(e) {
|
||
if (e.target.id === 'local_etd_date') {
|
||
populateETDTimeSlots();
|
||
}
|
||
});
|
||
|
||
// Local Flight (Book Out) Modal Functions
|
||
function openLocalFlightModal(flightType = 'LOCAL') {
|
||
document.getElementById('local-flight-form').reset();
|
||
document.getElementById('local-flight-id').value = '';
|
||
document.getElementById('local-flight-modal-title').textContent = 'Book Out';
|
||
document.getElementById('local_flight_type').value = flightType;
|
||
document.getElementById('localFlightModal').style.display = 'block';
|
||
|
||
// Clear aircraft lookup results
|
||
clearLocalAircraftLookup();
|
||
|
||
// Update destination field visibility based on flight type
|
||
handleFlightTypeChange(flightType);
|
||
|
||
// Auto-focus on registration field
|
||
setTimeout(() => {
|
||
document.getElementById('local_registration').focus();
|
||
}, 100);
|
||
}
|
||
|
||
function closeLocalFlightModal() {
|
||
document.getElementById('localFlightModal').style.display = 'none';
|
||
}
|
||
|
||
function openBookInModal() {
|
||
document.getElementById('book-in-form').reset();
|
||
document.getElementById('book-in-id').value = '';
|
||
document.getElementById('bookInModal').style.display = 'block';
|
||
|
||
// Clear aircraft lookup results
|
||
clearBookInAircraftLookup();
|
||
clearBookInArrivalAirportLookup();
|
||
|
||
// Populate ETA time slots
|
||
populateETATimeSlots();
|
||
|
||
// Auto-focus on registration field
|
||
setTimeout(() => {
|
||
document.getElementById('book_in_registration').focus();
|
||
}, 100);
|
||
}
|
||
|
||
function closeBookInModal() {
|
||
document.getElementById('bookInModal').style.display = 'none';
|
||
}
|
||
|
||
function populateETATimeSlots() {
|
||
const select = document.getElementById('book_in_eta_time');
|
||
const next15MinSlot = getNext10MinuteSlot();
|
||
|
||
select.innerHTML = '';
|
||
|
||
for (let i = 0; i < 14; i++) {
|
||
const time = new Date(next15MinSlot.getTime() + i * 10 * 60 * 1000);
|
||
const hours = time.getHours().toString().padStart(2, '0');
|
||
const minutes = time.getMinutes().toString().padStart(2, '0');
|
||
const timeStr = `${hours}:${minutes}`;
|
||
|
||
const option = document.createElement('option');
|
||
option.value = timeStr;
|
||
option.textContent = timeStr;
|
||
if (i === 0) {
|
||
option.selected = true;
|
||
}
|
||
select.appendChild(option);
|
||
}
|
||
}
|
||
|
||
// Handle flight type change to show/hide destination field
|
||
function handleFlightTypeChange(flightType) {
|
||
const destGroup = document.getElementById('departure-destination-group');
|
||
const destInput = document.getElementById('local_out_to');
|
||
const destLabel = document.getElementById('departure-destination-label');
|
||
|
||
if (flightType === 'DEPARTURE') {
|
||
destGroup.style.display = 'block';
|
||
destInput.required = true;
|
||
destLabel.textContent = 'Destination Airport *';
|
||
} else {
|
||
destGroup.style.display = 'none';
|
||
destInput.required = false;
|
||
destInput.value = '';
|
||
destLabel.textContent = 'Destination Airport';
|
||
}
|
||
|
||
// Populate ETD time slots for all flight types
|
||
populateETDTimeSlots();
|
||
}
|
||
|
||
function getNext10MinuteSlot() {
|
||
const now = new Date();
|
||
const minutes = now.getMinutes();
|
||
const remainder = minutes % 10;
|
||
|
||
const next = new Date(now);
|
||
if (remainder === 0) {
|
||
// Already on a 10-minute boundary, use this time
|
||
next.setSeconds(0);
|
||
} else {
|
||
// Round up to next 10-minute boundary
|
||
next.setMinutes(minutes + (10 - remainder));
|
||
next.setSeconds(0);
|
||
}
|
||
|
||
return next;
|
||
}
|
||
|
||
function populateETDTimeSlots() {
|
||
const timeSelect = document.getElementById('local_etd_time');
|
||
|
||
// Clear and repopulate
|
||
timeSelect.innerHTML = '<option value="">Select Time</option>';
|
||
|
||
// Get the next 10-minute slot
|
||
const defaultTime = getNext10MinuteSlot();
|
||
const defaultHour = defaultTime.getHours();
|
||
const defaultMinute = defaultTime.getMinutes();
|
||
|
||
// Generate 10-minute slots from next slot to 22:00
|
||
let currentHour = defaultHour;
|
||
let currentMinute = defaultMinute;
|
||
|
||
// Generate 10-minute slots from start time to 22:00
|
||
while (currentHour < 24) {
|
||
const timeStr = `${String(currentHour).padStart(2, '0')}:${String(currentMinute).padStart(2, '0')}`;
|
||
const option = document.createElement('option');
|
||
option.value = timeStr;
|
||
option.textContent = timeStr;
|
||
|
||
// Auto-select the next 10-minute slot
|
||
if (currentHour === defaultHour && currentMinute === defaultMinute) {
|
||
option.selected = true;
|
||
}
|
||
|
||
timeSelect.appendChild(option);
|
||
|
||
// Move to next 10-minute interval
|
||
currentMinute += 10;
|
||
if (currentMinute >= 60) {
|
||
currentMinute -= 60;
|
||
currentHour += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
function clearLocalAircraftLookup() {
|
||
document.getElementById('local-aircraft-lookup-results').innerHTML = '';
|
||
}
|
||
|
||
// Local Flight Edit Modal Functions
|
||
let currentLocalFlightId = null;
|
||
let currentBookedInArrivalId = null;
|
||
|
||
async function openLocalFlightEditModal(flightId) {
|
||
if (!accessToken) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/local-flights/${flightId}`, {
|
||
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Failed to load flight');
|
||
|
||
const flight = await response.json();
|
||
currentLocalFlightId = flight.id;
|
||
|
||
// Populate form
|
||
document.getElementById('local-edit-flight-id').value = flight.id;
|
||
document.getElementById('local_edit_registration').value = flight.registration;
|
||
document.getElementById('local_edit_type').value = flight.type;
|
||
document.getElementById('local_edit_callsign').value = flight.callsign || '';
|
||
document.getElementById('local_edit_pob').value = flight.pob;
|
||
document.getElementById('local_edit_flight_type').value = flight.flight_type;
|
||
document.getElementById('local_edit_duration').value = flight.duration || 45;
|
||
document.getElementById('local_edit_notes').value = flight.notes || '';
|
||
|
||
// Parse and populate departure time if exists
|
||
// Use departed_dt (actual departure) if available, otherwise etd (planned departure)
|
||
const departureTime = flight.departed_dt || flight.etd;
|
||
if (departureTime) {
|
||
const dept = new Date(departureTime);
|
||
document.getElementById('local_edit_departure_date').value = dept.toISOString().slice(0, 10);
|
||
document.getElementById('local_edit_departure_time').value = dept.toISOString().slice(11, 16);
|
||
}
|
||
|
||
// Show/hide action buttons based on status
|
||
const deptBtn = document.getElementById('local-btn-departed');
|
||
const landBtn = document.getElementById('local-btn-landed');
|
||
const cancelBtn = document.getElementById('local-btn-cancel');
|
||
|
||
deptBtn.style.display = flight.status === 'BOOKED_OUT' ? 'inline-block' : 'none';
|
||
landBtn.style.display = flight.status === 'DEPARTED' ? 'inline-block' : 'none';
|
||
cancelBtn.style.display = (flight.status === 'BOOKED_OUT' || flight.status === 'DEPARTED') ? 'inline-block' : 'none';
|
||
|
||
document.getElementById('local-flight-edit-title').textContent = `${flight.registration} - ${flight.flight_type}`;
|
||
|
||
// Load and display circuits if this is a CIRCUITS flight
|
||
const circuitsSection = document.getElementById('circuits-section');
|
||
if (flight.flight_type === 'CIRCUITS') {
|
||
circuitsSection.style.display = 'block';
|
||
loadCircuitsDisplay(flight.id);
|
||
} else {
|
||
circuitsSection.style.display = 'none';
|
||
}
|
||
|
||
document.getElementById('localFlightEditModal').style.display = 'block';
|
||
|
||
// Load journal for this local flight
|
||
await loadLocalFlightJournal(flightId);
|
||
} catch (error) {
|
||
console.error('Error loading flight:', error);
|
||
showNotification('Error loading flight details', true);
|
||
}
|
||
}
|
||
|
||
async function loadCircuitsDisplay(localFlightId) {
|
||
try {
|
||
const response = await authenticatedFetch(`/api/v1/circuits/flight/${localFlightId}`);
|
||
if (!response.ok) throw new Error('Failed to load circuits');
|
||
|
||
const circuits = await response.json();
|
||
displayCircuitsList(circuits);
|
||
} catch (error) {
|
||
console.error('Error loading circuits:', error);
|
||
document.getElementById('circuits-list').innerHTML = '<p style="color: #d32f2f;">Error loading circuits</p>';
|
||
}
|
||
}
|
||
|
||
function displayCircuitsList(circuits) {
|
||
const circuitsList = document.getElementById('circuits-list');
|
||
|
||
if (circuits.length === 0) {
|
||
circuitsList.innerHTML = '<p style="color: #666; font-style: italic;">No touch & go records yet</p>';
|
||
return;
|
||
}
|
||
|
||
let html = `<p style="color: #333; font-weight: bold;">Total circuits: ${circuits.length}</p>`;
|
||
html += '<div style="background-color: #f5f5f5; border-radius: 4px; padding: 1rem; margin-top: 0.5rem;">';
|
||
|
||
circuits.forEach((circuit, index) => {
|
||
const time = formatTimeOnly(circuit.circuit_timestamp);
|
||
html += `<div style="padding: 0.5rem 0; border-bottom: 1px solid #ddd;">Circuit ${index + 1}: <strong>${time}</strong></div>`;
|
||
});
|
||
|
||
html += '</div>';
|
||
circuitsList.innerHTML = html;
|
||
}
|
||
|
||
function closeLocalFlightEditModal() {
|
||
document.getElementById('localFlightEditModal').style.display = 'none';
|
||
currentLocalFlightId = null;
|
||
}
|
||
|
||
// Open departure edit modal
|
||
async function openDepartureEditModal(departureId) {
|
||
if (!accessToken) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/departures/${departureId}`, {
|
||
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Failed to load departure');
|
||
|
||
const departure = await response.json();
|
||
currentDepartureId = departure.id;
|
||
|
||
// Populate form
|
||
document.getElementById('departure-edit-id').value = departure.id;
|
||
document.getElementById('departure_edit_registration').value = departure.registration;
|
||
document.getElementById('departure_edit_type').value = departure.type;
|
||
document.getElementById('departure_edit_callsign').value = departure.callsign || '';
|
||
document.getElementById('departure_edit_out_to').value = departure.out_to;
|
||
document.getElementById('departure_edit_notes').value = departure.notes || '';
|
||
|
||
// Parse and populate ETD if exists
|
||
if (departure.etd) {
|
||
const etd = new Date(departure.etd);
|
||
document.getElementById('departure_edit_etd_date').value = etd.toISOString().slice(0, 10);
|
||
document.getElementById('departure_edit_etd_time').value = etd.toISOString().slice(11, 16);
|
||
}
|
||
|
||
// Show/hide action buttons based on status
|
||
const deptBtn = document.getElementById('departure-btn-departed');
|
||
const cancelBtn = document.getElementById('departure-btn-cancel');
|
||
|
||
if (deptBtn) deptBtn.style.display = departure.status === 'BOOKED_OUT' ? 'inline-block' : 'none';
|
||
if (cancelBtn) cancelBtn.style.display = departure.status === 'BOOKED_OUT' ? 'inline-block' : 'none';
|
||
|
||
document.getElementById('departure-edit-title').textContent = `${departure.registration} to ${departure.out_to}`;
|
||
document.getElementById('departureEditModal').style.display = 'block';
|
||
|
||
// Load journal for this departure
|
||
await loadDepartureJournal(departureId);
|
||
} catch (error) {
|
||
console.error('Error loading departure:', error);
|
||
showNotification('Error loading departure details', true);
|
||
}
|
||
}
|
||
|
||
function closeDepartureEditModal() {
|
||
document.getElementById('departureEditModal').style.display = 'none';
|
||
currentDepartureId = null;
|
||
}
|
||
|
||
// Departure edit form submission
|
||
document.getElementById('departure-edit-form').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
|
||
if (!currentDepartureId || !accessToken) return;
|
||
|
||
const formData = new FormData(this);
|
||
const updateData = {};
|
||
|
||
formData.forEach((value, key) => {
|
||
if (key === 'id') return;
|
||
|
||
// Handle date/time combination for ETD
|
||
if (key === 'etd_date' || key === 'etd_time') {
|
||
if (!updateData.etd && formData.get('etd_date') && formData.get('etd_time')) {
|
||
const dateStr = formData.get('etd_date');
|
||
const timeStr = formData.get('etd_time');
|
||
updateData.etd = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Only include non-empty values
|
||
if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) {
|
||
if (value.trim) {
|
||
updateData[key] = value.trim();
|
||
} else {
|
||
updateData[key] = value;
|
||
}
|
||
}
|
||
});
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/departures/${currentDepartureId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify(updateData)
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Failed to update departure');
|
||
|
||
closeDepartureEditModal();
|
||
loadPPRs(); // Refresh departures display
|
||
showNotification('Departure updated successfully');
|
||
} catch (error) {
|
||
console.error('Error updating departure:', error);
|
||
showNotification('Error updating departure', true);
|
||
}
|
||
});
|
||
|
||
// Arrival Edit Modal Functions
|
||
let currentArrivalId = null;
|
||
|
||
async function openArrivalEditModal(arrivalId) {
|
||
if (!accessToken) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/arrivals/${arrivalId}`, {
|
||
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Failed to load arrival');
|
||
|
||
const arrival = await response.json();
|
||
currentArrivalId = arrival.id;
|
||
|
||
// Populate form
|
||
document.getElementById('arrival-edit-id').value = arrival.id;
|
||
document.getElementById('arrival_edit_registration').value = arrival.registration || '';
|
||
document.getElementById('arrival_edit_type').value = arrival.type || '';
|
||
document.getElementById('arrival_edit_callsign').value = arrival.callsign || '';
|
||
document.getElementById('arrival_edit_in_from').value = arrival.in_from || '';
|
||
document.getElementById('arrival_edit_pob').value = arrival.pob || '';
|
||
document.getElementById('arrival_edit_notes').value = arrival.notes || '';
|
||
|
||
// Update title
|
||
const regOrCallsign = arrival.callsign || arrival.registration;
|
||
document.getElementById('arrival-edit-title').textContent = `Arrival: ${regOrCallsign}`;
|
||
|
||
// Show/hide quick action buttons based on status
|
||
const landedBtn = document.getElementById('arrival-btn-landed');
|
||
const cancelBtn = document.getElementById('arrival-btn-cancel');
|
||
landedBtn.style.display = arrival.status === 'BOOKED_IN' ? 'block' : 'none';
|
||
cancelBtn.style.display = arrival.status === 'BOOKED_IN' ? 'block' : 'none';
|
||
|
||
// Show modal
|
||
document.getElementById('arrivalEditModal').style.display = 'block';
|
||
|
||
// Load journal for this arrival
|
||
await loadArrivalJournal(arrivalId);
|
||
} catch (error) {
|
||
console.error('Error loading arrival:', error);
|
||
showNotification('Error loading arrival details', true);
|
||
}
|
||
}
|
||
|
||
function closeArrivalEditModal() {
|
||
document.getElementById('arrivalEditModal').style.display = 'none';
|
||
currentArrivalId = null;
|
||
}
|
||
|
||
// Arrival edit form submission
|
||
document.getElementById('arrival-edit-form').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
|
||
if (!currentArrivalId || !accessToken) return;
|
||
|
||
const formData = new FormData(this);
|
||
const updateData = {};
|
||
|
||
formData.forEach((value, key) => {
|
||
if (key === 'id') return;
|
||
|
||
// Only include non-empty values
|
||
if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) {
|
||
if (key === 'pob') {
|
||
updateData[key] = parseInt(value);
|
||
} else if (value.trim) {
|
||
updateData[key] = value.trim();
|
||
} else {
|
||
updateData[key] = value;
|
||
}
|
||
}
|
||
});
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/arrivals/${currentArrivalId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify(updateData)
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Failed to update arrival');
|
||
|
||
closeArrivalEditModal();
|
||
loadPPRs(); // Refresh arrivals display
|
||
showNotification('Arrival updated successfully');
|
||
} catch (error) {
|
||
console.error('Error updating arrival:', error);
|
||
showNotification('Error updating arrival', true);
|
||
}
|
||
});
|
||
|
||
async function updateArrivalStatus(status) {
|
||
if (!currentArrivalId || !accessToken) return;
|
||
|
||
if (status === 'CANCELLED') {
|
||
if (!confirm('Are you sure you want to cancel this arrival?')) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/arrivals/${currentArrivalId}/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 arrival status');
|
||
|
||
closeArrivalEditModal();
|
||
loadPPRs(); // Refresh arrivals display
|
||
showNotification(`Arrival marked as ${status.toLowerCase()}`);
|
||
} catch (error) {
|
||
console.error('Error updating arrival status:', error);
|
||
showNotification('Error updating arrival status', true);
|
||
}
|
||
}
|
||
|
||
// Update status from table buttons (with flight ID passed)
|
||
async function updateLocalFlightStatusFromTable(flightId, status) {
|
||
if (!accessToken) return;
|
||
|
||
// Show confirmation for cancel actions
|
||
if (status === 'CANCELLED') {
|
||
if (!confirm('Are you sure you want to cancel this flight? This action cannot be easily undone.')) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/local-flights/${flightId}/status`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify({ status: status })
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Failed to update status');
|
||
|
||
loadPPRs(); // Refresh display
|
||
showNotification(`Flight marked as ${status.toLowerCase()}`);
|
||
} catch (error) {
|
||
console.error('Error updating status:', error);
|
||
showNotification('Error updating flight status', true);
|
||
}
|
||
}
|
||
|
||
// Update status from table for departures
|
||
async function updateDepartureStatusFromTable(departureId, status) {
|
||
if (!accessToken) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/departures/${departureId}/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');
|
||
|
||
loadPPRs(); // Refresh display
|
||
showNotification(`Departure marked as ${status.toLowerCase()}`);
|
||
} catch (error) {
|
||
console.error('Error updating status:', error);
|
||
showNotification('Error updating departure status', true);
|
||
}
|
||
}
|
||
|
||
// Update status from table for booked-in arrivals
|
||
async function updateArrivalStatusFromTable(arrivalId, status) {
|
||
if (!accessToken) return;
|
||
|
||
// Show confirmation for cancel actions
|
||
if (status === 'CANCELLED') {
|
||
if (!confirm('Are you sure you want to cancel this arrival? This action cannot be easily undone.')) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/arrivals/${arrivalId}/status`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify({ status: status })
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Failed to update status');
|
||
|
||
loadArrivals(); // Refresh arrivals table
|
||
showNotification(`Arrival marked as ${status.toLowerCase()}`);
|
||
} catch (error) {
|
||
console.error('Error updating status:', error);
|
||
showNotification('Error updating arrival status', true);
|
||
}
|
||
}
|
||
|
||
// Update status from modal (uses currentLocalFlightId)
|
||
async function updateLocalFlightStatus(status) {
|
||
if (!currentLocalFlightId || !accessToken) return;
|
||
|
||
// Show confirmation for cancel actions
|
||
if (status === 'CANCELLED') {
|
||
if (!confirm('Are you sure you want to cancel this flight? This action cannot be easily undone.')) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/local-flights/${currentLocalFlightId}/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');
|
||
|
||
closeLocalFlightEditModal();
|
||
loadPPRs(); // Refresh display
|
||
showNotification(`Flight marked as ${status.toLowerCase()}`);
|
||
} catch (error) {
|
||
console.error('Error updating status:', error);
|
||
showNotification('Error updating flight status', true);
|
||
}
|
||
}
|
||
|
||
async function updateDepartureStatus(status) {
|
||
if (!currentDepartureId || !accessToken) return;
|
||
|
||
// Show confirmation for cancel actions
|
||
if (status === 'CANCELLED') {
|
||
if (!confirm('Are you sure you want to cancel this departure? This action cannot be easily undone.')) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/departures/${currentDepartureId}/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');
|
||
|
||
closeDepartureEditModal();
|
||
loadPPRs(); // Refresh display
|
||
showNotification(`Departure marked as ${status.toLowerCase()}`);
|
||
} catch (error) {
|
||
console.error('Error updating status:', error);
|
||
showNotification('Error updating departure status', true);
|
||
}
|
||
}
|
||
|
||
// Table help modal functions
|
||
const tableHelpTexts = {
|
||
arrivals: {
|
||
title: "Today's Pending Arrivals",
|
||
text: "Displays aircraft that are expected to arrive at Swansea today. These are flights that have filed PPRs or have been booked in as arriving. Aircraft in this list are actively planning to land today."
|
||
},
|
||
departures: {
|
||
title: "Today's Pending Departures",
|
||
text: "Displays aircraft that are ready to depart from Swansea today. This includes flights with approved PPRs awaiting departure, local flights that have been booked out, and departures to other airfields. These aircraft will depart today."
|
||
},
|
||
departed: {
|
||
title: "Departed Today",
|
||
text: "Displays the flights which have departed today to other airfields, either by way of a PPR or by booking out."
|
||
},
|
||
parked: {
|
||
title: "Parked Visitors",
|
||
text: "Displays visiting aircraft that are currently parked at Swansea airport and are NOT expected to depart today. The ETD column shows the day the aircraft intends to depart."
|
||
}
|
||
};
|
||
|
||
function showTableHelp(tableType) {
|
||
const helpData = tableHelpTexts[tableType];
|
||
if (!helpData) return;
|
||
|
||
const modal = document.getElementById('tableHelpModal');
|
||
const content = document.getElementById('tableHelpContent');
|
||
const header = modal.querySelector('.modal-header h2');
|
||
|
||
header.textContent = helpData.title;
|
||
content.innerHTML = `<p>${helpData.text}</p>`;
|
||
modal.style.display = 'block';
|
||
}
|
||
|
||
function closeTableHelp() {
|
||
document.getElementById('tableHelpModal').style.display = 'none';
|
||
}
|
||
|
||
// Local flight edit form submission
|
||
document.getElementById('local-flight-edit-form').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
|
||
if (!currentLocalFlightId || !accessToken) return;
|
||
|
||
const formData = new FormData(this);
|
||
const updateData = {};
|
||
|
||
formData.forEach((value, key) => {
|
||
if (key === 'id') return;
|
||
|
||
// Handle date/time combination for departure
|
||
if (key === 'departure_date' || key === 'departure_time') {
|
||
if (!updateData.departure_dt && formData.get('departure_date') && formData.get('departure_time')) {
|
||
const dateStr = formData.get('departure_date');
|
||
const timeStr = formData.get('departure_time');
|
||
updateData.departure_dt = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Only include non-empty values
|
||
if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) {
|
||
if (key === 'pob' || key === 'duration') {
|
||
updateData[key] = parseInt(value);
|
||
} else if (value.trim) {
|
||
updateData[key] = value.trim();
|
||
} else {
|
||
updateData[key] = value;
|
||
}
|
||
}
|
||
});
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/local-flights/${currentLocalFlightId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify(updateData)
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Failed to update flight');
|
||
|
||
closeLocalFlightEditModal();
|
||
loadPPRs(); // Refresh display
|
||
showNotification('Flight updated successfully');
|
||
} catch (error) {
|
||
console.error('Error updating flight:', error);
|
||
showNotification('Error updating flight', true);
|
||
}
|
||
});
|
||
|
||
// Add event listener for local flight form submission
|
||
document.getElementById('local-flight-form').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
|
||
if (!accessToken) return;
|
||
|
||
const formData = new FormData(this);
|
||
const flightType = formData.get('flight_type');
|
||
const flightData = {};
|
||
let endpoint = '/api/v1/local-flights/';
|
||
|
||
formData.forEach((value, key) => {
|
||
// Skip the hidden id field and empty values
|
||
if (key === 'id') return;
|
||
|
||
// Handle time-only ETD (always today)
|
||
if (key === 'etd_time') {
|
||
if (value.trim()) {
|
||
const today = new Date();
|
||
const year = today.getFullYear();
|
||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||
const day = String(today.getDate()).padStart(2, '0');
|
||
const dateStr = `${year}-${month}-${day}`;
|
||
flightData.etd = new Date(`${dateStr}T${value}`).toISOString();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Only include non-empty values
|
||
if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) {
|
||
if (key === 'pob' || key === 'duration') {
|
||
flightData[key] = parseInt(value);
|
||
} else if (value.trim) {
|
||
flightData[key] = value.trim();
|
||
} else {
|
||
flightData[key] = value;
|
||
}
|
||
}
|
||
});
|
||
|
||
// If DEPARTURE flight type, use departures endpoint instead
|
||
if (flightType === 'DEPARTURE') {
|
||
endpoint = '/api/v1/departures/';
|
||
// Remove flight_type from data and use out_to instead
|
||
delete flightData.flight_type;
|
||
}
|
||
|
||
console.log(`Submitting ${endpoint} data:`, flightData);
|
||
|
||
try {
|
||
const response = await fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify(flightData)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
let errorMessage = 'Failed to book out flight';
|
||
try {
|
||
const errorData = await response.json();
|
||
if (errorData.detail) {
|
||
errorMessage = typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail);
|
||
} else if (errorData.errors) {
|
||
errorMessage = errorData.errors.map(e => e.msg).join(', ');
|
||
}
|
||
} catch (e) {
|
||
const text = await response.text();
|
||
console.error('Server response:', text);
|
||
errorMessage = `Server error (${response.status})`;
|
||
}
|
||
throw new Error(errorMessage);
|
||
}
|
||
|
||
const result = await response.json();
|
||
closeLocalFlightModal();
|
||
loadPPRs(); // Refresh tables
|
||
showNotification(`Aircraft ${result.registration} booked out successfully!`);
|
||
} catch (error) {
|
||
console.error('Error booking out flight:', error);
|
||
showNotification(`Error: ${error.message}`, true);
|
||
}
|
||
});
|
||
|
||
document.getElementById('book-in-form').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
|
||
if (!accessToken) return;
|
||
|
||
const formData = new FormData(this);
|
||
const arrivalData = {};
|
||
|
||
formData.forEach((value, key) => {
|
||
// Skip the hidden id field and empty values
|
||
if (key === 'id') return;
|
||
|
||
// Handle time-only ETA (always today)
|
||
if (key === 'eta_time') {
|
||
if (value.trim()) {
|
||
const today = new Date();
|
||
const year = today.getFullYear();
|
||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||
const day = String(today.getDate()).padStart(2, '0');
|
||
const dateStr = `${year}-${month}-${day}`;
|
||
// Store ETA in the eta field
|
||
arrivalData.eta = new Date(`${dateStr}T${value}`).toISOString();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Only include non-empty values
|
||
if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) {
|
||
if (key === 'pob') {
|
||
arrivalData[key] = parseInt(value);
|
||
} else if (value.trim) {
|
||
arrivalData[key] = value.trim();
|
||
} else {
|
||
arrivalData[key] = value;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Book In uses LANDED status (they're arriving now)
|
||
arrivalData.status = 'LANDED';
|
||
|
||
console.log('Submitting arrivals data:', arrivalData);
|
||
|
||
try {
|
||
const response = await fetch('/api/v1/arrivals/', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify(arrivalData)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
let errorMessage = 'Failed to book in arrival';
|
||
try {
|
||
const errorData = await response.json();
|
||
if (errorData.detail) {
|
||
errorMessage = typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail);
|
||
} else if (errorData.errors) {
|
||
errorMessage = errorData.errors.map(e => e.msg).join(', ');
|
||
}
|
||
} catch (e) {
|
||
const text = await response.text();
|
||
console.error('Server response:', text);
|
||
errorMessage = `Server error (${response.status})`;
|
||
}
|
||
throw new Error(errorMessage);
|
||
}
|
||
|
||
const result = await response.json();
|
||
closeBookInModal();
|
||
loadPPRs(); // Refresh tables
|
||
showNotification(`Aircraft ${result.registration} booked in successfully!`);
|
||
} catch (error) {
|
||
console.error('Error booking in arrival:', error);
|
||
showNotification(`Error: ${error.message}`, true);
|
||
}
|
||
});
|
||
|
||
// 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> |