Compare commits

...

3 Commits

Author SHA1 Message Date
jamesp 0a49dfe219 Flow improvements 2026-06-29 07:15:01 -04:00
jamesp 8d8cb9ccad Drones to use AGL 2026-06-29 06:26:37 -04:00
jamesp 4b6dd9c93c UI fixes 2026-06-28 07:48:13 -04:00
28 changed files with 451 additions and 125 deletions
@@ -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'"
)
+4 -4
View File
@@ -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"), "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"), "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}", "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, "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"), "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"), "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}", "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, "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"), "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"), "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}", "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", "inside_frz": "Yes" if drone_request.location_inside_frz else "No",
"notes": drone_request.applicant_notes, "notes": drone_request.applicant_notes,
"requests_url": f"{settings.base_url}/drone-requests", "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"), "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"), "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}", "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, "edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
}, },
) )
+1 -1
View File
@@ -564,7 +564,7 @@ async def bulk_log_movement(
else: else:
ppr.out_to = entry.to_location or ppr.out_to ppr.out_to = entry.to_location or ppr.out_to
ppr.pob_out = entry.pob or ppr.pob_out 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): if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED):
ppr.status = PPRStatus.DEPARTED ppr.status = PPRStatus.DEPARTED
if entry.notes: if entry.notes:
+11 -3
View File
@@ -222,13 +222,21 @@ async def update_ppr_status(
# Send real-time update # Send real-time update
if hasattr(request.app.state, 'connection_manager'): 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({ await request.app.state.connection_manager.broadcast({
"type": "status_update", "type": "status_update",
"data": { "data": {
"id": ppr.id, "id": ppr.id,
"ac_reg": ppr.ac_reg, "ac_reg": ppr.ac_reg,
"status": ppr.status.value, "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
} }
}) })
@@ -348,7 +356,7 @@ async def update_ppr_public(
detail="Invalid or expired token" detail="Invalid or expired token"
) )
# Only allow editing if not already processed # 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( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="PPR cannot be edited at this stage" detail="PPR cannot be edited at this stage"
@@ -373,7 +381,7 @@ async def cancel_ppr_public(
detail="Invalid or expired token" detail="Invalid or expired token"
) )
# Only allow canceling if not already processed # 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( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="PPR cannot be cancelled at this stage" detail="PPR cannot be cancelled at this stage"
+2 -1
View File
@@ -145,7 +145,8 @@ async def get_public_departures(db: Session = Depends(get_db)):
'ac_type': departure.ac_type, 'ac_type': departure.ac_type,
'out_to': departure.out_to, 'out_to': departure.out_to,
'etd': departure.etd, 'etd': departure.etd,
'departed_dt': departure.departed_dt, 'takeoff_dt': departure.takeoff_dt,
'qsy_dt': departure.qsy_dt,
'status': departure.status.value, 'status': departure.status.value,
'isLocalFlight': False, 'isLocalFlight': False,
'isDeparture': False 'isDeparture': False
+5 -1
View File
@@ -58,6 +58,7 @@ class CRUDPPR:
PPRRecord.status == PPRStatus.NEW, PPRRecord.status == PPRStatus.NEW,
PPRRecord.status == PPRStatus.CONFIRMED, PPRRecord.status == PPRStatus.CONFIRMED,
PPRRecord.status == PPRStatus.LANDED, PPRRecord.status == PPRStatus.LANDED,
PPRRecord.status == PPRStatus.LOCAL,
PPRRecord.status == PPRStatus.DEPARTED PPRRecord.status == PPRStatus.DEPARTED
) )
) )
@@ -71,6 +72,7 @@ class CRUDPPR:
func.date(PPRRecord.etd) == today, func.date(PPRRecord.etd) == today,
or_( or_(
PPRRecord.status == PPRStatus.LANDED, PPRRecord.status == PPRStatus.LANDED,
PPRRecord.status == PPRStatus.LOCAL,
PPRRecord.status == PPRStatus.DEPARTED PPRRecord.status == PPRStatus.DEPARTED
) )
) )
@@ -151,8 +153,10 @@ class CRUDPPR:
current_time = timestamp if timestamp is not None else datetime.utcnow() current_time = timestamp if timestamp is not None else datetime.utcnow()
if status == PPRStatus.LANDED: if status == PPRStatus.LANDED:
db_obj.landed_dt = current_time db_obj.landed_dt = current_time
elif status == PPRStatus.LOCAL:
db_obj.takeoff_dt = current_time
elif status == PPRStatus.DEPARTED: elif status == PPRStatus.DEPARTED:
db_obj.departed_dt = current_time db_obj.qsy_dt = current_time
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
+1 -1
View File
@@ -35,7 +35,7 @@ class DroneRequest(Base):
estimated_completion_time = Column(String(8), nullable=True) estimated_completion_time = Column(String(8), nullable=True)
estimated_takeoff_at = Column(DateTime, nullable=False, index=True) estimated_takeoff_at = Column(DateTime, nullable=False, index=True)
estimated_completion_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_description = Column(Text, nullable=True)
location_latitude = Column(Float, nullable=False) location_latitude = Column(Float, nullable=False)
+3 -1
View File
@@ -9,6 +9,7 @@ class PPRStatus(str, Enum):
CONFIRMED = "CONFIRMED" CONFIRMED = "CONFIRMED"
CANCELED = "CANCELED" CANCELED = "CANCELED"
LANDED = "LANDED" LANDED = "LANDED"
LOCAL = "LOCAL"
DELETED = "DELETED" DELETED = "DELETED"
DEPARTED = "DEPARTED" DEPARTED = "DEPARTED"
ACTIVATED = "ACTIVATED" ACTIVATED = "ACTIVATED"
@@ -40,7 +41,8 @@ class PPRRecord(Base):
phone = Column(String(16), nullable=True) phone = Column(String(16), nullable=True)
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
landed_dt = Column(DateTime, 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) created_by = Column(String(16), nullable=True, index=True)
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True) submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
acknowledged_dt = Column(DateTime, nullable=True) acknowledged_dt = Column(DateTime, nullable=True)
+11 -3
View File
@@ -2,7 +2,7 @@ from datetime import date, datetime
from enum import Enum from enum import Enum
from typing import Any, Optional from typing import Any, Optional
from pydantic import BaseModel, EmailStr, Field, validator from pydantic import AliasChoices, BaseModel, EmailStr, Field, validator
class DroneRequestStatus(str, Enum): class DroneRequestStatus(str, Enum):
@@ -20,7 +20,11 @@ class DroneRequestBase(BaseModel):
flight_date: Optional[date] = None flight_date: Optional[date] = None
estimated_takeoff_time: Optional[str] = Field(None, max_length=8) estimated_takeoff_time: Optional[str] = Field(None, max_length=8)
estimated_completion_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_description: Optional[str] = None
location_latitude: float = Field(..., ge=-90, le=90) location_latitude: float = Field(..., ge=-90, le=90)
location_longitude: float = Field(..., ge=-180, le=180) 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_completion_time: Optional[str] = Field(None, max_length=8)
estimated_takeoff_at: Optional[datetime] = None estimated_takeoff_at: Optional[datetime] = None
estimated_completion_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_description: Optional[str] = None
location_latitude: Optional[float] = Field(None, ge=-90, le=90) location_latitude: Optional[float] = Field(None, ge=-90, le=90)
location_longitude: Optional[float] = Field(None, ge=-180, le=180) location_longitude: Optional[float] = Field(None, ge=-180, le=180)
+5 -2
View File
@@ -9,6 +9,7 @@ class PPRStatus(str, Enum):
CONFIRMED = "CONFIRMED" CONFIRMED = "CONFIRMED"
CANCELED = "CANCELED" CANCELED = "CANCELED"
LANDED = "LANDED" LANDED = "LANDED"
LOCAL = "LOCAL"
DELETED = "DELETED" DELETED = "DELETED"
DEPARTED = "DEPARTED" DEPARTED = "DEPARTED"
ACTIVATED = "ACTIVATED" ACTIVATED = "ACTIVATED"
@@ -85,7 +86,8 @@ class PPRInDBBase(PPRBase):
id: int id: int
status: PPRStatus status: PPRStatus
landed_dt: Optional[datetime] = None 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 created_by: Optional[str] = None
submitted_dt: datetime submitted_dt: datetime
acknowledged_dt: Optional[datetime] = None acknowledged_dt: Optional[datetime] = None
@@ -111,7 +113,8 @@ class PPRPublic(BaseModel):
out_to: Optional[str] = None out_to: Optional[str] = None
etd: Optional[datetime] = None etd: Optional[datetime] = None
landed_dt: 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 submitted_dt: datetime
class Config: class Config:
@@ -53,7 +53,7 @@
</tr> </tr>
<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; 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> </tr>
</table> </table>
@@ -47,7 +47,7 @@
</tr> </tr>
<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; 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> </tr>
</table> </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;">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;">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;">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> <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> </table>
</td> </td>
@@ -47,7 +47,7 @@
</tr> </tr>
<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; 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> </tr>
</table> </table>
+3 -2
View File
@@ -81,7 +81,7 @@ DROP TABLE IF EXISTS `submitted`;
/*!40101 SET character_set_client = utf8mb4 */; /*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `submitted` ( CREATE TABLE `submitted` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT, `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_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_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, `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, `phone` varchar(16) DEFAULT NULL,
`notes` varchar(2000) DEFAULT NULL, `notes` varchar(2000) DEFAULT NULL,
`landed_dt` datetime 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, `created_by` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci DEFAULT NULL,
`submitted_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `submitted_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `id` (`id`) UNIQUE KEY `id` (`id`)
+21 -4
View File
@@ -15,7 +15,7 @@ def drone_payload(**overrides):
"estimated_completion_time": "10:30", "estimated_completion_time": "10:30",
"estimated_takeoff_at": "2026-06-20T10:00:00", "estimated_takeoff_at": "2026-06-20T10:00:00",
"estimated_completion_at": "2026-06-20T10:30:00", "estimated_completion_at": "2026-06-20T10:30:00",
"maximum_elevation_ft_amsl": 250, "maximum_elevation_ft_agl": 250,
"location_description": "North apron", "location_description": "North apron",
"location_latitude": 51.623389, "location_latitude": 51.623389,
"location_longitude": -4.069231, "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 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): def test_authenticated_drone_request_list_update_status_comment_and_journal(auth_client, db, monkeypatch):
sent_emails = [] 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']}") get_response = auth_client.get(f"/api/v1/drone-requests/{created['id']}")
update_response = auth_client.patch( update_response = auth_client.patch(
f"/api/v1/drone-requests/{created['id']}", 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( status_response = auth_client.patch(
f"/api/v1/drone-requests/{created['id']}/status", 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 [request["id"] for request in list_response.json()] == [created["id"]]
assert get_response.status_code == 200 assert get_response.status_code == 200
assert update_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.status_code == 200
assert status_response.json()["status"] == "APPROVED" assert status_response.json()["status"] == "APPROVED"
assert status_response.json()["operator_comments"] == "Approved below 200ft" 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): def test_drone_request_not_found_and_validation_paths(auth_client, client):
invalid_response = client.post( invalid_response = client.post(
"/api/v1/drone-requests/public", "/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 assert invalid_response.status_code == 422
+28
View File
@@ -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) 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): def test_ppr_list_supports_status_date_and_pagination_filters(auth_client, ppr_factory):
ppr_factory( ppr_factory(
ac_reg="G-NEW1", ac_reg="G-NEW1",
+3 -2
View File
@@ -22,7 +22,7 @@ CREATE TABLE users (
-- Main PPR submissions table with improvements -- Main PPR submissions table with improvements
CREATE TABLE submitted ( CREATE TABLE submitted (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, 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_reg VARCHAR(16) NOT NULL,
ac_type VARCHAR(32) NOT NULL, ac_type VARCHAR(32) NOT NULL,
ac_call VARCHAR(16) DEFAULT NULL, ac_call VARCHAR(16) DEFAULT NULL,
@@ -38,7 +38,8 @@ CREATE TABLE submitted (
phone VARCHAR(16) DEFAULT NULL, phone VARCHAR(16) DEFAULT NULL,
notes TEXT DEFAULT NULL, notes TEXT DEFAULT NULL,
landed_dt DATETIME 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, created_by VARCHAR(16) DEFAULT NULL,
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
acknowledged_dt DATETIME DEFAULT NULL, acknowledged_dt DATETIME DEFAULT NULL,
+1 -1
View File
@@ -21,7 +21,7 @@ def drone_payload(operator_name):
"estimated_completion_time": "10:30", "estimated_completion_time": "10:30",
"estimated_takeoff_at": "2026-06-21T10:00:00", "estimated_takeoff_at": "2026-06-21T10:00:00",
"estimated_completion_at": "2026-06-21T10:30: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_description": "E2E north apron survey",
"location_latitude": 51.623389, "location_latitude": 51.623389,
"location_longitude": -4.069231, "location_longitude": -4.069231,
+133 -31
View File
@@ -49,11 +49,11 @@
<div class="container"> <div class="container">
<!-- Local Flights Table --> <!-- Local Traffic Table -->
<div class="ppr-table"> <div class="ppr-table">
<div class="table-header"> <div class="table-header">
<div style="display: flex; justify-content: space-between; align-items: center;"> <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> <span class="info-icon" onclick="showTableHelp('local-flights')" title="What is this?"></span>
</div> </div>
</div> </div>
@@ -84,7 +84,7 @@
</div> </div>
<div id="local-flights-no-data" class="no-data" style="display: none;"> <div id="local-flights-no-data" class="no-data" style="display: none;">
<h3>No Local Flights</h3> <h3>No Local Traffic</h3>
</div> </div>
</div> </div>
@@ -321,6 +321,50 @@
<script src="shared.js"></script> <script src="shared.js"></script>
<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() { async function loadPPRs() {
if (!accessToken) return; if (!accessToken) return;
@@ -403,12 +447,11 @@
document.getElementById('departures-no-data').style.display = 'none'; document.getElementById('departures-no-data').style.display = 'none';
try { try {
// Load PPR departures and airport departures simultaneously // Load PPR departures and airport departures that are still pending departure
const [pprResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([ const [pprResponse, depBookedOutResponse, depOutGroundResponse] = await Promise.all([
authenticatedFetch('/api/v1/pprs/?limit=1000'), authenticatedFetch('/api/v1/pprs/?limit=1000'),
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'), authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'),
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'), authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000')
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000')
]); ]);
if (!pprResponse.ok) { if (!pprResponse.ok) {
@@ -418,10 +461,9 @@
const allPPRs = await pprResponse.json(); const allPPRs = await pprResponse.json();
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : []; const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : []; const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : [];
const depLocal = depLocalResponse.ok ? await depLocalResponse.json() : [];
// Combine departures // Combine departures
const allDepartures = [...depBookedOut, ...depOutGround, ...depLocal]; const allDepartures = [...depBookedOut, ...depOutGround];
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
// Filter for PPR departures with ETD today and LANDED status only // Filter for PPR departures with ETD today and LANDED status only
@@ -434,7 +476,7 @@
return etdDate === today; 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 => ({ const depDepartures = allDepartures.map(flight => ({
...flight, ...flight,
isDeparture: true // Flag to distinguish from PPR isDeparture: true // Flag to distinguish from PPR
@@ -458,19 +500,41 @@
document.getElementById('local-flights-no-data').style.display = 'none'; document.getElementById('local-flights-no-data').style.display = 'none';
try { 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'); throw new Error('Failed to fetch local flights');
} }
const today = new Date().toISOString().split('T')[0]; 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; if (!flight.created_dt || ['CANCELLED', 'LANDED'].includes(flight.status)) return false;
return flight.created_dt.split('T')[0] === today; 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) { } catch (error) {
console.error('Error loading local flights:', error); console.error('Error loading local flights:', error);
if (error.message !== 'Session expired. Please log in again.') { if (error.message !== 'Session expired. Please log in again.') {
@@ -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() { async function loadDeparted() {
document.getElementById('departed-loading').style.display = 'block'; document.getElementById('departed-loading').style.display = 'block';
document.getElementById('departed-table-content').style.display = 'none'; document.getElementById('departed-table-content').style.display = 'none';
@@ -587,10 +651,10 @@
// Filter for PPRs departed today (only PPR'd departures, exclude local/circuits) // Filter for PPRs departed today (only PPR'd departures, exclude local/circuits)
const departed = allPPRs.filter(ppr => { const departed = allPPRs.filter(ppr => {
if (!ppr.departed_dt || ppr.status !== 'DEPARTED') { if (!ppr.qsy_dt || ppr.status !== 'DEPARTED') {
return false; return false;
} }
const departedDate = ppr.departed_dt.split('T')[0]; const departedDate = ppr.qsy_dt.split('T')[0];
return departedDate === today; return departedDate === today;
}); });
@@ -631,8 +695,8 @@
// Sort by departed time // Sort by departed time
departed.sort((a, b) => { departed.sort((a, b) => {
const aTime = a.departed_dt; const aTime = a.isDeparture ? a.departed_dt : a.qsy_dt;
const bTime = b.departed_dt; const bTime = b.isDeparture ? b.departed_dt : b.qsy_dt;
return parseUtcDate(aTime) - parseUtcDate(bTime); return parseUtcDate(aTime) - parseUtcDate(bTime);
}); });
@@ -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; 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.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;">${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); tbody.appendChild(row);
@@ -1156,24 +1220,52 @@
tbody.innerHTML = ''; tbody.innerHTML = '';
document.getElementById('local-flights-table-content').style.display = 'block'; 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) { for (const flight of localFlights) {
const row = document.createElement('tr'); 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.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`
: `<strong>${flight.registration}</strong>`; : `<strong>${flight.registration}</strong>`)
const typeIcon = flight.submitted_via === 'PUBLIC' : (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: #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>'; : '<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 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 = ''; 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 = ` actionButtons = `
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('GROUND', ${flight.id}, true)" title="Contact Pilot"> <button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('GROUND', ${flight.id}, true)" title="Contact Pilot">
CONTACT CONTACT
@@ -1202,10 +1294,10 @@
row.innerHTML = ` row.innerHTML = `
<td>${aircraftDisplay}</td> <td>${aircraftDisplay}</td>
<td style="text-align: center; width: 30px;">${typeIcon}</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>${flightType}</td>
<td>${etd}</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>${localFlightStatusBadge(flight.status)}</td>
<td>${circuits}</td> <td>${circuits}</td>
<td style="white-space: nowrap;">${actionButtons}</td> <td style="white-space: nowrap;">${actionButtons}</td>
@@ -1451,11 +1543,21 @@
fuel = flight.fuel || '-'; fuel = flight.fuel || '-';
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-'; landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
if (flight.status === 'LANDED') {
actionButtons = ` 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 TAKE OFF
</button> </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 = ` row.innerHTML = `
+107 -37
View File
@@ -255,11 +255,11 @@
</div> </div>
</div> </div>
<!-- Row 1: Local Area --> <!-- Row 1: Local Traffic -->
<div class="atc-section"> <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="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>
</div> </div>
@@ -300,6 +300,50 @@
<script src="shared.js"></script> <script src="shared.js"></script>
<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() { async function loadPPRs() {
if (!accessToken) return; if (!accessToken) return;
@@ -401,15 +445,13 @@
document.getElementById('departures-no-data').style.display = 'none'; document.getElementById('departures-no-data').style.display = 'none';
try { try {
// Load PPR departures, local flight departures, and airport departures simultaneously // Load PPR departures, local flight departures, and airport departures that are still pending departure
const [pprResponse, localBookedOutResponse, localOutGroundResponse, localLocalResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([ const [pprResponse, localBookedOutResponse, localOutGroundResponse, depBookedOutResponse, depOutGroundResponse] = await Promise.all([
authenticatedFetch('/api/v1/pprs/?limit=1000'), authenticatedFetch('/api/v1/pprs/?limit=1000'),
authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&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=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=BOOKED_OUT&limit=1000'),
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'), authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000')
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000')
]); ]);
if (!pprResponse.ok) { if (!pprResponse.ok) {
@@ -419,15 +461,13 @@
const allPPRs = await pprResponse.json(); const allPPRs = await pprResponse.json();
const localBookedOut = localBookedOutResponse.ok ? await localBookedOutResponse.json() : []; const localBookedOut = localBookedOutResponse.ok ? await localBookedOutResponse.json() : [];
const localOutGround = localOutGroundResponse.ok ? await localOutGroundResponse.json() : []; const localOutGround = localOutGroundResponse.ok ? await localOutGroundResponse.json() : [];
const localLocal = localLocalResponse.ok ? await localLocalResponse.json() : [];
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : []; const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : []; const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : [];
const depLocal = depLocalResponse.ok ? await depLocalResponse.json() : [];
// Combine local flights // Combine local flights
const allLocalFlights = [...localBookedOut, ...localOutGround, ...localLocal]; const allLocalFlights = [...localBookedOut, ...localOutGround];
// Combine departures // Combine departures
const allDepartures = [...depBookedOut, ...depOutGround, ...depLocal]; const allDepartures = [...depBookedOut, ...depOutGround];
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
// Filter for PPR departures with ETD today and LANDED status only // Filter for PPR departures with ETD today and LANDED status only
@@ -440,7 +480,7 @@
return etdDate === today; 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 const localDepartures = allLocalFlights
.filter(flight => { .filter(flight => {
// Only include flights booked out today (created_dt) // Only include flights booked out today (created_dt)
@@ -454,7 +494,7 @@
})); }));
departures.push(...localDepartures); 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 => ({ const depDepartures = allDepartures.map(flight => ({
...flight, ...flight,
isDeparture: true // Flag to distinguish from PPR isDeparture: true // Flag to distinguish from PPR
@@ -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() { async function loadDeparted() {
document.getElementById('departed-loading').style.display = 'block'; document.getElementById('departed-loading').style.display = 'block';
document.getElementById('departed-table-content').style.display = 'none'; document.getElementById('departed-table-content').style.display = 'none';
@@ -579,10 +619,10 @@
// Filter for PPRs departed today (only PPR'd departures, exclude local/circuits) // Filter for PPRs departed today (only PPR'd departures, exclude local/circuits)
const departed = allPPRs.filter(ppr => { const departed = allPPRs.filter(ppr => {
if (!ppr.departed_dt || ppr.status !== 'DEPARTED') { if (!ppr.qsy_dt || ppr.status !== 'DEPARTED') {
return false; return false;
} }
const departedDate = ppr.departed_dt.split('T')[0]; const departedDate = ppr.qsy_dt.split('T')[0];
return departedDate === today; return departedDate === today;
}); });
@@ -623,8 +663,8 @@
// Sort by departed time // Sort by departed time
departed.sort((a, b) => { departed.sort((a, b) => {
const aTime = a.departed_dt; const aTime = a.isDeparture ? a.departed_dt : a.qsy_dt;
const bTime = b.departed_dt; const bTime = b.isDeparture ? b.departed_dt : b.qsy_dt;
return parseUtcDate(aTime) - parseUtcDate(bTime); return parseUtcDate(aTime) - parseUtcDate(bTime);
}); });
@@ -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; 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.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;">${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); tbody.appendChild(row);
@@ -1044,7 +1084,7 @@
T&G T&G
</button>`; </button>`;
actionButtons = ` 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 LOCAL
</button> </button>
${circuitButton} ${circuitButton}
@@ -1172,12 +1212,13 @@
</button> </button>
`; `;
} else if (flight.status === 'GROUND') { } else if (flight.status === 'GROUND') {
const takeoffStatus = flight.flight_type === 'CIRCUITS' ? 'CIRCUIT' : 'LOCAL';
actionButtons = ` 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 TAKE OFF
</button> </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 // 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"> let circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
T&G T&G
@@ -1249,11 +1290,21 @@
fuel = flight.fuel || '-'; fuel = flight.fuel || '-';
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-'; landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
if (flight.status === 'LANDED') {
actionButtons = ` 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 TAKE OFF
</button> </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 = ` row.innerHTML = `
@@ -1368,19 +1419,26 @@
// Load departing aircraft (ready to take off) // Load departing aircraft (ready to take off)
async function loadDepartingAircraft() { async function loadDepartingAircraft() {
try { 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/departures/?status=GROUND&limit=1000'),
authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000') authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000')
]); ]);
let groundAircraft = []; 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 }))); if (groundLocalResponse.ok) groundAircraft = groundAircraft.concat((await groundLocalResponse.json()).map(l => ({ ...l, isLocalFlight: true })));
groundAircraft = groundAircraft.filter(ac => isTodayRecord(ac, ['created_dt', 'etd'])); groundAircraft = groundAircraft.filter(ac => isTodayRecord(ac, ['created_dt', 'etd']));
displayDepartingAircraft(groundAircraft.map(ac => ({ displayDepartingAircraft(groundAircraft.map(ac => ({
...ac, ...ac,
isDeparture: !ac.isLocalFlight isDeparture: !ac.isLocalFlight && !ac.isPPR
}))); })));
} catch (error) { } catch (error) {
console.error('Error loading departing aircraft:', error); console.error('Error loading departing aircraft:', error);
@@ -1403,6 +1461,7 @@
const type = ac.ac_type || ac.type; const type = ac.ac_type || ac.type;
const dest = ac.out_to; const dest = ac.out_to;
const isLocal = ac.isLocalFlight; const isLocal = ac.isLocalFlight;
const isPPR = ac.isPPR;
// All aircraft in awaiting departure are in GROUND status // All aircraft in awaiting departure are in GROUND status
let takeoffOnclick, buttonText, buttonTitle, clickType; let takeoffOnclick, buttonText, buttonTitle, clickType;
@@ -1413,13 +1472,18 @@
buttonText = 'TAKE OFF'; buttonText = 'TAKE OFF';
buttonTitle = takeoffTitle; buttonTitle = takeoffTitle;
clickType = 'local'; clickType = 'local';
} else if (isPPR) {
takeoffOnclick = `event.stopPropagation(); showTimestampModal('LOCAL', ${ac.id})`;
buttonText = 'TAKE OFF';
buttonTitle = 'Mark as Local';
clickType = 'ppr';
} else { } else {
takeoffOnclick = `event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('LOCAL', ${ac.id}, false, true)`; takeoffOnclick = `event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('LOCAL', ${ac.id}, false, true)`;
buttonText = 'TAKE OFF'; buttonText = 'TAKE OFF';
buttonTitle = 'Mark as Local'; buttonTitle = 'Mark as Local';
clickType = 'departure'; clickType = 'departure';
} }
const itemClass = isLocal ? 'local-flight' : 'departure'; const itemClass = isLocal ? 'local-flight' : (isPPR ? 'departure' : 'departure');
return ` return `
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${clickType}')"> <div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${clickType}')">
@@ -1437,6 +1501,7 @@
async function loadLocalAircraft() { async function loadLocalAircraft() {
try { try {
const response = await Promise.all([ 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/local-flights/?status=LOCAL&limit=1000'),
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000'), authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000'),
authenticatedFetch('/api/v1/arrivals/?status=LOCAL&limit=1000'), authenticatedFetch('/api/v1/arrivals/?status=LOCAL&limit=1000'),
@@ -1444,12 +1509,14 @@
]); ]);
let locals = []; let locals = [];
if (response[0].ok) locals = (await response[0].json()).map(l => ({ ...l, isLocalFlight: 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(d => ({ ...d, isDeparture: 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(a => ({ ...a, isArrival: 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(o => ({ ...o, isOverflight: 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 => { locals = locals.filter(ac => {
if (ac.isOverflight) return true; 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.isLocalFlight) return isTodayRecord(ac, ['created_dt', 'etd', 'takeoff_dt', 'departed_dt']);
if (ac.isArrival) return isTodayRecord(ac, ['created_dt', 'eta']); if (ac.isArrival) return isTodayRecord(ac, ['created_dt', 'eta']);
if (ac.isDeparture) return isTodayRecord(ac, ['created_dt', 'etd', 'takeoff_dt', 'departed_dt']); if (ac.isDeparture) return isTodayRecord(ac, ['created_dt', 'etd', 'takeoff_dt', 'departed_dt']);
@@ -1469,7 +1536,7 @@
countEl.textContent = aircraft.length; countEl.textContent = aircraft.length;
if (aircraft.length === 0) { 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; return;
} }
@@ -1478,9 +1545,12 @@
const type = ac.type || ac.ac_type || ac.aircraft_type || ''; const type = ac.type || ac.ac_type || ac.aircraft_type || '';
const dest = ac.out_to; const dest = ac.out_to;
const isDeparture = ac.isDeparture; const isDeparture = ac.isDeparture;
const isPPR = ac.isPPR;
let buttons; 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 // Departure in LOCAL status - show QSY and REJOIN buttons
buttons = ` buttons = `
<button class="status-btn" onclick="event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('DEPARTED', ${ac.id}, false, true)">QSY</button> <button class="status-btn" onclick="event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('DEPARTED', ${ac.id}, false, true)">QSY</button>
@@ -1493,9 +1563,9 @@
// Overflight in ACTIVE status - show QSY button // Overflight in ACTIVE status - show QSY button
buttons = `<button class="status-btn" onclick="event.stopPropagation(); currentOverflightId = '${ac.id}'; showOverflightQSYModal()">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 itemClass = isDeparture || isPPR ? '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 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 = isDeparture ? 'departure' : (ac.isArrival ? 'arrival' : (ac.isOverflight ? 'overflight' : 'local')); const entityType = isPPR ? 'ppr' : (isDeparture ? 'departure' : (ac.isArrival ? 'arrival' : (ac.isOverflight ? 'overflight' : 'local')));
return ` return `
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${entityType}')"> <div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${entityType}')">
+4 -4
View File
@@ -149,8 +149,8 @@
<input id="estimated_completion_at" type="datetime-local" required> <input id="estimated_completion_at" type="datetime-local" required>
</div> </div>
<div> <div>
<label for="maximum_elevation_ft_amsl">Maximum elevation ft AMSL</label> <label for="maximum_elevation_ft_agl">Maximum elevation ft AGL</label>
<input id="maximum_elevation_ft_amsl" type="number" min="0" required> <input id="maximum_elevation_ft_agl" type="number" min="0" required>
</div> </div>
<div> <div>
<label for="location_inside_frz">Inside FRZ</label> <label for="location_inside_frz">Inside FRZ</label>
@@ -228,7 +228,7 @@
setValue('phone', request.phone); setValue('phone', request.phone);
setValue('estimated_takeoff_at', toLocalInputValue(request.estimated_takeoff_at)); setValue('estimated_takeoff_at', toLocalInputValue(request.estimated_takeoff_at));
setValue('estimated_completion_at', toLocalInputValue(request.estimated_completion_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_inside_frz', request.location_inside_frz ? 'Yes' : 'No');
setValue('location_latitude', request.location_latitude); setValue('location_latitude', request.location_latitude);
setValue('location_longitude', request.location_longitude); setValue('location_longitude', request.location_longitude);
@@ -260,7 +260,7 @@
phone: value('phone') || null, phone: value('phone') || null,
estimated_takeoff_at: fromLocalInputValue(value('estimated_takeoff_at')), estimated_takeoff_at: fromLocalInputValue(value('estimated_takeoff_at')),
estimated_completion_at: fromLocalInputValue(value('estimated_completion_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_latitude: Number(value('location_latitude')),
location_longitude: Number(value('location_longitude')), location_longitude: Number(value('location_longitude')),
location_description: value('location_description') || null, location_description: value('location_description') || null,
+2 -2
View File
@@ -795,7 +795,7 @@
${field('Flyer ID', selectedRequest.flyer_id)} ${field('Flyer ID', selectedRequest.flyer_id)}
${field('Takeoff', formatDateTime(selectedRequest.estimated_takeoff_at))} ${field('Takeoff', formatDateTime(selectedRequest.estimated_takeoff_at))}
${field('Completion', formatDateTime(selectedRequest.estimated_completion_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('Inside FRZ', selectedRequest.location_inside_frz === null ? '-' : (selectedRequest.location_inside_frz ? 'Yes' : 'No'))}
${field('Latitude', selectedRequest.location_latitude)} ${field('Latitude', selectedRequest.location_latitude)}
${field('Longitude', selectedRequest.location_longitude)} ${field('Longitude', selectedRequest.location_longitude)}
@@ -862,7 +862,7 @@
addLayer(L.marker(point).addTo(map).bindPopup(` addLayer(L.marker(point).addTo(map).bindPopup(`
<strong>${escapeHtml(selectedRequest.reference_number)}</strong><br> <strong>${escapeHtml(selectedRequest.reference_number)}</strong><br>
${escapeHtml(selectedRequest.operator_name)}<br> ${escapeHtml(selectedRequest.operator_name)}<br>
${selectedRequest.maximum_elevation_ft_amsl} ft AMSL ${selectedRequest.maximum_elevation_ft_agl} ft AGL
`)); `));
} }
+1 -1
View File
@@ -472,7 +472,7 @@
document.getElementById('phone').value = ppr.phone || ''; document.getElementById('phone').value = ppr.phone || '';
document.getElementById('notes').value = ppr.notes || ''; document.getElementById('notes').value = ppr.notes || '';
if (['CANCELED', 'DELETED', 'LANDED', 'DEPARTED'].includes(ppr.status)) { if (['CANCELED', 'DELETED', 'LANDED', 'LOCAL', 'DEPARTED'].includes(ppr.status)) {
document.getElementById('update-btn').disabled = true; document.getElementById('update-btn').disabled = true;
document.getElementById('cancel-btn').disabled = true; document.getElementById('cancel-btn').disabled = true;
showNotification('This PPR can no longer be edited or cancelled online.', true); showNotification('This PPR can no longer be edited or cancelled online.', true);
+3 -3
View File
@@ -829,10 +829,10 @@
const toDisplay = await getAirportName(departure.out_to || ''); const toDisplay = await getAirportName(departure.out_to || '');
let timeDisplay, sortTime; let timeDisplay, sortTime;
if (departure.status === 'DEPARTED' && departure.departed_dt) { if (departure.status === 'DEPARTED' && departure.qsy_dt) {
const time = convertToLocalTime(departure.departed_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>`; 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 { } else {
timeDisplay = convertToLocalTime(departure.etd); timeDisplay = convertToLocalTime(departure.etd);
sortTime = departure.etd; sortTime = departure.etd;
+7 -5
View File
@@ -1181,7 +1181,7 @@
row.className = 'clickable-row'; row.className = 'clickable-row';
row.onclick = () => openReportDetail('PPR', ppr.id); row.onclick = () => openReportDetail('PPR', ppr.id);
const takeoff = 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 landing = ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '-';
const submitted = ppr.submitted_dt ? formatDateTime(ppr.submitted_dt) : '-'; const submitted = ppr.submitted_dt ? formatDateTime(ppr.submitted_dt) : '-';
@@ -1381,7 +1381,7 @@
} }
function getPPRSortTime(ppr) { function getPPRSortTime(ppr) {
return ppr.landed_dt || ppr.departed_dt || ppr.eta || ppr.etd || ppr.submitted_dt; return ppr.landed_dt || ppr.qsy_dt || ppr.takeoff_dt || ppr.eta || ppr.etd || ppr.submitted_dt;
} }
const detailConfig = { const detailConfig = {
@@ -1397,7 +1397,8 @@
['Captain', r => r.captain], ['Captain', r => r.captain],
['From', r => r.in_from], ['From', r => r.in_from],
['To', r => r.out_to], ['To', r => r.out_to],
['Takeoff', r => formatOptionalDateTime(r.departed_dt)], ['Takeoff', r => formatOptionalDateTime(r.takeoff_dt)],
['QSY', r => formatOptionalDateTime(r.qsy_dt)],
['Landing', r => formatOptionalDateTime(r.landed_dt)], ['Landing', r => formatOptionalDateTime(r.landed_dt)],
['ETA', r => formatOptionalDateTime(r.eta)], ['ETA', r => formatOptionalDateTime(r.eta)],
['ETD', r => formatOptionalDateTime(r.etd)], ['ETD', r => formatOptionalDateTime(r.etd)],
@@ -1571,7 +1572,7 @@
const headers = [ const headers = [
'ID', 'Status', 'Aircraft Reg', 'Aircraft Type', 'Callsign', 'Captain', 'ID', 'Status', 'Aircraft Reg', 'Aircraft Type', 'Callsign', 'Captain',
'From', 'To', 'Takeoff', 'Landing', 'POB In', 'POB Out', 'Fuel', 'From', 'To', 'Takeoff', 'QSY', 'Landing', 'POB In', 'POB Out', 'Fuel',
'Email', 'Phone', 'Notes', 'Submitted', 'Created By' 'Email', 'Phone', 'Notes', 'Submitted', 'Created By'
]; ];
@@ -1584,7 +1585,8 @@
ppr.captain, ppr.captain,
ppr.in_from, ppr.in_from,
ppr.out_to || '', ppr.out_to || '',
ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '', ppr.takeoff_dt ? formatDateTime(ppr.takeoff_dt) : '',
ppr.qsy_dt ? formatDateTime(ppr.qsy_dt) : '',
ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '', ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '',
ppr.pob_in, ppr.pob_in,
ppr.pob_out || '', ppr.pob_out || '',
+14 -3
View File
@@ -777,6 +777,9 @@
const ppr = await response.json(); const ppr = await response.json();
populateForm(ppr); 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 // Show/hide quick action buttons based on current status
if (ppr.status === 'NEW') { if (ppr.status === 'NEW') {
@@ -789,8 +792,16 @@
document.getElementById('btn-cancel').style.display = 'inline-block'; document.getElementById('btn-cancel').style.display = 'inline-block';
} else if (ppr.status === 'LANDED') { } else if (ppr.status === 'LANDED') {
document.getElementById('btn-landed').style.display = 'none'; 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'; 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 { } else {
// DEPARTED, CANCELED, DELETED - hide all quick actions and cancel button // DEPARTED, CANCELED, DELETED - hide all quick actions and cancel button
document.querySelector('.quick-actions').style.display = 'none'; document.querySelector('.quick-actions').style.display = 'none';
@@ -2657,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." text: "Displays visiting aircraft and airport departures that are ready to leave Swansea today. Local flights are shown in their own table."
}, },
"local-flights": { "local-flights": {
title: "Today's Local Flights", title: "Local Traffic",
text: "Displays local and circuit flights booked out today, with shortcuts for contact, takeoff, circuit work, touch-and-go, and landing." text: "Displays local traffic booked out today, including local flights, circuits, and PPR departures that are airborne locally before QSY."
}, },
overflights: { overflights: {
title: "Active Overflights", title: "Active Overflights",