Compare commits

...

16 Commits

Author SHA1 Message Date
211db514dd Merge pull request 'local-flights' (#5) from local-flights into main
Reviewed-on: #5
2025-12-20 12:29:31 -05:00
24971ac5fc Merge pull request 'Xmas silliness' (#4) from main into local-flights
Reviewed-on: #4
2025-12-19 12:07:44 -05:00
James Pattinson
a1a5f90f00 Xmas silliness 2025-12-19 17:06:48 +00:00
97995fa58e Help text 2025-12-19 09:07:36 -05:00
bcd582aee5 Filtering enhancements 2025-12-19 08:53:47 -05:00
dc6b551325 Correct missing funcs 2025-12-19 08:40:06 -05:00
ac29b6e929 UI config 2025-12-19 08:33:42 -05:00
0149f45893 AI tweaking 2025-12-19 08:06:47 -05:00
63564b54dd Overflight improvements 2025-12-19 05:51:55 -05:00
3ab9a6e04c Overflights reporting and menu layout changes 2025-12-19 05:28:17 -05:00
b46a88d471 Overflights implementation 2025-12-19 05:07:46 -05:00
658d4c4ff8 Movement summary 2025-12-18 11:53:44 -05:00
a43ab34a8f Better movement reporting 2025-12-18 11:45:24 -05:00
dee5d38b58 URL tidy 2025-12-18 10:46:18 -05:00
ee311cc120 Merge pull request 'Remove Full Tanks option' (#3) from main into local-flights
Reviewed-on: #3
2025-12-18 10:35:00 -05:00
James Pattinson
e63fdc74ec Remove Full Tanks option 2025-12-18 15:32:39 +00:00
28 changed files with 2737 additions and 149 deletions

View File

@@ -24,6 +24,11 @@ MAIL_FROM_NAME=your_mail_from_name_here
# Application settings
BASE_URL=your_base_url_here
# UI Configuration
TAG=
TOP_BAR_BASE_COLOR=#2c3e50
ENVIRONMENT=development
# Redis (optional)
REDIS_URL=

View File

@@ -79,6 +79,7 @@ def upgrade() -> None:
sa.Column('flight_type', sa.Enum('LOCAL', 'CIRCUITS', 'DEPARTURE', name='localflighttype'), nullable=False),
sa.Column('status', sa.Enum('BOOKED_OUT', 'DEPARTED', 'LANDED', 'CANCELLED', name='localflightstatus'), nullable=False, server_default='BOOKED_OUT'),
sa.Column('duration', sa.Integer(), nullable=True, comment='Duration in minutes'),
sa.Column('circuits', sa.Integer(), nullable=True, default=0, comment='Actual number of circuits completed'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('etd', sa.DateTime(), nullable=True),
@@ -173,11 +174,42 @@ def upgrade() -> None:
op.create_index('idx_circuit_local_flight_id', 'circuits', ['local_flight_id'])
op.create_index('idx_circuit_timestamp', 'circuits', ['circuit_timestamp'])
# Create overflights table for tracking aircraft talking to the tower but not departing/landing
op.create_table('overflights',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('registration', sa.String(length=16), nullable=False),
sa.Column('pob', sa.Integer(), nullable=True),
sa.Column('type', sa.String(length=32), nullable=True),
sa.Column('departure_airfield', sa.String(length=64), nullable=True),
sa.Column('destination_airfield', sa.String(length=64), nullable=True),
sa.Column('status', sa.Enum('ACTIVE', 'INACTIVE', 'CANCELLED', name='overflightstatus'), nullable=False, server_default='ACTIVE'),
sa.Column('call_dt', sa.DateTime(), nullable=False),
sa.Column('qsy_dt', sa.DateTime(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('created_by', sa.String(length=16), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_unicode_ci'
)
# Create indexes for overflights
op.create_index('idx_ovf_registration', 'overflights', ['registration'])
op.create_index('idx_ovf_departure_airfield', 'overflights', ['departure_airfield'])
op.create_index('idx_ovf_destination_airfield', 'overflights', ['destination_airfield'])
op.create_index('idx_ovf_status', 'overflights', ['status'])
op.create_index('idx_ovf_call_dt', 'overflights', ['call_dt'])
op.create_index('idx_ovf_created_dt', 'overflights', ['created_dt'])
op.create_index('idx_ovf_created_by', 'overflights', ['created_by'])
def downgrade() -> None:
"""
Drop the circuits, arrivals, departures, and local_flights tables.
Drop the overflights, circuits, arrivals, departures, and local_flights tables.
"""
op.drop_table('overflights')
op.drop_table('circuits')
op.drop_table('arrivals')
op.drop_table('departures')

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights
api_router = APIRouter()
@@ -8,6 +8,7 @@ api_router.include_router(pprs.router, prefix="/pprs", tags=["pprs"])
api_router.include_router(local_flights.router, prefix="/local-flights", tags=["local_flights"])
api_router.include_router(departures.router, prefix="/departures", tags=["departures"])
api_router.include_router(arrivals.router, prefix="/arrivals", tags=["arrivals"])
api_router.include_router(overflights.router, prefix="/overflights", tags=["overflights"])
api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"])
api_router.include_router(journal.router, prefix="/journal", tags=["journal"])
api_router.include_router(public.router, prefix="/public", tags=["public"])

View File

@@ -0,0 +1,206 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from datetime import date
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
from app.crud.crud_overflight import overflight as crud_overflight
from app.schemas.overflight import Overflight, OverflightCreate, OverflightUpdate, OverflightStatus, OverflightStatusUpdate
from app.models.ppr import User
from app.core.utils import get_client_ip
router = APIRouter()
@router.get("/", response_model=List[Overflight])
async def get_overflights(
request: Request,
skip: int = 0,
limit: int = 100,
status: Optional[OverflightStatus] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get overflight records with optional filtering"""
overflights = crud_overflight.get_multi(
db, skip=skip, limit=limit, status=status,
date_from=date_from, date_to=date_to
)
return overflights
@router.post("/", response_model=Overflight)
async def create_overflight(
request: Request,
overflight_in: OverflightCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Create a new overflight record"""
overflight = crud_overflight.create(db, obj_in=overflight_in, created_by=current_user.username)
# Send real-time update via WebSocket
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "overflight_created",
"data": {
"id": overflight.id,
"registration": overflight.registration,
"departure_airfield": overflight.departure_airfield,
"destination_airfield": overflight.destination_airfield,
"status": overflight.status.value
}
})
return overflight
@router.get("/{overflight_id}", response_model=Overflight)
async def get_overflight(
overflight_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get a specific overflight record"""
overflight = crud_overflight.get(db, overflight_id=overflight_id)
if not overflight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Overflight record not found"
)
return overflight
@router.put("/{overflight_id}", response_model=Overflight)
async def update_overflight(
request: Request,
overflight_id: int,
overflight_in: OverflightUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Update an overflight record"""
db_overflight = crud_overflight.get(db, overflight_id=overflight_id)
if not db_overflight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Overflight record not found"
)
# Get user IP from request
user_ip = request.client.host if request.client else None
overflight = crud_overflight.update(
db,
db_obj=db_overflight,
obj_in=overflight_in,
user=current_user.username,
user_ip=user_ip
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "overflight_updated",
"data": {
"id": overflight.id,
"registration": overflight.registration,
"status": overflight.status.value
}
})
return overflight
@router.patch("/{overflight_id}/status", response_model=Overflight)
async def update_overflight_status(
request: Request,
overflight_id: int,
status_update: OverflightStatusUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Update overflight status (ACTIVE -> INACTIVE for QSY)"""
client_ip = get_client_ip(request)
overflight = crud_overflight.update_status(
db,
overflight_id=overflight_id,
status=status_update.status,
timestamp=status_update.qsy_dt,
user=current_user.username,
user_ip=client_ip
)
if not overflight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Overflight record not found"
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "overflight_status_update",
"data": {
"id": overflight.id,
"registration": overflight.registration,
"status": overflight.status.value,
"qsy_dt": overflight.qsy_dt.isoformat() if overflight.qsy_dt else None
}
})
return overflight
@router.delete("/{overflight_id}", response_model=Overflight)
async def cancel_overflight(
request: Request,
overflight_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Cancel an overflight record"""
client_ip = get_client_ip(request)
overflight = crud_overflight.cancel(
db,
overflight_id=overflight_id,
user=current_user.username,
user_ip=client_ip
)
if not overflight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Overflight record not found"
)
# Send real-time update
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "overflight_cancelled",
"data": {
"id": overflight.id,
"registration": overflight.registration
}
})
return overflight
@router.get("/active/list", response_model=List[Overflight])
async def get_active_overflights(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get currently active overflights"""
overflights = crud_overflight.get_active_overflights(db)
return overflights
@router.get("/today/list", response_model=List[Overflight])
async def get_overflights_today(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Get today's overflights"""
overflights = crud_overflight.get_overflights_today(db)
return overflights

View File

@@ -11,10 +11,35 @@ from app.models.local_flight import LocalFlightStatus
from app.models.departure import DepartureStatus
from app.models.arrival import ArrivalStatus
from datetime import date, datetime, timedelta
import re
router = APIRouter()
def lighten_color(hex_color, factor=0.3):
"""Lighten a hex color by a factor (0-1)"""
hex_color = hex_color.lstrip('#')
if len(hex_color) != 6:
return hex_color # Invalid, return as is
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
r = min(255, int(r + (255 - r) * factor))
g = min(255, int(g + (255 - g) * factor))
b = min(255, int(b + (255 - b) * factor))
return f"#{r:02x}{g:02x}{b:02x}"
def darken_color(hex_color, factor=0.3):
"""Darken a hex color by a factor (0-1)"""
hex_color = hex_color.lstrip('#')
if len(hex_color) != 6:
return hex_color # Invalid, return as is
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
r = max(0, int(r * (1 - factor)))
g = max(0, int(g * (1 - factor)))
b = max(0, int(b * (1 - factor)))
return f"#{r:02x}{g:02x}{b:02x}"
@router.get("/arrivals")
async def get_public_arrivals(db: Session = Depends(get_db)):
"""Get today's arrivals for public display (PPR and local flights)"""
@@ -201,3 +226,17 @@ async def get_public_departures(db: Session = Depends(get_db)):
})
return departures_list
@router.get("/config")
async def get_ui_config():
"""Get UI configuration for client-side rendering"""
from app.core.config import settings
base_color = settings.top_bar_base_color
return {
"tag": settings.tag,
"top_bar_gradient_start": base_color,
"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
"environment": settings.environment
}

View File

@@ -28,6 +28,11 @@ class Settings(BaseSettings):
project_name: str = "Airfield PPR API"
base_url: str
# UI Configuration
tag: str = ""
top_bar_base_color: str = "#2c3e50"
environment: str = "production" # production, development, staging, etc.
# Redis settings (for future use)
redis_url: Optional[str] = None

View File

@@ -5,6 +5,7 @@ from datetime import date, datetime
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, LocalFlightStatusUpdate
from app.models.journal import EntityType
from app.models.circuit import Circuit
from app.crud.crud_journal import journal
@@ -149,6 +150,11 @@ class CRUDLocalFlight:
db_obj.departed_dt = current_time
elif status == LocalFlightStatus.LANDED:
db_obj.landed_dt = current_time
# Count circuits from the circuits table and populate the circuits column
circuit_count = db.query(func.count(Circuit.id)).filter(
Circuit.local_flight_id == flight_id
).scalar()
db_obj.circuits = circuit_count
db.add(db_obj)
db.commit()

View File

@@ -0,0 +1,172 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func, desc
from datetime import date, datetime
from app.models.overflight import Overflight, OverflightStatus
from app.schemas.overflight import OverflightCreate, OverflightUpdate, OverflightStatusUpdate
from app.models.journal import EntityType
from app.crud.crud_journal import journal
class CRUDOverflight:
def get(self, db: Session, overflight_id: int) -> Optional[Overflight]:
return db.query(Overflight).filter(Overflight.id == overflight_id).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[OverflightStatus] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None
) -> List[Overflight]:
query = db.query(Overflight)
if status:
query = query.filter(Overflight.status == status)
if date_from:
query = query.filter(func.date(Overflight.created_dt) >= date_from)
if date_to:
query = query.filter(func.date(Overflight.created_dt) <= date_to)
return query.order_by(desc(Overflight.created_dt)).offset(skip).limit(limit).all()
def get_active_overflights(self, db: Session) -> List[Overflight]:
"""Get currently active overflights"""
return db.query(Overflight).filter(
Overflight.status == OverflightStatus.ACTIVE
).order_by(desc(Overflight.created_dt)).all()
def get_overflights_today(self, db: Session) -> List[Overflight]:
"""Get today's overflights"""
today = date.today()
return db.query(Overflight).filter(
func.date(Overflight.created_dt) == today
).order_by(Overflight.created_dt).all()
def create(self, db: Session, obj_in: OverflightCreate, created_by: str) -> Overflight:
db_obj = Overflight(
**obj_in.dict(),
created_by=created_by,
status=OverflightStatus.ACTIVE
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log creation in journal
journal.log_change(
db,
EntityType.OVERFLIGHT,
db_obj.id,
f"Overflight created: {obj_in.registration} from {obj_in.departure_airfield} to {obj_in.destination_airfield}",
created_by,
None
)
return db_obj
def update(self, db: Session, db_obj: Overflight, obj_in: OverflightUpdate, user: str = "system", user_ip: Optional[str] = None) -> Overflight:
from datetime import datetime as dt
update_data = obj_in.dict(exclude_unset=True)
changes = []
for field, value in update_data.items():
old_value = getattr(db_obj, field)
# Normalize datetime values for comparison (ignore timezone differences)
if isinstance(old_value, dt) and isinstance(value, dt):
old_normalized = old_value.replace(tzinfo=None) if old_value.tzinfo else old_value
new_normalized = value.replace(tzinfo=None) if value.tzinfo else value
if old_normalized == new_normalized:
continue
if old_value != value:
changes.append(f"{field} changed from '{old_value}' to '{value}'")
setattr(db_obj, field, value)
if changes:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log changes in journal
for change in changes:
journal.log_change(
db,
EntityType.OVERFLIGHT,
db_obj.id,
change,
user,
user_ip
)
return db_obj
def update_status(
self,
db: Session,
overflight_id: int,
status: OverflightStatus,
timestamp: Optional[datetime] = None,
user: str = "system",
user_ip: Optional[str] = None
) -> Optional[Overflight]:
db_obj = self.get(db, overflight_id)
if not db_obj:
return None
# Ensure status is an OverflightStatus enum
if isinstance(status, str):
status = OverflightStatus(status)
old_status = db_obj.status
db_obj.status = status
# Set timestamp if transitioning to INACTIVE (QSY'd)
current_time = timestamp if timestamp is not None else datetime.utcnow()
if status == OverflightStatus.INACTIVE:
db_obj.qsy_dt = current_time
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log status change in journal
journal.log_change(
db,
EntityType.OVERFLIGHT,
overflight_id,
f"Status changed from {old_status.value} to {status.value}",
user,
user_ip
)
return db_obj
def cancel(self, db: Session, overflight_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[Overflight]:
db_obj = self.get(db, overflight_id)
if db_obj:
old_status = db_obj.status
db_obj.status = OverflightStatus.CANCELLED
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Log cancellation in journal
journal.log_change(
db,
EntityType.OVERFLIGHT,
overflight_id,
f"Status changed from {old_status.value} to CANCELLED",
user,
user_ip
)
return db_obj
overflight = CRUDOverflight()

View File

@@ -10,6 +10,7 @@ class EntityType(str, PyEnum):
LOCAL_FLIGHT = "LOCAL_FLIGHT"
ARRIVAL = "ARRIVAL"
DEPARTURE = "DEPARTURE"
OVERFLIGHT = "OVERFLIGHT"
class JournalEntry(Base):

View File

@@ -28,6 +28,7 @@ class LocalFlight(Base):
flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True)
status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True)
duration = Column(Integer, nullable=True) # Duration in minutes
circuits = Column(Integer, nullable=True, default=0) # Actual number of circuits completed
notes = Column(Text, nullable=True)
created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure

View File

@@ -0,0 +1,28 @@
from sqlalchemy import Column, Integer, String, DateTime, Text, Enum as SQLEnum, BigInteger
from sqlalchemy.sql import func
from enum import Enum
from app.db.session import Base
class OverflightStatus(str, Enum):
ACTIVE = "ACTIVE"
INACTIVE = "INACTIVE"
CANCELLED = "CANCELLED"
class Overflight(Base):
__tablename__ = "overflights"
id = Column(BigInteger, primary_key=True, autoincrement=True)
registration = Column(String(16), nullable=False, index=True)
pob = Column(Integer, nullable=True) # Persons on board
type = Column(String(32), nullable=True) # Aircraft type
departure_airfield = Column(String(64), nullable=True, index=True) # Airfield they departed from
destination_airfield = Column(String(64), nullable=True, index=True) # Where they're heading
status = Column(SQLEnum(OverflightStatus), nullable=False, default=OverflightStatus.ACTIVE, index=True)
call_dt = Column(DateTime, nullable=False, index=True) # Time of initial call
qsy_dt = Column(DateTime, nullable=True) # Time of frequency change (QSY)
notes = Column(Text, nullable=True)
created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
created_by = Column(String(16), nullable=True, index=True)
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())

