Compare commits
2 Commits
870bc0649b
...
74c21fe988
| Author | SHA1 | Date | |
|---|---|---|---|
| 74c21fe988 | |||
| c2e4d2adeb |
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
<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>
|
||||
<p>Best regards,<br>Swansea Airport Team</p>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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'];
|
||||
|
||||
@@ -315,7 +315,7 @@
|
||||
}
|
||||
|
||||
function fromLocalInputValue(value) {
|
||||
return new Date(value).toISOString();
|
||||
return `${value}:00Z`;
|
||||
}
|
||||
|
||||
function showMessage(message, isError = false, clear = false) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+29
-12
@@ -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 = {};
|
||||
|
||||
@@ -792,4 +809,4 @@
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+7
-7
@@ -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
@@ -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
@@ -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 = '';
|
||||
|
||||
+391
-89
@@ -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()">×</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);
|
||||
|
||||
// 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');
|
||||
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
return new Date(utcDateStr);
|
||||
}
|
||||
|
||||
function getPPRSortTime(ppr) {
|
||||
return ppr.landed_dt || ppr.departed_dt || ppr.eta || ppr.etd || ppr.submitted_dt;
|
||||
}
|
||||
|
||||
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 => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[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>
|
||||
|
||||
@@ -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
@@ -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 = '';
|
||||
|
||||
Reference in New Issue
Block a user