From c2e4d2adebc0331f6bb2dd2fcd3a8d73b8766142 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Sun, 28 Jun 2026 07:37:41 -0400 Subject: [PATCH] Reporting and TZ updates --- backend/app/api/endpoints/pprs.py | 28 +- backend/app/core/config.py | 1 + backend/app/templates/ppr_cancelled.html | 6 +- backend/app/templates/ppr_submitted.html | 7 +- backend/tests/test_pprs_api.py | 22 +- docker-compose.staging.yml | 82 ++++ web/admin.html | 27 +- web/atc.html | 30 +- web/book.html | 7 +- web/drone-request.html | 2 +- web/drone-requests.html | 7 +- web/edit.html | 41 +- web/index.html | 11 +- web/journal.html | 24 +- web/movements.html | 46 ++- web/reports.html | 480 ++++++++++++++++++----- web/shared-modals.html | 4 +- web/shared.js | 162 +++++--- 18 files changed, 719 insertions(+), 268 deletions(-) create mode 100644 docker-compose.staging.yml diff --git a/backend/app/api/endpoints/pprs.py b/backend/app/api/endpoints/pprs.py index 880d2a0..1f10587 100644 --- a/backend/app/api/endpoints/pprs.py +++ b/backend/app/api/endpoints/pprs.py @@ -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) } ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index dbf6593..cf0e3f9 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 = "" diff --git a/backend/app/templates/ppr_cancelled.html b/backend/app/templates/ppr_cancelled.html index 1737ee1..789e579 100644 --- a/backend/app/templates/ppr_cancelled.html +++ b/backend/app/templates/ppr_cancelled.html @@ -10,10 +10,10 @@

PPR Details:

If this was not intended, please contact us.

Best regards,
Swansea Airport Team

- \ No newline at end of file + diff --git a/backend/app/templates/ppr_submitted.html b/backend/app/templates/ppr_submitted.html index c807256..58d6583 100644 --- a/backend/app/templates/ppr_submitted.html +++ b/backend/app/templates/ppr_submitted.html @@ -10,12 +10,11 @@

PPR Details:

You can edit or cancel your PPR using this secure link.

You will receive further updates via email.

Best regards,
Swansea Airport Team

- \ No newline at end of file + diff --git a/backend/tests/test_pprs_api.py b/backend/tests/test_pprs_api.py index 23c5e44..df8f313 100644 --- a/backend/tests/test_pprs_api.py +++ b/backend/tests/test_pprs_api.py @@ -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 diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..81f0b73 --- /dev/null +++ b/docker-compose.staging.yml @@ -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 diff --git a/web/admin.html b/web/admin.html index cc57109..062ecbe 100644 --- a/web/admin.html +++ b/web/admin.html @@ -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'; diff --git a/web/atc.html b/web/atc.html index de23b11..e461565 100644 --- a/web/atc.html +++ b/web/atc.html @@ -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) { diff --git a/web/book.html b/web/book.html index b30972f..156e449 100644 --- a/web/book.html +++ b/web/book.html @@ -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']; diff --git a/web/drone-request.html b/web/drone-request.html index e71d0cf..65d5f2a 100644 --- a/web/drone-request.html +++ b/web/drone-request.html @@ -315,7 +315,7 @@ } function fromLocalInputValue(value) { - return new Date(value).toISOString(); + return `${value}:00Z`; } function showMessage(message, isError = false, clear = false) { diff --git a/web/drone-requests.html b/web/drone-requests.html index dc2bbd0..f69c92f 100644 --- a/web/drone-requests.html +++ b/web/drone-requests.html @@ -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() { diff --git a/web/edit.html b/web/edit.html index cc6a715..40445fe 100644 --- a/web/edit.html +++ b/web/edit.html @@ -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 @@ }); - \ No newline at end of file + diff --git a/web/index.html b/web/index.html index 85717ca..25063d9 100644 --- a/web/index.html +++ b/web/index.html @@ -631,8 +631,15 @@ if (!utcDateTimeString) return ''; try { - // Parse the ISO datetime string - const date = new Date(utcDateTimeString); + // 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(normalizedDateTime)) { + normalizedDateTime += 'Z'; + } + const date = new Date(normalizedDateTime); // Check if valid date if (isNaN(date.getTime())) { diff --git a/web/journal.html b/web/journal.html index dcad406..ae4de23 100644 --- a/web/journal.html +++ b/web/journal.html @@ -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) { diff --git a/web/movements.html b/web/movements.html index 63c6ae3..13d0214 100644 --- a/web/movements.html +++ b/web/movements.html @@ -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 = ''; diff --git a/web/reports.html b/web/reports.html index d78069f..73795c2 100644 --- a/web/reports.html +++ b/web/reports.html @@ -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 @@
Departures
0
-
+
Overflights
0
@@ -524,14 +624,12 @@ Callsign Captain From - ETA - POB In To - ETD + Takeoff + Landing + POB In POB Out Fuel - Landed - Departed Email Phone Notes @@ -582,8 +680,8 @@ Callsign From To - ETA / ETD / Called - Landed / Departed / QSY + Takeoff + Landing Circuits @@ -600,6 +698,22 @@
+ +
@@ -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 @@ ${ppr.ac_call || '-'} ${ppr.captain} ${ppr.in_from} - ${eta} - ${ppr.pob_in} ${ppr.out_to || '-'} - ${etd} + ${takeoff} + ${landing} + ${ppr.pob_in} ${ppr.pob_out || '-'} ${ppr.fuel || '-'} - ${landed} - ${departed} ${ppr.email || '-'} ${ppr.phone || '-'} ${ppr.notes || '-'} @@ -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 @@ ${callsign} ${from} ${to} - ${timeDisplay} - ${actualDisplay} + ${takeoff} + ${landing} ${circuits} `; @@ -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 = `
${escapeHtml(error.message)}
`; + 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 => ` +
+
${formatOptionalDateTime(entry.entry_dt)} by ${escapeHtml(entry.user || '-')}
+
${escapeHtml(entry.entry || '-')}
+
+ `).join('') + : '

No journal entries yet.

'; + } 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 ` +
+
${escapeHtml(label)}
+
${escapeHtml(displayValue)}
+
+ `; + } + + 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); diff --git a/web/shared-modals.html b/web/shared-modals.html index 81fceea..9307e41 100644 --- a/web/shared-modals.html +++ b/web/shared-modals.html @@ -69,7 +69,7 @@
- +