View File

@@ -62,6 +62,7 @@ class LocalFlightUpdate(BaseModel):
status: Optional[LocalFlightStatus] = None
etd: Optional[datetime] = None
departed_dt: Optional[datetime] = None
circuits: Optional[int] = None
notes: Optional[str] = None
@@ -77,6 +78,7 @@ class LocalFlightInDBBase(LocalFlightBase):
etd: Optional[datetime] = None
departed_dt: Optional[datetime] = None
landed_dt: Optional[datetime] = None
circuits: Optional[int] = None
created_by: Optional[str] = None
updated_at: datetime

View File

@@ -0,0 +1,107 @@
from pydantic import BaseModel, validator
from datetime import datetime
from typing import Optional
from enum import Enum
class OverflightStatus(str, Enum):
ACTIVE = "ACTIVE"
INACTIVE = "INACTIVE"
CANCELLED = "CANCELLED"
class OverflightBase(BaseModel):
registration: str # Using registration as callsign
pob: Optional[int] = None
type: Optional[str] = None # Aircraft type
departure_airfield: Optional[str] = None
destination_airfield: Optional[str] = None
call_dt: datetime # Time of initial call
qsy_dt: Optional[datetime] = None # Time of frequency change
notes: Optional[str] = None
@validator('registration')
def validate_registration(cls, v):
if not v or len(v.strip()) == 0:
raise ValueError('Aircraft registration is required')
return v.strip().upper()
@validator('type')
def validate_type(cls, v):
if v and len(v.strip()) > 0:
return v.strip()
return v
@validator('departure_airfield')
def validate_departure_airfield(cls, v):
if v and len(v.strip()) > 0:
return v.strip().upper()
return v
@validator('destination_airfield')
def validate_destination_airfield(cls, v):
if v and len(v.strip()) > 0:
return v.strip().upper()
return v
@validator('pob')
def validate_pob(cls, v):
if v is not None and v < 1:
raise ValueError('Persons on board must be at least 1')
return v
class OverflightCreate(OverflightBase):
pass
class OverflightUpdate(BaseModel):
callsign: Optional[str] = None
pob: Optional[int] = None
type: Optional[str] = None
departure_airfield: Optional[str] = None
destination_airfield: Optional[str] = None
call_dt: Optional[datetime] = None
qsy_dt: Optional[datetime] = None
status: Optional[OverflightStatus] = None
notes: Optional[str] = None
@validator('type')
def validate_type(cls, v):
if v is not None and len(v.strip()) == 0:
return None
return v.strip() if v else v
@validator('departure_airfield')
def validate_departure_airfield(cls, v):
if v is not None and len(v.strip()) == 0:
return None
return v.strip().upper() if v else v
@validator('destination_airfield')
def validate_destination_airfield(cls, v):
if v is not None and len(v.strip()) == 0:
return None
return v.strip().upper() if v else v
@validator('pob')
def validate_pob(cls, v):
if v is not None and v < 1:
raise ValueError('Persons on board must be at least 1')
return v
class OverflightStatusUpdate(BaseModel):
status: OverflightStatus
qsy_dt: Optional[datetime] = None
class Overflight(OverflightBase):
id: int
status: OverflightStatus
created_dt: datetime
created_by: Optional[str] = None
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -26,6 +26,8 @@ services:
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
WORKERS: "4"
ports:

