Compare commits
11 Commits
10ab215396
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a49dfe219 | |||
| 8d8cb9ccad | |||
| 4b6dd9c93c | |||
| 74c21fe988 | |||
| c2e4d2adeb | |||
| 870bc0649b | |||
| 5e12561fb2 | |||
| f33c12f541 | |||
| 05e7859447 | |||
| a3f1a10bf5 | |||
| 5e33c1d47b |
+2
-1
@@ -20,6 +20,7 @@ MAIL_USERNAME=your_mail_username_here
|
||||
MAIL_PASSWORD=your_mail_password_here
|
||||
MAIL_FROM=your_mail_from_address_here
|
||||
MAIL_FROM_NAME=your_mail_from_name_here
|
||||
DRONE_REQUEST_TOWER_EMAIL=tower@example.com
|
||||
|
||||
# Application settings
|
||||
BASE_URL=your_base_url_here
|
||||
@@ -38,4 +39,4 @@ WEB_PORT_EXTERNAL=8082
|
||||
# phpMyAdmin Configuration
|
||||
PMA_HOST=db
|
||||
UPLOAD_LIMIT=50M
|
||||
PMA_PORT_EXTERNAL=8083
|
||||
PMA_PORT_EXTERNAL=8083
|
||||
|
||||
+5
-1
@@ -84,4 +84,8 @@ htmlcov/
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# Playwright artifacts
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -208,6 +208,20 @@ Or in Docker:
|
||||
docker compose exec api pytest --cov=app --cov-report=term-missing
|
||||
```
|
||||
|
||||
### End-to-End Testing
|
||||
Browser e2e tests use pytest plus Playwright. The recommended path is the containerized runner, which joins the same Compose network as the app and opens the web service at `http://web`.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d db api web
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
|
||||
```
|
||||
|
||||
Authenticated browser tests are skipped unless `E2E_ADMIN_USERNAME` and `E2E_ADMIN_PASSWORD` are supplied. See [`tests/e2e/README.md`](./tests/e2e/README.md) for credential examples, host-run instructions, and guidance for adding specs.
|
||||
|
||||
The e2e Compose override uses a separate MySQL container and volume, so tests do not use the normal dev/prod database configured in `.env`. It is still a real MySQL database, but isolated for e2e.
|
||||
|
||||
E2e reports are written to `test-results/e2e-report.html` and `test-results/e2e-junit.xml`.
|
||||
|
||||
## Additional Features
|
||||
|
||||
### Email Notifications
|
||||
|
||||
@@ -18,7 +18,6 @@ drone_status = sa.Enum(
|
||||
'NEW',
|
||||
'APPROVED',
|
||||
'DENIED',
|
||||
'PENDING',
|
||||
'CANCELED',
|
||||
'INFLIGHT',
|
||||
'COMPLETED',
|
||||
|
||||
@@ -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'"
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book, movements, drone_requests
|
||||
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book, movements, drone_requests, contact_requests
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -13,6 +13,7 @@ api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"]
|
||||
api_router.include_router(journal.router, prefix="/journal", tags=["journal"])
|
||||
api_router.include_router(movements.router, prefix="/movements", tags=["movements"])
|
||||
api_router.include_router(drone_requests.router, prefix="/drone-requests", tags=["drone_requests"])
|
||||
api_router.include_router(contact_requests.router, prefix="/contact-requests", tags=["contact_requests"])
|
||||
api_router.include_router(public.router, prefix="/public", tags=["public"])
|
||||
api_router.include_router(public_book.router, prefix="/public-book", tags=["public_booking"])
|
||||
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from app.core.email import email_service
|
||||
from app.core.utils import get_client_ip
|
||||
from app.schemas.contact_request import ContactRequestCreate, ContactRequestReceipt
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
CONTACT_REQUEST_RECIPIENT = "tower@swansea-airport.wales"
|
||||
|
||||
|
||||
@router.post("/public", response_model=ContactRequestReceipt)
|
||||
async def create_public_contact_request(
|
||||
contact_request: ContactRequestCreate,
|
||||
request: Request,
|
||||
):
|
||||
submitted_at = datetime.now(timezone.utc)
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
print(
|
||||
"Public contact request received "
|
||||
f"at={submitted_at.isoformat()} "
|
||||
f"type={contact_request.enquiry_type.value} "
|
||||
f"name={contact_request.name!r} "
|
||||
f"email={contact_request.email} "
|
||||
f"source={contact_request.source_page or '-'} "
|
||||
f"ip={client_ip}"
|
||||
)
|
||||
|
||||
await email_service.send_email(
|
||||
to_email=CONTACT_REQUEST_RECIPIENT,
|
||||
subject=f"Website contact: {contact_request.subject}",
|
||||
template_name="contact_request.html",
|
||||
reply_to=f"{contact_request.name} <{contact_request.email}>",
|
||||
template_vars={
|
||||
"submitted_at": submitted_at.strftime("%Y-%m-%d %H:%M UTC"),
|
||||
"client_ip": client_ip,
|
||||
"name": contact_request.name,
|
||||
"email": contact_request.email,
|
||||
"phone": contact_request.phone,
|
||||
"enquiry_type": contact_request.enquiry_type.value,
|
||||
"subject": contact_request.subject,
|
||||
"message": contact_request.message,
|
||||
"source_page": contact_request.source_page,
|
||||
},
|
||||
)
|
||||
|
||||
return ContactRequestReceipt()
|
||||
@@ -17,6 +17,7 @@ from app.schemas.drone_request import (
|
||||
DroneRequest,
|
||||
DroneRequestComment,
|
||||
DroneRequestCreate,
|
||||
DroneRequestPublicSubmission,
|
||||
DroneRequestStatus,
|
||||
DroneRequestStatusUpdate,
|
||||
DroneRequestUpdate,
|
||||
@@ -51,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,
|
||||
},
|
||||
)
|
||||
@@ -69,12 +70,38 @@ 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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _send_drone_tower_notification(drone_request):
|
||||
tower_email = settings.drone_request_tower_email or settings.mail_from
|
||||
await email_service.send_email(
|
||||
to_email=tower_email,
|
||||
subject=f"Drone flight request awaiting review {drone_request.reference_number}",
|
||||
template_name="drone_request_tower_notification.html",
|
||||
template_vars={
|
||||
"reference_number": drone_request.reference_number,
|
||||
"operator_name": drone_request.operator_name,
|
||||
"operator_id": drone_request.operator_id,
|
||||
"flyer_name": drone_request.flyer_name,
|
||||
"flyer_id": drone_request.flyer_id,
|
||||
"email": drone_request.email,
|
||||
"phone": drone_request.phone,
|
||||
"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_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",
|
||||
},
|
||||
reply_to=f"{drone_request.operator_name} <{drone_request.email}>",
|
||||
)
|
||||
|
||||
|
||||
async def _send_drone_approved_email(drone_request, message: Optional[str] = None):
|
||||
await email_service.send_email(
|
||||
to_email=drone_request.email,
|
||||
@@ -88,12 +115,19 @@ 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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _public_submission_response(drone_request):
|
||||
payload = DroneRequest.model_validate(drone_request, from_attributes=True).model_dump(mode="json")
|
||||
payload["request_id"] = drone_request.reference_number
|
||||
payload["secure_link"] = f"{settings.base_url}/drone-request.html?token={drone_request.public_token}"
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/", response_model=List[DroneRequest])
|
||||
async def get_drone_requests(
|
||||
skip: int = 0,
|
||||
@@ -114,7 +148,7 @@ async def get_drone_requests(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/public", response_model=DroneRequest)
|
||||
@router.post("/public", response_model=DroneRequestPublicSubmission)
|
||||
async def create_public_drone_request(
|
||||
request: Request,
|
||||
drone_request_in: DroneRequestCreate,
|
||||
@@ -131,7 +165,8 @@ async def create_public_drone_request(
|
||||
|
||||
await _broadcast(request, "drone_request_created", drone_request)
|
||||
await _send_drone_submitted_email(drone_request)
|
||||
return drone_request
|
||||
await _send_drone_tower_notification(drone_request)
|
||||
return _public_submission_response(drone_request)
|
||||
|
||||
|
||||
@router.get("/public/edit/{token}", response_model=DroneRequest)
|
||||
@@ -155,7 +190,7 @@ async def update_drone_request_public(
|
||||
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
||||
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.PENDING, DroneRequestStatus.APPROVED]:
|
||||
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.APPROVED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Drone request cannot be edited while {drone_request.status.value}",
|
||||
@@ -182,7 +217,7 @@ async def cancel_drone_request_public(
|
||||
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
||||
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.PENDING, DroneRequestStatus.APPROVED]:
|
||||
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.APPROVED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Drone request cannot be cancelled while {drone_request.status.value}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,11 +21,13 @@ class Settings(BaseSettings):
|
||||
mail_password: str
|
||||
mail_from: str
|
||||
mail_from_name: str
|
||||
drone_request_tower_email: str | None = None
|
||||
|
||||
# Application settings
|
||||
api_v1_str: str = "/api/v1"
|
||||
project_name: str = "Airfield PPR API"
|
||||
base_url: str
|
||||
local_timezone: str = "Europe/London"
|
||||
|
||||
# UI Configuration
|
||||
tag: str = ""
|
||||
|
||||
@@ -19,7 +19,14 @@ class EmailService:
|
||||
template_dir = os.path.join(os.path.dirname(__file__), '..', 'templates')
|
||||
self.jinja_env = Environment(loader=FileSystemLoader(template_dir))
|
||||
|
||||
async def send_email(self, to_email: str, subject: str, template_name: str, template_vars: dict):
|
||||
async def send_email(
|
||||
self,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
template_name: str,
|
||||
template_vars: dict,
|
||||
reply_to: str | None = None,
|
||||
):
|
||||
# Render the template
|
||||
template = self.jinja_env.get_template(template_name)
|
||||
html_content = template.render(**template_vars)
|
||||
@@ -29,6 +36,8 @@ class EmailService:
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = f"{self.from_name} <{self.from_email}>"
|
||||
msg['To'] = to_email
|
||||
if reply_to:
|
||||
msg['Reply-To'] = reply_to
|
||||
|
||||
# Attach HTML content
|
||||
html_part = MIMEText(html_content, 'html')
|
||||
@@ -45,4 +54,4 @@ class EmailService:
|
||||
# In production, use logging
|
||||
|
||||
|
||||
email_service = EmailService()
|
||||
email_service = EmailService()
|
||||
|
||||
@@ -187,7 +187,7 @@ class CRUDLocalFlight:
|
||||
current_time = timestamp if timestamp is not None else datetime.utcnow()
|
||||
if status == LocalFlightStatus.GROUND:
|
||||
db_obj.contact_dt = current_time
|
||||
elif status == LocalFlightStatus.DEPARTED:
|
||||
elif status == LocalFlightStatus.DEPARTED and not db_obj.departed_dt:
|
||||
db_obj.departed_dt = current_time
|
||||
elif status == LocalFlightStatus.LANDED and not db_obj.landed_dt:
|
||||
db_obj.landed_dt = current_time
|
||||
@@ -200,6 +200,8 @@ class CRUDLocalFlight:
|
||||
# Takeoff: happens once when transitioning away from GROUND
|
||||
if old_status == LocalFlightStatus.GROUND and status in (LocalFlightStatus.DEPARTED, LocalFlightStatus.LOCAL, LocalFlightStatus.CIRCUIT) and not db_obj.takeoff_dt:
|
||||
db_obj.takeoff_dt = current_time
|
||||
if not db_obj.departed_dt:
|
||||
db_obj.departed_dt = current_time
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -10,7 +10,6 @@ class DroneRequestStatus(str, Enum):
|
||||
NEW = "NEW"
|
||||
APPROVED = "APPROVED"
|
||||
DENIED = "DENIED"
|
||||
PENDING = "PENDING"
|
||||
CANCELED = "CANCELED"
|
||||
INFLIGHT = "INFLIGHT"
|
||||
COMPLETED = "COMPLETED"
|
||||
@@ -36,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)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, validator
|
||||
|
||||
|
||||
class ContactEnquiryType(str, Enum):
|
||||
GENERAL = "general"
|
||||
AVIATION_BUSINESS = "aviation_business"
|
||||
PILOT = "pilot"
|
||||
EVENTS = "events"
|
||||
COMMUNITY = "community"
|
||||
|
||||
|
||||
class ContactRequestCreate(BaseModel):
|
||||
name: str = Field(..., max_length=128)
|
||||
email: EmailStr
|
||||
phone: Optional[str] = Field(None, max_length=32)
|
||||
enquiry_type: ContactEnquiryType
|
||||
subject: str = Field(..., max_length=160)
|
||||
message: str = Field(..., min_length=1, max_length=4000)
|
||||
source_page: Optional[str] = Field(None, max_length=256)
|
||||
|
||||
@validator("name", "subject", "message")
|
||||
def validate_required_text(cls, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("Field is required")
|
||||
return value
|
||||
|
||||
@validator("phone", "source_page")
|
||||
def strip_optional_text(cls, value):
|
||||
if value is None:
|
||||
return value
|
||||
value = value.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class ContactRequestReceipt(BaseModel):
|
||||
status: str = "received"
|
||||
@@ -2,14 +2,13 @@ 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):
|
||||
NEW = "NEW"
|
||||
APPROVED = "APPROVED"
|
||||
DENIED = "DENIED"
|
||||
PENDING = "PENDING"
|
||||
CANCELED = "CANCELED"
|
||||
INFLIGHT = "INFLIGHT"
|
||||
COMPLETED = "COMPLETED"
|
||||
@@ -21,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)
|
||||
@@ -69,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)
|
||||
@@ -104,3 +111,8 @@ class DroneRequest(DroneRequestBase):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DroneRequestPublicSubmission(DroneRequest):
|
||||
request_id: str
|
||||
secure_link: str
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Website Contact Request</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f4f6f8; color: #24313d; font-family: Arial, Helvetica, sans-serif; font-size: 16px; line-height: 1.55;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f6f8; padding: 24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-collapse: collapse; border-radius: 8px; overflow: hidden; border: 1px solid #dfe5eb;">
|
||||
<tr>
|
||||
<td style="background: #263645; color: #ffffff; padding: 24px 28px;">
|
||||
<h1 style="margin: 0; font-size: 24px; line-height: 1.25;">Website Contact Request</h1>
|
||||
<p style="margin: 8px 0 0; font-size: 17px;">{{ enquiry_type | e }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 28px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; margin: 0 0 22px;">
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc; width: 38%;"><strong>Submitted</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ submitted_at | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Name</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ name | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Email</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ email | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Phone</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ phone | default("-", true) | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Category</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ enquiry_type | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Subject</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ subject | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Source page</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ source_page | default("-", true) | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Client IP</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ client_ip | e }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2 style="font-size: 19px; margin: 0 0 10px;">Message</h2>
|
||||
<div style="border: 1px solid #dfe5eb; background: #f8fafc; padding: 14px 16px; white-space: pre-wrap;">{{ message | e }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Drone Request Awaiting Review</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f4f7fa; font-family: Arial, sans-serif; color: #263645;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background: #f4f7fa; padding: 24px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-radius: 8px; overflow: hidden;">
|
||||
<tr>
|
||||
<td style="background: #34495e; color: #ffffff; padding: 24px;">
|
||||
<h1 style="margin: 0; font-size: 24px;">Drone request awaiting review</h1>
|
||||
<p style="margin: 8px 0 0; font-size: 16px;">{{ reference_number }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 24px;">
|
||||
<p style="font-size: 17px; margin: 0 0 18px;">
|
||||
A new drone flight request has been submitted. Please review it and approve or deny it as soon as practical.
|
||||
</p>
|
||||
<p style="margin: 0 0 22px;">
|
||||
<a href="{{ requests_url }}" style="background: #2f93d1; color: #ffffff; display: inline-block; padding: 12px 18px; border-radius: 5px; text-decoration: none; font-weight: bold;">Open drone requests</a>
|
||||
</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; font-size: 15px;">
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Operator</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ operator_name }}{% if operator_id %} ({{ operator_id }}){% endif %}</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Flyer</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ flyer_name or '-' }}{% if flyer_id %} ({{ flyer_id }}){% endif %}</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Contact</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ email }}{% if phone %} / {{ phone }}{% endif %}</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Takeoff</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ takeoff_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;">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_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>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,10 +10,10 @@
|
||||
<p><strong>PPR Details:</strong></p>
|
||||
<ul>
|
||||
<li>Aircraft: {{ aircraft }}</li>
|
||||
<li>Original Arrival: {{ arrival_time }}</li>
|
||||
<li>Original Departure: {{ departure_time }}</li>
|
||||
<li>Original Arrival (local time): {{ arrival_time }}</li>
|
||||
<li>Original Departure (local time): {{ departure_time }}</li>
|
||||
</ul>
|
||||
<p>If this was not intended, please contact us.</p>
|
||||
<p>Best regards,<br>Swansea Airport Team</p>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
<p><strong>PPR Details:</strong></p>
|
||||
<ul>
|
||||
<li>Aircraft: {{ aircraft }}</li>
|
||||
<li>Arrival: {{ arrival_time }}</li>
|
||||
<li>Departure: {{ departure_time }}</li>
|
||||
<li>Purpose: {{ purpose }}</li>
|
||||
<li>Arrival (local time): {{ arrival_time }}</li>
|
||||
<li>Departure (local time): {{ departure_time }}</li>
|
||||
</ul>
|
||||
<p>You can <a href="{{ base_url }}/edit.html?token={{ public_token }}">edit or cancel</a> your PPR using this secure link.</p>
|
||||
<p>You will receive further updates via email.</p>
|
||||
<p>Best regards,<br>Swansea Airport Team</p>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -17,6 +17,7 @@ os.environ.setdefault("MAIL_USERNAME", "test")
|
||||
os.environ.setdefault("MAIL_PASSWORD", "test")
|
||||
os.environ.setdefault("MAIL_FROM", "noreply@example.test")
|
||||
os.environ.setdefault("MAIL_FROM_NAME", "PPR Tests")
|
||||
os.environ.setdefault("DRONE_REQUEST_TOWER_EMAIL", "tower@swansea-airport.wales")
|
||||
os.environ.setdefault("BASE_URL", "http://testserver")
|
||||
os.environ.setdefault("ENVIRONMENT", "test")
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
def contact_payload(**overrides):
|
||||
payload = {
|
||||
"name": "Jane Smith",
|
||||
"email": "jane@example.com",
|
||||
"phone": "07123 456789",
|
||||
"enquiry_type": "aviation_business",
|
||||
"subject": "Basing a maintenance business at Swansea",
|
||||
"message": "We would like to explore operating from Swansea Airport.",
|
||||
"source_page": "/contact/",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_public_contact_request_emails_tower_and_logs(client, monkeypatch, capsys):
|
||||
sent_emails = []
|
||||
|
||||
async def fake_send_email(**kwargs):
|
||||
sent_emails.append(kwargs)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.contact_requests.email_service.send_email", fake_send_email)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/contact-requests/public",
|
||||
json=contact_payload(),
|
||||
headers={"X-Forwarded-For": "203.0.113.10, 10.0.0.1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "received"}
|
||||
assert len(sent_emails) == 1
|
||||
|
||||
email = sent_emails[0]
|
||||
assert email["to_email"] == "tower@swansea-airport.wales"
|
||||
assert email["reply_to"] == "Jane Smith <jane@example.com>"
|
||||
assert email["subject"] == "Website contact: Basing a maintenance business at Swansea"
|
||||
assert email["template_name"] == "contact_request.html"
|
||||
assert email["template_vars"]["name"] == "Jane Smith"
|
||||
assert email["template_vars"]["enquiry_type"] == "aviation_business"
|
||||
assert email["template_vars"]["client_ip"] == "203.0.113.10"
|
||||
|
||||
log_output = capsys.readouterr().out
|
||||
assert "Public contact request received" in log_output
|
||||
assert "aviation_business" in log_output
|
||||
assert "jane@example.com" in log_output
|
||||
|
||||
|
||||
def test_public_contact_request_validation(client, monkeypatch):
|
||||
async def fake_send_email(**kwargs):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.contact_requests.email_service.send_email", fake_send_email)
|
||||
|
||||
invalid_category = client.post(
|
||||
"/api/v1/contact-requests/public",
|
||||
json=contact_payload(enquiry_type="sales"),
|
||||
)
|
||||
blank_required = client.post(
|
||||
"/api/v1/contact-requests/public",
|
||||
json=contact_payload(name=" ", subject="", message=" "),
|
||||
)
|
||||
|
||||
assert invalid_category.status_code == 422
|
||||
assert blank_required.status_code == 422
|
||||
@@ -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,
|
||||
@@ -47,7 +47,9 @@ def test_public_drone_request_create_edit_cancel_and_journal(client, db, monkeyp
|
||||
assert created["status"] == "NEW"
|
||||
assert created["location_inside_frz"] is True
|
||||
assert created["created_by"] == "public"
|
||||
assert len(sent_emails) == 1
|
||||
assert len(sent_emails) == 2
|
||||
assert sent_emails[1]["to_email"] == "tower@swansea-airport.wales"
|
||||
assert sent_emails[1]["template_name"] == "drone_request_tower_notification.html"
|
||||
|
||||
db_request = db.query(DroneRequest).filter(DroneRequest.id == created["id"]).one()
|
||||
assert db_request.public_token
|
||||
@@ -64,7 +66,7 @@ def test_public_drone_request_create_edit_cancel_and_journal(client, db, monkeyp
|
||||
assert patch_response.json()["operator_name"] == "Updated Rotor Ops"
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json()["status"] == "CANCELED"
|
||||
assert len(sent_emails) == 2
|
||||
assert len(sent_emails) == 3
|
||||
|
||||
blocked_patch = client.patch(
|
||||
f"/api/v1/drone-requests/public/edit/{db_request.public_token}",
|
||||
@@ -79,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 = []
|
||||
|
||||
@@ -98,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",
|
||||
@@ -114,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"
|
||||
@@ -125,13 +144,13 @@ def test_authenticated_drone_request_list_update_status_comment_and_journal(auth
|
||||
assert any("Drone request" in entry and "created" in entry for entry in entries)
|
||||
assert any("Status changed from NEW to APPROVED" in entry for entry in entries)
|
||||
assert any("Comment added" in entry for entry in entries)
|
||||
assert len(sent_emails) == 3
|
||||
assert len(sent_emails) == 4
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -194,6 +194,7 @@ def test_local_flight_lifecycle_special_lists_and_not_found_paths(auth_client, d
|
||||
|
||||
assert departed_response.status_code == 200
|
||||
assert departed_response.json()["takeoff_dt"] == "2026-06-20T10:05:00"
|
||||
assert departed_response.json()["departed_dt"] == "2026-06-20T10:05:00"
|
||||
assert landed_response.status_code == 200
|
||||
assert landed_response.json()["landed_dt"] == "2026-06-20T10:45:00"
|
||||
|
||||
@@ -222,6 +223,40 @@ def test_local_flight_lifecycle_special_lists_and_not_found_paths(auth_client, d
|
||||
assert auth_client.delete("/api/v1/local-flights/404").status_code == 404
|
||||
|
||||
|
||||
def test_local_flight_takeoff_to_local_sets_departed_dt(auth_client):
|
||||
create_response = auth_client.post(
|
||||
"/api/v1/local-flights/",
|
||||
json={
|
||||
"registration": "g-air",
|
||||
"type": "PA28",
|
||||
"pob": 2,
|
||||
"flight_type": "LOCAL",
|
||||
"duration": 30,
|
||||
"etd": "2026-06-20T09:00:00",
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
|
||||
takeoff_response = auth_client.patch(
|
||||
f"/api/v1/local-flights/{create_response.json()['id']}/status",
|
||||
json={"status": "LOCAL", "timestamp": "2026-06-20T09:05:00"},
|
||||
)
|
||||
|
||||
assert takeoff_response.status_code == 200
|
||||
assert takeoff_response.json()["status"] == "LOCAL"
|
||||
assert takeoff_response.json()["takeoff_dt"] == "2026-06-20T09:05:00"
|
||||
assert takeoff_response.json()["departed_dt"] == "2026-06-20T09:05:00"
|
||||
|
||||
landing_response = auth_client.patch(
|
||||
f"/api/v1/local-flights/{create_response.json()['id']}/status",
|
||||
json={"status": "LANDED", "timestamp": "2026-06-20T09:35:00"},
|
||||
)
|
||||
|
||||
assert landing_response.status_code == 200
|
||||
assert landing_response.json()["status"] == "LANDED"
|
||||
assert landing_response.json()["landed_dt"] == "2026-06-20T09:35:00"
|
||||
|
||||
|
||||
def test_overflight_lifecycle_special_lists_and_not_found_paths(auth_client, db):
|
||||
payload = {
|
||||
"registration": "g-ovr",
|
||||
|
||||
@@ -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,80 @@
|
||||
services:
|
||||
db:
|
||||
container_name: ppr_e2e_db
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tests/e2e/mysql.Dockerfile
|
||||
image: pprdev-e2e-db
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: e2e_root_password
|
||||
MYSQL_DATABASE: ppr_e2e
|
||||
MYSQL_USER: ppr_e2e
|
||||
MYSQL_PASSWORD: ppr_e2e_password
|
||||
volumes:
|
||||
- ppr_e2e_mysql_data:/var/lib/mysql
|
||||
|
||||
api:
|
||||
container_name: ppr_e2e_api
|
||||
ports:
|
||||
- "${E2E_API_PORT_EXTERNAL:-18002}:8000"
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_PORT: 3306
|
||||
DB_NAME: ppr_e2e
|
||||
DB_USER: ppr_e2e
|
||||
DB_PASSWORD: ppr_e2e_password
|
||||
BASE_URL: http://web
|
||||
MAIL_HOST: 127.0.0.1
|
||||
MAIL_PORT: 1
|
||||
MAIL_USERNAME: e2e
|
||||
MAIL_PASSWORD: e2e
|
||||
MAIL_FROM: e2e@example.com
|
||||
MAIL_FROM_NAME: PPR E2E
|
||||
DRONE_REQUEST_TOWER_EMAIL: tower@example.com
|
||||
|
||||
web:
|
||||
container_name: ppr_e2e_web
|
||||
ports:
|
||||
- "${E2E_WEB_PORT_EXTERNAL:-18055}:80"
|
||||
environment:
|
||||
BASE_URL: ""
|
||||
command: >
|
||||
sh -c "if [ -z \"$${BASE_URL}\" ]; then API_BASE='/api/v1'; else API_BASE=\"$${BASE_URL}/api/v1\"; fi;
|
||||
printf 'window.PPR_CONFIG = { apiBase: \"%s\" };' \"$${API_BASE}\" > /usr/share/nginx/html/config.js;
|
||||
nginx -g 'daemon off;'"
|
||||
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tests/e2e/Dockerfile
|
||||
working_dir: /workspace
|
||||
volumes:
|
||||
- .:/workspace
|
||||
environment:
|
||||
E2E_BASE_URL: http://web
|
||||
E2E_ADMIN_USERNAME: ${E2E_ADMIN_USERNAME:-}
|
||||
E2E_ADMIN_PASSWORD: ${E2E_ADMIN_PASSWORD:-}
|
||||
E2E_HEALTH_URL: http://api:8000/health
|
||||
E2E_ARTIFACT_UID: ${E2E_ARTIFACT_UID:-1000}
|
||||
E2E_ARTIFACT_GID: ${E2E_ARTIFACT_GID:-1000}
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
depends_on:
|
||||
- web
|
||||
networks:
|
||||
- public_network
|
||||
command: >
|
||||
bash -lc "mkdir -p test-results &&
|
||||
python tests/e2e/wait_for_web.py &&
|
||||
pytest tests/e2e
|
||||
--browser chromium
|
||||
--tracing=retain-on-failure
|
||||
--screenshot=only-on-failure
|
||||
--junitxml=test-results/e2e-junit.xml
|
||||
--html=test-results/e2e-report.html
|
||||
--self-contained-html;
|
||||
status=$$?;
|
||||
chown -R \"$${E2E_ARTIFACT_UID}:$${E2E_ARTIFACT_GID}\" test-results || true;
|
||||
exit $$status"
|
||||
|
||||
volumes:
|
||||
ppr_e2e_mysql_data:
|
||||
@@ -24,6 +24,7 @@ services:
|
||||
MAIL_PASSWORD: ${MAIL_PASSWORD}
|
||||
MAIL_FROM: ${MAIL_FROM}
|
||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
|
||||
BASE_URL: ${BASE_URL}
|
||||
TAG: ${TAG}
|
||||
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
||||
|
||||
@@ -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
|
||||
@@ -36,6 +36,7 @@ services:
|
||||
MAIL_PASSWORD: ${MAIL_PASSWORD}
|
||||
MAIL_FROM: ${MAIL_FROM}
|
||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
|
||||
BASE_URL: ${BASE_URL}
|
||||
TOWER_NAME: ${TOWER_NAME}
|
||||
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM mcr.microsoft.com/playwright/python:v1.45.0-jammy
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY tests/e2e/requirements.txt /tmp/e2e-requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/e2e-requirements.txt
|
||||
@@ -0,0 +1,90 @@
|
||||
# End-to-End Tests
|
||||
|
||||
The e2e suite uses pytest plus Playwright. The preferred path is the containerized runner, which joins the same Docker Compose network as the web app and opens `http://web`.
|
||||
|
||||
## Containerized Run
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d db api web
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
|
||||
```
|
||||
|
||||
The e2e override sets `E2E_BASE_URL=http://web`, makes the web container generate relative API config for browser-side requests, and forces the API container to use an e2e-only Compose database.
|
||||
|
||||
The e2e database is a real MySQL server, but it is isolated from the normal dev database:
|
||||
|
||||
- DB container: `ppr_e2e_db`
|
||||
- API container: `ppr_e2e_api`
|
||||
- Web container: `ppr_e2e_web`
|
||||
- Database name: `ppr_e2e`
|
||||
- Volume: `pprdev_ppr_e2e_mysql_data`
|
||||
|
||||
The e2e DB image is plain `mysql:8.0`, so the API should see a fresh empty database and create schema through Alembic migrations instead of stamping an older `db-init` schema.
|
||||
|
||||
Authenticated tests are skipped unless credentials are supplied:
|
||||
|
||||
```bash
|
||||
E2E_ADMIN_USERNAME=admin \
|
||||
E2E_ADMIN_PASSWORD=admin123 \
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
|
||||
```
|
||||
|
||||
## Rebuild The Test Image
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml build e2e
|
||||
```
|
||||
|
||||
## Test Evidence
|
||||
|
||||
Containerized runs write evidence to `test-results/`:
|
||||
|
||||
- `test-results/e2e-report.html` for a human-readable report
|
||||
- `test-results/e2e-junit.xml` for CI systems
|
||||
- Playwright traces and screenshots on failures
|
||||
|
||||
If the report files are not owned by your host user, pass your UID/GID:
|
||||
|
||||
```bash
|
||||
E2E_ARTIFACT_UID=$(id -u) \
|
||||
E2E_ARTIFACT_GID=$(id -g) \
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
|
||||
```
|
||||
|
||||
## Host Run
|
||||
|
||||
Running on the host is still supported for quick debugging if Python and Playwright are installed locally.
|
||||
|
||||
First-time host setup:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r backend/requirements.txt
|
||||
pip install -r tests/e2e/requirements.txt
|
||||
python -m playwright install --with-deps chromium
|
||||
```
|
||||
|
||||
Run against a host-exposed web port:
|
||||
|
||||
```bash
|
||||
E2E_BASE_URL=http://localhost:8082 pytest tests/e2e
|
||||
```
|
||||
|
||||
Run authenticated host tests:
|
||||
|
||||
```bash
|
||||
E2E_BASE_URL=http://localhost:8082 \
|
||||
E2E_ADMIN_USERNAME=admin \
|
||||
E2E_ADMIN_PASSWORD=admin123 \
|
||||
pytest tests/e2e
|
||||
```
|
||||
|
||||
## Adding Tests
|
||||
|
||||
Put browser specs in `tests/e2e/test_*.py`. Start with user-visible behavior and stable selectors:
|
||||
|
||||
- Navigate through the same URLs users open.
|
||||
- Prefer roles and labels, such as `get_by_role()` and `get_by_label()`.
|
||||
- Use API setup only when a test needs specific records to exist.
|
||||
- Keep specs independent so they can run in any order.
|
||||
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
|
||||
BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/")
|
||||
ADMIN_USERNAME = os.getenv("E2E_ADMIN_USERNAME")
|
||||
ADMIN_PASSWORD = os.getenv("E2E_ADMIN_PASSWORD")
|
||||
|
||||
|
||||
def app_url(path):
|
||||
return f"{BASE_URL}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def login_as_admin(page):
|
||||
if page.locator("#login-username").is_visible():
|
||||
page.locator("#login-username").fill(ADMIN_USERNAME)
|
||||
page.locator("#login-password").fill(ADMIN_PASSWORD)
|
||||
page.locator("#login-btn").click()
|
||||
expect(page.locator("#current-user")).to_have_text(ADMIN_USERNAME)
|
||||
if page.get_by_role("heading", name="Login").count() > 0:
|
||||
expect(page.get_by_role("heading", name="Login")).to_be_hidden()
|
||||
|
||||
|
||||
def skip_without_admin_credentials(pytest):
|
||||
if not ADMIN_USERNAME or not ADMIN_PASSWORD:
|
||||
pytest.skip("Set E2E_ADMIN_USERNAME and E2E_ADMIN_PASSWORD to run authenticated e2e tests")
|
||||
@@ -0,0 +1 @@
|
||||
FROM mysql:8.0
|
||||
@@ -0,0 +1,3 @@
|
||||
playwright==1.45.0
|
||||
pytest-playwright>=0.5,<0.6
|
||||
pytest-html>=4,<5
|
||||
@@ -0,0 +1,73 @@
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from helpers import app_url, login_as_admin, skip_without_admin_credentials
|
||||
|
||||
|
||||
def open_admin_dropdown(page):
|
||||
page.locator("#adminDropdownBtn").click()
|
||||
admin_menu = page.locator("#adminDropdownMenu")
|
||||
expect(admin_menu).to_have_class(re.compile("active"))
|
||||
return admin_menu
|
||||
|
||||
|
||||
def open_admin_page(page):
|
||||
skip_without_admin_credentials(pytest)
|
||||
page.goto(app_url("/admin"))
|
||||
expect(page).to_have_title(re.compile("PPR Admin Interface"))
|
||||
expect(page.get_by_role("heading", name=re.compile("Swansea Tower"))).to_be_visible()
|
||||
login_as_admin(page)
|
||||
|
||||
|
||||
def test_actions_and_admin_dropdowns_toggle_exclusively(page):
|
||||
open_admin_page(page)
|
||||
|
||||
actions_menu = page.locator("#actionsDropdownMenu")
|
||||
admin_menu = page.locator("#adminDropdownMenu")
|
||||
|
||||
expect(actions_menu).not_to_have_class(re.compile("active"))
|
||||
expect(admin_menu).not_to_have_class(re.compile("active"))
|
||||
|
||||
page.locator("#actionsDropdownBtn").click()
|
||||
expect(actions_menu).to_have_class(re.compile("active"))
|
||||
expect(admin_menu).not_to_have_class(re.compile("active"))
|
||||
expect(actions_menu.get_by_role("link", name=re.compile("New PPR"))).to_be_visible()
|
||||
expect(actions_menu.get_by_role("link", name=re.compile("Book Out"))).to_be_visible()
|
||||
expect(actions_menu.get_by_role("link", name=re.compile("Book In"))).to_be_visible()
|
||||
expect(actions_menu.get_by_role("link", name=re.compile("Overflight"))).to_be_visible()
|
||||
|
||||
admin_menu = open_admin_dropdown(page)
|
||||
expect(actions_menu).not_to_have_class(re.compile("active"))
|
||||
expect(admin_menu.get_by_role("link", name=re.compile("Admin View"))).to_be_visible()
|
||||
expect(admin_menu.get_by_role("link", name=re.compile("ATC View"))).to_be_visible()
|
||||
expect(admin_menu.get_by_role("link", name=re.compile("Reports"))).to_be_visible()
|
||||
expect(admin_menu.get_by_role("link", name=re.compile("Drone Requests"))).to_be_visible()
|
||||
expect(admin_menu.get_by_role("link", name=re.compile("Journal Log"))).to_be_visible()
|
||||
|
||||
page.locator(".container").click()
|
||||
expect(admin_menu).not_to_have_class(re.compile("active"))
|
||||
|
||||
|
||||
def test_admin_menu_links_navigate_to_expected_pages(page):
|
||||
open_admin_page(page)
|
||||
|
||||
menu_expectations = [
|
||||
("Admin View", re.compile(r"/admin$"), re.compile("PPR Admin Interface"), "title"),
|
||||
("ATC View", re.compile(r"/atc$"), re.compile("ATC Management Interface"), "title"),
|
||||
("Reports", re.compile(r"/reports$"), re.compile("PPR Reports"), "heading"),
|
||||
("Drone Requests", re.compile(r"/drone-requests$"), re.compile("Drone Flight Requests"), "heading"),
|
||||
("Journal Log", re.compile(r"/journal$"), re.compile("Journal Log"), "heading"),
|
||||
]
|
||||
|
||||
for label, url_pattern, expected_text, assertion_type in menu_expectations:
|
||||
page.goto(app_url("/admin"))
|
||||
login_as_admin(page)
|
||||
admin_menu = open_admin_dropdown(page)
|
||||
admin_menu.get_by_role("link", name=re.compile(label)).click()
|
||||
expect(page).to_have_url(url_pattern)
|
||||
if assertion_type == "title":
|
||||
expect(page).to_have_title(expected_text)
|
||||
else:
|
||||
expect(page.get_by_role("heading", name=expected_text)).to_be_visible()
|
||||
@@ -0,0 +1,64 @@
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from helpers import app_url, login_as_admin, skip_without_admin_credentials
|
||||
|
||||
|
||||
def drone_payload(operator_name):
|
||||
return {
|
||||
"operator_name": operator_name,
|
||||
"operator_id": "E2E-OP",
|
||||
"flyer_name": "E2E Remote Pilot",
|
||||
"flyer_id": "E2E-FLYER",
|
||||
"email": "drone-e2e@example.com",
|
||||
"phone": "0123456789",
|
||||
"flight_date": "2026-06-21",
|
||||
"estimated_takeoff_time": "10:00",
|
||||
"estimated_completion_time": "10:30",
|
||||
"estimated_takeoff_at": "2026-06-21T10:00:00",
|
||||
"estimated_completion_at": "2026-06-21T10:30:00",
|
||||
"maximum_elevation_ft_agl": 200,
|
||||
"location_description": "E2E north apron survey",
|
||||
"location_latitude": 51.623389,
|
||||
"location_longitude": -4.069231,
|
||||
"location_inside_frz": "yes",
|
||||
"notes": "Created by Playwright e2e",
|
||||
}
|
||||
|
||||
|
||||
def test_drone_requests_requires_login_and_loads_queue(page):
|
||||
skip_without_admin_credentials(pytest)
|
||||
|
||||
page.goto(app_url("/drone-requests"))
|
||||
|
||||
expect(page).to_have_title(re.compile("Drone Flight Requests"))
|
||||
expect(page.get_by_role("heading", name="Drone Flight Requests")).to_be_visible()
|
||||
expect(page.get_by_role("heading", name="Login")).to_be_visible()
|
||||
|
||||
login_as_admin(page)
|
||||
|
||||
expect(page.locator("#request-list-body")).not_to_contain_text("Loading requests...")
|
||||
expect(page.locator("#request-count")).to_have_text(re.compile(r"\d+"))
|
||||
|
||||
|
||||
def test_public_drone_request_appears_in_tower_queue(page):
|
||||
skip_without_admin_credentials(pytest)
|
||||
|
||||
operator_name = f"E2E Rotor Ops {datetime.utcnow().strftime('%H%M%S%f')}"
|
||||
create_response = page.request.post(
|
||||
app_url("/api/v1/drone-requests/public"),
|
||||
data=json.dumps(drone_payload(operator_name)),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert create_response.ok, create_response.text()
|
||||
created = create_response.json()
|
||||
|
||||
page.goto(app_url("/drone-requests"))
|
||||
login_as_admin(page)
|
||||
|
||||
expect(page.locator("#request-list-body")).to_contain_text(created["reference_number"])
|
||||
expect(page.locator("#request-list-body")).to_contain_text(operator_name)
|
||||
@@ -0,0 +1,56 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from helpers import app_url, login_as_admin, skip_without_admin_credentials
|
||||
|
||||
|
||||
def local_flight_row(page, registration):
|
||||
return page.locator("#local-flights-table-body tr").filter(has_text=registration)
|
||||
|
||||
|
||||
def confirm_timestamp(page, button_text):
|
||||
expect(page.locator("#timestampModal")).to_be_visible()
|
||||
expect(page.locator("#timestamp-submit-btn")).to_contain_text(button_text)
|
||||
page.locator("#timestamp-submit-btn").click()
|
||||
expect(page.locator("#timestampModal")).to_be_hidden()
|
||||
|
||||
|
||||
def test_operator_books_out_local_flight_records_touch_and_go_and_lands(page):
|
||||
skip_without_admin_credentials(pytest)
|
||||
registration = f"GE2E{datetime.utcnow().strftime('%H%M%S')}"
|
||||
|
||||
page.goto(app_url("/admin"))
|
||||
login_as_admin(page)
|
||||
|
||||
page.locator("#actionsDropdownBtn").click()
|
||||
page.locator("#actionsDropdownMenu").get_by_role("link", name="🛫 Book Out (L)").click()
|
||||
|
||||
expect(page.locator("#localFlightModal")).to_be_visible()
|
||||
page.locator("#local_registration").fill(registration)
|
||||
page.locator("#local_type").fill("PA28")
|
||||
page.locator("#local_pob").fill("1")
|
||||
page.locator("#local_flight_type").select_option("LOCAL")
|
||||
page.locator("#local_duration").fill("45")
|
||||
page.locator("#local_notes").fill("E2E local flight lifecycle")
|
||||
page.locator("#local-flight-form").get_by_role("button", name="🛫 Book Out").click()
|
||||
|
||||
expect(page.locator("#localFlightModal")).to_be_hidden()
|
||||
row = local_flight_row(page, registration)
|
||||
expect(row).to_be_visible()
|
||||
expect(row).to_contain_text("GROUND")
|
||||
|
||||
row.get_by_role("button", name="TAKE OFF").click()
|
||||
confirm_timestamp(page, "Confirm Takeoff")
|
||||
expect(row).to_contain_text("LOCAL")
|
||||
|
||||
row.get_by_role("button", name="T&G").click()
|
||||
expect(page.locator("#circuitModal")).to_be_visible()
|
||||
page.locator("#circuit-form").get_by_role("button", name="Record Circuit").click()
|
||||
expect(page.locator("#circuitModal")).to_be_hidden()
|
||||
expect(row.locator("td").nth(7)).to_have_text("1")
|
||||
|
||||
row.get_by_role("button", name="LAND").click()
|
||||
confirm_timestamp(page, "Confirm Landing")
|
||||
expect(local_flight_row(page, registration)).to_have_count(0)
|
||||
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
|
||||
BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/")
|
||||
|
||||
|
||||
def app_url(path):
|
||||
return f"{BASE_URL}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def test_flight_information_display_loads(page):
|
||||
page.goto(app_url("/"))
|
||||
|
||||
expect(page).to_have_title(re.compile("Swansea Airport - Arrivals & Departures"))
|
||||
expect(page.get_by_role("heading", name="Flight Information")).to_be_visible()
|
||||
|
||||
|
||||
def test_public_ppr_form_loads(page):
|
||||
page.goto(app_url("/ppr.html"))
|
||||
|
||||
expect(page).to_have_title(re.compile("Swansea PPR"))
|
||||
expect(page.get_by_role("heading", name=re.compile("PPR Request"))).to_be_visible()
|
||||
expect(page.get_by_role("button", name="Submit PPR Request")).to_be_visible()
|
||||
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
|
||||
BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/")
|
||||
|
||||
|
||||
def app_url(path):
|
||||
return f"{BASE_URL}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def test_public_ppr_form_submits_successfully(page):
|
||||
unique_registration = f"GE{datetime.utcnow().strftime('%H%M')}"
|
||||
|
||||
page.goto(app_url("/ppr.html"))
|
||||
|
||||
page.locator("#ac_reg").fill(unique_registration)
|
||||
page.locator("#ac_type").fill("PA28")
|
||||
page.locator("#ac_call").fill(unique_registration)
|
||||
page.locator("#captain").fill("E2E Test Pilot")
|
||||
page.locator("#in_from").fill("EGLL")
|
||||
page.locator("#eta-date").fill("2026-06-21")
|
||||
page.locator("#eta-time").select_option("10:00")
|
||||
page.locator("#pob_in").fill("2")
|
||||
page.locator("#fuel").select_option("100LL")
|
||||
page.locator("#out_to").fill("EGFF")
|
||||
page.locator("#etd-date").fill("2026-06-21")
|
||||
page.locator("#etd-time").select_option("12:00")
|
||||
page.locator("#pob_out").fill("2")
|
||||
page.locator("#phone").fill("0123456789")
|
||||
page.locator("#notes").fill("Submitted by Playwright e2e")
|
||||
|
||||
page.get_by_role("button", name="Submit PPR Request").click()
|
||||
|
||||
expect(page.locator("#success-message")).to_be_visible()
|
||||
expect(page.locator("#success-message")).to_contain_text("PPR Request Submitted")
|
||||
@@ -0,0 +1,24 @@
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
base_url = os.getenv("E2E_BASE_URL", "http://web").rstrip("/")
|
||||
health_url = os.getenv("E2E_HEALTH_URL", f"{base_url}/")
|
||||
deadline = time.time() + int(os.getenv("E2E_WEB_TIMEOUT_SECONDS", "120"))
|
||||
last_error = None
|
||||
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(health_url, timeout=5) as response:
|
||||
if response.status < 500:
|
||||
break
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
time.sleep(2)
|
||||
else:
|
||||
raise SystemExit(f"Timed out waiting for {health_url}: {last_error}")
|
||||
|
||||
with urllib.request.urlopen(f"{base_url}/", timeout=5) as response:
|
||||
if response.status >= 500:
|
||||
raise SystemExit(f"Web returned HTTP {response.status} at {base_url}/")
|
||||
+149
-52
@@ -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()
|
||||
? `<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'
|
||||
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>`)
|
||||
: (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) : '-';
|
||||
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
||||
TAKE OFF
|
||||
</button>
|
||||
`;
|
||||
if (flight.status === 'LANDED') {
|
||||
actionButtons = `
|
||||
<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 = `
|
||||
|
||||
+161
-65
@@ -232,7 +232,7 @@
|
||||
⚙️ Admin
|
||||
</button>
|
||||
<div class="dropdown-menu" id="adminDropdownMenu">
|
||||
<a href="#" onclick="window.location.href = '/admin'">🏠 Admin View</a>
|
||||
<a href="#" onclick="window.location.href = '/admin'">🏠 Home</a>
|
||||
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
|
||||
<a href="#" onclick="window.location.href = '/bulk-log'">🧾 Bulk Flight Log</a>
|
||||
<a href="#" onclick="window.location.href = '/journal'">📔 Journal Log</a>
|
||||
@@ -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) : '-';
|
||||
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
||||
TAKE OFF
|
||||
</button>
|
||||
`;
|
||||
if (flight.status === 'LANDED') {
|
||||
actionButtons = `
|
||||
<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 = `
|
||||
@@ -1358,21 +1404,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalDateString(date = new Date()) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function isTodayDateTime(value) {
|
||||
return Boolean(value) && value.split('T')[0] === getLocalDateString();
|
||||
}
|
||||
|
||||
function isTodayRecord(record, fields = ['created_dt']) {
|
||||
return fields.some(field => isTodayDateTime(record[field]));
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -1395,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;
|
||||
@@ -1405,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}')">
|
||||
@@ -1429,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'),
|
||||
@@ -1436,10 +1509,19 @@
|
||||
]);
|
||||
|
||||
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']);
|
||||
return true;
|
||||
});
|
||||
|
||||
displayLocalAircraft(locals);
|
||||
} catch (error) {
|
||||
@@ -1454,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;
|
||||
}
|
||||
|
||||
@@ -1463,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>
|
||||
@@ -1478,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}')">
|
||||
@@ -1507,9 +1592,13 @@
|
||||
const pprs = response[0].ok ? await response[0].json() : [];
|
||||
const arrivals = response[1].ok ? await response[1].json() : [];
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const today = getLocalDateString();
|
||||
let inbound = pprs.filter(p => p.status === 'CONFIRMED' && p.eta && p.eta.split('T')[0] === today);
|
||||
inbound = inbound.concat(arrivals.map(a => ({ ...a, isArrival: true })));
|
||||
inbound = inbound.concat(
|
||||
arrivals
|
||||
.filter(a => isTodayRecord(a, ['created_dt', 'eta']))
|
||||
.map(a => ({ ...a, isArrival: true }))
|
||||
);
|
||||
|
||||
displayInboundAircraft(inbound);
|
||||
} catch (error) {
|
||||
@@ -1565,6 +1654,11 @@
|
||||
if (response[0].ok) circuits = circuits.concat(await response[0].json());
|
||||
if (response[1].ok) circuits = circuits.concat((await response[1].json()).map(l => ({ ...l, circuitStatus: getCircuitStatus(l.status) })));
|
||||
if (response[2].ok) circuits = circuits.concat((await response[2].json()).map(a => ({ ...a, isArrival: true, circuitStatus: getCircuitStatus(a.status) })));
|
||||
circuits = circuits.filter(ac => (
|
||||
ac.isArrival
|
||||
? isTodayRecord(ac, ['created_dt', 'eta'])
|
||||
: isTodayRecord(ac, ['created_dt', 'etd', 'takeoff_dt', 'departed_dt'])
|
||||
));
|
||||
|
||||
displayCircuitAircraft(circuits);
|
||||
} catch (error) {
|
||||
@@ -1659,7 +1753,12 @@
|
||||
const response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
|
||||
const pprs = response.ok ? await response.json() : [];
|
||||
|
||||
const pending = pprs.filter(p => p.status === 'NEW' || p.status === 'CONFIRMED');
|
||||
const today = getLocalDateString();
|
||||
const pending = pprs.filter(p => (
|
||||
(p.status === 'NEW' || p.status === 'CONFIRMED') &&
|
||||
p.eta &&
|
||||
p.eta.split('T')[0] === today
|
||||
));
|
||||
displayPendingPPRs(pending);
|
||||
} catch (error) {
|
||||
console.error('Error loading pending PPRs:', error);
|
||||
@@ -1724,18 +1823,15 @@
|
||||
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
|
||||
|
||||
// Filter for today's bookings
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const bookedOutAircraft = [
|
||||
...localBookedOut.filter(flight => {
|
||||
const createdDate = flight.created_dt.split('T')[0];
|
||||
return createdDate === today;
|
||||
return isTodayRecord(flight, ['created_dt', 'etd']);
|
||||
}).map(flight => ({
|
||||
...flight,
|
||||
isLocalFlight: true
|
||||
})),
|
||||
...depBookedOut.filter(flight => {
|
||||
const createdDate = flight.created_dt.split('T')[0];
|
||||
return createdDate === today;
|
||||
return isTodayRecord(flight, ['created_dt', 'etd']);
|
||||
}).map(flight => ({
|
||||
...flight,
|
||||
isDeparture: true
|
||||
|
||||
+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'];
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
.status-NEW { background: #3498db; }
|
||||
.status-APPROVED { background: #27ae60; }
|
||||
.status-DENIED { background: #c0392b; }
|
||||
.status-PENDING { background: #f39c12; }
|
||||
.status-CANCELED { background: #7f8c8d; }
|
||||
.status-INFLIGHT { background: #8e44ad; }
|
||||
.status-COMPLETED { background: #2c3e50; }
|
||||
@@ -150,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>
|
||||
@@ -229,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);
|
||||
@@ -237,7 +236,7 @@
|
||||
setValue('notes', request.notes);
|
||||
setValue('operator_comments', request.operator_comments);
|
||||
|
||||
const locked = !['NEW', 'PENDING', 'APPROVED'].includes(request.status);
|
||||
const locked = !['NEW', 'APPROVED'].includes(request.status);
|
||||
document.getElementById('locked').style.display = locked ? 'block' : 'none';
|
||||
document.getElementById('save-btn').disabled = locked;
|
||||
document.getElementById('cancel-btn').disabled = locked;
|
||||
@@ -261,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,
|
||||
@@ -316,7 +315,7 @@
|
||||
}
|
||||
|
||||
function fromLocalInputValue(value) {
|
||||
return new Date(value).toISOString();
|
||||
return `${value}:00Z`;
|
||||
}
|
||||
|
||||
function showMessage(message, isError = false, clear = false) {
|
||||
|
||||
+466
-110
@@ -9,27 +9,57 @@
|
||||
<style>
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.95fr) minmax(520px, 1.35fr);
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.workspace.has-selection:not(.queue-open) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace.has-selection:not(.queue-open) .request-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace.has-selection:not(.queue-open) .detail-shell {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: none;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.toolbar h2 {
|
||||
font-size: 1.55rem;
|
||||
margin: 0 0 0.15rem;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-row select {
|
||||
width: 210px;
|
||||
}
|
||||
|
||||
.filter-row .btn {
|
||||
padding: 0.58rem 0.9rem;
|
||||
}
|
||||
|
||||
.request-list {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
@@ -37,16 +67,49 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace:not(.has-selection) .queue-header .queue-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace .table-header {
|
||||
padding: 0.72rem 0.85rem;
|
||||
}
|
||||
|
||||
.queue-header,
|
||||
.workbench-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.queue-toggle {
|
||||
border: 1px solid #cfd8e3;
|
||||
border-radius: 5px;
|
||||
background: white;
|
||||
color: #263645;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
padding: 0.42rem 0.65rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.queue-toggle:hover {
|
||||
background: #eef6fb;
|
||||
}
|
||||
|
||||
.request-list-body {
|
||||
max-height: calc(100vh - 210px);
|
||||
max-height: calc(100vh - 255px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.request-row {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.5rem;
|
||||
grid-template-columns: minmax(150px, 0.8fr) minmax(200px, 1.1fr) minmax(150px, 0.85fr) auto;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 0;
|
||||
@@ -60,6 +123,17 @@
|
||||
background: #eef6fb;
|
||||
}
|
||||
|
||||
.request-group {
|
||||
background: #f4f7f9;
|
||||
border-bottom: 1px solid #dfe6ed;
|
||||
color: #34495e;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 0.55rem 1rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.request-ref {
|
||||
font-weight: 700;
|
||||
color: #263645;
|
||||
@@ -80,26 +154,69 @@
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
min-width: 86px;
|
||||
min-width: 76px;
|
||||
padding: 0.25rem 0.55rem;
|
||||
}
|
||||
|
||||
.status-NEW { background: #3498db; }
|
||||
.status-APPROVED { background: #27ae60; }
|
||||
.status-DENIED { background: #c0392b; }
|
||||
.status-PENDING { background: #f39c12; }
|
||||
.status-CANCELED { background: #7f8c8d; }
|
||||
.status-INFLIGHT { background: #8e44ad; }
|
||||
.status-COMPLETED { background: #2c3e50; }
|
||||
|
||||
.detail-shell {
|
||||
display: none;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.workbench-bar {
|
||||
align-items: center;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(360px, 52vh) auto;
|
||||
gap: 1rem;
|
||||
gap: 0.8rem;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(360px, 1.45fr) auto;
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
|
||||
.workbench-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workbench-title strong {
|
||||
color: #263645;
|
||||
display: block;
|
||||
font-size: 1.05rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workbench-title > span {
|
||||
color: #607080;
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workbench-title .status-pill {
|
||||
margin-left: 0.45rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.selected-overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 0.9fr) minmax(360px, 1.1fr);
|
||||
gap: 0.85rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.detail-panel,
|
||||
.action-panel {
|
||||
.journal-panel {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
@@ -107,25 +224,26 @@
|
||||
}
|
||||
|
||||
.detail-body,
|
||||
.action-body {
|
||||
padding: 1rem;
|
||||
.journal-body {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.6rem 0.8rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: #607080;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
color: #263645;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.15rem;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -136,24 +254,35 @@
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
min-height: 360px;
|
||||
min-height: calc(100vh - 335px);
|
||||
}
|
||||
|
||||
#request-map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 360px;
|
||||
min-height: calc(100vh - 335px);
|
||||
}
|
||||
|
||||
.action-row {
|
||||
.request-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 220px) 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
grid-template-columns: minmax(130px, 170px) auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.request-actions .btn {
|
||||
padding: 0.5rem 0.7rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
border-top: 1px solid #eef1f4;
|
||||
margin-top: 0.85rem;
|
||||
padding-top: 0.85rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 86px;
|
||||
min-height: 42px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@@ -168,14 +297,13 @@
|
||||
}
|
||||
|
||||
.journal {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid #e6e9ec;
|
||||
padding-top: 0.75rem;
|
||||
max-height: 170px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.journal-entry {
|
||||
border-bottom: 1px solid #eef1f4;
|
||||
padding: 0.55rem 0;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -197,16 +325,38 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
@media (max-width: 780px) {
|
||||
.workspace,
|
||||
.detail-grid,
|
||||
.action-row {
|
||||
.request-row,
|
||||
.selected-overview,
|
||||
.workbench-bar,
|
||||
.request-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace.has-selection:not(.queue-open) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.map-panel,
|
||||
#request-map {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.request-list-body {
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script src="topbar.js"></script>
|
||||
@@ -233,59 +383,51 @@
|
||||
<div class="detail-meta">Requests from the public drone flight form</div>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<select id="status-filter" aria-label="Status filter">
|
||||
<option value="">All statuses</option>
|
||||
<option value="NEW">New</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="APPROVED">Approved</option>
|
||||
<option value="DENIED">Denied</option>
|
||||
<option value="CANCELED">Canceled</option>
|
||||
<option value="INFLIGHT">Inflight</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<select id="request-view-filter" aria-label="Request view">
|
||||
<option value="active">Active queue</option>
|
||||
<option value="older">Earlier dates</option>
|
||||
<option value="closed">Denied / canceled</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" onclick="loadRequests()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workspace">
|
||||
<div id="workspace" class="workspace">
|
||||
<section class="request-list">
|
||||
<div class="table-header">Request Queue - <span id="request-count">0</span></div>
|
||||
<div class="table-header queue-header">
|
||||
<span>Request Queue - <span id="request-count">0</span></span>
|
||||
<button class="queue-toggle" type="button" onclick="toggleQueue(false)">Hide</button>
|
||||
</div>
|
||||
<div id="request-list-body" class="request-list-body">
|
||||
<div class="empty-state">Loading requests...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-shell">
|
||||
<div class="detail-panel">
|
||||
<div class="table-header">Selected Request</div>
|
||||
<div id="detail-body" class="detail-body">
|
||||
<div class="empty-state">Select a request to view its details.</div>
|
||||
<div class="workbench-bar">
|
||||
<div id="workbench-title" class="workbench-title">
|
||||
<strong>No request selected</strong>
|
||||
<span>Select a request from the queue.</span>
|
||||
</div>
|
||||
<div id="workbench-actions" class="request-actions"></div>
|
||||
<button id="queue-toggle-main" class="queue-toggle" type="button" onclick="toggleQueue()">Queue</button>
|
||||
</div>
|
||||
|
||||
<div class="selected-overview">
|
||||
<div class="detail-panel">
|
||||
<div id="detail-body" class="detail-body">
|
||||
<div class="empty-state">Select a request to view its details.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-panel">
|
||||
<div id="request-map"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-panel">
|
||||
<div id="request-map"></div>
|
||||
</div>
|
||||
|
||||
<div class="action-panel">
|
||||
<div class="table-header">Lifecycle</div>
|
||||
<div class="action-body">
|
||||
<div class="action-row">
|
||||
<select id="status-select" aria-label="New status">
|
||||
<option value="NEW">NEW</option>
|
||||
<option value="PENDING">PENDING</option>
|
||||
<option value="APPROVED">APPROVED</option>
|
||||
<option value="DENIED">DENIED</option>
|
||||
<option value="CANCELED">CANCELED</option>
|
||||
<option value="INFLIGHT">INFLIGHT</option>
|
||||
<option value="COMPLETED">COMPLETED</option>
|
||||
</select>
|
||||
<textarea id="operator-comment" placeholder="Comment or request for more information"></textarea>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<button class="btn btn-success" onclick="saveStatus()">Set Status</button>
|
||||
<button class="btn btn-info" onclick="sendComment()">Send Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="journal-panel">
|
||||
<div class="table-header">Journal History</div>
|
||||
<div class="journal-body">
|
||||
<div id="journal" class="journal"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,19 +459,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messageModal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 520px;">
|
||||
<div class="modal-header">
|
||||
<h2>Send Message</h2>
|
||||
<button class="close" onclick="closeMessageModal()">×</button>
|
||||
</div>
|
||||
<form id="message-form">
|
||||
<div class="form-group">
|
||||
<label for="message-comment">Message to operator</label>
|
||||
<textarea id="message-comment" required placeholder="Type the message to email to the operator"></textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeMessageModal()">Cancel</button>
|
||||
<button id="message-send-btn" type="submit" class="btn btn-info">Send Message</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
let accessToken = null;
|
||||
let currentUser = null;
|
||||
let selectedRequest = null;
|
||||
let requests = [];
|
||||
let requestGroups = [];
|
||||
let map = null;
|
||||
let mapLayers = [];
|
||||
let frzGeometry = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||
document.getElementById('status-filter').addEventListener('change', loadRequests);
|
||||
document.getElementById('message-form').addEventListener('submit', sendMessageFromModal);
|
||||
document.getElementById('request-view-filter').addEventListener('change', () => {
|
||||
clearSelectedRequest();
|
||||
loadRequests();
|
||||
});
|
||||
initializeAuth();
|
||||
initializeMap();
|
||||
connectWebSocket();
|
||||
@@ -406,6 +572,9 @@
|
||||
currentUser = username;
|
||||
document.getElementById('current-user').textContent = username;
|
||||
hideLogin();
|
||||
if (typeof window.refreshDroneRequestBadge === 'function') {
|
||||
window.refreshDroneRequestBadge();
|
||||
}
|
||||
loadRequests();
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
@@ -422,6 +591,7 @@
|
||||
accessToken = null;
|
||||
currentUser = null;
|
||||
selectedRequest = null;
|
||||
setLifecycleControlsEnabled(false);
|
||||
showLogin();
|
||||
}
|
||||
|
||||
@@ -446,72 +616,186 @@
|
||||
|
||||
async function loadRequests() {
|
||||
if (!accessToken) return;
|
||||
const status = document.getElementById('status-filter').value;
|
||||
const url = status ? `/api/v1/drone-requests/?status=${encodeURIComponent(status)}` : '/api/v1/drone-requests/';
|
||||
const view = document.getElementById('request-view-filter').value;
|
||||
const body = document.getElementById('request-list-body');
|
||||
body.innerHTML = '<div class="empty-state">Loading requests...</div>';
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(url);
|
||||
if (!response.ok) throw new Error('Failed to load drone requests');
|
||||
requests = await response.json();
|
||||
requestGroups = await loadRequestGroups(view);
|
||||
requests = requestGroups.flatMap(group => group.items);
|
||||
renderRequestList();
|
||||
if (selectedRequest) {
|
||||
const fresh = requests.find(r => r.id === selectedRequest.id);
|
||||
if (fresh) selectRequest(fresh.id);
|
||||
if (fresh) {
|
||||
const keepQueueOpen = document.getElementById('workspace')?.classList.contains('queue-open');
|
||||
selectRequest(fresh.id, !keepQueueOpen);
|
||||
} else {
|
||||
clearSelectedRequest();
|
||||
}
|
||||
} else {
|
||||
setQueueOpen(true);
|
||||
updateWorkbenchTitle();
|
||||
}
|
||||
} catch (err) {
|
||||
body.innerHTML = `<div class="empty-state">${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRequestGroups(view) {
|
||||
const today = getLocalDateString(new Date());
|
||||
const yesterday = getLocalDateString(addDays(new Date(), -1));
|
||||
|
||||
if (view === 'older') {
|
||||
const older = await fetchDroneRequests({ date_to: yesterday });
|
||||
return [{ label: 'Earlier dated requests', items: older }];
|
||||
}
|
||||
|
||||
if (view === 'closed') {
|
||||
const [denied, canceled] = await Promise.all([
|
||||
fetchDroneRequests({ status: 'DENIED' }),
|
||||
fetchDroneRequests({ status: 'CANCELED' }),
|
||||
]);
|
||||
return [
|
||||
{ label: 'Denied requests', items: denied },
|
||||
{ label: 'Canceled requests', items: canceled },
|
||||
];
|
||||
}
|
||||
|
||||
const [newRequests, approvedToday, approvedUpcoming] = await Promise.all([
|
||||
fetchDroneRequests({ status: 'NEW' }),
|
||||
fetchDroneRequests({ status: 'APPROVED', date_from: today, date_to: today }),
|
||||
fetchDroneRequests({ status: 'APPROVED', date_from: getLocalDateString(addDays(new Date(), 1)) }),
|
||||
]);
|
||||
return [
|
||||
{ label: 'New requests', items: newRequests },
|
||||
{ label: "Today's approved flights", items: approvedToday },
|
||||
{ label: 'Upcoming approved flights', items: approvedUpcoming },
|
||||
];
|
||||
}
|
||||
|
||||
async function fetchDroneRequests(params) {
|
||||
const search = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) search.set(key, value);
|
||||
});
|
||||
const url = `/api/v1/drone-requests/${search.toString() ? `?${search.toString()}` : ''}`;
|
||||
const response = await authenticatedFetch(url);
|
||||
if (!response.ok) throw new Error('Failed to load drone requests');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
const next = new Date(date);
|
||||
next.setUTCDate(next.getUTCDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function getLocalDateString(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function renderRequestList() {
|
||||
document.getElementById('request-count').textContent = requests.length;
|
||||
setQueueOpen(!selectedRequest || document.getElementById('workspace').classList.contains('queue-open'));
|
||||
const body = document.getElementById('request-list-body');
|
||||
if (!requests.length) {
|
||||
if (!requestGroups.length) {
|
||||
body.innerHTML = '<div class="empty-state">No requests match the current filter.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = requests.map(req => `
|
||||
<button class="request-row ${selectedRequest && selectedRequest.id === req.id ? 'active' : ''}" onclick="selectRequest(${req.id})">
|
||||
<div>
|
||||
<div class="request-ref">${escapeHtml(req.reference_number)}</div>
|
||||
<div class="request-meta">${escapeHtml(req.operator_name)} · ${formatDateTime(req.estimated_takeoff_at)}</div>
|
||||
<div class="request-meta">${escapeHtml(req.location_description || `${req.location_latitude}, ${req.location_longitude}`)}</div>
|
||||
</div>
|
||||
<span class="status-pill status-${req.status}">${req.status}</span>
|
||||
</button>
|
||||
body.innerHTML = requestGroups.map(group => `
|
||||
<div class="request-group">${escapeHtml(group.label)} - ${group.items.length}</div>
|
||||
${group.items.length ? group.items.map(renderRequestRow).join('') : '<div class="empty-state">None</div>'}
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function selectRequest(id) {
|
||||
function renderRequestRow(req) {
|
||||
return `
|
||||
<button class="request-row ${selectedRequest && selectedRequest.id === req.id ? 'active' : ''}" onclick="selectRequest(${req.id})">
|
||||
<div>
|
||||
<div class="request-ref">${escapeHtml(req.reference_number)}</div>
|
||||
<div class="request-meta">${escapeHtml(req.operator_name)}</div>
|
||||
</div>
|
||||
<div class="request-meta">${escapeHtml(req.location_description || `${req.location_latitude}, ${req.location_longitude}`)}</div>
|
||||
<div class="request-meta">
|
||||
<strong>${formatDateTime(req.estimated_takeoff_at)}</strong><br>
|
||||
${formatDateTime(req.estimated_completion_at)}
|
||||
</div>
|
||||
<span class="status-pill status-${req.status}">${req.status}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
async function selectRequest(id, collapseQueue = true) {
|
||||
selectedRequest = requests.find(req => req.id === id);
|
||||
if (!selectedRequest) return;
|
||||
setQueueOpen(!collapseQueue);
|
||||
setLifecycleControlsEnabled(true);
|
||||
updateWorkbenchTitle();
|
||||
renderRequestList();
|
||||
renderDetails();
|
||||
renderMap();
|
||||
await loadJournal();
|
||||
}
|
||||
|
||||
function clearSelectedRequest() {
|
||||
selectedRequest = null;
|
||||
setQueueOpen(true);
|
||||
setLifecycleControlsEnabled(false);
|
||||
updateWorkbenchTitle();
|
||||
document.getElementById('detail-body').innerHTML = '<div class="empty-state">Select a request to view its details.</div>';
|
||||
document.getElementById('journal').innerHTML = '';
|
||||
renderRequestList();
|
||||
renderMap();
|
||||
}
|
||||
|
||||
function toggleQueue(forceOpen = null) {
|
||||
const workspace = document.getElementById('workspace');
|
||||
if (!workspace) return;
|
||||
const open = forceOpen === null ? !workspace.classList.contains('queue-open') : forceOpen;
|
||||
setQueueOpen(open);
|
||||
}
|
||||
|
||||
function setQueueOpen(open) {
|
||||
const workspace = document.getElementById('workspace');
|
||||
if (!workspace) return;
|
||||
workspace.classList.toggle('has-selection', Boolean(selectedRequest));
|
||||
workspace.classList.toggle('queue-open', open || !selectedRequest);
|
||||
const button = document.getElementById('queue-toggle-main');
|
||||
if (button) {
|
||||
button.textContent = workspace.classList.contains('queue-open')
|
||||
? 'Hide Queue'
|
||||
: `Queue (${requests.length})`;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (map) map.invalidateSize();
|
||||
}, 240);
|
||||
}
|
||||
|
||||
function updateWorkbenchTitle() {
|
||||
const title = document.getElementById('workbench-title');
|
||||
if (!title) return;
|
||||
if (!selectedRequest) {
|
||||
title.innerHTML = '<strong>No request selected</strong><span>Select a request from the queue.</span>';
|
||||
renderWorkbenchActions();
|
||||
return;
|
||||
}
|
||||
title.innerHTML = `
|
||||
<strong>${escapeHtml(selectedRequest.reference_number)} · ${escapeHtml(selectedRequest.operator_name)} <span class="status-pill status-${selectedRequest.status}">${selectedRequest.status}</span></strong>
|
||||
<span>${escapeHtml(selectedRequest.location_description || `${selectedRequest.location_latitude}, ${selectedRequest.location_longitude}`)} · ${formatDateTime(selectedRequest.estimated_takeoff_at)}</span>
|
||||
`;
|
||||
renderWorkbenchActions();
|
||||
}
|
||||
|
||||
function renderDetails() {
|
||||
document.getElementById('status-select').value = selectedRequest.status;
|
||||
document.getElementById('operator-comment').value = selectedRequest.operator_comments || '';
|
||||
document.getElementById('detail-body').innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; gap: 1rem; align-items: start; margin-bottom: 1rem;">
|
||||
<div>
|
||||
<h2>${escapeHtml(selectedRequest.reference_number)}</h2>
|
||||
<div class="detail-meta">${escapeHtml(selectedRequest.operator_name)} · ${escapeHtml(selectedRequest.email)} · ${escapeHtml(selectedRequest.phone || '-')}</div>
|
||||
</div>
|
||||
<span class="status-pill status-${selectedRequest.status}">${selectedRequest.status}</span>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
${field('Operator ID', selectedRequest.operator_id)}
|
||||
${field('Flyer', selectedRequest.flyer_name)}
|
||||
${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)}
|
||||
@@ -519,13 +803,44 @@
|
||||
${field('Applicant Notes', selectedRequest.notes || '-')}
|
||||
${field('Operator Comments', selectedRequest.operator_comments || '-')}
|
||||
</div>
|
||||
<div class="detail-actions">
|
||||
<button class="btn btn-info" onclick="openMessageModal()">Send Message</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderWorkbenchActions() {
|
||||
const actions = document.getElementById('workbench-actions');
|
||||
if (!actions) return;
|
||||
if (!selectedRequest) {
|
||||
actions.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
actions.innerHTML = `
|
||||
<select id="status-select" class="lifecycle-control" aria-label="New status">
|
||||
<option value="NEW">NEW</option>
|
||||
<option value="APPROVED">APPROVED</option>
|
||||
<option value="DENIED">DENIED</option>
|
||||
<option value="CANCELED">CANCELED</option>
|
||||
<option value="INFLIGHT">INFLIGHT</option>
|
||||
<option value="COMPLETED">COMPLETED</option>
|
||||
</select>
|
||||
<button class="btn btn-success lifecycle-control" onclick="saveStatus()">Set</button>
|
||||
`;
|
||||
document.getElementById('status-select').value = selectedRequest.status;
|
||||
}
|
||||
|
||||
function field(label, value) {
|
||||
return `<div><div class="field-label">${escapeHtml(label)}</div><div class="field-value">${escapeHtml(value == null ? '-' : String(value))}</div></div>`;
|
||||
}
|
||||
|
||||
function setLifecycleControlsEnabled(enabled) {
|
||||
document.querySelectorAll('.lifecycle-control').forEach(control => {
|
||||
control.disabled = !enabled;
|
||||
});
|
||||
}
|
||||
|
||||
function renderMap() {
|
||||
clearMapLayers();
|
||||
if (!map) return;
|
||||
@@ -547,14 +862,28 @@
|
||||
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
|
||||
`));
|
||||
}
|
||||
|
||||
if (!mapLayers.length) return;
|
||||
const group = L.featureGroup(mapLayers);
|
||||
map.fitBounds(group.getBounds().pad(0.18));
|
||||
setTimeout(() => map.invalidateSize(), 50);
|
||||
fitMapToLayers();
|
||||
}
|
||||
|
||||
function fitMapToLayers() {
|
||||
const mapEl = document.getElementById('request-map');
|
||||
if (!map || !mapEl || !mapLayers.length) return;
|
||||
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
if (!mapEl.offsetWidth || !mapEl.offsetHeight) return;
|
||||
|
||||
const group = L.featureGroup(mapLayers);
|
||||
const bounds = group.getBounds();
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds.pad(0.18));
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function addLayer(layer) {
|
||||
@@ -569,29 +898,46 @@
|
||||
async function saveStatus() {
|
||||
if (!selectedRequest) return showNotification('Select a request first', true);
|
||||
const status = document.getElementById('status-select').value;
|
||||
const comment = document.getElementById('operator-comment').value.trim();
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/v1/drone-requests/${selectedRequest.id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, comment: comment || null })
|
||||
body: JSON.stringify({ status, comment: null })
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update status');
|
||||
selectedRequest = await response.json();
|
||||
showNotification('Status updated');
|
||||
await loadRequests();
|
||||
renderDetails();
|
||||
await loadJournal();
|
||||
if (selectedRequest) {
|
||||
renderDetails();
|
||||
await loadJournal();
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendComment() {
|
||||
function openMessageModal() {
|
||||
if (!selectedRequest) return showNotification('Select a request first', true);
|
||||
const comment = document.getElementById('operator-comment').value.trim();
|
||||
document.getElementById('message-comment').value = '';
|
||||
document.getElementById('messageModal').style.display = 'block';
|
||||
document.getElementById('message-comment').focus();
|
||||
}
|
||||
|
||||
function closeMessageModal() {
|
||||
document.getElementById('messageModal').style.display = 'none';
|
||||
document.getElementById('message-form').reset();
|
||||
}
|
||||
|
||||
async function sendMessageFromModal(event) {
|
||||
event.preventDefault();
|
||||
if (!selectedRequest) return showNotification('Select a request first', true);
|
||||
|
||||
const comment = document.getElementById('message-comment').value.trim();
|
||||
if (!comment) return showNotification('Enter a comment first', true);
|
||||
const button = document.getElementById('message-send-btn');
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/v1/drone-requests/${selectedRequest.id}/comments`, {
|
||||
@@ -601,12 +947,17 @@
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to send comment');
|
||||
selectedRequest = await response.json();
|
||||
closeMessageModal();
|
||||
showNotification('Comment sent');
|
||||
await loadRequests();
|
||||
renderDetails();
|
||||
await loadJournal();
|
||||
if (selectedRequest) {
|
||||
renderDetails();
|
||||
await loadJournal();
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification(err.message, true);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,7 +989,12 @@
|
||||
if (event.data.startsWith('Heartbeat:')) return;
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type && data.type.startsWith('drone_request_')) loadRequests();
|
||||
if (data.type && data.type.startsWith('drone_request_')) {
|
||||
loadRequests();
|
||||
if (typeof window.refreshDroneRequestBadge === 'function') {
|
||||
window.refreshDroneRequestBadge();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('WebSocket parse failed', err);
|
||||
}
|
||||
|
||||
+29
-12
@@ -437,12 +437,12 @@
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const etaDate = new Date(utcDateStr);
|
||||
const etaDateStr = etaDate.toISOString().split('T')[0];
|
||||
const etaTimeStr = etaDate.toISOString().slice(11, 16);
|
||||
const etaDateStr = formatLocalDateInput(etaDate);
|
||||
const etaTimeStr = formatLocalTimeInput(etaDate);
|
||||
document.getElementById('eta-date').value = etaDateStr;
|
||||
document.getElementById('eta-time').value = etaTimeStr;
|
||||
}
|
||||
@@ -457,12 +457,12 @@
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const etdDate = new Date(utcDateStr);
|
||||
const etdDateStr = etdDate.toISOString().split('T')[0];
|
||||
const etdTimeStr = etdDate.toISOString().slice(11, 16);
|
||||
const etdDateStr = formatLocalDateInput(etdDate);
|
||||
const etdTimeStr = formatLocalTimeInput(etdDate);
|
||||
document.getElementById('etd-date').value = etdDateStr;
|
||||
document.getElementById('etd-time').value = etdTimeStr;
|
||||
}
|
||||
@@ -471,15 +471,35 @@
|
||||
document.getElementById('email').value = ppr.email || '';
|
||||
document.getElementById('phone').value = ppr.phone || '';
|
||||
document.getElementById('notes').value = ppr.notes || '';
|
||||
|
||||
if (['CANCELED', 'DELETED', 'LANDED', '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 = {};
|
||||
|
||||
@@ -792,4 +809,4 @@
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+14
-8
@@ -386,10 +386,12 @@
|
||||
<body>
|
||||
<header>
|
||||
<img src="assets/logo.png" alt="EGFH Logo" class="left-image">
|
||||
<!-- Booking QR hidden until the Book Out flow is ready.
|
||||
<div class="qr-code-container">
|
||||
<img id="bookingQR" alt="Scan to book a flight" title="Scan to book a flight">
|
||||
<div class="qr-label">Book Out</div>
|
||||
</div>
|
||||
-->
|
||||
<h1>Flight Information</h1>
|
||||
<img src="assets/flightImg.png" alt="EGFH Logo" class="right-image">
|
||||
</header>
|
||||
@@ -629,8 +631,15 @@
|
||||
if (!utcDateTimeString) return '';
|
||||
|
||||
try {
|
||||
// Parse the ISO datetime string
|
||||
const date = new Date(utcDateTimeString);
|
||||
// API datetimes are UTC, but DB-backed values may arrive without a timezone suffix.
|
||||
let normalizedDateTime = String(utcDateTimeString).trim();
|
||||
if (!normalizedDateTime.includes('T')) {
|
||||
normalizedDateTime = normalizedDateTime.replace(' ', 'T');
|
||||
}
|
||||
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(normalizedDateTime)) {
|
||||
normalizedDateTime += 'Z';
|
||||
}
|
||||
const date = new Date(normalizedDateTime);
|
||||
|
||||
// Check if valid date
|
||||
if (isNaN(date.getTime())) {
|
||||
@@ -820,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;
|
||||
@@ -893,9 +902,6 @@
|
||||
// Initialize Christmas mode
|
||||
initChristmasMode();
|
||||
|
||||
// Load booking QR code
|
||||
generateBookingQR();
|
||||
|
||||
loadArrivals();
|
||||
loadDepartures();
|
||||
|
||||
|
||||
+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 = '';
|
||||
|
||||
+393
-89
@@ -155,6 +155,14 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickable-row:hover {
|
||||
background-color: #eef6ff;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
background: #34495e;
|
||||
color: white;
|
||||
@@ -311,6 +319,98 @@
|
||||
background-color: #e74c3c;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
margin: 4% auto;
|
||||
width: min(920px, calc(100% - 2rem));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background: #34495e;
|
||||
color: white;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.8rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-field {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 0.65rem;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
color: #667085;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.journal-section {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.journal-entry {
|
||||
border-left: 3px solid #3498db;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f8fafc;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.journal-meta {
|
||||
font-size: 0.78rem;
|
||||
color: #667085;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
@@ -474,7 +574,7 @@
|
||||
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Departures</div>
|
||||
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-departures">0</div>
|
||||
</div>
|
||||
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('OVERFLIGHT')">
|
||||
<div class="summary-item" style="padding: 0.4rem;">
|
||||
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Overflights</div>
|
||||
<div class="summary-item-value" style="font-size: 1.1rem;" id="overflights-count">0</div>
|
||||
</div>
|
||||
@@ -524,14 +624,12 @@
|
||||
<th>Callsign</th>
|
||||
<th>Captain</th>
|
||||
<th>From</th>
|
||||
<th>ETA</th>
|
||||
<th>POB In</th>
|
||||
<th>To</th>
|
||||
<th>ETD</th>
|
||||
<th>Takeoff</th>
|
||||
<th>Landing</th>
|
||||
<th>POB In</th>
|
||||
<th>POB Out</th>
|
||||
<th>Fuel</th>
|
||||
<th>Landed</th>
|
||||
<th>Departed</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Notes</th>
|
||||
@@ -582,8 +680,8 @@
|
||||
<th>Callsign</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>ETA / ETD / Called</th>
|
||||
<th>Landed / Departed / QSY</th>
|
||||
<th>Takeoff</th>
|
||||
<th>Landing</th>
|
||||
<th>Circuits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -600,6 +698,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="reportDetailModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="report-detail-title">Details</h2>
|
||||
<button class="close" onclick="closeReportDetailModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="report-detail-body" class="detail-grid"></div>
|
||||
<div class="journal-section">
|
||||
<h3 style="margin: 0 0 0.5rem 0;">Journal</h3>
|
||||
<div id="report-detail-journal">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Notification -->
|
||||
<div id="notification" class="notification"></div>
|
||||
|
||||
@@ -699,10 +813,10 @@
|
||||
// Set date range to this week (Monday to Sunday)
|
||||
function setDateRangeThisWeek() {
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
|
||||
const monday = new Date(now.setDate(diff));
|
||||
const sunday = new Date(now.setDate(diff + 6));
|
||||
const dayOfWeek = now.getUTCDay();
|
||||
const diff = now.getUTCDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
|
||||
const monday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), diff));
|
||||
const sunday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), diff + 6));
|
||||
|
||||
document.getElementById('date-from').value = monday.toISOString().split('T')[0];
|
||||
document.getElementById('date-to').value = sunday.toISOString().split('T')[0];
|
||||
@@ -717,8 +831,8 @@
|
||||
// Set date range to this month
|
||||
function setDateRangeThisMonth() {
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
const firstDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
const lastDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0));
|
||||
|
||||
document.getElementById('date-from').value = firstDay.toISOString().split('T')[0];
|
||||
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
|
||||
@@ -871,9 +985,12 @@
|
||||
const arrivals = await arrivalsResponse.json();
|
||||
otherFlights.push(...arrivals.map(f => ({
|
||||
...f,
|
||||
entityType: 'ARRIVAL',
|
||||
flightType: 'ARRIVAL',
|
||||
aircraft_type: f.type,
|
||||
timeField: f.eta || f.landed_dt,
|
||||
sortTime: f.landed_dt || f.eta || f.created_dt,
|
||||
takeoffTime: null,
|
||||
landingTime: f.landed_dt,
|
||||
fromField: f.in_from,
|
||||
toField: 'EGFH'
|
||||
})));
|
||||
@@ -883,9 +1000,12 @@
|
||||
const departures = await departuresResponse.json();
|
||||
otherFlights.push(...departures.map(f => ({
|
||||
...f,
|
||||
entityType: 'DEPARTURE',
|
||||
flightType: 'DEPARTURE',
|
||||
aircraft_type: f.type,
|
||||
timeField: f.etd || f.departed_dt,
|
||||
sortTime: f.takeoff_dt || f.departed_dt || f.etd || f.created_dt,
|
||||
takeoffTime: f.takeoff_dt || f.departed_dt,
|
||||
landingTime: null,
|
||||
fromField: 'EGFH',
|
||||
toField: f.out_to
|
||||
})));
|
||||
@@ -895,10 +1015,13 @@
|
||||
const localFlights = await localFlightsResponse.json();
|
||||
otherFlights.push(...localFlights.map(f => ({
|
||||
...f,
|
||||
entityType: 'LOCAL_FLIGHT',
|
||||
flightType: f.flight_type === 'CIRCUITS' ? 'CIRCUIT' : f.flight_type,
|
||||
aircraft_type: f.type,
|
||||
circuits: f.circuits,
|
||||
timeField: f.departed_dt,
|
||||
sortTime: f.takeoff_dt || f.departed_dt || f.landed_dt || f.etd || f.created_dt,
|
||||
takeoffTime: f.takeoff_dt || f.departed_dt,
|
||||
landingTime: f.landed_dt,
|
||||
fromField: 'EGFH',
|
||||
toField: 'EGFH'
|
||||
})));
|
||||
@@ -908,10 +1031,13 @@
|
||||
const overflights = await overflightsResponse.json();
|
||||
otherFlights.push(...overflights.map(f => ({
|
||||
...f,
|
||||
entityType: 'OVERFLIGHT',
|
||||
flightType: 'OVERFLIGHT',
|
||||
aircraft_type: f.type,
|
||||
circuits: null,
|
||||
timeField: f.call_dt,
|
||||
sortTime: f.call_dt,
|
||||
takeoffTime: null,
|
||||
landingTime: null,
|
||||
fromField: f.departure_airfield,
|
||||
toField: f.destination_airfield,
|
||||
callsign: f.registration
|
||||
@@ -959,21 +1085,14 @@
|
||||
let dateRangeText = '';
|
||||
if (dateFrom && dateTo && dateFrom === dateTo) {
|
||||
// Single day
|
||||
const date = new Date(dateFrom + 'T00:00:00Z');
|
||||
dateRangeText = `for ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||
dateRangeText = `for ${formatDateOnly(dateFrom)}`;
|
||||
} else if (dateFrom && dateTo) {
|
||||
// Date range
|
||||
const fromDate = new Date(dateFrom + 'T00:00:00Z');
|
||||
const toDate = new Date(dateTo + 'T00:00:00Z');
|
||||
const fromText = fromDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const toText = toDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
dateRangeText = `for ${fromText} to ${toText}`;
|
||||
dateRangeText = `for ${formatDateOnly(dateFrom)} to ${formatDateOnly(dateTo)}`;
|
||||
} else if (dateFrom) {
|
||||
const date = new Date(dateFrom + 'T00:00:00Z');
|
||||
dateRangeText = `from ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||
dateRangeText = `from ${formatDateOnly(dateFrom)}`;
|
||||
} else if (dateTo) {
|
||||
const date = new Date(dateTo + 'T00:00:00Z');
|
||||
dateRangeText = `until ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||
dateRangeText = `until ${formatDateOnly(dateTo)}`;
|
||||
}
|
||||
|
||||
// Update summary title with date range
|
||||
@@ -1045,11 +1164,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by ETA (ascending)
|
||||
// Sort by first actual movement time, then planned times as a fallback.
|
||||
pprs.sort((a, b) => {
|
||||
if (!a.eta) return 1;
|
||||
if (!b.eta) return -1;
|
||||
return new Date(a.eta) - new Date(b.eta);
|
||||
const aTime = getPPRSortTime(a);
|
||||
const bTime = getPPRSortTime(b);
|
||||
if (!aTime) return 1;
|
||||
if (!bTime) return -1;
|
||||
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
@@ -1057,12 +1178,11 @@
|
||||
|
||||
for (const ppr of pprs) {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'clickable-row';
|
||||
row.onclick = () => openReportDetail('PPR', ppr.id);
|
||||
|
||||
// Format dates
|
||||
const eta = ppr.eta ? formatDateTime(ppr.eta) : '-';
|
||||
const etd = ppr.etd ? formatDateTime(ppr.etd) : '-';
|
||||
const landed = ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '-';
|
||||
const departed = ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '-';
|
||||
const takeoff = ppr.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);
|
||||
|
||||
// Format as dd/mm/yy hh:mm
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = String(date.getFullYear()).slice(-2);
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
return new Date(utcDateStr);
|
||||
}
|
||||
|
||||
function getPPRSortTime(ppr) {
|
||||
return ppr.landed_dt || ppr.qsy_dt || ppr.takeoff_dt || ppr.eta || ppr.etd || ppr.submitted_dt;
|
||||
}
|
||||
|
||||
const detailConfig = {
|
||||
PPR: {
|
||||
endpoint: id => `/api/v1/pprs/${id}`,
|
||||
journalType: 'PPR',
|
||||
title: record => `PPR: ${record.ac_reg || '-'}`,
|
||||
fields: [
|
||||
['Status', r => r.status],
|
||||
['Aircraft', r => r.ac_reg],
|
||||
['Type', r => r.ac_type],
|
||||
['Callsign', r => r.ac_call],
|
||||
['Captain', r => r.captain],
|
||||
['From', r => r.in_from],
|
||||
['To', r => r.out_to],
|
||||
['Takeoff', r => formatOptionalDateTime(r.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;">
|
||||
|
||||
+127
-62
@@ -128,6 +128,13 @@
|
||||
loadArrivals();
|
||||
showNotification('Arrival updated');
|
||||
}
|
||||
|
||||
if (data.type && data.type.startsWith('drone_request_')) {
|
||||
if (typeof window.refreshDroneRequestBadge === 'function') {
|
||||
window.refreshDroneRequestBadge();
|
||||
}
|
||||
showNotification('Drone request updated');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
@@ -513,6 +520,9 @@
|
||||
await updateUserRole(); // Update role-based UI
|
||||
startSessionExpiryCheck(); // Start monitoring session expiry
|
||||
connectWebSocket(); // Connect WebSocket for real-time updates
|
||||
if (typeof window.refreshDroneRequestBadge === 'function') {
|
||||
window.refreshDroneRequestBadge();
|
||||
}
|
||||
loadPPRs();
|
||||
} else {
|
||||
throw new Error(data.detail || 'Authentication failed');
|
||||
@@ -580,33 +590,104 @@
|
||||
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;
|
||||
function normalizeUtcDateString(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
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 utcDateStr;
|
||||
}
|
||||
|
||||
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 '-';
|
||||
// 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);
|
||||
const date = parseUtcDate(dateStr);
|
||||
return date && !Number.isNaN(date.getTime()) ? `${formatUtcDateInput(date)} ${formatUtcTimeInput(date)}` : '-';
|
||||
}
|
||||
|
||||
// Modal functions
|
||||
@@ -630,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();
|
||||
@@ -673,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;
|
||||
@@ -711,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') {
|
||||
@@ -723,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';
|
||||
@@ -744,31 +821,19 @@
|
||||
Object.keys(ppr).forEach(key => {
|
||||
if (key === 'eta' || key === 'etd') {
|
||||
if (ppr[key]) {
|
||||
// ppr[key] is UTC datetime string from API (naive, assume UTC)
|
||||
let utcDateStr = ppr[key];
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
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.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const dateValue = `${year}-${month}-${day}`;
|
||||
const dateValue = formatUtcDateInput(date);
|
||||
dateField.value = dateValue;
|
||||
|
||||
// Format time (round to nearest 15-minute interval)
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const rawMinutes = date.getMinutes();
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const rawMinutes = date.getUTCMinutes();
|
||||
const roundedMinutes = Math.round(rawMinutes / 15) * 15 % 60;
|
||||
const minutes = String(roundedMinutes).padStart(2, '0');
|
||||
const timeValue = `${hours}:${minutes}`;
|
||||
@@ -1079,12 +1144,12 @@
|
||||
// Combine date and time for ETA
|
||||
const dateStr = formData.get('eta-date');
|
||||
const timeStr = formData.get('eta-time');
|
||||
pprData.eta = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||||
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 = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||||
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;
|
||||
@@ -1846,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 = '';
|
||||
@@ -2603,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",
|
||||
|
||||
+70
-5
@@ -86,6 +86,21 @@
|
||||
.dropdown-menu a:first-child { border-radius: 5px 5px 0 0; }
|
||||
.dropdown-menu a:last-child { border-radius: 0 0 5px 5px; }
|
||||
.shortcut { font-size: 0.8rem; color: #999; margin-left: 1rem; }
|
||||
.notification-badge {
|
||||
min-width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
padding: 0 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.notification-badge[hidden] { display: none; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -97,8 +112,9 @@
|
||||
return `<a href="#" data-topbar-action="${action}"${idAttr}${hidden}>${label} ${shortcutText}</a>`;
|
||||
}
|
||||
|
||||
function navLink(label, href) {
|
||||
return `<a href="${href}">${label}</a>`;
|
||||
function navLink(label, href, id = '') {
|
||||
const idAttr = id ? ` id="${id}"` : '';
|
||||
return `<a href="${href}"${idAttr}>${label}</a>`;
|
||||
}
|
||||
|
||||
function normalizeTopbar() {
|
||||
@@ -138,12 +154,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-warning dropdown-toggle" id="adminDropdownBtn">⚙️ Menu</button>
|
||||
<button class="btn btn-warning dropdown-toggle" id="adminDropdownBtn">
|
||||
⚙️ Menu <span class="notification-badge" id="drone-request-menu-badge" hidden>0</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" id="adminDropdownMenu">
|
||||
${navLink('🏠 Admin View', '/admin')}
|
||||
${navLink('🏠 Home', '/admin')}
|
||||
${navLink('🎛️ ATC View', '/atc')}
|
||||
${navLink('📊 Reports', '/reports')}
|
||||
${navLink('🛸 Drone Requests', '/drone-requests')}
|
||||
${navLink('🛸 Drone Requests <span class="notification-badge" id="drone-request-badge" hidden>0</span>', '/drone-requests')}
|
||||
${navLink('📔 Journal Log', '/journal')}
|
||||
${actionLink('✈️ User Aircraft', 'user-aircraft', '', 'user-aircraft-dropdown')}
|
||||
${actionLink('👥 User Management', 'user-management', '', 'user-management-dropdown')}
|
||||
@@ -220,6 +238,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDroneRequestBadge() {
|
||||
const badges = [
|
||||
document.getElementById('drone-request-badge'),
|
||||
document.getElementById('drone-request-menu-badge'),
|
||||
].filter(Boolean);
|
||||
if (!badges.length) return;
|
||||
|
||||
const token = localStorage.getItem('ppr_access_token');
|
||||
if (!token) {
|
||||
badges.forEach(badge => {
|
||||
badge.hidden = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/drone-requests/?status=NEW&limit=100', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!response.ok) {
|
||||
badges.forEach(badge => {
|
||||
badge.hidden = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requests = await response.json();
|
||||
const count = Array.isArray(requests) ? requests.length : 0;
|
||||
const title = count === 1
|
||||
? '1 drone request waiting for approval'
|
||||
: `${count} drone requests waiting for approval`;
|
||||
badges.forEach(badge => {
|
||||
badge.textContent = count > 99 ? '99+' : String(count);
|
||||
badge.hidden = count === 0;
|
||||
badge.title = title;
|
||||
});
|
||||
} catch (error) {
|
||||
badges.forEach(badge => {
|
||||
badge.hidden = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.refreshDroneRequestBadge = refreshDroneRequestBadge;
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const toggle = event.target.closest('.dropdown-toggle');
|
||||
const action = event.target.closest('[data-topbar-action]');
|
||||
@@ -265,6 +328,8 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
normalizeTopbar();
|
||||
updateRoleVisibility();
|
||||
refreshDroneRequestBadge();
|
||||
handleDeferredAction();
|
||||
window.setInterval(refreshDroneRequestBadge, 60000);
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user