Compare commits
13 Commits
733e9b426f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a49dfe219 | |||
| 8d8cb9ccad | |||
| 4b6dd9c93c | |||
| 74c21fe988 | |||
| c2e4d2adeb | |||
| 870bc0649b | |||
| 5e12561fb2 | |||
| f33c12f541 | |||
| 05e7859447 | |||
| a3f1a10bf5 | |||
| 5e33c1d47b | |||
| 10ab215396 | |||
| a9b5ec67ba |
+2
-1
@@ -20,6 +20,7 @@ MAIL_USERNAME=your_mail_username_here
|
|||||||
MAIL_PASSWORD=your_mail_password_here
|
MAIL_PASSWORD=your_mail_password_here
|
||||||
MAIL_FROM=your_mail_from_address_here
|
MAIL_FROM=your_mail_from_address_here
|
||||||
MAIL_FROM_NAME=your_mail_from_name_here
|
MAIL_FROM_NAME=your_mail_from_name_here
|
||||||
|
DRONE_REQUEST_TOWER_EMAIL=tower@example.com
|
||||||
|
|
||||||
# Application settings
|
# Application settings
|
||||||
BASE_URL=your_base_url_here
|
BASE_URL=your_base_url_here
|
||||||
@@ -38,4 +39,4 @@ WEB_PORT_EXTERNAL=8082
|
|||||||
# phpMyAdmin Configuration
|
# phpMyAdmin Configuration
|
||||||
PMA_HOST=db
|
PMA_HOST=db
|
||||||
UPLOAD_LIMIT=50M
|
UPLOAD_LIMIT=50M
|
||||||
PMA_PORT_EXTERNAL=8083
|
PMA_PORT_EXTERNAL=8083
|
||||||
|
|||||||
+5
-1
@@ -84,4 +84,8 @@ htmlcov/
|
|||||||
coverage.xml
|
coverage.xml
|
||||||
*.cover
|
*.cover
|
||||||
.hypothesis/
|
.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
|
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
|
## Additional Features
|
||||||
|
|
||||||
### Email Notifications
|
### Email Notifications
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ drone_status = sa.Enum(
|
|||||||
'NEW',
|
'NEW',
|
||||||
'APPROVED',
|
'APPROVED',
|
||||||
'DENIED',
|
'DENIED',
|
||||||
'PENDING',
|
|
||||||
'CANCELED',
|
'CANCELED',
|
||||||
'INFLIGHT',
|
'INFLIGHT',
|
||||||
'COMPLETED',
|
'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 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()
|
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(journal.router, prefix="/journal", tags=["journal"])
|
||||||
api_router.include_router(movements.router, prefix="/movements", tags=["movements"])
|
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(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.router, prefix="/public", tags=["public"])
|
||||||
api_router.include_router(public_book.router, prefix="/public-book", tags=["public_booking"])
|
api_router.include_router(public_book.router, prefix="/public-book", tags=["public_booking"])
|
||||||
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
|
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,
|
DroneRequest,
|
||||||
DroneRequestComment,
|
DroneRequestComment,
|
||||||
DroneRequestCreate,
|
DroneRequestCreate,
|
||||||
|
DroneRequestPublicSubmission,
|
||||||
DroneRequestStatus,
|
DroneRequestStatus,
|
||||||
DroneRequestStatusUpdate,
|
DroneRequestStatusUpdate,
|
||||||
DroneRequestUpdate,
|
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"),
|
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||||
"maximum_elevation_ft_amsl": drone_request.maximum_elevation_ft_amsl,
|
"maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl,
|
||||||
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -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"),
|
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||||
"maximum_elevation_ft_amsl": drone_request.maximum_elevation_ft_amsl,
|
"maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl,
|
||||||
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
async def _send_drone_approved_email(drone_request, message: Optional[str] = None):
|
||||||
await email_service.send_email(
|
await email_service.send_email(
|
||||||
to_email=drone_request.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"),
|
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||||
"maximum_elevation_ft_amsl": drone_request.maximum_elevation_ft_amsl,
|
"maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl,
|
||||||
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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])
|
@router.get("/", response_model=List[DroneRequest])
|
||||||
async def get_drone_requests(
|
async def get_drone_requests(
|
||||||
skip: int = 0,
|
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(
|
async def create_public_drone_request(
|
||||||
request: Request,
|
request: Request,
|
||||||
drone_request_in: DroneRequestCreate,
|
drone_request_in: DroneRequestCreate,
|
||||||
@@ -131,7 +165,8 @@ async def create_public_drone_request(
|
|||||||
|
|
||||||
await _broadcast(request, "drone_request_created", drone_request)
|
await _broadcast(request, "drone_request_created", drone_request)
|
||||||
await _send_drone_submitted_email(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)
|
@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)
|
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||||
if not drone_request:
|
if not drone_request:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Drone request cannot be edited while {drone_request.status.value}",
|
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)
|
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||||
if not drone_request:
|
if not drone_request:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Drone request cannot be cancelled while {drone_request.status.value}",
|
detail=f"Drone request cannot be cancelled while {drone_request.status.value}",
|
||||||
|
|||||||
@@ -564,7 +564,7 @@ async def bulk_log_movement(
|
|||||||
else:
|
else:
|
||||||
ppr.out_to = entry.to_location or ppr.out_to
|
ppr.out_to = entry.to_location or ppr.out_to
|
||||||
ppr.pob_out = entry.pob or ppr.pob_out
|
ppr.pob_out = entry.pob or ppr.pob_out
|
||||||
ppr.departed_dt = timestamp
|
ppr.takeoff_dt = timestamp
|
||||||
if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED):
|
if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED):
|
||||||
ppr.status = PPRStatus.DEPARTED
|
ppr.status = PPRStatus.DEPARTED
|
||||||
if entry.notes:
|
if entry.notes:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
from sqlalchemy.orm import Session
|
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.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_ppr import ppr as crud_ppr
|
||||||
from app.crud.crud_journal import journal as crud_journal
|
from app.crud.crud_journal import journal as crud_journal
|
||||||
@@ -19,6 +20,14 @@ from app.core.config import settings
|
|||||||
router = APIRouter()
|
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])
|
@router.get("/", response_model=List[PPR])
|
||||||
async def get_pprs(
|
async def get_pprs(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -94,8 +103,8 @@ async def create_public_ppr(
|
|||||||
template_vars={
|
template_vars={
|
||||||
"name": ppr_in.captain,
|
"name": ppr_in.captain,
|
||||||
"aircraft": ppr_in.ac_reg,
|
"aircraft": ppr_in.ac_reg,
|
||||||
"arrival_time": ppr_in.eta.strftime("%Y-%m-%d %H:%M"),
|
"arrival_time": format_local_datetime(ppr_in.eta),
|
||||||
"departure_time": ppr_in.etd.strftime("%Y-%m-%d %H:%M") if ppr_in.etd else "N/A",
|
"departure_time": format_local_datetime(ppr_in.etd),
|
||||||
"purpose": ppr_in.notes or "N/A",
|
"purpose": ppr_in.notes or "N/A",
|
||||||
"public_token": ppr.public_token,
|
"public_token": ppr.public_token,
|
||||||
"base_url": settings.base_url
|
"base_url": settings.base_url
|
||||||
@@ -213,13 +222,21 @@ async def update_ppr_status(
|
|||||||
|
|
||||||
# Send real-time update
|
# Send real-time update
|
||||||
if hasattr(request.app.state, 'connection_manager'):
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
event_timestamp = None
|
||||||
|
if ppr.status == PPRStatus.LANDED and ppr.landed_dt:
|
||||||
|
event_timestamp = ppr.landed_dt.isoformat()
|
||||||
|
elif ppr.status == PPRStatus.LOCAL and ppr.takeoff_dt:
|
||||||
|
event_timestamp = ppr.takeoff_dt.isoformat()
|
||||||
|
elif ppr.status == PPRStatus.DEPARTED and ppr.qsy_dt:
|
||||||
|
event_timestamp = ppr.qsy_dt.isoformat()
|
||||||
|
|
||||||
await request.app.state.connection_manager.broadcast({
|
await request.app.state.connection_manager.broadcast({
|
||||||
"type": "status_update",
|
"type": "status_update",
|
||||||
"data": {
|
"data": {
|
||||||
"id": ppr.id,
|
"id": ppr.id,
|
||||||
"ac_reg": ppr.ac_reg,
|
"ac_reg": ppr.ac_reg,
|
||||||
"status": ppr.status.value,
|
"status": ppr.status.value,
|
||||||
"timestamp": ppr.landed_dt.isoformat() if ppr.landed_dt else (ppr.departed_dt.isoformat() if ppr.departed_dt else None)
|
"timestamp": event_timestamp
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -232,8 +249,8 @@ async def update_ppr_status(
|
|||||||
template_vars={
|
template_vars={
|
||||||
"name": ppr.captain,
|
"name": ppr.captain,
|
||||||
"aircraft": ppr.ac_reg,
|
"aircraft": ppr.ac_reg,
|
||||||
"arrival_time": ppr.eta.strftime("%Y-%m-%d %H:%M"),
|
"arrival_time": format_local_datetime(ppr.eta),
|
||||||
"departure_time": ppr.etd.strftime("%Y-%m-%d %H:%M") if ppr.etd else "N/A"
|
"departure_time": format_local_datetime(ppr.etd)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -316,11 +333,10 @@ async def get_ppr_for_edit(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="Invalid or expired token"
|
detail="Invalid or expired token"
|
||||||
)
|
)
|
||||||
# Only allow editing if not already processed
|
if ppr.status == PPRStatus.DELETED:
|
||||||
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="PPR cannot be edited at this stage"
|
detail="PPR is no longer available"
|
||||||
)
|
)
|
||||||
return ppr
|
return ppr
|
||||||
|
|
||||||
@@ -340,7 +356,7 @@ async def update_ppr_public(
|
|||||||
detail="Invalid or expired token"
|
detail="Invalid or expired token"
|
||||||
)
|
)
|
||||||
# Only allow editing if not already processed
|
# Only allow editing if not already processed
|
||||||
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]:
|
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.LOCAL, PPRStatus.DEPARTED]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="PPR cannot be edited at this stage"
|
detail="PPR cannot be edited at this stage"
|
||||||
@@ -365,7 +381,7 @@ async def cancel_ppr_public(
|
|||||||
detail="Invalid or expired token"
|
detail="Invalid or expired token"
|
||||||
)
|
)
|
||||||
# Only allow canceling if not already processed
|
# Only allow canceling if not already processed
|
||||||
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]:
|
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.LOCAL, PPRStatus.DEPARTED]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="PPR cannot be cancelled at this stage"
|
detail="PPR cannot be cancelled at this stage"
|
||||||
@@ -390,8 +406,8 @@ async def cancel_ppr_public(
|
|||||||
template_vars={
|
template_vars={
|
||||||
"name": cancelled_ppr.captain,
|
"name": cancelled_ppr.captain,
|
||||||
"aircraft": cancelled_ppr.ac_reg,
|
"aircraft": cancelled_ppr.ac_reg,
|
||||||
"arrival_time": cancelled_ppr.eta.strftime("%Y-%m-%d %H:%M"),
|
"arrival_time": format_local_datetime(cancelled_ppr.eta),
|
||||||
"departure_time": cancelled_ppr.etd.strftime("%Y-%m-%d %H:%M") if cancelled_ppr.etd else "N/A"
|
"departure_time": format_local_datetime(cancelled_ppr.etd)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -59,28 +59,36 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
|
|||||||
'isLocalFlight': False
|
'isLocalFlight': False
|
||||||
})
|
})
|
||||||
|
|
||||||
# Add local flights with DEPARTED status that were booked out today
|
|
||||||
local_flights = crud_local_flight.get_multi(
|
|
||||||
db,
|
|
||||||
status=LocalFlightStatus.DEPARTED,
|
|
||||||
limit=1000
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get today's date boundaries
|
# Get today's date boundaries
|
||||||
today = date.today()
|
today = date.today()
|
||||||
today_start = datetime.combine(today, datetime.min.time())
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
|
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
|
||||||
|
|
||||||
|
# Add airborne local flights that were booked out today.
|
||||||
|
# Admin now moves local flights from GROUND to LOCAL/CIRCUIT rather than DEPARTED.
|
||||||
|
airborne_local_statuses = {
|
||||||
|
LocalFlightStatus.DEPARTED,
|
||||||
|
LocalFlightStatus.LOCAL,
|
||||||
|
LocalFlightStatus.CIRCUIT,
|
||||||
|
LocalFlightStatus.CIRCUIT_DOWNWIND,
|
||||||
|
LocalFlightStatus.CIRCUIT_BASE,
|
||||||
|
LocalFlightStatus.CIRCUIT_FINAL,
|
||||||
|
}
|
||||||
|
local_flights = crud_local_flight.get_multi(db, limit=1000)
|
||||||
|
|
||||||
# Convert local flights to match the PPR format for display
|
# Convert local flights to match the PPR format for display
|
||||||
for flight in local_flights:
|
for flight in local_flights:
|
||||||
# Only include flights booked out today
|
# Only include flights booked out today
|
||||||
if not (today_start <= flight.created_dt < today_end):
|
if not (today_start <= flight.created_dt < today_end):
|
||||||
continue
|
continue
|
||||||
|
if flight.status not in airborne_local_statuses:
|
||||||
|
continue
|
||||||
|
|
||||||
# Calculate ETA from departed_dt + duration (if both are available)
|
# Calculate ETA from actual takeoff/departure + duration, falling back to ETD.
|
||||||
eta = flight.departed_dt
|
departure_time = flight.takeoff_dt or flight.departed_dt or flight.etd
|
||||||
if flight.departed_dt and flight.duration:
|
eta = departure_time
|
||||||
eta = flight.departed_dt + timedelta(minutes=flight.duration)
|
if departure_time and flight.duration:
|
||||||
|
eta = departure_time + timedelta(minutes=flight.duration)
|
||||||
|
|
||||||
arrivals_list.append({
|
arrivals_list.append({
|
||||||
'ac_call': flight.callsign or flight.registration,
|
'ac_call': flight.callsign or flight.registration,
|
||||||
@@ -89,7 +97,7 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
|
|||||||
'in_from': None,
|
'in_from': None,
|
||||||
'eta': eta,
|
'eta': eta,
|
||||||
'landed_dt': None,
|
'landed_dt': None,
|
||||||
'status': 'DEPARTED',
|
'status': flight.status.value,
|
||||||
'isLocalFlight': True,
|
'isLocalFlight': True,
|
||||||
'flight_type': flight.flight_type.value
|
'flight_type': flight.flight_type.value
|
||||||
})
|
})
|
||||||
@@ -137,29 +145,33 @@ async def get_public_departures(db: Session = Depends(get_db)):
|
|||||||
'ac_type': departure.ac_type,
|
'ac_type': departure.ac_type,
|
||||||
'out_to': departure.out_to,
|
'out_to': departure.out_to,
|
||||||
'etd': departure.etd,
|
'etd': departure.etd,
|
||||||
'departed_dt': departure.departed_dt,
|
'takeoff_dt': departure.takeoff_dt,
|
||||||
|
'qsy_dt': departure.qsy_dt,
|
||||||
'status': departure.status.value,
|
'status': departure.status.value,
|
||||||
'isLocalFlight': False,
|
'isLocalFlight': False,
|
||||||
'isDeparture': False
|
'isDeparture': False
|
||||||
})
|
})
|
||||||
|
|
||||||
# Add local flights with BOOKED_OUT status that were booked out today
|
|
||||||
local_flights = crud_local_flight.get_multi(
|
|
||||||
db,
|
|
||||||
status=LocalFlightStatus.BOOKED_OUT,
|
|
||||||
limit=1000
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get today's date boundaries
|
# Get today's date boundaries
|
||||||
today = date.today()
|
today = date.today()
|
||||||
today_start = datetime.combine(today, datetime.min.time())
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
|
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
|
||||||
|
|
||||||
|
# Add local flights awaiting takeoff that were booked out today.
|
||||||
|
# Admin-created flights start at GROUND, while public pilot submissions start at BOOKED_OUT.
|
||||||
|
local_departure_statuses = {
|
||||||
|
LocalFlightStatus.BOOKED_OUT,
|
||||||
|
LocalFlightStatus.GROUND,
|
||||||
|
}
|
||||||
|
local_flights = crud_local_flight.get_multi(db, limit=1000)
|
||||||
|
|
||||||
# Convert local flights to match the PPR format for display
|
# Convert local flights to match the PPR format for display
|
||||||
for flight in local_flights:
|
for flight in local_flights:
|
||||||
# Only include flights booked out today
|
# Only include flights booked out today
|
||||||
if not (today_start <= flight.created_dt < today_end):
|
if not (today_start <= flight.created_dt < today_end):
|
||||||
continue
|
continue
|
||||||
|
if flight.status not in local_departure_statuses:
|
||||||
|
continue
|
||||||
departures_list.append({
|
departures_list.append({
|
||||||
'ac_call': flight.callsign or flight.registration,
|
'ac_call': flight.callsign or flight.registration,
|
||||||
'ac_reg': flight.registration,
|
'ac_reg': flight.registration,
|
||||||
@@ -167,7 +179,7 @@ async def get_public_departures(db: Session = Depends(get_db)):
|
|||||||
'out_to': None,
|
'out_to': None,
|
||||||
'etd': flight.etd or flight.created_dt,
|
'etd': flight.etd or flight.created_dt,
|
||||||
'departed_dt': None,
|
'departed_dt': None,
|
||||||
'status': 'BOOKED_OUT',
|
'status': 'CONTACT' if flight.status == LocalFlightStatus.GROUND else 'BOOKED_OUT',
|
||||||
'isLocalFlight': True,
|
'isLocalFlight': True,
|
||||||
'flight_type': flight.flight_type.value,
|
'flight_type': flight.flight_type.value,
|
||||||
'isDeparture': False
|
'isDeparture': False
|
||||||
@@ -247,4 +259,4 @@ async def get_ui_config():
|
|||||||
"top_bar_gradient_end": lighten_color(base_color, 0.4), # Lighten for gradient end
|
"top_bar_gradient_end": lighten_color(base_color, 0.4), # Lighten for gradient end
|
||||||
"footer_color": darken_color(base_color, 0.2), # Darken for footer
|
"footer_color": darken_color(base_color, 0.2), # Darken for footer
|
||||||
"environment": settings.environment
|
"environment": settings.environment
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ class Settings(BaseSettings):
|
|||||||
mail_password: str
|
mail_password: str
|
||||||
mail_from: str
|
mail_from: str
|
||||||
mail_from_name: str
|
mail_from_name: str
|
||||||
|
drone_request_tower_email: str | None = None
|
||||||
|
|
||||||
# Application settings
|
# Application settings
|
||||||
api_v1_str: str = "/api/v1"
|
api_v1_str: str = "/api/v1"
|
||||||
project_name: str = "Airfield PPR API"
|
project_name: str = "Airfield PPR API"
|
||||||
base_url: str
|
base_url: str
|
||||||
|
local_timezone: str = "Europe/London"
|
||||||
|
|
||||||
# UI Configuration
|
# UI Configuration
|
||||||
tag: str = ""
|
tag: str = ""
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ class EmailService:
|
|||||||
template_dir = os.path.join(os.path.dirname(__file__), '..', 'templates')
|
template_dir = os.path.join(os.path.dirname(__file__), '..', 'templates')
|
||||||
self.jinja_env = Environment(loader=FileSystemLoader(template_dir))
|
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
|
# Render the template
|
||||||
template = self.jinja_env.get_template(template_name)
|
template = self.jinja_env.get_template(template_name)
|
||||||
html_content = template.render(**template_vars)
|
html_content = template.render(**template_vars)
|
||||||
@@ -29,6 +36,8 @@ class EmailService:
|
|||||||
msg['Subject'] = subject
|
msg['Subject'] = subject
|
||||||
msg['From'] = f"{self.from_name} <{self.from_email}>"
|
msg['From'] = f"{self.from_name} <{self.from_email}>"
|
||||||
msg['To'] = to_email
|
msg['To'] = to_email
|
||||||
|
if reply_to:
|
||||||
|
msg['Reply-To'] = reply_to
|
||||||
|
|
||||||
# Attach HTML content
|
# Attach HTML content
|
||||||
html_part = MIMEText(html_content, 'html')
|
html_part = MIMEText(html_content, 'html')
|
||||||
@@ -45,4 +54,4 @@ class EmailService:
|
|||||||
# In production, use logging
|
# 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()
|
current_time = timestamp if timestamp is not None else datetime.utcnow()
|
||||||
if status == LocalFlightStatus.GROUND:
|
if status == LocalFlightStatus.GROUND:
|
||||||
db_obj.contact_dt = current_time
|
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
|
db_obj.departed_dt = current_time
|
||||||
elif status == LocalFlightStatus.LANDED and not db_obj.landed_dt:
|
elif status == LocalFlightStatus.LANDED and not db_obj.landed_dt:
|
||||||
db_obj.landed_dt = current_time
|
db_obj.landed_dt = current_time
|
||||||
@@ -200,6 +200,8 @@ class CRUDLocalFlight:
|
|||||||
# Takeoff: happens once when transitioning away from GROUND
|
# 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:
|
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
|
db_obj.takeoff_dt = current_time
|
||||||
|
if not db_obj.departed_dt:
|
||||||
|
db_obj.departed_dt = current_time
|
||||||
|
|
||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class CRUDPPR:
|
|||||||
PPRRecord.status == PPRStatus.NEW,
|
PPRRecord.status == PPRStatus.NEW,
|
||||||
PPRRecord.status == PPRStatus.CONFIRMED,
|
PPRRecord.status == PPRStatus.CONFIRMED,
|
||||||
PPRRecord.status == PPRStatus.LANDED,
|
PPRRecord.status == PPRStatus.LANDED,
|
||||||
|
PPRRecord.status == PPRStatus.LOCAL,
|
||||||
PPRRecord.status == PPRStatus.DEPARTED
|
PPRRecord.status == PPRStatus.DEPARTED
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -71,6 +72,7 @@ class CRUDPPR:
|
|||||||
func.date(PPRRecord.etd) == today,
|
func.date(PPRRecord.etd) == today,
|
||||||
or_(
|
or_(
|
||||||
PPRRecord.status == PPRStatus.LANDED,
|
PPRRecord.status == PPRStatus.LANDED,
|
||||||
|
PPRRecord.status == PPRStatus.LOCAL,
|
||||||
PPRRecord.status == PPRStatus.DEPARTED
|
PPRRecord.status == PPRStatus.DEPARTED
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -151,8 +153,10 @@ class CRUDPPR:
|
|||||||
current_time = timestamp if timestamp is not None else datetime.utcnow()
|
current_time = timestamp if timestamp is not None else datetime.utcnow()
|
||||||
if status == PPRStatus.LANDED:
|
if status == PPRStatus.LANDED:
|
||||||
db_obj.landed_dt = current_time
|
db_obj.landed_dt = current_time
|
||||||
|
elif status == PPRStatus.LOCAL:
|
||||||
|
db_obj.takeoff_dt = current_time
|
||||||
elif status == PPRStatus.DEPARTED:
|
elif status == PPRStatus.DEPARTED:
|
||||||
db_obj.departed_dt = current_time
|
db_obj.qsy_dt = current_time
|
||||||
|
|
||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ class DroneRequestStatus(str, Enum):
|
|||||||
NEW = "NEW"
|
NEW = "NEW"
|
||||||
APPROVED = "APPROVED"
|
APPROVED = "APPROVED"
|
||||||
DENIED = "DENIED"
|
DENIED = "DENIED"
|
||||||
PENDING = "PENDING"
|
|
||||||
CANCELED = "CANCELED"
|
CANCELED = "CANCELED"
|
||||||
INFLIGHT = "INFLIGHT"
|
INFLIGHT = "INFLIGHT"
|
||||||
COMPLETED = "COMPLETED"
|
COMPLETED = "COMPLETED"
|
||||||
@@ -36,7 +35,7 @@ class DroneRequest(Base):
|
|||||||
estimated_completion_time = Column(String(8), nullable=True)
|
estimated_completion_time = Column(String(8), nullable=True)
|
||||||
estimated_takeoff_at = Column(DateTime, nullable=False, index=True)
|
estimated_takeoff_at = Column(DateTime, nullable=False, index=True)
|
||||||
estimated_completion_at = Column(DateTime, nullable=False, index=True)
|
estimated_completion_at = Column(DateTime, nullable=False, index=True)
|
||||||
maximum_elevation_ft_amsl = Column(Integer, nullable=False)
|
maximum_elevation_ft_agl = Column(Integer, nullable=False)
|
||||||
|
|
||||||
location_description = Column(Text, nullable=True)
|
location_description = Column(Text, nullable=True)
|
||||||
location_latitude = Column(Float, nullable=False)
|
location_latitude = Column(Float, nullable=False)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class PPRStatus(str, Enum):
|
|||||||
CONFIRMED = "CONFIRMED"
|
CONFIRMED = "CONFIRMED"
|
||||||
CANCELED = "CANCELED"
|
CANCELED = "CANCELED"
|
||||||
LANDED = "LANDED"
|
LANDED = "LANDED"
|
||||||
|
LOCAL = "LOCAL"
|
||||||
DELETED = "DELETED"
|
DELETED = "DELETED"
|
||||||
DEPARTED = "DEPARTED"
|
DEPARTED = "DEPARTED"
|
||||||
ACTIVATED = "ACTIVATED"
|
ACTIVATED = "ACTIVATED"
|
||||||
@@ -40,7 +41,8 @@ class PPRRecord(Base):
|
|||||||
phone = Column(String(16), nullable=True)
|
phone = Column(String(16), nullable=True)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
landed_dt = Column(DateTime, nullable=True)
|
landed_dt = Column(DateTime, nullable=True)
|
||||||
departed_dt = Column(DateTime, nullable=True)
|
takeoff_dt = Column(DateTime, nullable=True)
|
||||||
|
qsy_dt = Column(DateTime, nullable=True)
|
||||||
created_by = Column(String(16), nullable=True, index=True)
|
created_by = Column(String(16), nullable=True, index=True)
|
||||||
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
||||||
acknowledged_dt = Column(DateTime, nullable=True)
|
acknowledged_dt = Column(DateTime, nullable=True)
|
||||||
|
|||||||
@@ -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 enum import Enum
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field, validator
|
from pydantic import AliasChoices, BaseModel, EmailStr, Field, validator
|
||||||
|
|
||||||
|
|
||||||
class DroneRequestStatus(str, Enum):
|
class DroneRequestStatus(str, Enum):
|
||||||
NEW = "NEW"
|
NEW = "NEW"
|
||||||
APPROVED = "APPROVED"
|
APPROVED = "APPROVED"
|
||||||
DENIED = "DENIED"
|
DENIED = "DENIED"
|
||||||
PENDING = "PENDING"
|
|
||||||
CANCELED = "CANCELED"
|
CANCELED = "CANCELED"
|
||||||
INFLIGHT = "INFLIGHT"
|
INFLIGHT = "INFLIGHT"
|
||||||
COMPLETED = "COMPLETED"
|
COMPLETED = "COMPLETED"
|
||||||
@@ -21,7 +20,11 @@ class DroneRequestBase(BaseModel):
|
|||||||
flight_date: Optional[date] = None
|
flight_date: Optional[date] = None
|
||||||
estimated_takeoff_time: Optional[str] = Field(None, max_length=8)
|
estimated_takeoff_time: Optional[str] = Field(None, max_length=8)
|
||||||
estimated_completion_time: Optional[str] = Field(None, max_length=8)
|
estimated_completion_time: Optional[str] = Field(None, max_length=8)
|
||||||
maximum_elevation_ft_amsl: int = Field(..., ge=0)
|
maximum_elevation_ft_agl: int = Field(
|
||||||
|
...,
|
||||||
|
ge=0,
|
||||||
|
validation_alias=AliasChoices("maximum_elevation_ft_agl", "maximum_elevation_ft_amsl"),
|
||||||
|
)
|
||||||
location_description: Optional[str] = None
|
location_description: Optional[str] = None
|
||||||
location_latitude: float = Field(..., ge=-90, le=90)
|
location_latitude: float = Field(..., ge=-90, le=90)
|
||||||
location_longitude: float = Field(..., ge=-180, le=180)
|
location_longitude: float = Field(..., ge=-180, le=180)
|
||||||
@@ -69,7 +72,11 @@ class DroneRequestUpdate(BaseModel):
|
|||||||
estimated_completion_time: Optional[str] = Field(None, max_length=8)
|
estimated_completion_time: Optional[str] = Field(None, max_length=8)
|
||||||
estimated_takeoff_at: Optional[datetime] = None
|
estimated_takeoff_at: Optional[datetime] = None
|
||||||
estimated_completion_at: Optional[datetime] = None
|
estimated_completion_at: Optional[datetime] = None
|
||||||
maximum_elevation_ft_amsl: Optional[int] = Field(None, ge=0)
|
maximum_elevation_ft_agl: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
validation_alias=AliasChoices("maximum_elevation_ft_agl", "maximum_elevation_ft_amsl"),
|
||||||
|
)
|
||||||
location_description: Optional[str] = None
|
location_description: Optional[str] = None
|
||||||
location_latitude: Optional[float] = Field(None, ge=-90, le=90)
|
location_latitude: Optional[float] = Field(None, ge=-90, le=90)
|
||||||
location_longitude: Optional[float] = Field(None, ge=-180, le=180)
|
location_longitude: Optional[float] = Field(None, ge=-180, le=180)
|
||||||
@@ -104,3 +111,8 @@ class DroneRequest(DroneRequestBase):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class DroneRequestPublicSubmission(DroneRequest):
|
||||||
|
request_id: str
|
||||||
|
secure_link: str
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class PPRStatus(str, Enum):
|
|||||||
CONFIRMED = "CONFIRMED"
|
CONFIRMED = "CONFIRMED"
|
||||||
CANCELED = "CANCELED"
|
CANCELED = "CANCELED"
|
||||||
LANDED = "LANDED"
|
LANDED = "LANDED"
|
||||||
|
LOCAL = "LOCAL"
|
||||||
DELETED = "DELETED"
|
DELETED = "DELETED"
|
||||||
DEPARTED = "DEPARTED"
|
DEPARTED = "DEPARTED"
|
||||||
ACTIVATED = "ACTIVATED"
|
ACTIVATED = "ACTIVATED"
|
||||||
@@ -85,7 +86,8 @@ class PPRInDBBase(PPRBase):
|
|||||||
id: int
|
id: int
|
||||||
status: PPRStatus
|
status: PPRStatus
|
||||||
landed_dt: Optional[datetime] = None
|
landed_dt: Optional[datetime] = None
|
||||||
departed_dt: Optional[datetime] = None
|
takeoff_dt: Optional[datetime] = None
|
||||||
|
qsy_dt: Optional[datetime] = None
|
||||||
created_by: Optional[str] = None
|
created_by: Optional[str] = None
|
||||||
submitted_dt: datetime
|
submitted_dt: datetime
|
||||||
acknowledged_dt: Optional[datetime] = None
|
acknowledged_dt: Optional[datetime] = None
|
||||||
@@ -111,7 +113,8 @@ class PPRPublic(BaseModel):
|
|||||||
out_to: Optional[str] = None
|
out_to: Optional[str] = None
|
||||||
etd: Optional[datetime] = None
|
etd: Optional[datetime] = None
|
||||||
landed_dt: Optional[datetime] = None
|
landed_dt: Optional[datetime] = None
|
||||||
departed_dt: Optional[datetime] = None
|
takeoff_dt: Optional[datetime] = None
|
||||||
|
qsy_dt: Optional[datetime] = None
|
||||||
submitted_dt: datetime
|
submitted_dt: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -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>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
||||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td>
|
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
||||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td>
|
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
||||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td>
|
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
<p><strong>PPR Details:</strong></p>
|
<p><strong>PPR Details:</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Aircraft: {{ aircraft }}</li>
|
<li>Aircraft: {{ aircraft }}</li>
|
||||||
<li>Original Arrival: {{ arrival_time }}</li>
|
<li>Original Arrival (local time): {{ arrival_time }}</li>
|
||||||
<li>Original Departure: {{ departure_time }}</li>
|
<li>Original Departure (local time): {{ departure_time }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>If this was not intended, please contact us.</p>
|
<p>If this was not intended, please contact us.</p>
|
||||||
<p>Best regards,<br>Swansea Airport Team</p>
|
<p>Best regards,<br>Swansea Airport Team</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -10,12 +10,11 @@
|
|||||||
<p><strong>PPR Details:</strong></p>
|
<p><strong>PPR Details:</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Aircraft: {{ aircraft }}</li>
|
<li>Aircraft: {{ aircraft }}</li>
|
||||||
<li>Arrival: {{ arrival_time }}</li>
|
<li>Arrival (local time): {{ arrival_time }}</li>
|
||||||
<li>Departure: {{ departure_time }}</li>
|
<li>Departure (local time): {{ departure_time }}</li>
|
||||||
<li>Purpose: {{ purpose }}</li>
|
|
||||||
</ul>
|
</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 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>You will receive further updates via email.</p>
|
||||||
<p>Best regards,<br>Swansea Airport Team</p>
|
<p>Best regards,<br>Swansea Airport Team</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ DROP TABLE IF EXISTS `submitted`;
|
|||||||
/*!40101 SET character_set_client = utf8mb4 */;
|
/*!40101 SET character_set_client = utf8mb4 */;
|
||||||
CREATE TABLE `submitted` (
|
CREATE TABLE `submitted` (
|
||||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||||
`status` enum('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED') CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL DEFAULT 'NEW',
|
`status` enum('NEW','CONFIRMED','CANCELED','LANDED','LOCAL','DELETED','DEPARTED','ACTIVATED') CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL DEFAULT 'NEW',
|
||||||
`ac_reg` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
`ac_reg` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
||||||
`ac_type` varchar(32) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
`ac_type` varchar(32) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
||||||
`ac_call` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci DEFAULT NULL,
|
`ac_call` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci DEFAULT NULL,
|
||||||
@@ -97,7 +97,8 @@ CREATE TABLE `submitted` (
|
|||||||
`phone` varchar(16) DEFAULT NULL,
|
`phone` varchar(16) DEFAULT NULL,
|
||||||
`notes` varchar(2000) DEFAULT NULL,
|
`notes` varchar(2000) DEFAULT NULL,
|
||||||
`landed_dt` datetime DEFAULT NULL,
|
`landed_dt` datetime DEFAULT NULL,
|
||||||
`departed_dt` datetime DEFAULT NULL,
|
`takeoff_dt` datetime DEFAULT NULL,
|
||||||
|
`qsy_dt` datetime DEFAULT NULL,
|
||||||
`created_by` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci DEFAULT NULL,
|
`created_by` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci DEFAULT NULL,
|
||||||
`submitted_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`submitted_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE KEY `id` (`id`)
|
UNIQUE KEY `id` (`id`)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ os.environ.setdefault("MAIL_USERNAME", "test")
|
|||||||
os.environ.setdefault("MAIL_PASSWORD", "test")
|
os.environ.setdefault("MAIL_PASSWORD", "test")
|
||||||
os.environ.setdefault("MAIL_FROM", "noreply@example.test")
|
os.environ.setdefault("MAIL_FROM", "noreply@example.test")
|
||||||
os.environ.setdefault("MAIL_FROM_NAME", "PPR Tests")
|
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("BASE_URL", "http://testserver")
|
||||||
os.environ.setdefault("ENVIRONMENT", "test")
|
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_completion_time": "10:30",
|
||||||
"estimated_takeoff_at": "2026-06-20T10:00:00",
|
"estimated_takeoff_at": "2026-06-20T10:00:00",
|
||||||
"estimated_completion_at": "2026-06-20T10:30:00",
|
"estimated_completion_at": "2026-06-20T10:30:00",
|
||||||
"maximum_elevation_ft_amsl": 250,
|
"maximum_elevation_ft_agl": 250,
|
||||||
"location_description": "North apron",
|
"location_description": "North apron",
|
||||||
"location_latitude": 51.623389,
|
"location_latitude": 51.623389,
|
||||||
"location_longitude": -4.069231,
|
"location_longitude": -4.069231,
|
||||||
@@ -47,7 +47,9 @@ def test_public_drone_request_create_edit_cancel_and_journal(client, db, monkeyp
|
|||||||
assert created["status"] == "NEW"
|
assert created["status"] == "NEW"
|
||||||
assert created["location_inside_frz"] is True
|
assert created["location_inside_frz"] is True
|
||||||
assert created["created_by"] == "public"
|
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()
|
db_request = db.query(DroneRequest).filter(DroneRequest.id == created["id"]).one()
|
||||||
assert db_request.public_token
|
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 patch_response.json()["operator_name"] == "Updated Rotor Ops"
|
||||||
assert cancel_response.status_code == 200
|
assert cancel_response.status_code == 200
|
||||||
assert cancel_response.json()["status"] == "CANCELED"
|
assert cancel_response.json()["status"] == "CANCELED"
|
||||||
assert len(sent_emails) == 2
|
assert len(sent_emails) == 3
|
||||||
|
|
||||||
blocked_patch = client.patch(
|
blocked_patch = client.patch(
|
||||||
f"/api/v1/drone-requests/public/edit/{db_request.public_token}",
|
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
|
assert client.delete("/api/v1/drone-requests/public/cancel/missing-token").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_drone_request_accepts_legacy_amsl_altitude_key(client, db, monkeypatch):
|
||||||
|
async def fake_send_email(**kwargs):
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.api.endpoints.drone_requests.email_service.send_email", fake_send_email)
|
||||||
|
|
||||||
|
payload = drone_payload()
|
||||||
|
payload["maximum_elevation_ft_amsl"] = payload.pop("maximum_elevation_ft_agl")
|
||||||
|
|
||||||
|
create_response = client.post("/api/v1/drone-requests/public", json=payload)
|
||||||
|
|
||||||
|
assert create_response.status_code == 200
|
||||||
|
assert create_response.json()["maximum_elevation_ft_agl"] == 250
|
||||||
|
db_request = db.query(DroneRequest).filter(DroneRequest.id == create_response.json()["id"]).one()
|
||||||
|
assert db_request.maximum_elevation_ft_agl == 250
|
||||||
|
|
||||||
|
|
||||||
def test_authenticated_drone_request_list_update_status_comment_and_journal(auth_client, db, monkeypatch):
|
def test_authenticated_drone_request_list_update_status_comment_and_journal(auth_client, db, monkeypatch):
|
||||||
sent_emails = []
|
sent_emails = []
|
||||||
|
|
||||||
@@ -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']}")
|
get_response = auth_client.get(f"/api/v1/drone-requests/{created['id']}")
|
||||||
update_response = auth_client.patch(
|
update_response = auth_client.patch(
|
||||||
f"/api/v1/drone-requests/{created['id']}",
|
f"/api/v1/drone-requests/{created['id']}",
|
||||||
json={"operator_comments": "Needs tower review", "maximum_elevation_ft_amsl": 200},
|
json={"operator_comments": "Needs tower review", "maximum_elevation_ft_agl": 200},
|
||||||
)
|
)
|
||||||
status_response = auth_client.patch(
|
status_response = auth_client.patch(
|
||||||
f"/api/v1/drone-requests/{created['id']}/status",
|
f"/api/v1/drone-requests/{created['id']}/status",
|
||||||
@@ -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 [request["id"] for request in list_response.json()] == [created["id"]]
|
||||||
assert get_response.status_code == 200
|
assert get_response.status_code == 200
|
||||||
assert update_response.status_code == 200
|
assert update_response.status_code == 200
|
||||||
assert update_response.json()["maximum_elevation_ft_amsl"] == 200
|
assert update_response.json()["maximum_elevation_ft_agl"] == 200
|
||||||
assert status_response.status_code == 200
|
assert status_response.status_code == 200
|
||||||
assert status_response.json()["status"] == "APPROVED"
|
assert status_response.json()["status"] == "APPROVED"
|
||||||
assert status_response.json()["operator_comments"] == "Approved below 200ft"
|
assert status_response.json()["operator_comments"] == "Approved below 200ft"
|
||||||
@@ -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("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("Status changed from NEW to APPROVED" in entry for entry in entries)
|
||||||
assert any("Comment added" 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):
|
def test_drone_request_not_found_and_validation_paths(auth_client, client):
|
||||||
invalid_response = client.post(
|
invalid_response = client.post(
|
||||||
"/api/v1/drone-requests/public",
|
"/api/v1/drone-requests/public",
|
||||||
json=drone_payload(operator_name=" ", location_latitude=100, maximum_elevation_ft_amsl=-1),
|
json=drone_payload(operator_name=" ", location_latitude=100, maximum_elevation_ft_agl=-1),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert invalid_response.status_code == 422
|
assert invalid_response.status_code == 422
|
||||||
|
|||||||
@@ -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.status_code == 200
|
||||||
assert departed_response.json()["takeoff_dt"] == "2026-06-20T10:05:00"
|
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.status_code == 200
|
||||||
assert landed_response.json()["landed_dt"] == "2026-06-20T10:45:00"
|
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
|
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):
|
def test_overflight_lifecycle_special_lists_and_not_found_paths(auth_client, db):
|
||||||
payload = {
|
payload = {
|
||||||
"registration": "g-ovr",
|
"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)
|
assert any("Status changed from NEW to LANDED" in entry for entry in entries)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ppr_departure_lifecycle_goes_landed_local_departed(auth_client, ppr_payload):
|
||||||
|
created = auth_client.post("/api/v1/pprs/", json=ppr_payload).json()
|
||||||
|
|
||||||
|
landed_response = auth_client.patch(
|
||||||
|
f"/api/v1/pprs/{created['id']}/status",
|
||||||
|
json={"status": "LANDED", "timestamp": "2026-06-20T10:30:00"},
|
||||||
|
)
|
||||||
|
local_response = auth_client.patch(
|
||||||
|
f"/api/v1/pprs/{created['id']}/status",
|
||||||
|
json={"status": "LOCAL", "timestamp": "2026-06-20T12:55:00"},
|
||||||
|
)
|
||||||
|
departed_response = auth_client.patch(
|
||||||
|
f"/api/v1/pprs/{created['id']}/status",
|
||||||
|
json={"status": "DEPARTED", "timestamp": "2026-06-20T13:05:00"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert landed_response.status_code == 200
|
||||||
|
assert local_response.status_code == 200
|
||||||
|
assert local_response.json()["status"] == "LOCAL"
|
||||||
|
assert local_response.json()["landed_dt"] == "2026-06-20T10:30:00"
|
||||||
|
assert local_response.json()["takeoff_dt"] == "2026-06-20T12:55:00"
|
||||||
|
assert local_response.json()["qsy_dt"] is None
|
||||||
|
assert departed_response.status_code == 200
|
||||||
|
assert departed_response.json()["status"] == "DEPARTED"
|
||||||
|
assert departed_response.json()["takeoff_dt"] == "2026-06-20T12:55:00"
|
||||||
|
assert departed_response.json()["qsy_dt"] == "2026-06-20T13:05:00"
|
||||||
|
|
||||||
|
|
||||||
def test_ppr_list_supports_status_date_and_pagination_filters(auth_client, ppr_factory):
|
def test_ppr_list_supports_status_date_and_pagination_filters(auth_client, ppr_factory):
|
||||||
ppr_factory(
|
ppr_factory(
|
||||||
ac_reg="G-NEW1",
|
ac_reg="G-NEW1",
|
||||||
@@ -124,6 +152,8 @@ def test_public_ppr_create_sends_email_and_generates_token(client, db, ppr_paylo
|
|||||||
created = create_response.json()
|
created = create_response.json()
|
||||||
assert created["created_by"] == "public"
|
assert created["created_by"] == "public"
|
||||||
assert sent_email["to_email"] == "pilot@example.com"
|
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()
|
db_ppr = db.query(PPRRecord).filter(PPRRecord.id == created["id"]).one()
|
||||||
assert db_ppr.public_token
|
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.status_code == 200
|
||||||
assert cancel_response.json()["status"] == "CANCELED"
|
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.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
|
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"]
|
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):
|
def test_invalid_ppr_payload_returns_validation_error(auth_client, ppr_payload):
|
||||||
ppr_payload["pob_in"] = -1
|
ppr_payload["pob_in"] = -1
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,71 @@ def test_public_boards_include_todays_flights(client, db):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_boards_include_current_local_flight_statuses(client, db):
|
||||||
|
now = datetime.now().replace(microsecond=0)
|
||||||
|
ground_local = LocalFlight(
|
||||||
|
registration="G-GRND",
|
||||||
|
type="C152",
|
||||||
|
callsign="GGRND",
|
||||||
|
pob=1,
|
||||||
|
flight_type=LocalFlightType.LOCAL,
|
||||||
|
status=LocalFlightStatus.GROUND,
|
||||||
|
created_dt=now,
|
||||||
|
etd=now,
|
||||||
|
)
|
||||||
|
airborne_local = LocalFlight(
|
||||||
|
registration="G-AIR1",
|
||||||
|
type="PA28",
|
||||||
|
callsign="GAIR1",
|
||||||
|
pob=2,
|
||||||
|
flight_type=LocalFlightType.LOCAL,
|
||||||
|
status=LocalFlightStatus.LOCAL,
|
||||||
|
created_dt=now,
|
||||||
|
etd=now,
|
||||||
|
takeoff_dt=now,
|
||||||
|
duration=45,
|
||||||
|
)
|
||||||
|
circuit_local = LocalFlight(
|
||||||
|
registration="G-CCT1",
|
||||||
|
type="C152",
|
||||||
|
callsign="GCCT1",
|
||||||
|
pob=1,
|
||||||
|
flight_type=LocalFlightType.CIRCUITS,
|
||||||
|
status=LocalFlightStatus.CIRCUIT,
|
||||||
|
created_dt=now,
|
||||||
|
etd=now,
|
||||||
|
takeoff_dt=now,
|
||||||
|
duration=30,
|
||||||
|
)
|
||||||
|
landed_local = LocalFlight(
|
||||||
|
registration="G-LND1",
|
||||||
|
type="C172",
|
||||||
|
callsign="GLND1",
|
||||||
|
pob=1,
|
||||||
|
flight_type=LocalFlightType.LOCAL,
|
||||||
|
status=LocalFlightStatus.LANDED,
|
||||||
|
created_dt=now,
|
||||||
|
etd=now,
|
||||||
|
takeoff_dt=now,
|
||||||
|
landed_dt=now,
|
||||||
|
)
|
||||||
|
db.add_all([ground_local, airborne_local, circuit_local, landed_local])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
arrivals = client.get("/api/v1/public/arrivals")
|
||||||
|
departures = client.get("/api/v1/public/departures")
|
||||||
|
|
||||||
|
assert arrivals.status_code == 200
|
||||||
|
assert {
|
||||||
|
item["ac_reg"] for item in arrivals.json() if item.get("isLocalFlight")
|
||||||
|
} == {"G-AIR1", "G-CCT1"}
|
||||||
|
|
||||||
|
assert departures.status_code == 200
|
||||||
|
assert {
|
||||||
|
item["ac_reg"] for item in departures.json() if item.get("isLocalFlight")
|
||||||
|
} == {"G-GRND"}
|
||||||
|
|
||||||
|
|
||||||
def test_public_reference_lookups_return_seeded_records(client, db):
|
def test_public_reference_lookups_return_seeded_records(client, db):
|
||||||
db.add(
|
db.add(
|
||||||
Airport(
|
Airport(
|
||||||
|
|||||||
+3
-2
@@ -22,7 +22,7 @@ CREATE TABLE users (
|
|||||||
-- Main PPR submissions table with improvements
|
-- Main PPR submissions table with improvements
|
||||||
CREATE TABLE submitted (
|
CREATE TABLE submitted (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
status ENUM('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED') NOT NULL DEFAULT 'NEW',
|
status ENUM('NEW','CONFIRMED','CANCELED','LANDED','LOCAL','DELETED','DEPARTED','ACTIVATED') NOT NULL DEFAULT 'NEW',
|
||||||
ac_reg VARCHAR(16) NOT NULL,
|
ac_reg VARCHAR(16) NOT NULL,
|
||||||
ac_type VARCHAR(32) NOT NULL,
|
ac_type VARCHAR(32) NOT NULL,
|
||||||
ac_call VARCHAR(16) DEFAULT NULL,
|
ac_call VARCHAR(16) DEFAULT NULL,
|
||||||
@@ -38,7 +38,8 @@ CREATE TABLE submitted (
|
|||||||
phone VARCHAR(16) DEFAULT NULL,
|
phone VARCHAR(16) DEFAULT NULL,
|
||||||
notes TEXT DEFAULT NULL,
|
notes TEXT DEFAULT NULL,
|
||||||
landed_dt DATETIME DEFAULT NULL,
|
landed_dt DATETIME DEFAULT NULL,
|
||||||
departed_dt DATETIME DEFAULT NULL,
|
takeoff_dt DATETIME DEFAULT NULL,
|
||||||
|
qsy_dt DATETIME DEFAULT NULL,
|
||||||
created_by VARCHAR(16) DEFAULT NULL,
|
created_by VARCHAR(16) DEFAULT NULL,
|
||||||
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
acknowledged_dt DATETIME DEFAULT NULL,
|
acknowledged_dt DATETIME DEFAULT NULL,
|
||||||
|
|||||||
@@ -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_PASSWORD: ${MAIL_PASSWORD}
|
||||||
MAIL_FROM: ${MAIL_FROM}
|
MAIL_FROM: ${MAIL_FROM}
|
||||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||||
|
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
|
||||||
BASE_URL: ${BASE_URL}
|
BASE_URL: ${BASE_URL}
|
||||||
TAG: ${TAG}
|
TAG: ${TAG}
|
||||||
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
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_PASSWORD: ${MAIL_PASSWORD}
|
||||||
MAIL_FROM: ${MAIL_FROM}
|
MAIL_FROM: ${MAIL_FROM}
|
||||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||||
|
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
|
||||||
BASE_URL: ${BASE_URL}
|
BASE_URL: ${BASE_URL}
|
||||||
TOWER_NAME: ${TOWER_NAME}
|
TOWER_NAME: ${TOWER_NAME}
|
||||||
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
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}/")
|
||||||
+319
-126
@@ -6,6 +6,7 @@
|
|||||||
<title>PPR Admin Interface</title>
|
<title>PPR Admin Interface</title>
|
||||||
<link rel="stylesheet" href="admin.css">
|
<link rel="stylesheet" href="admin.css">
|
||||||
<script src="lookups.js"></script>
|
<script src="lookups.js"></script>
|
||||||
|
<script src="topbar.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
@@ -48,8 +49,47 @@
|
|||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<!-- Arrivals Table -->
|
<!-- Local Traffic Table -->
|
||||||
<div class="ppr-table">
|
<div class="ppr-table">
|
||||||
|
<div class="table-header">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div id="local-flights-loading" class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
Loading local flights...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="local-flights-table-content" style="display: none;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Registration</th>
|
||||||
|
<th style="width: 30px; text-align: center;"></th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Flight</th>
|
||||||
|
<th>ETD</th>
|
||||||
|
<th>POB</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Circuits</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="local-flights-table-body">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="local-flights-no-data" class="no-data" style="display: none;">
|
||||||
|
<h3>No Local Traffic</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Arrivals Table -->
|
||||||
|
<div class="ppr-table" style="margin-top: 2rem;">
|
||||||
<div class="table-header">
|
<div class="table-header">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<span>🛬 Today's Pending Arrivals - <span id="arrivals-count">0</span></span>
|
<span>🛬 Today's Pending Arrivals - <span id="arrivals-count">0</span></span>
|
||||||
@@ -281,6 +321,50 @@
|
|||||||
|
|
||||||
<script src="shared.js"></script>
|
<script src="shared.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
function normalizeUtcDateString(dateStr) {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
let utcDateStr = String(dateStr).trim();
|
||||||
|
if (!utcDateStr.includes('T')) {
|
||||||
|
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||||
|
}
|
||||||
|
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||||
|
utcDateStr += 'Z';
|
||||||
|
}
|
||||||
|
return utcDateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUtcDate(dateStr) {
|
||||||
|
const normalized = normalizeUtcDateString(dateStr);
|
||||||
|
return normalized ? new Date(normalized) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUtcDateInput(date) {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUtcTimeInput(date) {
|
||||||
|
return date.toISOString().slice(11, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeOnly(dateStr) {
|
||||||
|
const date = parseUtcDate(dateStr);
|
||||||
|
return date && !Number.isNaN(date.getTime()) ? formatUtcTimeInput(date) : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUtcDayMonth(dateStr) {
|
||||||
|
const date = parseUtcDate(dateStr);
|
||||||
|
if (!date || Number.isNaN(date.getTime())) return '-';
|
||||||
|
const isoDate = formatUtcDateInput(date);
|
||||||
|
return `${isoDate.slice(8, 10)}/${isoDate.slice(5, 7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUtcWeekdayDayMonth(dateStr) {
|
||||||
|
const date = parseUtcDate(dateStr);
|
||||||
|
if (!date || Number.isNaN(date.getTime())) return '-';
|
||||||
|
const dayName = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getUTCDay()];
|
||||||
|
return `${dayName} ${formatUtcDayMonth(dateStr)}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPPRs() {
|
async function loadPPRs() {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
|
|
||||||
@@ -292,22 +376,21 @@
|
|||||||
|
|
||||||
loadPPRsTimeout = setTimeout(async () => {
|
loadPPRsTimeout = setTimeout(async () => {
|
||||||
// Load all tables simultaneously
|
// Load all tables simultaneously
|
||||||
await Promise.all([loadArrivals(), loadDepartures(), loadOverflights(), loadDeparted(), loadParked(), loadUpcoming()]);
|
await Promise.all([loadArrivals(), loadDepartures(), loadLocalFlights(), loadOverflights(), loadDeparted(), loadParked(), loadUpcoming()]);
|
||||||
loadPPRsTimeout = null;
|
loadPPRsTimeout = null;
|
||||||
}, 100); // Wait 100ms before executing to batch multiple calls
|
}, 100); // Wait 100ms before executing to batch multiple calls
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load arrivals (NEW and CONFIRMED status for PPR, DEPARTED for local flights)
|
// Load arrivals (NEW and CONFIRMED status for PPR only)
|
||||||
async function loadArrivals() {
|
async function loadArrivals() {
|
||||||
document.getElementById('arrivals-loading').style.display = 'block';
|
document.getElementById('arrivals-loading').style.display = 'block';
|
||||||
document.getElementById('arrivals-table-content').style.display = 'none';
|
document.getElementById('arrivals-table-content').style.display = 'none';
|
||||||
document.getElementById('arrivals-no-data').style.display = 'none';
|
document.getElementById('arrivals-no-data').style.display = 'none';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load PPRs, local flights, and booked-in arrivals
|
// Load PPRs and booked-in arrivals
|
||||||
const [pprResponse, localResponse, bookInResponse] = await Promise.all([
|
const [pprResponse, bookInResponse] = await Promise.all([
|
||||||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||||||
authenticatedFetch('/api/v1/local-flights/?status=DEPARTED&limit=1000'),
|
|
||||||
authenticatedFetch('/api/v1/arrivals/?limit=1000')
|
authenticatedFetch('/api/v1/arrivals/?limit=1000')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -328,24 +411,6 @@
|
|||||||
return etaDate === today;
|
return etaDate === today;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add local flights in DEPARTED status (in the air, heading back) - only those booked out today
|
|
||||||
if (localResponse.ok) {
|
|
||||||
const localFlights = await localResponse.json();
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
const localInAir = localFlights
|
|
||||||
.filter(flight => {
|
|
||||||
// Only include flights booked out today (created_dt)
|
|
||||||
if (!flight.created_dt) return false;
|
|
||||||
const createdDate = flight.created_dt.split('T')[0];
|
|
||||||
return createdDate === today;
|
|
||||||
})
|
|
||||||
.map(flight => ({
|
|
||||||
...flight,
|
|
||||||
isLocalFlight: true // Flag to distinguish from PPR
|
|
||||||
}));
|
|
||||||
arrivals.push(...localInAir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add booked-in arrivals from the arrivals table
|
// Add booked-in arrivals from the arrivals table
|
||||||
if (bookInResponse.ok) {
|
if (bookInResponse.ok) {
|
||||||
const bookedInArrivals = await bookInResponse.json();
|
const bookedInArrivals = await bookInResponse.json();
|
||||||
@@ -375,23 +440,18 @@
|
|||||||
document.getElementById('arrivals-loading').style.display = 'none';
|
document.getElementById('arrivals-loading').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load departures (LANDED status for PPR, GROUND/LOCAL for local flights)
|
// Load departures (LANDED status for PPR plus non-local airport departures)
|
||||||
async function loadDepartures() {
|
async function loadDepartures() {
|
||||||
document.getElementById('departures-loading').style.display = 'block';
|
document.getElementById('departures-loading').style.display = 'block';
|
||||||
document.getElementById('departures-table-content').style.display = 'none';
|
document.getElementById('departures-table-content').style.display = 'none';
|
||||||
document.getElementById('departures-no-data').style.display = 'none';
|
document.getElementById('departures-no-data').style.display = 'none';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load PPR departures, local flight departures, and airport departures simultaneously
|
// Load PPR departures and airport departures that are still pending departure
|
||||||
const [pprResponse, localBookedOutResponse, localOutGroundResponse, localLocalResponse, localCircuitResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([
|
const [pprResponse, depBookedOutResponse, depOutGroundResponse] = await Promise.all([
|
||||||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||||||
authenticatedFetch('/api/v1/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/local-flights/?status=CIRCUIT&limit=1000'),
|
|
||||||
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'),
|
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'),
|
||||||
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'),
|
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000')
|
||||||
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000')
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!pprResponse.ok) {
|
if (!pprResponse.ok) {
|
||||||
@@ -399,18 +459,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allPPRs = await pprResponse.json();
|
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 localCircuit = localCircuitResponse.ok ? await localCircuitResponse.json() : [];
|
|
||||||
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
|
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
|
||||||
const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : [];
|
const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : [];
|
||||||
const depLocal = depLocalResponse.ok ? await depLocalResponse.json() : [];
|
|
||||||
|
|
||||||
// Combine local flights
|
|
||||||
const allLocalFlights = [...localBookedOut, ...localOutGround, ...localLocal, ...localCircuit];
|
|
||||||
// Combine departures
|
// Combine departures
|
||||||
const allDepartures = [...depBookedOut, ...depOutGround, ...depLocal];
|
const allDepartures = [...depBookedOut, ...depOutGround];
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
// Filter for PPR departures with ETD today and LANDED status only
|
// Filter for PPR departures with ETD today and LANDED status only
|
||||||
@@ -423,21 +476,7 @@
|
|||||||
return etdDate === today;
|
return etdDate === today;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add local flights (GROUND and LOCAL status - ready to go) - only those booked out today
|
// Add departures to other airports that are not yet airborne locally
|
||||||
const localDepartures = allLocalFlights
|
|
||||||
.filter(flight => {
|
|
||||||
// Only include flights booked out today (created_dt)
|
|
||||||
if (!flight.created_dt) return false;
|
|
||||||
const createdDate = flight.created_dt.split('T')[0];
|
|
||||||
return createdDate === today;
|
|
||||||
})
|
|
||||||
.map(flight => ({
|
|
||||||
...flight,
|
|
||||||
isLocalFlight: true // Flag to distinguish from PPR
|
|
||||||
}));
|
|
||||||
departures.push(...localDepartures);
|
|
||||||
|
|
||||||
// Add departures to other airports (BOOKED_OUT, GROUND, and LOCAL status)
|
|
||||||
const depDepartures = allDepartures.map(flight => ({
|
const depDepartures = allDepartures.map(flight => ({
|
||||||
...flight,
|
...flight,
|
||||||
isDeparture: true // Flag to distinguish from PPR
|
isDeparture: true // Flag to distinguish from PPR
|
||||||
@@ -455,6 +494,57 @@
|
|||||||
document.getElementById('departures-loading').style.display = 'none';
|
document.getElementById('departures-loading').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadLocalFlights() {
|
||||||
|
document.getElementById('local-flights-loading').style.display = 'block';
|
||||||
|
document.getElementById('local-flights-table-content').style.display = 'none';
|
||||||
|
document.getElementById('local-flights-no-data').style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 (!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 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, ...pprLocalTraffic, ...departureLocalTraffic]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading local flights:', error);
|
||||||
|
if (error.message !== 'Session expired. Please log in again.') {
|
||||||
|
showNotification('Error loading local flights', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('local-flights-loading').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Load overflights (ACTIVE status only)
|
// Load overflights (ACTIVE status only)
|
||||||
async function loadOverflights() {
|
async function loadOverflights() {
|
||||||
document.getElementById('overflights-loading').style.display = 'block';
|
document.getElementById('overflights-loading').style.display = 'block';
|
||||||
@@ -491,7 +581,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort by call_dt most recent
|
// 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 = '';
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
@@ -540,7 +630,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Load departed aircraft (DEPARTED status with departed_dt today)
|
// Load departed aircraft (DEPARTED status with QSY/departed time today)
|
||||||
async function loadDeparted() {
|
async function loadDeparted() {
|
||||||
document.getElementById('departed-loading').style.display = 'block';
|
document.getElementById('departed-loading').style.display = 'block';
|
||||||
document.getElementById('departed-table-content').style.display = 'none';
|
document.getElementById('departed-table-content').style.display = 'none';
|
||||||
@@ -561,10 +651,10 @@
|
|||||||
|
|
||||||
// Filter for PPRs departed today (only PPR'd departures, exclude local/circuits)
|
// Filter for PPRs departed today (only PPR'd departures, exclude local/circuits)
|
||||||
const departed = allPPRs.filter(ppr => {
|
const departed = allPPRs.filter(ppr => {
|
||||||
if (!ppr.departed_dt || ppr.status !== 'DEPARTED') {
|
if (!ppr.qsy_dt || ppr.status !== 'DEPARTED') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const departedDate = ppr.departed_dt.split('T')[0];
|
const departedDate = ppr.qsy_dt.split('T')[0];
|
||||||
return departedDate === today;
|
return departedDate === today;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -605,9 +695,9 @@
|
|||||||
|
|
||||||
// Sort by departed time
|
// Sort by departed time
|
||||||
departed.sort((a, b) => {
|
departed.sort((a, b) => {
|
||||||
const aTime = a.departed_dt;
|
const aTime = a.isDeparture ? a.departed_dt : a.qsy_dt;
|
||||||
const bTime = b.departed_dt;
|
const bTime = b.isDeparture ? b.departed_dt : b.qsy_dt;
|
||||||
return new Date(aTime) - new Date(bTime);
|
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||||
});
|
});
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
@@ -651,7 +741,7 @@
|
|||||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"><span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span></td>
|
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"><span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span></td>
|
||||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.ac_call || '-'}</td>
|
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.ac_call || '-'}</td>
|
||||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
|
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
|
||||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
|
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.qsy_dt)}</td>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
@@ -712,7 +802,7 @@
|
|||||||
parked.sort((a, b) => {
|
parked.sort((a, b) => {
|
||||||
if (!a.landed_dt) return 1;
|
if (!a.landed_dt) return 1;
|
||||||
if (!b.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 = '';
|
tbody.innerHTML = '';
|
||||||
@@ -745,16 +835,14 @@
|
|||||||
arrivedDisplay = formatTimeOnly(ppr.landed_dt);
|
arrivedDisplay = formatTimeOnly(ppr.landed_dt);
|
||||||
} else {
|
} else {
|
||||||
// Not today - show date (DD/MM)
|
// Not today - show date (DD/MM)
|
||||||
const date = new Date(ppr.landed_dt);
|
arrivedDisplay = formatUtcDayMonth(ppr.landed_dt);
|
||||||
arrivedDisplay = date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format ETD as just the date (DD/MM)
|
// Format ETD as just the date (DD/MM)
|
||||||
let etdDisplay = '-';
|
let etdDisplay = '-';
|
||||||
if (ppr.etd) {
|
if (ppr.etd) {
|
||||||
const etdDate = new Date(ppr.etd);
|
etdDisplay = formatUtcDayMonth(ppr.etd);
|
||||||
etdDisplay = etdDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
@@ -816,7 +904,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort by ETA date and time
|
// 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 = '';
|
tbody.innerHTML = '';
|
||||||
// Don't auto-expand, keep collapsed by default
|
// Don't auto-expand, keep collapsed by default
|
||||||
@@ -830,10 +918,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Format date as Day DD/MM (e.g., Wed 11/12)
|
// Format date as Day DD/MM (e.g., Wed 11/12)
|
||||||
const etaDate = new Date(ppr.eta);
|
const dateDisplay = formatUtcWeekdayDayMonth(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}`;
|
|
||||||
|
|
||||||
// Create notes indicator if notes exist
|
// Create notes indicator if notes exist
|
||||||
const notesIndicator = ppr.notes && ppr.notes.trim() ?
|
const notesIndicator = ppr.notes && ppr.notes.trim() ?
|
||||||
@@ -919,7 +1004,7 @@
|
|||||||
const bTime = b.eta || b.departure_dt;
|
const bTime = b.eta || b.departure_dt;
|
||||||
if (!aTime) return 1;
|
if (!aTime) return 1;
|
||||||
if (!bTime) return -1;
|
if (!bTime) return -1;
|
||||||
return new Date(aTime) - new Date(bTime);
|
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||||
});
|
});
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
document.getElementById('arrivals-table-content').style.display = 'block';
|
document.getElementById('arrivals-table-content').style.display = 'block';
|
||||||
@@ -967,7 +1052,7 @@
|
|||||||
let departureTime = flight.departed_dt || flight.etd;
|
let departureTime = flight.departed_dt || flight.etd;
|
||||||
let etaTime = departureTime;
|
let etaTime = departureTime;
|
||||||
if (departureTime && flight.duration) {
|
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
|
etaTime = new Date(departTime.getTime() + flight.duration * 60000).toISOString(); // duration is in minutes
|
||||||
}
|
}
|
||||||
eta = etaTime ? formatTimeOnly(etaTime) : '-';
|
eta = etaTime ? formatTimeOnly(etaTime) : '-';
|
||||||
@@ -1011,9 +1096,6 @@
|
|||||||
// Different action buttons based on status
|
// Different action buttons based on status
|
||||||
if (flight.status === 'INBOUND') {
|
if (flight.status === 'INBOUND') {
|
||||||
actionButtons = `
|
actionButtons = `
|
||||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Mark as Local">
|
|
||||||
LOCAL
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentBookedInArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
|
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentBookedInArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
|
||||||
LAND
|
LAND
|
||||||
</button>
|
</button>
|
||||||
@@ -1027,9 +1109,6 @@
|
|||||||
T&G
|
T&G
|
||||||
</button>`;
|
</button>`;
|
||||||
actionButtons = `
|
actionButtons = `
|
||||||
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'CIRCUIT')" title="Join Circuit">
|
|
||||||
CIRCUIT
|
|
||||||
</button>
|
|
||||||
${circuitButton}
|
${circuitButton}
|
||||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
|
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
|
||||||
LAND
|
LAND
|
||||||
@@ -1041,9 +1120,6 @@
|
|||||||
T&G
|
T&G
|
||||||
</button>`;
|
</button>`;
|
||||||
actionButtons = `
|
actionButtons = `
|
||||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Area">
|
|
||||||
LOCAL
|
|
||||||
</button>
|
|
||||||
${circuitButton}
|
${circuitButton}
|
||||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
|
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
|
||||||
LAND
|
LAND
|
||||||
@@ -1086,9 +1162,6 @@
|
|||||||
: '';
|
: '';
|
||||||
actionButtons = `
|
actionButtons = `
|
||||||
${ackButton}
|
${ackButton}
|
||||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); activatePPR(${flight.id}, '${flight.ac_reg}', ${flight.out_to ? 'true' : 'false'})" title="Activate PPR - create arrival/departure records">
|
|
||||||
ACTIVATE
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); showTimestampModal('LANDED', ${flight.id})" title="Mark as Landed">
|
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); showTimestampModal('LANDED', ${flight.id})" title="Mark as Landed">
|
||||||
LAND
|
LAND
|
||||||
</button>
|
</button>
|
||||||
@@ -1111,6 +1184,143 @@
|
|||||||
setupTooltips();
|
setupTooltips();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localFlightStatusBadge(status) {
|
||||||
|
const labels = {
|
||||||
|
BOOKED_OUT: 'BOOKED OUT',
|
||||||
|
GROUND: 'GROUND',
|
||||||
|
DEPARTED: 'AIRBORNE',
|
||||||
|
LOCAL: 'LOCAL',
|
||||||
|
CIRCUIT: 'CIRCUIT',
|
||||||
|
CIRCUIT_DOWNWIND: 'DOWNWIND',
|
||||||
|
CIRCUIT_BASE: 'BASE',
|
||||||
|
CIRCUIT_FINAL: 'FINAL',
|
||||||
|
LANDED: 'LANDED'
|
||||||
|
};
|
||||||
|
return `<span style="background-color: #6c757d; color: white; padding: 2px 6px; border-radius: 3px; font-size: 0.8rem;">${labels[status] || status || '-'}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function displayLocalFlights(localFlights) {
|
||||||
|
const tbody = document.getElementById('local-flights-table-body');
|
||||||
|
const recordCount = document.getElementById('local-flights-count');
|
||||||
|
|
||||||
|
recordCount.textContent = localFlights.length;
|
||||||
|
if (localFlights.length === 0) {
|
||||||
|
document.getElementById('local-flights-no-data').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localFlights.sort((a, b) => {
|
||||||
|
const aTime = a.etd || a.created_dt;
|
||||||
|
const bTime = b.etd || b.created_dt;
|
||||||
|
if (!aTime) return 1;
|
||||||
|
if (!bTime) return -1;
|
||||||
|
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
document.getElementById('local-flights-table-content').style.display = 'block';
|
||||||
|
|
||||||
|
const circuitCounts = await loadLocalFlightCircuitCounts(localFlights.filter(flight => !flight.isPPRLocalTraffic && !flight.isDepartureLocalTraffic));
|
||||||
|
|
||||||
|
for (const flight of localFlights) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
const isPPR = flight.isPPRLocalTraffic;
|
||||||
|
const isDeparture = flight.isDepartureLocalTraffic;
|
||||||
|
row.onclick = () => isPPR ? openPPRModal(flight.id) : (isDeparture ? openDepartureEditModal(flight.id) : openLocalFlightEditModal(flight.id));
|
||||||
|
|
||||||
|
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 = 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 = (isPPR || isDeparture) ? '-' : circuitCounts[flight.id] ?? flight.circuits ?? 0;
|
||||||
|
|
||||||
|
let actionButtons = '';
|
||||||
|
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
|
||||||
|
</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('${takeoffStatus}', ${flight.id}, true)" title="Mark as airborne">
|
||||||
|
TAKE OFF
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
} else if (['DEPARTED', 'LOCAL', 'CIRCUIT', 'CIRCUIT_DOWNWIND', 'CIRCUIT_BASE', 'CIRCUIT_FINAL'].includes(flight.status)) {
|
||||||
|
actionButtons = `
|
||||||
|
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
|
||||||
|
T&G
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, true)" title="Mark as Landed">
|
||||||
|
LAND
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
actionButtons = '<span style="color: #999;">-</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${aircraftDisplay}</td>
|
||||||
|
<td style="text-align: center; width: 30px;">${typeIcon}</td>
|
||||||
|
<td>${isPPR ? flight.ac_type || '-' : flight.type || '-'}</td>
|
||||||
|
<td>${flightType}</td>
|
||||||
|
<td>${etd}</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>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocalFlightCircuitCounts(localFlights) {
|
||||||
|
const counts = {};
|
||||||
|
await Promise.all(localFlights.map(async (flight) => {
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch(`/api/v1/circuits/flight/${flight.id}`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const circuits = await response.json();
|
||||||
|
counts[flight.id] = circuits.length;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Unable to load circuits for local flight ${flight.id}:`, error);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
function pprNeedsStripAck(ppr) {
|
function pprNeedsStripAck(ppr) {
|
||||||
return (ppr.status === 'NEW' || ppr.status === 'CONFIRMED') && !ppr.acknowledged_dt;
|
return (ppr.status === 'NEW' || ppr.status === 'CONFIRMED') && !ppr.acknowledged_dt;
|
||||||
}
|
}
|
||||||
@@ -1134,27 +1344,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activatePPR(pprId, acReg, hasDeparture) {
|
|
||||||
const msg = `Activate PPR for ${acReg}?\nThis will create an INBOUND arrival.`
|
|
||||||
+ (hasDeparture ? '\nThe outbound departure will appear automatically when the aircraft lands.' : '');
|
|
||||||
if (!confirm(msg)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch(`/api/v1/pprs/${pprId}/activate`, { method: 'POST' });
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.json().catch(() => ({}));
|
|
||||||
showNotification(err.detail || 'Failed to activate PPR', true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await response.json();
|
|
||||||
showNotification(result.message || 'PPR activated');
|
|
||||||
await loadPPRs();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error activating PPR:', error);
|
|
||||||
showNotification('Error activating PPR', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function displayDepartures(departures) {
|
async function displayDepartures(departures) {
|
||||||
const tbody = document.getElementById('departures-table-body');
|
const tbody = document.getElementById('departures-table-body');
|
||||||
const recordCount = document.getElementById('departures-count');
|
const recordCount = document.getElementById('departures-count');
|
||||||
@@ -1171,7 +1360,7 @@
|
|||||||
const bTime = b.etd || b.created_dt;
|
const bTime = b.etd || b.created_dt;
|
||||||
if (!aTime) return 1;
|
if (!aTime) return 1;
|
||||||
if (!bTime) return -1;
|
if (!bTime) return -1;
|
||||||
return new Date(aTime) - new Date(bTime);
|
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||||
});
|
});
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
document.getElementById('departures-table-content').style.display = 'block';
|
document.getElementById('departures-table-content').style.display = 'block';
|
||||||
@@ -1245,8 +1434,8 @@
|
|||||||
`;
|
`;
|
||||||
} else if (flight.status === 'LOCAL') {
|
} else if (flight.status === 'LOCAL') {
|
||||||
actionButtons = `
|
actionButtons = `
|
||||||
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; updateLocalFlightStatusFromTable(${flight.id}, 'CIRCUIT')" title="Rejoin Circuit">
|
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, true)" title="Mark as Landed">
|
||||||
REJOIN
|
LAND
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
} else if (flight.status === 'CIRCUIT') {
|
} else if (flight.status === 'CIRCUIT') {
|
||||||
@@ -1255,9 +1444,6 @@
|
|||||||
T&G
|
T&G
|
||||||
</button>`;
|
</button>`;
|
||||||
actionButtons = `
|
actionButtons = `
|
||||||
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; updateLocalFlightStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Area">
|
|
||||||
LOCAL
|
|
||||||
</button>
|
|
||||||
${circuitButton}
|
${circuitButton}
|
||||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, true)" title="Mark as Landed">
|
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, true)" title="Mark as Landed">
|
||||||
LAND
|
LAND
|
||||||
@@ -1324,15 +1510,12 @@
|
|||||||
// Action buttons for arrival
|
// Action buttons for arrival
|
||||||
if (flight.status === 'LOCAL') {
|
if (flight.status === 'LOCAL') {
|
||||||
actionButtons = `
|
actionButtons = `
|
||||||
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; updateArrivalStatusFromTable(${flight.id}, 'CIRCUIT')" title="Rejoin Circuit">
|
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; updateArrivalStatusFromTable(${flight.id}, 'LANDED')" title="Mark as Landed">
|
||||||
REJOIN
|
LAND
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
} else if (flight.status === 'CIRCUIT') {
|
} else if (flight.status === 'CIRCUIT') {
|
||||||
actionButtons = `
|
actionButtons = `
|
||||||
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Area">
|
|
||||||
LOCAL
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showCircuitModal(null, ${flight.id})" title="Record Touch & Go">
|
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showCircuitModal(null, ${flight.id})" title="Record Touch & Go">
|
||||||
T&G
|
T&G
|
||||||
</button>
|
</button>
|
||||||
@@ -1360,11 +1543,21 @@
|
|||||||
fuel = flight.fuel || '-';
|
fuel = flight.fuel || '-';
|
||||||
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
|
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
|
||||||
|
|
||||||
actionButtons = `
|
if (flight.status === 'LANDED') {
|
||||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
actionButtons = `
|
||||||
TAKE OFF
|
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('LOCAL', ${flight.id})" title="Mark as Local">
|
||||||
</button>
|
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 = `
|
row.innerHTML = `
|
||||||
@@ -1472,7 +1665,7 @@
|
|||||||
// Override: admin uses loadDepartures after circuit save
|
// Override: admin uses loadDepartures after circuit save
|
||||||
async function afterCircuitSaved() {
|
async function afterCircuitSaved() {
|
||||||
closeCircuitModal();
|
closeCircuitModal();
|
||||||
loadDepartures();
|
loadLocalFlights();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+162
-65
@@ -6,6 +6,7 @@
|
|||||||
<title>ATC Management Interface</title>
|
<title>ATC Management Interface</title>
|
||||||
<link rel="stylesheet" href="admin.css">
|
<link rel="stylesheet" href="admin.css">
|
||||||
<script src="lookups.js"></script>
|
<script src="lookups.js"></script>
|
||||||
|
<script src="topbar.js"></script>
|
||||||
<style>
|
<style>
|
||||||
/* ATC-specific styles */
|
/* ATC-specific styles */
|
||||||
.atc-container {
|
.atc-container {
|
||||||
@@ -231,7 +232,7 @@
|
|||||||
⚙️ Admin
|
⚙️ Admin
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu" id="adminDropdownMenu">
|
<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 = '/reports'">📊 Reports</a>
|
||||||
<a href="#" onclick="window.location.href = '/bulk-log'">🧾 Bulk Flight Log</a>
|
<a href="#" onclick="window.location.href = '/bulk-log'">🧾 Bulk Flight Log</a>
|
||||||
<a href="#" onclick="window.location.href = '/journal'">📔 Journal Log</a>
|
<a href="#" onclick="window.location.href = '/journal'">📔 Journal Log</a>
|
||||||
@@ -254,11 +255,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Row 1: Local Area -->
|
<!-- Row 1: Local Traffic -->
|
||||||
<div class="atc-section">
|
<div class="atc-section">
|
||||||
<h2>📍 Local Area <span class="count" id="local-count">0</span></h2>
|
<h2>📍 Local Traffic <span class="count" id="local-count">0</span></h2>
|
||||||
<div class="aircraft-list" id="local-list">
|
<div class="aircraft-list" id="local-list">
|
||||||
<div class="no-aircraft">No aircraft in local area</div>
|
<div class="no-aircraft">No local traffic</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -299,6 +300,50 @@
|
|||||||
|
|
||||||
<script src="shared.js"></script>
|
<script src="shared.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
function normalizeUtcDateString(dateStr) {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
let utcDateStr = String(dateStr).trim();
|
||||||
|
if (!utcDateStr.includes('T')) {
|
||||||
|
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||||
|
}
|
||||||
|
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||||
|
utcDateStr += 'Z';
|
||||||
|
}
|
||||||
|
return utcDateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUtcDate(dateStr) {
|
||||||
|
const normalized = normalizeUtcDateString(dateStr);
|
||||||
|
return normalized ? new Date(normalized) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUtcDateInput(date) {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUtcTimeInput(date) {
|
||||||
|
return date.toISOString().slice(11, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeOnly(dateStr) {
|
||||||
|
const date = parseUtcDate(dateStr);
|
||||||
|
return date && !Number.isNaN(date.getTime()) ? formatUtcTimeInput(date) : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUtcDayMonth(dateStr) {
|
||||||
|
const date = parseUtcDate(dateStr);
|
||||||
|
if (!date || Number.isNaN(date.getTime())) return '-';
|
||||||
|
const isoDate = formatUtcDateInput(date);
|
||||||
|
return `${isoDate.slice(8, 10)}/${isoDate.slice(5, 7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUtcWeekdayDayMonth(dateStr) {
|
||||||
|
const date = parseUtcDate(dateStr);
|
||||||
|
if (!date || Number.isNaN(date.getTime())) return '-';
|
||||||
|
const dayName = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getUTCDay()];
|
||||||
|
return `${dayName} ${formatUtcDayMonth(dateStr)}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPPRs() {
|
async function loadPPRs() {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
|
|
||||||
@@ -400,15 +445,13 @@
|
|||||||
document.getElementById('departures-no-data').style.display = 'none';
|
document.getElementById('departures-no-data').style.display = 'none';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load PPR departures, local flight departures, and airport departures simultaneously
|
// Load PPR departures, local flight departures, and airport departures that are still pending departure
|
||||||
const [pprResponse, localBookedOutResponse, localOutGroundResponse, localLocalResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([
|
const [pprResponse, localBookedOutResponse, localOutGroundResponse, depBookedOutResponse, depOutGroundResponse] = await Promise.all([
|
||||||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||||||
authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&limit=1000'),
|
authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&limit=1000'),
|
||||||
authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000'),
|
authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000'),
|
||||||
authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'),
|
|
||||||
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'),
|
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'),
|
||||||
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'),
|
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000')
|
||||||
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000')
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!pprResponse.ok) {
|
if (!pprResponse.ok) {
|
||||||
@@ -418,15 +461,13 @@
|
|||||||
const allPPRs = await pprResponse.json();
|
const allPPRs = await pprResponse.json();
|
||||||
const localBookedOut = localBookedOutResponse.ok ? await localBookedOutResponse.json() : [];
|
const localBookedOut = localBookedOutResponse.ok ? await localBookedOutResponse.json() : [];
|
||||||
const localOutGround = localOutGroundResponse.ok ? await localOutGroundResponse.json() : [];
|
const localOutGround = localOutGroundResponse.ok ? await localOutGroundResponse.json() : [];
|
||||||
const localLocal = localLocalResponse.ok ? await localLocalResponse.json() : [];
|
|
||||||
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
|
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
|
||||||
const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : [];
|
const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : [];
|
||||||
const depLocal = depLocalResponse.ok ? await depLocalResponse.json() : [];
|
|
||||||
|
|
||||||
// Combine local flights
|
// Combine local flights
|
||||||
const allLocalFlights = [...localBookedOut, ...localOutGround, ...localLocal];
|
const allLocalFlights = [...localBookedOut, ...localOutGround];
|
||||||
// Combine departures
|
// Combine departures
|
||||||
const allDepartures = [...depBookedOut, ...depOutGround, ...depLocal];
|
const allDepartures = [...depBookedOut, ...depOutGround];
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
// Filter for PPR departures with ETD today and LANDED status only
|
// Filter for PPR departures with ETD today and LANDED status only
|
||||||
@@ -439,7 +480,7 @@
|
|||||||
return etdDate === today;
|
return etdDate === today;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add local flights (GROUND and LOCAL status - ready to go) - only those booked out today
|
// Add local flights that are not yet airborne locally - only those booked out today
|
||||||
const localDepartures = allLocalFlights
|
const localDepartures = allLocalFlights
|
||||||
.filter(flight => {
|
.filter(flight => {
|
||||||
// Only include flights booked out today (created_dt)
|
// Only include flights booked out today (created_dt)
|
||||||
@@ -453,7 +494,7 @@
|
|||||||
}));
|
}));
|
||||||
departures.push(...localDepartures);
|
departures.push(...localDepartures);
|
||||||
|
|
||||||
// Add departures to other airports (BOOKED_OUT, GROUND, and LOCAL status)
|
// Add departures to other airports that are not yet airborne locally
|
||||||
const depDepartures = allDepartures.map(flight => ({
|
const depDepartures = allDepartures.map(flight => ({
|
||||||
...flight,
|
...flight,
|
||||||
isDeparture: true // Flag to distinguish from PPR
|
isDeparture: true // Flag to distinguish from PPR
|
||||||
@@ -507,7 +548,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort by call_dt most recent
|
// 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 = '';
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
@@ -557,7 +598,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Load departed aircraft (DEPARTED status with departed_dt today)
|
// Load departed aircraft (DEPARTED status with QSY/departed time today)
|
||||||
async function loadDeparted() {
|
async function loadDeparted() {
|
||||||
document.getElementById('departed-loading').style.display = 'block';
|
document.getElementById('departed-loading').style.display = 'block';
|
||||||
document.getElementById('departed-table-content').style.display = 'none';
|
document.getElementById('departed-table-content').style.display = 'none';
|
||||||
@@ -578,10 +619,10 @@
|
|||||||
|
|
||||||
// Filter for PPRs departed today (only PPR'd departures, exclude local/circuits)
|
// Filter for PPRs departed today (only PPR'd departures, exclude local/circuits)
|
||||||
const departed = allPPRs.filter(ppr => {
|
const departed = allPPRs.filter(ppr => {
|
||||||
if (!ppr.departed_dt || ppr.status !== 'DEPARTED') {
|
if (!ppr.qsy_dt || ppr.status !== 'DEPARTED') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const departedDate = ppr.departed_dt.split('T')[0];
|
const departedDate = ppr.qsy_dt.split('T')[0];
|
||||||
return departedDate === today;
|
return departedDate === today;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -622,9 +663,9 @@
|
|||||||
|
|
||||||
// Sort by departed time
|
// Sort by departed time
|
||||||
departed.sort((a, b) => {
|
departed.sort((a, b) => {
|
||||||
const aTime = a.departed_dt;
|
const aTime = a.isDeparture ? a.departed_dt : a.qsy_dt;
|
||||||
const bTime = b.departed_dt;
|
const bTime = b.isDeparture ? b.departed_dt : b.qsy_dt;
|
||||||
return new Date(aTime) - new Date(bTime);
|
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||||
});
|
});
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
@@ -668,7 +709,7 @@
|
|||||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"><span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span></td>
|
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"><span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span></td>
|
||||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.ac_call || '-'}</td>
|
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.ac_call || '-'}</td>
|
||||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
|
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
|
||||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
|
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.qsy_dt)}</td>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
@@ -729,7 +770,7 @@
|
|||||||
parked.sort((a, b) => {
|
parked.sort((a, b) => {
|
||||||
if (!a.landed_dt) return 1;
|
if (!a.landed_dt) return 1;
|
||||||
if (!b.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 = '';
|
tbody.innerHTML = '';
|
||||||
@@ -762,16 +803,14 @@
|
|||||||
arrivedDisplay = formatTimeOnly(ppr.landed_dt);
|
arrivedDisplay = formatTimeOnly(ppr.landed_dt);
|
||||||
} else {
|
} else {
|
||||||
// Not today - show date (DD/MM)
|
// Not today - show date (DD/MM)
|
||||||
const date = new Date(ppr.landed_dt);
|
arrivedDisplay = formatUtcDayMonth(ppr.landed_dt);
|
||||||
arrivedDisplay = date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format ETD as just the date (DD/MM)
|
// Format ETD as just the date (DD/MM)
|
||||||
let etdDisplay = '-';
|
let etdDisplay = '-';
|
||||||
if (ppr.etd) {
|
if (ppr.etd) {
|
||||||
const etdDate = new Date(ppr.etd);
|
etdDisplay = formatUtcDayMonth(ppr.etd);
|
||||||
etdDisplay = etdDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
@@ -833,7 +872,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort by ETA date and time
|
// 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 = '';
|
tbody.innerHTML = '';
|
||||||
// Don't auto-expand, keep collapsed by default
|
// Don't auto-expand, keep collapsed by default
|
||||||
@@ -844,10 +883,7 @@
|
|||||||
row.style.cssText = 'font-size: 0.85rem !important;';
|
row.style.cssText = 'font-size: 0.85rem !important;';
|
||||||
|
|
||||||
// Format date as Day DD/MM (e.g., Wed 11/12)
|
// Format date as Day DD/MM (e.g., Wed 11/12)
|
||||||
const etaDate = new Date(ppr.eta);
|
const dateDisplay = formatUtcWeekdayDayMonth(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}`;
|
|
||||||
|
|
||||||
// Create notes indicator if notes exist
|
// Create notes indicator if notes exist
|
||||||
const notesIndicator = ppr.notes && ppr.notes.trim() ?
|
const notesIndicator = ppr.notes && ppr.notes.trim() ?
|
||||||
@@ -929,7 +965,7 @@
|
|||||||
const bTime = b.eta || b.departure_dt;
|
const bTime = b.eta || b.departure_dt;
|
||||||
if (!aTime) return 1;
|
if (!aTime) return 1;
|
||||||
if (!bTime) return -1;
|
if (!bTime) return -1;
|
||||||
return new Date(aTime) - new Date(bTime);
|
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||||
});
|
});
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
document.getElementById('arrivals-table-content').style.display = 'block';
|
document.getElementById('arrivals-table-content').style.display = 'block';
|
||||||
@@ -974,7 +1010,7 @@
|
|||||||
let departureTime = flight.departed_dt || flight.etd;
|
let departureTime = flight.departed_dt || flight.etd;
|
||||||
let etaTime = departureTime;
|
let etaTime = departureTime;
|
||||||
if (departureTime && flight.duration) {
|
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
|
etaTime = new Date(departTime.getTime() + flight.duration * 60000).toISOString(); // duration is in minutes
|
||||||
}
|
}
|
||||||
eta = etaTime ? formatTimeOnly(etaTime) : '-';
|
eta = etaTime ? formatTimeOnly(etaTime) : '-';
|
||||||
@@ -1048,7 +1084,7 @@
|
|||||||
T&G
|
T&G
|
||||||
</button>`;
|
</button>`;
|
||||||
actionButtons = `
|
actionButtons = `
|
||||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Area">
|
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Traffic">
|
||||||
LOCAL
|
LOCAL
|
||||||
</button>
|
</button>
|
||||||
${circuitButton}
|
${circuitButton}
|
||||||
@@ -1125,7 +1161,7 @@
|
|||||||
const bTime = b.etd || b.created_dt;
|
const bTime = b.etd || b.created_dt;
|
||||||
if (!aTime) return 1;
|
if (!aTime) return 1;
|
||||||
if (!bTime) return -1;
|
if (!bTime) return -1;
|
||||||
return new Date(aTime) - new Date(bTime);
|
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||||
});
|
});
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
document.getElementById('departures-table-content').style.display = 'block';
|
document.getElementById('departures-table-content').style.display = 'block';
|
||||||
@@ -1176,12 +1212,13 @@
|
|||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
} else if (flight.status === 'GROUND') {
|
} else if (flight.status === 'GROUND') {
|
||||||
|
const takeoffStatus = flight.flight_type === 'CIRCUITS' ? 'CIRCUIT' : 'LOCAL';
|
||||||
actionButtons = `
|
actionButtons = `
|
||||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('DEPARTED', ${flight.id}, true)" title="Mark as Departed">
|
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('${takeoffStatus}', ${flight.id}, true)" title="Mark as airborne">
|
||||||
TAKE OFF
|
TAKE OFF
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
} else if (flight.status === 'DEPARTED') {
|
} else if (['DEPARTED', 'LOCAL', 'CIRCUIT', 'CIRCUIT_DOWNWIND', 'CIRCUIT_BASE', 'CIRCUIT_FINAL'].includes(flight.status)) {
|
||||||
// Allow touch and go for all local flight types
|
// Allow touch and go for all local flight types
|
||||||
let circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
|
let circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
|
||||||
T&G
|
T&G
|
||||||
@@ -1253,11 +1290,21 @@
|
|||||||
fuel = flight.fuel || '-';
|
fuel = flight.fuel || '-';
|
||||||
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
|
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
|
||||||
|
|
||||||
actionButtons = `
|
if (flight.status === 'LANDED') {
|
||||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
actionButtons = `
|
||||||
TAKE OFF
|
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('LOCAL', ${flight.id})" title="Mark as Local">
|
||||||
</button>
|
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 = `
|
row.innerHTML = `
|
||||||
@@ -1357,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)
|
// Load departing aircraft (ready to take off)
|
||||||
async function loadDepartingAircraft() {
|
async function loadDepartingAircraft() {
|
||||||
try {
|
try {
|
||||||
const [groundDeparturesResponse, groundLocalResponse] = await Promise.all([
|
const [pprResponse, groundDeparturesResponse, groundLocalResponse] = await Promise.all([
|
||||||
|
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||||||
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'),
|
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'),
|
||||||
authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000')
|
authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let groundAircraft = [];
|
let groundAircraft = [];
|
||||||
if (groundDeparturesResponse.ok) groundAircraft = await groundDeparturesResponse.json();
|
if (pprResponse.ok) {
|
||||||
|
const today = getLocalDateString();
|
||||||
|
groundAircraft = (await pprResponse.json())
|
||||||
|
.filter(ppr => ppr.status === 'LANDED' && ppr.etd && ppr.etd.split('T')[0] === today)
|
||||||
|
.map(ppr => ({ ...ppr, isPPR: true }));
|
||||||
|
}
|
||||||
|
if (groundDeparturesResponse.ok) groundAircraft = groundAircraft.concat(await groundDeparturesResponse.json());
|
||||||
if (groundLocalResponse.ok) groundAircraft = groundAircraft.concat((await groundLocalResponse.json()).map(l => ({ ...l, isLocalFlight: true })));
|
if (groundLocalResponse.ok) groundAircraft = groundAircraft.concat((await groundLocalResponse.json()).map(l => ({ ...l, isLocalFlight: true })));
|
||||||
|
groundAircraft = groundAircraft.filter(ac => isTodayRecord(ac, ['created_dt', 'etd']));
|
||||||
|
|
||||||
displayDepartingAircraft(groundAircraft.map(ac => ({
|
displayDepartingAircraft(groundAircraft.map(ac => ({
|
||||||
...ac,
|
...ac,
|
||||||
isDeparture: !ac.isLocalFlight
|
isDeparture: !ac.isLocalFlight && !ac.isPPR
|
||||||
})));
|
})));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading departing aircraft:', error);
|
console.error('Error loading departing aircraft:', error);
|
||||||
@@ -1394,6 +1461,7 @@
|
|||||||
const type = ac.ac_type || ac.type;
|
const type = ac.ac_type || ac.type;
|
||||||
const dest = ac.out_to;
|
const dest = ac.out_to;
|
||||||
const isLocal = ac.isLocalFlight;
|
const isLocal = ac.isLocalFlight;
|
||||||
|
const isPPR = ac.isPPR;
|
||||||
|
|
||||||
// All aircraft in awaiting departure are in GROUND status
|
// All aircraft in awaiting departure are in GROUND status
|
||||||
let takeoffOnclick, buttonText, buttonTitle, clickType;
|
let takeoffOnclick, buttonText, buttonTitle, clickType;
|
||||||
@@ -1404,13 +1472,18 @@
|
|||||||
buttonText = 'TAKE OFF';
|
buttonText = 'TAKE OFF';
|
||||||
buttonTitle = takeoffTitle;
|
buttonTitle = takeoffTitle;
|
||||||
clickType = 'local';
|
clickType = 'local';
|
||||||
|
} else if (isPPR) {
|
||||||
|
takeoffOnclick = `event.stopPropagation(); showTimestampModal('LOCAL', ${ac.id})`;
|
||||||
|
buttonText = 'TAKE OFF';
|
||||||
|
buttonTitle = 'Mark as Local';
|
||||||
|
clickType = 'ppr';
|
||||||
} else {
|
} else {
|
||||||
takeoffOnclick = `event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('LOCAL', ${ac.id}, false, true)`;
|
takeoffOnclick = `event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('LOCAL', ${ac.id}, false, true)`;
|
||||||
buttonText = 'TAKE OFF';
|
buttonText = 'TAKE OFF';
|
||||||
buttonTitle = 'Mark as Local';
|
buttonTitle = 'Mark as Local';
|
||||||
clickType = 'departure';
|
clickType = 'departure';
|
||||||
}
|
}
|
||||||
const itemClass = isLocal ? 'local-flight' : 'departure';
|
const itemClass = isLocal ? 'local-flight' : (isPPR ? 'departure' : 'departure');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${clickType}')">
|
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${clickType}')">
|
||||||
@@ -1428,6 +1501,7 @@
|
|||||||
async function loadLocalAircraft() {
|
async function loadLocalAircraft() {
|
||||||
try {
|
try {
|
||||||
const response = await Promise.all([
|
const response = await Promise.all([
|
||||||
|
authenticatedFetch('/api/v1/pprs/?status=LOCAL&limit=1000'),
|
||||||
authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'),
|
authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'),
|
||||||
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000'),
|
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000'),
|
||||||
authenticatedFetch('/api/v1/arrivals/?status=LOCAL&limit=1000'),
|
authenticatedFetch('/api/v1/arrivals/?status=LOCAL&limit=1000'),
|
||||||
@@ -1435,10 +1509,19 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
let locals = [];
|
let locals = [];
|
||||||
if (response[0].ok) locals = (await response[0].json()).map(l => ({ ...l, isLocalFlight: true }));
|
if (response[0].ok) locals = (await response[0].json()).map(p => ({ ...p, isPPR: true }));
|
||||||
if (response[1].ok) locals = locals.concat((await response[1].json()).map(d => ({ ...d, isDeparture: true })));
|
if (response[1].ok) locals = locals.concat((await response[1].json()).map(l => ({ ...l, isLocalFlight: true })));
|
||||||
if (response[2].ok) locals = locals.concat((await response[2].json()).map(a => ({ ...a, isArrival: true })));
|
if (response[2].ok) locals = locals.concat((await response[2].json()).map(d => ({ ...d, isDeparture: true })));
|
||||||
if (response[3].ok) locals = locals.concat((await response[3].json()).map(o => ({ ...o, isOverflight: true })));
|
if (response[3].ok) locals = locals.concat((await response[3].json()).map(a => ({ ...a, isArrival: true })));
|
||||||
|
if (response[4].ok) locals = locals.concat((await response[4].json()).map(o => ({ ...o, isOverflight: true })));
|
||||||
|
locals = locals.filter(ac => {
|
||||||
|
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);
|
displayLocalAircraft(locals);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1453,7 +1536,7 @@
|
|||||||
countEl.textContent = aircraft.length;
|
countEl.textContent = aircraft.length;
|
||||||
|
|
||||||
if (aircraft.length === 0) {
|
if (aircraft.length === 0) {
|
||||||
container.innerHTML = '<div class="no-aircraft">No aircraft in local area</div>';
|
container.innerHTML = '<div class="no-aircraft">No local traffic</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1462,9 +1545,12 @@
|
|||||||
const type = ac.type || ac.ac_type || ac.aircraft_type || '';
|
const type = ac.type || ac.ac_type || ac.aircraft_type || '';
|
||||||
const dest = ac.out_to;
|
const dest = ac.out_to;
|
||||||
const isDeparture = ac.isDeparture;
|
const isDeparture = ac.isDeparture;
|
||||||
|
const isPPR = ac.isPPR;
|
||||||
|
|
||||||
let buttons;
|
let buttons;
|
||||||
if (isDeparture) {
|
if (isPPR) {
|
||||||
|
buttons = `<button class="status-btn" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${ac.id})">QSY</button>`;
|
||||||
|
} else if (isDeparture) {
|
||||||
// Departure in LOCAL status - show QSY and REJOIN buttons
|
// Departure in LOCAL status - show QSY and REJOIN buttons
|
||||||
buttons = `
|
buttons = `
|
||||||
<button class="status-btn" onclick="event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('DEPARTED', ${ac.id}, false, true)">QSY</button>
|
<button class="status-btn" onclick="event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('DEPARTED', ${ac.id}, false, true)">QSY</button>
|
||||||
@@ -1477,9 +1563,9 @@
|
|||||||
// Overflight in ACTIVE status - show QSY button
|
// Overflight in ACTIVE status - show QSY button
|
||||||
buttons = `<button class="status-btn" onclick="event.stopPropagation(); currentOverflightId = '${ac.id}'; showOverflightQSYModal()">QSY</button>`;
|
buttons = `<button class="status-btn" onclick="event.stopPropagation(); currentOverflightId = '${ac.id}'; showOverflightQSYModal()">QSY</button>`;
|
||||||
}
|
}
|
||||||
const itemClass = isDeparture ? 'departure' : (ac.isArrival ? 'inbound' : (ac.isOverflight ? 'overflight' : 'local-flight'));
|
const itemClass = isDeparture || isPPR ? 'departure' : (ac.isArrival ? 'inbound' : (ac.isOverflight ? 'overflight' : 'local-flight'));
|
||||||
const detailsText = isDeparture ? `${type}${dest ? ` → ${dest}` : ` (Local)`}` : (ac.isOverflight ? `${ac.departure_airfield || '?'} → ${ac.destination_airfield || '?'}` : (ac.isArrival ? `${type} from ${ac.in_from || '?'}` : `${type}${dest ? ` → ${dest}` : ` Local Flight`}`));
|
const detailsText = isDeparture || isPPR ? `${type}${dest ? ` → ${dest}` : ` (Local)`}` : (ac.isOverflight ? `${ac.departure_airfield || '?'} → ${ac.destination_airfield || '?'}` : (ac.isArrival ? `${type} from ${ac.in_from || '?'}` : `${type}${dest ? ` → ${dest}` : ` Local Flight`}`));
|
||||||
const entityType = isDeparture ? 'departure' : (ac.isArrival ? 'arrival' : (ac.isOverflight ? 'overflight' : 'local'));
|
const entityType = isPPR ? 'ppr' : (isDeparture ? 'departure' : (ac.isArrival ? 'arrival' : (ac.isOverflight ? 'overflight' : 'local')));
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${entityType}')">
|
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${entityType}')">
|
||||||
@@ -1506,9 +1592,13 @@
|
|||||||
const pprs = response[0].ok ? await response[0].json() : [];
|
const pprs = response[0].ok ? await response[0].json() : [];
|
||||||
const arrivals = response[1].ok ? await response[1].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);
|
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);
|
displayInboundAircraft(inbound);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1564,6 +1654,11 @@
|
|||||||
if (response[0].ok) circuits = circuits.concat(await response[0].json());
|
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[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) })));
|
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);
|
displayCircuitAircraft(circuits);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1658,7 +1753,12 @@
|
|||||||
const response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
|
const response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
|
||||||
const pprs = response.ok ? await response.json() : [];
|
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);
|
displayPendingPPRs(pending);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading pending PPRs:', error);
|
console.error('Error loading pending PPRs:', error);
|
||||||
@@ -1723,18 +1823,15 @@
|
|||||||
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
|
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
|
||||||
|
|
||||||
// Filter for today's bookings
|
// Filter for today's bookings
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
const bookedOutAircraft = [
|
const bookedOutAircraft = [
|
||||||
...localBookedOut.filter(flight => {
|
...localBookedOut.filter(flight => {
|
||||||
const createdDate = flight.created_dt.split('T')[0];
|
return isTodayRecord(flight, ['created_dt', 'etd']);
|
||||||
return createdDate === today;
|
|
||||||
}).map(flight => ({
|
}).map(flight => ({
|
||||||
...flight,
|
...flight,
|
||||||
isLocalFlight: true
|
isLocalFlight: true
|
||||||
})),
|
})),
|
||||||
...depBookedOut.filter(flight => {
|
...depBookedOut.filter(flight => {
|
||||||
const createdDate = flight.created_dt.split('T')[0];
|
return isTodayRecord(flight, ['created_dt', 'etd']);
|
||||||
return createdDate === today;
|
|
||||||
}).map(flight => ({
|
}).map(flight => ({
|
||||||
...flight,
|
...flight,
|
||||||
isDeparture: true
|
isDeparture: true
|
||||||
|
|||||||
+3
-4
@@ -966,8 +966,7 @@
|
|||||||
if (/^[0-9]{4}$/.test(timeValue)) {
|
if (/^[0-9]{4}$/.test(timeValue)) {
|
||||||
timeValue = timeValue.slice(0, 2) + ':' + timeValue.slice(2);
|
timeValue = timeValue.slice(0, 2) + ':' + timeValue.slice(2);
|
||||||
}
|
}
|
||||||
const datetime = new Date(`${today}T${timeValue}:00`);
|
data[field] = `${today}T${timeValue}:00Z`;
|
||||||
data[field] = datetime.toISOString();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1031,8 +1030,8 @@
|
|||||||
function setDefaultTimes() {
|
function setDefaultTimes() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const futureTime = new Date(now.getTime() + 10 * 60000); // 10 minutes from now
|
const futureTime = new Date(now.getTime() + 10 * 60000); // 10 minutes from now
|
||||||
const futureHours = String(futureTime.getHours()).padStart(2, '0');
|
const futureHours = String(futureTime.getUTCHours()).padStart(2, '0');
|
||||||
const futureMinutes = String(futureTime.getMinutes()).padStart(2, '0');
|
const futureMinutes = String(futureTime.getUTCMinutes()).padStart(2, '0');
|
||||||
const futureTimeValue = `${futureHours}:${futureMinutes}`;
|
const futureTimeValue = `${futureHours}:${futureMinutes}`;
|
||||||
|
|
||||||
const etdFieldIds = ['localETD', 'circuitETD', 'depETD', 'arrETA'];
|
const etdFieldIds = ['localETD', 'circuitETD', 'depETD', 'arrETA'];
|
||||||
|
|||||||
@@ -373,6 +373,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script src="topbar.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
|
|||||||
@@ -38,7 +38,6 @@
|
|||||||
.status-NEW { background: #3498db; }
|
.status-NEW { background: #3498db; }
|
||||||
.status-APPROVED { background: #27ae60; }
|
.status-APPROVED { background: #27ae60; }
|
||||||
.status-DENIED { background: #c0392b; }
|
.status-DENIED { background: #c0392b; }
|
||||||
.status-PENDING { background: #f39c12; }
|
|
||||||
.status-CANCELED { background: #7f8c8d; }
|
.status-CANCELED { background: #7f8c8d; }
|
||||||
.status-INFLIGHT { background: #8e44ad; }
|
.status-INFLIGHT { background: #8e44ad; }
|
||||||
.status-COMPLETED { background: #2c3e50; }
|
.status-COMPLETED { background: #2c3e50; }
|
||||||
@@ -150,8 +149,8 @@
|
|||||||
<input id="estimated_completion_at" type="datetime-local" required>
|
<input id="estimated_completion_at" type="datetime-local" required>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="maximum_elevation_ft_amsl">Maximum elevation ft AMSL</label>
|
<label for="maximum_elevation_ft_agl">Maximum elevation ft AGL</label>
|
||||||
<input id="maximum_elevation_ft_amsl" type="number" min="0" required>
|
<input id="maximum_elevation_ft_agl" type="number" min="0" required>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="location_inside_frz">Inside FRZ</label>
|
<label for="location_inside_frz">Inside FRZ</label>
|
||||||
@@ -229,7 +228,7 @@
|
|||||||
setValue('phone', request.phone);
|
setValue('phone', request.phone);
|
||||||
setValue('estimated_takeoff_at', toLocalInputValue(request.estimated_takeoff_at));
|
setValue('estimated_takeoff_at', toLocalInputValue(request.estimated_takeoff_at));
|
||||||
setValue('estimated_completion_at', toLocalInputValue(request.estimated_completion_at));
|
setValue('estimated_completion_at', toLocalInputValue(request.estimated_completion_at));
|
||||||
setValue('maximum_elevation_ft_amsl', request.maximum_elevation_ft_amsl);
|
setValue('maximum_elevation_ft_agl', request.maximum_elevation_ft_agl);
|
||||||
setValue('location_inside_frz', request.location_inside_frz ? 'Yes' : 'No');
|
setValue('location_inside_frz', request.location_inside_frz ? 'Yes' : 'No');
|
||||||
setValue('location_latitude', request.location_latitude);
|
setValue('location_latitude', request.location_latitude);
|
||||||
setValue('location_longitude', request.location_longitude);
|
setValue('location_longitude', request.location_longitude);
|
||||||
@@ -237,7 +236,7 @@
|
|||||||
setValue('notes', request.notes);
|
setValue('notes', request.notes);
|
||||||
setValue('operator_comments', request.operator_comments);
|
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('locked').style.display = locked ? 'block' : 'none';
|
||||||
document.getElementById('save-btn').disabled = locked;
|
document.getElementById('save-btn').disabled = locked;
|
||||||
document.getElementById('cancel-btn').disabled = locked;
|
document.getElementById('cancel-btn').disabled = locked;
|
||||||
@@ -261,7 +260,7 @@
|
|||||||
phone: value('phone') || null,
|
phone: value('phone') || null,
|
||||||
estimated_takeoff_at: fromLocalInputValue(value('estimated_takeoff_at')),
|
estimated_takeoff_at: fromLocalInputValue(value('estimated_takeoff_at')),
|
||||||
estimated_completion_at: fromLocalInputValue(value('estimated_completion_at')),
|
estimated_completion_at: fromLocalInputValue(value('estimated_completion_at')),
|
||||||
maximum_elevation_ft_amsl: Number(value('maximum_elevation_ft_amsl')),
|
maximum_elevation_ft_agl: Number(value('maximum_elevation_ft_agl')),
|
||||||
location_latitude: Number(value('location_latitude')),
|
location_latitude: Number(value('location_latitude')),
|
||||||
location_longitude: Number(value('location_longitude')),
|
location_longitude: Number(value('location_longitude')),
|
||||||
location_description: value('location_description') || null,
|
location_description: value('location_description') || null,
|
||||||
@@ -316,7 +315,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fromLocalInputValue(value) {
|
function fromLocalInputValue(value) {
|
||||||
return new Date(value).toISOString();
|
return `${value}:00Z`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMessage(message, isError = false, clear = false) {
|
function showMessage(message, isError = false, clear = false) {
|
||||||
|
|||||||
+467
-110
@@ -9,27 +9,57 @@
|
|||||||
<style>
|
<style>
|
||||||
.workspace {
|
.workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(360px, 0.95fr) minmax(520px, 1.35fr);
|
grid-template-columns: 1fr;
|
||||||
gap: 1rem;
|
gap: 0;
|
||||||
align-items: start;
|
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 {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar h2 {
|
||||||
|
font-size: 1.55rem;
|
||||||
|
margin: 0 0 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-row {
|
.filter-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-row select {
|
||||||
|
width: 210px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row .btn {
|
||||||
|
padding: 0.58rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.request-list {
|
.request-list {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -37,16 +67,49 @@
|
|||||||
overflow: hidden;
|
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 {
|
.request-list-body {
|
||||||
max-height: calc(100vh - 210px);
|
max-height: calc(100vh - 255px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-row {
|
.request-row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: minmax(150px, 0.8fr) minmax(200px, 1.1fr) minmax(150px, 0.85fr) auto;
|
||||||
gap: 0.5rem;
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -60,6 +123,17 @@
|
|||||||
background: #eef6fb;
|
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 {
|
.request-ref {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #263645;
|
color: #263645;
|
||||||
@@ -80,26 +154,69 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 86px;
|
min-width: 76px;
|
||||||
padding: 0.25rem 0.55rem;
|
padding: 0.25rem 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-NEW { background: #3498db; }
|
.status-NEW { background: #3498db; }
|
||||||
.status-APPROVED { background: #27ae60; }
|
.status-APPROVED { background: #27ae60; }
|
||||||
.status-DENIED { background: #c0392b; }
|
.status-DENIED { background: #c0392b; }
|
||||||
.status-PENDING { background: #f39c12; }
|
|
||||||
.status-CANCELED { background: #7f8c8d; }
|
.status-CANCELED { background: #7f8c8d; }
|
||||||
.status-INFLIGHT { background: #8e44ad; }
|
.status-INFLIGHT { background: #8e44ad; }
|
||||||
.status-COMPLETED { background: #2c3e50; }
|
.status-COMPLETED { background: #2c3e50; }
|
||||||
|
|
||||||
.detail-shell {
|
.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;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(360px, 52vh) auto;
|
gap: 0.8rem;
|
||||||
gap: 1rem;
|
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,
|
.detail-panel,
|
||||||
.action-panel {
|
.journal-panel {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
@@ -107,25 +224,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-body,
|
.detail-body,
|
||||||
.action-body {
|
.journal-body {
|
||||||
padding: 1rem;
|
padding: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-grid {
|
.detail-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.85rem;
|
gap: 0.6rem 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-label {
|
.field-label {
|
||||||
color: #607080;
|
color: #607080;
|
||||||
font-size: 0.75rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-value {
|
.field-value {
|
||||||
color: #263645;
|
color: #263645;
|
||||||
|
font-size: 0.92rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -136,24 +254,35 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 360px;
|
min-height: calc(100vh - 335px);
|
||||||
}
|
}
|
||||||
|
|
||||||
#request-map {
|
#request-map {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 360px;
|
min-height: calc(100vh - 335px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-row {
|
.request-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(160px, 220px) 1fr auto;
|
grid-template-columns: minmax(130px, 170px) auto;
|
||||||
gap: 0.75rem;
|
gap: 0.45rem;
|
||||||
align-items: start;
|
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 {
|
textarea {
|
||||||
min-height: 86px;
|
min-height: 42px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,14 +297,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.journal {
|
.journal {
|
||||||
margin-top: 1rem;
|
max-height: 170px;
|
||||||
border-top: 1px solid #e6e9ec;
|
overflow-y: auto;
|
||||||
padding-top: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.journal-entry {
|
.journal-entry {
|
||||||
border-bottom: 1px solid #eef1f4;
|
border-bottom: 1px solid #eef1f4;
|
||||||
padding: 0.55rem 0;
|
padding: 0.4rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@@ -197,18 +325,41 @@
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 780px) {
|
||||||
.workspace,
|
.workspace,
|
||||||
.detail-grid,
|
.request-row,
|
||||||
.action-row {
|
.selected-overview,
|
||||||
|
.workbench-bar,
|
||||||
|
.request-actions {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace.has-selection:not(.queue-open) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-panel,
|
||||||
|
#request-map {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
.request-list-body {
|
.request-list-body {
|
||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script src="topbar.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
@@ -232,59 +383,51 @@
|
|||||||
<div class="detail-meta">Requests from the public drone flight form</div>
|
<div class="detail-meta">Requests from the public drone flight form</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<select id="status-filter" aria-label="Status filter">
|
<select id="request-view-filter" aria-label="Request view">
|
||||||
<option value="">All statuses</option>
|
<option value="active">Active queue</option>
|
||||||
<option value="NEW">New</option>
|
<option value="older">Earlier dates</option>
|
||||||
<option value="PENDING">Pending</option>
|
<option value="closed">Denied / canceled</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>
|
</select>
|
||||||
<button class="btn btn-primary" onclick="loadRequests()">Refresh</button>
|
<button class="btn btn-primary" onclick="loadRequests()">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="workspace">
|
<div id="workspace" class="workspace">
|
||||||
<section class="request-list">
|
<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 id="request-list-body" class="request-list-body">
|
||||||
<div class="empty-state">Loading requests...</div>
|
<div class="empty-state">Loading requests...</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="detail-shell">
|
<section class="detail-shell">
|
||||||
<div class="detail-panel">
|
<div class="workbench-bar">
|
||||||
<div class="table-header">Selected Request</div>
|
<div id="workbench-title" class="workbench-title">
|
||||||
<div id="detail-body" class="detail-body">
|
<strong>No request selected</strong>
|
||||||
<div class="empty-state">Select a request to view its details.</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="map-panel">
|
<div class="journal-panel">
|
||||||
<div id="request-map"></div>
|
<div class="table-header">Journal History</div>
|
||||||
</div>
|
<div class="journal-body">
|
||||||
|
|
||||||
<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 id="journal" class="journal"></div>
|
<div id="journal" class="journal"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,19 +459,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<script>
|
<script>
|
||||||
let accessToken = null;
|
let accessToken = null;
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let selectedRequest = null;
|
let selectedRequest = null;
|
||||||
let requests = [];
|
let requests = [];
|
||||||
|
let requestGroups = [];
|
||||||
let map = null;
|
let map = null;
|
||||||
let mapLayers = [];
|
let mapLayers = [];
|
||||||
let frzGeometry = null;
|
let frzGeometry = null;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
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();
|
initializeAuth();
|
||||||
initializeMap();
|
initializeMap();
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
@@ -405,6 +572,9 @@
|
|||||||
currentUser = username;
|
currentUser = username;
|
||||||
document.getElementById('current-user').textContent = username;
|
document.getElementById('current-user').textContent = username;
|
||||||
hideLogin();
|
hideLogin();
|
||||||
|
if (typeof window.refreshDroneRequestBadge === 'function') {
|
||||||
|
window.refreshDroneRequestBadge();
|
||||||
|
}
|
||||||
loadRequests();
|
loadRequests();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.textContent = err.message;
|
error.textContent = err.message;
|
||||||
@@ -421,6 +591,7 @@
|
|||||||
accessToken = null;
|
accessToken = null;
|
||||||
currentUser = null;
|
currentUser = null;
|
||||||
selectedRequest = null;
|
selectedRequest = null;
|
||||||
|
setLifecycleControlsEnabled(false);
|
||||||
showLogin();
|
showLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,72 +616,186 @@
|
|||||||
|
|
||||||
async function loadRequests() {
|
async function loadRequests() {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
const status = document.getElementById('status-filter').value;
|
const view = document.getElementById('request-view-filter').value;
|
||||||
const url = status ? `/api/v1/drone-requests/?status=${encodeURIComponent(status)}` : '/api/v1/drone-requests/';
|
|
||||||
const body = document.getElementById('request-list-body');
|
const body = document.getElementById('request-list-body');
|
||||||
body.innerHTML = '<div class="empty-state">Loading requests...</div>';
|
body.innerHTML = '<div class="empty-state">Loading requests...</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(url);
|
requestGroups = await loadRequestGroups(view);
|
||||||
if (!response.ok) throw new Error('Failed to load drone requests');
|
requests = requestGroups.flatMap(group => group.items);
|
||||||
requests = await response.json();
|
|
||||||
renderRequestList();
|
renderRequestList();
|
||||||
if (selectedRequest) {
|
if (selectedRequest) {
|
||||||
const fresh = requests.find(r => r.id === selectedRequest.id);
|
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) {
|
} catch (err) {
|
||||||
body.innerHTML = `<div class="empty-state">${escapeHtml(err.message)}</div>`;
|
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() {
|
function renderRequestList() {
|
||||||
document.getElementById('request-count').textContent = requests.length;
|
document.getElementById('request-count').textContent = requests.length;
|
||||||
|
setQueueOpen(!selectedRequest || document.getElementById('workspace').classList.contains('queue-open'));
|
||||||
const body = document.getElementById('request-list-body');
|
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>';
|
body.innerHTML = '<div class="empty-state">No requests match the current filter.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.innerHTML = requests.map(req => `
|
body.innerHTML = requestGroups.map(group => `
|
||||||
<button class="request-row ${selectedRequest && selectedRequest.id === req.id ? 'active' : ''}" onclick="selectRequest(${req.id})">
|
<div class="request-group">${escapeHtml(group.label)} - ${group.items.length}</div>
|
||||||
<div>
|
${group.items.length ? group.items.map(renderRequestRow).join('') : '<div class="empty-state">None</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>
|
|
||||||
`).join('');
|
`).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);
|
selectedRequest = requests.find(req => req.id === id);
|
||||||
if (!selectedRequest) return;
|
if (!selectedRequest) return;
|
||||||
|
setQueueOpen(!collapseQueue);
|
||||||
|
setLifecycleControlsEnabled(true);
|
||||||
|
updateWorkbenchTitle();
|
||||||
renderRequestList();
|
renderRequestList();
|
||||||
renderDetails();
|
renderDetails();
|
||||||
renderMap();
|
renderMap();
|
||||||
await loadJournal();
|
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() {
|
function renderDetails() {
|
||||||
document.getElementById('status-select').value = selectedRequest.status;
|
|
||||||
document.getElementById('operator-comment').value = selectedRequest.operator_comments || '';
|
|
||||||
document.getElementById('detail-body').innerHTML = `
|
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">
|
<div class="detail-grid">
|
||||||
${field('Operator ID', selectedRequest.operator_id)}
|
${field('Operator ID', selectedRequest.operator_id)}
|
||||||
${field('Flyer', selectedRequest.flyer_name)}
|
${field('Flyer', selectedRequest.flyer_name)}
|
||||||
${field('Flyer ID', selectedRequest.flyer_id)}
|
${field('Flyer ID', selectedRequest.flyer_id)}
|
||||||
${field('Takeoff', formatDateTime(selectedRequest.estimated_takeoff_at))}
|
${field('Takeoff', formatDateTime(selectedRequest.estimated_takeoff_at))}
|
||||||
${field('Completion', formatDateTime(selectedRequest.estimated_completion_at))}
|
${field('Completion', formatDateTime(selectedRequest.estimated_completion_at))}
|
||||||
${field('Max Elevation', `${selectedRequest.maximum_elevation_ft_amsl} ft AMSL`)}
|
${field('Max Elevation', `${selectedRequest.maximum_elevation_ft_agl} ft AGL`)}
|
||||||
${field('Inside FRZ', selectedRequest.location_inside_frz === null ? '-' : (selectedRequest.location_inside_frz ? 'Yes' : 'No'))}
|
${field('Inside FRZ', selectedRequest.location_inside_frz === null ? '-' : (selectedRequest.location_inside_frz ? 'Yes' : 'No'))}
|
||||||
${field('Latitude', selectedRequest.location_latitude)}
|
${field('Latitude', selectedRequest.location_latitude)}
|
||||||
${field('Longitude', selectedRequest.location_longitude)}
|
${field('Longitude', selectedRequest.location_longitude)}
|
||||||
@@ -518,13 +803,44 @@
|
|||||||
${field('Applicant Notes', selectedRequest.notes || '-')}
|
${field('Applicant Notes', selectedRequest.notes || '-')}
|
||||||
${field('Operator Comments', selectedRequest.operator_comments || '-')}
|
${field('Operator Comments', selectedRequest.operator_comments || '-')}
|
||||||
</div>
|
</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) {
|
function field(label, value) {
|
||||||
return `<div><div class="field-label">${escapeHtml(label)}</div><div class="field-value">${escapeHtml(value == null ? '-' : String(value))}</div></div>`;
|
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() {
|
function renderMap() {
|
||||||
clearMapLayers();
|
clearMapLayers();
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@@ -546,14 +862,28 @@
|
|||||||
addLayer(L.marker(point).addTo(map).bindPopup(`
|
addLayer(L.marker(point).addTo(map).bindPopup(`
|
||||||
<strong>${escapeHtml(selectedRequest.reference_number)}</strong><br>
|
<strong>${escapeHtml(selectedRequest.reference_number)}</strong><br>
|
||||||
${escapeHtml(selectedRequest.operator_name)}<br>
|
${escapeHtml(selectedRequest.operator_name)}<br>
|
||||||
${selectedRequest.maximum_elevation_ft_amsl} ft AMSL
|
${selectedRequest.maximum_elevation_ft_agl} ft AGL
|
||||||
`));
|
`));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mapLayers.length) return;
|
if (!mapLayers.length) return;
|
||||||
const group = L.featureGroup(mapLayers);
|
fitMapToLayers();
|
||||||
map.fitBounds(group.getBounds().pad(0.18));
|
}
|
||||||
setTimeout(() => map.invalidateSize(), 50);
|
|
||||||
|
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) {
|
function addLayer(layer) {
|
||||||
@@ -568,29 +898,46 @@
|
|||||||
async function saveStatus() {
|
async function saveStatus() {
|
||||||
if (!selectedRequest) return showNotification('Select a request first', true);
|
if (!selectedRequest) return showNotification('Select a request first', true);
|
||||||
const status = document.getElementById('status-select').value;
|
const status = document.getElementById('status-select').value;
|
||||||
const comment = document.getElementById('operator-comment').value.trim();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(`/api/v1/drone-requests/${selectedRequest.id}/status`, {
|
const response = await authenticatedFetch(`/api/v1/drone-requests/${selectedRequest.id}/status`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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');
|
if (!response.ok) throw new Error('Failed to update status');
|
||||||
selectedRequest = await response.json();
|
selectedRequest = await response.json();
|
||||||
showNotification('Status updated');
|
showNotification('Status updated');
|
||||||
await loadRequests();
|
await loadRequests();
|
||||||
renderDetails();
|
if (selectedRequest) {
|
||||||
await loadJournal();
|
renderDetails();
|
||||||
|
await loadJournal();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showNotification(err.message, true);
|
showNotification(err.message, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendComment() {
|
function openMessageModal() {
|
||||||
if (!selectedRequest) return showNotification('Select a request first', true);
|
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);
|
if (!comment) return showNotification('Enter a comment first', true);
|
||||||
|
const button = document.getElementById('message-send-btn');
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(`/api/v1/drone-requests/${selectedRequest.id}/comments`, {
|
const response = await authenticatedFetch(`/api/v1/drone-requests/${selectedRequest.id}/comments`, {
|
||||||
@@ -600,12 +947,17 @@
|
|||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to send comment');
|
if (!response.ok) throw new Error('Failed to send comment');
|
||||||
selectedRequest = await response.json();
|
selectedRequest = await response.json();
|
||||||
|
closeMessageModal();
|
||||||
showNotification('Comment sent');
|
showNotification('Comment sent');
|
||||||
await loadRequests();
|
await loadRequests();
|
||||||
renderDetails();
|
if (selectedRequest) {
|
||||||
await loadJournal();
|
renderDetails();
|
||||||
|
await loadJournal();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showNotification(err.message, true);
|
showNotification(err.message, true);
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,7 +989,12 @@
|
|||||||
if (event.data.startsWith('Heartbeat:')) return;
|
if (event.data.startsWith('Heartbeat:')) return;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
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) {
|
} catch (err) {
|
||||||
console.warn('WebSocket parse failed', err);
|
console.warn('WebSocket parse failed', err);
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-12
@@ -437,12 +437,12 @@
|
|||||||
if (!utcDateStr.includes('T')) {
|
if (!utcDateStr.includes('T')) {
|
||||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||||
}
|
}
|
||||||
if (!utcDateStr.includes('Z')) {
|
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||||
utcDateStr += 'Z';
|
utcDateStr += 'Z';
|
||||||
}
|
}
|
||||||
const etaDate = new Date(utcDateStr);
|
const etaDate = new Date(utcDateStr);
|
||||||
const etaDateStr = etaDate.toISOString().split('T')[0];
|
const etaDateStr = formatLocalDateInput(etaDate);
|
||||||
const etaTimeStr = etaDate.toISOString().slice(11, 16);
|
const etaTimeStr = formatLocalTimeInput(etaDate);
|
||||||
document.getElementById('eta-date').value = etaDateStr;
|
document.getElementById('eta-date').value = etaDateStr;
|
||||||
document.getElementById('eta-time').value = etaTimeStr;
|
document.getElementById('eta-time').value = etaTimeStr;
|
||||||
}
|
}
|
||||||
@@ -457,12 +457,12 @@
|
|||||||
if (!utcDateStr.includes('T')) {
|
if (!utcDateStr.includes('T')) {
|
||||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||||
}
|
}
|
||||||
if (!utcDateStr.includes('Z')) {
|
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||||
utcDateStr += 'Z';
|
utcDateStr += 'Z';
|
||||||
}
|
}
|
||||||
const etdDate = new Date(utcDateStr);
|
const etdDate = new Date(utcDateStr);
|
||||||
const etdDateStr = etdDate.toISOString().split('T')[0];
|
const etdDateStr = formatLocalDateInput(etdDate);
|
||||||
const etdTimeStr = etdDate.toISOString().slice(11, 16);
|
const etdTimeStr = formatLocalTimeInput(etdDate);
|
||||||
document.getElementById('etd-date').value = etdDateStr;
|
document.getElementById('etd-date').value = etdDateStr;
|
||||||
document.getElementById('etd-time').value = etdTimeStr;
|
document.getElementById('etd-time').value = etdTimeStr;
|
||||||
}
|
}
|
||||||
@@ -471,15 +471,35 @@
|
|||||||
document.getElementById('email').value = ppr.email || '';
|
document.getElementById('email').value = ppr.email || '';
|
||||||
document.getElementById('phone').value = ppr.phone || '';
|
document.getElementById('phone').value = ppr.phone || '';
|
||||||
document.getElementById('notes').value = ppr.notes || '';
|
document.getElementById('notes').value = ppr.notes || '';
|
||||||
|
|
||||||
|
if (['CANCELED', 'DELETED', 'LANDED', '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 {
|
} 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) {
|
} catch (error) {
|
||||||
console.error('Error loading PPR:', 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)
|
// Aircraft lookup (same as submit form)
|
||||||
let aircraftLookupTimeout;
|
let aircraftLookupTimeout;
|
||||||
async function handleAircraftLookup(registration) {
|
async function handleAircraftLookup(registration) {
|
||||||
@@ -692,9 +712,6 @@
|
|||||||
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
|
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Auto-save any unsaved aircraft types
|
|
||||||
await autoSaveUnsavedAircraft(this);
|
|
||||||
|
|
||||||
const formData = new FormData(this);
|
const formData = new FormData(this);
|
||||||
const pprData = {};
|
const pprData = {};
|
||||||
|
|
||||||
@@ -792,4 +809,4 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+14
-8
@@ -386,10 +386,12 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<img src="assets/logo.png" alt="EGFH Logo" class="left-image">
|
<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">
|
<div class="qr-code-container">
|
||||||
<img id="bookingQR" alt="Scan to book a flight" title="Scan to book a flight">
|
<img id="bookingQR" alt="Scan to book a flight" title="Scan to book a flight">
|
||||||
<div class="qr-label">Book Out</div>
|
<div class="qr-label">Book Out</div>
|
||||||
</div>
|
</div>
|
||||||
|
-->
|
||||||
<h1>Flight Information</h1>
|
<h1>Flight Information</h1>
|
||||||
<img src="assets/flightImg.png" alt="EGFH Logo" class="right-image">
|
<img src="assets/flightImg.png" alt="EGFH Logo" class="right-image">
|
||||||
</header>
|
</header>
|
||||||
@@ -629,8 +631,15 @@
|
|||||||
if (!utcDateTimeString) return '';
|
if (!utcDateTimeString) return '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parse the ISO datetime string
|
// API datetimes are UTC, but DB-backed values may arrive without a timezone suffix.
|
||||||
const date = new Date(utcDateTimeString);
|
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
|
// Check if valid date
|
||||||
if (isNaN(date.getTime())) {
|
if (isNaN(date.getTime())) {
|
||||||
@@ -820,10 +829,10 @@
|
|||||||
const toDisplay = await getAirportName(departure.out_to || '');
|
const toDisplay = await getAirportName(departure.out_to || '');
|
||||||
|
|
||||||
let timeDisplay, sortTime;
|
let timeDisplay, sortTime;
|
||||||
if (departure.status === 'DEPARTED' && departure.departed_dt) {
|
if (departure.status === 'DEPARTED' && departure.qsy_dt) {
|
||||||
const time = convertToLocalTime(departure.departed_dt);
|
const time = convertToLocalTime(departure.qsy_dt);
|
||||||
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">DEPARTED</span></div>`;
|
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">DEPARTED</span></div>`;
|
||||||
sortTime = departure.departed_dt;
|
sortTime = departure.qsy_dt;
|
||||||
} else {
|
} else {
|
||||||
timeDisplay = convertToLocalTime(departure.etd);
|
timeDisplay = convertToLocalTime(departure.etd);
|
||||||
sortTime = departure.etd;
|
sortTime = departure.etd;
|
||||||
@@ -893,9 +902,6 @@
|
|||||||
// Initialize Christmas mode
|
// Initialize Christmas mode
|
||||||
initChristmasMode();
|
initChristmasMode();
|
||||||
|
|
||||||
// Load booking QR code
|
|
||||||
generateBookingQR();
|
|
||||||
|
|
||||||
loadArrivals();
|
loadArrivals();
|
||||||
loadDepartures();
|
loadDepartures();
|
||||||
|
|
||||||
|
|||||||
+8
-17
@@ -240,6 +240,7 @@
|
|||||||
background: #1976D2;
|
background: #1976D2;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script src="topbar.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
@@ -408,10 +409,10 @@
|
|||||||
function setDefaultDates() {
|
function setDefaultDates() {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const thirtyDaysAgo = new Date(today);
|
const thirtyDaysAgo = new Date(today);
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30);
|
||||||
|
|
||||||
document.getElementById('dateFrom').valueAsDate = thirtyDaysAgo;
|
document.getElementById('dateFrom').value = thirtyDaysAgo.toISOString().split('T')[0];
|
||||||
document.getElementById('dateTo').valueAsDate = today;
|
document.getElementById('dateTo').value = today.toISOString().split('T')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadJournalEntries() {
|
async function loadJournalEntries() {
|
||||||
@@ -711,23 +712,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(dateString) {
|
function formatDateTime(dateString) {
|
||||||
const date = new Date(dateString);
|
const normalized = dateString.includes('T') ? dateString : dateString.replace(' ', 'T');
|
||||||
return date.toLocaleString('en-US', {
|
const date = new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(normalized) ? normalized : `${normalized}Z`);
|
||||||
year: 'numeric',
|
return date.toISOString().slice(0, 19).replace('T', ' ');
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toISOString().slice(0, 10);
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
|
|||||||
+24
-25
@@ -358,6 +358,7 @@
|
|||||||
min-width: 1200px;
|
min-width: 1200px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script src="topbar.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
@@ -646,10 +647,10 @@
|
|||||||
// Set date range to this week (Monday to Sunday)
|
// Set date range to this week (Monday to Sunday)
|
||||||
function setDateRangeThisWeek() {
|
function setDateRangeThisWeek() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dayOfWeek = now.getDay();
|
const dayOfWeek = now.getUTCDay();
|
||||||
const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
|
const diff = now.getUTCDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
|
||||||
const monday = new Date(now.setDate(diff));
|
const monday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), diff));
|
||||||
const sunday = new Date(now.setDate(diff + 6));
|
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-from').value = monday.toISOString().split('T')[0];
|
||||||
document.getElementById('date-to').value = sunday.toISOString().split('T')[0];
|
document.getElementById('date-to').value = sunday.toISOString().split('T')[0];
|
||||||
@@ -664,8 +665,8 @@
|
|||||||
// Set date range to this month
|
// Set date range to this month
|
||||||
function setDateRangeThisMonth() {
|
function setDateRangeThisMonth() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
const firstDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
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-from').value = firstDay.toISOString().split('T')[0];
|
||||||
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
|
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
|
||||||
@@ -811,21 +812,14 @@
|
|||||||
let dateRangeText = '';
|
let dateRangeText = '';
|
||||||
if (dateFrom && dateTo && dateFrom === dateTo) {
|
if (dateFrom && dateTo && dateFrom === dateTo) {
|
||||||
// Single day
|
// Single day
|
||||||
const date = new Date(dateFrom + 'T00:00:00Z');
|
dateRangeText = `for ${formatDateOnly(dateFrom)}`;
|
||||||
dateRangeText = `for ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
|
||||||
} else if (dateFrom && dateTo) {
|
} else if (dateFrom && dateTo) {
|
||||||
// Date range
|
// Date range
|
||||||
const fromDate = new Date(dateFrom + 'T00:00:00Z');
|
dateRangeText = `for ${formatDateOnly(dateFrom)} to ${formatDateOnly(dateTo)}`;
|
||||||
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}`;
|
|
||||||
} else if (dateFrom) {
|
} else if (dateFrom) {
|
||||||
const date = new Date(dateFrom + 'T00:00:00Z');
|
dateRangeText = `from ${formatDateOnly(dateFrom)}`;
|
||||||
dateRangeText = `from ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
|
||||||
} else if (dateTo) {
|
} else if (dateTo) {
|
||||||
const date = new Date(dateTo + 'T00:00:00Z');
|
dateRangeText = `until ${formatDateOnly(dateTo)}`;
|
||||||
dateRangeText = `until ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update summary title with date range
|
// Update summary title with date range
|
||||||
@@ -905,11 +899,11 @@
|
|||||||
const date = new Date(utcDateStr);
|
const date = new Date(utcDateStr);
|
||||||
|
|
||||||
// Format as dd/mm/yy hh:mm
|
// Format as dd/mm/yy hh:mm
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||||
const year = String(date.getFullYear()).slice(-2);
|
const year = String(date.getUTCFullYear()).slice(-2);
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||||
|
|
||||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||||
}
|
}
|
||||||
@@ -926,12 +920,17 @@
|
|||||||
const date = new Date(utcDateStr);
|
const date = new Date(utcDateStr);
|
||||||
|
|
||||||
// Format as hh:mm only
|
// Format as hh:mm only
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||||
|
|
||||||
return `${hours}:${minutes}`;
|
return `${hours}:${minutes}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateOnly(dateStr) {
|
||||||
|
const [year, month, day] = dateStr.split('-');
|
||||||
|
return `${day}/${month}/${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Clear filters
|
// Clear filters
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
document.getElementById('status-filter').value = '';
|
document.getElementById('status-filter').value = '';
|
||||||
@@ -1274,4 +1273,4 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+395
-90
@@ -155,6 +155,14 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clickable-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-row:hover {
|
||||||
|
background-color: #eef6ff;
|
||||||
|
}
|
||||||
|
|
||||||
.table-header {
|
.table-header {
|
||||||
background: #34495e;
|
background: #34495e;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -311,6 +319,98 @@
|
|||||||
background-color: #e74c3c;
|
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 */
|
/* Responsive design */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.container {
|
.container {
|
||||||
@@ -358,6 +458,7 @@
|
|||||||
min-width: 1200px;
|
min-width: 1200px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script src="topbar.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
@@ -473,7 +574,7 @@
|
|||||||
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Departures</div>
|
<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 class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-departures">0</div>
|
||||||
</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-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 class="summary-item-value" style="font-size: 1.1rem;" id="overflights-count">0</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,14 +624,12 @@
|
|||||||
<th>Callsign</th>
|
<th>Callsign</th>
|
||||||
<th>Captain</th>
|
<th>Captain</th>
|
||||||
<th>From</th>
|
<th>From</th>
|
||||||
<th>ETA</th>
|
|
||||||
<th>POB In</th>
|
|
||||||
<th>To</th>
|
<th>To</th>
|
||||||
<th>ETD</th>
|
<th>Takeoff</th>
|
||||||
|
<th>Landing</th>
|
||||||
|
<th>POB In</th>
|
||||||
<th>POB Out</th>
|
<th>POB Out</th>
|
||||||
<th>Fuel</th>
|
<th>Fuel</th>
|
||||||
<th>Landed</th>
|
|
||||||
<th>Departed</th>
|
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Phone</th>
|
<th>Phone</th>
|
||||||
<th>Notes</th>
|
<th>Notes</th>
|
||||||
@@ -581,8 +680,8 @@
|
|||||||
<th>Callsign</th>
|
<th>Callsign</th>
|
||||||
<th>From</th>
|
<th>From</th>
|
||||||
<th>To</th>
|
<th>To</th>
|
||||||
<th>ETA / ETD / Called</th>
|
<th>Takeoff</th>
|
||||||
<th>Landed / Departed / QSY</th>
|
<th>Landing</th>
|
||||||
<th>Circuits</th>
|
<th>Circuits</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -599,6 +698,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Success Notification -->
|
||||||
<div id="notification" class="notification"></div>
|
<div id="notification" class="notification"></div>
|
||||||
|
|
||||||
@@ -698,10 +813,10 @@
|
|||||||
// Set date range to this week (Monday to Sunday)
|
// Set date range to this week (Monday to Sunday)
|
||||||
function setDateRangeThisWeek() {
|
function setDateRangeThisWeek() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dayOfWeek = now.getDay();
|
const dayOfWeek = now.getUTCDay();
|
||||||
const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
|
const diff = now.getUTCDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
|
||||||
const monday = new Date(now.setDate(diff));
|
const monday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), diff));
|
||||||
const sunday = new Date(now.setDate(diff + 6));
|
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-from').value = monday.toISOString().split('T')[0];
|
||||||
document.getElementById('date-to').value = sunday.toISOString().split('T')[0];
|
document.getElementById('date-to').value = sunday.toISOString().split('T')[0];
|
||||||
@@ -716,8 +831,8 @@
|
|||||||
// Set date range to this month
|
// Set date range to this month
|
||||||
function setDateRangeThisMonth() {
|
function setDateRangeThisMonth() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
const firstDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
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-from').value = firstDay.toISOString().split('T')[0];
|
||||||
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
|
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
|
||||||
@@ -870,9 +985,12 @@
|
|||||||
const arrivals = await arrivalsResponse.json();
|
const arrivals = await arrivalsResponse.json();
|
||||||
otherFlights.push(...arrivals.map(f => ({
|
otherFlights.push(...arrivals.map(f => ({
|
||||||
...f,
|
...f,
|
||||||
|
entityType: 'ARRIVAL',
|
||||||
flightType: 'ARRIVAL',
|
flightType: 'ARRIVAL',
|
||||||
aircraft_type: f.type,
|
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,
|
fromField: f.in_from,
|
||||||
toField: 'EGFH'
|
toField: 'EGFH'
|
||||||
})));
|
})));
|
||||||
@@ -882,9 +1000,12 @@
|
|||||||
const departures = await departuresResponse.json();
|
const departures = await departuresResponse.json();
|
||||||
otherFlights.push(...departures.map(f => ({
|
otherFlights.push(...departures.map(f => ({
|
||||||
...f,
|
...f,
|
||||||
|
entityType: 'DEPARTURE',
|
||||||
flightType: 'DEPARTURE',
|
flightType: 'DEPARTURE',
|
||||||
aircraft_type: f.type,
|
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',
|
fromField: 'EGFH',
|
||||||
toField: f.out_to
|
toField: f.out_to
|
||||||
})));
|
})));
|
||||||
@@ -894,10 +1015,13 @@
|
|||||||
const localFlights = await localFlightsResponse.json();
|
const localFlights = await localFlightsResponse.json();
|
||||||
otherFlights.push(...localFlights.map(f => ({
|
otherFlights.push(...localFlights.map(f => ({
|
||||||
...f,
|
...f,
|
||||||
|
entityType: 'LOCAL_FLIGHT',
|
||||||
flightType: f.flight_type === 'CIRCUITS' ? 'CIRCUIT' : f.flight_type,
|
flightType: f.flight_type === 'CIRCUITS' ? 'CIRCUIT' : f.flight_type,
|
||||||
aircraft_type: f.type,
|
aircraft_type: f.type,
|
||||||
circuits: f.circuits,
|
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',
|
fromField: 'EGFH',
|
||||||
toField: 'EGFH'
|
toField: 'EGFH'
|
||||||
})));
|
})));
|
||||||
@@ -907,10 +1031,13 @@
|
|||||||
const overflights = await overflightsResponse.json();
|
const overflights = await overflightsResponse.json();
|
||||||
otherFlights.push(...overflights.map(f => ({
|
otherFlights.push(...overflights.map(f => ({
|
||||||
...f,
|
...f,
|
||||||
|
entityType: 'OVERFLIGHT',
|
||||||
flightType: 'OVERFLIGHT',
|
flightType: 'OVERFLIGHT',
|
||||||
aircraft_type: f.type,
|
aircraft_type: f.type,
|
||||||
circuits: null,
|
circuits: null,
|
||||||
timeField: f.call_dt,
|
sortTime: f.call_dt,
|
||||||
|
takeoffTime: null,
|
||||||
|
landingTime: null,
|
||||||
fromField: f.departure_airfield,
|
fromField: f.departure_airfield,
|
||||||
toField: f.destination_airfield,
|
toField: f.destination_airfield,
|
||||||
callsign: f.registration
|
callsign: f.registration
|
||||||
@@ -958,21 +1085,14 @@
|
|||||||
let dateRangeText = '';
|
let dateRangeText = '';
|
||||||
if (dateFrom && dateTo && dateFrom === dateTo) {
|
if (dateFrom && dateTo && dateFrom === dateTo) {
|
||||||
// Single day
|
// Single day
|
||||||
const date = new Date(dateFrom + 'T00:00:00Z');
|
dateRangeText = `for ${formatDateOnly(dateFrom)}`;
|
||||||
dateRangeText = `for ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
|
||||||
} else if (dateFrom && dateTo) {
|
} else if (dateFrom && dateTo) {
|
||||||
// Date range
|
// Date range
|
||||||
const fromDate = new Date(dateFrom + 'T00:00:00Z');
|
dateRangeText = `for ${formatDateOnly(dateFrom)} to ${formatDateOnly(dateTo)}`;
|
||||||
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}`;
|
|
||||||
} else if (dateFrom) {
|
} else if (dateFrom) {
|
||||||
const date = new Date(dateFrom + 'T00:00:00Z');
|
dateRangeText = `from ${formatDateOnly(dateFrom)}`;
|
||||||
dateRangeText = `from ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
|
||||||
} else if (dateTo) {
|
} else if (dateTo) {
|
||||||
const date = new Date(dateTo + 'T00:00:00Z');
|
dateRangeText = `until ${formatDateOnly(dateTo)}`;
|
||||||
dateRangeText = `until ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update summary title with date range
|
// Update summary title with date range
|
||||||
@@ -1044,11 +1164,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by ETA (ascending)
|
// Sort by first actual movement time, then planned times as a fallback.
|
||||||
pprs.sort((a, b) => {
|
pprs.sort((a, b) => {
|
||||||
if (!a.eta) return 1;
|
const aTime = getPPRSortTime(a);
|
||||||
if (!b.eta) return -1;
|
const bTime = getPPRSortTime(b);
|
||||||
return new Date(a.eta) - new Date(b.eta);
|
if (!aTime) return 1;
|
||||||
|
if (!bTime) return -1;
|
||||||
|
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||||
});
|
});
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
@@ -1056,12 +1178,11 @@
|
|||||||
|
|
||||||
for (const ppr of pprs) {
|
for (const ppr of pprs) {
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
|
row.className = 'clickable-row';
|
||||||
|
row.onclick = () => openReportDetail('PPR', ppr.id);
|
||||||
|
|
||||||
// Format dates
|
const takeoff = ppr.takeoff_dt ? formatDateTime(ppr.takeoff_dt) : '-';
|
||||||
const eta = ppr.eta ? formatDateTime(ppr.eta) : '-';
|
const landing = ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '-';
|
||||||
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 submitted = ppr.submitted_dt ? formatDateTime(ppr.submitted_dt) : '-';
|
const submitted = ppr.submitted_dt ? formatDateTime(ppr.submitted_dt) : '-';
|
||||||
|
|
||||||
// Status styling
|
// Status styling
|
||||||
@@ -1075,14 +1196,12 @@
|
|||||||
<td>${ppr.ac_call || '-'}</td>
|
<td>${ppr.ac_call || '-'}</td>
|
||||||
<td>${ppr.captain}</td>
|
<td>${ppr.captain}</td>
|
||||||
<td>${ppr.in_from}</td>
|
<td>${ppr.in_from}</td>
|
||||||
<td>${eta}</td>
|
|
||||||
<td>${ppr.pob_in}</td>
|
|
||||||
<td>${ppr.out_to || '-'}</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.pob_out || '-'}</td>
|
||||||
<td>${ppr.fuel || '-'}</td>
|
<td>${ppr.fuel || '-'}</td>
|
||||||
<td>${landed}</td>
|
|
||||||
<td>${departed}</td>
|
|
||||||
<td>${ppr.email || '-'}</td>
|
<td>${ppr.email || '-'}</td>
|
||||||
<td>${ppr.phone || '-'}</td>
|
<td>${ppr.phone || '-'}</td>
|
||||||
<td>${ppr.notes || '-'}</td>
|
<td>${ppr.notes || '-'}</td>
|
||||||
@@ -1175,10 +1294,10 @@
|
|||||||
const tbody = document.getElementById('other-flights-table-body');
|
const tbody = document.getElementById('other-flights-table-body');
|
||||||
const tableInfo = document.getElementById('other-flights-info');
|
const tableInfo = document.getElementById('other-flights-info');
|
||||||
|
|
||||||
// Apply filter if one is selected
|
// Overflights are counted in the summary but omitted from the detail table for now.
|
||||||
let filteredFlights = flights;
|
let filteredFlights = flights.filter(flight => flight.flightType !== 'OVERFLIGHT');
|
||||||
if (otherFlightsFilterType) {
|
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})` : '');
|
tableInfo.textContent = `${filteredFlights.length} flights found` + (otherFlightsFilterType ? ` (filtered by ${otherFlightsFilterType})` : '');
|
||||||
@@ -1189,13 +1308,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by time field (ascending)
|
// Sort by the first pertinent movement time.
|
||||||
filteredFlights.sort((a, b) => {
|
filteredFlights.sort((a, b) => {
|
||||||
const aTime = a.timeField;
|
const aTime = a.sortTime;
|
||||||
const bTime = b.timeField;
|
const bTime = b.sortTime;
|
||||||
if (!aTime) return 1;
|
if (!aTime) return 1;
|
||||||
if (!bTime) return -1;
|
if (!bTime) return -1;
|
||||||
return new Date(aTime) - new Date(bTime);
|
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||||
});
|
});
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
@@ -1204,6 +1323,8 @@
|
|||||||
|
|
||||||
for (const flight of filteredFlights) {
|
for (const flight of filteredFlights) {
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
|
row.className = 'clickable-row';
|
||||||
|
row.onclick = () => openReportDetail(flight.entityType, flight.id);
|
||||||
|
|
||||||
const typeLabel = flight.flightType;
|
const typeLabel = flight.flightType;
|
||||||
const registration = flight.registration || '-';
|
const registration = flight.registration || '-';
|
||||||
@@ -1211,18 +1332,8 @@
|
|||||||
const callsign = flight.callsign || '-';
|
const callsign = flight.callsign || '-';
|
||||||
const from = flight.fromField || '-';
|
const from = flight.fromField || '-';
|
||||||
const to = flight.toField || '-';
|
const to = flight.toField || '-';
|
||||||
const timeDisplay = flight.timeField ? formatDateTime(flight.timeField) : '-';
|
const takeoff = flight.takeoffTime ? formatDateTime(flight.takeoffTime) : '-';
|
||||||
|
const landing = flight.landingTime ? formatDateTime(flight.landingTime) : '-';
|
||||||
// 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 status = flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING');
|
const status = flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING');
|
||||||
const circuits = (flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits > 0 ? flight.circuits : '-') : '-';
|
const circuits = (flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits > 0 ? flight.circuits : '-') : '-';
|
||||||
@@ -1235,8 +1346,8 @@
|
|||||||
<td>${callsign}</td>
|
<td>${callsign}</td>
|
||||||
<td>${from}</td>
|
<td>${from}</td>
|
||||||
<td>${to}</td>
|
<td>${to}</td>
|
||||||
<td>${timeDisplay}</td>
|
<td>${takeoff}</td>
|
||||||
<td>${actualDisplay}</td>
|
<td>${landing}</td>
|
||||||
<td>${circuits}</td>
|
<td>${circuits}</td>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -1246,23 +1357,202 @@
|
|||||||
|
|
||||||
function formatDateTime(dateStr) {
|
function formatDateTime(dateStr) {
|
||||||
if (!dateStr) return '-';
|
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')) {
|
if (!utcDateStr.includes('T')) {
|
||||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||||
}
|
}
|
||||||
if (!utcDateStr.includes('Z')) {
|
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||||
utcDateStr += 'Z';
|
utcDateStr += 'Z';
|
||||||
}
|
}
|
||||||
const date = new Date(utcDateStr);
|
return new Date(utcDateStr);
|
||||||
|
}
|
||||||
// Format as dd/mm/yy hh:mm
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
function getPPRSortTime(ppr) {
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
return ppr.landed_dt || ppr.qsy_dt || ppr.takeoff_dt || ppr.eta || ppr.etd || ppr.submitted_dt;
|
||||||
const year = String(date.getFullYear()).slice(-2);
|
}
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
const detailConfig = {
|
||||||
|
PPR: {
|
||||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
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
|
// Clear filters
|
||||||
@@ -1282,8 +1572,8 @@
|
|||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
'ID', 'Status', 'Aircraft Reg', 'Aircraft Type', 'Callsign', 'Captain',
|
'ID', 'Status', 'Aircraft Reg', 'Aircraft Type', 'Callsign', 'Captain',
|
||||||
'From', 'ETA', 'POB In', 'To', 'ETD', 'POB Out', 'Fuel',
|
'From', 'To', 'Takeoff', 'QSY', 'Landing', 'POB In', 'POB Out', 'Fuel',
|
||||||
'Landed', 'Departed', 'Email', 'Phone', 'Notes', 'Submitted', 'Created By'
|
'Email', 'Phone', 'Notes', 'Submitted', 'Created By'
|
||||||
];
|
];
|
||||||
|
|
||||||
const csvData = currentPPRs.map(ppr => [
|
const csvData = currentPPRs.map(ppr => [
|
||||||
@@ -1294,14 +1584,13 @@
|
|||||||
ppr.ac_call || '',
|
ppr.ac_call || '',
|
||||||
ppr.captain,
|
ppr.captain,
|
||||||
ppr.in_from,
|
ppr.in_from,
|
||||||
ppr.eta ? formatDateTime(ppr.eta) : '',
|
|
||||||
ppr.pob_in,
|
|
||||||
ppr.out_to || '',
|
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.pob_out || '',
|
||||||
ppr.fuel || '',
|
ppr.fuel || '',
|
||||||
ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '',
|
|
||||||
ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '',
|
|
||||||
ppr.email || '',
|
ppr.email || '',
|
||||||
ppr.phone || '',
|
ppr.phone || '',
|
||||||
ppr.notes || '',
|
ppr.notes || '',
|
||||||
@@ -1318,22 +1607,26 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exportFlights = currentOtherFlights.filter(flight => flight.flightType !== 'OVERFLIGHT');
|
||||||
|
if (exportFlights.length === 0) {
|
||||||
|
showNotification('No table data to export', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
'Flight Type', 'Aircraft Registration', 'Aircraft Type', 'Callsign', 'From', 'To',
|
'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.flightType,
|
||||||
flight.registration || '',
|
flight.registration || '',
|
||||||
flight.aircraft_type || '',
|
flight.aircraft_type || '',
|
||||||
flight.callsign || '',
|
flight.callsign || '',
|
||||||
flight.fromField || '',
|
flight.fromField || '',
|
||||||
flight.toField || '',
|
flight.toField || '',
|
||||||
flight.timeField ? formatDateTime(flight.timeField) : '',
|
flight.takeoffTime ? formatDateTime(flight.takeoffTime) : '',
|
||||||
flight.flightType === 'ARRIVAL'
|
flight.landingTime ? formatDateTime(flight.landingTime) : '',
|
||||||
? (flight.landed_dt ? formatDateTime(flight.landed_dt) : '')
|
|
||||||
: (flight.departed_dt ? formatDateTime(flight.departed_dt) : ''),
|
|
||||||
flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING'),
|
flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING'),
|
||||||
(flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits || '') : ''
|
(flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits || '') : ''
|
||||||
]);
|
]);
|
||||||
@@ -1382,8 +1675,20 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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
|
// Initialize when page loads
|
||||||
document.addEventListener('DOMContentLoaded', initializePage);
|
document.addEventListener('DOMContentLoaded', initializePage);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
<div id="arrival-airport-lookup-results"></div>
|
<div id="arrival-airport-lookup-results"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="eta">ETA (Local Time) *</label>
|
<label for="eta">ETA (UTC) *</label>
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
<input type="date" id="eta-date" name="eta-date" required style="flex: 1;">
|
<input type="date" id="eta-date" name="eta-date" required style="flex: 1;">
|
||||||
<select id="eta-time" name="eta-time" 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 id="departure-airport-lookup-results"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="etd">ETD (Local Time)</label>
|
<label for="etd">ETD (UTC)</label>
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
<input type="date" id="etd-date" name="etd-date" tabindex="-1" style="flex: 1;">
|
<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;">
|
<select id="etd-time" name="etd-time" tabindex="-1" style="flex: 1;">
|
||||||
|
|||||||
+134
-62
@@ -128,6 +128,13 @@
|
|||||||
loadArrivals();
|
loadArrivals();
|
||||||
showNotification('Arrival updated');
|
showNotification('Arrival updated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.type && data.type.startsWith('drone_request_')) {
|
||||||
|
if (typeof window.refreshDroneRequestBadge === 'function') {
|
||||||
|
window.refreshDroneRequestBadge();
|
||||||
|
}
|
||||||
|
showNotification('Drone request updated');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing WebSocket message:', error);
|
console.error('Error parsing WebSocket message:', error);
|
||||||
}
|
}
|
||||||
@@ -513,6 +520,9 @@
|
|||||||
await updateUserRole(); // Update role-based UI
|
await updateUserRole(); // Update role-based UI
|
||||||
startSessionExpiryCheck(); // Start monitoring session expiry
|
startSessionExpiryCheck(); // Start monitoring session expiry
|
||||||
connectWebSocket(); // Connect WebSocket for real-time updates
|
connectWebSocket(); // Connect WebSocket for real-time updates
|
||||||
|
if (typeof window.refreshDroneRequestBadge === 'function') {
|
||||||
|
window.refreshDroneRequestBadge();
|
||||||
|
}
|
||||||
loadPPRs();
|
loadPPRs();
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.detail || 'Authentication failed');
|
throw new Error(data.detail || 'Authentication failed');
|
||||||
@@ -580,33 +590,104 @@
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load PPR records - now loads all tables
|
function normalizeUtcDateString(dateStr) {
|
||||||
function formatTimeOnly(dateStr) {
|
if (!dateStr) return null;
|
||||||
if (!dateStr) return '-';
|
let utcDateStr = String(dateStr).trim();
|
||||||
// Ensure the datetime string is treated as UTC
|
|
||||||
let utcDateStr = dateStr;
|
|
||||||
if (!utcDateStr.includes('T')) {
|
if (!utcDateStr.includes('T')) {
|
||||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||||
}
|
}
|
||||||
if (!utcDateStr.includes('Z')) {
|
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||||
utcDateStr += 'Z';
|
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);
|
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) {
|
function formatDateTime(dateStr) {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
// Ensure the datetime string is treated as UTC
|
const date = parseUtcDate(dateStr);
|
||||||
let utcDateStr = dateStr;
|
return date && !Number.isNaN(date.getTime()) ? `${formatUtcDateInput(date)} ${formatUtcTimeInput(date)}` : '-';
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal functions
|
// Modal functions
|
||||||
@@ -630,24 +711,10 @@
|
|||||||
const eta = new Date(now.getTime() + 60 * 60 * 1000); // +1 hour
|
const eta = new Date(now.getTime() + 60 * 60 * 1000); // +1 hour
|
||||||
const etd = new Date(now.getTime() + 2 * 60 * 60 * 1000); // +2 hours
|
const etd = new Date(now.getTime() + 2 * 60 * 60 * 1000); // +2 hours
|
||||||
|
|
||||||
// Format date and time for separate inputs
|
document.getElementById('eta-date').value = formatUtcDateInput(eta);
|
||||||
function formatDate(date) {
|
document.getElementById('eta-time').value = formatUtcTimeInput(eta);
|
||||||
const year = date.getFullYear();
|
document.getElementById('etd-date').value = formatUtcDateInput(etd);
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
document.getElementById('etd-time').value = formatUtcTimeInput(etd);
|
||||||
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);
|
|
||||||
|
|
||||||
// Clear aircraft lookup results
|
// Clear aircraft lookup results
|
||||||
clearAircraftLookup();
|
clearAircraftLookup();
|
||||||
@@ -673,15 +740,14 @@
|
|||||||
const etaTime = document.getElementById('eta-time').value;
|
const etaTime = document.getElementById('eta-time').value;
|
||||||
|
|
||||||
if (etaDate && etaTime) {
|
if (etaDate && etaTime) {
|
||||||
// Parse ETA
|
const eta = parseUtcDate(combineUtcDateTimeInput(etaDate, etaTime));
|
||||||
const eta = new Date(`${etaDate}T${etaTime}`);
|
|
||||||
|
|
||||||
// Calculate ETD (2 hours after ETA)
|
// Calculate ETD (2 hours after ETA)
|
||||||
const etd = new Date(eta.getTime() + 2 * 60 * 60 * 1000);
|
const etd = new Date(eta.getTime() + 2 * 60 * 60 * 1000);
|
||||||
|
|
||||||
// Format ETD
|
// Format ETD
|
||||||
const etdDateStr = `${etd.getFullYear()}-${String(etd.getMonth() + 1).padStart(2, '0')}-${String(etd.getDate()).padStart(2, '0')}`;
|
const etdDateStr = formatUtcDateInput(etd);
|
||||||
const etdTimeStr = `${String(etd.getHours()).padStart(2, '0')}:${String(etd.getMinutes()).padStart(2, '0')}`;
|
const etdTimeStr = formatUtcTimeInput(etd);
|
||||||
|
|
||||||
// Update ETD fields
|
// Update ETD fields
|
||||||
document.getElementById('etd-date').value = etdDateStr;
|
document.getElementById('etd-date').value = etdDateStr;
|
||||||
@@ -711,6 +777,9 @@
|
|||||||
|
|
||||||
const ppr = await response.json();
|
const ppr = await response.json();
|
||||||
populateForm(ppr);
|
populateForm(ppr);
|
||||||
|
const departedBtn = document.getElementById('btn-departed');
|
||||||
|
departedBtn.textContent = '🛫 Depart';
|
||||||
|
departedBtn.setAttribute('onclick', "showTimestampModal('DEPARTED')");
|
||||||
|
|
||||||
// Show/hide quick action buttons based on current status
|
// Show/hide quick action buttons based on current status
|
||||||
if (ppr.status === 'NEW') {
|
if (ppr.status === 'NEW') {
|
||||||
@@ -723,8 +792,16 @@
|
|||||||
document.getElementById('btn-cancel').style.display = 'inline-block';
|
document.getElementById('btn-cancel').style.display = 'inline-block';
|
||||||
} else if (ppr.status === 'LANDED') {
|
} else if (ppr.status === 'LANDED') {
|
||||||
document.getElementById('btn-landed').style.display = 'none';
|
document.getElementById('btn-landed').style.display = 'none';
|
||||||
document.getElementById('btn-departed').style.display = 'inline-block';
|
departedBtn.style.display = 'inline-block';
|
||||||
|
departedBtn.textContent = '🛫 Take Off';
|
||||||
|
departedBtn.setAttribute('onclick', "showTimestampModal('LOCAL')");
|
||||||
document.getElementById('btn-cancel').style.display = 'inline-block';
|
document.getElementById('btn-cancel').style.display = 'inline-block';
|
||||||
|
} else if (ppr.status === 'LOCAL') {
|
||||||
|
document.getElementById('btn-landed').style.display = 'none';
|
||||||
|
departedBtn.style.display = 'inline-block';
|
||||||
|
departedBtn.textContent = 'QSY';
|
||||||
|
departedBtn.setAttribute('onclick', "showTimestampModal('DEPARTED')");
|
||||||
|
document.getElementById('btn-cancel').style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
// DEPARTED, CANCELED, DELETED - hide all quick actions and cancel button
|
// DEPARTED, CANCELED, DELETED - hide all quick actions and cancel button
|
||||||
document.querySelector('.quick-actions').style.display = 'none';
|
document.querySelector('.quick-actions').style.display = 'none';
|
||||||
@@ -744,31 +821,19 @@
|
|||||||
Object.keys(ppr).forEach(key => {
|
Object.keys(ppr).forEach(key => {
|
||||||
if (key === 'eta' || key === 'etd') {
|
if (key === 'eta' || key === 'etd') {
|
||||||
if (ppr[key]) {
|
if (ppr[key]) {
|
||||||
// ppr[key] is UTC datetime string from API (naive, assume UTC)
|
const date = parseUtcDate(ppr[key]);
|
||||||
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
|
|
||||||
|
|
||||||
// Split into date and time components for separate inputs
|
// Split into date and time components for separate inputs
|
||||||
const dateField = document.getElementById(`${key}-date`);
|
const dateField = document.getElementById(`${key}-date`);
|
||||||
const timeField = document.getElementById(`${key}-time`);
|
const timeField = document.getElementById(`${key}-time`);
|
||||||
|
|
||||||
if (dateField && timeField) {
|
if (dateField && timeField) {
|
||||||
// Format date
|
const dateValue = formatUtcDateInput(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}`;
|
|
||||||
dateField.value = dateValue;
|
dateField.value = dateValue;
|
||||||
|
|
||||||
// Format time (round to nearest 15-minute interval)
|
// Format time (round to nearest 15-minute interval)
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||||
const rawMinutes = date.getMinutes();
|
const rawMinutes = date.getUTCMinutes();
|
||||||
const roundedMinutes = Math.round(rawMinutes / 15) * 15 % 60;
|
const roundedMinutes = Math.round(rawMinutes / 15) * 15 % 60;
|
||||||
const minutes = String(roundedMinutes).padStart(2, '0');
|
const minutes = String(roundedMinutes).padStart(2, '0');
|
||||||
const timeValue = `${hours}:${minutes}`;
|
const timeValue = `${hours}:${minutes}`;
|
||||||
@@ -904,6 +969,9 @@
|
|||||||
|
|
||||||
// Circuit modal functions
|
// Circuit modal functions
|
||||||
function showCircuitModal(localFlightId = null, arrivalId = null) {
|
function showCircuitModal(localFlightId = null, arrivalId = null) {
|
||||||
|
localFlightId = localFlightId || currentLocalFlightId;
|
||||||
|
arrivalId = arrivalId || currentArrivalId;
|
||||||
|
|
||||||
if (!localFlightId && !arrivalId) return;
|
if (!localFlightId && !arrivalId) return;
|
||||||
|
|
||||||
// Set the current IDs
|
// Set the current IDs
|
||||||
@@ -1076,12 +1144,12 @@
|
|||||||
// Combine date and time for ETA
|
// Combine date and time for ETA
|
||||||
const dateStr = formData.get('eta-date');
|
const dateStr = formData.get('eta-date');
|
||||||
const timeStr = formData.get('eta-time');
|
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')) {
|
} else if (key === 'etd-date' && formData.get('etd-time')) {
|
||||||
// Combine date and time for ETD
|
// Combine date and time for ETD
|
||||||
const dateStr = formData.get('etd-date');
|
const dateStr = formData.get('etd-date');
|
||||||
const timeStr = formData.get('etd-time');
|
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') {
|
} else if (key !== 'eta-time' && key !== 'etd-time') {
|
||||||
// Skip the time fields as they're handled above
|
// Skip the time fields as they're handled above
|
||||||
pprData[key] = value;
|
pprData[key] = value;
|
||||||
@@ -1843,13 +1911,13 @@
|
|||||||
|
|
||||||
// Parse and populate call_dt
|
// Parse and populate call_dt
|
||||||
if (overflight.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);
|
document.getElementById('overflight_edit_call_dt').value = callDt.toISOString().slice(0, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and populate qsy_dt if exists
|
// Parse and populate qsy_dt if exists
|
||||||
if (overflight.qsy_dt) {
|
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);
|
document.getElementById('overflight_edit_qsy_dt').value = qsyDt.toISOString().slice(0, 16);
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('overflight_edit_qsy_dt').value = '';
|
document.getElementById('overflight_edit_qsy_dt').value = '';
|
||||||
@@ -2593,11 +2661,15 @@
|
|||||||
const tableHelpTexts = {
|
const tableHelpTexts = {
|
||||||
arrivals: {
|
arrivals: {
|
||||||
title: "Today's Pending Arrivals",
|
title: "Today's Pending Arrivals",
|
||||||
text: "Displays aircraft that are expected to arrive at Swansea today. These are flights that have filed PPRs or have been booked in as arriving. Aircraft in this list are actively planning to land today."
|
text: "Displays aircraft that are expected to arrive at Swansea today. These are PPR arrivals and aircraft booked in as arrivals."
|
||||||
},
|
},
|
||||||
departures: {
|
departures: {
|
||||||
title: "Today's Pending Departures",
|
title: "Today's Pending Departures",
|
||||||
text: "Displays aircraft that are ready to depart from Swansea today. This includes flights with approved PPRs awaiting departure, local flights that have been booked out, and departures to other airfields. These aircraft will depart today."
|
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: "Local Traffic",
|
||||||
|
text: "Displays local traffic booked out today, including local flights, circuits, and PPR departures that are airborne locally before QSY."
|
||||||
},
|
},
|
||||||
overflights: {
|
overflights: {
|
||||||
title: "Active Overflights",
|
title: "Active Overflights",
|
||||||
|
|||||||
+335
@@ -0,0 +1,335 @@
|
|||||||
|
(function () {
|
||||||
|
const actionMap = {
|
||||||
|
'new-ppr': "openNewPPRModal",
|
||||||
|
'book-out': "openLocalFlightModal",
|
||||||
|
'book-in': "openBookInModal",
|
||||||
|
overflight: "openOverflightModal",
|
||||||
|
'user-aircraft': "openUserAircraftModal",
|
||||||
|
'user-management': "openUserManagementModal"
|
||||||
|
};
|
||||||
|
|
||||||
|
function injectTopbarStyles() {
|
||||||
|
if (document.getElementById('shared-topbar-styles')) return;
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'shared-topbar-styles';
|
||||||
|
style.textContent = `
|
||||||
|
.top-bar {
|
||||||
|
background: linear-gradient(135deg, #2c3e50, #3498db);
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.top-bar .title { order: 2; flex: 1; text-align: center; }
|
||||||
|
.top-bar .title h1 { margin: 0; font-size: 1.5rem; }
|
||||||
|
.top-bar .menu-buttons {
|
||||||
|
order: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.top-bar .user-info {
|
||||||
|
order: 3;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.top-bar .btn {
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.top-bar .btn-success { background-color: #27ae60; color: white; }
|
||||||
|
.top-bar .btn-warning { background-color: #f39c12; color: white; }
|
||||||
|
.dropdown { position: relative; display: inline-block; }
|
||||||
|
.dropdown-toggle { white-space: nowrap; }
|
||||||
|
.dropdown-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
background-color: white;
|
||||||
|
min-width: 220px;
|
||||||
|
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
|
||||||
|
border-radius: 5px;
|
||||||
|
z-index: 1000;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.dropdown-menu.active { display: block; }
|
||||||
|
.dropdown-menu a {
|
||||||
|
color: #333;
|
||||||
|
padding: 0.75rem 1.2rem;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dropdown-menu a:hover { background-color: #f5f5f5; }
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLink(label, action, shortcut, id = '') {
|
||||||
|
const shortcutText = shortcut ? `<span class="shortcut">(${shortcut})</span>` : '';
|
||||||
|
const idAttr = id ? ` id="${id}"` : '';
|
||||||
|
const hidden = id === 'user-management-dropdown' ? ' style="display: none;"' : '';
|
||||||
|
return `<a href="#" data-topbar-action="${action}"${idAttr}${hidden}>${label} ${shortcutText}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navLink(label, href, id = '') {
|
||||||
|
const idAttr = id ? ` id="${id}"` : '';
|
||||||
|
return `<a href="${href}"${idAttr}>${label}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTopbar() {
|
||||||
|
const topbar = document.querySelector('.top-bar');
|
||||||
|
if (!topbar || topbar.dataset.sharedTopbar === 'true') return;
|
||||||
|
|
||||||
|
injectTopbarStyles();
|
||||||
|
|
||||||
|
const existingTitle = topbar.querySelector('#tower-title, .title h1, h1');
|
||||||
|
const path = window.location.pathname.replace(/\/$/, '') || '/';
|
||||||
|
const titleByPath = {
|
||||||
|
'/admin': '✈️ Swansea Tower',
|
||||||
|
'/atc': '✈️ Swansea Tower - ATC View',
|
||||||
|
'/reports': '📊 PPR Reports',
|
||||||
|
'/movements': '📈 Movements',
|
||||||
|
'/drone-requests': 'Drone Requests',
|
||||||
|
'/bulk-log': '🧾 Bulk Flight Log',
|
||||||
|
'/journal': '📔 Journal Log'
|
||||||
|
};
|
||||||
|
const titleText = titleByPath[path] || (existingTitle ? existingTitle.textContent.trim() : 'Tower Ops');
|
||||||
|
const existingUser = topbar.querySelector('#current-user');
|
||||||
|
const username = existingUser ? existingUser.textContent.trim() : 'Loading...';
|
||||||
|
|
||||||
|
topbar.dataset.sharedTopbar = 'true';
|
||||||
|
topbar.innerHTML = `
|
||||||
|
<div class="title">
|
||||||
|
<h1 id="tower-title">${titleText}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="menu-buttons">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-success dropdown-toggle" id="actionsDropdownBtn">📋 Actions</button>
|
||||||
|
<div class="dropdown-menu" id="actionsDropdownMenu">
|
||||||
|
${actionLink('➕ New PPR', 'new-ppr', 'N')}
|
||||||
|
${actionLink('🛫 Book Out', 'book-out', 'L')}
|
||||||
|
${actionLink('🛬 Book In', 'book-in', 'I')}
|
||||||
|
${actionLink('🔄 Overflight', 'overflight', 'O')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown">
|
||||||
|
<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('🏠 Home', '/admin')}
|
||||||
|
${navLink('🎛️ ATC View', '/atc')}
|
||||||
|
${navLink('📊 Reports', '/reports')}
|
||||||
|
${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')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="user-info">
|
||||||
|
Logged in as: <span id="current-user">${username || 'Loading...'}</span> |
|
||||||
|
<a href="#" data-topbar-logout style="color: white;">Logout</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdowns(except = null) {
|
||||||
|
document.querySelectorAll('.dropdown-menu.active').forEach(menu => {
|
||||||
|
if (menu !== except) menu.classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAction(action) {
|
||||||
|
const fnName = actionMap[action];
|
||||||
|
if (fnName && typeof window[fnName] === 'function') {
|
||||||
|
if (action === 'book-out') {
|
||||||
|
window[fnName]('LOCAL');
|
||||||
|
} else {
|
||||||
|
window[fnName]();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = `/admin?action=${encodeURIComponent(action)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeferredAction() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const action = params.get('action');
|
||||||
|
if (!action) return;
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
runAction(action);
|
||||||
|
const cleanUrl = window.location.pathname + window.location.hash;
|
||||||
|
window.history.replaceState({}, document.title, cleanUrl);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateRoleVisibility() {
|
||||||
|
const token = localStorage.getItem('ppr_access_token');
|
||||||
|
const userManagement = document.getElementById('user-management-dropdown');
|
||||||
|
const userAircraft = document.getElementById('user-aircraft-dropdown');
|
||||||
|
|
||||||
|
if (!token || (!userManagement && !userAircraft)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/auth/test-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const user = await response.json();
|
||||||
|
const role = (user.role || '').toUpperCase();
|
||||||
|
if (role === 'ADMINISTRATOR') {
|
||||||
|
if (userManagement) userManagement.style.display = 'flex';
|
||||||
|
if (userAircraft) userAircraft.style.display = 'flex';
|
||||||
|
} else if (role === 'OPERATOR') {
|
||||||
|
if (userManagement) userManagement.style.display = 'none';
|
||||||
|
if (userAircraft) userAircraft.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
if (userManagement) userManagement.style.display = 'none';
|
||||||
|
if (userAircraft) userAircraft.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (userManagement) userManagement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]');
|
||||||
|
const logout = event.target.closest('[data-topbar-logout]');
|
||||||
|
|
||||||
|
if (toggle) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
const menu = toggle.parentElement.querySelector('.dropdown-menu');
|
||||||
|
const willOpen = !menu.classList.contains('active');
|
||||||
|
closeDropdowns(menu);
|
||||||
|
menu.classList.toggle('active', willOpen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
closeDropdowns();
|
||||||
|
runAction(action.dataset.topbarAction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logout) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
if (typeof window.logout === 'function') {
|
||||||
|
window.logout();
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('ppr_access_token');
|
||||||
|
localStorage.removeItem('ppr_username');
|
||||||
|
localStorage.removeItem('ppr_token_expiry');
|
||||||
|
window.location.href = '/admin';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.target.closest('.dropdown')) {
|
||||||
|
closeDropdowns();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
normalizeTopbar();
|
||||||
|
updateRoleVisibility();
|
||||||
|
refreshDroneRequestBadge();
|
||||||
|
handleDeferredAction();
|
||||||
|
window.setInterval(refreshDroneRequestBadge, 60000);
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user