3049 lines
137 KiB
HTML
3049 lines
137 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-primary" onclick="window.open('reports.html', '_blank')">
|
||
📊 Reports
|
||
</button>
|
||
<button class="btn btn-warning" onclick="openUserManagementModal()" id="user-management-btn" style="display: none;">
|
||
👥 User Management
|
||
</button>
|
||
<button class="btn btn-primary" onclick="loadPPRs()">
|
||
🔄 Refresh
|
||
</button>
|
||
</div>
|
||
<div class="user-info">
|
||
Logged in as: <span id="current-user">Loading...</span> |
|
||
<a href="#" onclick="logout()" style="color: white;">Logout</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container">
|
||
|
||
<!-- Arrivals Table -->
|
||
<div class="ppr-table">
|
||
<div class="table-header">
|
||
🛬 Today's Pending Arrivals - <span id="arrivals-count">0</span>
|
||
</div>
|
||
|
||
<div id="arrivals-loading" class="loading">
|
||
<div class="spinner"></div>
|
||
Loading arrivals...
|
||
</div>
|
||
|
||
<div id="arrivals-table-content" style="display: none;">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Registration</th>
|
||
<th>Type</th>
|
||
<th>From</th>
|
||
<th>ETA</th>
|
||
<th>POB</th>
|
||
<th>Fuel</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="arrivals-table-body">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div id="arrivals-no-data" class="no-data" style="display: none;">
|
||
<h3>No Pending Arrivals</h3>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Departures Table -->
|
||
<div class="ppr-table" style="margin-top: 2rem;">
|
||
<div class="table-header">
|
||
🛫 Today's Pending Departures - <span id="departures-count">0</span>
|
||
</div>
|
||
|
||
<div id="departures-loading" class="loading">
|
||
<div class="spinner"></div>
|
||
Loading departures...
|
||
</div>
|
||
|
||
<div id="departures-table-content" style="display: none;">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Registration</th>
|
||
<th>Type</th>
|
||
<th>To</th>
|
||
<th>ETD</th>
|
||
<th>POB</th>
|
||
<th>Fuel</th>
|
||
<th>Landed</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="departures-table-body">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div id="departures-no-data" class="no-data" style="display: none;">
|
||
<h3>No Pending Departures</h3>
|
||
<p>No aircraft currently landed and ready to depart.</p>
|
||
</div>
|
||
</div>
|
||
<br>
|
||
<!-- Departed and Parked Tables -->
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem;">
|
||
<!-- Departed Today -->
|
||
<div class="ppr-table">
|
||
<div class="table-header" style="padding: 0.3rem 0.5rem; font-size: 0.85rem;">
|
||
✈️ Departed Today - <span id="departed-count">0</span>
|
||
</div>
|
||
|
||
<div id="departed-loading" class="loading" style="display: none;">
|
||
<div class="spinner"></div>
|
||
Loading departed aircraft...
|
||
</div>
|
||
|
||
<div id="departed-table-content" style="display: none;">
|
||
<table>
|
||
<thead>
|
||
<tr style="font-size: 0.85rem !important;">
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Registration</th>
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Callsign</th>
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Destination</th>
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Departed</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="departed-table-body">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div id="departed-no-data" class="no-data" style="display: none; padding: 1rem; font-size: 0.9rem;">
|
||
<p>No departures today.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Parked Visitors -->
|
||
<div class="ppr-table">
|
||
<div class="table-header" style="padding: 0.3rem 0.5rem; font-size: 0.85rem;">
|
||
🅿️ Parked Visitors - <span id="parked-count">0</span>
|
||
</div>
|
||
|
||
<div id="parked-loading" class="loading" style="display: none;">
|
||
<div class="spinner"></div>
|
||
Loading parked visitors...
|
||
</div>
|
||
|
||
<div id="parked-table-content" style="display: none;">
|
||
<table>
|
||
<thead>
|
||
<tr style="font-size: 0.85rem !important;">
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Registration</th>
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Type</th>
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">From</th>
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Arrived</th>
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">ETD</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="parked-table-body">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div id="parked-no-data" class="no-data" style="display: none; padding: 1rem; font-size: 0.9rem;">
|
||
<p>No parked visitors.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Upcoming PPRs (Future Days) -->
|
||
<div class="ppr-table" style="margin-top: 1rem;">
|
||
<div class="table-header-collapsible" style="padding: 0.3rem 0.5rem; font-size: 0.85rem;" onclick="toggleUpcomingTable()">
|
||
<span>📅 Future PPRs - <span id="upcoming-count">0</span></span>
|
||
<span class="collapse-icon collapsed" id="upcoming-collapse-icon">▼</span>
|
||
</div>
|
||
|
||
<div id="upcoming-loading" class="loading" style="display: none;">
|
||
<div class="spinner"></div>
|
||
Loading upcoming PPRs...
|
||
</div>
|
||
|
||
<div id="upcoming-table-content" style="display: none;">
|
||
<table>
|
||
<thead>
|
||
<tr style="font-size: 0.85rem !important;">
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Date</th>
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Registration</th>
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Type</th>
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">From</th>
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">ETA</th>
|
||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Notes</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="upcoming-table-body">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div id="upcoming-no-data" class="no-data" style="display: none; padding: 1rem; font-size: 0.9rem;">
|
||
<p>No upcoming PPRs.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Login Modal -->
|
||
<div id="loginModal" class="modal" style="display: none;">
|
||
<div class="modal-content" style="max-width: 400px;">
|
||
<div class="modal-header">
|
||
<h2>PPR Admin Login</h2>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="login-form">
|
||
<div class="form-group">
|
||
<label for="login-username">Username:</label>
|
||
<input type="text" id="login-username" name="username" required autofocus>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="login-password">Password:</label>
|
||
<input type="password" id="login-password" name="password" required>
|
||
</div>
|
||
<div id="login-error" style="color: #dc3545; margin: 1rem 0; display: none;"></div>
|
||
<div class="form-actions" style="border-top: none; padding-top: 0;">
|
||
<button type="submit" class="btn btn-success" id="login-btn">
|
||
🔐 Login
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PPR Detail/Edit Modal -->
|
||
<div id="pprModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2 id="modal-title">PPR Details</h2>
|
||
<button class="close" onclick="closePPRModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="quick-actions">
|
||
<button id="btn-landed" class="btn btn-warning btn-sm" onclick="showTimestampModal('LANDED')">
|
||
🛬 Land
|
||
</button>
|
||
<button id="btn-departed" class="btn btn-primary btn-sm" onclick="showTimestampModal('DEPARTED')">
|
||
🛫 Depart
|
||
</button>
|
||
</div>
|
||
|
||
<form id="ppr-form">
|
||
<input type="hidden" id="ppr-id" name="id">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="ac_reg">Aircraft Registration *</label>
|
||
<input type="text" id="ac_reg" name="ac_reg" required oninput="handleAircraftLookup(this.value)">
|
||
<div id="aircraft-lookup-results"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="ac_type">Aircraft Type *</label>
|
||
<input type="text" id="ac_type" name="ac_type" required tabindex="-1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="ac_call">Callsign</label>
|
||
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration" tabindex="-1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="captain">Captain *</label>
|
||
<input type="text" id="captain" name="captain" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="in_from">Arriving From *</label>
|
||
<input type="text" id="in_from" name="in_from" required placeholder="ICAO Code or Airport Name" oninput="handleArrivalAirportLookup(this.value)">
|
||
<div id="arrival-airport-lookup-results"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="eta">ETA (Local Time) *</label>
|
||
<div style="display: flex; gap: 0.5rem;">
|
||
<input type="date" id="eta-date" name="eta-date" required style="flex: 1;">
|
||
<select id="eta-time" name="eta-time" required style="flex: 1;">
|
||
<option value="">Select Time</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="pob_in">POB Inbound *</label>
|
||
<input type="number" id="pob_in" name="pob_in" required min="1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="fuel">Fuel Required</label>
|
||
<select id="fuel" name="fuel" tabindex="-1">
|
||
<option value="">None</option>
|
||
<option value="100LL">100LL</option>
|
||
<option value="JET A1">JET A1</option>
|
||
<option value="FULL">Full Tanks</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="out_to">Departing To</label>
|
||
<input type="text" id="out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleDepartureAirportLookup(this.value)" tabindex="-1">
|
||
<div id="departure-airport-lookup-results"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="etd">ETD (Local Time)</label>
|
||
<div style="display: flex; gap: 0.5rem;">
|
||
<input type="date" id="etd-date" name="etd-date" tabindex="-1" style="flex: 1;">
|
||
<select id="etd-time" name="etd-time" tabindex="-1" style="flex: 1;">
|
||
<option value="">Select Time</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="pob_out">POB Outbound</label>
|
||
<input type="number" id="pob_out" name="pob_out" min="1" tabindex="-1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="email">Email</label>
|
||
<input type="email" id="email" name="email" tabindex="-1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="phone">Phone</label>
|
||
<input type="tel" id="phone" name="phone" tabindex="-1">
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label for="notes">Notes</label>
|
||
<textarea id="notes" name="notes" rows="3" tabindex="-1"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-danger" id="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" 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-primary" onclick="closeLocalFlightModal()">
|
||
Cancel
|
||
</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
|
||
</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_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-primary" onclick="closeLocalFlightEditModal()">
|
||
Cancel
|
||
</button>
|
||
<button type="submit" class="btn btn-success">
|
||
💾 Save Changes
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- User Management Modal -->
|
||
<div id="userManagementModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>User Management</h2>
|
||
<button class="close" onclick="closeUserManagementModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="quick-actions" style="margin-bottom: 1rem;">
|
||
<button class="btn btn-success" onclick="openUserCreateModal()">
|
||
➕ Create New User
|
||
</button>
|
||
</div>
|
||
|
||
<div id="users-loading" class="loading">
|
||
<div class="spinner"></div>
|
||
Loading users...
|
||
</div>
|
||
|
||
<div id="users-table-content" style="display: none;">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Username</th>
|
||
<th>Role</th>
|
||
<th>Created</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="users-table-body">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div id="users-no-data" class="no-data" style="display: none;">
|
||
<h3>No users found</h3>
|
||
<p>No users are configured in the system.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- User Create/Edit Modal -->
|
||
<div id="userModal" class="modal">
|
||
<div class="modal-content" style="max-width: 500px;">
|
||
<div class="modal-header">
|
||
<h2 id="user-modal-title">Create User</h2>
|
||
<button class="close" onclick="closeUserModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="user-form">
|
||
<input type="hidden" id="user-id" name="id">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group full-width">
|
||
<label for="user-username">Username *</label>
|
||
<input type="text" id="user-username" name="username" required>
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label for="user-password">Password *</label>
|
||
<input type="password" id="user-password" name="password" required>
|
||
<small style="color: #666; font-size: 0.8rem;">Leave blank when editing to keep current password</small>
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label for="user-role">Role *</label>
|
||
<select id="user-role" name="role" required>
|
||
<option value="READ_ONLY">Read Only - View only access</option>
|
||
<option value="OPERATOR">Operator - PPR management access</option>
|
||
<option value="ADMINISTRATOR">Administrator - Full access</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-primary" onclick="closeUserModal()">
|
||
Cancel
|
||
</button>
|
||
<button type="submit" class="btn btn-success">
|
||
💾 Save User
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Change User Password Modal -->
|
||
<div id="changePasswordModal" class="modal">
|
||
<div class="modal-content" style="max-width: 500px;">
|
||
<div class="modal-header">
|
||
<h2 id="change-password-title">Change User Password</h2>
|
||
<button class="close" onclick="closeChangePasswordModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="change-password-form">
|
||
<div class="form-group full-width">
|
||
<label for="change-password-username" style="font-weight: bold;">Username</label>
|
||
<input type="text" id="change-password-username" name="username" readonly style="background-color: #f5f5f5; cursor: not-allowed;">
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label for="change-password-new">New Password *</label>
|
||
<input type="password" id="change-password-new" name="new_password" required>
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label for="change-password-confirm">Confirm New Password *</label>
|
||
<input type="password" id="change-password-confirm" name="confirm_password" required>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-primary" onclick="closeChangePasswordModal()">
|
||
Cancel
|
||
</button>
|
||
<button type="submit" class="btn btn-warning">
|
||
🔐 Change Password
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Success Notification -->
|
||
<div id="notification" class="notification"></div>
|
||
|
||
<!-- Timestamp Modal for Landing/Departure -->
|
||
<div id="timestampModal" class="modal">
|
||
<div class="modal-content" style="max-width: 400px;">
|
||
<div class="modal-header">
|
||
<h2 id="timestamp-modal-title">Confirm Landing Time</h2>
|
||
<button class="close" onclick="closeTimestampModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="timestamp-form">
|
||
<div class="form-group">
|
||
<label for="event-timestamp">Event Time (Local Time) *</label>
|
||
<input type="datetime-local" id="event-timestamp" name="timestamp" required>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-primary" onclick="closeTimestampModal()">
|
||
Cancel
|
||
</button>
|
||
<button type="submit" class="btn btn-success" id="timestamp-submit-btn">
|
||
Confirm
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let currentUser = null;
|
||
let accessToken = null;
|
||
let currentPPRId = null;
|
||
let isNewPPR = false;
|
||
let wsConnection = null;
|
||
let pendingStatusUpdate = null; // Track pending status update for timestamp modal
|
||
let wsHeartbeatInterval = null;
|
||
let wsReconnectTimeout = null;
|
||
let lastHeartbeatResponse = null;
|
||
let sessionExpiryWarningShown = false;
|
||
let sessionExpiryCheckInterval = null;
|
||
let etdManuallyEdited = false; // Track if user has manually edited ETD
|
||
|
||
// WebSocket connection for real-time updates
|
||
function connectWebSocket() {
|
||
if (wsConnection && wsConnection.readyState === WebSocket.OPEN) {
|
||
return; // Already connected
|
||
}
|
||
|
||
// Clear any existing reconnect timeout
|
||
if (wsReconnectTimeout) {
|
||
clearTimeout(wsReconnectTimeout);
|
||
wsReconnectTimeout = null;
|
||
}
|
||
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${protocol}//${window.location.host}/ws/tower-updates`;
|
||
|
||
console.log('Connecting to WebSocket:', wsUrl);
|
||
wsConnection = new WebSocket(wsUrl);
|
||
|
||
wsConnection.onopen = function(event) {
|
||
console.log('✅ WebSocket connected for real-time updates');
|
||
lastHeartbeatResponse = Date.now();
|
||
startHeartbeat();
|
||
showNotification('Real-time updates connected');
|
||
};
|
||
|
||
wsConnection.onmessage = function(event) {
|
||
try {
|
||
// Check if it's a heartbeat response
|
||
if (event.data.startsWith('Heartbeat:')) {
|
||
lastHeartbeatResponse = Date.now();
|
||
console.log('💓 WebSocket heartbeat received');
|
||
return;
|
||
}
|
||
|
||
const data = JSON.parse(event.data);
|
||
console.log('WebSocket message received:', data);
|
||
|
||
// Refresh PPRs when any PPR-related event occurs
|
||
if (data.type && (data.type.includes('ppr_') || data.type === 'status_update')) {
|
||
console.log('PPR update detected, refreshing...');
|
||
loadPPRs();
|
||
showNotification('Data updated');
|
||
}
|
||
|
||
// Refresh local flights when any local flight event occurs
|
||
if (data.type && (data.type.includes('local_flight_'))) {
|
||
console.log('Local flight update detected, refreshing...');
|
||
loadLocalFlights();
|
||
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 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;
|
||
}
|
||
|
||
// 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');
|
||
}
|
||
});
|
||
}
|
||
|
||
function showLogin() {
|
||
document.getElementById('loginModal').style.display = 'block';
|
||
document.getElementById('login-username').focus();
|
||
}
|
||
|
||
function hideLogin() {
|
||
document.getElementById('loginModal').style.display = 'none';
|
||
document.getElementById('login-error').style.display = 'none';
|
||
document.getElementById('login-form').reset();
|
||
}
|
||
|
||
async function handleLogin() {
|
||
const username = document.getElementById('login-username').value;
|
||
const password = document.getElementById('login-password').value;
|
||
const loginBtn = document.getElementById('login-btn');
|
||
const errorDiv = document.getElementById('login-error');
|
||
|
||
// Show loading state
|
||
loginBtn.disabled = true;
|
||
loginBtn.textContent = '🔄 Logging in...';
|
||
errorDiv.style.display = 'none';
|
||
|
||
try {
|
||
const response = await fetch('/api/v1/auth/login', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
},
|
||
body: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (response.ok && data.access_token) {
|
||
// Store token and user info with expiry from server response
|
||
const expiresInMs = (data.expires_in || 1800) * 1000; // Use server value or default to 30 min
|
||
const expiryTime = new Date().getTime() + expiresInMs;
|
||
|
||
localStorage.setItem('ppr_access_token', data.access_token);
|
||
localStorage.setItem('ppr_username', username);
|
||
localStorage.setItem('ppr_token_expiry', expiryTime.toString());
|
||
|
||
accessToken = data.access_token;
|
||
currentUser = username;
|
||
document.getElementById('current-user').textContent = username;
|
||
|
||
hideLogin();
|
||
await updateUserRole(); // Update role-based UI
|
||
startSessionExpiryCheck(); // Start monitoring session expiry
|
||
connectWebSocket(); // Connect WebSocket for real-time updates
|
||
loadPPRs();
|
||
} else {
|
||
throw new Error(data.detail || 'Authentication failed');
|
||
}
|
||
} catch (error) {
|
||
console.error('Login error:', error);
|
||
errorDiv.textContent = error.message || 'Login failed. Please check your credentials.';
|
||
errorDiv.style.display = 'block';
|
||
} finally {
|
||
// Reset button state
|
||
loginBtn.disabled = false;
|
||
loginBtn.textContent = '🔐 Login';
|
||
}
|
||
}
|
||
|
||
function logout() {
|
||
// Clear stored token and user info
|
||
localStorage.removeItem('ppr_access_token');
|
||
localStorage.removeItem('ppr_username');
|
||
localStorage.removeItem('ppr_token_expiry');
|
||
|
||
accessToken = null;
|
||
currentUser = null;
|
||
|
||
stopSessionExpiryCheck(); // Stop monitoring session
|
||
disconnectWebSocket(); // Disconnect WebSocket
|
||
|
||
// Close any open modals
|
||
closePPRModal();
|
||
closeTimestampModal();
|
||
|
||
// Show login again
|
||
showLogin();
|
||
}
|
||
|
||
// Legacy authenticate function - now redirects to new login
|
||
function authenticate() {
|
||
showLogin();
|
||
}
|
||
|
||
// Enhanced fetch wrapper with token expiry handling
|
||
async function authenticatedFetch(url, options = {}) {
|
||
if (!accessToken) {
|
||
showLogin();
|
||
throw new Error('No access token available');
|
||
}
|
||
|
||
// Add authorization header
|
||
const headers = {
|
||
...options.headers,
|
||
'Authorization': `Bearer ${accessToken}`
|
||
};
|
||
|
||
const response = await fetch(url, {
|
||
...options,
|
||
headers
|
||
});
|
||
|
||
// Handle 401 - token expired
|
||
if (response.status === 401) {
|
||
logout();
|
||
throw new Error('Session expired. Please log in again.');
|
||
}
|
||
|
||
return response;
|
||
}
|
||
|
||
// Load PPR records - now loads all tables
|
||
async function loadPPRs() {
|
||
if (!accessToken) return;
|
||
|
||
// Load all tables simultaneously
|
||
await Promise.all([loadArrivals(), loadDepartures(), loadDeparted(), loadParked(), loadUpcoming()]);
|
||
}
|
||
|
||
// Load arrivals (NEW and CONFIRMED status 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 and local flights that are in the air
|
||
const [pprResponse, localResponse] = await Promise.all([
|
||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||
authenticatedFetch('/api/v1/local-flights/?status=DEPARTED&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)
|
||
if (localResponse.ok) {
|
||
const localFlights = await localResponse.json();
|
||
const localInAir = localFlights.map(flight => ({
|
||
...flight,
|
||
isLocalFlight: true // Flag to distinguish from PPR
|
||
}));
|
||
arrivals.push(...localInAir);
|
||
}
|
||
|
||
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)
|
||
if (localResponse.ok) {
|
||
const localFlights = await localResponse.json();
|
||
const localDepartures = localFlights.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, localResponse] = await Promise.all([
|
||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||
authenticatedFetch('/api/v1/local-flights/?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
|
||
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 local flights departed today
|
||
if (localResponse.ok) {
|
||
const localFlights = await localResponse.json();
|
||
const localDeparted = localFlights.filter(flight => {
|
||
if (!flight.departure_dt) return false;
|
||
const departedDate = flight.departure_dt.split('T')[0];
|
||
return departedDate === today;
|
||
}).map(flight => ({
|
||
...flight,
|
||
isLocalFlight: true
|
||
}));
|
||
departed.push(...localDeparted);
|
||
}
|
||
|
||
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 || a.departure_dt;
|
||
const bTime = b.departed_dt || b.departure_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;
|
||
|
||
row.onclick = () => {
|
||
if (isLocal) {
|
||
openLocalFlightEditModal(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;">${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.departure_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;">${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 response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch parked visitors');
|
||
}
|
||
|
||
const allPPRs = await response.json();
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
// Filter for parked visitors: LANDED status and (no ETD or ETD not today)
|
||
// Show all parked aircraft regardless of when they arrived
|
||
const parked = allPPRs.filter(ppr => {
|
||
if (ppr.status !== 'LANDED') {
|
||
return false;
|
||
}
|
||
// No ETD means parked
|
||
if (!ppr.etd) {
|
||
return true;
|
||
}
|
||
// ETD exists but is not today
|
||
const etdDate = ppr.etd.split('T')[0];
|
||
return etdDate !== today;
|
||
});
|
||
|
||
displayParked(parked);
|
||
} catch (error) {
|
||
console.error('Error loading parked visitors:', error);
|
||
if (error.message !== 'Session expired. Please log in again.') {
|
||
showNotification('Error loading parked visitors', true);
|
||
}
|
||
}
|
||
|
||
document.getElementById('parked-loading').style.display = 'none';
|
||
}
|
||
|
||
function displayParked(parked) {
|
||
const tbody = document.getElementById('parked-table-body');
|
||
document.getElementById('parked-count').textContent = parked.length;
|
||
|
||
if (parked.length === 0) {
|
||
document.getElementById('parked-no-data').style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// Sort by landed time
|
||
parked.sort((a, b) => {
|
||
if (!a.landed_dt) return 1;
|
||
if (!b.landed_dt) return -1;
|
||
return new Date(a.landed_dt) - new Date(b.landed_dt);
|
||
});
|
||
|
||
tbody.innerHTML = '';
|
||
document.getElementById('parked-table-content').style.display = 'block';
|
||
|
||
for (const ppr of parked) {
|
||
const row = document.createElement('tr');
|
||
row.onclick = () => openPPRModal(ppr.id);
|
||
row.style.cssText = 'font-size: 0.85rem !important; font-style: italic;';
|
||
|
||
// Format arrival: time if today, date if not
|
||
const today = new Date().toISOString().split('T')[0];
|
||
let arrivedDisplay = '-';
|
||
if (ppr.landed_dt) {
|
||
const landedDate = ppr.landed_dt.split('T')[0];
|
||
if (landedDate === today) {
|
||
// Today - show time only
|
||
arrivedDisplay = formatTimeOnly(ppr.landed_dt);
|
||
} else {
|
||
// Not today - show date (DD/MM)
|
||
const date = new Date(ppr.landed_dt);
|
||
arrivedDisplay = date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||
}
|
||
}
|
||
|
||
// Format ETD as just the date (DD/MM)
|
||
let etdDisplay = '-';
|
||
if (ppr.etd) {
|
||
const etdDate = new Date(ppr.etd);
|
||
etdDisplay = etdDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||
}
|
||
|
||
row.innerHTML = `
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.ac_reg || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.ac_type || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.in_from || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${arrivedDisplay}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${etdDisplay}</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
}
|
||
}
|
||
|
||
// Load upcoming PPRs (future days with NEW or CONFIRMED status)
|
||
async function loadUpcoming() {
|
||
document.getElementById('upcoming-loading').style.display = 'block';
|
||
document.getElementById('upcoming-table-content').style.display = 'none';
|
||
document.getElementById('upcoming-no-data').style.display = 'none';
|
||
|
||
try {
|
||
const response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch upcoming PPRs');
|
||
}
|
||
|
||
const allPPRs = await response.json();
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
// Filter for PPRs with ETA in the future (not today) and NEW or CONFIRMED status
|
||
const upcoming = allPPRs.filter(ppr => {
|
||
if (!ppr.eta || (ppr.status !== 'NEW' && ppr.status !== 'CONFIRMED')) {
|
||
return false;
|
||
}
|
||
const etaDate = ppr.eta.split('T')[0];
|
||
return etaDate > today;
|
||
});
|
||
|
||
displayUpcoming(upcoming);
|
||
} catch (error) {
|
||
console.error('Error loading upcoming PPRs:', error);
|
||
if (error.message !== 'Session expired. Please log in again.') {
|
||
showNotification('Error loading upcoming PPRs', true);
|
||
}
|
||
}
|
||
|
||
document.getElementById('upcoming-loading').style.display = 'none';
|
||
}
|
||
|
||
function displayUpcoming(upcoming) {
|
||
const tbody = document.getElementById('upcoming-table-body');
|
||
document.getElementById('upcoming-count').textContent = upcoming.length;
|
||
|
||
if (upcoming.length === 0) {
|
||
// Don't show anything if collapsed by default
|
||
return;
|
||
}
|
||
|
||
// Sort by ETA date and time
|
||
upcoming.sort((a, b) => new Date(a.eta) - new Date(b.eta));
|
||
|
||
tbody.innerHTML = '';
|
||
// Don't auto-expand, keep collapsed by default
|
||
|
||
for (const ppr of upcoming) {
|
||
const row = document.createElement('tr');
|
||
row.onclick = () => openPPRModal(ppr.id);
|
||
row.style.cssText = 'font-size: 0.85rem !important;';
|
||
|
||
// Format date as Day DD/MM (e.g., Wed 11/12)
|
||
const etaDate = new Date(ppr.eta);
|
||
const dayName = etaDate.toLocaleDateString('en-GB', { weekday: 'short' });
|
||
const dateStr = etaDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||
const dateDisplay = `${dayName} ${dateStr}`;
|
||
|
||
// Create notes indicator if notes exist
|
||
const notesIndicator = ppr.notes && ppr.notes.trim() ?
|
||
`<span class="notes-tooltip">
|
||
<span class="notes-indicator">📝</span>
|
||
<span class="tooltip-text">${ppr.notes}</span>
|
||
</span>` : '';
|
||
|
||
row.innerHTML = `
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${dateDisplay}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.ac_reg || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.ac_type || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.in_from || '-'}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(ppr.eta)}</td>
|
||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${notesIndicator}</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
}
|
||
// Setup tooltips after rendering
|
||
setupTooltips();
|
||
}
|
||
|
||
// Toggle upcoming table collapse/expand
|
||
function toggleUpcomingTable() {
|
||
const content = document.getElementById('upcoming-table-content');
|
||
const noData = document.getElementById('upcoming-no-data');
|
||
const icon = document.getElementById('upcoming-collapse-icon');
|
||
|
||
const isVisible = content.style.display === 'block' || noData.style.display === 'block';
|
||
|
||
if (isVisible) {
|
||
content.style.display = 'none';
|
||
noData.style.display = 'none';
|
||
icon.classList.add('collapsed');
|
||
} else {
|
||
const count = parseInt(document.getElementById('upcoming-count').textContent);
|
||
if (count > 0) {
|
||
content.style.display = 'block';
|
||
} else {
|
||
noData.style.display = 'block';
|
||
}
|
||
icon.classList.remove('collapsed');
|
||
}
|
||
}
|
||
|
||
// ICAO code to airport name cache
|
||
const airportNameCache = {};
|
||
|
||
async function getAirportDisplay(code) {
|
||
if (!code || code.length !== 4 || !/^[A-Z]{4}$/.test(code)) return code;
|
||
if (airportNameCache[code]) return `${code}<br><span style="font-size:0.8em;color:#666;font-style:italic;">${airportNameCache[code]}</span>`;
|
||
try {
|
||
const resp = await authenticatedFetch(`/api/v1/airport/lookup/${code}`);
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
if (data && data.length && data[0].name) {
|
||
airportNameCache[code] = data[0].name;
|
||
return `${code}<br><span style="font-size:0.8em;color:#666;font-style:italic;">${data[0].name}</span>`;
|
||
}
|
||
}
|
||
} catch {}
|
||
return code;
|
||
}
|
||
|
||
async function displayArrivals(arrivals) {
|
||
const tbody = document.getElementById('arrivals-table-body');
|
||
const recordCount = document.getElementById('arrivals-count');
|
||
recordCount.textContent = arrivals.length;
|
||
if (arrivals.length === 0) {
|
||
document.getElementById('arrivals-no-data').style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// Sort arrivals by ETA/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;
|
||
|
||
// Click handler that routes to correct modal
|
||
row.onclick = () => {
|
||
if (isLocal) {
|
||
openLocalFlightEditModal(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;
|
||
|
||
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;
|
||
fromDisplay = `<i>${flight.flight_type === 'CIRCUITS' ? 'Circuits' : flight.flight_type === 'LOCAL' ? 'Local Flight' : 'Departure'}</i>`;
|
||
eta = flight.departure_dt ? formatTimeOnly(flight.departure_dt) : '-';
|
||
pob = flight.pob || '-';
|
||
fuel = '-';
|
||
actionButtons = `
|
||
<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 {
|
||
// 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;
|
||
|
||
// 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>${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) {
|
||
// TODO: Open departure edit modal
|
||
} 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;
|
||
|
||
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>`;
|
||
}
|
||
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') {
|
||
actionButtons = `
|
||
<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>`;
|
||
}
|
||
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>`;
|
||
}
|
||
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>${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}`);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function loadJournal(pprId) {
|
||
try {
|
||
const response = await fetch(`/api/v1/pprs/${pprId}/journal`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${accessToken}`
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch journal');
|
||
}
|
||
|
||
const entries = await response.json();
|
||
displayJournal(entries);
|
||
} catch (error) {
|
||
console.error('Error loading journal:', error);
|
||
document.getElementById('journal-entries').innerHTML = 'Error loading journal entries';
|
||
}
|
||
}
|
||
|
||
function displayJournal(entries) {
|
||
const container = document.getElementById('journal-entries');
|
||
|
||
if (entries.length === 0) {
|
||
container.innerHTML = '<p>No journal entries yet.</p>';
|
||
} else {
|
||
container.innerHTML = entries.map(entry => `
|
||
<div class="journal-entry">
|
||
<div class="journal-meta">
|
||
${formatDateTime(entry.entry_dt)} by ${entry.user}
|
||
</div>
|
||
<div class="journal-text">${entry.entry}</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// Always show journal section when displaying entries
|
||
document.getElementById('journal-section').style.display = 'block';
|
||
}
|
||
|
||
function closePPRModal() {
|
||
document.getElementById('pprModal').style.display = 'none';
|
||
currentPPRId = null;
|
||
isNewPPR = false;
|
||
}
|
||
|
||
// Timestamp modal functions
|
||
function showTimestampModal(status, pprId = null, isLocalFlight = false, isDeparture = false) {
|
||
const targetId = pprId || (isLocalFlight ? currentLocalFlightId : currentPPRId);
|
||
if (!targetId) return;
|
||
|
||
pendingStatusUpdate = { status: status, pprId: targetId, isLocalFlight: isLocalFlight, isDeparture: isDeparture };
|
||
|
||
const modalTitle = document.getElementById('timestamp-modal-title');
|
||
const submitBtn = document.getElementById('timestamp-submit-btn');
|
||
|
||
if (status === 'LANDED') {
|
||
modalTitle.textContent = 'Confirm Landing Time';
|
||
submitBtn.textContent = '🛬 Confirm Landing';
|
||
} else if (status === 'DEPARTED') {
|
||
modalTitle.textContent = 'Confirm Departure Time';
|
||
submitBtn.textContent = '🛫 Confirm Departure';
|
||
}
|
||
|
||
// Set default timestamp to current time
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||
const day = String(now.getDate()).padStart(2, '0');
|
||
const hours = String(now.getHours()).padStart(2, '0');
|
||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||
document.getElementById('event-timestamp').value = `${year}-${month}-${day}T${hours}:${minutes}`;
|
||
|
||
document.getElementById('timestampModal').style.display = 'block';
|
||
}
|
||
|
||
function closeTimestampModal() {
|
||
document.getElementById('timestampModal').style.display = 'none';
|
||
pendingStatusUpdate = null;
|
||
document.getElementById('timestamp-form').reset();
|
||
}
|
||
|
||
// Timestamp form submission
|
||
document.getElementById('timestamp-form').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
|
||
if (!pendingStatusUpdate || !accessToken) return;
|
||
|
||
const timestampInput = document.getElementById('event-timestamp').value;
|
||
let timestamp = null;
|
||
|
||
if (timestampInput.trim()) {
|
||
// Convert local datetime-local to UTC ISO string
|
||
timestamp = new Date(timestampInput).toISOString();
|
||
}
|
||
|
||
try {
|
||
// Determine the correct API endpoint based on flight type
|
||
const isLocal = pendingStatusUpdate.isLocalFlight;
|
||
const isDeparture = pendingStatusUpdate.isDeparture;
|
||
let endpoint;
|
||
|
||
if (isLocal) {
|
||
endpoint = `/api/v1/local-flights/${pendingStatusUpdate.pprId}/status`;
|
||
} else if (isDeparture) {
|
||
endpoint = `/api/v1/departures/${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();
|
||
loadPPRs(); // Refresh all tables
|
||
showNotification(`Status updated to ${updatedStatus}`);
|
||
if (!isLocal) {
|
||
closePPRModal(); // Close PPR modal after successful status update
|
||
}
|
||
} catch (error) {
|
||
console.error('Error updating status:', error);
|
||
showNotification(`Error updating status: ${error.message}`, true);
|
||
}
|
||
});
|
||
|
||
// Form submission
|
||
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
|
||
if (!accessToken) return;
|
||
|
||
const formData = new FormData(this);
|
||
const pprData = {};
|
||
|
||
formData.forEach((value, key) => {
|
||
if (key !== 'id' && value.trim() !== '') {
|
||
if (key === 'pob_in' || key === 'pob_out') {
|
||
pprData[key] = parseInt(value);
|
||
} else if (key === 'eta-date' && formData.get('eta-time')) {
|
||
// Combine date and time for ETA
|
||
const dateStr = formData.get('eta-date');
|
||
const timeStr = formData.get('eta-time');
|
||
pprData.eta = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||
} else if (key === 'etd-date' && formData.get('etd-time')) {
|
||
// Combine date and time for ETD
|
||
const dateStr = formData.get('etd-date');
|
||
const timeStr = formData.get('etd-time');
|
||
pprData.etd = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||
} else if (key !== 'eta-time' && key !== 'etd-time') {
|
||
// Skip the time fields as they're handled above
|
||
pprData[key] = value;
|
||
}
|
||
}
|
||
});
|
||
|
||
try {
|
||
let response;
|
||
if (isNewPPR) {
|
||
response = await fetch('/api/v1/pprs/', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify(pprData)
|
||
});
|
||
} else {
|
||
response = await fetch(`/api/v1/pprs/${currentPPRId}`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify(pprData)
|
||
});
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to save PPR');
|
||
}
|
||
|
||
const wasNewPPR = isNewPPR;
|
||
closePPRModal();
|
||
loadPPRs(); // Refresh both tables
|
||
showNotification(wasNewPPR ? 'PPR created successfully!' : 'PPR updated successfully!');
|
||
} catch (error) {
|
||
console.error('Error saving PPR:', error);
|
||
showNotification('Error saving PPR', true);
|
||
}
|
||
});
|
||
|
||
// Status update functions
|
||
async function updateStatus(status) {
|
||
if (!currentPPRId || !accessToken) return;
|
||
|
||
// Show confirmation for cancel actions
|
||
if (status === 'CANCELED') {
|
||
if (!confirm('Are you sure you want to cancel this PPR? This action cannot be easily undone.')) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/pprs/${currentPPRId}/status`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify({ status: status })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to update status');
|
||
}
|
||
|
||
await loadJournal(currentPPRId); // Refresh journal
|
||
loadPPRs(); // Refresh both tables
|
||
showNotification(`Status updated to ${status}`);
|
||
closePPRModal(); // Close modal after successful status update
|
||
} catch (error) {
|
||
console.error('Error updating status:', error);
|
||
showNotification('Error updating status', true);
|
||
}
|
||
}
|
||
|
||
async function updateStatusFromTable(pprId, status) {
|
||
if (!accessToken) return;
|
||
|
||
// Show confirmation for cancel actions
|
||
if (status === 'CANCELED') {
|
||
if (!confirm('Are you sure you want to cancel this PPR? This action cannot be easily undone.')) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/pprs/${pprId}/status`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify({ status: status })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({}));
|
||
throw new Error(`Failed to update status: ${response.status} ${response.statusText} - ${errorData.detail || 'Unknown error'}`);
|
||
}
|
||
|
||
loadPPRs(); // Refresh both tables
|
||
showNotification(`Status updated to ${status}`);
|
||
} catch (error) {
|
||
console.error('Error updating status:', error);
|
||
showNotification(`Error updating status: ${error.message}`, true);
|
||
}
|
||
}
|
||
|
||
async function deletePPR() {
|
||
if (!currentPPRId || !accessToken) return;
|
||
|
||
if (!confirm('Are you sure you want to delete this PPR? This action cannot be undone.')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/pprs/${currentPPRId}`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'Authorization': `Bearer ${accessToken}`
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to delete PPR');
|
||
}
|
||
|
||
closePPRModal();
|
||
loadPPRs(); // Refresh both tables
|
||
showNotification('PPR deleted successfully!');
|
||
} catch (error) {
|
||
console.error('Error deleting PPR:', error);
|
||
showNotification('Error deleting PPR', true);
|
||
}
|
||
}
|
||
|
||
// User Management Functions
|
||
let currentUserRole = null;
|
||
let isNewUser = false;
|
||
let currentUserId = null;
|
||
|
||
async function openUserManagementModal() {
|
||
if (!accessToken) return;
|
||
|
||
document.getElementById('userManagementModal').style.display = 'block';
|
||
await loadUsers();
|
||
}
|
||
|
||
function closeUserManagementModal() {
|
||
document.getElementById('userManagementModal').style.display = 'none';
|
||
}
|
||
|
||
async function loadUsers() {
|
||
if (!accessToken) return;
|
||
|
||
document.getElementById('users-loading').style.display = 'block';
|
||
document.getElementById('users-table-content').style.display = 'none';
|
||
document.getElementById('users-no-data').style.display = 'none';
|
||
|
||
try {
|
||
const response = await authenticatedFetch('/api/v1/auth/users');
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch users');
|
||
}
|
||
|
||
const users = await response.json();
|
||
displayUsers(users);
|
||
} catch (error) {
|
||
console.error('Error loading users:', error);
|
||
if (error.message !== 'Session expired. Please log in again.') {
|
||
showNotification('Error loading users', true);
|
||
}
|
||
}
|
||
|
||
document.getElementById('users-loading').style.display = 'none';
|
||
}
|
||
|
||
function displayUsers(users) {
|
||
const tbody = document.getElementById('users-table-body');
|
||
|
||
if (users.length === 0) {
|
||
document.getElementById('users-no-data').style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = '';
|
||
document.getElementById('users-table-content').style.display = 'block';
|
||
|
||
users.forEach(user => {
|
||
const row = document.createElement('tr');
|
||
|
||
// Format role for display
|
||
const roleDisplay = {
|
||
'ADMINISTRATOR': 'Administrator',
|
||
'OPERATOR': 'Operator',
|
||
'READ_ONLY': 'Read Only'
|
||
}[user.role] || user.role;
|
||
|
||
// Format created date
|
||
const createdDate = user.created_at ? formatDateTime(user.created_at) : '-';
|
||
|
||
row.innerHTML = `
|
||
<td><strong>${user.username}</strong></td>
|
||
<td>${roleDisplay}</td>
|
||
<td>${createdDate}</td>
|
||
<td>
|
||
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); openUserEditModal(${user.id})" title="Edit User">
|
||
✏️
|
||
</button>
|
||
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); openChangePasswordModal(${user.id}, '${user.username}')" title="Change Password">
|
||
🔐
|
||
</button>
|
||
</td>
|
||
`;
|
||
|
||
tbody.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function openUserCreateModal() {
|
||
isNewUser = true;
|
||
currentUserId = null;
|
||
document.getElementById('user-modal-title').textContent = 'Create New User';
|
||
|
||
// Clear form
|
||
document.getElementById('user-form').reset();
|
||
document.getElementById('user-id').value = '';
|
||
document.getElementById('user-password').required = true;
|
||
|
||
// Show password help text
|
||
const passwordHelp = document.querySelector('#user-password + small');
|
||
if (passwordHelp) passwordHelp.style.display = 'none';
|
||
|
||
document.getElementById('userModal').style.display = 'block';
|
||
|
||
// Auto-focus on username field
|
||
setTimeout(() => {
|
||
document.getElementById('user-username').focus();
|
||
}, 100);
|
||
}
|
||
|
||
async function openUserEditModal(userId) {
|
||
if (!accessToken) return;
|
||
|
||
isNewUser = false;
|
||
currentUserId = userId;
|
||
document.getElementById('user-modal-title').textContent = 'Edit User';
|
||
|
||
try {
|
||
const response = await authenticatedFetch(`/api/v1/auth/users/${userId}`);
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch user details');
|
||
}
|
||
|
||
const user = await response.json();
|
||
populateUserForm(user);
|
||
|
||
document.getElementById('userModal').style.display = 'block';
|
||
} catch (error) {
|
||
console.error('Error loading user details:', error);
|
||
showNotification('Error loading user details', true);
|
||
}
|
||
}
|
||
|
||
function populateUserForm(user) {
|
||
document.getElementById('user-id').value = user.id;
|
||
document.getElementById('user-username').value = user.username;
|
||
document.getElementById('user-password').value = ''; // Don't populate password
|
||
document.getElementById('user-role').value = user.role;
|
||
|
||
// Make password optional for editing
|
||
document.getElementById('user-password').required = false;
|
||
|
||
// Show password help text
|
||
const passwordHelp = document.querySelector('#user-password + small');
|
||
if (passwordHelp) passwordHelp.style.display = 'block';
|
||
}
|
||
|
||
function closeUserModal() {
|
||
document.getElementById('userModal').style.display = 'none';
|
||
currentUserId = null;
|
||
isNewUser = false;
|
||
}
|
||
|
||
let currentChangePasswordUserId = null;
|
||
|
||
function openChangePasswordModal(userId, username) {
|
||
if (!accessToken) return;
|
||
|
||
currentChangePasswordUserId = userId;
|
||
document.getElementById('change-password-username').value = username;
|
||
document.getElementById('change-password-new').value = '';
|
||
document.getElementById('change-password-confirm').value = '';
|
||
document.getElementById('changePasswordModal').style.display = 'block';
|
||
|
||
// Auto-focus on new password field
|
||
setTimeout(() => {
|
||
document.getElementById('change-password-new').focus();
|
||
}, 100);
|
||
}
|
||
|
||
function closeChangePasswordModal() {
|
||
document.getElementById('changePasswordModal').style.display = 'none';
|
||
currentChangePasswordUserId = null;
|
||
}
|
||
|
||
// Change password form submission
|
||
document.getElementById('change-password-form').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
|
||
if (!accessToken || !currentChangePasswordUserId) return;
|
||
|
||
const newPassword = document.getElementById('change-password-new').value.trim();
|
||
const confirmPassword = document.getElementById('change-password-confirm').value.trim();
|
||
|
||
// Validate passwords match
|
||
if (newPassword !== confirmPassword) {
|
||
showNotification('Passwords do not match!', true);
|
||
return;
|
||
}
|
||
|
||
// Validate password length
|
||
if (newPassword.length < 6) {
|
||
showNotification('Password must be at least 6 characters long!', true);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/auth/users/${currentChangePasswordUserId}/change-password`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify({ password: newPassword })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({}));
|
||
throw new Error(errorData.detail || 'Failed to change password');
|
||
}
|
||
|
||
closeChangePasswordModal();
|
||
showNotification('Password changed successfully!');
|
||
} catch (error) {
|
||
console.error('Error changing password:', error);
|
||
showNotification(`Error changing password: ${error.message}`, true);
|
||
}
|
||
});
|
||
|
||
// User form submission
|
||
document.getElementById('user-form').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
|
||
if (!accessToken) return;
|
||
|
||
const formData = new FormData(this);
|
||
const userData = {};
|
||
|
||
formData.forEach((value, key) => {
|
||
if (key !== 'id' && value.trim() !== '') {
|
||
userData[key] = value;
|
||
}
|
||
});
|
||
|
||
try {
|
||
let response;
|
||
if (isNewUser) {
|
||
response = await fetch('/api/v1/auth/users', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify(userData)
|
||
});
|
||
} else {
|
||
response = await fetch(`/api/v1/auth/users/${currentUserId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
},
|
||
body: JSON.stringify(userData)
|
||
});
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({}));
|
||
throw new Error(errorData.detail || 'Failed to save user');
|
||
}
|
||
|
||
const wasNewUser = isNewUser;
|
||
closeUserModal();
|
||
await loadUsers(); // Refresh user list
|
||
showNotification(wasNewUser ? 'User created successfully!' : 'User updated successfully!');
|
||
} catch (error) {
|
||
console.error('Error saving user:', error);
|
||
showNotification(`Error saving user: ${error.message}`, true);
|
||
}
|
||
});
|
||
|
||
// Update user role detection and UI visibility
|
||
async function updateUserRole() {
|
||
console.log('updateUserRole called'); // Debug log
|
||
if (!accessToken) {
|
||
console.log('No access token, skipping role update'); // Debug log
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await authenticatedFetch('/api/v1/auth/test-token', {
|
||
method: 'POST'
|
||
});
|
||
|
||
if (response.ok) {
|
||
const userData = await response.json();
|
||
currentUserRole = userData.role;
|
||
console.log('User role from API:', currentUserRole); // Debug log
|
||
|
||
// Show user management button only for administrators
|
||
const userManagementBtn = document.getElementById('user-management-btn');
|
||
if (currentUserRole && currentUserRole.toUpperCase() === 'ADMINISTRATOR') {
|
||
userManagementBtn.style.display = 'inline-block';
|
||
console.log('Showing user management button'); // Debug log
|
||
} else {
|
||
userManagementBtn.style.display = 'none';
|
||
console.log('Hiding user management button, current role:', currentUserRole); // Debug log
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error updating user role:', error);
|
||
// Hide user management by default on error
|
||
document.getElementById('user-management-btn').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Close modal when clicking outside
|
||
window.onclick = function(event) {
|
||
const pprModal = document.getElementById('pprModal');
|
||
const timestampModal = document.getElementById('timestampModal');
|
||
const userManagementModal = document.getElementById('userManagementModal');
|
||
const userModal = document.getElementById('userModal');
|
||
|
||
if (event.target === pprModal) {
|
||
closePPRModal();
|
||
}
|
||
if (event.target === timestampModal) {
|
||
closeTimestampModal();
|
||
}
|
||
if (event.target === userManagementModal) {
|
||
closeUserManagementModal();
|
||
}
|
||
if (event.target === userModal) {
|
||
closeUserModal();
|
||
}
|
||
}
|
||
|
||
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';
|
||
}
|
||
|
||
// 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;
|
||
|
||
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_notes').value = flight.notes || '';
|
||
|
||
// Parse and populate departure time if exists
|
||
if (flight.departure_dt) {
|
||
const dept = new Date(flight.departure_dt);
|
||
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}`;
|
||
document.getElementById('localFlightEditModal').style.display = 'block';
|
||
} catch (error) {
|
||
console.error('Error loading flight:', error);
|
||
showNotification('Error loading flight details', true);
|
||
}
|
||
}
|
||
|
||
function closeLocalFlightEditModal() {
|
||
document.getElementById('localFlightEditModal').style.display = 'none';
|
||
currentLocalFlightId = null;
|
||
}
|
||
|
||
// 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 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);
|
||
}
|
||
}
|
||
|
||
// 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') {
|
||
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') {
|
||
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);
|
||
}
|
||
});
|
||
|
||
// 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> |