diff --git a/backend/alembic/versions/002_local_flights.py b/backend/alembic/versions/002_local_flights.py
new file mode 100644
index 0000000..f59411b
--- /dev/null
+++ b/backend/alembic/versions/002_local_flights.py
@@ -0,0 +1,58 @@
+"""Add local_flights table for tracking local flights
+
+Revision ID: 002_local_flights
+Revises: 001_initial_schema
+Create Date: 2025-12-12 12:00:00.000000
+
+This migration adds a new table for tracking local flights (circuits, local, departure)
+that don't require PPR submissions.
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '002_local_flights'
+down_revision = '001_initial_schema'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ """
+ Create local_flights table for tracking aircraft that book out locally.
+ """
+
+ op.create_table('local_flights',
+ sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
+ sa.Column('registration', sa.String(length=16), nullable=False),
+ sa.Column('type', sa.String(length=32), nullable=False),
+ sa.Column('callsign', sa.String(length=16), nullable=True),
+ sa.Column('pob', sa.Integer(), nullable=False),
+ 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('notes', sa.Text(), nullable=True),
+ sa.Column('booked_out_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
+ sa.Column('departure_dt', sa.DateTime(), nullable=True),
+ sa.Column('landed_dt', sa.DateTime(), nullable=True),
+ 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 frequently queried columns
+ op.create_index('idx_registration', 'local_flights', ['registration'])
+ op.create_index('idx_flight_type', 'local_flights', ['flight_type'])
+ op.create_index('idx_status', 'local_flights', ['status'])
+ op.create_index('idx_booked_out_dt', 'local_flights', ['booked_out_dt'])
+ op.create_index('idx_created_by', 'local_flights', ['created_by'])
+
+
+def downgrade() -> None:
+ """
+ Drop the local_flights table.
+ """
+ op.drop_table('local_flights')
diff --git a/backend/app/api/api.py b/backend/app/api/api.py
index 935b4fe..439e869 100644
--- a/backend/app/api/api.py
+++ b/backend/app/api/api.py
@@ -1,10 +1,11 @@
from fastapi import APIRouter
-from app.api.endpoints import auth, pprs, public, aircraft, airport
+from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
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(public.router, prefix="/public", tags=["public"])
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
\ No newline at end of file
diff --git a/backend/app/api/endpoints/local_flights.py b/backend/app/api/endpoints/local_flights.py
new file mode 100644
index 0000000..30dcfed
--- /dev/null
+++ b/backend/app/api/endpoints/local_flights.py
@@ -0,0 +1,195 @@
+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_local_flight import local_flight as crud_local_flight
+from app.schemas.local_flight import LocalFlight, LocalFlightCreate, LocalFlightUpdate, LocalFlightStatus, LocalFlightType, LocalFlightStatusUpdate
+from app.models.ppr import User
+from app.core.utils import get_client_ip
+
+router = APIRouter()
+
+
+@router.get("/", response_model=List[LocalFlight])
+async def get_local_flights(
+ request: Request,
+ skip: int = 0,
+ limit: int = 100,
+ status: Optional[LocalFlightStatus] = None,
+ flight_type: Optional[LocalFlightType] = 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 local flight records with optional filtering"""
+ flights = crud_local_flight.get_multi(
+ db, skip=skip, limit=limit, status=status,
+ flight_type=flight_type, date_from=date_from, date_to=date_to
+ )
+ return flights
+
+
+@router.post("/", response_model=LocalFlight)
+async def create_local_flight(
+ request: Request,
+ flight_in: LocalFlightCreate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_operator_user)
+):
+ """Create a new local flight record (book out)"""
+ flight = crud_local_flight.create(db, obj_in=flight_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": "local_flight_booked_out",
+ "data": {
+ "id": flight.id,
+ "registration": flight.registration,
+ "flight_type": flight.flight_type.value,
+ "status": flight.status.value
+ }
+ })
+
+ return flight
+
+
+@router.get("/{flight_id}", response_model=LocalFlight)
+async def get_local_flight(
+ flight_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_read_user)
+):
+ """Get a specific local flight record"""
+ flight = crud_local_flight.get(db, flight_id=flight_id)
+ if not flight:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Local flight record not found"
+ )
+ return flight
+
+
+@router.put("/{flight_id}", response_model=LocalFlight)
+async def update_local_flight(
+ request: Request,
+ flight_id: int,
+ flight_in: LocalFlightUpdate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_operator_user)
+):
+ """Update a local flight record"""
+ db_flight = crud_local_flight.get(db, flight_id=flight_id)
+ if not db_flight:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Local flight record not found"
+ )
+
+ flight = crud_local_flight.update(db, db_obj=db_flight, obj_in=flight_in)
+
+ # Send real-time update
+ if hasattr(request.app.state, 'connection_manager'):
+ await request.app.state.connection_manager.broadcast({
+ "type": "local_flight_updated",
+ "data": {
+ "id": flight.id,
+ "registration": flight.registration,
+ "status": flight.status.value
+ }
+ })
+
+ return flight
+
+
+@router.patch("/{flight_id}/status", response_model=LocalFlight)
+async def update_local_flight_status(
+ request: Request,
+ flight_id: int,
+ status_update: LocalFlightStatusUpdate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_operator_user)
+):
+ """Update local flight status (LANDED, CANCELLED, etc.)"""
+ flight = crud_local_flight.update_status(
+ db,
+ flight_id=flight_id,
+ status=status_update.status,
+ timestamp=status_update.timestamp
+ )
+ if not flight:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Local flight record not found"
+ )
+
+ # Send real-time update
+ if hasattr(request.app.state, 'connection_manager'):
+ await request.app.state.connection_manager.broadcast({
+ "type": "local_flight_status_update",
+ "data": {
+ "id": flight.id,
+ "registration": flight.registration,
+ "status": flight.status.value,
+ "landed_dt": flight.landed_dt.isoformat() if flight.landed_dt else None
+ }
+ })
+
+ return flight
+
+
+@router.delete("/{flight_id}", response_model=LocalFlight)
+async def cancel_local_flight(
+ request: Request,
+ flight_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_operator_user)
+):
+ """Cancel a local flight record"""
+ flight = crud_local_flight.cancel(db, flight_id=flight_id)
+ if not flight:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Local flight record not found"
+ )
+
+ # Send real-time update
+ if hasattr(request.app.state, 'connection_manager'):
+ await request.app.state.connection_manager.broadcast({
+ "type": "local_flight_cancelled",
+ "data": {
+ "id": flight.id,
+ "registration": flight.registration
+ }
+ })
+
+ return flight
+
+
+@router.get("/active/current", response_model=List[LocalFlight])
+async def get_active_flights(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_read_user)
+):
+ """Get currently active (booked out) flights"""
+ return crud_local_flight.get_active_flights(db)
+
+
+@router.get("/today/departures", response_model=List[LocalFlight])
+async def get_today_departures(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_read_user)
+):
+ """Get today's departures (booked out or departed)"""
+ return crud_local_flight.get_departures_today(db)
+
+
+@router.get("/today/booked-out", response_model=List[LocalFlight])
+async def get_today_booked_out(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_read_user)
+):
+ """Get all flights booked out today"""
+ return crud_local_flight.get_booked_out_today(db)
diff --git a/backend/app/crud/crud_local_flight.py b/backend/app/crud/crud_local_flight.py
new file mode 100644
index 0000000..259612d
--- /dev/null
+++ b/backend/app/crud/crud_local_flight.py
@@ -0,0 +1,136 @@
+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.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
+from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, LocalFlightStatusUpdate
+
+
+class CRUDLocalFlight:
+ def get(self, db: Session, flight_id: int) -> Optional[LocalFlight]:
+ return db.query(LocalFlight).filter(LocalFlight.id == flight_id).first()
+
+ def get_multi(
+ self,
+ db: Session,
+ skip: int = 0,
+ limit: int = 100,
+ status: Optional[LocalFlightStatus] = None,
+ flight_type: Optional[LocalFlightType] = None,
+ date_from: Optional[date] = None,
+ date_to: Optional[date] = None
+ ) -> List[LocalFlight]:
+ query = db.query(LocalFlight)
+
+ if status:
+ query = query.filter(LocalFlight.status == status)
+
+ if flight_type:
+ query = query.filter(LocalFlight.flight_type == flight_type)
+
+ if date_from:
+ query = query.filter(func.date(LocalFlight.booked_out_dt) >= date_from)
+
+ if date_to:
+ query = query.filter(func.date(LocalFlight.booked_out_dt) <= date_to)
+
+ return query.order_by(desc(LocalFlight.booked_out_dt)).offset(skip).limit(limit).all()
+
+ def get_active_flights(self, db: Session) -> List[LocalFlight]:
+ """Get currently active (booked out or departed) flights"""
+ return db.query(LocalFlight).filter(
+ or_(
+ LocalFlight.status == LocalFlightStatus.BOOKED_OUT,
+ LocalFlight.status == LocalFlightStatus.DEPARTED
+ )
+ ).order_by(desc(LocalFlight.booked_out_dt)).all()
+
+ def get_departures_today(self, db: Session) -> List[LocalFlight]:
+ """Get today's departures (booked out or departed)"""
+ today = date.today()
+ return db.query(LocalFlight).filter(
+ and_(
+ func.date(LocalFlight.booked_out_dt) == today,
+ or_(
+ LocalFlight.status == LocalFlightStatus.BOOKED_OUT,
+ LocalFlight.status == LocalFlightStatus.DEPARTED
+ )
+ )
+ ).order_by(LocalFlight.booked_out_dt).all()
+
+ def get_booked_out_today(self, db: Session) -> List[LocalFlight]:
+ """Get all flights booked out today"""
+ today = date.today()
+ return db.query(LocalFlight).filter(
+ and_(
+ func.date(LocalFlight.booked_out_dt) == today,
+ or_(
+ LocalFlight.status == LocalFlightStatus.BOOKED_OUT,
+ LocalFlight.status == LocalFlightStatus.LANDED
+ )
+ )
+ ).order_by(LocalFlight.booked_out_dt).all()
+
+ def create(self, db: Session, obj_in: LocalFlightCreate, created_by: str) -> LocalFlight:
+ db_obj = LocalFlight(
+ **obj_in.dict(),
+ created_by=created_by,
+ status=LocalFlightStatus.BOOKED_OUT
+ )
+ db.add(db_obj)
+ db.commit()
+ db.refresh(db_obj)
+ return db_obj
+
+ def update(self, db: Session, db_obj: LocalFlight, obj_in: LocalFlightUpdate) -> LocalFlight:
+ update_data = obj_in.dict(exclude_unset=True)
+
+ for field, value in update_data.items():
+ if value is not None:
+ setattr(db_obj, field, value)
+
+ db.add(db_obj)
+ db.commit()
+ db.refresh(db_obj)
+ return db_obj
+
+ def update_status(
+ self,
+ db: Session,
+ flight_id: int,
+ status: LocalFlightStatus,
+ timestamp: Optional[datetime] = None
+ ) -> Optional[LocalFlight]:
+ db_obj = self.get(db, flight_id)
+ if not db_obj:
+ return None
+
+ # Ensure status is a LocalFlightStatus enum
+ if isinstance(status, str):
+ status = LocalFlightStatus(status)
+
+ db_obj.status = status
+
+ # Set timestamps based on status
+ current_time = timestamp if timestamp is not None else datetime.utcnow()
+ if status == LocalFlightStatus.DEPARTED:
+ db_obj.departure_dt = current_time
+ elif status == LocalFlightStatus.LANDED:
+ db_obj.landed_dt = current_time
+
+ db.add(db_obj)
+ db.commit()
+ db.refresh(db_obj)
+ return db_obj
+
+ def cancel(self, db: Session, flight_id: int) -> Optional[LocalFlight]:
+ db_obj = self.get(db, flight_id)
+ if db_obj:
+ db_obj.status = LocalFlightStatus.CANCELLED
+ db.add(db_obj)
+ db.commit()
+ db.refresh(db_obj)
+ return db_obj
+
+
+local_flight = CRUDLocalFlight()
diff --git a/backend/app/main.py b/backend/app/main.py
index 17f9971..787eaea 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -8,6 +8,10 @@ import redis.asyncio as redis
from app.core.config import settings
from app.api.api import api_router
+# Import models to ensure they're registered with SQLAlchemy
+from app.models.ppr import PPRRecord, User, Journal, Airport, Aircraft
+from app.models.local_flight import LocalFlight
+
# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
diff --git a/backend/app/models/local_flight.py b/backend/app/models/local_flight.py
new file mode 100644
index 0000000..593e155
--- /dev/null
+++ b/backend/app/models/local_flight.py
@@ -0,0 +1,35 @@
+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 LocalFlightType(str, Enum):
+ LOCAL = "LOCAL"
+ CIRCUITS = "CIRCUITS"
+ DEPARTURE = "DEPARTURE"
+
+
+class LocalFlightStatus(str, Enum):
+ BOOKED_OUT = "BOOKED_OUT"
+ DEPARTED = "DEPARTED"
+ LANDED = "LANDED"
+ CANCELLED = "CANCELLED"
+
+
+class LocalFlight(Base):
+ __tablename__ = "local_flights"
+
+ id = Column(BigInteger, primary_key=True, autoincrement=True)
+ registration = Column(String(16), nullable=False, index=True)
+ type = Column(String(32), nullable=False) # Aircraft type
+ callsign = Column(String(16), nullable=True)
+ pob = Column(Integer, nullable=False) # Persons on board
+ flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True)
+ status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True)
+ notes = Column(Text, nullable=True)
+ booked_out_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
+ departure_dt = Column(DateTime, nullable=True) # Actual takeoff time
+ landed_dt = Column(DateTime, nullable=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())
diff --git a/backend/app/schemas/local_flight.py b/backend/app/schemas/local_flight.py
new file mode 100644
index 0000000..2fd1351
--- /dev/null
+++ b/backend/app/schemas/local_flight.py
@@ -0,0 +1,81 @@
+from pydantic import BaseModel, validator
+from datetime import datetime
+from typing import Optional
+from enum import Enum
+
+
+class LocalFlightType(str, Enum):
+ LOCAL = "LOCAL"
+ CIRCUITS = "CIRCUITS"
+ DEPARTURE = "DEPARTURE"
+
+
+class LocalFlightStatus(str, Enum):
+ BOOKED_OUT = "BOOKED_OUT"
+ DEPARTED = "DEPARTED"
+ LANDED = "LANDED"
+ CANCELLED = "CANCELLED"
+
+
+class LocalFlightBase(BaseModel):
+ registration: str
+ type: str # Aircraft type
+ callsign: Optional[str] = None
+ pob: int
+ flight_type: LocalFlightType
+ 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 not v or len(v.strip()) == 0:
+ raise ValueError('Aircraft type is required')
+ return v.strip()
+
+ @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 LocalFlightCreate(LocalFlightBase):
+ pass
+
+
+class LocalFlightUpdate(BaseModel):
+ registration: Optional[str] = None
+ type: Optional[str] = None
+ callsign: Optional[str] = None
+ pob: Optional[int] = None
+ flight_type: Optional[LocalFlightType] = None
+ status: Optional[LocalFlightStatus] = None
+ departure_dt: Optional[datetime] = None
+ notes: Optional[str] = None
+
+
+class LocalFlightStatusUpdate(BaseModel):
+ status: LocalFlightStatus
+ timestamp: Optional[datetime] = None
+
+
+class LocalFlightInDBBase(LocalFlightBase):
+ id: int
+ status: LocalFlightStatus
+ booked_out_dt: datetime
+ departure_dt: Optional[datetime] = None
+ landed_dt: Optional[datetime] = None
+ created_by: Optional[str] = None
+ updated_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class LocalFlight(LocalFlightInDBBase):
+ pass
diff --git a/web/admin.html b/web/admin.html
index 54ca3f5..ba58310 100644
--- a/web/admin.html
+++ b/web/admin.html
@@ -632,6 +632,9 @@
+
@@ -965,6 +968,137 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1508,22 +1642,24 @@
await Promise.all([loadArrivals(), loadDepartures(), loadDeparted(), loadParked(), loadUpcoming()]);
}
- // Load arrivals (NEW and CONFIRMED status)
+ // Load arrivals (NEW and CONFIRMED status for PPR, DEPARTED for local flights)
async function loadArrivals() {
document.getElementById('arrivals-loading').style.display = 'block';
document.getElementById('arrivals-table-content').style.display = 'none';
document.getElementById('arrivals-no-data').style.display = 'none';
try {
- // Load all PPRs and filter client-side for today's arrivals
- // We filter by ETA date (not ETD) and NEW/CONFIRMED status
- const response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
+ // Load PPRs and local flights that are in the air
+ const [pprResponse, localResponse] = await Promise.all([
+ authenticatedFetch('/api/v1/pprs/?limit=1000'),
+ authenticatedFetch('/api/v1/local-flights/?status=DEPARTED&limit=1000')
+ ]);
- if (!response.ok) {
+ if (!pprResponse.ok) {
throw new Error('Failed to fetch arrivals');
}
- const allPPRs = await response.json();
+ const allPPRs = await pprResponse.json();
const today = new Date().toISOString().split('T')[0];
// Filter for arrivals with ETA today and NEW or CONFIRMED status
@@ -1536,6 +1672,16 @@
return etaDate === today;
});
+ // Add local flights in DEPARTED status (in the air, heading back)
+ if (localResponse.ok) {
+ const localFlights = await localResponse.json();
+ const localInAir = localFlights.map(flight => ({
+ ...flight,
+ isLocalFlight: true // Flag to distinguish from PPR
+ }));
+ arrivals.push(...localInAir);
+ }
+
displayArrivals(arrivals);
} catch (error) {
console.error('Error loading arrivals:', error);
@@ -1547,25 +1693,27 @@
document.getElementById('arrivals-loading').style.display = 'none';
}
- // Load departures (LANDED status)
+ // Load departures (LANDED status for PPR, BOOKED_OUT only for local flights)
async function loadDepartures() {
document.getElementById('departures-loading').style.display = 'block';
document.getElementById('departures-table-content').style.display = 'none';
document.getElementById('departures-no-data').style.display = 'none';
try {
- // Load all PPRs and filter client-side for today's departures
- // We filter by ETD date and LANDED status only (exclude DEPARTED)
- const response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
+ // Load PPR departures and local flight departures (BOOKED_OUT only) simultaneously
+ const [pprResponse, localResponse] = await Promise.all([
+ authenticatedFetch('/api/v1/pprs/?limit=1000'),
+ authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&limit=1000')
+ ]);
- if (!response.ok) {
- throw new Error('Failed to fetch departures');
+ if (!pprResponse.ok) {
+ throw new Error('Failed to fetch PPR departures');
}
- const allPPRs = await response.json();
+ const allPPRs = await pprResponse.json();
const today = new Date().toISOString().split('T')[0];
- // Filter for departures with ETD today and LANDED status only
+ // Filter for PPR departures with ETD today and LANDED status only
const departures = allPPRs.filter(ppr => {
if (!ppr.etd || ppr.status !== 'LANDED') {
return false;
@@ -1575,6 +1723,16 @@
return etdDate === today;
});
+ // Add local flights (BOOKED_OUT status - ready to go)
+ if (localResponse.ok) {
+ const localFlights = await localResponse.json();
+ const localDepartures = localFlights.map(flight => ({
+ ...flight,
+ isLocalFlight: true // Flag to distinguish from PPR
+ }));
+ departures.push(...localDepartures);
+ }
+
displayDepartures(departures);
} catch (error) {
console.error('Error loading departures:', error);
@@ -1593,16 +1751,19 @@
document.getElementById('departed-no-data').style.display = 'none';
try {
- const response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
+ const [pprResponse, localResponse] = await Promise.all([
+ authenticatedFetch('/api/v1/pprs/?limit=1000'),
+ authenticatedFetch('/api/v1/local-flights/?status=DEPARTED&limit=1000')
+ ]);
- if (!response.ok) {
+ if (!pprResponse.ok) {
throw new Error('Failed to fetch departed aircraft');
}
- const allPPRs = await response.json();
+ const allPPRs = await pprResponse.json();
const today = new Date().toISOString().split('T')[0];
- // Filter for aircraft departed today
+ // Filter for PPRs departed today
const departed = allPPRs.filter(ppr => {
if (!ppr.departed_dt || ppr.status !== 'DEPARTED') {
return false;
@@ -1611,6 +1772,20 @@
return departedDate === today;
});
+ // Add local flights departed today
+ if (localResponse.ok) {
+ const localFlights = await localResponse.json();
+ const localDeparted = localFlights.filter(flight => {
+ if (!flight.departure_dt) return false;
+ const departedDate = flight.departure_dt.split('T')[0];
+ return departedDate === today;
+ }).map(flight => ({
+ ...flight,
+ isLocalFlight: true
+ }));
+ departed.push(...localDeparted);
+ }
+
displayDeparted(departed);
} catch (error) {
console.error('Error loading departed aircraft:', error);
@@ -1632,22 +1807,43 @@
}
// Sort by departed time
- departed.sort((a, b) => new Date(a.departed_dt) - new Date(b.departed_dt));
+ departed.sort((a, b) => {
+ const aTime = a.departed_dt || a.departure_dt;
+ const bTime = b.departed_dt || b.departure_dt;
+ return new Date(aTime) - new Date(bTime);
+ });
tbody.innerHTML = '';
document.getElementById('departed-table-content').style.display = 'block';
- for (const ppr of departed) {
+ for (const flight of departed) {
const row = document.createElement('tr');
- row.onclick = () => openPPRModal(ppr.id);
+ const isLocal = flight.isLocalFlight;
+
+ row.onclick = () => {
+ if (isLocal) {
+ openLocalFlightEditModal(flight.id);
+ } else {
+ openPPRModal(flight.id);
+ }
+ };
row.style.cssText = 'font-size: 0.85rem !important; font-style: italic;';
- row.innerHTML = `
-
${ppr.ac_reg || '-'} |
-
${ppr.ac_call || '-'} |
-
${ppr.out_to || '-'} |
-
${formatTimeOnly(ppr.departed_dt)} |
- `;
+ if (isLocal) {
+ row.innerHTML = `
+
${flight.registration || '-'} |
+
${flight.callsign || '-'} |
+
- |
+
${formatTimeOnly(flight.departure_dt)} |
+ `;
+ } else {
+ row.innerHTML = `
+
${flight.ac_reg || '-'} |
+
${flight.ac_call || '-'} |
+
${flight.out_to || '-'} |
+
${formatTimeOnly(flight.departed_dt)} |
+ `;
+ }
tbody.appendChild(row);
}
}
@@ -1885,47 +2081,89 @@
return;
}
- // Sort arrivals by ETA (ascending)
+ // Sort arrivals by ETA/departure time (ascending)
arrivals.sort((a, b) => {
- if (!a.eta) return 1;
- if (!b.eta) return -1;
- return new Date(a.eta) - new Date(b.eta);
+ const aTime = a.eta || a.departure_dt;
+ const bTime = b.eta || b.departure_dt;
+ if (!aTime) return 1;
+ if (!bTime) return -1;
+ return new Date(aTime) - new Date(bTime);
});
tbody.innerHTML = '';
document.getElementById('arrivals-table-content').style.display = 'block';
- for (const ppr of arrivals) {
+ for (const flight of arrivals) {
const row = document.createElement('tr');
- row.onclick = () => openPPRModal(ppr.id);
+ const isLocal = flight.isLocalFlight;
+
+ // Click handler that routes to correct modal
+ row.onclick = () => {
+ if (isLocal) {
+ openLocalFlightEditModal(flight.id);
+ } else {
+ openPPRModal(flight.id);
+ }
+ };
+
// Create notes indicator if notes exist
- const notesIndicator = ppr.notes && ppr.notes.trim() ?
+ const notesIndicator = flight.notes && flight.notes.trim() ?
`
📝
- ${ppr.notes}
+ ${flight.notes}
` : '';
- // Display callsign as main item if present, registration below; otherwise show registration
- const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ?
- `
${ppr.ac_call}${ppr.ac_reg}` :
- `
${ppr.ac_reg}`;
- // Lookup airport name for in_from
- let fromDisplay = ppr.in_from;
- if (ppr.in_from && ppr.in_from.length === 4 && /^[A-Z]{4}$/.test(ppr.in_from)) {
- fromDisplay = await getAirportDisplay(ppr.in_from);
- }
- row.innerHTML = `
-
${aircraftDisplay}${notesIndicator} |
-
${ppr.ac_type} |
-
${fromDisplay} |
-
${formatTimeOnly(ppr.eta)} |
-
${ppr.pob_in} |
-
${ppr.fuel || '-'} |
-
- |
+ `;
+ } else {
+ // PPR display
+ const callsign = flight.ac_call && flight.ac_call.trim() ? flight.ac_call : flight.ac_reg;
+ aircraftDisplay = `
${callsign}${flight.ac_reg}`;
+ acType = flight.ac_type;
+
+ // Lookup airport name for in_from
+ let fromDisplay_temp = flight.in_from;
+ if (flight.in_from && flight.in_from.length === 4 && /^[A-Z]{4}$/.test(flight.in_from)) {
+ fromDisplay_temp = await getAirportDisplay(flight.in_from);
+ }
+ fromDisplay = fromDisplay_temp;
+
+ eta = formatTimeOnly(flight.eta);
+ pob = flight.pob_in;
+ fuel = flight.fuel || '-';
+ actionButtons = `
+
+ LAND
+
+
+ CANCEL
+
+ `;
+ }
+
+ row.innerHTML = `
+
${aircraftDisplay}${notesIndicator} |
+
${acType} |
+
${fromDisplay} |
+
${eta} |
+
${pob} |
+
${fuel} |
+
${actionButtons} |
`;
tbody.appendChild(row);
}
@@ -1944,46 +2182,100 @@
// Sort departures by ETD (ascending), nulls last
departures.sort((a, b) => {
- if (!a.etd) return 1;
- if (!b.etd) return -1;
- return new Date(a.etd) - new Date(b.etd);
+ const aTime = a.etd || a.booked_out_dt;
+ const bTime = b.etd || b.booked_out_dt;
+ if (!aTime) return 1;
+ if (!bTime) return -1;
+ return new Date(aTime) - new Date(bTime);
});
tbody.innerHTML = '';
document.getElementById('departures-table-content').style.display = 'block';
- for (const ppr of departures) {
+ for (const flight of departures) {
const row = document.createElement('tr');
- row.onclick = () => openPPRModal(ppr.id);
+ const isLocal = flight.isLocalFlight;
+
+ // Click handler that routes to correct modal
+ row.onclick = () => {
+ if (isLocal) {
+ openLocalFlightEditModal(flight.id);
+ } else {
+ openPPRModal(flight.id);
+ }
+ };
+
// Create notes indicator if notes exist
- const notesIndicator = ppr.notes && ppr.notes.trim() ?
+ const notesIndicator = flight.notes && flight.notes.trim() ?
`
📝
- ${ppr.notes}
+ ${flight.notes}
` : '';
- // Display callsign as main item if present, registration below; otherwise show registration
- const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ?
- `
${ppr.ac_call}${ppr.ac_reg}` :
- `
${ppr.ac_reg}`;
- // Lookup airport name for out_to
- let toDisplay = ppr.out_to || '-';
- if (ppr.out_to && ppr.out_to.length === 4 && /^[A-Z]{4}$/.test(ppr.out_to)) {
- toDisplay = await getAirportDisplay(ppr.out_to);
- }
- row.innerHTML = `
-
${aircraftDisplay}${notesIndicator} |
-
${ppr.ac_type} |
-
${toDisplay} |
-
${ppr.etd ? formatTimeOnly(ppr.etd) : '-'} |
-
${ppr.pob_out || ppr.pob_in} |
-
${ppr.fuel || '-'} |
-
${ppr.landed_dt ? formatTimeOnly(ppr.landed_dt) : '-'} |
-
-
+
+ let aircraftDisplay, toDisplay, etd, pob, fuel, landedDt, actionButtons;
+
+ if (isLocal) {
+ // Local flight display
+ const callsign = flight.callsign && flight.callsign.trim() ? flight.callsign : flight.registration;
+ aircraftDisplay = `${callsign} ${flight.registration}`;
+ toDisplay = '-';
+ etd = flight.booked_out_dt ? formatTimeOnly(flight.booked_out_dt) : '-';
+ pob = flight.pob || '-';
+ fuel = '-';
+ landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
+
+ // Action buttons for local flight
+ if (flight.status === 'BOOKED_OUT') {
+ actionButtons = `
+
+ TAKE OFF
+
+
+ CANCEL
+
+ `;
+ } else if (flight.status === 'DEPARTED') {
+ actionButtons = `
+
+ LAND
+
+
+ CANCEL
+
+ `;
+ } else {
+ actionButtons = '-';
+ }
+ } else {
+ // PPR display
+ const callsign = flight.ac_call && flight.ac_call.trim() ? flight.ac_call : flight.ac_reg;
+ aircraftDisplay = `${callsign} ${flight.ac_reg}`;
+ toDisplay = flight.out_to || '-';
+ if (flight.out_to && flight.out_to.length === 4 && /^[A-Z]{4}$/.test(flight.out_to)) {
+ toDisplay = await getAirportDisplay(flight.out_to);
+ }
+ etd = flight.etd ? formatTimeOnly(flight.etd) : '-';
+ pob = flight.pob_out || flight.pob_in;
+ fuel = flight.fuel || '-';
+ landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
+
+ actionButtons = `
+
TAKE OFF
-
+
CANCEL
- |
+ `;
+ }
+
+ row.innerHTML = `
+
${aircraftDisplay}${notesIndicator} |
+
${isLocal ? flight.type : flight.ac_type} |
+
${toDisplay} |
+
${etd} |
+
${pob} |
+
${fuel} |
+
${landedDt} |
+
${actionButtons} |
`;
tbody.appendChild(row);
}
@@ -3114,6 +3406,337 @@
tooltip.style.top = top + 'px';
}
+ // Local Flight (Book Out) Modal Functions
+ function openLocalFlightModal() {
+ document.getElementById('local-flight-form').reset();
+ document.getElementById('local-flight-id').value = '';
+ document.getElementById('local-flight-modal-title').textContent = 'Book Out';
+ document.getElementById('localFlightModal').style.display = 'block';
+
+ // Clear aircraft lookup results
+ clearLocalAircraftLookup();
+
+ // Auto-focus on registration field
+ setTimeout(() => {
+ document.getElementById('local_registration').focus();
+ }, 100);
+ }
+
+ function closeLocalFlightModal() {
+ document.getElementById('localFlightModal').style.display = 'none';
+ }
+
+ // Handle aircraft lookup for local flights
+ let localAircraftLookupTimeout;
+ function handleLocalAircraftLookup(registration) {
+ // Clear previous timeout
+ if (localAircraftLookupTimeout) {
+ clearTimeout(localAircraftLookupTimeout);
+ }
+
+ // Clear results if input is too short
+ if (registration.length < 4) {
+ clearLocalAircraftLookup();
+ return;
+ }
+
+ // Show searching indicator
+ document.getElementById('local-aircraft-lookup-results').innerHTML =
+ '
Searching...
';
+
+ // Debounce the search - wait 300ms after user stops typing
+ localAircraftLookupTimeout = setTimeout(() => {
+ performLocalAircraftLookup(registration);
+ }, 300);
+ }
+
+ async function performLocalAircraftLookup(registration) {
+ try {
+ // Clean the input (remove non-alphanumeric characters and make uppercase)
+ const cleanInput = registration.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
+
+ if (cleanInput.length < 4) {
+ clearLocalAircraftLookup();
+ return;
+ }
+
+ // Call the API
+ const response = await authenticatedFetch(`/api/v1/aircraft/lookup/${cleanInput}`);
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch aircraft data');
+ }
+
+ const matches = await response.json();
+ displayLocalAircraftLookupResults(matches, cleanInput);
+
+ } catch (error) {
+ console.error('Aircraft lookup error:', error);
+ document.getElementById('local-aircraft-lookup-results').innerHTML =
+ '
Lookup failed - please enter manually
';
+ }
+ }
+
+ function displayLocalAircraftLookupResults(matches, searchTerm) {
+ const resultsDiv = document.getElementById('local-aircraft-lookup-results');
+
+ if (matches.length === 0) {
+ resultsDiv.innerHTML = '
No matches found
';
+ } else if (matches.length === 1) {
+ // Unique match found - auto-populate
+ const aircraft = matches[0];
+ resultsDiv.innerHTML = `
+
+ ✓ ${aircraft.manufacturer_name} ${aircraft.model} (${aircraft.type_code})
+
+ `;
+
+ // Auto-populate the form fields
+ document.getElementById('local_registration').value = aircraft.registration;
+ document.getElementById('local_type').value = aircraft.type_code;
+
+ } else {
+ // Multiple matches - show list but don't auto-populate
+ resultsDiv.innerHTML = `
+
+ Multiple matches found (${matches.length}) - please be more specific
+
+ `;
+ }
+ }
+
+ function clearLocalAircraftLookup() {
+ document.getElementById('local-aircraft-lookup-results').innerHTML = '';
+ }
+
+ // Local Flight Edit Modal Functions
+ let currentLocalFlightId = null;
+
+ async function openLocalFlightEditModal(flightId) {
+ if (!accessToken) return;
+
+ try {
+ const response = await fetch(`/api/v1/local-flights/${flightId}`, {
+ headers: { 'Authorization': `Bearer ${accessToken}` }
+ });
+
+ if (!response.ok) throw new Error('Failed to load flight');
+
+ const flight = await response.json();
+ currentLocalFlightId = flight.id;
+
+ // Populate form
+ document.getElementById('local-edit-flight-id').value = flight.id;
+ document.getElementById('local_edit_registration').value = flight.registration;
+ document.getElementById('local_edit_type').value = flight.type;
+ document.getElementById('local_edit_callsign').value = flight.callsign || '';
+ document.getElementById('local_edit_pob').value = flight.pob;
+ document.getElementById('local_edit_flight_type').value = flight.flight_type;
+ document.getElementById('local_edit_notes').value = flight.notes || '';
+
+ // Parse and populate departure time if exists
+ if (flight.departure_dt) {
+ const dept = new Date(flight.departure_dt);
+ document.getElementById('local_edit_departure_date').value = dept.toISOString().slice(0, 10);
+ document.getElementById('local_edit_departure_time').value = dept.toISOString().slice(11, 16);
+ }
+
+ // Show/hide action buttons based on status
+ const deptBtn = document.getElementById('local-btn-departed');
+ const landBtn = document.getElementById('local-btn-landed');
+ const cancelBtn = document.getElementById('local-btn-cancel');
+
+ deptBtn.style.display = flight.status === 'BOOKED_OUT' ? 'inline-block' : 'none';
+ landBtn.style.display = flight.status === 'DEPARTED' ? 'inline-block' : 'none';
+ cancelBtn.style.display = (flight.status === 'BOOKED_OUT' || flight.status === 'DEPARTED') ? 'inline-block' : 'none';
+
+ document.getElementById('local-flight-edit-title').textContent = `${flight.registration} - ${flight.flight_type}`;
+ document.getElementById('localFlightEditModal').style.display = 'block';
+ } catch (error) {
+ console.error('Error loading flight:', error);
+ showNotification('Error loading flight details', true);
+ }
+ }
+
+ function closeLocalFlightEditModal() {
+ document.getElementById('localFlightEditModal').style.display = 'none';
+ currentLocalFlightId = null;
+ }
+
+ // Update status from table buttons (with flight ID passed)
+ async function updateLocalFlightStatusFromTable(flightId, status) {
+ if (!accessToken) return;
+
+ try {
+ const response = await fetch(`/api/v1/local-flights/${flightId}/status`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${accessToken}`
+ },
+ body: JSON.stringify({ status: status })
+ });
+
+ if (!response.ok) throw new Error('Failed to update status');
+
+ loadPPRs(); // Refresh display
+ showNotification(`Flight marked as ${status.toLowerCase()}`);
+ } catch (error) {
+ console.error('Error updating status:', error);
+ showNotification('Error updating flight status', true);
+ }
+ }
+
+ // Update status from modal (uses currentLocalFlightId)
+ async function updateLocalFlightStatus(status) {
+ if (!currentLocalFlightId || !accessToken) return;
+
+ try {
+ const response = await fetch(`/api/v1/local-flights/${currentLocalFlightId}/status`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${accessToken}`
+ },
+ body: JSON.stringify({ status: status })
+ });
+
+ if (!response.ok) throw new Error('Failed to update status');
+
+ closeLocalFlightEditModal();
+ loadPPRs(); // Refresh display
+ showNotification(`Flight marked as ${status.toLowerCase()}`);
+ } catch (error) {
+ console.error('Error updating status:', error);
+ showNotification('Error updating flight status', true);
+ }
+ }
+
+ // Local flight edit form submission
+ document.getElementById('local-flight-edit-form').addEventListener('submit', async function(e) {
+ e.preventDefault();
+
+ if (!currentLocalFlightId || !accessToken) return;
+
+ const formData = new FormData(this);
+ const updateData = {};
+
+ formData.forEach((value, key) => {
+ if (key === 'id') return;
+
+ // Handle date/time combination for departure
+ if (key === 'departure_date' || key === 'departure_time') {
+ if (!updateData.departure_dt && formData.get('departure_date') && formData.get('departure_time')) {
+ const dateStr = formData.get('departure_date');
+ const timeStr = formData.get('departure_time');
+ updateData.departure_dt = new Date(`${dateStr}T${timeStr}`).toISOString();
+ }
+ return;
+ }
+
+ // Only include non-empty values
+ if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) {
+ if (key === 'pob') {
+ updateData[key] = parseInt(value);
+ } else if (value.trim) {
+ updateData[key] = value.trim();
+ } else {
+ updateData[key] = value;
+ }
+ }
+ });
+
+ try {
+ const response = await fetch(`/api/v1/local-flights/${currentLocalFlightId}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${accessToken}`
+ },
+ body: JSON.stringify(updateData)
+ });
+
+ if (!response.ok) throw new Error('Failed to update flight');
+
+ closeLocalFlightEditModal();
+ loadPPRs(); // Refresh display
+ showNotification('Flight updated successfully');
+ } catch (error) {
+ console.error('Error updating flight:', error);
+ showNotification('Error updating flight', true);
+ }
+ });
+
+ // Add event listener for local flight form submission
+ document.getElementById('local-flight-form').addEventListener('submit', async function(e) {
+ e.preventDefault();
+
+ if (!accessToken) return;
+
+ const formData = new FormData(this);
+ const flightData = {};
+
+ formData.forEach((value, key) => {
+ // Skip the hidden id field and empty values
+ if (key === 'id') return;
+
+ // Handle date/time combination for departure
+ if (key === 'departure_date' || key === 'departure_time') {
+ if (!flightData.departure_dt && formData.get('departure_date') && formData.get('departure_time')) {
+ const dateStr = formData.get('departure_date');
+ const timeStr = formData.get('departure_time');
+ flightData.departure_dt = new Date(`${dateStr}T${timeStr}`).toISOString();
+ }
+ return;
+ }
+
+ // Only include non-empty values
+ if (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) {
+ if (key === 'pob') {
+ flightData[key] = parseInt(value);
+ } else if (value.trim) {
+ flightData[key] = value.trim();
+ } else {
+ flightData[key] = value;
+ }
+ }
+ });
+
+ console.log('Submitting flight data:', flightData);
+
+ try {
+ const response = await fetch('/api/v1/local-flights/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${accessToken}`
+ },
+ body: JSON.stringify(flightData)
+ });
+
+ if (!response.ok) {
+ let errorMessage = 'Failed to book out flight';
+ try {
+ const errorData = await response.json();
+ errorMessage = errorData.detail || errorMessage;
+ } catch (e) {
+ const text = await response.text();
+ console.error('Server response:', text);
+ errorMessage = `Server error (${response.status})`;
+ }
+ throw new Error(errorMessage);
+ }
+
+ const result = await response.json();
+ closeLocalFlightModal();
+ loadPPRs(); // Refresh tables
+ showNotification(`Aircraft ${result.registration} booked out successfully!`);
+ } catch (error) {
+ console.error('Error booking out flight:', error);
+ showNotification(`Error: ${error.message}`, true);
+ }
+ });
+
// Add hover listeners to all notes tooltips
function setupTooltips() {
document.querySelectorAll('.notes-tooltip').forEach(tooltip => {