Compare commits
5 Commits
870bc0649b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a49dfe219 | |||
| 8d8cb9ccad | |||
| 4b6dd9c93c | |||
| 74c21fe988 | |||
| c2e4d2adeb |
@@ -0,0 +1,34 @@
|
||||
"""Rename drone request altitude to AGL
|
||||
|
||||
Revision ID: 010_drone_request_agl_altitude
|
||||
Revises: 009_drone_requests
|
||||
Create Date: 2026-06-29 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '010_drone_request_agl_altitude'
|
||||
down_revision = '009_drone_requests'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
'drone_requests',
|
||||
'maximum_elevation_ft_amsl',
|
||||
new_column_name='maximum_elevation_ft_agl',
|
||||
existing_type=sa.Integer(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
'drone_requests',
|
||||
'maximum_elevation_ft_agl',
|
||||
new_column_name='maximum_elevation_ft_amsl',
|
||||
existing_type=sa.Integer(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add LOCAL status to PPR records
|
||||
|
||||
Revision ID: 011_ppr_local_status
|
||||
Revises: 010_drone_request_agl_altitude
|
||||
Create Date: 2026-06-29 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = '011_ppr_local_status'
|
||||
down_revision = '010_drone_request_agl_altitude'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE submitted CHANGE COLUMN departed_dt takeoff_dt DATETIME NULL")
|
||||
op.execute("ALTER TABLE submitted ADD COLUMN qsy_dt DATETIME NULL AFTER takeoff_dt")
|
||||
op.execute(
|
||||
"ALTER TABLE submitted MODIFY COLUMN status "
|
||||
"ENUM('NEW','CONFIRMED','CANCELED','LANDED','LOCAL','DELETED','DEPARTED','ACTIVATED') "
|
||||
"NOT NULL DEFAULT 'NEW'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("UPDATE submitted SET status = 'LANDED' WHERE status = 'LOCAL'")
|
||||
op.execute("ALTER TABLE submitted DROP COLUMN qsy_dt")
|
||||
op.execute("ALTER TABLE submitted CHANGE COLUMN takeoff_dt departed_dt DATETIME NULL")
|
||||
op.execute(
|
||||
"ALTER TABLE submitted MODIFY COLUMN status "
|
||||
"ENUM('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED','ACTIVATED') "
|
||||
"NOT NULL DEFAULT 'NEW'"
|
||||
)
|
||||
@@ -52,7 +52,7 @@ async def _send_drone_email(drone_request, subject: str, message: str):
|
||||
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||
"maximum_elevation_ft_amsl": drone_request.maximum_elevation_ft_amsl,
|
||||
"maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl,
|
||||
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
||||
},
|
||||
)
|
||||
@@ -70,7 +70,7 @@ async def _send_drone_submitted_email(drone_request):
|
||||
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||
"maximum_elevation_ft_amsl": drone_request.maximum_elevation_ft_amsl,
|
||||
"maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl,
|
||||
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
||||
},
|
||||
)
|
||||
@@ -93,7 +93,7 @@ async def _send_drone_tower_notification(drone_request):
|
||||
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||
"maximum_elevation_ft_amsl": drone_request.maximum_elevation_ft_amsl,
|
||||
"maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl,
|
||||
"inside_frz": "Yes" if drone_request.location_inside_frz else "No",
|
||||
"notes": drone_request.applicant_notes,
|
||||
"requests_url": f"{settings.base_url}/drone-requests",
|
||||
@@ -115,7 +115,7 @@ async def _send_drone_approved_email(drone_request, message: Optional[str] = Non
|
||||
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||
"maximum_elevation_ft_amsl": drone_request.maximum_elevation_ft_amsl,
|
||||
"maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl,
|
||||
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -564,7 +564,7 @@ async def bulk_log_movement(
|
||||
else:
|
||||
ppr.out_to = entry.to_location or ppr.out_to
|
||||
ppr.pob_out = entry.pob or ppr.pob_out
|
||||
ppr.departed_dt = timestamp
|
||||
ppr.takeoff_dt = timestamp
|
||||
if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED):
|
||||
ppr.status = PPRStatus.DEPARTED
|
||||
if entry.notes:
|
||||
|
||||
@@ -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
|
||||
@@ -213,13 +222,21 @@ async def update_ppr_status(
|
||||
|
||||
# Send real-time update
|
||||
if hasattr(request.app.state, 'connection_manager'):
|
||||
event_timestamp = None
|
||||
if ppr.status == PPRStatus.LANDED and ppr.landed_dt:
|
||||
event_timestamp = ppr.landed_dt.isoformat()
|
||||
elif ppr.status == PPRStatus.LOCAL and ppr.takeoff_dt:
|
||||
event_timestamp = ppr.takeoff_dt.isoformat()
|
||||
elif ppr.status == PPRStatus.DEPARTED and ppr.qsy_dt:
|
||||
event_timestamp = ppr.qsy_dt.isoformat()
|
||||
|
||||
await request.app.state.connection_manager.broadcast({
|
||||
"type": "status_update",
|
||||
"data": {
|
||||
"id": ppr.id,
|
||||
"ac_reg": ppr.ac_reg,
|
||||
"status": ppr.status.value,
|
||||
"timestamp": ppr.landed_dt.isoformat() if ppr.landed_dt else (ppr.departed_dt.isoformat() if ppr.departed_dt else None)
|
||||
"timestamp": event_timestamp
|
||||
}
|
||||
})
|
||||
|
||||
@@ -232,8 +249,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 +333,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
|
||||
|
||||
@@ -340,7 +356,7 @@ async def update_ppr_public(
|
||||
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 in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.LOCAL, PPRStatus.DEPARTED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="PPR cannot be edited at this stage"
|
||||
@@ -365,7 +381,7 @@ async def cancel_ppr_public(
|
||||
detail="Invalid or expired token"
|
||||
)
|
||||
# Only allow canceling if not already processed
|
||||
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]:
|
||||
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.LOCAL, PPRStatus.DEPARTED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="PPR cannot be cancelled at this stage"
|
||||
@@ -390,8 +406,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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -145,7 +145,8 @@ async def get_public_departures(db: Session = Depends(get_db)):
|
||||
'ac_type': departure.ac_type,
|
||||
'out_to': departure.out_to,
|
||||
'etd': departure.etd,
|
||||
'departed_dt': departure.departed_dt,
|
||||
'takeoff_dt': departure.takeoff_dt,
|
||||
'qsy_dt': departure.qsy_dt,
|
||||
'status': departure.status.value,
|
||||
'isLocalFlight': False,
|
||||
'isDeparture': False
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -58,6 +58,7 @@ class CRUDPPR:
|
||||
PPRRecord.status == PPRStatus.NEW,
|
||||
PPRRecord.status == PPRStatus.CONFIRMED,
|
||||
PPRRecord.status == PPRStatus.LANDED,
|
||||
PPRRecord.status == PPRStatus.LOCAL,
|
||||
PPRRecord.status == PPRStatus.DEPARTED
|
||||
)
|
||||
)
|
||||
@@ -71,6 +72,7 @@ class CRUDPPR:
|
||||
func.date(PPRRecord.etd) == today,
|
||||
or_(
|
||||
PPRRecord.status == PPRStatus.LANDED,
|
||||
PPRRecord.status == PPRStatus.LOCAL,
|
||||
PPRRecord.status == PPRStatus.DEPARTED
|
||||
)
|
||||
)
|
||||
@@ -151,8 +153,10 @@ class CRUDPPR:
|
||||
current_time = timestamp if timestamp is not None else datetime.utcnow()
|
||||
if status == PPRStatus.LANDED:
|
||||
db_obj.landed_dt = current_time
|
||||
elif status == PPRStatus.LOCAL:
|
||||
db_obj.takeoff_dt = current_time
|
||||
elif status == PPRStatus.DEPARTED:
|
||||
db_obj.departed_dt = current_time
|
||||
db_obj.qsy_dt = current_time
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
|
||||
@@ -35,7 +35,7 @@ class DroneRequest(Base):
|
||||
estimated_completion_time = Column(String(8), nullable=True)
|
||||
estimated_takeoff_at = Column(DateTime, nullable=False, index=True)
|
||||
estimated_completion_at = Column(DateTime, nullable=False, index=True)
|
||||
maximum_elevation_ft_amsl = Column(Integer, nullable=False)
|
||||
maximum_elevation_ft_agl = Column(Integer, nullable=False)
|
||||
|
||||
location_description = Column(Text, nullable=True)
|
||||
location_latitude = Column(Float, nullable=False)
|
||||
|
||||
@@ -9,6 +9,7 @@ class PPRStatus(str, Enum):
|
||||
CONFIRMED = "CONFIRMED"
|
||||
CANCELED = "CANCELED"
|
||||
LANDED = "LANDED"
|
||||
LOCAL = "LOCAL"
|
||||
DELETED = "DELETED"
|
||||
DEPARTED = "DEPARTED"
|
||||
ACTIVATED = "ACTIVATED"
|
||||
@@ -40,7 +41,8 @@ class PPRRecord(Base):
|
||||
phone = Column(String(16), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
landed_dt = Column(DateTime, nullable=True)
|
||||
departed_dt = Column(DateTime, nullable=True)
|
||||
takeoff_dt = Column(DateTime, nullable=True)
|
||||
qsy_dt = Column(DateTime, nullable=True)
|
||||
created_by = Column(String(16), nullable=True, index=True)
|
||||
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
||||
acknowledged_dt = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, validator
|
||||
from pydantic import AliasChoices, BaseModel, EmailStr, Field, validator
|
||||
|
||||
|
||||
class DroneRequestStatus(str, Enum):
|
||||
@@ -20,7 +20,11 @@ class DroneRequestBase(BaseModel):
|
||||
flight_date: Optional[date] = None
|
||||
estimated_takeoff_time: Optional[str] = Field(None, max_length=8)
|
||||
estimated_completion_time: Optional[str] = Field(None, max_length=8)
|
||||
maximum_elevation_ft_amsl: int = Field(..., ge=0)
|
||||
maximum_elevation_ft_agl: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
validation_alias=AliasChoices("maximum_elevation_ft_agl", "maximum_elevation_ft_amsl"),
|
||||
)
|
||||
location_description: Optional[str] = None
|
||||
location_latitude: float = Field(..., ge=-90, le=90)
|
||||
location_longitude: float = Field(..., ge=-180, le=180)
|
||||
@@ -68,7 +72,11 @@ class DroneRequestUpdate(BaseModel):
|
||||
estimated_completion_time: Optional[str] = Field(None, max_length=8)
|
||||
estimated_takeoff_at: Optional[datetime] = None
|
||||
estimated_completion_at: Optional[datetime] = None
|
||||
maximum_elevation_ft_amsl: Optional[int] = Field(None, ge=0)
|
||||
maximum_elevation_ft_agl: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
validation_alias=AliasChoices("maximum_elevation_ft_agl", "maximum_elevation_ft_amsl"),
|
||||
)
|
||||
location_description: Optional[str] = None
|
||||
location_latitude: Optional[float] = Field(None, ge=-90, le=90)
|
||||
location_longitude: Optional[float] = Field(None, ge=-180, le=180)
|
||||
|
||||
@@ -9,6 +9,7 @@ class PPRStatus(str, Enum):
|
||||
CONFIRMED = "CONFIRMED"
|
||||
CANCELED = "CANCELED"
|
||||
LANDED = "LANDED"
|
||||
LOCAL = "LOCAL"
|
||||
DELETED = "DELETED"
|
||||
DEPARTED = "DEPARTED"
|
||||
ACTIVATED = "ACTIVATED"
|
||||
@@ -85,7 +86,8 @@ class PPRInDBBase(PPRBase):
|
||||
id: int
|
||||
status: PPRStatus
|
||||
landed_dt: Optional[datetime] = None
|
||||
departed_dt: Optional[datetime] = None
|
||||
takeoff_dt: Optional[datetime] = None
|
||||
qsy_dt: Optional[datetime] = None
|
||||
created_by: Optional[str] = None
|
||||
submitted_dt: datetime
|
||||
acknowledged_dt: Optional[datetime] = None
|
||||
@@ -111,7 +113,8 @@ class PPRPublic(BaseModel):
|
||||
out_to: Optional[str] = None
|
||||
etd: Optional[datetime] = None
|
||||
landed_dt: Optional[datetime] = None
|
||||
departed_dt: Optional[datetime] = None
|
||||
takeoff_dt: Optional[datetime] = None
|
||||
qsy_dt: Optional[datetime] = None
|
||||
submitted_dt: datetime
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Completion</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ completion_time }}</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Location</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ location }}</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Inside FRZ</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ inside_frz }}</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Max elevation</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Max elevation</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Applicant notes</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ notes or '-' }}</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
<p><strong>PPR Details:</strong></p>
|
||||
<ul>
|
||||
<li>Aircraft: {{ aircraft }}</li>
|
||||
<li>Original Arrival: {{ arrival_time }}</li>
|
||||
<li>Original Departure: {{ departure_time }}</li>
|
||||
<li>Original Arrival (local time): {{ arrival_time }}</li>
|
||||
<li>Original Departure (local time): {{ departure_time }}</li>
|
||||
</ul>
|
||||
<p>If this was not intended, please contact us.</p>
|
||||
<p>Best regards,<br>Swansea Airport Team</p>
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
<p><strong>PPR Details:</strong></p>
|
||||
<ul>
|
||||
<li>Aircraft: {{ aircraft }}</li>
|
||||
<li>Arrival: {{ arrival_time }}</li>
|
||||
<li>Departure: {{ departure_time }}</li>
|
||||
<li>Purpose: {{ purpose }}</li>
|
||||
<li>Arrival (local time): {{ arrival_time }}</li>
|
||||
<li>Departure (local time): {{ departure_time }}</li>
|
||||
</ul>
|
||||
<p>You can <a href="{{ base_url }}/edit.html?token={{ public_token }}">edit or cancel</a> your PPR using this secure link.</p>
|
||||
<p>You will receive further updates via email.</p>
|
||||
|
||||
@@ -81,7 +81,7 @@ DROP TABLE IF EXISTS `submitted`;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `submitted` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`status` enum('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED') CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL DEFAULT 'NEW',
|
||||
`status` enum('NEW','CONFIRMED','CANCELED','LANDED','LOCAL','DELETED','DEPARTED','ACTIVATED') CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL DEFAULT 'NEW',
|
||||
`ac_reg` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
||||
`ac_type` varchar(32) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
||||
`ac_call` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci DEFAULT NULL,
|
||||
@@ -97,7 +97,8 @@ CREATE TABLE `submitted` (
|
||||
`phone` varchar(16) DEFAULT NULL,
|
||||
`notes` varchar(2000) DEFAULT NULL,
|
||||
`landed_dt` datetime DEFAULT NULL,
|
||||
`departed_dt` datetime DEFAULT NULL,
|
||||
`takeoff_dt` datetime DEFAULT NULL,
|
||||
`qsy_dt` datetime DEFAULT NULL,
|
||||
`created_by` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci DEFAULT NULL,
|
||||
`submitted_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `id` (`id`)
|
||||
|
||||
@@ -15,7 +15,7 @@ def drone_payload(**overrides):
|
||||
"estimated_completion_time": "10:30",
|
||||
"estimated_takeoff_at": "2026-06-20T10:00:00",
|
||||
"estimated_completion_at": "2026-06-20T10:30:00",
|
||||
"maximum_elevation_ft_amsl": 250,
|
||||
"maximum_elevation_ft_agl": 250,
|
||||
"location_description": "North apron",
|
||||
"location_latitude": 51.623389,
|
||||
"location_longitude": -4.069231,
|
||||
@@ -81,6 +81,23 @@ def test_public_drone_request_create_edit_cancel_and_journal(client, db, monkeyp
|
||||
assert client.delete("/api/v1/drone-requests/public/cancel/missing-token").status_code == 404
|
||||
|
||||
|
||||
def test_drone_request_accepts_legacy_amsl_altitude_key(client, db, monkeypatch):
|
||||
async def fake_send_email(**kwargs):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.drone_requests.email_service.send_email", fake_send_email)
|
||||
|
||||
payload = drone_payload()
|
||||
payload["maximum_elevation_ft_amsl"] = payload.pop("maximum_elevation_ft_agl")
|
||||
|
||||
create_response = client.post("/api/v1/drone-requests/public", json=payload)
|
||||
|
||||
assert create_response.status_code == 200
|
||||
assert create_response.json()["maximum_elevation_ft_agl"] == 250
|
||||
db_request = db.query(DroneRequest).filter(DroneRequest.id == create_response.json()["id"]).one()
|
||||
assert db_request.maximum_elevation_ft_agl == 250
|
||||
|
||||
|
||||
def test_authenticated_drone_request_list_update_status_comment_and_journal(auth_client, db, monkeypatch):
|
||||
sent_emails = []
|
||||
|
||||
@@ -100,7 +117,7 @@ def test_authenticated_drone_request_list_update_status_comment_and_journal(auth
|
||||
get_response = auth_client.get(f"/api/v1/drone-requests/{created['id']}")
|
||||
update_response = auth_client.patch(
|
||||
f"/api/v1/drone-requests/{created['id']}",
|
||||
json={"operator_comments": "Needs tower review", "maximum_elevation_ft_amsl": 200},
|
||||
json={"operator_comments": "Needs tower review", "maximum_elevation_ft_agl": 200},
|
||||
)
|
||||
status_response = auth_client.patch(
|
||||
f"/api/v1/drone-requests/{created['id']}/status",
|
||||
@@ -116,7 +133,7 @@ def test_authenticated_drone_request_list_update_status_comment_and_journal(auth
|
||||
assert [request["id"] for request in list_response.json()] == [created["id"]]
|
||||
assert get_response.status_code == 200
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["maximum_elevation_ft_amsl"] == 200
|
||||
assert update_response.json()["maximum_elevation_ft_agl"] == 200
|
||||
assert status_response.status_code == 200
|
||||
assert status_response.json()["status"] == "APPROVED"
|
||||
assert status_response.json()["operator_comments"] == "Approved below 200ft"
|
||||
@@ -133,7 +150,7 @@ def test_authenticated_drone_request_list_update_status_comment_and_journal(auth
|
||||
def test_drone_request_not_found_and_validation_paths(auth_client, client):
|
||||
invalid_response = client.post(
|
||||
"/api/v1/drone-requests/public",
|
||||
json=drone_payload(operator_name=" ", location_latitude=100, maximum_elevation_ft_amsl=-1),
|
||||
json=drone_payload(operator_name=" ", location_latitude=100, maximum_elevation_ft_agl=-1),
|
||||
)
|
||||
|
||||
assert invalid_response.status_code == 422
|
||||
|
||||
@@ -43,6 +43,34 @@ def test_authenticated_user_can_create_read_update_and_audit_ppr(auth_client, pp
|
||||
assert any("Status changed from NEW to LANDED" in entry for entry in entries)
|
||||
|
||||
|
||||
def test_ppr_departure_lifecycle_goes_landed_local_departed(auth_client, ppr_payload):
|
||||
created = auth_client.post("/api/v1/pprs/", json=ppr_payload).json()
|
||||
|
||||
landed_response = auth_client.patch(
|
||||
f"/api/v1/pprs/{created['id']}/status",
|
||||
json={"status": "LANDED", "timestamp": "2026-06-20T10:30:00"},
|
||||
)
|
||||
local_response = auth_client.patch(
|
||||
f"/api/v1/pprs/{created['id']}/status",
|
||||
json={"status": "LOCAL", "timestamp": "2026-06-20T12:55:00"},
|
||||
)
|
||||
departed_response = auth_client.patch(
|
||||
f"/api/v1/pprs/{created['id']}/status",
|
||||
json={"status": "DEPARTED", "timestamp": "2026-06-20T13:05:00"},
|
||||
)
|
||||
|
||||
assert landed_response.status_code == 200
|
||||
assert local_response.status_code == 200
|
||||
assert local_response.json()["status"] == "LOCAL"
|
||||
assert local_response.json()["landed_dt"] == "2026-06-20T10:30:00"
|
||||
assert local_response.json()["takeoff_dt"] == "2026-06-20T12:55:00"
|
||||
assert local_response.json()["qsy_dt"] is None
|
||||
assert departed_response.status_code == 200
|
||||
assert departed_response.json()["status"] == "DEPARTED"
|
||||
assert departed_response.json()["takeoff_dt"] == "2026-06-20T12:55:00"
|
||||
assert departed_response.json()["qsy_dt"] == "2026-06-20T13:05:00"
|
||||
|
||||
|
||||
def test_ppr_list_supports_status_date_and_pagination_filters(auth_client, ppr_factory):
|
||||
ppr_factory(
|
||||
ac_reg="G-NEW1",
|
||||
@@ -124,6 +152,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 +176,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 +211,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
|
||||
|
||||
|
||||
+3
-2
@@ -22,7 +22,7 @@ CREATE TABLE users (
|
||||
-- Main PPR submissions table with improvements
|
||||
CREATE TABLE submitted (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
status ENUM('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED') NOT NULL DEFAULT 'NEW',
|
||||
status ENUM('NEW','CONFIRMED','CANCELED','LANDED','LOCAL','DELETED','DEPARTED','ACTIVATED') NOT NULL DEFAULT 'NEW',
|
||||
ac_reg VARCHAR(16) NOT NULL,
|
||||
ac_type VARCHAR(32) NOT NULL,
|
||||
ac_call VARCHAR(16) DEFAULT NULL,
|
||||
@@ -38,7 +38,8 @@ CREATE TABLE submitted (
|
||||
phone VARCHAR(16) DEFAULT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
landed_dt DATETIME DEFAULT NULL,
|
||||
departed_dt DATETIME DEFAULT NULL,
|
||||
takeoff_dt DATETIME DEFAULT NULL,
|
||||
qsy_dt DATETIME DEFAULT NULL,
|
||||
created_by VARCHAR(16) DEFAULT NULL,
|
||||
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
acknowledged_dt DATETIME DEFAULT NULL,
|
||||
|
||||
@@ -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
|
||||
@@ -21,7 +21,7 @@ def drone_payload(operator_name):
|
||||
"estimated_completion_time": "10:30",
|
||||
"estimated_takeoff_at": "2026-06-21T10:00:00",
|
||||
"estimated_completion_at": "2026-06-21T10:30:00",
|
||||
"maximum_elevation_ft_amsl": 200,
|
||||
"maximum_elevation_ft_agl": 200,
|
||||
"location_description": "E2E north apron survey",
|
||||
"location_latitude": 51.623389,
|
||||
"location_longitude": -4.069231,
|
||||
|
||||
+144
-47
@@ -49,11 +49,11 @@
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Local Flights Table -->
|
||||
<!-- Local Traffic Table -->
|
||||
<div class="ppr-table">
|
||||
<div class="table-header">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>🛩️ Today's Local Flights - <span id="local-flights-count">0</span></span>
|
||||
<span>🛩️ Local Traffic - <span id="local-flights-count">0</span></span>
|
||||
<span class="info-icon" onclick="showTableHelp('local-flights')" title="What is this?">ℹ️</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,7 +84,7 @@
|
||||
</div>
|
||||
|
||||
<div id="local-flights-no-data" class="no-data" style="display: none;">
|
||||
<h3>No Local Flights</h3>
|
||||
<h3>No Local Traffic</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -321,6 +321,50 @@
|
||||
|
||||
<script src="shared.js"></script>
|
||||
<script>
|
||||
function normalizeUtcDateString(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)) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
return utcDateStr;
|
||||
}
|
||||
|
||||
function parseUtcDate(dateStr) {
|
||||
const normalized = normalizeUtcDateString(dateStr);
|
||||
return normalized ? new Date(normalized) : null;
|
||||
}
|
||||
|
||||
function formatUtcDateInput(date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatUtcTimeInput(date) {
|
||||
return date.toISOString().slice(11, 16);
|
||||
}
|
||||
|
||||
function formatTimeOnly(dateStr) {
|
||||
const date = parseUtcDate(dateStr);
|
||||
return date && !Number.isNaN(date.getTime()) ? formatUtcTimeInput(date) : '-';
|
||||
}
|
||||
|
||||
function formatUtcDayMonth(dateStr) {
|
||||
const date = parseUtcDate(dateStr);
|
||||
if (!date || Number.isNaN(date.getTime())) return '-';
|
||||
const isoDate = formatUtcDateInput(date);
|
||||
return `${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)}`;
|
||||
}
|
||||
|
||||
async function loadPPRs() {
|
||||
if (!accessToken) return;
|
||||
|
||||
@@ -403,12 +447,11 @@
|
||||
document.getElementById('departures-no-data').style.display = 'none';
|
||||
|
||||
try {
|
||||
// Load PPR departures and airport departures simultaneously
|
||||
const [pprResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([
|
||||
// Load PPR departures and airport departures that are still pending departure
|
||||
const [pprResponse, depBookedOutResponse, depOutGroundResponse] = await Promise.all([
|
||||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000')
|
||||
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000')
|
||||
]);
|
||||
|
||||
if (!pprResponse.ok) {
|
||||
@@ -418,10 +461,9 @@
|
||||
const allPPRs = await pprResponse.json();
|
||||
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
|
||||
const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : [];
|
||||
const depLocal = depLocalResponse.ok ? await depLocalResponse.json() : [];
|
||||
|
||||
// Combine departures
|
||||
const allDepartures = [...depBookedOut, ...depOutGround, ...depLocal];
|
||||
const allDepartures = [...depBookedOut, ...depOutGround];
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Filter for PPR departures with ETD today and LANDED status only
|
||||
@@ -434,7 +476,7 @@
|
||||
return etdDate === today;
|
||||
});
|
||||
|
||||
// Add departures to other airports (BOOKED_OUT, GROUND, and LOCAL status)
|
||||
// Add departures to other airports that are not yet airborne locally
|
||||
const depDepartures = allDepartures.map(flight => ({
|
||||
...flight,
|
||||
isDeparture: true // Flag to distinguish from PPR
|
||||
@@ -458,19 +500,41 @@
|
||||
document.getElementById('local-flights-no-data').style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/v1/local-flights/?limit=1000');
|
||||
const [localResponse, pprResponse, depResponse] = await Promise.all([
|
||||
authenticatedFetch('/api/v1/local-flights/?limit=1000'),
|
||||
authenticatedFetch('/api/v1/pprs/?status=LOCAL&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000')
|
||||
]);
|
||||
|
||||
if (!response.ok) {
|
||||
if (!localResponse.ok || !pprResponse.ok || !depResponse.ok) {
|
||||
throw new Error('Failed to fetch local flights');
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const localFlights = (await response.json()).filter(flight => {
|
||||
const localFlights = (await localResponse.json()).filter(flight => {
|
||||
if (!flight.created_dt || ['CANCELLED', 'LANDED'].includes(flight.status)) return false;
|
||||
return flight.created_dt.split('T')[0] === today;
|
||||
});
|
||||
const pprLocalTraffic = (await pprResponse.json())
|
||||
.filter(ppr => {
|
||||
const dateFields = [ppr.etd, ppr.landed_dt, ppr.submitted_dt];
|
||||
return dateFields.some(value => value && value.split('T')[0] === today);
|
||||
})
|
||||
.map(ppr => ({
|
||||
...ppr,
|
||||
isPPRLocalTraffic: true
|
||||
}));
|
||||
const departureLocalTraffic = (await depResponse.json())
|
||||
.filter(departure => {
|
||||
const dateFields = [departure.created_dt, departure.etd, departure.takeoff_dt, departure.departed_dt];
|
||||
return dateFields.some(value => value && value.split('T')[0] === today);
|
||||
})
|
||||
.map(departure => ({
|
||||
...departure,
|
||||
isDepartureLocalTraffic: true
|
||||
}));
|
||||
|
||||
displayLocalFlights(localFlights);
|
||||
displayLocalFlights([...localFlights, ...pprLocalTraffic, ...departureLocalTraffic]);
|
||||
} catch (error) {
|
||||
console.error('Error loading local flights:', error);
|
||||
if (error.message !== 'Session expired. Please log in again.') {
|
||||
@@ -517,7 +581,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 = '';
|
||||
|
||||
@@ -566,7 +630,7 @@
|
||||
}
|
||||
|
||||
|
||||
// Load departed aircraft (DEPARTED status with departed_dt today)
|
||||
// Load departed aircraft (DEPARTED status with QSY/departed time today)
|
||||
async function loadDeparted() {
|
||||
document.getElementById('departed-loading').style.display = 'block';
|
||||
document.getElementById('departed-table-content').style.display = 'none';
|
||||
@@ -587,10 +651,10 @@
|
||||
|
||||
// Filter for PPRs departed today (only PPR'd departures, exclude local/circuits)
|
||||
const departed = allPPRs.filter(ppr => {
|
||||
if (!ppr.departed_dt || ppr.status !== 'DEPARTED') {
|
||||
if (!ppr.qsy_dt || ppr.status !== 'DEPARTED') {
|
||||
return false;
|
||||
}
|
||||
const departedDate = ppr.departed_dt.split('T')[0];
|
||||
const departedDate = ppr.qsy_dt.split('T')[0];
|
||||
return departedDate === today;
|
||||
});
|
||||
|
||||
@@ -631,9 +695,9 @@
|
||||
|
||||
// Sort by departed time
|
||||
departed.sort((a, b) => {
|
||||
const aTime = a.departed_dt;
|
||||
const bTime = b.departed_dt;
|
||||
return new Date(aTime) - new Date(bTime);
|
||||
const aTime = a.isDeparture ? a.departed_dt : a.qsy_dt;
|
||||
const bTime = b.isDeparture ? b.departed_dt : b.qsy_dt;
|
||||
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
@@ -677,7 +741,7 @@
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"><span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span></td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.ac_call || '-'}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.qsy_dt)}</td>
|
||||
`;
|
||||
}
|
||||
tbody.appendChild(row);
|
||||
@@ -738,7 +802,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 +835,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 +904,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 +918,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 +1004,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 +1052,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,30 +1214,58 @@
|
||||
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('local-flights-table-content').style.display = 'block';
|
||||
|
||||
const circuitCounts = await loadLocalFlightCircuitCounts(localFlights);
|
||||
const circuitCounts = await loadLocalFlightCircuitCounts(localFlights.filter(flight => !flight.isPPRLocalTraffic && !flight.isDepartureLocalTraffic));
|
||||
|
||||
for (const flight of localFlights) {
|
||||
const row = document.createElement('tr');
|
||||
row.onclick = () => openLocalFlightEditModal(flight.id);
|
||||
const isPPR = flight.isPPRLocalTraffic;
|
||||
const isDeparture = flight.isDepartureLocalTraffic;
|
||||
row.onclick = () => isPPR ? openPPRModal(flight.id) : (isDeparture ? openDepartureEditModal(flight.id) : openLocalFlightEditModal(flight.id));
|
||||
|
||||
const aircraftDisplay = flight.callsign && flight.callsign.trim()
|
||||
const aircraftDisplay = isPPR
|
||||
? (flight.ac_call && flight.ac_call.trim()
|
||||
? `<strong>${flight.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.ac_reg}</span>`
|
||||
: `<strong>${flight.ac_reg}</strong>`)
|
||||
: isDeparture
|
||||
? (flight.callsign && flight.callsign.trim()
|
||||
? `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`
|
||||
: `<strong>${flight.registration}</strong>`;
|
||||
const typeIcon = flight.submitted_via === 'PUBLIC'
|
||||
: `<strong>${flight.registration}</strong>`)
|
||||
: (flight.callsign && flight.callsign.trim()
|
||||
? `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`
|
||||
: `<strong>${flight.registration}</strong>`);
|
||||
const typeIcon = isPPR
|
||||
? '<span style="color: #032cfc; font-weight: bold; font-size: 0.9em;" title="From PPR">P</span>'
|
||||
: isDeparture
|
||||
? (flight.submitted_via === 'PUBLIC'
|
||||
? '<span style="color: #b8860b; font-weight: bold; font-size: 0.9em;" title="Submitted by Pilot Online">O</span>'
|
||||
: '<span style="color: #6f42c1; font-weight: bold; font-size: 0.9em;" title="Airport departure">D</span>')
|
||||
: flight.submitted_via === 'PUBLIC'
|
||||
? '<span style="color: #b8860b; font-weight: bold; font-size: 0.9em;" title="Submitted by Pilot Online">O</span>'
|
||||
: '<span style="color: #228b22; font-weight: bold; font-size: 0.9em;" title="Local flight">L</span>';
|
||||
const flightType = flight.flight_type === 'CIRCUITS' ? 'Circuits' : flight.flight_type === 'LOCAL' ? 'Local' : 'Departure';
|
||||
const flightType = isPPR ? (flight.out_to ? `To ${flight.out_to}` : 'PPR Departure') : isDeparture ? (flight.out_to ? `To ${flight.out_to}` : 'Departure') : flight.flight_type === 'CIRCUITS' ? 'Circuits' : flight.flight_type === 'LOCAL' ? 'Local' : 'Departure';
|
||||
const etd = flight.etd ? formatTimeOnly(flight.etd) : (flight.created_dt ? formatTimeOnly(flight.created_dt) : '-');
|
||||
const circuits = circuitCounts[flight.id] ?? flight.circuits ?? 0;
|
||||
const circuits = (isPPR || isDeparture) ? '-' : circuitCounts[flight.id] ?? flight.circuits ?? 0;
|
||||
|
||||
let actionButtons = '';
|
||||
if (flight.status === 'BOOKED_OUT') {
|
||||
if (isPPR) {
|
||||
actionButtons = `
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
||||
QSY
|
||||
</button>
|
||||
`;
|
||||
} else if (isDeparture) {
|
||||
actionButtons = `
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentDepartureId = ${flight.id}; showTimestampModal('DEPARTED', ${flight.id}, false, true)" title="Mark as Departed">
|
||||
QSY
|
||||
</button>
|
||||
`;
|
||||
} else if (flight.status === 'BOOKED_OUT') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('GROUND', ${flight.id}, true)" title="Contact Pilot">
|
||||
CONTACT
|
||||
@@ -1207,10 +1294,10 @@
|
||||
row.innerHTML = `
|
||||
<td>${aircraftDisplay}</td>
|
||||
<td style="text-align: center; width: 30px;">${typeIcon}</td>
|
||||
<td>${flight.type || '-'}</td>
|
||||
<td>${isPPR ? flight.ac_type || '-' : flight.type || '-'}</td>
|
||||
<td>${flightType}</td>
|
||||
<td>${etd}</td>
|
||||
<td>${flight.pob || '-'}</td>
|
||||
<td>${isPPR ? (flight.pob_out || flight.pob_in || '-') : (flight.pob || '-')}</td>
|
||||
<td>${localFlightStatusBadge(flight.status)}</td>
|
||||
<td>${circuits}</td>
|
||||
<td style="white-space: nowrap;">${actionButtons}</td>
|
||||
@@ -1273,7 +1360,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';
|
||||
@@ -1456,11 +1543,21 @@
|
||||
fuel = flight.fuel || '-';
|
||||
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
|
||||
|
||||
if (flight.status === 'LANDED') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('LOCAL', ${flight.id})" title="Mark as Local">
|
||||
TAKE OFF
|
||||
</button>
|
||||
`;
|
||||
} else if (flight.status === 'LOCAL') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
||||
QSY
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
actionButtons = '<span style="color: #999;">-</span>';
|
||||
}
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
|
||||
+118
-56
@@ -255,11 +255,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 1: Local Area -->
|
||||
<!-- Row 1: Local Traffic -->
|
||||
<div class="atc-section">
|
||||
<h2>📍 Local Area <span class="count" id="local-count">0</span></h2>
|
||||
<h2>📍 Local Traffic <span class="count" id="local-count">0</span></h2>
|
||||
<div class="aircraft-list" id="local-list">
|
||||
<div class="no-aircraft">No aircraft in local area</div>
|
||||
<div class="no-aircraft">No local traffic</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -300,6 +300,50 @@
|
||||
|
||||
<script src="shared.js"></script>
|
||||
<script>
|
||||
function normalizeUtcDateString(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)) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
return utcDateStr;
|
||||
}
|
||||
|
||||
function parseUtcDate(dateStr) {
|
||||
const normalized = normalizeUtcDateString(dateStr);
|
||||
return normalized ? new Date(normalized) : null;
|
||||
}
|
||||
|
||||
function formatUtcDateInput(date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatUtcTimeInput(date) {
|
||||
return date.toISOString().slice(11, 16);
|
||||
}
|
||||
|
||||
function formatTimeOnly(dateStr) {
|
||||
const date = parseUtcDate(dateStr);
|
||||
return date && !Number.isNaN(date.getTime()) ? formatUtcTimeInput(date) : '-';
|
||||
}
|
||||
|
||||
function formatUtcDayMonth(dateStr) {
|
||||
const date = parseUtcDate(dateStr);
|
||||
if (!date || Number.isNaN(date.getTime())) return '-';
|
||||
const isoDate = formatUtcDateInput(date);
|
||||
return `${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)}`;
|
||||
}
|
||||
|
||||
async function loadPPRs() {
|
||||
if (!accessToken) return;
|
||||
|
||||
@@ -401,15 +445,13 @@
|
||||
document.getElementById('departures-no-data').style.display = 'none';
|
||||
|
||||
try {
|
||||
// Load PPR departures, local flight departures, and airport departures simultaneously
|
||||
const [pprResponse, localBookedOutResponse, localOutGroundResponse, localLocalResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([
|
||||
// Load PPR departures, local flight departures, and airport departures that are still pending departure
|
||||
const [pprResponse, localBookedOutResponse, localOutGroundResponse, depBookedOutResponse, depOutGroundResponse] = await Promise.all([
|
||||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000')
|
||||
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000')
|
||||
]);
|
||||
|
||||
if (!pprResponse.ok) {
|
||||
@@ -419,15 +461,13 @@
|
||||
const allPPRs = await pprResponse.json();
|
||||
const localBookedOut = localBookedOutResponse.ok ? await localBookedOutResponse.json() : [];
|
||||
const localOutGround = localOutGroundResponse.ok ? await localOutGroundResponse.json() : [];
|
||||
const localLocal = localLocalResponse.ok ? await localLocalResponse.json() : [];
|
||||
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
|
||||
const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : [];
|
||||
const depLocal = depLocalResponse.ok ? await depLocalResponse.json() : [];
|
||||
|
||||
// Combine local flights
|
||||
const allLocalFlights = [...localBookedOut, ...localOutGround, ...localLocal];
|
||||
const allLocalFlights = [...localBookedOut, ...localOutGround];
|
||||
// Combine departures
|
||||
const allDepartures = [...depBookedOut, ...depOutGround, ...depLocal];
|
||||
const allDepartures = [...depBookedOut, ...depOutGround];
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Filter for PPR departures with ETD today and LANDED status only
|
||||
@@ -440,7 +480,7 @@
|
||||
return etdDate === today;
|
||||
});
|
||||
|
||||
// Add local flights (GROUND and LOCAL status - ready to go) - only those booked out today
|
||||
// Add local flights that are not yet airborne locally - only those booked out today
|
||||
const localDepartures = allLocalFlights
|
||||
.filter(flight => {
|
||||
// Only include flights booked out today (created_dt)
|
||||
@@ -454,7 +494,7 @@
|
||||
}));
|
||||
departures.push(...localDepartures);
|
||||
|
||||
// Add departures to other airports (BOOKED_OUT, GROUND, and LOCAL status)
|
||||
// Add departures to other airports that are not yet airborne locally
|
||||
const depDepartures = allDepartures.map(flight => ({
|
||||
...flight,
|
||||
isDeparture: true // Flag to distinguish from PPR
|
||||
@@ -508,7 +548,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 = '';
|
||||
|
||||
@@ -558,7 +598,7 @@
|
||||
}
|
||||
|
||||
|
||||
// Load departed aircraft (DEPARTED status with departed_dt today)
|
||||
// Load departed aircraft (DEPARTED status with QSY/departed time today)
|
||||
async function loadDeparted() {
|
||||
document.getElementById('departed-loading').style.display = 'block';
|
||||
document.getElementById('departed-table-content').style.display = 'none';
|
||||
@@ -579,10 +619,10 @@
|
||||
|
||||
// Filter for PPRs departed today (only PPR'd departures, exclude local/circuits)
|
||||
const departed = allPPRs.filter(ppr => {
|
||||
if (!ppr.departed_dt || ppr.status !== 'DEPARTED') {
|
||||
if (!ppr.qsy_dt || ppr.status !== 'DEPARTED') {
|
||||
return false;
|
||||
}
|
||||
const departedDate = ppr.departed_dt.split('T')[0];
|
||||
const departedDate = ppr.qsy_dt.split('T')[0];
|
||||
return departedDate === today;
|
||||
});
|
||||
|
||||
@@ -623,9 +663,9 @@
|
||||
|
||||
// Sort by departed time
|
||||
departed.sort((a, b) => {
|
||||
const aTime = a.departed_dt;
|
||||
const bTime = b.departed_dt;
|
||||
return new Date(aTime) - new Date(bTime);
|
||||
const aTime = a.isDeparture ? a.departed_dt : a.qsy_dt;
|
||||
const bTime = b.isDeparture ? b.departed_dt : b.qsy_dt;
|
||||
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
@@ -669,7 +709,7 @@
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"><span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span></td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.ac_call || '-'}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.qsy_dt)}</td>
|
||||
`;
|
||||
}
|
||||
tbody.appendChild(row);
|
||||
@@ -730,7 +770,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 +803,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 +872,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 +883,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 +965,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 +1010,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) : '-';
|
||||
@@ -1049,7 +1084,7 @@
|
||||
T&G
|
||||
</button>`;
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Area">
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Traffic">
|
||||
LOCAL
|
||||
</button>
|
||||
${circuitButton}
|
||||
@@ -1126,7 +1161,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';
|
||||
@@ -1177,12 +1212,13 @@
|
||||
</button>
|
||||
`;
|
||||
} else if (flight.status === 'GROUND') {
|
||||
const takeoffStatus = flight.flight_type === 'CIRCUITS' ? 'CIRCUIT' : 'LOCAL';
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('DEPARTED', ${flight.id}, true)" title="Mark as Departed">
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('${takeoffStatus}', ${flight.id}, true)" title="Mark as airborne">
|
||||
TAKE OFF
|
||||
</button>
|
||||
`;
|
||||
} else if (flight.status === 'DEPARTED') {
|
||||
} else if (['DEPARTED', 'LOCAL', 'CIRCUIT', 'CIRCUIT_DOWNWIND', 'CIRCUIT_BASE', 'CIRCUIT_FINAL'].includes(flight.status)) {
|
||||
// Allow touch and go for all local flight types
|
||||
let circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
|
||||
T&G
|
||||
@@ -1254,11 +1290,21 @@
|
||||
fuel = flight.fuel || '-';
|
||||
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
|
||||
|
||||
if (flight.status === 'LANDED') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('LOCAL', ${flight.id})" title="Mark as Local">
|
||||
TAKE OFF
|
||||
</button>
|
||||
`;
|
||||
} else if (flight.status === 'LOCAL') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
||||
QSY
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
actionButtons = '<span style="color: #999;">-</span>';
|
||||
}
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
@@ -1359,10 +1405,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) {
|
||||
@@ -1376,19 +1419,26 @@
|
||||
// Load departing aircraft (ready to take off)
|
||||
async function loadDepartingAircraft() {
|
||||
try {
|
||||
const [groundDeparturesResponse, groundLocalResponse] = await Promise.all([
|
||||
const [pprResponse, groundDeparturesResponse, groundLocalResponse] = await Promise.all([
|
||||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000')
|
||||
]);
|
||||
|
||||
let groundAircraft = [];
|
||||
if (groundDeparturesResponse.ok) groundAircraft = await groundDeparturesResponse.json();
|
||||
if (pprResponse.ok) {
|
||||
const today = getLocalDateString();
|
||||
groundAircraft = (await pprResponse.json())
|
||||
.filter(ppr => ppr.status === 'LANDED' && ppr.etd && ppr.etd.split('T')[0] === today)
|
||||
.map(ppr => ({ ...ppr, isPPR: true }));
|
||||
}
|
||||
if (groundDeparturesResponse.ok) groundAircraft = groundAircraft.concat(await groundDeparturesResponse.json());
|
||||
if (groundLocalResponse.ok) groundAircraft = groundAircraft.concat((await groundLocalResponse.json()).map(l => ({ ...l, isLocalFlight: true })));
|
||||
groundAircraft = groundAircraft.filter(ac => isTodayRecord(ac, ['created_dt', 'etd']));
|
||||
|
||||
displayDepartingAircraft(groundAircraft.map(ac => ({
|
||||
...ac,
|
||||
isDeparture: !ac.isLocalFlight
|
||||
isDeparture: !ac.isLocalFlight && !ac.isPPR
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Error loading departing aircraft:', error);
|
||||
@@ -1411,6 +1461,7 @@
|
||||
const type = ac.ac_type || ac.type;
|
||||
const dest = ac.out_to;
|
||||
const isLocal = ac.isLocalFlight;
|
||||
const isPPR = ac.isPPR;
|
||||
|
||||
// All aircraft in awaiting departure are in GROUND status
|
||||
let takeoffOnclick, buttonText, buttonTitle, clickType;
|
||||
@@ -1421,13 +1472,18 @@
|
||||
buttonText = 'TAKE OFF';
|
||||
buttonTitle = takeoffTitle;
|
||||
clickType = 'local';
|
||||
} else if (isPPR) {
|
||||
takeoffOnclick = `event.stopPropagation(); showTimestampModal('LOCAL', ${ac.id})`;
|
||||
buttonText = 'TAKE OFF';
|
||||
buttonTitle = 'Mark as Local';
|
||||
clickType = 'ppr';
|
||||
} else {
|
||||
takeoffOnclick = `event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('LOCAL', ${ac.id}, false, true)`;
|
||||
buttonText = 'TAKE OFF';
|
||||
buttonTitle = 'Mark as Local';
|
||||
clickType = 'departure';
|
||||
}
|
||||
const itemClass = isLocal ? 'local-flight' : 'departure';
|
||||
const itemClass = isLocal ? 'local-flight' : (isPPR ? 'departure' : 'departure');
|
||||
|
||||
return `
|
||||
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${clickType}')">
|
||||
@@ -1445,6 +1501,7 @@
|
||||
async function loadLocalAircraft() {
|
||||
try {
|
||||
const response = await Promise.all([
|
||||
authenticatedFetch('/api/v1/pprs/?status=LOCAL&limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000'),
|
||||
authenticatedFetch('/api/v1/arrivals/?status=LOCAL&limit=1000'),
|
||||
@@ -1452,12 +1509,14 @@
|
||||
]);
|
||||
|
||||
let locals = [];
|
||||
if (response[0].ok) locals = (await response[0].json()).map(l => ({ ...l, isLocalFlight: true }));
|
||||
if (response[1].ok) locals = locals.concat((await response[1].json()).map(d => ({ ...d, isDeparture: true })));
|
||||
if (response[2].ok) locals = locals.concat((await response[2].json()).map(a => ({ ...a, isArrival: true })));
|
||||
if (response[3].ok) locals = locals.concat((await response[3].json()).map(o => ({ ...o, isOverflight: true })));
|
||||
if (response[0].ok) locals = (await response[0].json()).map(p => ({ ...p, isPPR: true }));
|
||||
if (response[1].ok) locals = locals.concat((await response[1].json()).map(l => ({ ...l, isLocalFlight: true })));
|
||||
if (response[2].ok) locals = locals.concat((await response[2].json()).map(d => ({ ...d, isDeparture: true })));
|
||||
if (response[3].ok) locals = locals.concat((await response[3].json()).map(a => ({ ...a, isArrival: true })));
|
||||
if (response[4].ok) locals = locals.concat((await response[4].json()).map(o => ({ ...o, isOverflight: true })));
|
||||
locals = locals.filter(ac => {
|
||||
if (ac.isOverflight) return true;
|
||||
if (ac.isPPR) return isTodayRecord(ac, ['etd', 'landed_dt', 'submitted_dt']);
|
||||
if (ac.isLocalFlight) return isTodayRecord(ac, ['created_dt', 'etd', 'takeoff_dt', 'departed_dt']);
|
||||
if (ac.isArrival) return isTodayRecord(ac, ['created_dt', 'eta']);
|
||||
if (ac.isDeparture) return isTodayRecord(ac, ['created_dt', 'etd', 'takeoff_dt', 'departed_dt']);
|
||||
@@ -1477,7 +1536,7 @@
|
||||
countEl.textContent = aircraft.length;
|
||||
|
||||
if (aircraft.length === 0) {
|
||||
container.innerHTML = '<div class="no-aircraft">No aircraft in local area</div>';
|
||||
container.innerHTML = '<div class="no-aircraft">No local traffic</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1486,9 +1545,12 @@
|
||||
const type = ac.type || ac.ac_type || ac.aircraft_type || '';
|
||||
const dest = ac.out_to;
|
||||
const isDeparture = ac.isDeparture;
|
||||
const isPPR = ac.isPPR;
|
||||
|
||||
let buttons;
|
||||
if (isDeparture) {
|
||||
if (isPPR) {
|
||||
buttons = `<button class="status-btn" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${ac.id})">QSY</button>`;
|
||||
} else if (isDeparture) {
|
||||
// Departure in LOCAL status - show QSY and REJOIN buttons
|
||||
buttons = `
|
||||
<button class="status-btn" onclick="event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('DEPARTED', ${ac.id}, false, true)">QSY</button>
|
||||
@@ -1501,9 +1563,9 @@
|
||||
// Overflight in ACTIVE status - show QSY button
|
||||
buttons = `<button class="status-btn" onclick="event.stopPropagation(); currentOverflightId = '${ac.id}'; showOverflightQSYModal()">QSY</button>`;
|
||||
}
|
||||
const itemClass = isDeparture ? 'departure' : (ac.isArrival ? 'inbound' : (ac.isOverflight ? 'overflight' : 'local-flight'));
|
||||
const detailsText = isDeparture ? `${type}${dest ? ` → ${dest}` : ` (Local)`}` : (ac.isOverflight ? `${ac.departure_airfield || '?'} → ${ac.destination_airfield || '?'}` : (ac.isArrival ? `${type} from ${ac.in_from || '?'}` : `${type}${dest ? ` → ${dest}` : ` Local Flight`}`));
|
||||
const entityType = isDeparture ? 'departure' : (ac.isArrival ? 'arrival' : (ac.isOverflight ? 'overflight' : 'local'));
|
||||
const itemClass = isDeparture || isPPR ? 'departure' : (ac.isArrival ? 'inbound' : (ac.isOverflight ? 'overflight' : 'local-flight'));
|
||||
const detailsText = isDeparture || isPPR ? `${type}${dest ? ` → ${dest}` : ` (Local)`}` : (ac.isOverflight ? `${ac.departure_airfield || '?'} → ${ac.destination_airfield || '?'}` : (ac.isArrival ? `${type} from ${ac.in_from || '?'}` : `${type}${dest ? ` → ${dest}` : ` Local Flight`}`));
|
||||
const entityType = isPPR ? 'ppr' : (isDeparture ? 'departure' : (ac.isArrival ? 'arrival' : (ac.isOverflight ? 'overflight' : 'local')));
|
||||
|
||||
return `
|
||||
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${entityType}')">
|
||||
|
||||
+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'];
|
||||
|
||||
@@ -149,8 +149,8 @@
|
||||
<input id="estimated_completion_at" type="datetime-local" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="maximum_elevation_ft_amsl">Maximum elevation ft AMSL</label>
|
||||
<input id="maximum_elevation_ft_amsl" type="number" min="0" required>
|
||||
<label for="maximum_elevation_ft_agl">Maximum elevation ft AGL</label>
|
||||
<input id="maximum_elevation_ft_agl" type="number" min="0" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="location_inside_frz">Inside FRZ</label>
|
||||
@@ -228,7 +228,7 @@
|
||||
setValue('phone', request.phone);
|
||||
setValue('estimated_takeoff_at', toLocalInputValue(request.estimated_takeoff_at));
|
||||
setValue('estimated_completion_at', toLocalInputValue(request.estimated_completion_at));
|
||||
setValue('maximum_elevation_ft_amsl', request.maximum_elevation_ft_amsl);
|
||||
setValue('maximum_elevation_ft_agl', request.maximum_elevation_ft_agl);
|
||||
setValue('location_inside_frz', request.location_inside_frz ? 'Yes' : 'No');
|
||||
setValue('location_latitude', request.location_latitude);
|
||||
setValue('location_longitude', request.location_longitude);
|
||||
@@ -260,7 +260,7 @@
|
||||
phone: value('phone') || null,
|
||||
estimated_takeoff_at: fromLocalInputValue(value('estimated_takeoff_at')),
|
||||
estimated_completion_at: fromLocalInputValue(value('estimated_completion_at')),
|
||||
maximum_elevation_ft_amsl: Number(value('maximum_elevation_ft_amsl')),
|
||||
maximum_elevation_ft_agl: Number(value('maximum_elevation_ft_agl')),
|
||||
location_latitude: Number(value('location_latitude')),
|
||||
location_longitude: Number(value('location_longitude')),
|
||||
location_description: value('location_description') || null,
|
||||
@@ -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() {
|
||||
@@ -798,7 +795,7 @@
|
||||
${field('Flyer ID', selectedRequest.flyer_id)}
|
||||
${field('Takeoff', formatDateTime(selectedRequest.estimated_takeoff_at))}
|
||||
${field('Completion', formatDateTime(selectedRequest.estimated_completion_at))}
|
||||
${field('Max Elevation', `${selectedRequest.maximum_elevation_ft_amsl} ft AMSL`)}
|
||||
${field('Max Elevation', `${selectedRequest.maximum_elevation_ft_agl} ft AGL`)}
|
||||
${field('Inside FRZ', selectedRequest.location_inside_frz === null ? '-' : (selectedRequest.location_inside_frz ? 'Yes' : 'No'))}
|
||||
${field('Latitude', selectedRequest.location_latitude)}
|
||||
${field('Longitude', selectedRequest.location_longitude)}
|
||||
@@ -865,7 +862,7 @@
|
||||
addLayer(L.marker(point).addTo(map).bindPopup(`
|
||||
<strong>${escapeHtml(selectedRequest.reference_number)}</strong><br>
|
||||
${escapeHtml(selectedRequest.operator_name)}<br>
|
||||
${selectedRequest.maximum_elevation_ft_amsl} ft AMSL
|
||||
${selectedRequest.maximum_elevation_ft_agl} ft AGL
|
||||
`));
|
||||
}
|
||||
|
||||
|
||||
+28
-11
@@ -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', 'LOCAL', '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 = {};
|
||||
|
||||
|
||||
+10
-10
@@ -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())) {
|
||||
@@ -829,10 +829,10 @@
|
||||
const toDisplay = await getAirportName(departure.out_to || '');
|
||||
|
||||
let timeDisplay, sortTime;
|
||||
if (departure.status === 'DEPARTED' && departure.departed_dt) {
|
||||
const time = convertToLocalTime(departure.departed_dt);
|
||||
if (departure.status === 'DEPARTED' && departure.qsy_dt) {
|
||||
const time = convertToLocalTime(departure.qsy_dt);
|
||||
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">DEPARTED</span></div>`;
|
||||
sortTime = departure.departed_dt;
|
||||
sortTime = departure.qsy_dt;
|
||||
} else {
|
||||
timeDisplay = convertToLocalTime(departure.etd);
|
||||
sortTime = departure.etd;
|
||||
|
||||
+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
-87
@@ -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.takeoff_dt ? formatDateTime(ppr.takeoff_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,202 @@
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
let utcDateStr = dateStr;
|
||||
const date = parseUtcDate(dateStr);
|
||||
|
||||
// Format as dd/mm/yy hh:mm
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const year = String(date.getUTCFullYear()).slice(-2);
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function parseUtcDate(dateStr) {
|
||||
let utcDateStr = String(dateStr).trim();
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const date = new Date(utcDateStr);
|
||||
return new Date(utcDateStr);
|
||||
}
|
||||
|
||||
// Format as dd/mm/yy hh:mm
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = String(date.getFullYear()).slice(-2);
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
function getPPRSortTime(ppr) {
|
||||
return ppr.landed_dt || ppr.qsy_dt || ppr.takeoff_dt || ppr.eta || ppr.etd || ppr.submitted_dt;
|
||||
}
|
||||
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
const detailConfig = {
|
||||
PPR: {
|
||||
endpoint: id => `/api/v1/pprs/${id}`,
|
||||
journalType: 'PPR',
|
||||
title: record => `PPR: ${record.ac_reg || '-'}`,
|
||||
fields: [
|
||||
['Status', r => r.status],
|
||||
['Aircraft', r => r.ac_reg],
|
||||
['Type', r => r.ac_type],
|
||||
['Callsign', r => r.ac_call],
|
||||
['Captain', r => r.captain],
|
||||
['From', r => r.in_from],
|
||||
['To', r => r.out_to],
|
||||
['Takeoff', r => formatOptionalDateTime(r.takeoff_dt)],
|
||||
['QSY', r => formatOptionalDateTime(r.qsy_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 +1572,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', 'QSY', 'Landing', 'POB In', 'POB Out', 'Fuel',
|
||||
'Email', 'Phone', 'Notes', 'Submitted', 'Created By'
|
||||
];
|
||||
|
||||
const csvData = currentPPRs.map(ppr => [
|
||||
@@ -1295,14 +1584,13 @@
|
||||
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.takeoff_dt ? formatDateTime(ppr.takeoff_dt) : '',
|
||||
ppr.qsy_dt ? formatDateTime(ppr.qsy_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 +1607,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 +1675,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;">
|
||||
|
||||
+115
-73
@@ -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;
|
||||
@@ -736,6 +777,9 @@
|
||||
|
||||
const ppr = await response.json();
|
||||
populateForm(ppr);
|
||||
const departedBtn = document.getElementById('btn-departed');
|
||||
departedBtn.textContent = '🛫 Depart';
|
||||
departedBtn.setAttribute('onclick', "showTimestampModal('DEPARTED')");
|
||||
|
||||
// Show/hide quick action buttons based on current status
|
||||
if (ppr.status === 'NEW') {
|
||||
@@ -748,8 +792,16 @@
|
||||
document.getElementById('btn-cancel').style.display = 'inline-block';
|
||||
} else if (ppr.status === 'LANDED') {
|
||||
document.getElementById('btn-landed').style.display = 'none';
|
||||
document.getElementById('btn-departed').style.display = 'inline-block';
|
||||
departedBtn.style.display = 'inline-block';
|
||||
departedBtn.textContent = '🛫 Take Off';
|
||||
departedBtn.setAttribute('onclick', "showTimestampModal('LOCAL')");
|
||||
document.getElementById('btn-cancel').style.display = 'inline-block';
|
||||
} else if (ppr.status === 'LOCAL') {
|
||||
document.getElementById('btn-landed').style.display = 'none';
|
||||
departedBtn.style.display = 'inline-block';
|
||||
departedBtn.textContent = 'QSY';
|
||||
departedBtn.setAttribute('onclick', "showTimestampModal('DEPARTED')");
|
||||
document.getElementById('btn-cancel').style.display = 'none';
|
||||
} else {
|
||||
// DEPARTED, CANCELED, DELETED - hide all quick actions and cancel button
|
||||
document.querySelector('.quick-actions').style.display = 'none';
|
||||
@@ -769,20 +821,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 +1144,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 +1911,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 = '';
|
||||
@@ -2626,8 +2668,8 @@
|
||||
text: "Displays visiting aircraft and airport departures that are ready to leave Swansea today. Local flights are shown in their own table."
|
||||
},
|
||||
"local-flights": {
|
||||
title: "Today's Local Flights",
|
||||
text: "Displays local and circuit flights booked out today, with shortcuts for contact, takeoff, circuit work, touch-and-go, and landing."
|
||||
title: "Local Traffic",
|
||||
text: "Displays local traffic booked out today, including local flights, circuits, and PPR departures that are airborne locally before QSY."
|
||||
},
|
||||
overflights: {
|
||||
title: "Active Overflights",
|
||||
|
||||
Reference in New Issue
Block a user