Compare commits

..

2 Commits

Author SHA1 Message Date
jamesp 74c21fe988 Merge remote-tracking branch 'origin/main' 2026-06-28 07:41:54 -04:00
jamesp c2e4d2adeb Reporting and TZ updates 2026-06-28 07:37:41 -04:00
18 changed files with 715 additions and 284 deletions
+18 -10
View File
@@ -1,7 +1,8 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from datetime import date
from datetime import date, timezone
from zoneinfo import ZoneInfo
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
from app.crud.crud_ppr import ppr as crud_ppr
from app.crud.crud_journal import journal as crud_journal
@@ -19,6 +20,14 @@ from app.core.config import settings
router = APIRouter()
def format_local_datetime(dt):
if not dt:
return "N/A"
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(ZoneInfo(settings.local_timezone)).strftime("%Y-%m-%d %H:%M")
@router.get("/", response_model=List[PPR])
async def get_pprs(
request: Request,
@@ -94,8 +103,8 @@ async def create_public_ppr(
template_vars={
"name": ppr_in.captain,
"aircraft": ppr_in.ac_reg,
"arrival_time": ppr_in.eta.strftime("%Y-%m-%d %H:%M"),
"departure_time": ppr_in.etd.strftime("%Y-%m-%d %H:%M") if ppr_in.etd else "N/A",
"arrival_time": format_local_datetime(ppr_in.eta),
"departure_time": format_local_datetime(ppr_in.etd),
"purpose": ppr_in.notes or "N/A",
"public_token": ppr.public_token,
"base_url": settings.base_url
@@ -232,8 +241,8 @@ async def update_ppr_status(
template_vars={
"name": ppr.captain,
"aircraft": ppr.ac_reg,
"arrival_time": ppr.eta.strftime("%Y-%m-%d %H:%M"),
"departure_time": ppr.etd.strftime("%Y-%m-%d %H:%M") if ppr.etd else "N/A"
"arrival_time": format_local_datetime(ppr.eta),
"departure_time": format_local_datetime(ppr.etd)
}
)
@@ -316,11 +325,10 @@ async def get_ppr_for_edit(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invalid or expired token"
)
# Only allow editing if not already processed
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]:
if ppr.status == PPRStatus.DELETED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="PPR cannot be edited at this stage"
detail="PPR is no longer available"
)
return ppr
@@ -390,8 +398,8 @@ async def cancel_ppr_public(
template_vars={
"name": cancelled_ppr.captain,
"aircraft": cancelled_ppr.ac_reg,
"arrival_time": cancelled_ppr.eta.strftime("%Y-%m-%d %H:%M"),
"departure_time": cancelled_ppr.etd.strftime("%Y-%m-%d %H:%M") if cancelled_ppr.etd else "N/A"
"arrival_time": format_local_datetime(cancelled_ppr.eta),
"departure_time": format_local_datetime(cancelled_ppr.etd)
}
)
+1
View File
@@ -27,6 +27,7 @@ class Settings(BaseSettings):
api_v1_str: str = "/api/v1"
project_name: str = "Airfield PPR API"
base_url: str
local_timezone: str = "Europe/London"
# UI Configuration
tag: str = ""
+2 -2
View File
@@ -10,8 +10,8 @@
<p><strong>PPR Details:</strong></p>
<ul>
<li>Aircraft: {{ aircraft }}</li>
<li>Original Arrival: {{ arrival_time }}</li>
<li>Original Departure: {{ departure_time }}</li>
<li>Original Arrival (local time): {{ arrival_time }}</li>
<li>Original Departure (local time): {{ departure_time }}</li>
</ul>
<p>If this was not intended, please contact us.</p>
<p>Best regards,<br>Swansea Airport Team</p>
+2 -3
View File
@@ -10,9 +10,8 @@
<p><strong>PPR Details:</strong></p>
<ul>
<li>Aircraft: {{ aircraft }}</li>
<li>Arrival: {{ arrival_time }}</li>
<li>Departure: {{ departure_time }}</li>
<li>Purpose: {{ purpose }}</li>
<li>Arrival (local time): {{ arrival_time }}</li>
<li>Departure (local time): {{ departure_time }}</li>
</ul>
<p>You can <a href="{{ base_url }}/edit.html?token={{ public_token }}">edit or cancel</a> your PPR using this secure link.</p>
<p>You will receive further updates via email.</p>
+21 -1
View File
@@ -124,6 +124,8 @@ def test_public_ppr_create_sends_email_and_generates_token(client, db, ppr_paylo
created = create_response.json()
assert created["created_by"] == "public"
assert sent_email["to_email"] == "pilot@example.com"
assert sent_email["template_vars"]["arrival_time"] == "2026-06-20 11:00"
assert sent_email["template_vars"]["departure_time"] == "2026-06-20 13:00"
db_ppr = db.query(PPRRecord).filter(PPRRecord.id == created["id"]).one()
assert db_ppr.public_token
@@ -146,7 +148,9 @@ def test_public_ppr_token_edit_and_cancel_paths(client, ppr_factory, db):
assert cancel_response.status_code == 200
assert cancel_response.json()["status"] == "CANCELED"
assert client.get("/api/v1/pprs/public/edit/public-edit-token").status_code == 400
assert client.get("/api/v1/pprs/public/edit/public-edit-token").status_code == 200
assert client.patch("/api/v1/pprs/public/edit/public-edit-token", json={}).status_code == 400
assert client.delete("/api/v1/pprs/public/cancel/public-edit-token").status_code == 400
assert client.patch("/api/v1/pprs/public/edit/missing-token", json={}).status_code == 404
assert client.delete("/api/v1/pprs/public/cancel/missing-token").status_code == 404
@@ -179,6 +183,22 @@ def test_activate_rejects_processed_ppr(auth_client, ppr_factory):
assert "cannot be activated" in response.json()["detail"]
def test_public_ppr_processed_token_can_view_but_not_edit_or_cancel(client, ppr_factory):
ppr = ppr_factory(status="LANDED", public_token="processed-token")
get_response = client.get("/api/v1/pprs/public/edit/processed-token")
patch_response = client.patch(
"/api/v1/pprs/public/edit/processed-token",
json={"captain": "Too Late"},
)
cancel_response = client.delete("/api/v1/pprs/public/cancel/processed-token")
assert get_response.status_code == 200
assert get_response.json()["id"] == ppr.id
assert patch_response.status_code == 400
assert cancel_response.status_code == 400
def test_invalid_ppr_payload_returns_validation_error(auth_client, ppr_payload):
ppr_payload["pob_in"] = -1
+82
View File
@@ -0,0 +1,82 @@
# Production docker-compose configuration
# This uses an external database and optimized settings
services:
# FastAPI Backend
api:
build: ./backend
restart: always
environment:
DB_HOST: ${DB_HOST}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
DB_PORT: ${DB_PORT}
SECRET_KEY: ${SECRET_KEY}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES}
API_V1_STR: ${API_V1_STR}
PROJECT_NAME: ${PROJECT_NAME}
MAIL_HOST: ${MAIL_HOST}
MAIL_PORT: ${MAIL_PORT}
MAIL_USERNAME: ${MAIL_USERNAME}
MAIL_PASSWORD: ${MAIL_PASSWORD}
MAIL_FROM: ${MAIL_FROM}
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
BASE_URL: ${BASE_URL}
REDIS_URL: ${REDIS_URL}
TAG: ${TAG}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
ENVIRONMENT: production
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
ports:
- "${API_PORT_EXTERNAL}:8000"
volumes:
- ./backend:/app
- ./db-init:/db-init:ro # Mount CSV data for seeding
- ./web/assets:/web/assets # Mount assets for QR code generation
extra_hosts:
- "host.docker.internal:host-gateway"
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '1'
memory: 1G
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Nginx web server for public frontend
web:
image: nginx:alpine
restart: always
environment:
BASE_URL: ${BASE_URL}
command: >
sh -c "echo 'window.PPR_CONFIG = { apiBase: \"'\$BASE_URL'/api/v1\" };' > /usr/share/nginx/html/config.js &&
nginx -g 'daemon off;'"
ports:
- "${WEB_PORT_EXTERNAL}:80"
volumes:
- ./web:/usr/share/nginx/html
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- api
networks:
- default
- webapps
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
networks:
default:
webapps:
external: true
+11 -16
View File
@@ -517,7 +517,7 @@
}
// Sort by call_dt most recent
overflights.sort((a, b) => new Date(b.call_dt) - new Date(a.call_dt));
overflights.sort((a, b) => parseUtcDate(b.call_dt) - parseUtcDate(a.call_dt));
tbody.innerHTML = '';
@@ -633,7 +633,7 @@
departed.sort((a, b) => {
const aTime = a.departed_dt;
const bTime = b.departed_dt;
return new Date(aTime) - new Date(bTime);
return parseUtcDate(aTime) - parseUtcDate(bTime);
});
tbody.innerHTML = '';
@@ -738,7 +738,7 @@
parked.sort((a, b) => {
if (!a.landed_dt) return 1;
if (!b.landed_dt) return -1;
return new Date(a.landed_dt) - new Date(b.landed_dt);
return parseUtcDate(a.landed_dt) - parseUtcDate(b.landed_dt);
});
tbody.innerHTML = '';
@@ -771,16 +771,14 @@
arrivedDisplay = formatTimeOnly(ppr.landed_dt);
} else {
// Not today - show date (DD/MM)
const date = new Date(ppr.landed_dt);
arrivedDisplay = date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
arrivedDisplay = formatUtcDayMonth(ppr.landed_dt);
}
}
// Format ETD as just the date (DD/MM)
let etdDisplay = '-';
if (ppr.etd) {
const etdDate = new Date(ppr.etd);
etdDisplay = etdDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
etdDisplay = formatUtcDayMonth(ppr.etd);
}
row.innerHTML = `
@@ -842,7 +840,7 @@
}
// Sort by ETA date and time
upcoming.sort((a, b) => new Date(a.eta) - new Date(b.eta));
upcoming.sort((a, b) => parseUtcDate(a.eta) - parseUtcDate(b.eta));
tbody.innerHTML = '';
// Don't auto-expand, keep collapsed by default
@@ -856,10 +854,7 @@
}
// Format date as Day DD/MM (e.g., Wed 11/12)
const etaDate = new Date(ppr.eta);
const dayName = etaDate.toLocaleDateString('en-GB', { weekday: 'short' });
const dateStr = etaDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
const dateDisplay = `${dayName} ${dateStr}`;
const dateDisplay = formatUtcWeekdayDayMonth(ppr.eta);
// Create notes indicator if notes exist
const notesIndicator = ppr.notes && ppr.notes.trim() ?
@@ -945,7 +940,7 @@
const bTime = b.eta || b.departure_dt;
if (!aTime) return 1;
if (!bTime) return -1;
return new Date(aTime) - new Date(bTime);
return parseUtcDate(aTime) - parseUtcDate(bTime);
});
tbody.innerHTML = '';
document.getElementById('arrivals-table-content').style.display = 'block';
@@ -993,7 +988,7 @@
let departureTime = flight.departed_dt || flight.etd;
let etaTime = departureTime;
if (departureTime && flight.duration) {
const departTime = new Date(departureTime);
const departTime = parseUtcDate(departureTime);
etaTime = new Date(departTime.getTime() + flight.duration * 60000).toISOString(); // duration is in minutes
}
eta = etaTime ? formatTimeOnly(etaTime) : '-';
@@ -1155,7 +1150,7 @@
const bTime = b.etd || b.created_dt;
if (!aTime) return 1;
if (!bTime) return -1;
return new Date(aTime) - new Date(bTime);
return parseUtcDate(aTime) - parseUtcDate(bTime);
});
tbody.innerHTML = '';
@@ -1273,7 +1268,7 @@
const bTime = b.etd || b.created_dt;
if (!aTime) return 1;
if (!bTime) return -1;
return new Date(aTime) - new Date(bTime);
return parseUtcDate(aTime) - parseUtcDate(bTime);
});
tbody.innerHTML = '';
document.getElementById('departures-table-content').style.display = 'block';
+11 -19
View File
@@ -508,7 +508,7 @@
}
// Sort by call_dt most recent
overflights.sort((a, b) => new Date(b.call_dt) - new Date(a.call_dt));
overflights.sort((a, b) => parseUtcDate(b.call_dt) - parseUtcDate(a.call_dt));
tbody.innerHTML = '';
@@ -625,7 +625,7 @@
departed.sort((a, b) => {
const aTime = a.departed_dt;
const bTime = b.departed_dt;
return new Date(aTime) - new Date(bTime);
return parseUtcDate(aTime) - parseUtcDate(bTime);
});
tbody.innerHTML = '';
@@ -730,7 +730,7 @@
parked.sort((a, b) => {
if (!a.landed_dt) return 1;
if (!b.landed_dt) return -1;
return new Date(a.landed_dt) - new Date(b.landed_dt);
return parseUtcDate(a.landed_dt) - parseUtcDate(b.landed_dt);
});
tbody.innerHTML = '';
@@ -763,16 +763,14 @@
arrivedDisplay = formatTimeOnly(ppr.landed_dt);
} else {
// Not today - show date (DD/MM)
const date = new Date(ppr.landed_dt);
arrivedDisplay = date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
arrivedDisplay = formatUtcDayMonth(ppr.landed_dt);
}
}
// Format ETD as just the date (DD/MM)
let etdDisplay = '-';
if (ppr.etd) {
const etdDate = new Date(ppr.etd);
etdDisplay = etdDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
etdDisplay = formatUtcDayMonth(ppr.etd);
}
row.innerHTML = `
@@ -834,7 +832,7 @@
}
// Sort by ETA date and time
upcoming.sort((a, b) => new Date(a.eta) - new Date(b.eta));
upcoming.sort((a, b) => parseUtcDate(a.eta) - parseUtcDate(b.eta));
tbody.innerHTML = '';
// Don't auto-expand, keep collapsed by default
@@ -845,10 +843,7 @@
row.style.cssText = 'font-size: 0.85rem !important;';
// Format date as Day DD/MM (e.g., Wed 11/12)
const etaDate = new Date(ppr.eta);
const dayName = etaDate.toLocaleDateString('en-GB', { weekday: 'short' });
const dateStr = etaDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
const dateDisplay = `${dayName} ${dateStr}`;
const dateDisplay = formatUtcWeekdayDayMonth(ppr.eta);
// Create notes indicator if notes exist
const notesIndicator = ppr.notes && ppr.notes.trim() ?
@@ -930,7 +925,7 @@
const bTime = b.eta || b.departure_dt;
if (!aTime) return 1;
if (!bTime) return -1;
return new Date(aTime) - new Date(bTime);
return parseUtcDate(aTime) - parseUtcDate(bTime);
});
tbody.innerHTML = '';
document.getElementById('arrivals-table-content').style.display = 'block';
@@ -975,7 +970,7 @@
let departureTime = flight.departed_dt || flight.etd;
let etaTime = departureTime;
if (departureTime && flight.duration) {
const departTime = new Date(departureTime);
const departTime = parseUtcDate(departureTime);
etaTime = new Date(departTime.getTime() + flight.duration * 60000).toISOString(); // duration is in minutes
}
eta = etaTime ? formatTimeOnly(etaTime) : '-';
@@ -1126,7 +1121,7 @@
const bTime = b.etd || b.created_dt;
if (!aTime) return 1;
if (!bTime) return -1;
return new Date(aTime) - new Date(bTime);
return parseUtcDate(aTime) - parseUtcDate(bTime);
});
tbody.innerHTML = '';
document.getElementById('departures-table-content').style.display = 'block';
@@ -1359,10 +1354,7 @@
}
function getLocalDateString(date = new 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}`;
return date.toISOString().split('T')[0];
}
function isTodayDateTime(value) {
+3 -4
View File
@@ -966,8 +966,7 @@
if (/^[0-9]{4}$/.test(timeValue)) {
timeValue = timeValue.slice(0, 2) + ':' + timeValue.slice(2);
}
const datetime = new Date(`${today}T${timeValue}:00`);
data[field] = datetime.toISOString();
data[field] = `${today}T${timeValue}:00Z`;
}
});
@@ -1031,8 +1030,8 @@
function setDefaultTimes() {
const now = new Date();
const futureTime = new Date(now.getTime() + 10 * 60000); // 10 minutes from now
const futureHours = String(futureTime.getHours()).padStart(2, '0');
const futureMinutes = String(futureTime.getMinutes()).padStart(2, '0');
const futureHours = String(futureTime.getUTCHours()).padStart(2, '0');
const futureMinutes = String(futureTime.getUTCMinutes()).padStart(2, '0');
const futureTimeValue = `${futureHours}:${futureMinutes}`;
const etdFieldIds = ['localETD', 'circuitETD', 'depETD', 'arrETA'];
+1 -1
View File
@@ -315,7 +315,7 @@
}
function fromLocalInputValue(value) {
return new Date(value).toISOString();
return `${value}:00Z`;
}
function showMessage(message, isError = false, clear = false) {
+2 -5
View File
@@ -686,15 +686,12 @@
function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function getLocalDateString(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}`;
return date.toISOString().split('T')[0];
}
function renderRequestList() {
+28 -11
View File
@@ -437,12 +437,12 @@
if (!utcDateStr.includes('T')) {
utcDateStr = utcDateStr.replace(' ', 'T');
}
if (!utcDateStr.includes('Z')) {
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
utcDateStr += 'Z';
}
const etaDate = new Date(utcDateStr);
const etaDateStr = etaDate.toISOString().split('T')[0];
const etaTimeStr = etaDate.toISOString().slice(11, 16);
const etaDateStr = formatLocalDateInput(etaDate);
const etaTimeStr = formatLocalTimeInput(etaDate);
document.getElementById('eta-date').value = etaDateStr;
document.getElementById('eta-time').value = etaTimeStr;
}
@@ -457,12 +457,12 @@
if (!utcDateStr.includes('T')) {
utcDateStr = utcDateStr.replace(' ', 'T');
}
if (!utcDateStr.includes('Z')) {
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
utcDateStr += 'Z';
}
const etdDate = new Date(utcDateStr);
const etdDateStr = etdDate.toISOString().split('T')[0];
const etdTimeStr = etdDate.toISOString().slice(11, 16);
const etdDateStr = formatLocalDateInput(etdDate);
const etdTimeStr = formatLocalTimeInput(etdDate);
document.getElementById('etd-date').value = etdDateStr;
document.getElementById('etd-time').value = etdTimeStr;
}
@@ -471,15 +471,35 @@
document.getElementById('email').value = ppr.email || '';
document.getElementById('phone').value = ppr.phone || '';
document.getElementById('notes').value = ppr.notes || '';
if (['CANCELED', 'DELETED', 'LANDED', 'DEPARTED'].includes(ppr.status)) {
document.getElementById('update-btn').disabled = true;
document.getElementById('cancel-btn').disabled = true;
showNotification('This PPR can no longer be edited or cancelled online.', true);
}
} else {
throw new Error('Failed to load PPR data');
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Failed to load PPR data');
}
} catch (error) {
console.error('Error loading PPR:', error);
showNotification('Error loading PPR data', true);
showNotification(`Error loading PPR: ${error.message}`, true);
}
}
function formatLocalDateInput(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 formatLocalTimeInput(date) {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
// Aircraft lookup (same as submit form)
let aircraftLookupTimeout;
async function handleAircraftLookup(registration) {
@@ -692,9 +712,6 @@
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
e.preventDefault();
// Auto-save any unsaved aircraft types
await autoSaveUnsavedAircraft(this);
const formData = new FormData(this);
const pprData = {};
+7 -7
View File
@@ -631,15 +631,15 @@
if (!utcDateTimeString) return '';
try {
// API datetimes are UTC; normalize naive strings before local display.
let utcDateStr = utcDateTimeString;
if (!utcDateStr.includes('T')) {
utcDateStr = utcDateStr.replace(' ', 'T');
// API datetimes are UTC, but DB-backed values may arrive without a timezone suffix.
let normalizedDateTime = String(utcDateTimeString).trim();
if (!normalizedDateTime.includes('T')) {
normalizedDateTime = normalizedDateTime.replace(' ', 'T');
}
if (!/[zZ]|[+-]\d{2}:\d{2}$/.test(utcDateStr)) {
utcDateStr += 'Z';
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(normalizedDateTime)) {
normalizedDateTime += 'Z';
}
const date = new Date(utcDateStr);
const date = new Date(normalizedDateTime);
// Check if valid date
if (isNaN(date.getTime())) {
+7 -17
View File
@@ -409,10 +409,10 @@
function setDefaultDates() {
const today = new Date();
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30);
document.getElementById('dateFrom').valueAsDate = thirtyDaysAgo;
document.getElementById('dateTo').valueAsDate = today;
document.getElementById('dateFrom').value = thirtyDaysAgo.toISOString().split('T')[0];
document.getElementById('dateTo').value = today.toISOString().split('T')[0];
}
async function loadJournalEntries() {
@@ -712,23 +712,13 @@
}
function formatDateTime(dateString) {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const normalized = dateString.includes('T') ? dateString : dateString.replace(' ', 'T');
const date = new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(normalized) ? normalized : `${normalized}Z`);
return date.toISOString().slice(0, 19).replace('T', ' ');
}
function formatDate(date) {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit'
});
return date.toISOString().slice(0, 10);
}
function escapeHtml(text) {
+22 -24
View File
@@ -647,10 +647,10 @@
// Set date range to this week (Monday to Sunday)
function setDateRangeThisWeek() {
const now = new Date();
const dayOfWeek = now.getDay();
const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
const monday = new Date(now.setDate(diff));
const sunday = new Date(now.setDate(diff + 6));
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];
@@ -665,8 +665,8 @@
// Set date range to this month
function setDateRangeThisMonth() {
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
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];
@@ -812,21 +812,14 @@
let dateRangeText = '';
if (dateFrom && dateTo && dateFrom === dateTo) {
// Single day
const date = new Date(dateFrom + 'T00:00:00Z');
dateRangeText = `for ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
dateRangeText = `for ${formatDateOnly(dateFrom)}`;
} else if (dateFrom && dateTo) {
// Date range
const fromDate = new Date(dateFrom + 'T00:00:00Z');
const toDate = new Date(dateTo + 'T00:00:00Z');
const fromText = fromDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
const toText = toDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
dateRangeText = `for ${fromText} to ${toText}`;
dateRangeText = `for ${formatDateOnly(dateFrom)} to ${formatDateOnly(dateTo)}`;
} else if (dateFrom) {
const date = new Date(dateFrom + 'T00:00:00Z');
dateRangeText = `from ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
dateRangeText = `from ${formatDateOnly(dateFrom)}`;
} else if (dateTo) {
const date = new Date(dateTo + 'T00:00:00Z');
dateRangeText = `until ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
dateRangeText = `until ${formatDateOnly(dateTo)}`;
}
// Update summary title with date range
@@ -906,11 +899,11 @@
const date = new Date(utcDateStr);
// Format as dd/mm/yy hh:mm
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = String(date.getFullYear()).slice(-2);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
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}`;
}
@@ -927,12 +920,17 @@
const date = new Date(utcDateStr);
// Format as hh:mm only
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
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 = '';
+389 -87
View File
@@ -155,6 +155,14 @@
overflow: hidden;
}
.clickable-row {
cursor: pointer;
}
.clickable-row:hover {
background-color: #eef6ff;
}
.table-header {
background: #34495e;
color: white;
@@ -311,6 +319,98 @@
background-color: #e74c3c;
}
.modal {
display: none;
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.45);
}
.modal-content {
background: white;
margin: 4% auto;
width: min(920px, calc(100% - 2rem));
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0,0,0,0.25);
overflow: hidden;
}
.modal-header {
background: #34495e;
color: white;
padding: 1rem 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
font-size: 1.2rem;
}
.close {
background: none;
border: none;
color: white;
font-size: 1.8rem;
cursor: pointer;
line-height: 1;
}
.modal-body {
padding: 1.25rem;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
}
.detail-field {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 0.65rem;
background: #fafafa;
}
.detail-label {
font-size: 0.72rem;
text-transform: uppercase;
color: #667085;
font-weight: 700;
margin-bottom: 0.25rem;
}
.detail-value {
overflow-wrap: anywhere;
}
.journal-section {
margin-top: 1rem;
border-top: 1px solid #e5e7eb;
padding-top: 1rem;
}
.journal-entry {
border-left: 3px solid #3498db;
padding: 0.5rem 0.75rem;
background: #f8fafc;
margin-top: 0.5rem;
}
.journal-meta {
font-size: 0.78rem;
color: #667085;
margin-bottom: 0.25rem;
}
/* Responsive design */
@media (max-width: 768px) {
.container {
@@ -474,7 +574,7 @@
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Departures</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-departures">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('OVERFLIGHT')">
<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="overflights-count">0</div>
</div>
@@ -524,14 +624,12 @@
<th>Callsign</th>
<th>Captain</th>
<th>From</th>
<th>ETA</th>
<th>POB In</th>
<th>To</th>
<th>ETD</th>
<th>Takeoff</th>
<th>Landing</th>
<th>POB In</th>
<th>POB Out</th>
<th>Fuel</th>
<th>Landed</th>
<th>Departed</th>
<th>Email</th>
<th>Phone</th>
<th>Notes</th>
@@ -582,8 +680,8 @@
<th>Callsign</th>
<th>From</th>
<th>To</th>
<th>ETA / ETD / Called</th>
<th>Landed / Departed / QSY</th>
<th>Takeoff</th>
<th>Landing</th>
<th>Circuits</th>
</tr>
</thead>
@@ -600,6 +698,22 @@
</div>
</div>
<div id="reportDetailModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="report-detail-title">Details</h2>
<button class="close" onclick="closeReportDetailModal()">&times;</button>
</div>
<div class="modal-body">
<div id="report-detail-body" class="detail-grid"></div>
<div class="journal-section">
<h3 style="margin: 0 0 0.5rem 0;">Journal</h3>
<div id="report-detail-journal">Loading...</div>
</div>
</div>
</div>
</div>
<!-- Success Notification -->
<div id="notification" class="notification"></div>
@@ -699,10 +813,10 @@
// Set date range to this week (Monday to Sunday)
function setDateRangeThisWeek() {
const now = new Date();
const dayOfWeek = now.getDay();
const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
const monday = new Date(now.setDate(diff));
const sunday = new Date(now.setDate(diff + 6));
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];
@@ -717,8 +831,8 @@
// Set date range to this month
function setDateRangeThisMonth() {
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
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];
@@ -871,9 +985,12 @@
const arrivals = await arrivalsResponse.json();
otherFlights.push(...arrivals.map(f => ({
...f,
entityType: 'ARRIVAL',
flightType: 'ARRIVAL',
aircraft_type: f.type,
timeField: f.eta || f.landed_dt,
sortTime: f.landed_dt || f.eta || f.created_dt,
takeoffTime: null,
landingTime: f.landed_dt,
fromField: f.in_from,
toField: 'EGFH'
})));
@@ -883,9 +1000,12 @@
const departures = await departuresResponse.json();
otherFlights.push(...departures.map(f => ({
...f,
entityType: 'DEPARTURE',
flightType: 'DEPARTURE',
aircraft_type: f.type,
timeField: f.etd || f.departed_dt,
sortTime: f.takeoff_dt || f.departed_dt || f.etd || f.created_dt,
takeoffTime: f.takeoff_dt || f.departed_dt,
landingTime: null,
fromField: 'EGFH',
toField: f.out_to
})));
@@ -895,10 +1015,13 @@
const localFlights = await localFlightsResponse.json();
otherFlights.push(...localFlights.map(f => ({
...f,
entityType: 'LOCAL_FLIGHT',
flightType: f.flight_type === 'CIRCUITS' ? 'CIRCUIT' : f.flight_type,
aircraft_type: f.type,
circuits: f.circuits,
timeField: f.departed_dt,
sortTime: f.takeoff_dt || f.departed_dt || f.landed_dt || f.etd || f.created_dt,
takeoffTime: f.takeoff_dt || f.departed_dt,
landingTime: f.landed_dt,
fromField: 'EGFH',
toField: 'EGFH'
})));
@@ -908,10 +1031,13 @@
const overflights = await overflightsResponse.json();
otherFlights.push(...overflights.map(f => ({
...f,
entityType: 'OVERFLIGHT',
flightType: 'OVERFLIGHT',
aircraft_type: f.type,
circuits: null,
timeField: f.call_dt,
sortTime: f.call_dt,
takeoffTime: null,
landingTime: null,
fromField: f.departure_airfield,
toField: f.destination_airfield,
callsign: f.registration
@@ -959,21 +1085,14 @@
let dateRangeText = '';
if (dateFrom && dateTo && dateFrom === dateTo) {
// Single day
const date = new Date(dateFrom + 'T00:00:00Z');
dateRangeText = `for ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
dateRangeText = `for ${formatDateOnly(dateFrom)}`;
} else if (dateFrom && dateTo) {
// Date range
const fromDate = new Date(dateFrom + 'T00:00:00Z');
const toDate = new Date(dateTo + 'T00:00:00Z');
const fromText = fromDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
const toText = toDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
dateRangeText = `for ${fromText} to ${toText}`;
dateRangeText = `for ${formatDateOnly(dateFrom)} to ${formatDateOnly(dateTo)}`;
} else if (dateFrom) {
const date = new Date(dateFrom + 'T00:00:00Z');
dateRangeText = `from ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
dateRangeText = `from ${formatDateOnly(dateFrom)}`;
} else if (dateTo) {
const date = new Date(dateTo + 'T00:00:00Z');
dateRangeText = `until ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
dateRangeText = `until ${formatDateOnly(dateTo)}`;
}
// Update summary title with date range
@@ -1045,11 +1164,13 @@
return;
}
// Sort by ETA (ascending)
// Sort by first actual movement time, then planned times as a fallback.
pprs.sort((a, b) => {
if (!a.eta) return 1;
if (!b.eta) return -1;
return new Date(a.eta) - new Date(b.eta);
const aTime = getPPRSortTime(a);
const bTime = getPPRSortTime(b);
if (!aTime) return 1;
if (!bTime) return -1;
return parseUtcDate(aTime) - parseUtcDate(bTime);
});
tbody.innerHTML = '';
@@ -1057,12 +1178,11 @@
for (const ppr of pprs) {
const row = document.createElement('tr');
row.className = 'clickable-row';
row.onclick = () => openReportDetail('PPR', ppr.id);
// Format dates
const eta = ppr.eta ? formatDateTime(ppr.eta) : '-';
const etd = ppr.etd ? formatDateTime(ppr.etd) : '-';
const landed = ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '-';
const departed = ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '-';
const takeoff = ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '-';
const landing = ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '-';
const submitted = ppr.submitted_dt ? formatDateTime(ppr.submitted_dt) : '-';
// Status styling
@@ -1076,14 +1196,12 @@
<td>${ppr.ac_call || '-'}</td>
<td>${ppr.captain}</td>
<td>${ppr.in_from}</td>
<td>${eta}</td>
<td>${ppr.pob_in}</td>
<td>${ppr.out_to || '-'}</td>
<td>${etd}</td>
<td>${takeoff}</td>
<td>${landing}</td>
<td>${ppr.pob_in}</td>
<td>${ppr.pob_out || '-'}</td>
<td>${ppr.fuel || '-'}</td>
<td>${landed}</td>
<td>${departed}</td>
<td>${ppr.email || '-'}</td>
<td>${ppr.phone || '-'}</td>
<td>${ppr.notes || '-'}</td>
@@ -1176,10 +1294,10 @@
const tbody = document.getElementById('other-flights-table-body');
const tableInfo = document.getElementById('other-flights-info');
// Apply filter if one is selected
let filteredFlights = flights;
// Overflights are counted in the summary but omitted from the detail table for now.
let filteredFlights = flights.filter(flight => flight.flightType !== 'OVERFLIGHT');
if (otherFlightsFilterType) {
filteredFlights = flights.filter(flight => flight.flightType === otherFlightsFilterType);
filteredFlights = filteredFlights.filter(flight => flight.flightType === otherFlightsFilterType);
}
tableInfo.textContent = `${filteredFlights.length} flights found` + (otherFlightsFilterType ? ` (filtered by ${otherFlightsFilterType})` : '');
@@ -1190,13 +1308,13 @@
return;
}
// Sort by time field (ascending)
// Sort by the first pertinent movement time.
filteredFlights.sort((a, b) => {
const aTime = a.timeField;
const bTime = b.timeField;
const aTime = a.sortTime;
const bTime = b.sortTime;
if (!aTime) return 1;
if (!bTime) return -1;
return new Date(aTime) - new Date(bTime);
return parseUtcDate(aTime) - parseUtcDate(bTime);
});
tbody.innerHTML = '';
@@ -1205,6 +1323,8 @@
for (const flight of filteredFlights) {
const row = document.createElement('tr');
row.className = 'clickable-row';
row.onclick = () => openReportDetail(flight.entityType, flight.id);
const typeLabel = flight.flightType;
const registration = flight.registration || '-';
@@ -1212,18 +1332,8 @@
const callsign = flight.callsign || '-';
const from = flight.fromField || '-';
const to = flight.toField || '-';
const timeDisplay = flight.timeField ? formatDateTime(flight.timeField) : '-';
// Different display for different flight types
let actualDisplay = '-';
if (flight.flightType === 'ARRIVAL') {
actualDisplay = flight.landed_dt ? formatDateTime(flight.landed_dt) : '-';
} else if (flight.flightType === 'OVERFLIGHT') {
// For overflights, show qsy_dt (frequency change time)
actualDisplay = flight.qsy_dt ? formatDateTime(flight.qsy_dt) : '-';
} else {
actualDisplay = flight.departed_dt ? formatDateTime(flight.departed_dt) : '-';
}
const takeoff = flight.takeoffTime ? formatDateTime(flight.takeoffTime) : '-';
const landing = flight.landingTime ? formatDateTime(flight.landingTime) : '-';
const status = flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING');
const circuits = (flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits > 0 ? flight.circuits : '-') : '-';
@@ -1236,8 +1346,8 @@
<td>${callsign}</td>
<td>${from}</td>
<td>${to}</td>
<td>${timeDisplay}</td>
<td>${actualDisplay}</td>
<td>${takeoff}</td>
<td>${landing}</td>
<td>${circuits}</td>
`;
@@ -1247,23 +1357,201 @@
function formatDateTime(dateStr) {
if (!dateStr) return '-';
let utcDateStr = dateStr;
const date = parseUtcDate(dateStr);
// 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 parseUtcDate(dateStr) {
let utcDateStr = String(dateStr).trim();
if (!utcDateStr.includes('T')) {
utcDateStr = utcDateStr.replace(' ', 'T');
}
if (!utcDateStr.includes('Z')) {
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
utcDateStr += 'Z';
}
const date = new Date(utcDateStr);
return new Date(utcDateStr);
}
// Format as dd/mm/yy hh:mm
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = String(date.getFullYear()).slice(-2);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
function getPPRSortTime(ppr) {
return ppr.landed_dt || ppr.departed_dt || ppr.eta || ppr.etd || ppr.submitted_dt;
}
return `${day}/${month}/${year} ${hours}:${minutes}`;
const detailConfig = {
PPR: {
endpoint: id => `/api/v1/pprs/${id}`,
journalType: 'PPR',
title: record => `PPR: ${record.ac_reg || '-'}`,
fields: [
['Status', r => r.status],
['Aircraft', r => r.ac_reg],
['Type', r => r.ac_type],
['Callsign', r => r.ac_call],
['Captain', r => r.captain],
['From', r => r.in_from],
['To', r => r.out_to],
['Takeoff', r => formatOptionalDateTime(r.departed_dt)],
['Landing', r => formatOptionalDateTime(r.landed_dt)],
['ETA', r => formatOptionalDateTime(r.eta)],
['ETD', r => formatOptionalDateTime(r.etd)],
['POB In', r => r.pob_in],
['POB Out', r => r.pob_out],
['Fuel', r => r.fuel],
['Email', r => r.email],
['Phone', r => r.phone],
['Submitted', r => formatOptionalDateTime(r.submitted_dt)],
['Created By', r => r.created_by],
['Notes', r => r.notes]
]
},
LOCAL_FLIGHT: {
endpoint: id => `/api/v1/local-flights/${id}`,
journalType: 'LOCAL_FLIGHT',
title: record => `${record.flight_type || 'LOCAL'}: ${record.registration || '-'}`,
fields: [
['Status', r => r.status],
['Aircraft', r => r.registration],
['Type', r => r.type],
['Callsign', r => r.callsign],
['Flight Type', r => r.flight_type],
['From', () => 'EGFH'],
['To', () => 'EGFH'],
['Takeoff', r => formatOptionalDateTime(r.takeoff_dt || r.departed_dt)],
['Landing', r => formatOptionalDateTime(r.landed_dt)],
['ETD', r => formatOptionalDateTime(r.etd)],
['POB', r => r.pob],
['Duration', r => r.duration ? `${r.duration} min` : null],
['Circuits', r => r.circuits],
['Created', r => formatOptionalDateTime(r.created_dt)],
['Created By', r => r.created_by],
['Notes', r => r.notes]
]
},
ARRIVAL: {
endpoint: id => `/api/v1/arrivals/${id}`,
journalType: 'ARRIVAL',
title: record => `Arrival: ${record.registration || record.callsign || '-'}`,
fields: [
['Status', r => r.status],
['Aircraft', r => r.registration],
['Type', r => r.type],
['Callsign', r => r.callsign],
['From', r => r.in_from],
['To', () => 'EGFH'],
['Landing', r => formatOptionalDateTime(r.landed_dt)],
['ETA', r => formatOptionalDateTime(r.eta)],
['POB', r => r.pob],
['Created', r => formatOptionalDateTime(r.created_dt)],
['Created By', r => r.created_by],
['Notes', r => r.notes]
]
},
DEPARTURE: {
endpoint: id => `/api/v1/departures/${id}`,
journalType: 'DEPARTURE',
title: record => `Departure: ${record.registration || '-'}`,
fields: [
['Status', r => r.status],
['Aircraft', r => r.registration],
['Type', r => r.type],
['Callsign', r => r.callsign],
['From', () => 'EGFH'],
['To', r => r.out_to],
['Takeoff', r => formatOptionalDateTime(r.takeoff_dt || r.departed_dt)],
['ETD', r => formatOptionalDateTime(r.etd)],
['POB', r => r.pob],
['Created', r => formatOptionalDateTime(r.created_dt)],
['Created By', r => r.created_by],
['Notes', r => r.notes]
]
}
};
async function openReportDetail(type, id) {
const config = detailConfig[type];
if (!config) return;
document.getElementById('report-detail-title').textContent = 'Loading...';
document.getElementById('report-detail-body').innerHTML = '';
document.getElementById('report-detail-journal').textContent = 'Loading...';
document.getElementById('reportDetailModal').style.display = 'block';
try {
const response = await authenticatedFetch(config.endpoint(id));
if (!response.ok) throw new Error('Unable to load details');
const record = await response.json();
document.getElementById('report-detail-title').textContent = config.title(record);
document.getElementById('report-detail-body').innerHTML = config.fields
.map(([label, getter]) => detailField(label, getter(record)))
.join('');
await loadReportJournal(config.journalType, id);
} catch (error) {
console.error('Error loading report detail:', error);
document.getElementById('report-detail-title').textContent = 'Unable to load details';
document.getElementById('report-detail-body').innerHTML = `<div class="detail-field"><div class="detail-value">${escapeHtml(error.message)}</div></div>`;
document.getElementById('report-detail-journal').textContent = '-';
}
}
async function loadReportJournal(entityType, entityId) {
try {
const response = await authenticatedFetch(`/api/v1/journal/${entityType}/${entityId}`);
if (!response.ok) throw new Error('Unable to load journal');
const data = await response.json();
const entries = data.entries || [];
document.getElementById('report-detail-journal').innerHTML = entries.length
? entries.map(entry => `
<div class="journal-entry">
<div class="journal-meta">${formatOptionalDateTime(entry.entry_dt)} by ${escapeHtml(entry.user || '-')}</div>
<div>${escapeHtml(entry.entry || '-')}</div>
</div>
`).join('')
: '<p>No journal entries yet.</p>';
} catch (error) {
document.getElementById('report-detail-journal').textContent = error.message;
}
}
function closeReportDetailModal() {
document.getElementById('reportDetailModal').style.display = 'none';
}
function detailField(label, value) {
const displayValue = value === null || value === undefined || value === '' ? '-' : value;
return `
<div class="detail-field">
<div class="detail-label">${escapeHtml(label)}</div>
<div class="detail-value">${escapeHtml(displayValue)}</div>
</div>
`;
}
function formatOptionalDateTime(value) {
return value ? formatDateTime(value) : '-';
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function formatDateOnly(dateStr) {
const [year, month, day] = dateStr.split('-');
return `${day}/${month}/${year}`;
}
// Clear filters
@@ -1283,8 +1571,8 @@
const headers = [
'ID', 'Status', 'Aircraft Reg', 'Aircraft Type', 'Callsign', 'Captain',
'From', 'ETA', 'POB In', 'To', 'ETD', 'POB Out', 'Fuel',
'Landed', 'Departed', 'Email', 'Phone', 'Notes', 'Submitted', 'Created By'
'From', 'To', 'Takeoff', 'Landing', 'POB In', 'POB Out', 'Fuel',
'Email', 'Phone', 'Notes', 'Submitted', 'Created By'
];
const csvData = currentPPRs.map(ppr => [
@@ -1295,14 +1583,12 @@
ppr.ac_call || '',
ppr.captain,
ppr.in_from,
ppr.eta ? formatDateTime(ppr.eta) : '',
ppr.pob_in,
ppr.out_to || '',
ppr.etd ? formatDateTime(ppr.etd) : '',
ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '',
ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '',
ppr.pob_in,
ppr.pob_out || '',
ppr.fuel || '',
ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '',
ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '',
ppr.email || '',
ppr.phone || '',
ppr.notes || '',
@@ -1319,22 +1605,26 @@
return;
}
const exportFlights = currentOtherFlights.filter(flight => flight.flightType !== 'OVERFLIGHT');
if (exportFlights.length === 0) {
showNotification('No table data to export', true);
return;
}
const headers = [
'Flight Type', 'Aircraft Registration', 'Aircraft Type', 'Callsign', 'From', 'To',
'ETA/ETD', 'Landed/Departed', 'Status', 'Circuits'
'Takeoff', 'Landing', 'Status', 'Circuits'
];
const csvData = currentOtherFlights.map(flight => [
const csvData = exportFlights.map(flight => [
flight.flightType,
flight.registration || '',
flight.aircraft_type || '',
flight.callsign || '',
flight.fromField || '',
flight.toField || '',
flight.timeField ? formatDateTime(flight.timeField) : '',
flight.flightType === 'ARRIVAL'
? (flight.landed_dt ? formatDateTime(flight.landed_dt) : '')
: (flight.departed_dt ? formatDateTime(flight.departed_dt) : ''),
flight.takeoffTime ? formatDateTime(flight.takeoffTime) : '',
flight.landingTime ? formatDateTime(flight.landingTime) : '',
flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING'),
(flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits || '') : ''
]);
@@ -1383,6 +1673,18 @@
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && document.getElementById('reportDetailModal').style.display === 'block') {
closeReportDetailModal();
}
});
window.addEventListener('click', function(e) {
if (e.target === document.getElementById('reportDetailModal')) {
closeReportDetailModal();
}
});
// Initialize when page loads
document.addEventListener('DOMContentLoaded', initializePage);
</script>
+2 -2
View File
@@ -69,7 +69,7 @@
<div id="arrival-airport-lookup-results"></div>
</div>
<div class="form-group">
<label for="eta">ETA (Local Time) *</label>
<label for="eta">ETA (UTC) *</label>
<div style="display: flex; gap: 0.5rem;">
<input type="date" id="eta-date" name="eta-date" required style="flex: 1;">
<select id="eta-time" name="eta-time" required style="flex: 1;">
@@ -95,7 +95,7 @@
<div id="departure-airport-lookup-results"></div>
</div>
<div class="form-group">
<label for="etd">ETD (Local Time)</label>
<label for="etd">ETD (UTC)</label>
<div style="display: flex; gap: 0.5rem;">
<input type="date" id="etd-date" name="etd-date" tabindex="-1" style="flex: 1;">
<select id="etd-time" name="etd-time" tabindex="-1" style="flex: 1;">
+101 -70
View File
@@ -590,50 +590,106 @@
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);
}
function normalizeUtcDateString(dateStr) {
let utcDateStr = dateStr;
if (!dateStr) return null;
let utcDateStr = String(dateStr).trim();
if (!utcDateStr.includes('T')) {
utcDateStr = utcDateStr.replace(' ', 'T');
}
if (!/[zZ]|[+-]\d{2}:\d{2}$/.test(utcDateStr)) {
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
utcDateStr += 'Z';
}
return utcDateStr;
}
function utcInputToIso(dateStr, timeStr) {
function parseUtcDate(dateStr) {
const normalized = normalizeUtcDateString(dateStr);
return normalized ? new Date(normalized) : null;
}
function utcDateOnly(dateStr) {
const date = parseUtcDate(dateStr);
return date && !Number.isNaN(date.getTime()) ? date.toISOString().slice(0, 10) : '';
}
function formatUtcDateInput(date) {
return date.toISOString().slice(0, 10);
}
function formatUtcTimeInput(date) {
return date.toISOString().slice(11, 16);
}
function formatUtcDayMonth(dateStr) {
const isoDate = utcDateOnly(dateStr);
return isoDate ? `${isoDate.slice(8, 10)}/${isoDate.slice(5, 7)}` : '-';
}
function formatUtcWeekdayDayMonth(dateStr) {
const date = parseUtcDate(dateStr);
if (!date || Number.isNaN(date.getTime())) return '-';
const dayName = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getUTCDay()];
return `${dayName} ${formatUtcDayMonth(dateStr)}`;
}
function combineUtcDateTimeInput(dateStr, timeStr) {
return `${dateStr}T${timeStr}:00Z`;
}
async function autoSaveUnsavedAircraft(form) {
if (!form || !form.hasAttribute('data-unsaved-aircraft') || !accessToken) return;
const formData = new FormData(form);
const registration = (
formData.get('ac_reg') ||
formData.get('registration') ||
formData.get('local_registration') ||
formData.get('book_in_registration') ||
formData.get('overflight_registration') ||
''
).trim();
const typeCode = (
formData.get('ac_type') ||
formData.get('type') ||
formData.get('local_type') ||
formData.get('book_in_type') ||
formData.get('overflight_type') ||
''
).trim();
if (!registration || !typeCode) return;
try {
const response = await authenticatedFetch('/api/v1/aircraft/user-aircraft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
registration,
type_code: typeCode
})
});
if (response.ok) {
form.removeAttribute('data-unsaved-aircraft');
}
} catch (error) {
console.warn('Could not save user aircraft type:', error);
}
}
// Load PPR records - now loads all tables
function formatTimeOnly(dateStr) {
if (!dateStr) return '-';
const date = parseUtcDate(dateStr);
return date && !Number.isNaN(date.getTime()) ? formatUtcTimeInput(date) : '-';
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
const date = parseUtcDate(dateStr);
return date && !Number.isNaN(date.getTime()) ? `${formatUtcDateInput(date)} ${formatUtcTimeInput(date)}` : '-';
}
// Modal functions
function openNewPPRModal() {
isNewPPR = true;
@@ -655,24 +711,10 @@
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);
document.getElementById('eta-date').value = formatUtcDateInput(eta);
document.getElementById('eta-time').value = formatUtcTimeInput(eta);
document.getElementById('etd-date').value = formatUtcDateInput(etd);
document.getElementById('etd-time').value = formatUtcTimeInput(etd);
// Clear aircraft lookup results
clearAircraftLookup();
@@ -698,15 +740,14 @@
const etaTime = document.getElementById('eta-time').value;
if (etaDate && etaTime) {
// Parse ETA
const eta = new Date(`${etaDate}T${etaTime}`);
const eta = parseUtcDate(combineUtcDateTimeInput(etaDate, 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')}`;
const etdDateStr = formatUtcDateInput(etd);
const etdTimeStr = formatUtcTimeInput(etd);
// Update ETD fields
document.getElementById('etd-date').value = etdDateStr;
@@ -769,20 +810,14 @@
Object.keys(ppr).forEach(key => {
if (key === 'eta' || key === 'etd') {
if (ppr[key]) {
// ppr[key] is UTC datetime string from API (naive, assume UTC)
const utcDateStr = normalizeUtcDateString(ppr[key]);
const date = new Date(utcDateStr); // Now correctly parsed as UTC
const date = parseUtcDate(ppr[key]);
// 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.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
const dateValue = `${year}-${month}-${day}`;
const dateValue = formatUtcDateInput(date);
dateField.value = dateValue;
// Format time (round to nearest 15-minute interval)
@@ -1098,16 +1133,12 @@
// Combine date and time for ETA
const dateStr = formData.get('eta-date');
const timeStr = formData.get('eta-time');
pprData.eta = isNewPPR
? new Date(`${dateStr}T${timeStr}`).toISOString()
: utcInputToIso(dateStr, timeStr);
pprData.eta = combineUtcDateTimeInput(dateStr, timeStr);
} 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 = isNewPPR
? new Date(`${dateStr}T${timeStr}`).toISOString()
: utcInputToIso(dateStr, timeStr);
pprData.etd = combineUtcDateTimeInput(dateStr, timeStr);
} else if (key !== 'eta-time' && key !== 'etd-time') {
// Skip the time fields as they're handled above
pprData[key] = value;
@@ -1869,13 +1900,13 @@
// Parse and populate call_dt
if (overflight.call_dt) {
const callDt = new Date(overflight.call_dt);
const callDt = parseUtcDate(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);
const qsyDt = parseUtcDate(overflight.qsy_dt);
document.getElementById('overflight_edit_qsy_dt').value = qsyDt.toISOString().slice(0, 16);
} else {
document.getElementById('overflight_edit_qsy_dt').value = '';