View File

@@ -38,6 +38,9 @@ services:
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
BASE_URL: ${BASE_URL}
REDIS_URL: ${REDIS_URL}
TOWER_NAME: ${TOWER_NAME}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
ENVIRONMENT: ${ENVIRONMENT}
ports:
- "${API_PORT_EXTERNAL}:8000" # Use different port to avoid conflicts with existing system
depends_on:

View File

@@ -37,9 +37,14 @@ http {
try_files $uri =404;
}
# Serve HTML files without .html extension (e.g., /admin -> admin.html)
location ~ ^/([a-zA-Z0-9_-]+)$ {
try_files /$1.html =404;
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
try_files $uri $uri/ =404;
# Apply X-Frame-Options to other files
add_header X-Frame-Options "SAMEORIGIN" always;
}

View File

@@ -16,22 +16,95 @@ body {
color: white;
padding: 0.5rem 2rem;
display: flex;
justify-content: space-between;
justify-content: center;
align-items: center;
gap: 2rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
position: relative;
z-index: 100;
}
.title {
order: 2;
flex: 1;
text-align: center;
}
.title h1 {
margin: 0;
font-size: 1.5rem;
}
.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;
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-toggle {
white-space: nowrap;
}
.dropdown-menu {
display: none;
position: absolute;
background-color: white;
min-width: 200px;
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 a {
color: #333;
padding: 0.75rem 1.5rem;
text-decoration: none;
display: flex;
justify-content: space-between;
align-items: center;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.shortcut {
font-size: 0.8rem;
color: #999;
margin-left: 1rem;
}
.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;
}
.dropdown-menu.active {
display: block;
}
.top-bar .user-info {
@@ -96,6 +169,15 @@ body {
background-color: #2980b9;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-secondary:hover {
background-color: #7f8c8d;
}
.btn-danger {
background-color: #e74c3c;
color: white;
@@ -315,10 +397,10 @@ tbody tr:hover {
border-color: transparent #333 transparent transparent;
}
.notes-tooltip:hover .tooltip-text {
/* .notes-tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
}
} */
/* Modal Styles */
.modal {

File diff suppressed because it is too large Load Diff

27
web/assets/bell.svg Normal file
View File

@@ -0,0 +1,27 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Bell body -->
<path d="M 30,40 Q 30,35 35,35 L 65,35 Q 70,35 70,40 Q 70,60 50,70 Q 30,60 30,40" fill="#FFD700" stroke="#DAA520" stroke-width="2"/>
<!-- Bell shine/highlight -->
<ellipse cx="45" cy="45" rx="8" ry="6" fill="#FFED4E" opacity="0.6"/>
<!-- Bell clapper -->
<circle cx="50" cy="65" r="4" fill="#8B4513"/>
<path d="M 50,65 Q 48,75 47,85" stroke="#8B4513" stroke-width="2" fill="none"/>
<!-- Top of bell (rope/hanging part) -->
<rect x="47" y="25" width="6" height="10" fill="#DAA520" rx="2"/>
<!-- Loop -->
<path d="M 48,25 Q 40,20 50,15 Q 60,20 52,25" stroke="#DAA520" stroke-width="2" fill="none"/>
<!-- Decorative berries around bell -->
<circle cx="25" cy="50" r="2" fill="#E74C3C"/>
<circle cx="75" cy="50" r="2" fill="#E74C3C"/>
<circle cx="28" cy="60" r="2" fill="#E74C3C"/>
<circle cx="72" cy="60" r="2" fill="#E74C3C"/>
<!-- Holly leaves -->
<path d="M 20,45 L 18,48 L 20,50 L 18,52 L 20,55" stroke="#228B22" stroke-width="1.5" fill="none"/>
<path d="M 80,45 L 82,48 L 80,50 L 82,52 L 80,55" stroke="#228B22" stroke-width="1.5" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

10
web/assets/candycane.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Candy cane curve -->
<path d="M 50,10 Q 30,30 30,60 Q 30,90 50,100" stroke="#E74C3C" stroke-width="12" fill="none" stroke-linecap="round"/>
<!-- White stripe -->
<path d="M 50,10 Q 30,30 30,60 Q 30,90 50,100" stroke="#FFFFFF" stroke-width="6" fill="none" stroke-linecap="round" stroke-dasharray="8,8"/>
<!-- Highlight -->
<path d="M 48,15 Q 32,32 32,60 Q 32,88 48,98" stroke="#FFFFFF" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 546 B

25
web/assets/gift.svg Normal file
View File

@@ -0,0 +1,25 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Box -->
<rect x="15" y="30" width="70" height="70" fill="#E74C3C" stroke="#C0392B" stroke-width="2"/>
<!-- Box lid/3D effect -->
<polygon points="15,30 25,20 85,20 75,30" fill="#C0392B"/>
<polygon points="75,30 85,20 85,90 75,100" fill="#A93226"/>
<!-- Ribbon vertical -->
<rect x="42" y="20" width="16" height="85" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
<!-- Ribbon horizontal -->
<rect x="10" y="57" width="80" height="16" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
<!-- Bow on top -->
<ellipse cx="35" cy="18" rx="10" ry="8" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
<ellipse cx="65" cy="18" rx="10" ry="8" fill="#FFD700" stroke="#DAA520" stroke-width="1"/>
<circle cx="50" cy="20" r="5" fill="#DAA520"/>
<!-- Pattern on box -->
<circle cx="30" cy="50" r="3" fill="#FFFFFF" opacity="0.5"/>
<circle cx="70" cy="60" r="3" fill="#FFFFFF" opacity="0.5"/>
<circle cx="50" cy="75" r="3" fill="#FFFFFF" opacity="0.5"/>
<circle cx="35" cy="80" r="3" fill="#FFFFFF" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

39
web/assets/reindeer.svg Normal file
View File

@@ -0,0 +1,39 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Antlers -->
<path d="M 35,25 Q 25,10 20,5" stroke="#8B4513" stroke-width="3" fill="none"/>
<path d="M 65,25 Q 75,10 80,5" stroke="#8B4513" stroke-width="3" fill="none"/>
<path d="M 33,22 Q 22,15 15,8" stroke="#8B4513" stroke-width="2" fill="none"/>
<path d="M 67,22 Q 78,15 85,8" stroke="#8B4513" stroke-width="2" fill="none"/>
<!-- Head -->
<circle cx="50" cy="35" r="15" fill="#8B4513"/>
<!-- Ears -->
<ellipse cx="38" cy="22" rx="5" ry="8" fill="#8B4513"/>
<ellipse cx="62" cy="22" rx="5" ry="8" fill="#8B4513"/>
<!-- Eyes -->
<circle cx="45" cy="32" r="2" fill="#000000"/>
<circle cx="55" cy="32" r="2" fill="#000000"/>
<!-- Nose (red) -->
<circle cx="50" cy="40" r="4" fill="#E74C3C"/>
<!-- Mouth -->
<path d="M 48,45 Q 50,47 52,45" stroke="#000000" stroke-width="1" fill="none"/>
<!-- Neck -->
<rect x="43" y="48" width="14" height="8" fill="#8B4513"/>
<!-- Body -->
<ellipse cx="50" cy="70" rx="20" ry="25" fill="#8B4513"/>
<!-- Legs -->
<rect x="35" y="90" width="6" height="25" fill="#8B4513"/>
<rect x="45" y="90" width="6" height="25" fill="#8B4513"/>
<rect x="55" y="90" width="6" height="25" fill="#8B4513"/>
<rect x="65" y="90" width="6" height="25" fill="#8B4513"/>
<!-- Tail -->
<circle cx="68" cy="65" r="5" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

37
web/assets/santa.svg Normal file
View File

@@ -0,0 +1,37 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Santa hat -->
<polygon points="20,20 80,20 75,35 25,35" fill="#E74C3C"/>
<circle cx="50" cy="18" r="8" fill="#E74C3C"/>
<circle cx="77" cy="28" r="6" fill="#FFFFFF"/>
<!-- Face -->
<circle cx="50" cy="50" r="18" fill="#F5DEB3"/>
<!-- Eyes -->
<circle cx="42" cy="45" r="2.5" fill="#000000"/>
<circle cx="58" cy="45" r="2.5" fill="#000000"/>
<!-- Nose -->
<circle cx="50" cy="52" r="2" fill="#E74C3C"/>
<!-- Beard -->
<path d="M 35,58 Q 35,65 50,68 Q 65,65 65,58" fill="#FFFFFF"/>
<!-- Mouth -->
<path d="M 42,60 Q 50,63 58,60" stroke="#000000" stroke-width="1" fill="none"/>
<!-- Body -->
<rect x="35" y="68" width="30" height="25" rx="5" fill="#E74C3C"/>
<!-- Belt -->
<rect x="32" y="85" width="36" height="4" fill="#000000"/>
<circle cx="68" cy="87" r="2.5" fill="#FFD700"/>
<!-- Arms -->
<rect x="15" y="75" width="20" height="6" rx="3" fill="#F5DEB3"/>
<rect x="65" y="75" width="20" height="6" rx="3" fill="#F5DEB3"/>
<!-- Legs -->
<rect x="40" y="93" width="8" height="20" fill="#000000"/>
<rect x="52" y="93" width="8" height="20" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

17
web/assets/tree.svg Normal file
View File

@@ -0,0 +1,17 @@
<svg viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Tree trunk -->
<rect x="40" y="80" width="20" height="25" fill="#8B4513"/>
<!-- Tree layers -->
<polygon points="50,10 20,50 30,50 10,80 40,80 5,110 50,110 95,110 60,80 90,80 70,50 80,50" fill="#228B22"/>
<!-- Tree highlights -->
<circle cx="50" cy="35" r="4" fill="#FFD700" opacity="0.7"/>
<circle cx="35" cy="55" r="3" fill="#FFD700" opacity="0.7"/>
<circle cx="65" cy="60" r="3" fill="#FFD700" opacity="0.7"/>
<circle cx="45" cy="80" r="3" fill="#FFD700" opacity="0.7"/>
<circle cx="55" cy="90" r="3" fill="#FFD700" opacity="0.7"/>
<!-- Star on top -->
<polygon points="50,5 55,15 65,15 57,20 60,30 50,25 40,30 43,20 35,15 45,15" fill="#FFD700"/>
</svg>

After

Width:  |  Height:  |  Size: 758 B

View File

@@ -132,6 +132,226 @@
grid-template-columns: 1fr; /* Stack columns on smaller screens */
}
}
/* Christmas toggle switch */
.christmas-toggle {
position: absolute;
right: 20px;
top: 20px;
display: flex;
align-items: center;
gap: 10px;
color: white;
font-size: 14px;
}
.toggle-checkbox {
width: 50px;
height: 24px;
cursor: pointer;
appearance: none;
background-color: #555;
border-radius: 12px;
border: none;
outline: none;
transition: background-color 0.3s;
position: relative;
}
.toggle-checkbox:checked {
background-color: #27ae60;
}
.toggle-checkbox::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: white;
top: 2px;
left: 2px;
transition: left 0.3s;
}
.toggle-checkbox:checked::before {
left: 28px;
}
/* Santa hat styles */
.santa-hat {
position: absolute;
width: 60px;
height: 50px;
top: -20px;
transform: rotate(-20deg);
z-index: 10;
}
.santa-hat::before {
content: '';
position: absolute;
width: 100%;
height: 70%;
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
clip-path: polygon(0 0, 100% 0, 90% 100%, 10% 100%);
}
.santa-hat::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
bottom: -5px;
right: -8px;
box-shadow: -15px 5px 0 -5px white;
}
/* Jingle bell styles */
.jingle-bell {
display: inline-block;
position: relative;
width: 12px;
height: 14px;
margin: 0 2px;
animation: jingle 0.4s ease-in-out infinite;
}
.jingle-bell::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
background: #f1c40f;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.jingle-bell::after {
content: '';
position: absolute;
width: 3px;
height: 6px;
background: #d4a500;
top: -6px;
left: 50%;
transform: translateX(-50%);
}
@keyframes jingle {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(5deg); }
75% { transform: rotate(-5deg); }
}
/* Snow animation */
.snowflake {
position: fixed;
top: -10px;
color: white;
font-size: 1em;
font-weight: bold;
text-shadow: 0 0 5px rgba(255,255,255,0.8);
z-index: 1;
user-select: none;
pointer-events: none;
animation: snowfall linear infinite;
opacity: 0.8;
}
@keyframes snowfall {
to {
transform: translateY(100vh) translateX(100px);
opacity: 0;
}
}
body.christmas-active .snowflake {
animation: snowfall linear infinite;
}
/* Festive header when active */
body.christmas-active header {
background: linear-gradient(90deg, #27ae60 0%, #e74c3c 50%, #27ae60 100%);
background-size: 200% 100%;
animation: festive-pulse 3s ease-in-out infinite;
}
@keyframes festive-pulse {
0%, 100% { background-position: 0% 0%; }
50% { background-position: 100% 0%; }
}
/* Jingle bells in header when active */
body.christmas-active h1::before {
content: '🔔 ';
animation: jingle 0.4s ease-in-out infinite;
display: inline-block;
font-size: 30px;
margin-right: 15px;
}
body.christmas-active h1::after {
content: ' 🔔';
animation: jingle 0.4s ease-in-out infinite;
display: inline-block;
font-size: 30px;
margin-left: 15px;
}
/* Corner decorations */
.corner-decoration {
position: fixed;
font-size: 80px;
z-index: 5;
pointer-events: none;
opacity: 0.9;
width: 100px;
height: 100px;
}
.corner-decoration img {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Bottom decorations */
.bottom-decoration {
position: fixed;
bottom: 20px;
width: 80px;
height: 80px;
z-index: 5;
pointer-events: none;
opacity: 0.85;
}
.bottom-decoration img {
width: 100%;
height: 100%;
object-fit: contain;
}
.corner-decoration.bottom-left {
bottom: 10px;
left: 10px;
animation: sway 3s ease-in-out infinite;
}
.corner-decoration.bottom-right {
bottom: 10px;
right: 10px;
animation: sway 3s ease-in-out infinite reverse;
}
@keyframes sway {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(-5deg); }
}
</style>
</head>
<body>
@@ -191,6 +411,118 @@
</footer>
<script>
// Christmas mode toggle functionality
function initChristmasMode() {
// Check URL parameter first for override
const urlParams = new URLSearchParams(window.location.search);
const christmasParam = urlParams.get('christmas');
let shouldEnable = false;
if (christmasParam === 'on') {
shouldEnable = true;
} else if (christmasParam === 'off') {
shouldEnable = false;
} else {
// Auto-enable for December
const now = new Date();
shouldEnable = now.getMonth() === 11; // December is month 11 (0-indexed)
}
if (shouldEnable) {
enableChristmasMode();
}
}
function enableChristmasMode() {
document.body.classList.add('christmas-active');
// Create falling snowflakes
function createSnowflake() {
const snowflake = document.createElement('div');
snowflake.classList.add('snowflake');
snowflake.textContent = '❄';
snowflake.style.left = Math.random() * window.innerWidth + 'px';
snowflake.style.animationDuration = (Math.random() * 5 + 8) + 's';
snowflake.style.animationDelay = Math.random() * 2 + 's';
document.body.appendChild(snowflake);
setTimeout(() => snowflake.remove(), 13000);
}
// Create snowflakes periodically
const snowInterval = setInterval(() => {
if (!document.body.classList.contains('christmas-active')) {
clearInterval(snowInterval);
return;
}
createSnowflake();
}, 300);
// Add corner decorations
const leftCorner = document.createElement('div');
leftCorner.classList.add('corner-decoration', 'bottom-left');
const treeImg = document.createElement('img');
treeImg.src = 'assets/tree.svg';
treeImg.alt = 'Christmas Tree';
leftCorner.appendChild(treeImg);
leftCorner.id = 'corner-left';
document.body.appendChild(leftCorner);
const rightCorner = document.createElement('div');
rightCorner.classList.add('corner-decoration', 'bottom-right');
const santaImg = document.createElement('img');
santaImg.src = 'assets/santa.svg';
santaImg.alt = 'Santa';
rightCorner.appendChild(santaImg);
rightCorner.id = 'corner-right';
document.body.appendChild(rightCorner);
// Add bottom decorations in a row
const bottomDecorations = [
{ src: 'assets/reindeer.svg', alt: 'Reindeer' },
{ src: 'assets/bell.svg', alt: 'Bell' },
{ src: 'assets/gift.svg', alt: 'Gift' },
{ src: 'assets/candycane.svg', alt: 'Candy Cane' },
{ src: 'assets/bell.svg', alt: 'Bell' },
{ src: 'assets/gift.svg', alt: 'Gift' }
];
const screenWidth = window.innerWidth;
const totalDecorations = bottomDecorations.length;
const spacing = screenWidth / (totalDecorations + 1);
bottomDecorations.forEach((deco, index) => {
const div = document.createElement('div');
div.classList.add('bottom-decoration');
div.style.left = (spacing * (index + 1) - 40) + 'px'; // 40 is half the width
div.style.animation = `sway ${3 + index * 0.5}s ease-in-out infinite`;
const img = document.createElement('img');
img.src = deco.src;
img.alt = deco.alt;
div.appendChild(img);
div.id = `bottom-deco-${index}`;
document.body.appendChild(div);
});
}
function disableChristmasMode() {
document.body.classList.remove('christmas-active');
// Remove corner decorations
document.getElementById('corner-left')?.remove();
document.getElementById('corner-right')?.remove();
// Remove bottom decorations
document.querySelectorAll('[id^="bottom-deco-"]').forEach(deco => deco.remove());
// Remove snowflakes
document.querySelectorAll('.snowflake').forEach(flake => flake.remove());
}
let wsConnection = null;
// ICAO code to airport name cache
@@ -517,6 +849,9 @@
// Load data on page load
window.addEventListener('load', function() {
// Initialize Christmas mode
initChristmasMode();
loadArrivals();
loadDepartures();

View File

@@ -193,6 +193,8 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
typeFieldId = 'local_type';
} else if (fieldId === 'book_in_registration') {
typeFieldId = 'book_in_type';
} else if (fieldId === 'overflight_registration') {
typeFieldId = 'overflight_type';
}
if (typeFieldId) {
@@ -361,12 +363,38 @@ function initializeLookups() {
);
lookupManager.register('book-in-arrival-airport', bookInArrivalAirportLookup);
const overflightAircraftLookup = createLookup(
'overflight_registration',
'overflight-aircraft-lookup-results',
null,
{ isAircraft: true, minLength: 4, debounceMs: 300 }
);
lookupManager.register('overflight-aircraft', overflightAircraftLookup);
const overflightDepartureLookup = createLookup(
'overflight_departure_airfield',
'overflight-departure-airport-lookup-results',
null,
{ isAirport: true, minLength: 2 }
);
lookupManager.register('overflight-departure', overflightDepartureLookup);
const overflightDestinationLookup = createLookup(
'overflight_destination_airfield',
'overflight-destination-airport-lookup-results',
null,
{ isAirport: true, minLength: 2 }
);
lookupManager.register('overflight-destination', overflightDestinationLookup);
// Attach keyboard handlers to airport input fields
setTimeout(() => {
if (arrivalAirportLookup.attachKeyboardHandler) arrivalAirportLookup.attachKeyboardHandler();
if (departureAirportLookup.attachKeyboardHandler) departureAirportLookup.attachKeyboardHandler();
if (localOutToLookup.attachKeyboardHandler) localOutToLookup.attachKeyboardHandler();
if (bookInArrivalAirportLookup.attachKeyboardHandler) bookInArrivalAirportLookup.attachKeyboardHandler();
if (overflightDepartureLookup.attachKeyboardHandler) overflightDepartureLookup.attachKeyboardHandler();
if (overflightDestinationLookup.attachKeyboardHandler) overflightDestinationLookup.attachKeyboardHandler();
}, 100);
}

View File

@@ -384,7 +384,6 @@
<option value="">None</option>
<option value="100LL">100LL</option>
<option value="JET A1">JET A1</option>
<option value="FULL">Full Tanks</option>
</select>
</div>
<div class="form-group">

View File

@@ -22,23 +22,32 @@
color: white;
padding: 0.5rem 2rem;
display: flex;
justify-content: space-between;
justify-content: center;
align-items: center;
gap: 2rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.title {
order: 2;
flex: 1;
text-align: center;
}
.title h1 {
margin: 0;
font-size: 1.5rem;
}
.menu-buttons {
order: 1;
display: flex;
gap: 1rem;
align-items: center;
}
.top-bar .user-info {
order: 3;
font-size: 0.9rem;
opacity: 0.9;
display: flex;
@@ -227,6 +236,45 @@
.status.canceled { background: #ffebee; color: #d32f2f; }
.status.deleted { background: #f3e5f5; color: #7b1fa2; }
.summary-box {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.summary-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
}
.summary-item {
background: rgba(255,255,255,0.1);
border-radius: 6px;
padding: 1rem;
border-left: 4px solid rgba(255,255,255,0.3);
}
.summary-item-label {
font-size: 0.85rem;
opacity: 0.9;
margin-bottom: 0.5rem;
}
.summary-item-value {
font-size: 2rem;
font-weight: 700;
}
.no-data {
text-align: center;
padding: 3rem;
@@ -313,14 +361,14 @@
</head>
<body>
<div class="top-bar">
<div class="title">
<h1>📊 PPR Reports</h1>
</div>
<div class="menu-buttons">
<button class="btn btn-secondary" onclick="window.location.href='admin.html'">
<button class="btn btn-secondary" onclick="window.location.href='admin'">
← Back to Admin
</button>
</div>
<div class="title">
<h1 id="tower-title">📊 PPR Reports</h1>
</div>
<div class="user-info">
Logged in as: <span id="current-user">Loading...</span> |
<a href="#" onclick="logout()" style="color: white;">Logout</a>
@@ -330,18 +378,26 @@
<div class="container">
<!-- Filters Section -->
<div class="filters-section">
<div class="filters-grid">
<div class="filter-group">
<label for="date-from">Date From:</label>
<input type="date" id="date-from">
<div style="display: flex; gap: 0.5rem; align-items: flex-end; flex-wrap: wrap;">
<!-- Quick Filter Buttons -->
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary" id="filter-today" onclick="setDateRangeToday()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📅 Today</button>
<button class="btn btn-secondary" id="filter-week" onclick="setDateRangeThisWeek()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📆 This Week</button>
<button class="btn btn-secondary" id="filter-month" onclick="setDateRangeThisMonth()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📊 This Month</button>
<button class="btn btn-secondary" id="filter-custom" onclick="toggleCustomRange()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">📋 Custom Range</button>
</div>
<div class="filter-group">
<label for="date-to">Date To:</label>
<input type="date" id="date-to">
<!-- Custom Date Range (hidden by default) -->
<div id="custom-range-container" style="display: none; display: flex; gap: 0.5rem; align-items: center;">
<input type="date" id="date-from" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
<span style="font-weight: 600; color: #666;">to</span>
<input type="date" id="date-to" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
</div>
<div class="filter-group">
<label for="status-filter">Status:</label>
<select id="status-filter">
<!-- Status Filter -->
<div style="display: flex; flex-direction: column; gap: 0.3rem;">
<label for="status-filter" style="font-weight: 600; font-size: 0.85rem; color: #555;">Status:</label>
<select id="status-filter" style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.9rem;">
<option value="">All Statuses</option>
<option value="NEW">New</option>
<option value="CONFIRMED">Confirmed</option>
@@ -351,23 +407,91 @@
<option value="DELETED">Deleted</option>
</select>
</div>
<div class="filter-group">
<label for="search-input">Search:</label>
<input type="text" id="search-input" placeholder="Aircraft reg, callsign, captain, or airport...">
<!-- Search Input -->
<div style="flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 0.3rem;">
<label for="search-input" style="font-weight: 600; font-size: 0.85rem; color: #555;">Search:</label>
<input type="text" id="search-input" placeholder="Aircraft reg, callsign, captain, or airport..." style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.9rem;">
</div>
<div class="filter-actions">
<button class="btn btn-primary" onclick="loadReports()">
<!-- Action Buttons -->
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary" onclick="loadReports()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">
🔍 Search
</button>
<button class="btn btn-secondary" onclick="clearFilters()">
<button class="btn btn-secondary" onclick="clearFilters()" style="font-size: 0.9rem; padding: 0.5rem 1rem; white-space: nowrap;">
🗑️ Clear
</button>
</div>
</div>
</div>
<!-- Summary Box -->
<div class="summary-box">
<div class="summary-title">📊 Movements Summary</div>
<div style="display: grid; grid-template-columns: 1fr auto; gap: 2rem; align-items: center;">
<div class="summary-grid">
<!-- PPR Section -->
<div style="grid-column: 1/-1; padding-bottom: 0.8rem; border-bottom: 2px solid rgba(255,255,255,0.3);">
<div style="font-size: 0.85rem; font-weight: 600; margin-bottom: 0.4rem;">PPR Movements</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.8rem;">
<div class="summary-item" style="padding: 0.4rem;">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Arrivals</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="ppr-arrivals">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem;">
<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="ppr-departures">0</div>
</div>
<div class="summary-item" style="border-left-color: #ffd700; background: rgba(255,215,0,0.1); padding: 0.4rem;">
<div class="summary-item-label" style="font-weight: 600; font-size: 0.7rem; margin-bottom: 0.1rem;">Total</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="ppr-total">0</div>
</div>
</div>
</div>
<!-- Non-PPR Section -->
<div style="grid-column: 1/-1; padding-top: 0.8rem;">
<div style="font-size: 0.85rem; font-weight: 600; margin-bottom: 0.4rem;">Non-PPR Movements</div>
<div style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.8rem;">
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('LOCAL')">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Local</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="local-flights-movements">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('CIRCUIT')">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Circuits</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="circuits-movements">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('ARRIVAL')">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Arrivals</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-arrivals">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('DEPARTURE')">
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Departures</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-departures">0</div>
</div>
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('OVERFLIGHT')">
<div class="summary-item-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>
<div class="summary-item" style="border-left-color: #ffd700; background: rgba(255,215,0,0.1); padding: 0.4rem;">
<div class="summary-item-label" style="font-weight: 600; font-size: 0.7rem; margin-bottom: 0.1rem;">Total</div>
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-total">0</div>
</div>
</div>
</div>
</div>
<!-- Grand Total - positioned on the right -->
<div style="text-align: center; padding: 1rem; background: rgba(255,215,0,0.05); border-radius: 8px; border-left: 4px solid #ffd700; min-width: 120px;">
<div style="font-size: 0.75rem; opacity: 0.85; margin-bottom: 0.2rem;">GRAND TOTAL</div>
<div style="font-size: 2.2rem; font-weight: 700;" id="grand-total-movements">0</div>
</div>
</div>
</div>
<!-- Reports Table -->
<div class="reports-table">
<div class="reports-table" id="ppr-reports-section">
<div class="table-header">
<div>
<strong>PPR Records</strong>
@@ -422,6 +546,54 @@
<p>No records match your current filters.</p>
</div>
</div>
<!-- Other Flights Table -->
<div class="reports-table" style="margin-top: 2rem;">
<div class="table-header">
<div>
<strong>Other Flights</strong>
<div class="table-info" id="other-flights-info">Loading...</div>
</div>
<div class="export-buttons">
<button class="btn btn-success" onclick="exportOtherFlightsToCSV()">
📊 Export CSV
</button>
</div>
</div>
<div id="other-flights-loading" class="loading">
<div class="spinner"></div>
Loading flights...
</div>
<div id="other-flights-table-content" style="display: none;">
<div class="table-container">
<table>
<thead>
<tr>
<th>Status</th>
<th>Type</th>
<th>Aircraft</th>
<th>Aircraft Type</th>
<th>Callsign</th>
<th>From</th>
<th>To</th>
<th>ETA / ETD / Called</th>
<th>Landed / Departed / QSY</th>
<th>Circuits</th>
</tr>
</thead>
<tbody id="other-flights-table-body">
</tbody>
</table>
</div>
</div>
<div id="other-flights-no-data" class="no-data" style="display: none;">
<h3>No other flights found</h3>
<p>No flights match your current filters.</p>
</div>
</div>
</div>
<!-- Success Notification -->
@@ -431,22 +603,156 @@
let currentUser = null;
let accessToken = null;
let currentPPRs = []; // Store current results for export
let currentOtherFlights = []; // Store other flights for export
let otherFlightsFilterType = null; // Track which non-PPR flight type is selected for filtering
// Load UI configuration from API
async function loadUIConfig() {
try {
const response = await fetch('/api/v1/public/config');
if (response.ok) {
const config = await response.json();
// Update tower title
const titleElement = document.getElementById('tower-title');
if (titleElement && config.tag) {
titleElement.innerHTML = `📊 Reports ${config.tag}`;
}
// Update top bar gradient
const topBar = document.querySelector('.top-bar');
if (topBar && config.top_bar_gradient_start && config.top_bar_gradient_end) {
topBar.style.background = `linear-gradient(135deg, ${config.top_bar_gradient_start}, ${config.top_bar_gradient_end})`;
}
// Update page title
if (config.tag) {
document.title = `PPR Reports - ${config.tag}`;
}
// Optionally indicate environment (e.g., add to title if not production)
if (config.environment && config.environment !== 'production') {
const envIndicator = ` (${config.environment.toUpperCase()})`;
if (titleElement) {
titleElement.innerHTML += envIndicator;
}
if (document.title) {
document.title += envIndicator;
}
}
}
} catch (error) {
console.warn('Failed to load UI config:', error);
}
}
// Initialize the page
async function initializePage() {
loadUIConfig(); // Load UI configuration first
await initializeAuth();
setupDefaultDateRange();
await loadReports();
}
// Set default date range to current month
// Set default date range to today
function setupDefaultDateRange() {
setDateRangeToday();
}
// Toggle custom date range picker
function toggleCustomRange() {
const container = document.getElementById('custom-range-container');
const customBtn = document.getElementById('filter-custom');
const isVisible = container.style.display !== 'none';
container.style.display = isVisible ? 'none' : 'flex';
// Update button style
if (isVisible) {
customBtn.classList.remove('btn-primary');
customBtn.classList.add('btn-secondary');
} else {
customBtn.classList.remove('btn-secondary');
customBtn.classList.add('btn-primary');
// Focus on the first date input when opening
document.getElementById('date-from').focus();
}
}
// Set date range to today
function setDateRangeToday() {
const today = new Date().toISOString().split('T')[0];
document.getElementById('date-from').value = today;
document.getElementById('date-to').value = today;
// Hide custom range picker if it's open
document.getElementById('custom-range-container').style.display = 'none';
updateFilterButtonStyles('today');
loadReports();
}
// Set date range to this week (Monday to Sunday)
function setDateRangeThisWeek() {
const now = new Date();
const dayOfWeek = now.getDay();
const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
const monday = new Date(now.setDate(diff));
const sunday = new Date(now.setDate(diff + 6));
document.getElementById('date-from').value = monday.toISOString().split('T')[0];
document.getElementById('date-to').value = sunday.toISOString().split('T')[0];
// Hide custom range picker if it's open
document.getElementById('custom-range-container').style.display = 'none';
updateFilterButtonStyles('week');
loadReports();
}
// Set date range to this month
function setDateRangeThisMonth() {
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
document.getElementById('date-from').value = firstDay.toISOString().split('T')[0];
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
// Hide custom range picker if it's open
document.getElementById('custom-range-container').style.display = 'none';
updateFilterButtonStyles('month');
loadReports();
}
// Update button styles to show which filter is active
function updateFilterButtonStyles(activeFilter) {
const todayBtn = document.getElementById('filter-today');
const weekBtn = document.getElementById('filter-week');
const monthBtn = document.getElementById('filter-month');
// Reset all buttons
[todayBtn, weekBtn, monthBtn].forEach(btn => {
btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary');
});
// Highlight active button
switch(activeFilter) {
case 'today':
todayBtn.classList.remove('btn-secondary');
todayBtn.classList.add('btn-primary');
break;
case 'week':
weekBtn.classList.remove('btn-secondary');
weekBtn.classList.add('btn-primary');
break;
case 'month':
monthBtn.classList.remove('btn-secondary');
monthBtn.classList.add('btn-primary');
break;
}
}
// Authentication management
@@ -466,7 +772,7 @@
}
// No valid cached token, redirect to admin
window.location.href = 'admin.html';
window.location.href = 'admin';
}
function logout() {
@@ -475,13 +781,13 @@
localStorage.removeItem('ppr_token_expiry');
accessToken = null;
currentUser = null;
window.location.href = 'admin.html';
window.location.href = 'admin';
}
// Enhanced fetch wrapper with token expiry handling
async function authenticatedFetch(url, options = {}) {
if (!accessToken) {
window.location.href = 'admin.html';
window.location.href = 'admin';
throw new Error('No access token available');
}
@@ -508,6 +814,9 @@
document.getElementById('reports-loading').style.display = 'block';
document.getElementById('reports-table-content').style.display = 'none';
document.getElementById('reports-no-data').style.display = 'none';
document.getElementById('other-flights-loading').style.display = 'block';
document.getElementById('other-flights-table-content').style.display = 'none';
document.getElementById('other-flights-no-data').style.display = 'none';
try {
const dateFrom = document.getElementById('date-from').value;
@@ -521,13 +830,20 @@
if (dateTo) url += `&date_to=${dateTo}`;
if (status) url += `&status=${status}`;
const response = await authenticatedFetch(url);
// Fetch all data in parallel
const [pprResponse, arrivalsResponse, departuresResponse, localFlightsResponse, overflightsResponse] = await Promise.all([
authenticatedFetch(url),
authenticatedFetch(`/api/v1/arrivals/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
authenticatedFetch(`/api/v1/departures/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
authenticatedFetch(`/api/v1/local-flights/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
authenticatedFetch(`/api/v1/overflights/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`)
]);
if (!response.ok) {
if (!pprResponse.ok) {
throw new Error('Failed to fetch PPR records');
}
let pprs = await response.json();
let pprs = await pprResponse.json();
// Apply client-side search filtering
if (search) {
@@ -543,6 +859,78 @@
currentPPRs = pprs; // Store for export
displayReports(pprs);
// Process other flights
let otherFlights = [];
if (arrivalsResponse.ok) {
const arrivals = await arrivalsResponse.json();
otherFlights.push(...arrivals.map(f => ({
...f,
flightType: 'ARRIVAL',
aircraft_type: f.type,
timeField: f.eta || f.landed_dt,
fromField: f.in_from,
toField: 'EGFH'
})));
}
if (departuresResponse.ok) {
const departures = await departuresResponse.json();
otherFlights.push(...departures.map(f => ({
...f,
flightType: 'DEPARTURE',
aircraft_type: f.type,
timeField: f.etd || f.departed_dt,
fromField: 'EGFH',
toField: f.out_to
})));
}
if (localFlightsResponse.ok) {
const localFlights = await localFlightsResponse.json();
otherFlights.push(...localFlights.map(f => ({
...f,
flightType: f.flight_type === 'CIRCUITS' ? 'CIRCUIT' : f.flight_type,
aircraft_type: f.type,
circuits: f.circuits,
timeField: f.departed_dt,
fromField: 'EGFH',
toField: 'EGFH'
})));
}
if (overflightsResponse.ok) {
const overflights = await overflightsResponse.json();
otherFlights.push(...overflights.map(f => ({
...f,
flightType: 'OVERFLIGHT',
aircraft_type: f.type,
circuits: null,
timeField: f.call_dt,
fromField: f.departure_airfield,
toField: f.destination_airfield,
callsign: f.registration
})));
}
// Apply search filtering to other flights
if (search) {
const searchLower = search.toLowerCase();
otherFlights = otherFlights.filter(f =>
(f.registration && f.registration.toLowerCase().includes(searchLower)) ||
(f.callsign && f.callsign.toLowerCase().includes(searchLower)) ||
(f.fromField && f.fromField.toLowerCase().includes(searchLower)) ||
(f.toField && f.toField.toLowerCase().includes(searchLower))
);
}
currentOtherFlights = otherFlights;
displayOtherFlights(otherFlights);
// Calculate and display movements summary
calculateMovementsSummary(pprs, otherFlights, dateFrom, dateTo);
} catch (error) {
console.error('Error loading reports:', error);
if (error.message !== 'Session expired. Please log in again.') {
@@ -551,6 +939,94 @@
}
document.getElementById('reports-loading').style.display = 'none';
document.getElementById('other-flights-loading').style.display = 'none';
}
// Calculate and display movements summary
function calculateMovementsSummary(pprs, otherFlights, dateFrom, dateTo) {
let pprArrivals = 0; // PPR landings
let pprDepartures = 0; // PPR takeoffs
let localFlightsMovements = 0;
let circuitsMovements = 0;
let nonPprArrivals = 0;
let nonPprDepartures = 0;
// Format date range for display
let dateRangeText = '';
if (dateFrom && dateTo && dateFrom === dateTo) {
// Single day
const date = new Date(dateFrom + 'T00:00:00Z');
dateRangeText = `for ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
} else if (dateFrom && dateTo) {
// Date range
const fromDate = new Date(dateFrom + 'T00:00:00Z');
const toDate = new Date(dateTo + 'T00:00:00Z');
const fromText = fromDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
const toText = toDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
dateRangeText = `for ${fromText} to ${toText}`;
} else if (dateFrom) {
const date = new Date(dateFrom + 'T00:00:00Z');
dateRangeText = `from ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
} else if (dateTo) {
const date = new Date(dateTo + 'T00:00:00Z');
dateRangeText = `until ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
}
// Update summary title with date range
const summaryTitle = document.querySelector('.summary-title');
if (summaryTitle) {
summaryTitle.textContent = `📊 Movements Summary ${dateRangeText}`;
}
// PPR movements (excluding CANCELED):
// - LANDED = 1 arrival (landing)
// - DEPARTED = 1 departure + 1 arrival (because departure implies a prior landing)
pprs.filter(ppr => ppr.status !== 'CANCELED').forEach(ppr => {
if (ppr.status === 'LANDED') {
pprArrivals += 1;
} else if (ppr.status === 'DEPARTED') {
pprDepartures += 1;
pprArrivals += 1; // Each departure implies a landing happened
}
});
// Other flights movements (excluding CANCELLED)
let overflightCount = 0;
otherFlights.filter(flight => flight.status !== 'CANCELLED').forEach(flight => {
if (flight.flightType === 'ARRIVAL') {
nonPprArrivals += 1;
} else if (flight.flightType === 'DEPARTURE') {
nonPprDepartures += 1;
} else if (flight.flightType === 'LOCAL') {
// 2 movements (takeoff + landing) for the flight itself
localFlightsMovements += 2;
} else if (flight.flightType === 'CIRCUIT') {
// 2 movements (takeoff + landing) plus the circuit count
const circuits = flight.circuits || 0;
circuitsMovements += 2 + circuits;
} else if (flight.flightType === 'OVERFLIGHT') {
// 1 movement for each overflight (they're just talking to tower)
overflightCount += 1;
}
});
const pprTotal = pprArrivals + pprDepartures;
const nonPprTotal = localFlightsMovements + circuitsMovements + nonPprArrivals + nonPprDepartures + overflightCount;
const grandTotal = pprTotal + nonPprTotal;
// Update the summary display
document.getElementById('ppr-arrivals').textContent = pprArrivals;
document.getElementById('ppr-departures').textContent = pprDepartures;
document.getElementById('ppr-total').textContent = pprTotal;
document.getElementById('overflights-count').textContent = overflightCount;
document.getElementById('local-flights-movements').textContent = localFlightsMovements;
document.getElementById('circuits-movements').textContent = circuitsMovements;
document.getElementById('non-ppr-arrivals').textContent = nonPprArrivals;
document.getElementById('non-ppr-departures').textContent = nonPprDepartures;
document.getElementById('non-ppr-total').textContent = nonPprTotal;
document.getElementById('grand-total-movements').textContent = grandTotal;
}
// Display reports in table
@@ -615,6 +1091,156 @@
}
}
// Filter other flights by type
function filterOtherFlights(flightType) {
// Toggle filter if clicking the same type
if (otherFlightsFilterType === flightType) {
otherFlightsFilterType = null;
} else {
otherFlightsFilterType = flightType;
}
// Show/hide PPR section based on filter
const pprSection = document.getElementById('ppr-reports-section');
if (pprSection) {
pprSection.style.display = otherFlightsFilterType ? 'none' : 'block';
}
// Update visual indication of active filter
updateFilterIndicators();
// Re-display flights with new filter
displayOtherFlights(currentOtherFlights);
}
// Update visual indicators for active filter
function updateFilterIndicators() {
// Select all clickable non-PPR summary items (those with onclick attribute)
const summaryItems = document.querySelectorAll('.summary-item[onclick*="filterOtherFlights"]');
summaryItems.forEach(item => {
item.style.opacity = '1';
item.style.borderLeftColor = '';
item.style.borderLeftWidth = '0';
});
if (otherFlightsFilterType) {
// Get the ID of the selected filter's summary item
let selectedId = '';
switch(otherFlightsFilterType) {
case 'LOCAL':
selectedId = 'local-flights-movements';
break;
case 'CIRCUIT':
selectedId = 'circuits-movements';
break;
case 'ARRIVAL':
selectedId = 'non-ppr-arrivals';
break;
case 'DEPARTURE':
selectedId = 'non-ppr-departures';
break;
case 'OVERFLIGHT':
selectedId = 'overflights-count';
break;
}
// Find and highlight the selected item
if (selectedId) {
const selectedElement = document.getElementById(selectedId);
if (selectedElement) {
const summaryItem = selectedElement.closest('.summary-item');
if (summaryItem) {
summaryItem.style.borderLeftColor = '#4CAF50';
summaryItem.style.borderLeftWidth = '4px';
summaryItem.style.opacity = '1';
}
}
}
// Dim other items that are clickable (non-PPR items)
const allSummaryItems = document.querySelectorAll('.summary-item[onclick]');
allSummaryItems.forEach(item => {
if (item.querySelector('#' + selectedId) === null) {
item.style.opacity = '0.5';
}
});
}
}
// Display other flights in table
function displayOtherFlights(flights) {
const tbody = document.getElementById('other-flights-table-body');
const tableInfo = document.getElementById('other-flights-info');
// Apply filter if one is selected
let filteredFlights = flights;
if (otherFlightsFilterType) {
filteredFlights = flights.filter(flight => flight.flightType === otherFlightsFilterType);
}
tableInfo.textContent = `${filteredFlights.length} flights found` + (otherFlightsFilterType ? ` (filtered by ${otherFlightsFilterType})` : '');
if (filteredFlights.length === 0) {
document.getElementById('other-flights-no-data').style.display = 'block';
document.getElementById('other-flights-table-content').style.display = 'none';
return;
}
// Sort by time field (ascending)
filteredFlights.sort((a, b) => {
const aTime = a.timeField;
const bTime = b.timeField;
if (!aTime) return 1;
if (!bTime) return -1;
return new Date(aTime) - new Date(bTime);
});
tbody.innerHTML = '';
document.getElementById('other-flights-table-content').style.display = 'block';
document.getElementById('other-flights-no-data').style.display = 'none';
for (const flight of filteredFlights) {
const row = document.createElement('tr');
const typeLabel = flight.flightType;
const registration = flight.registration || '-';
const aircraftType = flight.aircraft_type || '-';
const callsign = flight.callsign || '-';
const from = flight.fromField || '-';
const to = flight.toField || '-';
const timeDisplay = flight.timeField ? formatDateTime(flight.timeField) : '-';
// Different display for different flight types
let actualDisplay = '-';
if (flight.flightType === 'ARRIVAL') {
actualDisplay = flight.landed_dt ? formatDateTime(flight.landed_dt) : '-';
} else if (flight.flightType === 'OVERFLIGHT') {
// For overflights, show qsy_dt (frequency change time)
actualDisplay = flight.qsy_dt ? formatDateTime(flight.qsy_dt) : '-';
} else {
actualDisplay = flight.departed_dt ? formatDateTime(flight.departed_dt) : '-';
}
const status = flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING');
const circuits = (flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits > 0 ? flight.circuits : '-') : '-';
row.innerHTML = `
<td><span class="status ${status.toLowerCase()}">${status}</span></td>
<td><strong>${typeLabel}</strong></td>
<td>${registration}</td>
<td>${aircraftType}</td>
<td>${callsign}</td>
<td>${from}</td>
<td>${to}</td>
<td>${timeDisplay}</td>
<td>${actualDisplay}</td>
<td>${circuits}</td>
`;
tbody.appendChild(row);
}
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
let utcDateStr = dateStr;
@@ -683,6 +1309,35 @@
downloadCSV(headers, csvData, 'ppr_reports.csv');
}
function exportOtherFlightsToCSV() {
if (currentOtherFlights.length === 0) {
showNotification('No data to export', true);
return;
}
const headers = [
'Flight Type', 'Aircraft Registration', 'Aircraft Type', 'Callsign', 'From', 'To',
'ETA/ETD', 'Landed/Departed', 'Status', 'Circuits'
];
const csvData = currentOtherFlights.map(flight => [
flight.flightType,
flight.registration || '',
flight.aircraft_type || '',
flight.callsign || '',
flight.fromField || '',
flight.toField || '',
flight.timeField ? formatDateTime(flight.timeField) : '',
flight.flightType === 'ARRIVAL'
? (flight.landed_dt ? formatDateTime(flight.landed_dt) : '')
: (flight.departed_dt ? formatDateTime(flight.departed_dt) : ''),
flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING'),
(flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits || '') : ''
]);
downloadCSV(headers, csvData, 'other_flights_reports.csv');
}
function downloadCSV(headers, data, filename) {
const csvContent = [
headers.join(','),