Files
ppr-ng/web/movements.html
2026-06-28 07:37:41 -04:00

1277 lines
51 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PPR Movements - Swansea PPR</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f5f5;
color: #333;
}
.top-bar {
background: linear-gradient(135deg, #2c3e50, #3498db);
color: white;
padding: 0.5rem 2rem;
display: flex;
justify-content: center;
align-items: center;
gap: 2rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.title {
order: 2;
flex: 1;
text-align: center;
}
.title h1 {
margin: 0;
font-size: 1.5rem;
}
.menu-buttons {
order: 1;
display: flex;
gap: 1rem;
align-items: center;
}
.top-bar .user-info {
order: 3;
font-size: 0.9rem;
opacity: 0.9;
display: flex;
align-items: center;
gap: 0.3rem;
}
.container {
max-width: 1600px;
margin: 0 auto;
padding: 2rem;
}
.btn {
padding: 0.7rem 1.5rem;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover {
background-color: #2980b9;
}
.btn-success {
background-color: #27ae60;
color: white;
}
.btn-success:hover {
background-color: #229954;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-secondary:hover {
background-color: #7f8c8d;
}
.btn-danger {
background-color: #e74c3c;
color: white;
}
.btn-danger:hover {
background-color: #c0392b;
}
.filters-section {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 1.5rem;
margin-bottom: 2rem;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
align-items: end;
}
.filter-group {
display: flex;
flex-direction: column;
}
.filter-group label {
font-weight: 600;
margin-bottom: 0.5rem;
color: #555;
}
.filter-group input, .filter-group select {
padding: 0.6rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.filter-actions {
display: flex;
gap: 1rem;
align-items: end;
}
.reports-table {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.table-header {
background: #34495e;
color: white;
padding: 1rem;
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
}
.table-info {
font-size: 0.9rem;
opacity: 0.9;
}
.loading {
text-align: center;
padding: 3rem;
color: #666;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid #eee;
font-size: 0.85rem;
}
th {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
position: sticky;
top: 0;
white-space: nowrap;
}
tbody tr {
transition: background-color 0.2s ease;
}
tbody tr:hover {
background-color: #f8f9fa;
}
.status {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status.new { background: #e3f2fd; color: #1565c0; }
.status.confirmed { background: #e8f5e8; color: #2e7d32; }
.status.landed { background: #fff3e0; color: #ef6c00; }
.status.departed { background: #fce4ec; color: #c2185b; }
.status.canceled { background: #ffebee; color: #d32f2f; }
.status.deleted { background: #f3e5f5; color: #7b1fa2; }
.summary-box {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.summary-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
}
.summary-item {
background: rgba(255,255,255,0.1);
border-radius: 6px;
padding: 1rem;
border-left: 4px solid rgba(255,255,255,0.3);
}
.summary-item-label {
font-size: 0.85rem;
opacity: 0.9;
margin-bottom: 0.5rem;
}
.summary-item-value {
font-size: 2rem;
font-weight: 700;
}
.no-data {
text-align: center;
padding: 3rem;
color: #666;
}
.export-buttons {
display: flex;
gap: 0.5rem;
}
.notification {
position: fixed;
top: 20px;
right: 20px;
background-color: #27ae60;
color: white;
padding: 12px 20px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 10000;
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s ease;
font-weight: 500;
}
.notification.show {
opacity: 1;
transform: translateY(0);
}
.notification.error {
background-color: #e74c3c;
}
/* Responsive design */
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.filters-grid {
grid-template-columns: 1fr;
}
.filter-actions {
flex-direction: column;
align-items: stretch;
}
.table-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.export-buttons {
width: 100%;
justify-content: center;
}
th, td {
padding: 0.3rem;
font-size: 0.75rem;
}
.btn {
padding: 0.5rem 1rem;
font-size: 0.8rem;
}
}
/* Scrollable table for mobile */
.table-container {
overflow-x: auto;
max-width: 100%;
}
.table-container table {
min-width: 1200px;
}
</style>
<script src="topbar.js"></script>
</head>
<body>
<div class="top-bar">
<div class="menu-buttons">
<button class="btn btn-secondary" onclick="window.location.href='admin'">
← Back to Admin
</button>
<button class="btn btn-secondary" onclick="window.location.href='journal'">
📔 Journal Log
</button>
</div>
<div class="title">
<h1 id="tower-title">📊 PPR Reports</h1>
</div>
<div class="user-info">
Logged in as: <span id="current-user">Loading...</span> |
<a href="#" onclick="logout()" style="color: white;">Logout</a>
</div>
</div>
<div class="container">
<!-- Filters Section -->
<div class="filters-section">
<div style="display: flex; gap: 0.5rem; align-items: flex-end; flex-wrap: wrap;">
<!-- Quick Filter Buttons -->
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary" id="filter-today" onclick="setDateRangeToday()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📅 Today</button>
<button class="btn btn-secondary" id="filter-week" onclick="setDateRangeThisWeek()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📆 This Week</button>
<button class="btn btn-secondary" id="filter-month" onclick="setDateRangeThisMonth()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📊 This Month</button>
<button class="btn btn-secondary" id="filter-custom" onclick="toggleCustomRange()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📋 Custom Range</button>
</div>
<!-- Custom Date Range (hidden by default) -->
<div id="custom-range-container" style="display: none; display: flex; gap: 0.5rem; align-items: center;">
<input type="date" id="date-from" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
<span style="font-weight: 600; color: #666;">to</span>
<input type="date" id="date-to" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
</div>
<!-- Status Filter -->
<div style="display: flex; flex-direction: column; gap: 0.3rem;">
<label for="status-filter" style="font-weight: 600; font-size: 0.85rem; color: #555;">Status:</label>
<select id="status-filter" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.9rem;">
<option value="">All Statuses</option>
<option value="NEW">New</option>
<option value="CONFIRMED">Confirmed</option>
<option value="LANDED">Landed</option>
<option value="DEPARTED">Departed</option>
<option value="CANCELED">Canceled</option>
<option value="DELETED">Deleted</option>
</select>
</div>
<!-- Search Input -->
<div style="flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 0.3rem;">
<label for="search-input" style="font-weight: 600; font-size: 0.85rem; color: #555;">Search:</label>
<input type="text" id="search-input" placeholder="Aircraft reg, callsign, captain, or airport..." style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.9rem;">
</div>
<!-- Action Buttons -->
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary" onclick="loadMovements()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">
🔍 Search
</button>
<button class="btn btn-secondary" onclick="clearFilters()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">
🗑️ Clear
</button>
</div>
</div>
<!-- Movement Logging Buttons -->
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 2px solid rgba(255,255,255,0.2);">
<div style="font-size: 0.9rem; font-weight: 600; margin-bottom: 0.5rem; color: rgba(255,255,255,0.9);">Log Movement:</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button class="btn" onclick="openLogMovementModal('LOCAL')" style="background: linear-gradient(135deg, #ff6b6b, #ee5a52); color: white; border: none; font-size: 0.85rem; padding: 0.6rem 1rem; border-radius: 6px; cursor: pointer; white-space: nowrap;">
✈️ Log Local
</button>
<button class="btn" onclick="openLogMovementModal('ARRIVAL')" style="background: linear-gradient(135deg, #ffd93d, #ffb142); color: #333; border: none; font-size: 0.85rem; padding: 0.6rem 1rem; border-radius: 6px; cursor: pointer; white-space: nowrap;">
🛬 Log Arrival
</button>
<button class="btn" onclick="openLogMovementModal('DEPARTURE')" style="background: linear-gradient(135deg, #4ecdc4, #44a08d); color: white; border: none; font-size: 0.85rem; padding: 0.6rem 1rem; border-radius: 6px; cursor: pointer; white-space: nowrap;">
🛫 Log Departure
</button>
<button class="btn" onclick="openLogMovementModal('OVERFLIGHT')" style="background: linear-gradient(135deg, #6bcf7f, #51a058); color: white; border: none; font-size: 0.85rem; padding: 0.6rem 1rem; border-radius: 6px; cursor: pointer; white-space: nowrap;">
🔄 Log Overflight
</button>
</div>
</div>
</div>
<!-- Summary Box -->
<div class="summary-box">
<div class="summary-title">📊 Movements Summary</div>
<div style="display: grid; grid-template-columns: 1fr auto; gap: 2rem; align-items: center;">
<div class="summary-grid">
<!-- Movement Types -->
<div style="grid-column: 1/-1; padding-bottom: 0.8rem;">
<div style="font-size: 0.85rem; font-weight: 600; margin-bottom: 0.4rem;">Movement Types</div>
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.8rem;">
<div class="summary-item" style="padding: 0.4rem;">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Takeoffs</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="ppr-movements">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem;">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Landings</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="local-flights-movements">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem;">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Touch & Go</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="circuits-movements">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem;">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Overflights</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-arrivals">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem;">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Go Around</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-departures">0</div>
</div>
</div>
</div>
<!-- Total -->
<div style="grid-column: 1/-1; padding-top: 0.8rem; border-top: 2px solid rgba(255,255,255,0.3);">
<div style="display: grid; grid-template-columns: 1fr; gap: 0.8rem;">
<div class="summary-item" style="border-left-color: #ffd700; background: rgba(255,215,0,0.1); padding: 0.4rem;">
<div class="summary-item-label" style="font-weight: 600; font-size: 0.7rem; margin-bottom: 0.1rem;">Total Movements</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="grand-total-movements">0</div>
</div>
</div>
</div>
</div>
<!-- Grand Total - positioned on the right -->
<div style="text-align: center; padding: 1rem; background: rgba(255,215,0,0.05); border-radius: 8px; border-left: 4px solid #ffd700; min-width: 120px;">
<div style="font-size: 0.75rem; opacity: 0.85; margin-bottom: 0.2rem;">GRAND TOTAL</div>
<div style="font-size: 2.2rem; font-weight: 700;" id="grand-total-movements">0</div>
</div>
</div>
</div>
<!-- Reports Table -->
<div class="reports-table" id="ppr-reports-section">
<div class="table-header">
<div>
<strong>Movements</strong>
<div class="table-info" id="table-info">Loading...</div>
</div>
<div class="export-buttons">
<button class="btn btn-success" onclick="exportToCSV()">
📊 Export CSV
</button>
</div>
</div>
<div id="reports-loading" class="loading">
<div class="spinner"></div>
Loading movements...
</div>
<div id="reports-table-content" style="display: none;">
<div class="table-container">
<table>
<thead>
<tr>
<th>Time</th>
<th>Movement Type</th>
<th>Aircraft</th>
<th>Type</th>
<th>Callsign</th>
<th>From</th>
<th>To</th>
<th>Runway</th>
<th>Wind</th>
<th>Pressure</th>
<th>Created By</th>
</tr>
</thead>
<tbody id="movements-table-body">
</tbody>
</table>
</div>
</div>
<div id="reports-no-data" class="no-data" style="display: none;">
<h3>No movements found</h3>
<p>No movements match your current filters.</p>
</div>
</div>
</div>
<!-- Success Notification -->
<div id="notification" class="notification"></div>
<script>
let currentUser = null;
let accessToken = null;
let currentMovements = []; // Store current movements for export
// 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 = `📊 Reports ${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 page title
if (config.tag) {
document.title = `PPR Reports - ${config.tag}`;
}
// 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;
}
if (document.title) {
document.title += envIndicator;
}
}
}
} catch (error) {
console.warn('Failed to load UI config:', error);
}
}
// Initialize the page
async function initializePage() {
loadUIConfig(); // Load UI configuration first
await initializeAuth();
setupDefaultDateRange();
await loadMovements();
}
// Set default date range to today
function setupDefaultDateRange() {
setDateRangeToday();
}
// Toggle custom date range picker
function toggleCustomRange() {
const container = document.getElementById('custom-range-container');
const customBtn = document.getElementById('filter-custom');
const isVisible = container.style.display !== 'none';
container.style.display = isVisible ? 'none' : 'flex';
// Update button style
if (isVisible) {
customBtn.classList.remove('btn-primary');
customBtn.classList.add('btn-secondary');
} else {
customBtn.classList.remove('btn-secondary');
customBtn.classList.add('btn-primary');
// Focus on the first date input when opening
document.getElementById('date-from').focus();
}
}
// Set date range to today
function setDateRangeToday() {
const today = new Date().toISOString().split('T')[0];
document.getElementById('date-from').value = today;
document.getElementById('date-to').value = today;
// Hide custom range picker if it's open
document.getElementById('custom-range-container').style.display = 'none';
updateFilterButtonStyles('today');
loadMovements();
}
// Set date range to this week (Monday to Sunday)
function setDateRangeThisWeek() {
const now = new Date();
const dayOfWeek = now.getUTCDay();
const diff = now.getUTCDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
const monday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), diff));
const sunday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), diff + 6));
document.getElementById('date-from').value = monday.toISOString().split('T')[0];
document.getElementById('date-to').value = sunday.toISOString().split('T')[0];
// Hide custom range picker if it's open
document.getElementById('custom-range-container').style.display = 'none';
updateFilterButtonStyles('week');
loadMovements();
}
// Set date range to this month
function setDateRangeThisMonth() {
const now = new Date();
const firstDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const lastDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0));
document.getElementById('date-from').value = firstDay.toISOString().split('T')[0];
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
// Hide custom range picker if it's open
document.getElementById('custom-range-container').style.display = 'none';
updateFilterButtonStyles('month');
loadMovements();
}
// Update button styles to show which filter is active
function updateFilterButtonStyles(activeFilter) {
const todayBtn = document.getElementById('filter-today');
const weekBtn = document.getElementById('filter-week');
const monthBtn = document.getElementById('filter-month');
// Reset all buttons
[todayBtn, weekBtn, monthBtn].forEach(btn => {
btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary');
});
// Highlight active button
switch(activeFilter) {
case 'today':
todayBtn.classList.remove('btn-secondary');
todayBtn.classList.add('btn-primary');
break;
case 'week':
weekBtn.classList.remove('btn-secondary');
weekBtn.classList.add('btn-primary');
break;
case 'month':
monthBtn.classList.remove('btn-secondary');
monthBtn.classList.add('btn-primary');
break;
}
}
// Authentication management
async function initializeAuth() {
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)) {
accessToken = cachedToken;
currentUser = cachedUser;
document.getElementById('current-user').textContent = cachedUser;
return;
}
}
// No valid cached token, redirect to admin
window.location.href = 'admin';
}
function logout() {
localStorage.removeItem('ppr_access_token');
localStorage.removeItem('ppr_username');
localStorage.removeItem('ppr_token_expiry');
accessToken = null;
currentUser = null;
window.location.href = 'admin';
}
// Enhanced fetch wrapper with token expiry handling
async function authenticatedFetch(url, options = {}) {
if (!accessToken) {
window.location.href = 'admin';
throw new Error('No access token available');
}
const headers = {
...options.headers,
'Authorization': `Bearer ${accessToken}`
};
const response = await fetch(url, {
...options,
headers
});
if (response.status === 401) {
logout();
throw new Error('Session expired. Please log in again.');
}
return response;
}
// Load reports data
async function loadMovements() {
document.getElementById('reports-loading').style.display = 'block';
document.getElementById('reports-table-content').style.display = 'none';
document.getElementById('reports-no-data').style.display = 'none';
try {
const dateFrom = document.getElementById('date-from').value;
const dateTo = document.getElementById('date-to').value;
let url = `/api/v1/movements/?limit=10000`;
if (dateFrom) url += `&date_from=${dateFrom}`;
if (dateTo) url += `&date_to=${dateTo}`;
const response = await authenticatedFetch(url);
const movements = await response.json();
currentMovements = movements;
displayMovements(movements);
calculateMovementsSummary(movements, dateFrom, dateTo);
} catch (error) {
console.error('Error loading movements:', error);
showNotification('Failed to load movements: ' + error.message, true);
} finally {
document.getElementById('reports-loading').style.display = 'none';
}
}
// Calculate and display movements summary
function calculateMovementsSummary(movements, dateFrom, dateTo) {
let takeoffCount = 0;
let landingCount = 0;
let overflightCount = 0;
let touchAndGoCount = 0;
let goAroundCount = 0;
// Count movements by type
movements.forEach(movement => {
if (movement.movement_type === 'TAKEOFF') takeoffCount++;
else if (movement.movement_type === 'LANDING') landingCount++;
else if (movement.movement_type === 'OVERFLIGHT') overflightCount++;
else if (movement.movement_type === 'TOUCH_AND_GO') touchAndGoCount++;
else if (movement.movement_type === 'GO_AROUND') goAroundCount++;
});
// Format date range for display
let dateRangeText = '';
if (dateFrom && dateTo && dateFrom === dateTo) {
// Single day
dateRangeText = `for ${formatDateOnly(dateFrom)}`;
} else if (dateFrom && dateTo) {
// Date range
dateRangeText = `for ${formatDateOnly(dateFrom)} to ${formatDateOnly(dateTo)}`;
} else if (dateFrom) {
dateRangeText = `from ${formatDateOnly(dateFrom)}`;
} else if (dateTo) {
dateRangeText = `until ${formatDateOnly(dateTo)}`;
}
// Update summary title with date range
const summaryTitle = document.querySelector('.summary-title');
if (summaryTitle) {
summaryTitle.textContent = `📊 Movements Summary ${dateRangeText}`;
}
// Update summary values
document.getElementById('ppr-movements').textContent = takeoffCount;
document.getElementById('local-flights-movements').textContent = landingCount;
document.getElementById('circuits-movements').textContent = touchAndGoCount;
document.getElementById('non-ppr-arrivals').textContent = overflightCount;
document.getElementById('non-ppr-departures').textContent = goAroundCount;
document.getElementById('grand-total-movements').textContent = movements.length;
}
// Display reports in table
function displayMovements(movements) {
const tbody = document.getElementById('movements-table-body');
const recordCount = document.getElementById('table-info');
recordCount.textContent = `${movements.length} movements`;
if (movements.length === 0) {
document.getElementById('reports-no-data').style.display = 'block';
return;
}
// Sort movements by timestamp (descending)
movements.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
tbody.innerHTML = '';
document.getElementById('reports-table-content').style.display = 'block';
for (const movement of movements) {
const row = document.createElement('tr');
const time = movement.timestamp ? formatTimeOnly(movement.timestamp) : '-';
const type = movement.movement_type || '-';
const aircraft = movement.aircraft_registration || '-';
const aircraftType = movement.aircraft_type || '-';
const callsign = movement.callsign || '-';
const fromLoc = movement.from_location || '-';
const toLoc = movement.to_location || '-';
const runway = movement.runway || '-';
const wind = movement.wind || '-';
const pressure = movement.pressure_setting || '-';
const createdBy = movement.created_by || '-';
row.innerHTML = `
<td>${time}</td>
<td>${type}</td>
<td>${aircraft}</td>
<td>${aircraftType}</td>
<td>${callsign}</td>
<td>${fromLoc}</td>
<td>${toLoc}</td>
<td>${runway}</td>
<td>${wind}</td>
<td>${pressure}</td>
<td>${createdBy}</td>
`;
tbody.appendChild(row);
}
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
let utcDateStr = dateStr;
if (!utcDateStr.includes('T')) {
utcDateStr = utcDateStr.replace(' ', 'T');
}
if (!utcDateStr.includes('Z')) {
utcDateStr += 'Z';
}
const date = new Date(utcDateStr);
// Format as dd/mm/yy hh:mm
const day = String(date.getUTCDate()).padStart(2, '0');
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const year = String(date.getUTCFullYear()).slice(-2);
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}`;
}
function formatTimeOnly(dateStr) {
if (!dateStr) return '-';
let utcDateStr = dateStr;
if (!utcDateStr.includes('T')) {
utcDateStr = utcDateStr.replace(' ', 'T');
}
if (!utcDateStr.includes('Z')) {
utcDateStr += 'Z';
}
const date = new Date(utcDateStr);
// Format as hh:mm only
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
function formatDateOnly(dateStr) {
const [year, month, day] = dateStr.split('-');
return `${day}/${month}/${year}`;
}
// Clear filters
function clearFilters() {
document.getElementById('status-filter').value = '';
document.getElementById('search-input').value = '';
setupDefaultDateRange();
loadMovements();
}
// Movement logging functions
function openLogMovementModal(movementType) {
const modal = document.getElementById('log-movement-modal');
const title = document.getElementById('modal-title');
const typeSelect = document.getElementById('movement-type');
const fromContainer = document.getElementById('from-container');
const toContainer = document.getElementById('to-container');
const runwayContainer = document.getElementById('runway-container');
// Set modal title and movement type
title.textContent = `Log ${movementType.charAt(0).toUpperCase() + movementType.slice(1).toLowerCase()} Movement`;
// Set the appropriate movement type value
switch(movementType) {
case 'LOCAL':
typeSelect.value = 'TAKEOFF'; // Local flights log as takeoff initially
break;
case 'ARRIVAL':
typeSelect.value = 'LANDING';
break;
case 'DEPARTURE':
typeSelect.value = 'TAKEOFF';
break;
case 'OVERFLIGHT':
typeSelect.value = 'OVERFLIGHT';
break;
}
// Show/hide fields based on movement type
if (movementType === 'ARRIVAL') {
fromContainer.style.display = 'block';
toContainer.style.display = 'none'; // To is always EGFH for arrivals
runwayContainer.style.display = 'block';
document.getElementById('to-location').value = 'EGFH';
} else if (movementType === 'DEPARTURE') {
fromContainer.style.display = 'none'; // From is always EGFH for departures
toContainer.style.display = 'block';
runwayContainer.style.display = 'block';
document.getElementById('from-location').value = 'EGFH';
} else if (movementType === 'LOCAL') {
fromContainer.style.display = 'none'; // Both from and to are EGFH for local
toContainer.style.display = 'none';
runwayContainer.style.display = 'block';
document.getElementById('from-location').value = 'EGFH';
document.getElementById('to-location').value = 'EGFH';
} else if (movementType === 'OVERFLIGHT') {
fromContainer.style.display = 'block';
toContainer.style.display = 'block';
runwayContainer.style.display = 'none'; // Overflights don't use runway
}
// Set current timestamp as default
const now = new Date();
const timestamp = now.toISOString().slice(0, 16); // Format for datetime-local input
document.getElementById('movement-timestamp').value = timestamp;
// Clear form
document.getElementById('log-movement-form').reset();
document.getElementById('movement-timestamp').value = timestamp;
modal.style.display = 'block';
}
function closeLogMovementModal() {
document.getElementById('log-movement-modal').style.display = 'none';
}
function submitMovementLog() {
const form = document.getElementById('log-movement-form');
// Basic validation
const timestamp = document.getElementById('movement-timestamp').value;
const aircraftReg = document.getElementById('aircraft-reg').value.trim();
if (!timestamp || !aircraftReg) {
showNotification('Please fill in all required fields', true);
return;
}
// Collect form data
const movementData = {
timestamp: timestamp + ':00Z', // Add seconds and Z for UTC
movement_type: document.getElementById('movement-type').value,
aircraft_registration: aircraftReg.toUpperCase(),
aircraft_type: document.getElementById('aircraft-type').value.trim().toUpperCase() || null,
callsign: document.getElementById('callsign').value.trim().toUpperCase() || null,
from_location: document.getElementById('from-location').value.trim().toUpperCase() || null,
to_location: document.getElementById('to-location').value.trim().toUpperCase() || null,
runway: document.getElementById('runway').value.trim() || null,
wind: document.getElementById('wind').value.trim() || null,
pressure_setting: document.getElementById('pressure').value.trim() || null,
notes: document.getElementById('movement-notes').value.trim() || null
};
// For now, just show what would be sent
console.log('Movement data to submit:', movementData);
showNotification('Movement logging feature coming soon! Data collected: ' + JSON.stringify(movementData, null, 2));
closeLogMovementModal();
}
// Export movements to CSV
function exportToCSV() {
if (currentMovements.length === 0) {
showNotification('No data to export', true);
return;
}
const headers = [
'Time', 'Type', 'Aircraft Reg', 'Aircraft Type', 'Callsign', 'From', 'To',
'Runway', 'Wind', 'Pressure', 'Created By'
];
const csvData = currentMovements.map(movement => [
movement.timestamp ? formatTimeOnly(movement.timestamp) : '',
movement.movement_type || '',
movement.aircraft_registration || '',
movement.aircraft_type || '',
movement.callsign || '',
movement.from_location || '',
movement.to_location || '',
movement.runway || '',
movement.wind || '',
movement.pressure_setting || '',
movement.created_by || ''
]);
downloadCSV(headers, csvData, 'movements.csv');
}
function downloadCSV(headers, data, filename) {
const csvContent = [
headers.join(','),
...data.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showNotification(`Exported ${data.length} records to ${filename}`);
}
// Notification system
function showNotification(message, isError = false) {
const notification = document.getElementById('notification');
notification.textContent = message;
notification.className = 'notification' + (isError ? ' error' : '');
setTimeout(() => {
notification.classList.add('show');
}, 10);
setTimeout(() => {
notification.classList.remove('show');
}, 3000);
}
// Handle Enter key in search input
document.getElementById('search-input').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
loadMovements();
}
});
// Initialize when page loads
document.addEventListener('DOMContentLoaded', initializePage);
</script>
<!-- Movement Logging Modal -->
<div id="log-movement-modal" class="modal" style="display: none;" onclick="if(event.target === this) closeLogMovementModal()">
<div class="modal-content" style="max-width: 600px;">
<div class="modal-header">
<h3 id="modal-title">Log Movement</h3>
<span class="modal-close" onclick="closeLogMovementModal()">&times;</span>
</div>
<div class="modal-body">
<form id="log-movement-form">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
<div>
<label for="movement-timestamp" style="display: block; margin-bottom: 0.3rem; font-weight: 600;">Time *</label>
<input type="datetime-local" id="movement-timestamp" required style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
</div>
<div>
<label for="movement-type" style="display: block; margin-bottom: 0.3rem; font-weight: 600;">Type</label>
<select id="movement-type" style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;" disabled>
<option value="TAKEOFF">Takeoff</option>
<option value="LANDING">Landing</option>
<option value="TOUCH_AND_GO">Touch & Go</option>
<option value="OVERFLIGHT">Overflight</option>
<option value="GO_AROUND">Go Around</option>
</select>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
<div>
<label for="aircraft-reg" style="display: block; margin-bottom: 0.3rem; font-weight: 600;">Aircraft Registration *</label>
<input type="text" id="aircraft-reg" required style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;" placeholder="e.g. G-ABCD">
</div>
<div>
<label for="aircraft-type" style="display: block; margin-bottom: 0.3rem; font-weight: 600;">Aircraft Type</label>
<input type="text" id="aircraft-type" style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;" placeholder="e.g. C172">
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
<div>
<label for="callsign" style="display: block; margin-bottom: 0.3rem; font-weight: 600;">Callsign</label>
<input type="text" id="callsign" style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;" placeholder="e.g. ABC123">
</div>
<div id="runway-container">
<label for="runway" style="display: block; margin-bottom: 0.3rem; font-weight: 600;">Runway</label>
<input type="text" id="runway" style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;" placeholder="e.g. 22">
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
<div id="from-container">
<label for="from-location" style="display: block; margin-bottom: 0.3rem; font-weight: 600;">From</label>
<input type="text" id="from-location" style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;" placeholder="e.g. EGFH">
</div>
<div id="to-container">
<label for="to-location" style="display: block; margin-bottom: 0.3rem; font-weight: 600;">To</label>
<input type="text" id="to-location" style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;" placeholder="e.g. EGFH">
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
<div>
<label for="wind" style="display: block; margin-bottom: 0.3rem; font-weight: 600;">Wind</label>
<input type="text" id="wind" style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;" placeholder="e.g. 230/15">
</div>
<div>
<label for="pressure" style="display: block; margin-bottom: 0.3rem; font-weight: 600;">Pressure</label>
<input type="text" id="pressure" style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;" placeholder="e.g. 1013">
</div>
</div>
<div style="margin-bottom: 1rem;">
<label for="movement-notes" style="display: block; margin-bottom: 0.3rem; font-weight: 600;">Notes</label>
<textarea id="movement-notes" rows="3" style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;" placeholder="Optional notes..."></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeLogMovementModal()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="submitMovementLog()">Log Movement</button>
</div>
</div>
</div>
<!-- Modal Styles -->
<style>
.modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 0;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
max-height: 90vh;
overflow-y: auto;
width: 95%;
max-width: 600px;
}
@media (max-width: 768px) {
.modal-content {
width: 95%;
margin: 2% auto;
max-height: 95vh;
}
.modal-body {
padding: 1rem;
}
.modal-header, .modal-footer {
padding: 0.75rem 1rem;
}
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
color: #333;
}
.modal-close {
font-size: 1.5rem;
cursor: pointer;
color: #666;
}
.modal-close:hover {
color: #333;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>
</body>
</html>