3014 lines
129 KiB
JavaScript
3014 lines
129 KiB
JavaScript
let currentUser = null;
|
|
let accessToken = null;
|
|
let currentPPRId = null;
|
|
let isNewPPR = false;
|
|
let wsConnection = null;
|
|
let pendingStatusUpdate = null; // Track pending status update for timestamp modal
|
|
let wsHeartbeatInterval = null;
|
|
let wsReconnectTimeout = null;
|
|
let lastHeartbeatResponse = null;
|
|
let sessionExpiryWarningShown = false;
|
|
let sessionExpiryCheckInterval = null;
|
|
let etdManuallyEdited = false; // Track if user has manually edited ETD
|
|
let loadPPRsTimeout = null; // Debounce timer for loadPPRs to prevent duplicate refreshes
|
|
|
|
// Modal state variables
|
|
let currentLocalFlightId = null;
|
|
let currentBookedInArrivalId = null;
|
|
let currentDepartureId = null;
|
|
let currentArrivalId = null;
|
|
let currentOverflightId = null;
|
|
let isOverflightQSYMode = false; // Track if we're in overflight QSY mode
|
|
|
|
// User management variables
|
|
let currentUserRole = null;
|
|
let isNewUser = false;
|
|
let currentUserId = null;
|
|
let currentChangePasswordUserId = null;
|
|
|
|
// ==================== GENERIC MODAL HELPER ====================
|
|
function closeModal(modalId, additionalCleanup = null) {
|
|
document.getElementById(modalId).style.display = 'none';
|
|
if (additionalCleanup) {
|
|
additionalCleanup();
|
|
}
|
|
}
|
|
|
|
// Load UI configuration from API
|
|
async function loadUIConfig() {
|
|
try {
|
|
const response = await fetch('/api/v1/public/config');
|
|
if (response.ok) {
|
|
const config = await response.json();
|
|
|
|
// Update tower title
|
|
const titleElement = document.getElementById('tower-title');
|
|
if (titleElement && config.tag) {
|
|
titleElement.innerHTML = `✈️ Tower Ops ${config.tag}`;
|
|
}
|
|
|
|
// Update top bar gradient
|
|
const topBar = document.querySelector('.top-bar');
|
|
if (topBar && config.top_bar_gradient_start && config.top_bar_gradient_end) {
|
|
topBar.style.background = `linear-gradient(135deg, ${config.top_bar_gradient_start}, ${config.top_bar_gradient_end})`;
|
|
}
|
|
|
|
// Update footer color
|
|
const footerBar = document.querySelector('.footer-bar');
|
|
if (footerBar && config.footer_color) {
|
|
footerBar.style.background = config.footer_color;
|
|
}
|
|
|
|
// Optionally indicate environment (e.g., add to title if not production)
|
|
if (config.environment && config.environment !== 'production') {
|
|
const envIndicator = ` (${config.environment.toUpperCase()})`;
|
|
if (titleElement) {
|
|
titleElement.innerHTML += envIndicator;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to load UI config:', error);
|
|
}
|
|
}
|
|
|
|
// 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`;
|
|
|
|
wsConnection = new WebSocket(wsUrl);
|
|
|
|
wsConnection.onopen = function(event) {
|
|
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();
|
|
return;
|
|
}
|
|
|
|
const data = JSON.parse(event.data);
|
|
|
|
// Refresh PPRs when any PPR-related event occurs
|
|
if (data.type && (data.type.includes('ppr_') || data.type === 'status_update')) {
|
|
loadPPRs();
|
|
showNotification('Data updated');
|
|
}
|
|
|
|
// Refresh local flights when any local flight event occurs
|
|
if (data.type && (data.type.includes('local_flight_'))) {
|
|
loadPPRs();
|
|
showNotification('Local flight updated');
|
|
}
|
|
|
|
// Refresh departures when any departure event occurs
|
|
if (data.type && (data.type.includes('departure_'))) {
|
|
loadDepartures();
|
|
showNotification('Departure updated');
|
|
}
|
|
|
|
// Refresh arrivals when any arrival event occurs
|
|
if (data.type && (data.type.includes('arrival_'))) {
|
|
loadArrivals();
|
|
showNotification('Arrival updated');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error parsing WebSocket message:', error);
|
|
}
|
|
};
|
|
|
|
wsConnection.onclose = function(event) {
|
|
stopHeartbeat();
|
|
|
|
// Attempt to reconnect after 5 seconds if still logged in
|
|
if (accessToken) {
|
|
showNotification('Real-time updates disconnected, reconnecting...', true);
|
|
wsReconnectTimeout = setTimeout(() => {
|
|
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');
|
|
|
|
// Check if last heartbeat was more than 60 seconds ago
|
|
if (lastHeartbeatResponse && (Date.now() - lastHeartbeatResponse > 60000)) {
|
|
console.warn('⚠️ No heartbeat response for 60 seconds, reconnecting...');
|
|
wsConnection.close();
|
|
}
|
|
} else {
|
|
console.warn('⚠️ WebSocket not open, stopping heartbeat');
|
|
stopHeartbeat();
|
|
}
|
|
}, 30000);
|
|
}
|
|
|
|
function stopHeartbeat() {
|
|
if (wsHeartbeatInterval) {
|
|
clearInterval(wsHeartbeatInterval);
|
|
wsHeartbeatInterval = null;
|
|
}
|
|
}
|
|
|
|
function disconnectWebSocket() {
|
|
stopHeartbeat();
|
|
if (wsReconnectTimeout) {
|
|
clearTimeout(wsReconnectTimeout);
|
|
wsReconnectTimeout = null;
|
|
}
|
|
if (wsConnection) {
|
|
wsConnection.close();
|
|
wsConnection = null;
|
|
}
|
|
}
|
|
|
|
// Notification system
|
|
function showNotification(message, isError = false) {
|
|
const notification = document.getElementById('notification');
|
|
notification.textContent = message;
|
|
notification.className = 'notification' + (isError ? ' error' : '');
|
|
|
|
// Show notification
|
|
setTimeout(() => {
|
|
notification.classList.add('show');
|
|
}, 10);
|
|
|
|
// Hide after 3 seconds
|
|
setTimeout(() => {
|
|
notification.classList.remove('show');
|
|
}, 3000);
|
|
}
|
|
|
|
// Initialize time dropdowns
|
|
function initializeTimeDropdowns() {
|
|
const timeSelects = ['eta-time', 'etd-time'];
|
|
|
|
timeSelects.forEach(selectId => {
|
|
const select = document.getElementById(selectId);
|
|
// Clear existing options except the first one
|
|
select.innerHTML = '<option value="">Select Time</option>';
|
|
|
|
// Add time options in 15-minute intervals
|
|
for (let hour = 0; hour < 24; hour++) {
|
|
for (let minute = 0; minute < 60; minute += 15) {
|
|
const timeString = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
|
const option = document.createElement('option');
|
|
option.value = timeString;
|
|
option.textContent = timeString;
|
|
select.appendChild(option);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Session expiry monitoring
|
|
function startSessionExpiryCheck() {
|
|
// Clear any existing interval
|
|
if (sessionExpiryCheckInterval) {
|
|
clearInterval(sessionExpiryCheckInterval);
|
|
}
|
|
|
|
sessionExpiryWarningShown = false;
|
|
|
|
// Check every 60 seconds
|
|
sessionExpiryCheckInterval = setInterval(() => {
|
|
const tokenExpiry = localStorage.getItem('ppr_token_expiry');
|
|
if (!tokenExpiry || !accessToken) {
|
|
stopSessionExpiryCheck();
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const expiryTime = parseInt(tokenExpiry);
|
|
const timeUntilExpiry = expiryTime - now;
|
|
|
|
// Warn if less than 5 minutes remaining
|
|
if (timeUntilExpiry < 5 * 60 * 1000 && !sessionExpiryWarningShown) {
|
|
sessionExpiryWarningShown = true;
|
|
const minutesLeft = Math.ceil(timeUntilExpiry / 60000);
|
|
showNotification(`⚠️ Session expires in ${minutesLeft} minute${minutesLeft !== 1 ? 's' : ''}. Please save your work.`, true);
|
|
console.warn(`Session expires in ${minutesLeft} minutes`);
|
|
}
|
|
|
|
// Force logout if expired
|
|
if (timeUntilExpiry <= 0) {
|
|
console.error('Session has expired');
|
|
showNotification('Session expired. Please log in again.', true);
|
|
logout();
|
|
}
|
|
}, 60000); // Check every minute
|
|
}
|
|
|
|
function stopSessionExpiryCheck() {
|
|
if (sessionExpiryCheckInterval) {
|
|
clearInterval(sessionExpiryCheckInterval);
|
|
sessionExpiryCheckInterval = null;
|
|
}
|
|
sessionExpiryWarningShown = false;
|
|
}
|
|
|
|
// Authentication management
|
|
async function initializeAuth() {
|
|
// Try to get cached token
|
|
const cachedToken = localStorage.getItem('ppr_access_token');
|
|
const cachedUser = localStorage.getItem('ppr_username');
|
|
const tokenExpiry = localStorage.getItem('ppr_token_expiry');
|
|
|
|
if (cachedToken && cachedUser && tokenExpiry) {
|
|
const now = new Date().getTime();
|
|
if (now < parseInt(tokenExpiry)) {
|
|
// Token is still valid
|
|
accessToken = cachedToken;
|
|
currentUser = cachedUser;
|
|
document.getElementById('current-user').textContent = cachedUser;
|
|
await updateUserRole(); // Update role-based UI
|
|
startSessionExpiryCheck(); // Start monitoring session expiry
|
|
connectWebSocket(); // Connect WebSocket for real-time updates
|
|
loadPPRs();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// No valid cached token, show login
|
|
showLogin();
|
|
}
|
|
|
|
function setupLoginForm() {
|
|
document.getElementById('login-form').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
await handleLogin();
|
|
});
|
|
}
|
|
|
|
function setupKeyboardShortcuts() {
|
|
document.addEventListener('keydown', function(e) {
|
|
// Press 'Escape' to close PPR modal if it's open (allow even when typing in inputs)
|
|
if (e.key === 'Escape' && document.getElementById('pprModal').style.display === 'block') {
|
|
e.preventDefault();
|
|
closePPRModal();
|
|
return;
|
|
}
|
|
|
|
// Press 'Escape' to close timestamp modal if it's open (allow even when typing in inputs)
|
|
if (e.key === 'Escape' && document.getElementById('timestampModal').style.display === 'block') {
|
|
e.preventDefault();
|
|
closeTimestampModal();
|
|
return;
|
|
}
|
|
|
|
// Press 'Escape' to close circuit modal if it's open (allow even when typing in inputs)
|
|
if (e.key === 'Escape' && document.getElementById('circuitModal').style.display === 'block') {
|
|
e.preventDefault();
|
|
closeCircuitModal();
|
|
return;
|
|
}
|
|
|
|
// Press 'Escape' to close Book Out modal if it's open (allow even when typing in inputs)
|
|
if (e.key === 'Escape' && document.getElementById('localFlightModal').style.display === 'block') {
|
|
e.preventDefault();
|
|
closeModal('localFlightModal');
|
|
return;
|
|
}
|
|
|
|
// Press 'Escape' to close Book In modal if it's open (allow even when typing in inputs)
|
|
if (e.key === 'Escape' && document.getElementById('bookInModal').style.display === 'block') {
|
|
e.preventDefault();
|
|
closeModal('bookInModal');
|
|
return;
|
|
}
|
|
|
|
// Press 'Escape' to close Overflight modal if it's open (allow even when typing in inputs)
|
|
if (e.key === 'Escape' && document.getElementById('overflightModal').style.display === 'block') {
|
|
e.preventDefault();
|
|
closeModal('overflightModal');
|
|
return;
|
|
}
|
|
|
|
// Press 'Escape' to close local flight edit modal if it's open (allow even when typing in inputs)
|
|
if (e.key === 'Escape' && document.getElementById('localFlightEditModal').style.display === 'block') {
|
|
e.preventDefault();
|
|
closeLocalFlightEditModal();
|
|
return;
|
|
}
|
|
|
|
// Press 'Escape' to close departure edit modal if it's open (allow even when typing in inputs)
|
|
if (e.key === 'Escape' && document.getElementById('departureEditModal').style.display === 'block') {
|
|
e.preventDefault();
|
|
closeDepartureEditModal();
|
|
return;
|
|
}
|
|
|
|
// Press 'Escape' to close arrival edit modal if it's open (allow even when typing in inputs)
|
|
if (e.key === 'Escape' && document.getElementById('arrivalEditModal').style.display === 'block') {
|
|
e.preventDefault();
|
|
closeArrivalEditModal();
|
|
return;
|
|
}
|
|
|
|
// Press 'Escape' to close overflight edit modal if it's open (allow even when typing in inputs)
|
|
if (e.key === 'Escape' && document.getElementById('overflightEditModal').style.display === 'block') {
|
|
e.preventDefault();
|
|
closeModal('overflightEditModal');
|
|
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 'g' to book out local flight starting with G
|
|
if (e.key === 'g' || e.key === 'G') {
|
|
e.preventDefault();
|
|
openLocalFlightModal('LOCAL', 'G');
|
|
}
|
|
|
|
// Press 'l' to book out local flight (LOCAL type)
|
|
if (e.key === 'l' || e.key === 'L') {
|
|
e.preventDefault();
|
|
openLocalFlightModal('LOCAL');
|
|
}
|
|
|
|
// Press 'o' to open overflight modal
|
|
if (e.key === 'o' || e.key === 'O') {
|
|
e.preventDefault();
|
|
openOverflightModal();
|
|
}
|
|
|
|
// Press 'c' to book out circuits
|
|
if (e.key === 'c' || e.key === 'C') {
|
|
e.preventDefault();
|
|
openLocalFlightModal('CIRCUITS');
|
|
}
|
|
|
|
// Press 'd' to book out departure
|
|
if (e.key === 'd' || e.key === 'D') {
|
|
e.preventDefault();
|
|
openLocalFlightModal('DEPARTURE');
|
|
}
|
|
|
|
// Press 'i' to book in arrival
|
|
if (e.key === 'i' || e.key === 'I') {
|
|
e.preventDefault();
|
|
openBookInModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Dropdown menu handlers
|
|
document.addEventListener('click', function(e) {
|
|
const actionsBtn = document.getElementById('actionsDropdownBtn');
|
|
const actionsMenu = document.getElementById('actionsDropdownMenu');
|
|
const adminBtn = document.getElementById('adminDropdownBtn');
|
|
const adminMenu = document.getElementById('adminDropdownMenu');
|
|
|
|
// Handle Actions dropdown
|
|
if (e.target === actionsBtn) {
|
|
e.preventDefault();
|
|
actionsMenu.classList.toggle('active');
|
|
adminMenu.classList.remove('active');
|
|
} else if (!actionsMenu.contains(e.target)) {
|
|
actionsMenu.classList.remove('active');
|
|
}
|
|
|
|
// Handle Admin dropdown
|
|
if (e.target === adminBtn) {
|
|
e.preventDefault();
|
|
adminMenu.classList.toggle('active');
|
|
actionsMenu.classList.remove('active');
|
|
} else if (!adminMenu.contains(e.target)) {
|
|
adminMenu.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
function closeActionsDropdown() {
|
|
document.getElementById('actionsDropdownMenu').classList.remove('active');
|
|
}
|
|
|
|
function closeAdminDropdown() {
|
|
document.getElementById('adminDropdownMenu').classList.remove('active');
|
|
}
|
|
|
|
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
|
|
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 = '';
|
|
|
|
// Clear the unsaved aircraft flag
|
|
document.getElementById('ppr-form').removeAttribute('data-unsaved-aircraft');
|
|
|
|
// 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) {
|
|
Object.keys(ppr).forEach(key => {
|
|
if (key === 'eta' || key === 'etd') {
|
|
if (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
|
|
|
|
// 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;
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
} else {
|
|
const field = document.getElementById(key);
|
|
if (field) {
|
|
field.value = ppr[key] || '';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Generic function to load journal for any entity type
|
|
async function loadJournalForEntity(entityType, entityId, containerElementId) {
|
|
try {
|
|
const response = await fetch(`/api/v1/journal/${entityType}/${entityId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch journal');
|
|
}
|
|
|
|
const data = await response.json();
|
|
// The new API returns { entity_type, entity_id, entries, total_entries }
|
|
displayJournalForContainer(data.entries || [], containerElementId);
|
|
} catch (error) {
|
|
console.error('Error loading journal:', error);
|
|
const container = document.getElementById(containerElementId);
|
|
if (container) container.innerHTML = 'Error loading journal entries';
|
|
}
|
|
}
|
|
|
|
// PPR-specific journal loader (backward compatible)
|
|
async function loadJournal(pprId) {
|
|
await loadJournalForEntity('PPR', pprId, 'journal-entries');
|
|
}
|
|
|
|
// Local Flight specific journal loader
|
|
async function loadLocalFlightJournal(flightId) {
|
|
await loadJournalForEntity('LOCAL_FLIGHT', flightId, 'local-flight-journal-entries');
|
|
}
|
|
|
|
// Departure specific journal loader
|
|
async function loadDepartureJournal(departureId) {
|
|
await loadJournalForEntity('DEPARTURE', departureId, 'departure-journal-entries');
|
|
}
|
|
|
|
// Arrival specific journal loader
|
|
async function loadArrivalJournal(arrivalId) {
|
|
await loadJournalForEntity('ARRIVAL', arrivalId, 'arrival-journal-entries');
|
|
}
|
|
|
|
// Display journal in a specific container
|
|
function displayJournalForContainer(entries, containerElementId) {
|
|
const container = document.getElementById(containerElementId);
|
|
if (!container) return;
|
|
|
|
if (entries.length === 0) {
|
|
container.innerHTML = '<p>No journal entries yet.</p>';
|
|
} else {
|
|
container.innerHTML = entries.map(entry => `
|
|
<div class="journal-entry">
|
|
<div class="journal-meta">
|
|
${formatDateTime(entry.entry_dt)} by ${entry.user}
|
|
</div>
|
|
<div class="journal-text">${entry.entry}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
}
|
|
|
|
function displayJournal(entries) {
|
|
displayJournalForContainer(entries, 'journal-entries');
|
|
|
|
// Always show journal section when displaying entries
|
|
document.getElementById('journal-section').style.display = 'block';
|
|
}
|
|
|
|
function closePPRModal() {
|
|
closeModal('pprModal', () => {
|
|
currentPPRId = null;
|
|
isNewPPR = false;
|
|
});
|
|
}
|
|
|
|
// Timestamp modal functions
|
|
function showTimestampModal(status, pprId = null, isLocalFlight = false, isDeparture = false, isBookedIn = false) {
|
|
const targetId = pprId || (isLocalFlight ? currentLocalFlightId : (isBookedIn ? currentBookedInArrivalId : currentPPRId));
|
|
if (!targetId) return;
|
|
|
|
pendingStatusUpdate = { status: status, pprId: targetId, isLocalFlight: isLocalFlight, isDeparture: isDeparture, isBookedIn: isBookedIn };
|
|
|
|
const modalTitle = document.getElementById('timestamp-modal-title');
|
|
const submitBtn = document.getElementById('timestamp-submit-btn');
|
|
|
|
if (status === 'LANDED') {
|
|
modalTitle.textContent = 'Confirm Landing Time';
|
|
submitBtn.textContent = '🛬 Confirm Landing';
|
|
} else if (status === 'DEPARTED') {
|
|
modalTitle.textContent = 'Confirm Departure Time';
|
|
submitBtn.textContent = '🛫 Confirm Departure';
|
|
} else if (status === 'GROUND') {
|
|
modalTitle.textContent = 'Confirm Contact Time';
|
|
submitBtn.textContent = '📞 Confirm Contact';
|
|
} else if (status === 'LOCAL') {
|
|
modalTitle.textContent = 'Confirm Takeoff Time';
|
|
submitBtn.textContent = '🛫 Confirm Takeoff';
|
|
}
|
|
|
|
// Set default timestamp to current UTC time
|
|
const now = new Date();
|
|
const year = now.getUTCFullYear();
|
|
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
const day = String(now.getUTCDate()).padStart(2, '0');
|
|
const hours = String(now.getUTCHours()).padStart(2, '0');
|
|
const minutes = String(now.getUTCMinutes()).padStart(2, '0');
|
|
document.getElementById('event-timestamp').value = `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
|
|
document.getElementById('timestampModal').style.display = 'block';
|
|
}
|
|
|
|
function closeTimestampModal() {
|
|
document.getElementById('timestampModal').style.display = 'none';
|
|
pendingStatusUpdate = null;
|
|
document.getElementById('timestamp-form').reset();
|
|
}
|
|
|
|
// Circuit modal functions
|
|
function showCircuitModal(localFlightId = null, arrivalId = null) {
|
|
if (!localFlightId && !arrivalId) return;
|
|
|
|
// Set the current IDs
|
|
currentLocalFlightId = localFlightId;
|
|
currentArrivalId = arrivalId;
|
|
|
|
// Set default timestamp to current UTC time
|
|
const now = new Date();
|
|
const year = now.getUTCFullYear();
|
|
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
const day = String(now.getUTCDate()).padStart(2, '0');
|
|
const hours = String(now.getUTCHours()).padStart(2, '0');
|
|
const minutes = String(now.getUTCMinutes()).padStart(2, '0');
|
|
document.getElementById('circuit-timestamp').value = `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
|
|
document.getElementById('circuitModal').style.display = 'block';
|
|
}
|
|
|
|
function closeCircuitModal() {
|
|
document.getElementById('circuitModal').style.display = 'none';
|
|
document.getElementById('circuit-form').reset();
|
|
currentLocalFlightId = null;
|
|
currentArrivalId = null;
|
|
}
|
|
|
|
// Circuit form submission
|
|
document.getElementById('circuit-form').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
if ((!currentLocalFlightId && !currentArrivalId) || !accessToken) return;
|
|
|
|
const circuitTimestampInput = document.getElementById('circuit-timestamp').value;
|
|
if (!circuitTimestampInput) {
|
|
showNotification('Please select a circuit time', true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Input is treated as UTC - append Z to force UTC interpretation
|
|
const circuitTimestamp = circuitTimestampInput + ':00Z';
|
|
|
|
const requestBody = {
|
|
circuit_timestamp: circuitTimestamp
|
|
};
|
|
|
|
// Add the appropriate ID based on what we're tracking
|
|
if (currentLocalFlightId) {
|
|
requestBody.local_flight_id = currentLocalFlightId;
|
|
} else if (currentArrivalId) {
|
|
requestBody.arrival_id = currentArrivalId;
|
|
}
|
|
|
|
const response = await authenticatedFetch('/api/v1/circuits/', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(requestBody)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.detail || 'Failed to record circuit');
|
|
}
|
|
|
|
const circuit = await response.json();
|
|
showNotification(`✈️ Circuit recorded at ${formatTimeOnly(circuit.circuit_timestamp)}`);
|
|
await afterCircuitSaved();
|
|
} catch (error) {
|
|
console.error('Error recording circuit:', error);
|
|
showNotification('Error recording circuit: ' + error.message, true);
|
|
}
|
|
});
|
|
|
|
// Default hook: override per page to add post-save behaviour
|
|
async function afterCircuitSaved() {
|
|
closeCircuitModal();
|
|
loadPPRs();
|
|
}
|
|
|
|
document.getElementById('timestamp-form').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
// Handle overflight QSY mode
|
|
if (isOverflightQSYMode) {
|
|
isOverflightQSYMode = false;
|
|
await handleOverflightQSYSubmit();
|
|
return;
|
|
}
|
|
|
|
if (!pendingStatusUpdate || !accessToken) return;
|
|
|
|
const timestampInput = document.getElementById('event-timestamp').value;
|
|
let timestamp = null;
|
|
|
|
if (timestampInput.trim()) {
|
|
// Input is UTC - append Z to force UTC interpretation
|
|
timestamp = timestampInput + ':00Z';
|
|
}
|
|
|
|
try {
|
|
// Determine the correct API endpoint based on flight type
|
|
const isLocal = pendingStatusUpdate.isLocalFlight;
|
|
const isDeparture = pendingStatusUpdate.isDeparture;
|
|
const isBookedIn = pendingStatusUpdate.isBookedIn;
|
|
let endpoint;
|
|
|
|
if (isLocal) {
|
|
endpoint = `/api/v1/local-flights/${pendingStatusUpdate.pprId}/status`;
|
|
} else if (isDeparture) {
|
|
endpoint = `/api/v1/departures/${pendingStatusUpdate.pprId}/status`;
|
|
} else if (isBookedIn) {
|
|
endpoint = `/api/v1/arrivals/${pendingStatusUpdate.pprId}/status`;
|
|
} else {
|
|
endpoint = `/api/v1/pprs/${pendingStatusUpdate.pprId}/status`;
|
|
}
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
body: JSON.stringify({
|
|
status: pendingStatusUpdate.status,
|
|
timestamp: timestamp
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(`Failed to update status: ${response.status} ${response.statusText} - ${errorData.detail || 'Unknown error'}`);
|
|
}
|
|
|
|
const updatedStatus = pendingStatusUpdate.status;
|
|
closeTimestampModal();
|
|
// Refresh appropriate table based on flight type
|
|
if (isBookedIn) {
|
|
loadArrivals(); // Refresh arrivals table
|
|
} else {
|
|
loadPPRs(); // Refresh all tables (PPR, local, departures)
|
|
}
|
|
showNotification(`Status updated to ${updatedStatus}`);
|
|
if (!isLocal && !isBookedIn) {
|
|
closePPRModal(); // Close PPR modal after successful status update
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating status:', error);
|
|
showNotification(`Error updating status: ${error.message}`, true);
|
|
}
|
|
});
|
|
|
|
// Form submission
|
|
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
if (!accessToken) return;
|
|
|
|
// Auto-save any unsaved aircraft types
|
|
await autoSaveUnsavedAircraft(this);
|
|
|
|
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
|
|
async function openUserManagementModal() {
|
|
if (!accessToken) return;
|
|
|
|
document.getElementById('userManagementModal').style.display = 'block';
|
|
await loadUsers();
|
|
}
|
|
|
|
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() {
|
|
closeModal('userModal', () => {
|
|
currentUserId = null;
|
|
isNewUser = false;
|
|
});
|
|
}
|
|
|
|
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() {
|
|
closeModal('changePasswordModal', () => {
|
|
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);
|
|
}
|
|
});
|
|
|
|
// ==================== USER AIRCRAFT MANAGEMENT ====================
|
|
|
|
async function openUserAircraftModal() {
|
|
if (!accessToken) return;
|
|
|
|
document.getElementById('userAircraftModal').style.display = 'block';
|
|
await loadUserAircraft();
|
|
|
|
// Set up search functionality
|
|
const searchInput = document.getElementById('user-aircraft-search');
|
|
searchInput.addEventListener('input', filterUserAircraft);
|
|
}
|
|
|
|
async function loadUserAircraft() {
|
|
if (!accessToken) return;
|
|
|
|
document.getElementById('user-aircraft-loading').style.display = 'block';
|
|
document.getElementById('user-aircraft-table-content').style.display = 'none';
|
|
document.getElementById('user-aircraft-no-data').style.display = 'none';
|
|
|
|
try {
|
|
const response = await authenticatedFetch('/api/v1/aircraft/user-aircraft');
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch user aircraft');
|
|
}
|
|
|
|
const userAircraft = await response.json();
|
|
displayUserAircraft(userAircraft);
|
|
} catch (error) {
|
|
console.error('Error loading user aircraft:', error);
|
|
if (error.message !== 'Session expired. Please log in again.') {
|
|
showNotification('Error loading user aircraft', true);
|
|
}
|
|
}
|
|
|
|
document.getElementById('user-aircraft-loading').style.display = 'none';
|
|
}
|
|
|
|
function displayUserAircraft(userAircraft) {
|
|
const tbody = document.getElementById('user-aircraft-table-body');
|
|
const searchInput = document.getElementById('user-aircraft-search');
|
|
|
|
// Store original data for filtering
|
|
tbody._originalData = userAircraft;
|
|
|
|
if (userAircraft.length === 0) {
|
|
document.getElementById('user-aircraft-no-data').style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
// Apply current filter if any
|
|
const filterText = searchInput.value.trim().toLowerCase();
|
|
const filteredAircraft = filterText ?
|
|
userAircraft.filter(ua =>
|
|
ua.registration.toLowerCase().includes(filterText) ||
|
|
ua.type_code.toLowerCase().includes(filterText) ||
|
|
ua.created_by.toLowerCase().includes(filterText)
|
|
) : userAircraft;
|
|
|
|
if (filteredAircraft.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 2rem;">No aircraft match the search criteria</td></tr>';
|
|
} else {
|
|
tbody.innerHTML = '';
|
|
filteredAircraft.forEach(aircraft => {
|
|
const row = document.createElement('tr');
|
|
|
|
// Format created date
|
|
const createdDate = aircraft.created_at ? formatDateTime(aircraft.created_at) : '-';
|
|
|
|
row.innerHTML = `
|
|
<td><strong>${aircraft.registration}</strong></td>
|
|
<td>${aircraft.type_code}</td>
|
|
<td>${aircraft.created_by}</td>
|
|
<td>${createdDate}</td>
|
|
<td>
|
|
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); openUserAircraftEditModal('${aircraft.registration}', '${aircraft.type_code}')" title="Edit Aircraft">
|
|
✏️
|
|
</button>
|
|
</td>
|
|
`;
|
|
|
|
tbody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
document.getElementById('user-aircraft-table-content').style.display = 'block';
|
|
}
|
|
|
|
function filterUserAircraft() {
|
|
const tbody = document.getElementById('user-aircraft-table-body');
|
|
if (tbody._originalData) {
|
|
displayUserAircraft(tbody._originalData);
|
|
}
|
|
}
|
|
|
|
function openUserAircraftEditModal(registration, typeCode) {
|
|
document.getElementById('edit-aircraft-registration').value = registration;
|
|
document.getElementById('edit-aircraft-type').value = typeCode;
|
|
document.getElementById('user-aircraft-edit-title').textContent = `Edit ${registration}`;
|
|
document.getElementById('userAircraftEditModal').style.display = 'block';
|
|
|
|
// Auto-focus on type field
|
|
setTimeout(() => {
|
|
document.getElementById('edit-aircraft-type').focus();
|
|
}, 100);
|
|
}
|
|
|
|
// User aircraft edit form submission
|
|
document.getElementById('user-aircraft-edit-form').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
if (!accessToken) return;
|
|
|
|
const registration = document.getElementById('edit-aircraft-registration').value.trim();
|
|
const typeCode = document.getElementById('edit-aircraft-type').value.trim();
|
|
|
|
if (!registration || !typeCode) {
|
|
showNotification('Registration and type are required', true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await authenticatedFetch(`/api/v1/aircraft/user-aircraft/${registration}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
registration: registration,
|
|
type_code: typeCode
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(errorData.detail || 'Failed to update aircraft');
|
|
}
|
|
|
|
closeModal('userAircraftEditModal');
|
|
await loadUserAircraft(); // Refresh list
|
|
showNotification('Aircraft updated successfully!');
|
|
} catch (error) {
|
|
console.error('Error updating aircraft:', error);
|
|
showNotification(`Error updating aircraft: ${error.message}`, true);
|
|
}
|
|
});
|
|
|
|
async function deleteUserAircraft() {
|
|
if (!accessToken) return;
|
|
|
|
const registration = document.getElementById('edit-aircraft-registration').value.trim();
|
|
|
|
if (!confirm(`Are you sure you want to delete the aircraft entry for ${registration}? This action cannot be undone.`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await authenticatedFetch(`/api/v1/aircraft/user-aircraft/${registration}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(errorData.detail || 'Failed to delete aircraft');
|
|
}
|
|
|
|
closeModal('userAircraftEditModal');
|
|
await loadUserAircraft(); // Refresh list
|
|
showNotification('Aircraft deleted successfully!');
|
|
} catch (error) {
|
|
console.error('Error deleting aircraft:', error);
|
|
showNotification(`Error deleting aircraft: ${error.message}`, true);
|
|
}
|
|
}
|
|
|
|
// Update user role detection and UI visibility
|
|
async function updateUserRole() {
|
|
if (!accessToken) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await authenticatedFetch('/api/v1/auth/test-token', {
|
|
method: 'POST'
|
|
});
|
|
|
|
if (response.ok) {
|
|
const userData = await response.json();
|
|
currentUserRole = userData.role;
|
|
|
|
// Show user management in dropdown only for administrators
|
|
const userManagementDropdown = document.getElementById('user-management-dropdown');
|
|
// Show user aircraft for operators and administrators
|
|
const userAircraftDropdown = document.getElementById('user-aircraft-dropdown');
|
|
|
|
if (currentUserRole && currentUserRole.toUpperCase() === 'ADMINISTRATOR') {
|
|
if (userManagementDropdown) userManagementDropdown.style.display = 'block';
|
|
if (userAircraftDropdown) userAircraftDropdown.style.display = 'block';
|
|
} else if (currentUserRole && currentUserRole.toUpperCase() === 'OPERATOR') {
|
|
if (userManagementDropdown) userManagementDropdown.style.display = 'none';
|
|
if (userAircraftDropdown) userAircraftDropdown.style.display = 'block';
|
|
} else {
|
|
if (userManagementDropdown) userManagementDropdown.style.display = 'none';
|
|
if (userAircraftDropdown) userAircraftDropdown.style.display = 'none';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating user role:', error);
|
|
// Hide admin features by default on error
|
|
const userManagementDropdown = document.getElementById('user-management-dropdown');
|
|
const userAircraftDropdown = document.getElementById('user-aircraft-dropdown');
|
|
if (userManagementDropdown) userManagementDropdown.style.display = 'none';
|
|
if (userAircraftDropdown) userAircraftDropdown.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Close modal when clicking outside
|
|
window.onclick = function(event) {
|
|
const pprModal = document.getElementById('pprModal');
|
|
const timestampModal = document.getElementById('timestampModal');
|
|
const userManagementModal = document.getElementById('userManagementModal');
|
|
const userModal = document.getElementById('userModal');
|
|
const tableHelpModal = document.getElementById('tableHelpModal');
|
|
const bookInModal = document.getElementById('bookInModal');
|
|
|
|
if (event.target === pprModal) {
|
|
closePPRModal();
|
|
}
|
|
if (event.target === timestampModal) {
|
|
closeTimestampModal();
|
|
}
|
|
if (event.target === userManagementModal) {
|
|
closeModal('userManagementModal');
|
|
}
|
|
if (event.target === userModal) {
|
|
closeUserModal();
|
|
}
|
|
if (event.target === tableHelpModal) {
|
|
closeModal('tableHelpModal');
|
|
}
|
|
if (event.target === bookInModal) {
|
|
closeModal('bookInModal');
|
|
}
|
|
}
|
|
|
|
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', prefillReg = '') {
|
|
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 the unsaved aircraft flag
|
|
document.getElementById('local-flight-form').removeAttribute('data-unsaved-aircraft');
|
|
|
|
// Clear aircraft lookup results
|
|
clearLocalAircraftLookup();
|
|
|
|
// Update destination field visibility based on flight type
|
|
handleFlightTypeChange(flightType);
|
|
|
|
// Auto-focus on registration field and prefill if provided
|
|
setTimeout(() => {
|
|
const regField = document.getElementById('local_registration');
|
|
regField.focus();
|
|
if (prefillReg) {
|
|
regField.value = prefillReg;
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
function openBookInModal() {
|
|
document.getElementById('book-in-form').reset();
|
|
document.getElementById('book-in-id').value = '';
|
|
document.getElementById('bookInModal').style.display = 'block';
|
|
|
|
// Clear the unsaved aircraft flag
|
|
document.getElementById('book-in-form').removeAttribute('data-unsaved-aircraft');
|
|
|
|
// Clear aircraft lookup results
|
|
clearBookInAircraftLookup();
|
|
clearBookInArrivalAirportLookup();
|
|
|
|
// Populate ETA time slots
|
|
populateETATimeSlots();
|
|
|
|
// Auto-focus on registration field
|
|
setTimeout(() => {
|
|
document.getElementById('book_in_registration').focus();
|
|
}, 100);
|
|
}
|
|
|
|
function openOverflightModal() {
|
|
document.getElementById('overflight-form').reset();
|
|
document.getElementById('overflight-id').value = '';
|
|
document.getElementById('overflightModal').style.display = 'block';
|
|
|
|
// Clear the unsaved aircraft flag
|
|
document.getElementById('overflight-form').removeAttribute('data-unsaved-aircraft');
|
|
|
|
// Clear aircraft lookup results
|
|
clearOverflightAircraftLookup();
|
|
clearOverflightDepartureAirportLookup();
|
|
clearOverflightDestinationAirportLookup();
|
|
|
|
// Set current UTC time as call time
|
|
const now = new Date();
|
|
const year = now.getUTCFullYear();
|
|
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
const day = String(now.getUTCDate()).padStart(2, '0');
|
|
const hours = now.getUTCHours().toString().padStart(2, '0');
|
|
const minutes = now.getUTCMinutes().toString().padStart(2, '0');
|
|
document.getElementById('overflight_call_dt').value = `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
|
|
// Auto-focus on registration field
|
|
setTimeout(() => {
|
|
document.getElementById('overflight_registration').focus();
|
|
}, 100);
|
|
}
|
|
|
|
async function openOverflightEditModal(overflightId) {
|
|
if (!accessToken) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/overflights/${overflightId}`, {
|
|
headers: { 'Authorization': `Bearer ${accessToken}` }
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to load overflight');
|
|
|
|
const overflight = await response.json();
|
|
currentOverflightId = overflight.id;
|
|
|
|
// Populate form
|
|
document.getElementById('overflight-edit-id').value = overflight.id;
|
|
document.getElementById('overflight_edit_registration').value = overflight.registration;
|
|
document.getElementById('overflight_edit_type').value = overflight.type || '';
|
|
document.getElementById('overflight_edit_pob').value = overflight.pob || '';
|
|
document.getElementById('overflight_edit_departure_airfield').value = overflight.departure_airfield || '';
|
|
document.getElementById('overflight_edit_destination_airfield').value = overflight.destination_airfield || '';
|
|
document.getElementById('overflight_edit_status').value = overflight.status;
|
|
document.getElementById('overflight_edit_notes').value = overflight.notes || '';
|
|
|
|
// Parse and populate call_dt
|
|
if (overflight.call_dt) {
|
|
const callDt = new Date(overflight.call_dt);
|
|
document.getElementById('overflight_edit_call_dt').value = callDt.toISOString().slice(0, 16);
|
|
}
|
|
|
|
// Parse and populate qsy_dt if exists
|
|
if (overflight.qsy_dt) {
|
|
const qsyDt = new Date(overflight.qsy_dt);
|
|
document.getElementById('overflight_edit_qsy_dt').value = qsyDt.toISOString().slice(0, 16);
|
|
} else {
|
|
document.getElementById('overflight_edit_qsy_dt').value = '';
|
|
}
|
|
|
|
// Show/hide action buttons based on status
|
|
const qsyBtn = document.getElementById('overflight-btn-qsy');
|
|
const cancelBtn = document.getElementById('overflight-btn-cancel');
|
|
|
|
if (qsyBtn) qsyBtn.style.display = overflight.status === 'ACTIVE' ? 'inline-block' : 'none';
|
|
if (cancelBtn) cancelBtn.style.display = overflight.status === 'ACTIVE' ? 'inline-block' : 'none';
|
|
|
|
document.getElementById('overflight-edit-title').textContent = `${overflight.registration} - Overflight`;
|
|
document.getElementById('overflightEditModal').style.display = 'block';
|
|
} catch (error) {
|
|
console.error('Error loading overflight:', error);
|
|
showNotification('Error loading overflight details', true);
|
|
}
|
|
}
|
|
|
|
function closeOverflightEditModal() {
|
|
closeModal('overflightEditModal');
|
|
}
|
|
|
|
async function updateOverflightStatus(newStatus, qsyTime = null) {
|
|
if (!currentOverflightId || !accessToken) return;
|
|
|
|
try {
|
|
const body = { status: newStatus };
|
|
if (qsyTime) {
|
|
body.qsy_dt = qsyTime;
|
|
}
|
|
|
|
const response = await fetch(`/api/v1/overflights/${currentOverflightId}/status`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to update overflight status');
|
|
|
|
closeOverflightEditModal();
|
|
loadPPRs(); // Refresh overflights display
|
|
showNotification(`Overflight marked as ${newStatus}`);
|
|
} catch (error) {
|
|
console.error('Error updating overflight status:', error);
|
|
showNotification('Error updating overflight status', true);
|
|
}
|
|
}
|
|
|
|
function showOverflightQSYModal() {
|
|
if (!currentOverflightId) return;
|
|
|
|
isOverflightQSYMode = true;
|
|
|
|
const modalTitle = document.getElementById('timestamp-modal-title');
|
|
const submitBtn = document.getElementById('timestamp-submit-btn');
|
|
|
|
modalTitle.textContent = 'Confirm QSY Time';
|
|
submitBtn.textContent = '📡 Confirm QSY';
|
|
|
|
// Set default timestamp to current UTC time
|
|
const now = new Date();
|
|
const year = now.getUTCFullYear();
|
|
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
const day = String(now.getUTCDate()).padStart(2, '0');
|
|
const hours = String(now.getUTCHours()).padStart(2, '0');
|
|
const minutes = String(now.getUTCMinutes()).padStart(2, '0');
|
|
document.getElementById('event-timestamp').value = `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
|
|
document.getElementById('timestampModal').style.display = 'block';
|
|
}
|
|
|
|
async function handleOverflightQSYSubmit() {
|
|
const timestamp = document.getElementById('event-timestamp').value;
|
|
if (!timestamp) {
|
|
showNotification('Please enter a QSY time', true);
|
|
return;
|
|
}
|
|
|
|
// Convert datetime-local value to ISO string
|
|
// datetime-local format is "YYYY-MM-DDTHH:mm" and we need to treat it as UTC
|
|
const isoString = timestamp + ':00Z'; // Add seconds and Z for UTC
|
|
|
|
closeTimestampModal();
|
|
await updateOverflightStatus('INACTIVE', isoString);
|
|
}
|
|
|
|
async function cancelOverflightFromTable(overflightId) {
|
|
if (!confirm('Are you sure you want to cancel this overflight? This action cannot be easily undone.')) {
|
|
return;
|
|
}
|
|
|
|
if (!accessToken) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/overflights/${overflightId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to cancel overflight');
|
|
|
|
loadPPRs();
|
|
showNotification('Overflight cancelled');
|
|
} catch (error) {
|
|
console.error('Error cancelling overflight:', error);
|
|
showNotification('Error cancelling overflight', true);
|
|
}
|
|
}
|
|
|
|
function confirmCancelOverflight() {
|
|
if (!currentOverflightId) return;
|
|
|
|
if (!confirm('Are you sure you want to cancel this overflight? This action cannot be easily undone.')) {
|
|
return;
|
|
}
|
|
|
|
cancelOverflightFromModal();
|
|
}
|
|
|
|
async function cancelOverflightFromModal() {
|
|
if (!currentOverflightId || !accessToken) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/overflights/${currentOverflightId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to cancel overflight');
|
|
|
|
closeOverflightEditModal();
|
|
loadPPRs();
|
|
showNotification('Overflight cancelled');
|
|
} catch (error) {
|
|
console.error('Error cancelling overflight:', error);
|
|
showNotification('Error cancelling overflight', true);
|
|
}
|
|
}
|
|
|
|
// Overflight edit form submission
|
|
document.getElementById('overflight-edit-form').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
if (!currentOverflightId || !accessToken) return;
|
|
|
|
const formData = new FormData(this);
|
|
const updateData = {};
|
|
|
|
formData.forEach((value, key) => {
|
|
if (key === 'id') return;
|
|
|
|
// Handle datetime-local fields - treat as UTC
|
|
if (key === 'call_dt' || key === 'qsy_dt') {
|
|
if (value) {
|
|
updateData[key] = value + ':00Z';
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Only include non-empty values
|
|
if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) {
|
|
if (value.trim) {
|
|
updateData[key] = value.trim();
|
|
} else {
|
|
updateData[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/overflights/${currentOverflightId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
body: JSON.stringify(updateData)
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to update overflight');
|
|
|
|
closeOverflightEditModal();
|
|
loadPPRs(); // Refresh overflights display
|
|
showNotification('Overflight updated successfully');
|
|
} catch (error) {
|
|
console.error('Error updating overflight:', error);
|
|
showNotification('Error updating overflight', true);
|
|
}
|
|
});
|
|
|
|
function populateETATimeSlots() {
|
|
const select = document.getElementById('book_in_eta_time');
|
|
const next15MinSlot = getNext10MinuteSlot();
|
|
|
|
select.innerHTML = '';
|
|
|
|
for (let i = 0; i < 14; i++) {
|
|
const time = new Date(next15MinSlot.getTime() + i * 10 * 60 * 1000);
|
|
const hours = time.getUTCHours().toString().padStart(2, '0');
|
|
const minutes = time.getUTCMinutes().toString().padStart(2, '0');
|
|
const timeStr = `${hours}:${minutes}`;
|
|
|
|
const option = document.createElement('option');
|
|
option.value = timeStr;
|
|
option.textContent = timeStr;
|
|
if (i === 0) {
|
|
option.selected = true;
|
|
}
|
|
select.appendChild(option);
|
|
}
|
|
}
|
|
|
|
// Handle flight type change to show/hide destination field
|
|
function handleFlightTypeChange(flightType) {
|
|
const destGroup = document.getElementById('departure-destination-group');
|
|
const destInput = document.getElementById('local_out_to');
|
|
const destLabel = document.getElementById('departure-destination-label');
|
|
|
|
if (flightType === 'DEPARTURE') {
|
|
destGroup.style.display = 'block';
|
|
destInput.required = true;
|
|
destLabel.textContent = 'Destination Airport *';
|
|
} else {
|
|
destGroup.style.display = 'none';
|
|
destInput.required = false;
|
|
destInput.value = '';
|
|
destLabel.textContent = 'Destination Airport';
|
|
}
|
|
|
|
// Populate ETD time slots for all flight types
|
|
populateETDTimeSlots();
|
|
}
|
|
|
|
function getNext10MinuteSlot() {
|
|
const now = new Date();
|
|
const utcMinutes = now.getUTCMinutes();
|
|
const remainder = utcMinutes % 10;
|
|
|
|
const next = new Date(now);
|
|
if (remainder !== 0) {
|
|
next.setTime(now.getTime() + (10 - remainder) * 60 * 1000);
|
|
}
|
|
next.setUTCSeconds(0, 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.getUTCHours();
|
|
const defaultMinute = defaultTime.getUTCMinutes();
|
|
|
|
// 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
|
|
async function openLocalFlightEditModal(flightId) {
|
|
if (!accessToken) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/local-flights/${flightId}`, {
|
|
headers: { 'Authorization': `Bearer ${accessToken}` }
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to load flight');
|
|
|
|
const flight = await response.json();
|
|
currentLocalFlightId = flight.id;
|
|
|
|
// Populate form
|
|
document.getElementById('local-edit-flight-id').value = flight.id;
|
|
document.getElementById('local_edit_registration').value = flight.registration;
|
|
document.getElementById('local_edit_type').value = flight.type;
|
|
document.getElementById('local_edit_callsign').value = flight.callsign || '';
|
|
document.getElementById('local_edit_pob').value = flight.pob;
|
|
document.getElementById('local_edit_flight_type').value = flight.flight_type;
|
|
document.getElementById('local_edit_duration').value = flight.duration || 45;
|
|
document.getElementById('local_edit_notes').value = flight.notes || '';
|
|
|
|
// Parse and populate takeoff time if exists
|
|
// Use takeoff_dt (actual takeoff) if available, otherwise departed_dt, otherwise etd
|
|
const takeoffTime = flight.takeoff_dt || flight.departed_dt || flight.etd;
|
|
if (takeoffTime) {
|
|
const tStr = takeoffTime.includes('Z') ? takeoffTime : takeoffTime.replace(' ', 'T') + 'Z';
|
|
const dept = new Date(tStr);
|
|
document.getElementById('local_edit_departure_date').value = dept.toISOString().slice(0, 10);
|
|
document.getElementById('local_edit_departure_time').value = dept.toISOString().slice(11, 16);
|
|
} else {
|
|
document.getElementById('local_edit_departure_date').value = '';
|
|
document.getElementById('local_edit_departure_time').value = '';
|
|
}
|
|
|
|
// Parse and populate landing time if exists
|
|
if (flight.landed_dt) {
|
|
const lStr = flight.landed_dt.includes('Z') ? flight.landed_dt : flight.landed_dt.replace(' ', 'T') + 'Z';
|
|
const land = new Date(lStr);
|
|
document.getElementById('local_edit_landed_date').value = land.toISOString().slice(0, 10);
|
|
document.getElementById('local_edit_landed_time').value = land.toISOString().slice(11, 16);
|
|
} else {
|
|
document.getElementById('local_edit_landed_date').value = '';
|
|
document.getElementById('local_edit_landed_time').value = '';
|
|
}
|
|
|
|
// Show/hide action buttons based on status
|
|
const deptBtn = document.getElementById('local-btn-departed');
|
|
const landBtn = document.getElementById('local-btn-landed');
|
|
const cancelBtn = document.getElementById('local-btn-cancel');
|
|
|
|
deptBtn.style.display = flight.status === 'BOOKED_OUT' ? 'inline-block' : 'none';
|
|
landBtn.style.display = flight.status === 'DEPARTED' ? 'inline-block' : 'none';
|
|
cancelBtn.style.display = (flight.status === 'BOOKED_OUT' || flight.status === 'DEPARTED') ? 'inline-block' : 'none';
|
|
|
|
document.getElementById('local-flight-edit-title').textContent = `${flight.registration} - ${flight.flight_type}`;
|
|
|
|
// Load and display circuits for all local flight types
|
|
const circuitsSection = document.getElementById('circuits-section');
|
|
circuitsSection.style.display = 'block';
|
|
loadCircuitsDisplay(flight.id);
|
|
|
|
document.getElementById('localFlightEditModal').style.display = 'block';
|
|
|
|
// Load journal for this local flight
|
|
await loadLocalFlightJournal(flightId);
|
|
} catch (error) {
|
|
console.error('Error loading flight:', error);
|
|
showNotification('Error loading flight details', true);
|
|
}
|
|
}
|
|
|
|
async function loadCircuitsDisplay(localFlightId) {
|
|
try {
|
|
const response = await authenticatedFetch(`/api/v1/circuits/flight/${localFlightId}`);
|
|
if (!response.ok) throw new Error('Failed to load circuits');
|
|
|
|
const circuits = await response.json();
|
|
displayCircuitsList(circuits);
|
|
} catch (error) {
|
|
console.error('Error loading circuits:', error);
|
|
document.getElementById('circuits-list').innerHTML = '<p style="color: #d32f2f;">Error loading circuits</p>';
|
|
}
|
|
}
|
|
|
|
function displayCircuitsList(circuits) {
|
|
const circuitsList = document.getElementById('circuits-list');
|
|
|
|
if (circuits.length === 0) {
|
|
circuitsList.innerHTML = '<p style="color: #666; font-style: italic;">No touch & go records yet</p>';
|
|
return;
|
|
}
|
|
|
|
let html = `<p style="color: #333; font-weight: bold;">Total circuits: ${circuits.length}</p>`;
|
|
html += '<div style="background-color: #f5f5f5; border-radius: 4px; padding: 1rem; margin-top: 0.5rem;">';
|
|
|
|
circuits.forEach((circuit, index) => {
|
|
const time = formatTimeOnly(circuit.circuit_timestamp);
|
|
html += `<div style="padding: 0.5rem 0; border-bottom: 1px solid #ddd;">Circuit ${index + 1}: <strong>${time}</strong></div>`;
|
|
});
|
|
|
|
html += '</div>';
|
|
circuitsList.innerHTML = html;
|
|
}
|
|
|
|
function closeLocalFlightEditModal() {
|
|
document.getElementById('localFlightEditModal').style.display = 'none';
|
|
currentLocalFlightId = null;
|
|
}
|
|
|
|
// Open departure edit modal
|
|
async function openDepartureEditModal(departureId) {
|
|
if (!accessToken) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/departures/${departureId}`, {
|
|
headers: { 'Authorization': `Bearer ${accessToken}` }
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to load departure');
|
|
|
|
const departure = await response.json();
|
|
currentDepartureId = departure.id;
|
|
|
|
// Populate form
|
|
document.getElementById('departure-edit-id').value = departure.id;
|
|
document.getElementById('departure_edit_registration').value = departure.registration;
|
|
document.getElementById('departure_edit_type').value = departure.type || '';
|
|
document.getElementById('departure_edit_callsign').value = departure.callsign || '';
|
|
document.getElementById('departure_edit_pob').value = departure.pob || '';
|
|
document.getElementById('departure_edit_out_to').value = departure.out_to;
|
|
document.getElementById('departure_edit_notes').value = departure.notes || '';
|
|
|
|
// Parse and populate ETD if exists (UTC)
|
|
if (departure.etd) {
|
|
const eStr = departure.etd.includes('Z') ? departure.etd : departure.etd.replace(' ', 'T') + 'Z';
|
|
const etd = new Date(eStr);
|
|
document.getElementById('departure_edit_etd_date').value = etd.toISOString().slice(0, 10);
|
|
document.getElementById('departure_edit_etd_time').value = etd.toISOString().slice(11, 16);
|
|
} else {
|
|
document.getElementById('departure_edit_etd_date').value = '';
|
|
document.getElementById('departure_edit_etd_time').value = '';
|
|
}
|
|
|
|
// Parse and populate takeoff time if exists (takeoff_dt or departed_dt)
|
|
const takeoffTime = departure.takeoff_dt || departure.departed_dt;
|
|
if (takeoffTime) {
|
|
const tStr = takeoffTime.includes('Z') ? takeoffTime : takeoffTime.replace(' ', 'T') + 'Z';
|
|
const takeoff = new Date(tStr);
|
|
document.getElementById('departure_edit_takeoff_date').value = takeoff.toISOString().slice(0, 10);
|
|
document.getElementById('departure_edit_takeoff_time').value = takeoff.toISOString().slice(11, 16);
|
|
} else {
|
|
document.getElementById('departure_edit_takeoff_date').value = '';
|
|
document.getElementById('departure_edit_takeoff_time').value = '';
|
|
}
|
|
|
|
// Show/hide action buttons based on status
|
|
const deptBtn = document.getElementById('departure-btn-departed');
|
|
const cancelBtn = document.getElementById('departure-btn-cancel');
|
|
|
|
if (deptBtn) deptBtn.style.display = departure.status === 'BOOKED_OUT' ? 'inline-block' : 'none';
|
|
if (cancelBtn) cancelBtn.style.display = departure.status === 'BOOKED_OUT' ? 'inline-block' : 'none';
|
|
|
|
document.getElementById('departure-edit-title').textContent = `${departure.registration} to ${departure.out_to}`;
|
|
document.getElementById('departureEditModal').style.display = 'block';
|
|
|
|
// Load journal for this departure
|
|
await loadDepartureJournal(departureId);
|
|
} catch (error) {
|
|
console.error('Error loading departure:', error);
|
|
showNotification('Error loading departure details', true);
|
|
}
|
|
}
|
|
|
|
function closeDepartureEditModal() {
|
|
closeModal('departureEditModal', () => {
|
|
currentDepartureId = null;
|
|
});
|
|
}
|
|
|
|
// Departure edit form submission
|
|
document.getElementById('departure-edit-form').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
if (!currentDepartureId || !accessToken) return;
|
|
|
|
const formData = new FormData(this);
|
|
const updateData = {};
|
|
|
|
formData.forEach((value, key) => {
|
|
if (key === 'id') return;
|
|
|
|
// Handle date/time combination for ETD
|
|
if (key === 'etd_date' || key === 'etd_time') {
|
|
if (!updateData.etd && formData.get('etd_date') && formData.get('etd_time')) {
|
|
const dateStr = formData.get('etd_date');
|
|
const timeStr = formData.get('etd_time');
|
|
updateData.etd = `${dateStr}T${timeStr}:00Z`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle date/time combination for takeoff_dt
|
|
if (key === 'takeoff_date' || key === 'takeoff_time') {
|
|
if (!updateData.takeoff_dt && formData.get('takeoff_date') && formData.get('takeoff_time')) {
|
|
const dateStr = formData.get('takeoff_date');
|
|
const timeStr = formData.get('takeoff_time');
|
|
updateData.takeoff_dt = `${dateStr}T${timeStr}:00Z`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Cast pob to integer
|
|
if (key === 'pob') {
|
|
const num = parseInt(value, 10);
|
|
if (!isNaN(num)) updateData.pob = num;
|
|
return;
|
|
}
|
|
|
|
// Only include non-empty values
|
|
if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) {
|
|
if (value.trim) {
|
|
updateData[key] = value.trim();
|
|
} else {
|
|
updateData[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/departures/${currentDepartureId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
body: JSON.stringify(updateData)
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to update departure');
|
|
|
|
closeDepartureEditModal();
|
|
loadPPRs(); // Refresh departures display
|
|
showNotification('Departure updated successfully');
|
|
} catch (error) {
|
|
console.error('Error updating departure:', error);
|
|
showNotification('Error updating departure', true);
|
|
}
|
|
});
|
|
|
|
// Arrival Edit Modal Functions
|
|
async function openArrivalEditModal(arrivalId) {
|
|
if (!accessToken) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/arrivals/${arrivalId}`, {
|
|
headers: { 'Authorization': `Bearer ${accessToken}` }
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to load arrival');
|
|
|
|
const arrival = await response.json();
|
|
currentArrivalId = arrival.id;
|
|
|
|
// Populate form
|
|
document.getElementById('arrival-edit-id').value = arrival.id;
|
|
document.getElementById('arrival_edit_registration').value = arrival.registration || '';
|
|
document.getElementById('arrival_edit_type').value = arrival.type || '';
|
|
document.getElementById('arrival_edit_callsign').value = arrival.callsign || '';
|
|
document.getElementById('arrival_edit_in_from').value = arrival.in_from || '';
|
|
document.getElementById('arrival_edit_pob').value = arrival.pob || '';
|
|
document.getElementById('arrival_edit_notes').value = arrival.notes || '';
|
|
|
|
// Update title
|
|
const regOrCallsign = arrival.callsign || arrival.registration;
|
|
document.getElementById('arrival-edit-title').textContent = `Arrival: ${regOrCallsign}`;
|
|
|
|
// Show/hide quick action buttons based on status
|
|
const landedBtn = document.getElementById('arrival-btn-landed');
|
|
const cancelBtn = document.getElementById('arrival-btn-cancel');
|
|
landedBtn.style.display = arrival.status === 'INBOUND' ? 'block' : 'none';
|
|
cancelBtn.style.display = arrival.status === 'INBOUND' ? 'block' : 'none';
|
|
|
|
// Show modal
|
|
document.getElementById('arrivalEditModal').style.display = 'block';
|
|
|
|
// Load journal for this arrival
|
|
await loadArrivalJournal(arrivalId);
|
|
} catch (error) {
|
|
console.error('Error loading arrival:', error);
|
|
showNotification('Error loading arrival details', true);
|
|
}
|
|
}
|
|
|
|
function closeArrivalEditModal() {
|
|
closeModal('arrivalEditModal', () => {
|
|
currentArrivalId = null;
|
|
});
|
|
}
|
|
|
|
// Arrival edit form submission
|
|
document.getElementById('arrival-edit-form').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
if (!currentArrivalId || !accessToken) return;
|
|
|
|
const formData = new FormData(this);
|
|
const updateData = {};
|
|
|
|
formData.forEach((value, key) => {
|
|
if (key === 'id') return;
|
|
|
|
// Only include non-empty values
|
|
if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) {
|
|
if (key === 'pob') {
|
|
updateData[key] = parseInt(value);
|
|
} else if (value.trim) {
|
|
updateData[key] = value.trim();
|
|
} else {
|
|
updateData[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/arrivals/${currentArrivalId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
body: JSON.stringify(updateData)
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to update arrival');
|
|
|
|
closeArrivalEditModal();
|
|
loadPPRs(); // Refresh arrivals display
|
|
showNotification('Arrival updated successfully');
|
|
} catch (error) {
|
|
console.error('Error updating arrival:', error);
|
|
showNotification('Error updating arrival', true);
|
|
}
|
|
});
|
|
|
|
async function updateArrivalStatus(status) {
|
|
if (!currentArrivalId || !accessToken) return;
|
|
|
|
if (status === 'CANCELLED') {
|
|
if (!confirm('Are you sure you want to cancel this arrival?')) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/arrivals/${currentArrivalId}/status`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
body: JSON.stringify({ status: status })
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to update arrival status');
|
|
|
|
closeArrivalEditModal();
|
|
loadPPRs(); // Refresh arrivals display
|
|
showNotification(`Arrival marked as ${status.toLowerCase()}`);
|
|
} catch (error) {
|
|
console.error('Error updating arrival status:', error);
|
|
showNotification('Error updating arrival status', true);
|
|
}
|
|
}
|
|
|
|
// Update status from table buttons (with flight ID passed)
|
|
// Update status from modal (uses currentLocalFlightId)
|
|
async function updateLocalFlightStatus(status) {
|
|
if (!currentLocalFlightId || !accessToken) return;
|
|
|
|
// Show confirmation for cancel actions
|
|
if (status === 'CANCELLED') {
|
|
if (!confirm('Are you sure you want to cancel this flight? This action cannot be easily undone.')) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/local-flights/${currentLocalFlightId}/status`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
body: JSON.stringify({ status: status })
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to update status');
|
|
|
|
closeLocalFlightEditModal();
|
|
loadPPRs(); // Refresh display
|
|
showNotification(`Flight marked as ${status.toLowerCase()}`);
|
|
} catch (error) {
|
|
console.error('Error updating status:', error);
|
|
showNotification('Error updating flight status', true);
|
|
}
|
|
}
|
|
|
|
async function updateDepartureStatus(status) {
|
|
if (!currentDepartureId || !accessToken) return;
|
|
|
|
// Show confirmation for cancel actions
|
|
if (status === 'CANCELLED') {
|
|
if (!confirm('Are you sure you want to cancel this departure? This action cannot be easily undone.')) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/departures/${currentDepartureId}/status`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
body: JSON.stringify({ status: status })
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to update status');
|
|
|
|
closeDepartureEditModal();
|
|
loadPPRs(); // Refresh display
|
|
showNotification(`Departure marked as ${status.toLowerCase()}`);
|
|
} catch (error) {
|
|
console.error('Error updating status:', error);
|
|
showNotification('Error updating departure status', true);
|
|
}
|
|
}
|
|
|
|
// Table help modal functions
|
|
const tableHelpTexts = {
|
|
arrivals: {
|
|
title: "Today's Pending Arrivals",
|
|
text: "Displays aircraft that are expected to arrive at Swansea today. These are flights that have filed PPRs or have been booked in as arriving. Aircraft in this list are actively planning to land today."
|
|
},
|
|
departures: {
|
|
title: "Today's Pending Departures",
|
|
text: "Displays aircraft that are ready to depart from Swansea today. This includes flights with approved PPRs awaiting departure, local flights that have been booked out, and departures to other airfields. These aircraft will depart today."
|
|
},
|
|
overflights: {
|
|
title: "Active Overflights",
|
|
text: "Displays aircraft that are currently in contact with Air / Ground. Once marked a QSY (changed frequency), they are no longer considered active overflights."
|
|
},
|
|
departed: {
|
|
title: "Departed Today",
|
|
text: "Displays the flights which have departed today to other airfields, either by way of a PPR or by booking out."
|
|
},
|
|
parked: {
|
|
title: "Parked Visitors",
|
|
text: "Displays visiting aircraft that are currently parked at Swansea airport and are NOT expected to depart today. The ETD column shows the day the aircraft intends to depart."
|
|
}
|
|
};
|
|
|
|
function showTableHelp(tableType) {
|
|
const helpData = tableHelpTexts[tableType];
|
|
if (!helpData) return;
|
|
|
|
const modal = document.getElementById('tableHelpModal');
|
|
const content = document.getElementById('tableHelpContent');
|
|
const header = modal.querySelector('.modal-header h2');
|
|
|
|
header.textContent = helpData.title;
|
|
content.innerHTML = `<p>${helpData.text}</p>`;
|
|
modal.style.display = 'block';
|
|
}
|
|
|
|
// 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 takeoff
|
|
if (key === 'departure_date' || key === 'departure_time') {
|
|
if (!updateData.takeoff_dt && formData.get('departure_date') && formData.get('departure_time')) {
|
|
const dateStr = formData.get('departure_date');
|
|
const timeStr = formData.get('departure_time');
|
|
updateData.takeoff_dt = `${dateStr}T${timeStr}:00Z`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle date/time combination for landing
|
|
if (key === 'landed_date' || key === 'landed_time') {
|
|
if (!updateData.landed_dt && formData.get('landed_date') && formData.get('landed_time')) {
|
|
const dateStr = formData.get('landed_date');
|
|
const timeStr = formData.get('landed_time');
|
|
updateData.landed_dt = `${dateStr}T${timeStr}:00Z`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Only include non-empty values
|
|
if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) {
|
|
if (key === 'pob' || key === 'duration') {
|
|
updateData[key] = parseInt(value);
|
|
} else if (value.trim) {
|
|
updateData[key] = value.trim();
|
|
} else {
|
|
updateData[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/local-flights/${currentLocalFlightId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
body: JSON.stringify(updateData)
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to update flight');
|
|
|
|
closeLocalFlightEditModal();
|
|
loadPPRs(); // Refresh display
|
|
showNotification('Flight updated successfully');
|
|
} catch (error) {
|
|
console.error('Error updating flight:', error);
|
|
showNotification('Error updating flight', true);
|
|
}
|
|
});
|
|
|
|
// Add event listener for local flight form submission
|
|
document.getElementById('local-flight-form').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
if (!accessToken) return;
|
|
|
|
// Auto-save any unsaved aircraft types
|
|
await autoSaveUnsavedAircraft(this);
|
|
|
|
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 UTC)
|
|
if (key === 'etd_time') {
|
|
if (value.trim()) {
|
|
const today = new Date();
|
|
const dateStr = today.toISOString().split('T')[0]; // UTC date
|
|
flightData.etd = `${dateStr}T${value}:00Z`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Only include non-empty values
|
|
if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) {
|
|
if (key === 'pob' || key === 'duration') {
|
|
flightData[key] = parseInt(value);
|
|
} else if (value.trim) {
|
|
flightData[key] = value.trim();
|
|
} else {
|
|
flightData[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
// If DEPARTURE flight type, use departures endpoint instead
|
|
if (flightType === 'DEPARTURE') {
|
|
endpoint = '/api/v1/departures/';
|
|
// Remove flight_type from data and use out_to instead
|
|
delete flightData.flight_type;
|
|
}
|
|
|
|
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();
|
|
closeModal('localFlightModal');
|
|
loadPPRs(); // Refresh tables
|
|
showNotification(`Aircraft ${result.registration} booked out successfully!`);
|
|
} catch (error) {
|
|
console.error('Error booking out flight:', error);
|
|
showNotification(`Error: ${error.message}`, true);
|
|
}
|
|
});
|
|
|
|
document.getElementById('book-in-form').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
if (!accessToken) return;
|
|
|
|
// Auto-save any unsaved aircraft types
|
|
await autoSaveUnsavedAircraft(this);
|
|
|
|
const formData = new FormData(this);
|
|
const arrivalData = {};
|
|
|
|
formData.forEach((value, key) => {
|
|
// Skip the hidden id field and empty values
|
|
if (key === 'id') return;
|
|
|
|
// Handle time-only ETA (always today UTC)
|
|
if (key === 'eta_time') {
|
|
if (value.trim()) {
|
|
const today = new Date();
|
|
const dateStr = today.toISOString().split('T')[0]; // UTC date
|
|
arrivalData.eta = `${dateStr}T${value}:00Z`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Only include non-empty values
|
|
if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) {
|
|
if (key === 'pob') {
|
|
arrivalData[key] = parseInt(value);
|
|
} else if (value.trim) {
|
|
arrivalData[key] = value.trim();
|
|
} else {
|
|
arrivalData[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Book In uses LANDED status (they're arriving now)
|
|
arrivalData.status = 'LANDED';
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/arrivals/', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
body: JSON.stringify(arrivalData)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = 'Failed to book in arrival';
|
|
try {
|
|
const errorData = await response.json();
|
|
if (errorData.detail) {
|
|
errorMessage = typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail);
|
|
} else if (errorData.errors) {
|
|
errorMessage = errorData.errors.map(e => e.msg).join(', ');
|
|
}
|
|
} catch (e) {
|
|
const text = await response.text();
|
|
console.error('Server response:', text);
|
|
errorMessage = `Server error (${response.status})`;
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const result = await response.json();
|
|
closeModal('bookInModal');
|
|
loadPPRs(); // Refresh tables
|
|
showNotification(`Aircraft ${result.registration} booked in successfully!`);
|
|
} catch (error) {
|
|
console.error('Error booking in arrival:', error);
|
|
showNotification(`Error: ${error.message}`, true);
|
|
}
|
|
});
|
|
|
|
// Overflight form submission
|
|
document.getElementById('overflight-form').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
if (!accessToken) return;
|
|
|
|
// Auto-save any unsaved aircraft types
|
|
await autoSaveUnsavedAircraft(this);
|
|
|
|
const formData = new FormData(this);
|
|
const overflightData = {};
|
|
|
|
formData.forEach((value, key) => {
|
|
if (key === 'id') return;
|
|
|
|
if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) {
|
|
if (key === 'pob') {
|
|
overflightData[key] = parseInt(value);
|
|
} else if (key === 'call_dt') {
|
|
// Treat as UTC - append Z
|
|
if (value.trim()) {
|
|
overflightData[key] = value + ':00Z';
|
|
}
|
|
} else if (value.trim) {
|
|
overflightData[key] = value.trim();
|
|
} else {
|
|
overflightData[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/overflights/', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
body: JSON.stringify(overflightData)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = 'Failed to register overflight';
|
|
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();
|
|
closeModal('overflightModal');
|
|
loadPPRs();
|
|
showNotification(`Overflight ${result.registration} registered successfully!`);
|
|
} catch (error) {
|
|
console.error('Error registering overflight:', error);
|
|
showNotification(`Error: ${error.message}`, true);
|
|
}
|
|
});
|
|
|
|
// Overflight lookup handlers - use lookupManager
|
|
function handleOverflightAircraftLookup(input) {
|
|
const lookup = lookupManager.lookups['overflight-aircraft'];
|
|
if (lookup) lookup.handle(input);
|
|
}
|
|
|
|
function clearOverflightAircraftLookup() {
|
|
const lookup = lookupManager.lookups['overflight-aircraft'];
|
|
if (lookup) lookup.clear();
|
|
}
|
|
|
|
function handleOverflightDepartureAirportLookup(input) {
|
|
const lookup = lookupManager.lookups['overflight-departure'];
|
|
if (lookup) lookup.handle(input);
|
|
}
|
|
|
|
function clearOverflightDepartureAirportLookup() {
|
|
const lookup = lookupManager.lookups['overflight-departure'];
|
|
if (lookup) lookup.clear();
|
|
}
|
|
|
|
function handleOverflightDestinationAirportLookup(input) {
|
|
const lookup = lookupManager.lookups['overflight-destination'];
|
|
if (lookup) lookup.handle(input);
|
|
}
|
|
|
|
function clearOverflightDestinationAirportLookup() {
|
|
const lookup = lookupManager.lookups['overflight-destination'];
|
|
if (lookup) lookup.clear();
|
|
}
|
|
|
|
// Position tooltip near mouse cursor
|
|
function positionTooltip(event) {
|
|
const tooltip = event.currentTarget.querySelector('.tooltip-text');
|
|
if (tooltip) {
|
|
const rect = tooltip.getBoundingClientRect();
|
|
const tooltipWidth = 300; // matches CSS width
|
|
const tooltipHeight = rect.height || 100; // estimate if not yet rendered
|
|
|
|
let left = event.pageX + 10;
|
|
let top = event.pageY + 10;
|
|
|
|
// Adjust if tooltip would go off screen
|
|
if (left + tooltipWidth > window.innerWidth) {
|
|
left = event.pageX - tooltipWidth - 10;
|
|
}
|
|
|
|
if (top + tooltipHeight > window.innerHeight) {
|
|
top = event.pageY - tooltipHeight - 10;
|
|
}
|
|
|
|
tooltip.style.left = left + 'px';
|
|
tooltip.style.top = top + 'px';
|
|
tooltip.style.visibility = 'visible';
|
|
tooltip.style.opacity = '1';
|
|
}
|
|
}
|
|
|
|
// Add hover listeners to all notes tooltips
|
|
function setupTooltips() {
|
|
document.querySelectorAll('.notes-tooltip').forEach(tooltip => {
|
|
tooltip.addEventListener('mouseenter', positionTooltip);
|
|
tooltip.addEventListener('mouseleave', hideTooltip);
|
|
});
|
|
}
|
|
|
|
// Hide tooltip when mouse leaves
|
|
function hideTooltip(event) {
|
|
const tooltip = event.currentTarget.querySelector('.tooltip-text');
|
|
if (tooltip) {
|
|
tooltip.style.visibility = 'hidden';
|
|
tooltip.style.opacity = '0';
|
|
}
|
|
}
|
|
|
|
// Initialize the page when DOM is loaded
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadUIConfig(); // Load UI configuration first
|
|
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);
|
|
});
|