Compare commits
46 Commits
bd1200f377
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 211db514dd | |||
| 24971ac5fc | |||
|
|
a1a5f90f00 | ||
| 97995fa58e | |||
| bcd582aee5 | |||
| dc6b551325 | |||
| ac29b6e929 | |||
| 0149f45893 | |||
| 63564b54dd | |||
| 3ab9a6e04c | |||
| b46a88d471 | |||
| 658d4c4ff8 | |||
| a43ab34a8f | |||
| dee5d38b58 | |||
| ee311cc120 | |||
|
|
e63fdc74ec | ||
| a8c0a37b7e | |||
| c92f838489 | |||
|
|
8513a7bb0f | ||
| d183678282 | |||
| a2682314c9 | |||
| f3eb83665f | |||
| f572fb75f5 | |||
| f65c54109e | |||
| d53ddff4be | |||
| 2d4f1467de | |||
| 65eb3272f2 | |||
| 6209c7acce | |||
| d7eefdb652 | |||
| 98d0e3cfd7 | |||
| d2e7d3c3dd | |||
| ea35de5eb5 | |||
| 97517777df | |||
| ab3319af06 | |||
| 32ad7a793a | |||
| dbb285fa20 | |||
| f7467690e4 | |||
| 1d1c504f91 | |||
| 0aeed2268a | |||
| 56e4ab6e3e | |||
| ee1b42442e | |||
| cc5697eaa0 | |||
| 9cfd88d848 | |||
|
|
7efc2ef37a | ||
|
|
5f2aa82e36 | ||
|
|
e8bd30aadc |
@@ -24,6 +24,11 @@ MAIL_FROM_NAME=your_mail_from_name_here
|
|||||||
# Application settings
|
# Application settings
|
||||||
BASE_URL=your_base_url_here
|
BASE_URL=your_base_url_here
|
||||||
|
|
||||||
|
# UI Configuration
|
||||||
|
TAG=
|
||||||
|
TOP_BAR_BASE_COLOR=#2c3e50
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
# Redis (optional)
|
# Redis (optional)
|
||||||
REDIS_URL=
|
REDIS_URL=
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
# Import all models to ensure they are registered with Base
|
# Import all models to ensure they are registered with Base
|
||||||
from app.models.ppr import PPRRecord, User, Journal, Airport, Aircraft
|
from app.models.ppr import PPRRecord, User, Airport, Aircraft
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# access to the values within the .ini file in use.
|
||||||
|
|||||||
216
backend/alembic/versions/002_local_flights.py
Normal file
216
backend/alembic/versions/002_local_flights.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""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. Also adds etd and renames booked_out_dt to created_dt,
|
||||||
|
and departure_dt to departed_dt for consistency. Transforms journal table from PPR-specific
|
||||||
|
to a generic polymorphic journal for all entity types.
|
||||||
|
|
||||||
|
"""
|
||||||
|
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, departures, and arrivals tables.
|
||||||
|
Transform journal table from PPR-specific to generic polymorphic journal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Modify existing journal table to support all entity types
|
||||||
|
# First add new columns (check if they don't already exist)
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# Get table columns to check if entity_type and entity_id already exist
|
||||||
|
connection = op.get_context().bind
|
||||||
|
inspector = inspect(connection)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('journal')]
|
||||||
|
|
||||||
|
if 'entity_type' not in columns:
|
||||||
|
op.add_column('journal', sa.Column('entity_type', sa.String(50), nullable=True))
|
||||||
|
if 'entity_id' not in columns:
|
||||||
|
op.add_column('journal', sa.Column('entity_id', sa.BigInteger(), nullable=True))
|
||||||
|
|
||||||
|
# Migrate existing PPR journal entries: backfill entity_type and entity_id
|
||||||
|
op.execute("""
|
||||||
|
UPDATE journal SET
|
||||||
|
entity_type = 'PPR',
|
||||||
|
entity_id = ppr_id
|
||||||
|
WHERE entity_type IS NULL AND ppr_id IS NOT NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Make new columns NOT NULL after migration
|
||||||
|
op.alter_column('journal', 'entity_type', existing_type=sa.String(50), nullable=False)
|
||||||
|
op.alter_column('journal', 'entity_id', existing_type=sa.BigInteger(), nullable=False)
|
||||||
|
|
||||||
|
# Make ip column nullable (new entries won't always have it)
|
||||||
|
op.alter_column('journal', 'ip', existing_type=sa.String(45), nullable=True)
|
||||||
|
|
||||||
|
# Drop the foreign key constraint before dropping the column
|
||||||
|
if 'ppr_id' in columns:
|
||||||
|
op.drop_constraint('journal_ibfk_1', 'journal', type_='foreignkey')
|
||||||
|
op.drop_column('journal', 'ppr_id')
|
||||||
|
|
||||||
|
# Add composite index for efficient queries
|
||||||
|
op.create_index('idx_entity_lookup', 'journal', ['entity_type', 'entity_id'])
|
||||||
|
|
||||||
|
# Drop old index if it exists
|
||||||
|
try:
|
||||||
|
op.drop_index('idx_ppr_id', table_name='journal')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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=True),
|
||||||
|
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('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),
|
||||||
|
sa.Column('departed_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 local_flights
|
||||||
|
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_created_dt', 'local_flights', ['created_dt'])
|
||||||
|
op.create_index('idx_etd', 'local_flights', ['etd'])
|
||||||
|
op.create_index('idx_created_by', 'local_flights', ['created_by'])
|
||||||
|
|
||||||
|
# Create departures table for non-PPR departures to other airports
|
||||||
|
op.create_table('departures',
|
||||||
|
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=True),
|
||||||
|
sa.Column('callsign', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('pob', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('out_to', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('BOOKED_OUT', 'DEPARTED', 'CANCELLED', name='departuresstatus'), nullable=False, server_default='BOOKED_OUT'),
|
||||||
|
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),
|
||||||
|
sa.Column('departed_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'
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index('idx_dep_registration', 'departures', ['registration'])
|
||||||
|
op.create_index('idx_dep_out_to', 'departures', ['out_to'])
|
||||||
|
op.create_index('idx_dep_status', 'departures', ['status'])
|
||||||
|
op.create_index('idx_dep_created_dt', 'departures', ['created_dt'])
|
||||||
|
op.create_index('idx_dep_etd', 'departures', ['etd'])
|
||||||
|
op.create_index('idx_dep_created_by', 'departures', ['created_by'])
|
||||||
|
|
||||||
|
# Create arrivals table for non-PPR arrivals from elsewhere
|
||||||
|
op.create_table('arrivals',
|
||||||
|
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=True),
|
||||||
|
sa.Column('callsign', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('pob', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('in_from', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('BOOKED_IN', 'LANDED', 'CANCELLED', name='arrivalsstatus'), nullable=False, server_default='BOOKED_IN'),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.Column('eta', 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'
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index('idx_arr_registration', 'arrivals', ['registration'])
|
||||||
|
op.create_index('idx_arr_in_from', 'arrivals', ['in_from'])
|
||||||
|
op.create_index('idx_arr_status', 'arrivals', ['status'])
|
||||||
|
op.create_index('idx_arr_created_dt', 'arrivals', ['created_dt'])
|
||||||
|
op.create_index('idx_arr_eta', 'arrivals', ['eta'])
|
||||||
|
op.create_index('idx_arr_created_by', 'arrivals', ['created_by'])
|
||||||
|
|
||||||
|
# Create circuits table for tracking touch-and-go events during circuit training
|
||||||
|
op.create_table('circuits',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('local_flight_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('circuit_timestamp', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['local_flight_id'], ['local_flights.id'], ondelete='CASCADE'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
mysql_collate='utf8mb4_unicode_ci'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for circuits
|
||||||
|
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 overflights, circuits, arrivals, departures, and local_flights tables.
|
||||||
|
"""
|
||||||
|
op.drop_table('overflights')
|
||||||
|
op.drop_table('circuits')
|
||||||
|
op.drop_table('arrivals')
|
||||||
|
op.drop_table('departures')
|
||||||
|
op.drop_table('local_flights')
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
from fastapi import APIRouter
|
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, departures, arrivals, circuits, journal, overflights
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
|
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
|
||||||
api_router.include_router(pprs.router, prefix="/pprs", tags=["pprs"])
|
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"])
|
api_router.include_router(public.router, prefix="/public", tags=["public"])
|
||||||
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
|
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
|
||||||
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
|
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
|
||||||
179
backend/app/api/endpoints/arrivals.py
Normal file
179
backend/app/api/endpoints/arrivals.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
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_arrival import arrival as crud_arrival
|
||||||
|
from app.schemas.arrival import Arrival, ArrivalCreate, ArrivalUpdate, ArrivalStatus, ArrivalStatusUpdate
|
||||||
|
from app.models.ppr import User
|
||||||
|
from app.core.utils import get_client_ip
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[Arrival])
|
||||||
|
async def get_arrivals(
|
||||||
|
request: Request,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status: Optional[ArrivalStatus] = 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 arrival records with optional filtering"""
|
||||||
|
arrivals = crud_arrival.get_multi(
|
||||||
|
db, skip=skip, limit=limit, status=status,
|
||||||
|
date_from=date_from, date_to=date_to
|
||||||
|
)
|
||||||
|
return arrivals
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=Arrival)
|
||||||
|
async def create_arrival(
|
||||||
|
request: Request,
|
||||||
|
arrival_in: ArrivalCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Create a new arrival record"""
|
||||||
|
arrival = crud_arrival.create(db, obj_in=arrival_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": "arrival_booked_in",
|
||||||
|
"data": {
|
||||||
|
"id": arrival.id,
|
||||||
|
"registration": arrival.registration,
|
||||||
|
"in_from": arrival.in_from,
|
||||||
|
"status": arrival.status.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return arrival
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{arrival_id}", response_model=Arrival)
|
||||||
|
async def get_arrival(
|
||||||
|
arrival_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get a specific arrival record"""
|
||||||
|
arrival = crud_arrival.get(db, arrival_id=arrival_id)
|
||||||
|
if not arrival:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Arrival record not found"
|
||||||
|
)
|
||||||
|
return arrival
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{arrival_id}", response_model=Arrival)
|
||||||
|
async def update_arrival(
|
||||||
|
request: Request,
|
||||||
|
arrival_id: int,
|
||||||
|
arrival_in: ArrivalUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Update an arrival record"""
|
||||||
|
db_arrival = crud_arrival.get(db, arrival_id=arrival_id)
|
||||||
|
if not db_arrival:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Arrival record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user IP from request
|
||||||
|
user_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
|
arrival = crud_arrival.update(
|
||||||
|
db,
|
||||||
|
db_obj=db_arrival,
|
||||||
|
obj_in=arrival_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": "arrival_updated",
|
||||||
|
"data": {
|
||||||
|
"id": arrival.id,
|
||||||
|
"registration": arrival.registration,
|
||||||
|
"status": arrival.status.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return arrival
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{arrival_id}/status", response_model=Arrival)
|
||||||
|
async def update_arrival_status(
|
||||||
|
request: Request,
|
||||||
|
arrival_id: int,
|
||||||
|
status_update: ArrivalStatusUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Update arrival status"""
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
arrival = crud_arrival.update_status(
|
||||||
|
db,
|
||||||
|
arrival_id=arrival_id,
|
||||||
|
status=status_update.status,
|
||||||
|
timestamp=status_update.timestamp,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=client_ip
|
||||||
|
)
|
||||||
|
if not arrival:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Arrival record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "arrival_status_update",
|
||||||
|
"data": {
|
||||||
|
"id": arrival.id,
|
||||||
|
"registration": arrival.registration,
|
||||||
|
"status": arrival.status.value,
|
||||||
|
"landed_dt": arrival.landed_dt.isoformat() if arrival.landed_dt else None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return arrival
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{arrival_id}", response_model=Arrival)
|
||||||
|
async def cancel_arrival(
|
||||||
|
request: Request,
|
||||||
|
arrival_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Cancel an arrival record"""
|
||||||
|
arrival = crud_arrival.cancel(db, arrival_id=arrival_id)
|
||||||
|
if not arrival:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Arrival record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "arrival_cancelled",
|
||||||
|
"data": {
|
||||||
|
"id": arrival.id,
|
||||||
|
"registration": arrival.registration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return arrival
|
||||||
@@ -7,7 +7,7 @@ from app.api.deps import get_db, get_current_admin_user, get_current_read_user
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import create_access_token
|
from app.core.security import create_access_token
|
||||||
from app.crud.crud_user import user as crud_user
|
from app.crud.crud_user import user as crud_user
|
||||||
from app.schemas.ppr import Token, UserCreate, UserUpdate, User
|
from app.schemas.ppr import Token, UserCreate, UserUpdate, User, ChangePassword
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -58,6 +58,22 @@ async def list_users(
|
|||||||
return users
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}", response_model=User)
|
||||||
|
async def get_user(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(get_current_admin_user)
|
||||||
|
):
|
||||||
|
"""Get a specific user's details (admin only)"""
|
||||||
|
user = crud_user.get(db, user_id=user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users", response_model=User)
|
@router.post("/users", response_model=User)
|
||||||
async def create_user(
|
async def create_user(
|
||||||
user_in: UserCreate,
|
user_in: UserCreate,
|
||||||
@@ -91,3 +107,21 @@ async def update_user(
|
|||||||
)
|
)
|
||||||
user = crud_user.update(db, db_obj=user, obj_in=user_in)
|
user = crud_user.update(db, db_obj=user, obj_in=user_in)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/change-password", response_model=User)
|
||||||
|
async def change_user_password(
|
||||||
|
user_id: int,
|
||||||
|
password_data: ChangePassword,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(get_current_admin_user)
|
||||||
|
):
|
||||||
|
"""Change a user's password (admin only)"""
|
||||||
|
user = crud_user.get(db, user_id=user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
user = crud_user.change_password(db, db_obj=user, new_password=password_data.password)
|
||||||
|
return user
|
||||||
108
backend/app/api/endpoints/circuits.py
Normal file
108
backend/app/api/endpoints/circuits.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from typing import List
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
|
||||||
|
from app.crud.crud_circuit import crud_circuit
|
||||||
|
from app.schemas.circuit import Circuit, CircuitCreate, CircuitUpdate
|
||||||
|
from app.models.ppr import User
|
||||||
|
from app.core.utils import get_client_ip
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[Circuit])
|
||||||
|
async def get_circuits(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get circuit records"""
|
||||||
|
circuits = crud_circuit.get_multi(db, skip=skip, limit=limit)
|
||||||
|
return circuits
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/flight/{local_flight_id}", response_model=List[Circuit])
|
||||||
|
async def get_circuits_by_flight(
|
||||||
|
local_flight_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get all circuits for a specific local flight"""
|
||||||
|
circuits = crud_circuit.get_by_local_flight(db, local_flight_id=local_flight_id)
|
||||||
|
return circuits
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=Circuit)
|
||||||
|
async def create_circuit(
|
||||||
|
request: Request,
|
||||||
|
circuit_in: CircuitCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Record a new circuit (touch and go) for a local flight"""
|
||||||
|
circuit = crud_circuit.create(db, obj_in=circuit_in)
|
||||||
|
|
||||||
|
# Send real-time update via WebSocket
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "circuit_recorded",
|
||||||
|
"data": {
|
||||||
|
"id": circuit.id,
|
||||||
|
"local_flight_id": circuit.local_flight_id,
|
||||||
|
"circuit_timestamp": circuit.circuit_timestamp.isoformat()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return circuit
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{circuit_id}", response_model=Circuit)
|
||||||
|
async def get_circuit(
|
||||||
|
circuit_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get a specific circuit record"""
|
||||||
|
circuit = crud_circuit.get(db, circuit_id=circuit_id)
|
||||||
|
if not circuit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Circuit record not found"
|
||||||
|
)
|
||||||
|
return circuit
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{circuit_id}", response_model=Circuit)
|
||||||
|
async def update_circuit(
|
||||||
|
circuit_id: int,
|
||||||
|
circuit_in: CircuitUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Update a circuit record"""
|
||||||
|
circuit = crud_circuit.get(db, circuit_id=circuit_id)
|
||||||
|
if not circuit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Circuit record not found"
|
||||||
|
)
|
||||||
|
circuit = crud_circuit.update(db, db_obj=circuit, obj_in=circuit_in)
|
||||||
|
return circuit
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{circuit_id}")
|
||||||
|
async def delete_circuit(
|
||||||
|
circuit_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Delete a circuit record"""
|
||||||
|
circuit = crud_circuit.get(db, circuit_id=circuit_id)
|
||||||
|
if not circuit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Circuit record not found"
|
||||||
|
)
|
||||||
|
crud_circuit.delete(db, circuit_id=circuit_id)
|
||||||
|
return {"detail": "Circuit record deleted"}
|
||||||
179
backend/app/api/endpoints/departures.py
Normal file
179
backend/app/api/endpoints/departures.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
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_departure import departure as crud_departure
|
||||||
|
from app.schemas.departure import Departure, DepartureCreate, DepartureUpdate, DepartureStatus, DepartureStatusUpdate
|
||||||
|
from app.models.ppr import User
|
||||||
|
from app.core.utils import get_client_ip
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[Departure])
|
||||||
|
async def get_departures(
|
||||||
|
request: Request,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status: Optional[DepartureStatus] = 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 departure records with optional filtering"""
|
||||||
|
departures = crud_departure.get_multi(
|
||||||
|
db, skip=skip, limit=limit, status=status,
|
||||||
|
date_from=date_from, date_to=date_to
|
||||||
|
)
|
||||||
|
return departures
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=Departure)
|
||||||
|
async def create_departure(
|
||||||
|
request: Request,
|
||||||
|
departure_in: DepartureCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Create a new departure record"""
|
||||||
|
departure = crud_departure.create(db, obj_in=departure_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": "departure_booked_out",
|
||||||
|
"data": {
|
||||||
|
"id": departure.id,
|
||||||
|
"registration": departure.registration,
|
||||||
|
"out_to": departure.out_to,
|
||||||
|
"status": departure.status.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return departure
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{departure_id}", response_model=Departure)
|
||||||
|
async def get_departure(
|
||||||
|
departure_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_read_user)
|
||||||
|
):
|
||||||
|
"""Get a specific departure record"""
|
||||||
|
departure = crud_departure.get(db, departure_id=departure_id)
|
||||||
|
if not departure:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Departure record not found"
|
||||||
|
)
|
||||||
|
return departure
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{departure_id}", response_model=Departure)
|
||||||
|
async def update_departure(
|
||||||
|
request: Request,
|
||||||
|
departure_id: int,
|
||||||
|
departure_in: DepartureUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Update a departure record"""
|
||||||
|
db_departure = crud_departure.get(db, departure_id=departure_id)
|
||||||
|
if not db_departure:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Departure record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user IP from request
|
||||||
|
user_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
|
departure = crud_departure.update(
|
||||||
|
db,
|
||||||
|
db_obj=db_departure,
|
||||||
|
obj_in=departure_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": "departure_updated",
|
||||||
|
"data": {
|
||||||
|
"id": departure.id,
|
||||||
|
"registration": departure.registration,
|
||||||
|
"status": departure.status.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return departure
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{departure_id}/status", response_model=Departure)
|
||||||
|
async def update_departure_status(
|
||||||
|
request: Request,
|
||||||
|
departure_id: int,
|
||||||
|
status_update: DepartureStatusUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Update departure status"""
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
departure = crud_departure.update_status(
|
||||||
|
db,
|
||||||
|
departure_id=departure_id,
|
||||||
|
status=status_update.status,
|
||||||
|
timestamp=status_update.timestamp,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=client_ip
|
||||||
|
)
|
||||||
|
if not departure:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Departure record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "departure_status_update",
|
||||||
|
"data": {
|
||||||
|
"id": departure.id,
|
||||||
|
"registration": departure.registration,
|
||||||
|
"status": departure.status.value,
|
||||||
|
"departed_dt": departure.departed_dt.isoformat() if departure.departed_dt else None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return departure
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{departure_id}", response_model=Departure)
|
||||||
|
async def cancel_departure(
|
||||||
|
request: Request,
|
||||||
|
departure_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_operator_user)
|
||||||
|
):
|
||||||
|
"""Cancel a departure record"""
|
||||||
|
departure = crud_departure.cancel(db, departure_id=departure_id)
|
||||||
|
if not departure:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Departure record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send real-time update
|
||||||
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
|
await request.app.state.connection_manager.broadcast({
|
||||||
|
"type": "departure_cancelled",
|
||||||
|
"data": {
|
||||||
|
"id": departure.id,
|
||||||
|
"registration": departure.registration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return departure
|
||||||
63
backend/app/api/endpoints/journal.py
Normal file
63
backend/app/api/endpoints/journal.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.api import deps
|
||||||
|
from app.crud.crud_journal import journal
|
||||||
|
from app.models.journal import EntityType
|
||||||
|
from app.schemas.journal import JournalEntryResponse, EntityJournalResponse
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
router = APIRouter(tags=["journal"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{entity_type}/{entity_id}", response_model=EntityJournalResponse)
|
||||||
|
async def get_entity_journal(
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: int,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Session = Depends(deps.get_db),
|
||||||
|
current_user = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get journal entries for a specific entity (PPR, LOCAL_FLIGHT, ARRIVAL, or DEPARTURE).
|
||||||
|
|
||||||
|
The journal is immutable - entries are created automatically by the backend
|
||||||
|
when changes are made. This endpoint is read-only.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- entity_type: One of 'PPR', 'LOCAL_FLIGHT', 'ARRIVAL', 'DEPARTURE'
|
||||||
|
- entity_id: The ID of the entity
|
||||||
|
- limit: Maximum number of entries to return (default 100)
|
||||||
|
"""
|
||||||
|
# Validate entity type
|
||||||
|
try:
|
||||||
|
entity = EntityType[entity_type.upper()]
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid entity_type. Must be one of: {', '.join([e.value for e in EntityType])}"
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = journal.get_entity_journal(db, entity, entity_id, limit=limit)
|
||||||
|
|
||||||
|
return EntityJournalResponse(
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
entries=entries,
|
||||||
|
total_entries=len(entries)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/{username}", response_model=List[JournalEntryResponse])
|
||||||
|
async def get_user_journal(
|
||||||
|
username: str,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Session = Depends(deps.get_db),
|
||||||
|
current_user = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all journal entries created by a specific user.
|
||||||
|
|
||||||
|
This endpoint is read-only and returns entries in reverse chronological order.
|
||||||
|
"""
|
||||||
|
entries = journal.get_user_journal(db, username, limit=limit)
|
||||||
|
return entries
|
||||||
207
backend/app/api/endpoints/local_flights.py
Normal file
207
backend/app/api/endpoints/local_flights.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user IP from request
|
||||||
|
user_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
|
flight = crud_local_flight.update(
|
||||||
|
db,
|
||||||
|
db_obj=db_flight,
|
||||||
|
obj_in=flight_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": "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.)"""
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
flight = crud_local_flight.update_status(
|
||||||
|
db,
|
||||||
|
flight_id=flight_id,
|
||||||
|
status=status_update.status,
|
||||||
|
timestamp=status_update.timestamp,
|
||||||
|
user=current_user.username,
|
||||||
|
user_ip=client_ip
|
||||||
|
)
|
||||||
|
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)
|
||||||
206
backend/app/api/endpoints/overflights.py
Normal file
206
backend/app/api/endpoints/overflights.py
Normal 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
|
||||||
@@ -3,20 +3,240 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.api.deps import get_db
|
from app.api.deps import get_db
|
||||||
from app.crud.crud_ppr import ppr as crud_ppr
|
from app.crud.crud_ppr import ppr as crud_ppr
|
||||||
|
from app.crud.crud_local_flight import local_flight as crud_local_flight
|
||||||
|
from app.crud.crud_departure import departure as crud_departure
|
||||||
|
from app.crud.crud_arrival import arrival as crud_arrival
|
||||||
from app.schemas.ppr import PPRPublic
|
from app.schemas.ppr import PPRPublic
|
||||||
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/arrivals", response_model=List[PPRPublic])
|
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)):
|
async def get_public_arrivals(db: Session = Depends(get_db)):
|
||||||
"""Get today's arrivals for public display"""
|
"""Get today's arrivals for public display (PPR and local flights)"""
|
||||||
arrivals = crud_ppr.get_arrivals_today(db)
|
arrivals = crud_ppr.get_arrivals_today(db)
|
||||||
return arrivals
|
|
||||||
|
# Convert PPR arrivals to dictionaries
|
||||||
|
arrivals_list = []
|
||||||
|
for arrival in arrivals:
|
||||||
|
arrivals_list.append({
|
||||||
|
'ac_call': arrival.ac_call,
|
||||||
|
'ac_reg': arrival.ac_reg,
|
||||||
|
'ac_type': arrival.ac_type,
|
||||||
|
'in_from': arrival.in_from,
|
||||||
|
'eta': arrival.eta,
|
||||||
|
'landed_dt': arrival.landed_dt,
|
||||||
|
'status': arrival.status.value,
|
||||||
|
'isLocalFlight': False
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add local flights with DEPARTED status that were booked out today
|
||||||
|
local_flights = crud_local_flight.get_multi(
|
||||||
|
db,
|
||||||
|
status=LocalFlightStatus.DEPARTED,
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get today's date boundaries
|
||||||
|
today = date.today()
|
||||||
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
|
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
|
||||||
|
|
||||||
|
# Convert local flights to match the PPR format for display
|
||||||
|
for flight in local_flights:
|
||||||
|
# Only include flights booked out today
|
||||||
|
if not (today_start <= flight.created_dt < today_end):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate ETA from departed_dt + duration (if both are available)
|
||||||
|
eta = flight.departed_dt
|
||||||
|
if flight.departed_dt and flight.duration:
|
||||||
|
eta = flight.departed_dt + timedelta(minutes=flight.duration)
|
||||||
|
|
||||||
|
arrivals_list.append({
|
||||||
|
'ac_call': flight.callsign or flight.registration,
|
||||||
|
'ac_reg': flight.registration,
|
||||||
|
'ac_type': flight.type,
|
||||||
|
'in_from': None,
|
||||||
|
'eta': eta,
|
||||||
|
'landed_dt': None,
|
||||||
|
'status': 'DEPARTED',
|
||||||
|
'isLocalFlight': True,
|
||||||
|
'flight_type': flight.flight_type.value
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add booked-in arrivals
|
||||||
|
booked_in_arrivals = crud_arrival.get_multi(db, limit=1000)
|
||||||
|
for arrival in booked_in_arrivals:
|
||||||
|
# Only include BOOKED_IN and LANDED arrivals
|
||||||
|
if arrival.status not in (ArrivalStatus.BOOKED_IN, ArrivalStatus.LANDED):
|
||||||
|
continue
|
||||||
|
# For BOOKED_IN, only include those created today
|
||||||
|
if arrival.status == ArrivalStatus.BOOKED_IN:
|
||||||
|
if not (today_start <= arrival.created_dt < today_end):
|
||||||
|
continue
|
||||||
|
# For LANDED, only include those landed today
|
||||||
|
elif arrival.status == ArrivalStatus.LANDED:
|
||||||
|
if not arrival.landed_dt or not (today_start <= arrival.landed_dt < today_end):
|
||||||
|
continue
|
||||||
|
|
||||||
|
arrivals_list.append({
|
||||||
|
'registration': arrival.registration,
|
||||||
|
'callsign': arrival.callsign,
|
||||||
|
'type': arrival.type,
|
||||||
|
'in_from': arrival.in_from,
|
||||||
|
'eta': arrival.eta,
|
||||||
|
'landed_dt': arrival.landed_dt,
|
||||||
|
'status': arrival.status.value,
|
||||||
|
'isBookedIn': True
|
||||||
|
})
|
||||||
|
|
||||||
|
return arrivals_list
|
||||||
|
|
||||||
|
|
||||||
@router.get("/departures", response_model=List[PPRPublic])
|
@router.get("/departures")
|
||||||
async def get_public_departures(db: Session = Depends(get_db)):
|
async def get_public_departures(db: Session = Depends(get_db)):
|
||||||
"""Get today's departures for public display"""
|
"""Get today's departures for public display (PPR, local flights, and departures to other airports)"""
|
||||||
departures = crud_ppr.get_departures_today(db)
|
departures = crud_ppr.get_departures_today(db)
|
||||||
return departures
|
|
||||||
|
# Convert PPR departures to dictionaries
|
||||||
|
departures_list = []
|
||||||
|
for departure in departures:
|
||||||
|
departures_list.append({
|
||||||
|
'ac_call': departure.ac_call,
|
||||||
|
'ac_reg': departure.ac_reg,
|
||||||
|
'ac_type': departure.ac_type,
|
||||||
|
'out_to': departure.out_to,
|
||||||
|
'etd': departure.etd,
|
||||||
|
'departed_dt': departure.departed_dt,
|
||||||
|
'status': departure.status.value,
|
||||||
|
'isLocalFlight': False,
|
||||||
|
'isDeparture': False
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add local flights with BOOKED_OUT status that were booked out today
|
||||||
|
local_flights = crud_local_flight.get_multi(
|
||||||
|
db,
|
||||||
|
status=LocalFlightStatus.BOOKED_OUT,
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get today's date boundaries
|
||||||
|
today = date.today()
|
||||||
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
|
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
|
||||||
|
|
||||||
|
# Convert local flights to match the PPR format for display
|
||||||
|
for flight in local_flights:
|
||||||
|
# Only include flights booked out today
|
||||||
|
if not (today_start <= flight.created_dt < today_end):
|
||||||
|
continue
|
||||||
|
departures_list.append({
|
||||||
|
'ac_call': flight.callsign or flight.registration,
|
||||||
|
'ac_reg': flight.registration,
|
||||||
|
'ac_type': flight.type,
|
||||||
|
'out_to': None,
|
||||||
|
'etd': flight.etd or flight.created_dt,
|
||||||
|
'departed_dt': None,
|
||||||
|
'status': 'BOOKED_OUT',
|
||||||
|
'isLocalFlight': True,
|
||||||
|
'flight_type': flight.flight_type.value,
|
||||||
|
'isDeparture': False
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add departures to other airports with BOOKED_OUT status
|
||||||
|
departures_to_airports = crud_departure.get_multi(
|
||||||
|
db,
|
||||||
|
status=DepartureStatus.BOOKED_OUT,
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get today's date boundaries
|
||||||
|
today = date.today()
|
||||||
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
|
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
|
||||||
|
|
||||||
|
# Convert departures to match the format for display
|
||||||
|
for dep in departures_to_airports:
|
||||||
|
# Only include departures booked out today
|
||||||
|
if not (today_start <= dep.created_dt < today_end):
|
||||||
|
continue
|
||||||
|
departures_list.append({
|
||||||
|
'ac_call': dep.callsign or dep.registration,
|
||||||
|
'ac_reg': dep.registration,
|
||||||
|
'ac_type': dep.type,
|
||||||
|
'out_to': dep.out_to,
|
||||||
|
'etd': dep.etd or dep.created_dt,
|
||||||
|
'departed_dt': None,
|
||||||
|
'status': 'BOOKED_OUT',
|
||||||
|
'isLocalFlight': False,
|
||||||
|
'isDeparture': True
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add departures to other airports with DEPARTED status (taken off today)
|
||||||
|
departed_to_airports = crud_departure.get_multi(
|
||||||
|
db,
|
||||||
|
status=DepartureStatus.DEPARTED,
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
for dep in departed_to_airports:
|
||||||
|
# Only include departures that departed today
|
||||||
|
if not dep.departed_dt or not (today_start <= dep.departed_dt < today_end):
|
||||||
|
continue
|
||||||
|
departures_list.append({
|
||||||
|
'ac_call': dep.callsign or dep.registration,
|
||||||
|
'ac_reg': dep.registration,
|
||||||
|
'ac_type': dep.type,
|
||||||
|
'out_to': dep.out_to,
|
||||||
|
'etd': dep.etd or dep.created_dt,
|
||||||
|
'departed_dt': dep.departed_dt,
|
||||||
|
'status': 'DEPARTED',
|
||||||
|
'isLocalFlight': False,
|
||||||
|
'isDeparture': True
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ class Settings(BaseSettings):
|
|||||||
project_name: str = "Airfield PPR API"
|
project_name: str = "Airfield PPR API"
|
||||||
base_url: str
|
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 settings (for future use)
|
||||||
redis_url: Optional[str] = None
|
redis_url: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
147
backend/app/crud/crud_arrival.py
Normal file
147
backend/app/crud/crud_arrival.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
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.arrival import Arrival, ArrivalStatus
|
||||||
|
from app.schemas.arrival import ArrivalCreate, ArrivalUpdate, ArrivalStatusUpdate
|
||||||
|
from app.models.journal import EntityType
|
||||||
|
from app.crud.crud_journal import journal
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDArrival:
|
||||||
|
def get(self, db: Session, arrival_id: int) -> Optional[Arrival]:
|
||||||
|
return db.query(Arrival).filter(Arrival.id == arrival_id).first()
|
||||||
|
|
||||||
|
def get_multi(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status: Optional[ArrivalStatus] = None,
|
||||||
|
date_from: Optional[date] = None,
|
||||||
|
date_to: Optional[date] = None
|
||||||
|
) -> List[Arrival]:
|
||||||
|
query = db.query(Arrival)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter(Arrival.status == status)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
query = query.filter(func.date(Arrival.created_dt) >= date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query = query.filter(func.date(Arrival.created_dt) <= date_to)
|
||||||
|
|
||||||
|
return query.order_by(desc(Arrival.created_dt)).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
def get_arrivals_today(self, db: Session) -> List[Arrival]:
|
||||||
|
"""Get today's arrivals (booked in or landed)"""
|
||||||
|
today = date.today()
|
||||||
|
return db.query(Arrival).filter(
|
||||||
|
and_(
|
||||||
|
func.date(Arrival.created_dt) == today,
|
||||||
|
or_(
|
||||||
|
Arrival.status == ArrivalStatus.BOOKED_IN,
|
||||||
|
Arrival.status == ArrivalStatus.LANDED
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by(Arrival.created_dt).all()
|
||||||
|
|
||||||
|
def create(self, db: Session, obj_in: ArrivalCreate, created_by: str) -> Arrival:
|
||||||
|
db_obj = Arrival(
|
||||||
|
**obj_in.dict(),
|
||||||
|
created_by=created_by,
|
||||||
|
status=ArrivalStatus.BOOKED_IN
|
||||||
|
)
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update(self, db: Session, db_obj: Arrival, obj_in: ArrivalUpdate, user: str = "system", user_ip: Optional[str] = None) -> Arrival:
|
||||||
|
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):
|
||||||
|
# Compare only the date and time, ignoring timezone
|
||||||
|
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 # Skip if datetimes are the same
|
||||||
|
|
||||||
|
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.ARRIVAL,
|
||||||
|
db_obj.id,
|
||||||
|
change,
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update_status(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
arrival_id: int,
|
||||||
|
status: ArrivalStatus,
|
||||||
|
timestamp: Optional[datetime] = None,
|
||||||
|
user: str = "system",
|
||||||
|
user_ip: Optional[str] = None
|
||||||
|
) -> Optional[Arrival]:
|
||||||
|
db_obj = self.get(db, arrival_id)
|
||||||
|
if not db_obj:
|
||||||
|
return None
|
||||||
|
|
||||||
|
old_status = db_obj.status
|
||||||
|
db_obj.status = status
|
||||||
|
|
||||||
|
if status == ArrivalStatus.LANDED and timestamp:
|
||||||
|
db_obj.landed_dt = timestamp
|
||||||
|
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log status change in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.ARRIVAL,
|
||||||
|
arrival_id,
|
||||||
|
f"Status changed from {old_status.value} to {status.value}",
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def cancel(self, db: Session, arrival_id: int) -> Optional[Arrival]:
|
||||||
|
db_obj = self.get(db, arrival_id)
|
||||||
|
if not db_obj:
|
||||||
|
return None
|
||||||
|
|
||||||
|
db_obj.status = ArrivalStatus.CANCELLED
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
|
arrival = CRUDArrival()
|
||||||
55
backend/app/crud/crud_circuit.py
Normal file
55
backend/app/crud/crud_circuit.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import desc
|
||||||
|
from datetime import datetime
|
||||||
|
from app.models.circuit import Circuit
|
||||||
|
from app.schemas.circuit import CircuitCreate, CircuitUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDCircuit:
|
||||||
|
def get(self, db: Session, circuit_id: int) -> Optional[Circuit]:
|
||||||
|
return db.query(Circuit).filter(Circuit.id == circuit_id).first()
|
||||||
|
|
||||||
|
def get_by_local_flight(self, db: Session, local_flight_id: int) -> List[Circuit]:
|
||||||
|
"""Get all circuits for a specific local flight"""
|
||||||
|
return db.query(Circuit).filter(
|
||||||
|
Circuit.local_flight_id == local_flight_id
|
||||||
|
).order_by(Circuit.circuit_timestamp).all()
|
||||||
|
|
||||||
|
def get_multi(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100
|
||||||
|
) -> List[Circuit]:
|
||||||
|
return db.query(Circuit).order_by(desc(Circuit.created_at)).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
def create(self, db: Session, obj_in: CircuitCreate) -> Circuit:
|
||||||
|
db_obj = Circuit(
|
||||||
|
local_flight_id=obj_in.local_flight_id,
|
||||||
|
circuit_timestamp=obj_in.circuit_timestamp
|
||||||
|
)
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update(self, db: Session, db_obj: Circuit, obj_in: CircuitUpdate) -> Circuit:
|
||||||
|
obj_data = obj_in.dict(exclude_unset=True)
|
||||||
|
for field, value in obj_data.items():
|
||||||
|
setattr(db_obj, field, value)
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def delete(self, db: Session, circuit_id: int) -> bool:
|
||||||
|
circuit = self.get(db, circuit_id)
|
||||||
|
if circuit:
|
||||||
|
db.delete(circuit)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
crud_circuit = CRUDCircuit()
|
||||||
147
backend/app/crud/crud_departure.py
Normal file
147
backend/app/crud/crud_departure.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
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.departure import Departure, DepartureStatus
|
||||||
|
from app.schemas.departure import DepartureCreate, DepartureUpdate, DepartureStatusUpdate
|
||||||
|
from app.models.journal import EntityType
|
||||||
|
from app.crud.crud_journal import journal
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDDeparture:
|
||||||
|
def get(self, db: Session, departure_id: int) -> Optional[Departure]:
|
||||||
|
return db.query(Departure).filter(Departure.id == departure_id).first()
|
||||||
|
|
||||||
|
def get_multi(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status: Optional[DepartureStatus] = None,
|
||||||
|
date_from: Optional[date] = None,
|
||||||
|
date_to: Optional[date] = None
|
||||||
|
) -> List[Departure]:
|
||||||
|
query = db.query(Departure)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter(Departure.status == status)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
query = query.filter(func.date(Departure.created_dt) >= date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query = query.filter(func.date(Departure.created_dt) <= date_to)
|
||||||
|
|
||||||
|
return query.order_by(desc(Departure.created_dt)).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
def get_departures_today(self, db: Session) -> List[Departure]:
|
||||||
|
"""Get today's departures (booked out or departed)"""
|
||||||
|
today = date.today()
|
||||||
|
return db.query(Departure).filter(
|
||||||
|
and_(
|
||||||
|
func.date(Departure.created_dt) == today,
|
||||||
|
or_(
|
||||||
|
Departure.status == DepartureStatus.BOOKED_OUT,
|
||||||
|
Departure.status == DepartureStatus.DEPARTED
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by(Departure.created_dt).all()
|
||||||
|
|
||||||
|
def create(self, db: Session, obj_in: DepartureCreate, created_by: str) -> Departure:
|
||||||
|
db_obj = Departure(
|
||||||
|
**obj_in.dict(),
|
||||||
|
created_by=created_by,
|
||||||
|
status=DepartureStatus.BOOKED_OUT
|
||||||
|
)
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update(self, db: Session, db_obj: Departure, obj_in: DepartureUpdate, user: str = "system", user_ip: Optional[str] = None) -> Departure:
|
||||||
|
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):
|
||||||
|
# Compare only the date and time, ignoring timezone
|
||||||
|
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 # Skip if datetimes are the same
|
||||||
|
|
||||||
|
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.DEPARTURE,
|
||||||
|
db_obj.id,
|
||||||
|
change,
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update_status(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
departure_id: int,
|
||||||
|
status: DepartureStatus,
|
||||||
|
timestamp: Optional[datetime] = None,
|
||||||
|
user: str = "system",
|
||||||
|
user_ip: Optional[str] = None
|
||||||
|
) -> Optional[Departure]:
|
||||||
|
db_obj = self.get(db, departure_id)
|
||||||
|
if not db_obj:
|
||||||
|
return None
|
||||||
|
|
||||||
|
old_status = db_obj.status
|
||||||
|
db_obj.status = status
|
||||||
|
|
||||||
|
if status == DepartureStatus.DEPARTED and timestamp:
|
||||||
|
db_obj.departed_dt = timestamp
|
||||||
|
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log status change in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.DEPARTURE,
|
||||||
|
departure_id,
|
||||||
|
f"Status changed from {old_status.value} to {status.value}",
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def cancel(self, db: Session, departure_id: int) -> Optional[Departure]:
|
||||||
|
db_obj = self.get(db, departure_id)
|
||||||
|
if not db_obj:
|
||||||
|
return None
|
||||||
|
|
||||||
|
db_obj.status = DepartureStatus.CANCELLED
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
|
departure = CRUDDeparture()
|
||||||
@@ -1,35 +1,95 @@
|
|||||||
from typing import List
|
from typing import List, Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.models.ppr import Journal
|
from app.models.journal import JournalEntry, EntityType
|
||||||
from app.schemas.ppr import JournalCreate
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class CRUDJournal:
|
class CRUDJournal:
|
||||||
def create(self, db: Session, obj_in: JournalCreate) -> Journal:
|
"""CRUD operations for the generic journal table.
|
||||||
db_obj = Journal(**obj_in.dict())
|
|
||||||
db.add(db_obj)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(db_obj)
|
|
||||||
return db_obj
|
|
||||||
|
|
||||||
def get_by_ppr_id(self, db: Session, ppr_id: int) -> List[Journal]:
|
This journal is immutable - entries can only be created (by backend) and queried.
|
||||||
return db.query(Journal).filter(Journal.ppr_id == ppr_id).order_by(Journal.entry_dt.desc()).all()
|
There are no API endpoints for creating journal entries; the backend logs changes directly.
|
||||||
|
"""
|
||||||
|
|
||||||
def log_change(
|
def log_change(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
entity_type: EntityType,
|
||||||
|
entity_id: int,
|
||||||
|
entry: str,
|
||||||
|
user: str,
|
||||||
|
ip: Optional[str] = None
|
||||||
|
) -> JournalEntry:
|
||||||
|
"""Log a change to an entity. Internal backend use only."""
|
||||||
|
journal_entry = JournalEntry(
|
||||||
|
entity_type=entity_type.value,
|
||||||
|
entity_id=entity_id,
|
||||||
|
entry=entry,
|
||||||
|
user=user,
|
||||||
|
ip=ip,
|
||||||
|
entry_dt=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(journal_entry)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(journal_entry)
|
||||||
|
return journal_entry
|
||||||
|
|
||||||
|
def get_entity_journal(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
entity_type: EntityType,
|
||||||
|
entity_id: int,
|
||||||
|
limit: int = 100
|
||||||
|
) -> List[JournalEntry]:
|
||||||
|
"""Get all journal entries for a specific entity. Read-only API endpoint."""
|
||||||
|
return db.query(JournalEntry).filter(
|
||||||
|
JournalEntry.entity_type == entity_type.value,
|
||||||
|
JournalEntry.entity_id == entity_id
|
||||||
|
).order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
def get_user_journal(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
user: str,
|
||||||
|
limit: int = 100
|
||||||
|
) -> List[JournalEntry]:
|
||||||
|
"""Get all journal entries created by a specific user."""
|
||||||
|
return db.query(JournalEntry).filter(
|
||||||
|
JournalEntry.user == user
|
||||||
|
).order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
# Convenience methods for backward compatibility with PPR journal
|
||||||
|
def log_ppr_change(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
ppr_id: int,
|
ppr_id: int,
|
||||||
entry: str,
|
entry: str,
|
||||||
user: str,
|
user: str,
|
||||||
ip: str
|
ip: Optional[str] = None
|
||||||
) -> Journal:
|
) -> JournalEntry:
|
||||||
journal_in = JournalCreate(
|
"""Log a change to a PPR (convenience method)."""
|
||||||
ppr_id=ppr_id,
|
return self.log_change(
|
||||||
|
db=db,
|
||||||
|
entity_type=EntityType.PPR,
|
||||||
|
entity_id=ppr_id,
|
||||||
entry=entry,
|
entry=entry,
|
||||||
user=user,
|
user=user,
|
||||||
ip=ip
|
ip=ip
|
||||||
)
|
)
|
||||||
return self.create(db, journal_in)
|
|
||||||
|
def get_ppr_journal(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
ppr_id: int,
|
||||||
|
limit: int = 100
|
||||||
|
) -> List[JournalEntry]:
|
||||||
|
"""Get all journal entries for a PPR (convenience method)."""
|
||||||
|
return self.get_entity_journal(
|
||||||
|
db=db,
|
||||||
|
entity_type=EntityType.PPR,
|
||||||
|
entity_id=ppr_id,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
journal = CRUDJournal()
|
journal = CRUDJournal()
|
||||||
185
backend/app/crud/crud_local_flight.py
Normal file
185
backend/app/crud/crud_local_flight.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
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
|
||||||
|
from app.models.journal import EntityType
|
||||||
|
from app.models.circuit import Circuit
|
||||||
|
from app.crud.crud_journal import journal
|
||||||
|
|
||||||
|
|
||||||
|
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.created_dt) >= date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query = query.filter(func.date(LocalFlight.created_dt) <= date_to)
|
||||||
|
|
||||||
|
return query.order_by(desc(LocalFlight.created_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.created_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.created_dt) == today,
|
||||||
|
or_(
|
||||||
|
LocalFlight.status == LocalFlightStatus.BOOKED_OUT,
|
||||||
|
LocalFlight.status == LocalFlightStatus.DEPARTED
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by(LocalFlight.created_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.created_dt) == today,
|
||||||
|
or_(
|
||||||
|
LocalFlight.status == LocalFlightStatus.BOOKED_OUT,
|
||||||
|
LocalFlight.status == LocalFlightStatus.LANDED
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by(LocalFlight.created_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, user: str = "system", user_ip: Optional[str] = None) -> LocalFlight:
|
||||||
|
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):
|
||||||
|
# Compare only the date and time, ignoring timezone
|
||||||
|
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 # Skip if datetimes are the same
|
||||||
|
|
||||||
|
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.LOCAL_FLIGHT,
|
||||||
|
db_obj.id,
|
||||||
|
change,
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
def update_status(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
flight_id: int,
|
||||||
|
status: LocalFlightStatus,
|
||||||
|
timestamp: Optional[datetime] = None,
|
||||||
|
user: str = "system",
|
||||||
|
user_ip: Optional[str] = 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)
|
||||||
|
|
||||||
|
old_status = db_obj.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.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()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log status change in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.LOCAL_FLIGHT,
|
||||||
|
flight_id,
|
||||||
|
f"Status changed from {old_status.value} to {status.value}",
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
172
backend/app/crud/crud_overflight.py
Normal file
172
backend/app/crud/crud_overflight.py
Normal 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()
|
||||||
@@ -4,6 +4,7 @@ from sqlalchemy import and_, or_, func, desc
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
import secrets
|
import secrets
|
||||||
from app.models.ppr import PPRRecord, PPRStatus
|
from app.models.ppr import PPRRecord, PPRStatus
|
||||||
|
from app.models.journal import EntityType
|
||||||
from app.schemas.ppr import PPRCreate, PPRUpdate
|
from app.schemas.ppr import PPRCreate, PPRUpdate
|
||||||
from app.crud.crud_journal import journal as crud_journal
|
from app.crud.crud_journal import journal as crud_journal
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ class CRUDPPR:
|
|||||||
# Log creation in journal
|
# Log creation in journal
|
||||||
crud_journal.log_change(
|
crud_journal.log_change(
|
||||||
db,
|
db,
|
||||||
|
EntityType.PPR,
|
||||||
db_obj.id,
|
db_obj.id,
|
||||||
f"PPR created for {db_obj.ac_reg}",
|
f"PPR created for {db_obj.ac_reg}",
|
||||||
created_by,
|
created_by,
|
||||||
@@ -98,11 +100,22 @@ class CRUDPPR:
|
|||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def update(self, db: Session, db_obj: PPRRecord, obj_in: PPRUpdate, user: str = "system", user_ip: str = "127.0.0.1") -> PPRRecord:
|
def update(self, db: Session, db_obj: PPRRecord, obj_in: PPRUpdate, user: str = "system", user_ip: str = "127.0.0.1") -> PPRRecord:
|
||||||
|
from datetime import datetime as dt
|
||||||
|
|
||||||
update_data = obj_in.dict(exclude_unset=True)
|
update_data = obj_in.dict(exclude_unset=True)
|
||||||
changes = []
|
changes = []
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
old_value = getattr(db_obj, field)
|
old_value = getattr(db_obj, field)
|
||||||
|
|
||||||
|
# Normalize datetime values for comparison (ignore timezone differences)
|
||||||
|
if isinstance(old_value, dt) and isinstance(value, dt):
|
||||||
|
# Compare only the date and time, ignoring timezone
|
||||||
|
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 # Skip if datetimes are the same
|
||||||
|
|
||||||
if old_value != value:
|
if old_value != value:
|
||||||
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
||||||
setattr(db_obj, field, value)
|
setattr(db_obj, field, value)
|
||||||
@@ -114,7 +127,7 @@ class CRUDPPR:
|
|||||||
|
|
||||||
# Log changes in journal
|
# Log changes in journal
|
||||||
for change in changes:
|
for change in changes:
|
||||||
crud_journal.log_change(db, db_obj.id, change, user, user_ip)
|
crud_journal.log_ppr_change(db, db_obj.id, change, user, user_ip)
|
||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
@@ -146,7 +159,7 @@ class CRUDPPR:
|
|||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
|
|
||||||
# Log status change in journal
|
# Log status change in journal
|
||||||
crud_journal.log_change(
|
crud_journal.log_ppr_change(
|
||||||
db,
|
db,
|
||||||
db_obj.id,
|
db_obj.id,
|
||||||
f"Status changed from {old_status.value} to {status.value}",
|
f"Status changed from {old_status.value} to {status.value}",
|
||||||
|
|||||||
@@ -50,5 +50,14 @@ class CRUDUser:
|
|||||||
# For future use if we add user status
|
# For future use if we add user status
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def change_password(self, db: Session, db_obj: User, new_password: str) -> User:
|
||||||
|
"""Change a user's password (typically used by admins to reset another user's password)"""
|
||||||
|
hashed_password = get_password_hash(new_password)
|
||||||
|
db_obj.password = hashed_password
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
user = CRUDUser()
|
user = CRUDUser()
|
||||||
@@ -8,6 +8,13 @@ import redis.asyncio as redis
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.api.api import api_router
|
from app.api.api import api_router
|
||||||
|
|
||||||
|
# Import models to ensure they're registered with SQLAlchemy
|
||||||
|
from app.models.ppr import PPRRecord, User, Airport, Aircraft
|
||||||
|
from app.models.journal import JournalEntry
|
||||||
|
from app.models.local_flight import LocalFlight
|
||||||
|
from app.models.departure import Departure
|
||||||
|
from app.models.arrival import Arrival
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
30
backend/app/models/arrival.py
Normal file
30
backend/app/models/arrival.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from enum import Enum
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class ArrivalStatus(str, Enum):
|
||||||
|
BOOKED_IN = "BOOKED_IN"
|
||||||
|
LANDED = "LANDED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class Arrival(Base):
|
||||||
|
__tablename__ = "arrivals"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
registration = Column(String(16), nullable=False, index=True)
|
||||||
|
type = Column(String(32), nullable=True)
|
||||||
|
callsign = Column(String(16), nullable=True)
|
||||||
|
pob = Column(Integer, nullable=False)
|
||||||
|
in_from = Column(String(4), nullable=False, index=True)
|
||||||
|
status = Column(SQLEnum(ArrivalStatus), default=ArrivalStatus.BOOKED_IN, nullable=False, index=True)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True)
|
||||||
|
eta = Column(DateTime, nullable=True, index=True)
|
||||||
|
landed_dt = Column(DateTime, nullable=True)
|
||||||
|
created_by = Column(String(16), nullable=True, index=True)
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
12
backend/app/models/circuit.py
Normal file
12
backend/app/models/circuit.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from sqlalchemy import Column, DateTime, BigInteger, ForeignKey
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Circuit(Base):
|
||||||
|
__tablename__ = "circuits"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
local_flight_id = Column(BigInteger, ForeignKey("local_flights.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
circuit_timestamp = Column(DateTime, nullable=False, index=True)
|
||||||
|
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||||
30
backend/app/models/departure.py
Normal file
30
backend/app/models/departure.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from enum import Enum
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class DepartureStatus(str, Enum):
|
||||||
|
BOOKED_OUT = "BOOKED_OUT"
|
||||||
|
DEPARTED = "DEPARTED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class Departure(Base):
|
||||||
|
__tablename__ = "departures"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
registration = Column(String(16), nullable=False, index=True)
|
||||||
|
type = Column(String(32), nullable=True)
|
||||||
|
callsign = Column(String(16), nullable=True)
|
||||||
|
pob = Column(Integer, nullable=False)
|
||||||
|
out_to = Column(String(64), nullable=False, index=True)
|
||||||
|
status = Column(SQLEnum(DepartureStatus), default=DepartureStatus.BOOKED_OUT, nullable=False, index=True)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
created_dt = Column(DateTime, server_default=func.now(), nullable=False, index=True)
|
||||||
|
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure
|
||||||
|
departed_dt = Column(DateTime, nullable=True) # Actual departure time
|
||||||
|
created_by = Column(String(16), nullable=True, index=True)
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
34
backend/app/models/journal.py
Normal file
34
backend/app/models/journal.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from sqlalchemy import Column, BigInteger, String, Text, DateTime, Index, func
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum as PyEnum
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EntityType(str, PyEnum):
|
||||||
|
"""Entity types that can have journal entries"""
|
||||||
|
PPR = "PPR"
|
||||||
|
LOCAL_FLIGHT = "LOCAL_FLIGHT"
|
||||||
|
ARRIVAL = "ARRIVAL"
|
||||||
|
DEPARTURE = "DEPARTURE"
|
||||||
|
OVERFLIGHT = "OVERFLIGHT"
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntry(Base):
|
||||||
|
"""
|
||||||
|
Generic journal table for tracking changes across all entity types.
|
||||||
|
Replaces the PPR-specific journal table.
|
||||||
|
"""
|
||||||
|
__tablename__ = "journal"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
entity_type = Column(String(50), nullable=False, index=True) # PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE
|
||||||
|
entity_id = Column(BigInteger, nullable=False, index=True) # ID of the entity
|
||||||
|
entry = Column(Text, nullable=False)
|
||||||
|
user = Column(String(50), nullable=False, index=True)
|
||||||
|
ip = Column(String(45), nullable=True) # Made optional for new entries
|
||||||
|
entry_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
||||||
|
|
||||||
|
# Composite index for efficient queries
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_entity_lookup', 'entity_type', 'entity_id'),
|
||||||
|
)
|
||||||
38
backend/app/models/local_flight.py
Normal file
38
backend/app/models/local_flight.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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)
|
||||||
|
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
|
||||||
|
departed_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())
|
||||||
28
backend/app/models/overflight.py
Normal file
28
backend/app/models/overflight.py
Normal 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())
|
||||||
@@ -60,17 +60,6 @@ class User(Base):
|
|||||||
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||||
|
|
||||||
|
|
||||||
class Journal(Base):
|
|
||||||
__tablename__ = "journal"
|
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
|
||||||
ppr_id = Column(BigInteger, nullable=False, index=True) # Changed to BigInteger to match submitted.id
|
|
||||||
entry = Column(Text, nullable=False)
|
|
||||||
user = Column(String(50), nullable=False, index=True)
|
|
||||||
ip = Column(String(45), nullable=False)
|
|
||||||
entry_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
|
||||||
|
|
||||||
|
|
||||||
class Airport(Base):
|
class Airport(Base):
|
||||||
__tablename__ = "airports"
|
__tablename__ = "airports"
|
||||||
|
|
||||||
|
|||||||
68
backend/app/schemas/arrival.py
Normal file
68
backend/app/schemas/arrival.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from pydantic import BaseModel, validator
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ArrivalStatus(str, Enum):
|
||||||
|
BOOKED_IN = "BOOKED_IN"
|
||||||
|
LANDED = "LANDED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class ArrivalBase(BaseModel):
|
||||||
|
registration: str
|
||||||
|
type: Optional[str] = None
|
||||||
|
callsign: Optional[str] = None
|
||||||
|
pob: int
|
||||||
|
in_from: str
|
||||||
|
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('in_from')
|
||||||
|
def validate_in_from(cls, v):
|
||||||
|
if not v or len(v.strip()) == 0:
|
||||||
|
raise ValueError('Origin airport 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 ArrivalCreate(ArrivalBase):
|
||||||
|
eta: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ArrivalUpdate(BaseModel):
|
||||||
|
registration: Optional[str] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
callsign: Optional[str] = None
|
||||||
|
pob: Optional[int] = None
|
||||||
|
in_from: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ArrivalStatusUpdate(BaseModel):
|
||||||
|
status: ArrivalStatus
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Arrival(ArrivalBase):
|
||||||
|
id: int
|
||||||
|
status: ArrivalStatus
|
||||||
|
created_dt: datetime
|
||||||
|
eta: Optional[datetime] = None
|
||||||
|
landed_dt: Optional[datetime] = None
|
||||||
|
created_by: Optional[str] = None
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
24
backend/app/schemas/circuit.py
Normal file
24
backend/app/schemas/circuit.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitBase(BaseModel):
|
||||||
|
local_flight_id: int
|
||||||
|
circuit_timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitCreate(CircuitBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitUpdate(BaseModel):
|
||||||
|
circuit_timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Circuit(CircuitBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
65
backend/app/schemas/departure.py
Normal file
65
backend/app/schemas/departure.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from pydantic import BaseModel, validator
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class DepartureStatus(str, Enum):
|
||||||
|
BOOKED_OUT = "BOOKED_OUT"
|
||||||
|
DEPARTED = "DEPARTED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class DepartureBase(BaseModel):
|
||||||
|
registration: str
|
||||||
|
type: Optional[str] = None
|
||||||
|
callsign: Optional[str] = None
|
||||||
|
pob: int
|
||||||
|
out_to: str
|
||||||
|
etd: Optional[datetime] = None # Estimated Time of Departure
|
||||||
|
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('out_to')
|
||||||
|
def validate_out_to(cls, v):
|
||||||
|
if not v or len(v.strip()) == 0:
|
||||||
|
raise ValueError('Destination airport 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 DepartureCreate(DepartureBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DepartureUpdate(BaseModel):
|
||||||
|
registration: Optional[str] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
callsign: Optional[str] = None
|
||||||
|
pob: Optional[int] = None
|
||||||
|
out_to: Optional[str] = None
|
||||||
|
etd: Optional[datetime] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DepartureStatusUpdate(BaseModel):
|
||||||
|
status: DepartureStatus
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Departure(DepartureBase):
|
||||||
|
id: int
|
||||||
|
status: DepartureStatus
|
||||||
|
created_dt: datetime
|
||||||
|
etd: Optional[datetime] = None
|
||||||
|
departed_dt: Optional[datetime] = None
|
||||||
28
backend/app/schemas/journal.py
Normal file
28
backend/app/schemas/journal.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryResponse(BaseModel):
|
||||||
|
"""Read-only schema for journal entries"""
|
||||||
|
id: int
|
||||||
|
entity_type: str # PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE
|
||||||
|
entity_id: int
|
||||||
|
entry: str
|
||||||
|
user: str
|
||||||
|
ip: Optional[str]
|
||||||
|
entry_dt: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class EntityJournalResponse(BaseModel):
|
||||||
|
"""Response containing all journal entries for an entity"""
|
||||||
|
entity_type: str
|
||||||
|
entity_id: int
|
||||||
|
entries: list[JournalEntryResponse]
|
||||||
|
total_entries: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
90
backend/app/schemas/local_flight.py
Normal file
90
backend/app/schemas/local_flight.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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: Optional[str] = None # Aircraft type - optional, can be looked up later
|
||||||
|
callsign: Optional[str] = None
|
||||||
|
pob: int
|
||||||
|
flight_type: LocalFlightType
|
||||||
|
duration: Optional[int] = 45 # Duration in minutes, default 45
|
||||||
|
etd: Optional[datetime] = None # Estimated Time of Departure
|
||||||
|
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', pre=True, always=False)
|
||||||
|
def validate_type(cls, v):
|
||||||
|
if v is None or (isinstance(v, str) and len(v.strip()) == 0):
|
||||||
|
return None
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.strip()
|
||||||
|
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 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
|
||||||
|
duration: Optional[int] = None
|
||||||
|
status: Optional[LocalFlightStatus] = None
|
||||||
|
etd: Optional[datetime] = None
|
||||||
|
departed_dt: Optional[datetime] = None
|
||||||
|
circuits: Optional[int] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFlightStatusUpdate(BaseModel):
|
||||||
|
status: LocalFlightStatus
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFlightInDBBase(LocalFlightBase):
|
||||||
|
id: int
|
||||||
|
status: LocalFlightStatus
|
||||||
|
created_dt: datetime
|
||||||
|
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
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFlight(LocalFlightInDBBase):
|
||||||
|
pass
|
||||||
107
backend/app/schemas/overflight.py
Normal file
107
backend/app/schemas/overflight.py
Normal 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
|
||||||
@@ -135,6 +135,11 @@ class UserUpdate(BaseModel):
|
|||||||
role: Optional[UserRole] = None
|
role: Optional[UserRole] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePassword(BaseModel):
|
||||||
|
"""Schema for admin-initiated password changes"""
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class UserInDBBase(UserBase):
|
class UserInDBBase(UserBase):
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
|
|||||||
@@ -125,17 +125,23 @@ elif [ $DB_STATE -eq 0 ]; then
|
|||||||
echo "Checking for pending migrations..."
|
echo "Checking for pending migrations..."
|
||||||
cd /app
|
cd /app
|
||||||
|
|
||||||
# Get current and head revisions
|
# Get current and head revisions (handle both hash and named revisions)
|
||||||
CURRENT=$(alembic current 2>/dev/null | grep -o '[a-f0-9]\{12\}' | head -1 || echo "none")
|
CURRENT=$(alembic current 2>/dev/null | tail -1 | awk '{print $NF}' || echo "none")
|
||||||
HEAD=$(alembic heads 2>/dev/null | grep -o '[a-f0-9]\{12\}' | head -1 || echo "none")
|
HEAD=$(alembic heads 2>/dev/null | tail -1 | awk '{print $NF}' || echo "none")
|
||||||
|
|
||||||
|
echo " Current: $CURRENT"
|
||||||
|
echo " Target: $HEAD"
|
||||||
|
|
||||||
if [ "$CURRENT" != "$HEAD" ] && [ "$HEAD" != "none" ]; then
|
if [ "$CURRENT" != "$HEAD" ] && [ "$HEAD" != "none" ]; then
|
||||||
echo "✓ Pending migrations detected"
|
echo "✓ Pending migrations detected"
|
||||||
echo " Current: $CURRENT"
|
|
||||||
echo " Target: $HEAD"
|
|
||||||
echo "Applying migrations..."
|
echo "Applying migrations..."
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
echo "✓ Migrations applied"
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✓ Migrations applied successfully"
|
||||||
|
else
|
||||||
|
echo "✗ Migration failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "✓ Database is up to date"
|
echo "✓ Database is up to date"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ services:
|
|||||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||||
BASE_URL: ${BASE_URL}
|
BASE_URL: ${BASE_URL}
|
||||||
REDIS_URL: ${REDIS_URL}
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
TAG: ${TAG}
|
||||||
|
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
||||||
ENVIRONMENT: production
|
ENVIRONMENT: production
|
||||||
WORKERS: "4"
|
WORKERS: "4"
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ services:
|
|||||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||||
BASE_URL: ${BASE_URL}
|
BASE_URL: ${BASE_URL}
|
||||||
REDIS_URL: ${REDIS_URL}
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
TOWER_NAME: ${TOWER_NAME}
|
||||||
|
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
||||||
|
ENVIRONMENT: ${ENVIRONMENT}
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT_EXTERNAL}:8000" # Use different port to avoid conflicts with existing system
|
- "${API_PORT_EXTERNAL}:8000" # Use different port to avoid conflicts with existing system
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
15
nginx.conf
15
nginx.conf
@@ -37,9 +37,14 @@ http {
|
|||||||
try_files $uri =404;
|
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
|
# Serve static files
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ =404;
|
||||||
# Apply X-Frame-Options to other files
|
# Apply X-Frame-Options to other files
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
}
|
}
|
||||||
@@ -63,6 +68,14 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket timeout settings (prevent connection drops)
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
|
||||||
|
# Additional WebSocket connection settings
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
|
|||||||
762
web/admin.css
Normal file
762
web/admin.css
Normal file
@@ -0,0 +1,762 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
padding-bottom: 40px; /* Make room for footer */
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
background: linear-gradient(135deg, #2c3e50, #3498db);
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background-color: #229954;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background-color: #f39c12;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning:hover {
|
||||||
|
background-color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-info {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-info:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #95a5a6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select, .filter-group input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ppr-table {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header {
|
||||||
|
background: #34495e;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header-collapsible {
|
||||||
|
background: #34495e;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header-collapsible:hover {
|
||||||
|
background: #3d5a6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-icon {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-icon.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #34495e;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
z-index: 50;
|
||||||
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #3498db;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.new { background: #e3f2fd; color: #1565c0; }
|
||||||
|
.status.confirmed { background: #e8f5e8; color: #2e7d32; }
|
||||||
|
.status.landed { background: #fff3e0; color: #ef6c00; }
|
||||||
|
.status.departed { background: #fce4ec; color: #c2185b; }
|
||||||
|
.status.canceled { background: #ffebee; color: #d32f2f; }
|
||||||
|
.status.deleted { background: #f3e5f5; color: #7b1fa2; }
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-left: 5px;
|
||||||
|
cursor: help;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-tooltip {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-tooltip .tooltip-text {
|
||||||
|
visibility: hidden;
|
||||||
|
width: 300px;
|
||||||
|
background-color: #333;
|
||||||
|
color: #fff;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-tooltip .tooltip-text::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: -5px;
|
||||||
|
margin-top: -5px;
|
||||||
|
border-width: 5px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: transparent #333 transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .notes-tooltip:hover .tooltip-text {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: white;
|
||||||
|
margin: 5% auto;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
background: #34495e;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: right;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer .btn {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input, .form-group select, .form-group textarea {
|
||||||
|
padding: 0.6rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-form .form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-form .form-group input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-error {
|
||||||
|
background-color: #ffebee;
|
||||||
|
border: 1px solid #ffcdd2;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.8rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-entries {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-entry {
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
padding-bottom: 0.8rem;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-entry:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aircraft Lookup Styles */
|
||||||
|
#aircraft-lookup-results {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 20px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-match {
|
||||||
|
padding: 0.3rem;
|
||||||
|
background-color: #e8f5e8;
|
||||||
|
border: 1px solid #c3e6c3;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-no-match {
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-searching {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Airport Lookup Styles */
|
||||||
|
#arrival-airport-lookup-results, #departure-airport-lookup-results, #local-out-to-lookup-results {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 20px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-match {
|
||||||
|
padding: 0.3rem;
|
||||||
|
background-color: #e8f5e8;
|
||||||
|
border: 1px solid #c3e6c3;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-no-match {
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-searching {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-option {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-option:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-option:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-code {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-name {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-location {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||||
|
z-index: 10000;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.error {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unified Lookup Styles */
|
||||||
|
.lookup-no-match {
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-searching {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-option {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-option:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-option-selected {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border-left: 3px solid #2196f3;
|
||||||
|
padding-left: calc(0.5rem - 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-option-selected:hover {
|
||||||
|
background-color: #bbdefb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-option:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-code {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-name {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-location {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
4028
web/admin.html
4028
web/admin.html
File diff suppressed because it is too large
Load Diff
27
web/assets/bell.svg
Normal file
27
web/assets/bell.svg
Normal 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
10
web/assets/candycane.svg
Normal 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
25
web/assets/gift.svg
Normal 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
39
web/assets/reindeer.svg
Normal 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
37
web/assets/santa.svg
Normal 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
17
web/assets/tree.svg
Normal 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 |
521
web/index.html
521
web/index.html
@@ -132,6 +132,226 @@
|
|||||||
grid-template-columns: 1fr; /* Stack columns on smaller screens */
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -191,6 +411,118 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<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;
|
let wsConnection = null;
|
||||||
|
|
||||||
// ICAO code to airport name cache
|
// ICAO code to airport name cache
|
||||||
@@ -234,9 +566,9 @@
|
|||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
console.log('WebSocket message received:', data);
|
console.log('WebSocket message received:', data);
|
||||||
|
|
||||||
// Refresh display when any PPR-related event occurs
|
// Refresh display when any PPR-related, local flight, departure, or arrival event occurs
|
||||||
if (data.type && (data.type.includes('ppr_') || data.type === 'status_update')) {
|
if (data.type && (data.type.includes('ppr_') || data.type === 'status_update' || data.type.includes('local_flight_') || data.type.includes('departure_') || data.type.includes('arrival_'))) {
|
||||||
console.log('PPR update detected, refreshing display...');
|
console.log('Flight update detected, refreshing display...');
|
||||||
loadArrivals();
|
loadArrivals();
|
||||||
loadDepartures();
|
loadDepartures();
|
||||||
}
|
}
|
||||||
@@ -302,30 +634,89 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build rows asynchronously to lookup airport names
|
// Build rows with metadata for sorting
|
||||||
const rows = await Promise.all(arrivals.map(async (arrival) => {
|
const rowsWithData = await Promise.all(arrivals.map(async (arrival) => {
|
||||||
const aircraftDisplay = `${escapeHtml(arrival.ac_reg || '')} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(arrival.ac_type || '')})</span>`;
|
const isLocal = arrival.isLocalFlight;
|
||||||
|
const isBookedIn = arrival.isBookedIn;
|
||||||
|
const isLanded = arrival.status === 'LANDED' || (arrival.status === 'DEPARTED' && arrival.landed_dt);
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
let sortKey = '';
|
||||||
|
|
||||||
|
if (isLocal) {
|
||||||
|
// Local flight
|
||||||
|
const aircraftId = arrival.ac_call || arrival.ac_reg || '';
|
||||||
|
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(arrival.ac_type || '')})</span>`;
|
||||||
|
const fromDisplay = `<i>${getFlightTypeDisplay(arrival.flight_type)}</i>`;
|
||||||
|
const time = convertToLocalTime(arrival.eta);
|
||||||
|
const timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">IN AIR</span></div>`;
|
||||||
|
|
||||||
|
html = `<tr><td>${aircraftDisplay}</td><td>${fromDisplay}</td><td>${timeDisplay}</td></tr>`;
|
||||||
|
sortKey = `0-${arrival.eta}`; // Live flights, sort by ETA
|
||||||
|
} else if (isBookedIn) {
|
||||||
|
// Booked-in arrival
|
||||||
|
const aircraftId = arrival.callsign || arrival.registration || '';
|
||||||
|
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(arrival.type || '')})</span>`;
|
||||||
const fromDisplay = await getAirportName(arrival.in_from || '');
|
const fromDisplay = await getAirportName(arrival.in_from || '');
|
||||||
|
|
||||||
// Show landed time if available, otherwise ETA
|
let timeDisplay, sortTime;
|
||||||
let timeDisplay;
|
if (arrival.status === 'LANDED' && arrival.landed_dt) {
|
||||||
|
// Show landed time if LANDED
|
||||||
|
const time = convertToLocalTime(arrival.landed_dt);
|
||||||
|
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #27ae60; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #27ae60; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">LANDED</span></div>`;
|
||||||
|
sortTime = arrival.landed_dt;
|
||||||
|
} else {
|
||||||
|
// Show ETA if BOOKED_IN
|
||||||
|
const time = convertToLocalTime(arrival.eta);
|
||||||
|
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">IN AIR</span></div>`;
|
||||||
|
sortTime = arrival.eta;
|
||||||
|
}
|
||||||
|
|
||||||
|
html = `<tr><td>${aircraftDisplay}</td><td>${escapeHtml(fromDisplay)}</td><td>${timeDisplay}</td></tr>`;
|
||||||
|
sortKey = isLanded ? `1-${sortTime}` : `0-${sortTime}`; // 0=live, 1=completed
|
||||||
|
} else {
|
||||||
|
// PPR
|
||||||
|
const aircraftId = arrival.ac_call || arrival.ac_reg || '';
|
||||||
|
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(arrival.ac_type || '')})</span>`;
|
||||||
|
const fromDisplay = await getAirportName(arrival.in_from || '');
|
||||||
|
|
||||||
|
let timeDisplay, sortTime;
|
||||||
if ((arrival.status === 'LANDED' || arrival.status === 'DEPARTED') && arrival.landed_dt) {
|
if ((arrival.status === 'LANDED' || arrival.status === 'DEPARTED') && arrival.landed_dt) {
|
||||||
const time = convertToLocalTime(arrival.landed_dt);
|
const time = convertToLocalTime(arrival.landed_dt);
|
||||||
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #27ae60; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #27ae60; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">LANDED</span></div>`;
|
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #27ae60; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #27ae60; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">LANDED</span></div>`;
|
||||||
|
sortTime = arrival.landed_dt;
|
||||||
} else {
|
} else {
|
||||||
timeDisplay = convertToLocalTime(arrival.eta);
|
timeDisplay = convertToLocalTime(arrival.eta);
|
||||||
|
sortTime = arrival.eta;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
html = `<tr><td>${aircraftDisplay}</td><td>${escapeHtml(fromDisplay)}</td><td>${timeDisplay}</td></tr>`;
|
||||||
<tr>
|
sortKey = isLanded ? `1-${sortTime}` : `0-${sortTime}`;
|
||||||
<td>${aircraftDisplay}</td>
|
}
|
||||||
<td>${escapeHtml(fromDisplay)}</td>
|
|
||||||
<td>${timeDisplay}</td>
|
return { html, sortKey, isLanded };
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
tbody.innerHTML = rows.join('');
|
// Sort: live flights first (0) by ETA ascending, then completed flights (1) by time descending
|
||||||
|
rowsWithData.sort((a, b) => {
|
||||||
|
const aParts = a.sortKey.split('-');
|
||||||
|
const bParts = b.sortKey.split('-');
|
||||||
|
const aType = parseInt(aParts[0]);
|
||||||
|
const bType = parseInt(bParts[0]);
|
||||||
|
|
||||||
|
if (aType !== bType) return aType - bType; // Live before completed
|
||||||
|
|
||||||
|
// Sort by time
|
||||||
|
if (aType === 0) {
|
||||||
|
// Live flights: ascending (earliest first)
|
||||||
|
return aParts[1].localeCompare(bParts[1]);
|
||||||
|
} else {
|
||||||
|
// Completed flights: descending (most recent first)
|
||||||
|
return bParts[1].localeCompare(aParts[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.innerHTML = rowsWithData.map(r => r.html).join('');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading arrivals:', error);
|
console.error('Error loading arrivals:', error);
|
||||||
@@ -351,30 +742,87 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build rows asynchronously to lookup airport names
|
// Build rows with metadata for sorting
|
||||||
const rows = await Promise.all(departures.map(async (departure) => {
|
const rowsWithData = await Promise.all(departures.map(async (departure) => {
|
||||||
const aircraftDisplay = `${escapeHtml(departure.ac_reg || '')} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(departure.ac_type || '')})</span>`;
|
const isLocal = departure.isLocalFlight;
|
||||||
|
const isDeparture = departure.isDeparture;
|
||||||
|
const isDeparted = departure.status === 'DEPARTED';
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
let sortKey = '';
|
||||||
|
|
||||||
|
if (isLocal) {
|
||||||
|
// Local flight
|
||||||
|
const aircraftId = departure.ac_call || departure.ac_reg || '';
|
||||||
|
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(departure.ac_type || '')})</span>`;
|
||||||
|
const toDisplay = `<i>${getFlightTypeDisplay(departure.flight_type)}</i>`;
|
||||||
|
const time = convertToLocalTime(departure.etd);
|
||||||
|
const timeDisplay = `<div>${escapeHtml(time)}</div>`;
|
||||||
|
|
||||||
|
html = `<tr><td>${aircraftDisplay}</td><td>${toDisplay}</td><td>${timeDisplay}</td></tr>`;
|
||||||
|
sortKey = `0-${departure.etd}`; // Live flights, sort by ETD
|
||||||
|
} else if (isDeparture) {
|
||||||
|
// Departure to other airport
|
||||||
|
const aircraftId = departure.ac_call || departure.ac_reg || '';
|
||||||
|
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(departure.ac_type || '')})</span>`;
|
||||||
const toDisplay = await getAirportName(departure.out_to || '');
|
const toDisplay = await getAirportName(departure.out_to || '');
|
||||||
|
|
||||||
// Show departed time if available, otherwise ETD
|
let timeDisplay, sortTime;
|
||||||
let timeDisplay;
|
|
||||||
if (departure.status === 'DEPARTED' && departure.departed_dt) {
|
if (departure.status === 'DEPARTED' && departure.departed_dt) {
|
||||||
const time = convertToLocalTime(departure.departed_dt);
|
const time = convertToLocalTime(departure.departed_dt);
|
||||||
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">DEPARTED</span></div>`;
|
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">DEPARTED</span></div>`;
|
||||||
|
sortTime = departure.departed_dt;
|
||||||
} else {
|
} else {
|
||||||
timeDisplay = convertToLocalTime(departure.etd);
|
const time = convertToLocalTime(departure.etd);
|
||||||
|
timeDisplay = `<div>${escapeHtml(time)}</div>`;
|
||||||
|
sortTime = departure.etd;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
html = `<tr><td>${aircraftDisplay}</td><td>${toDisplay}</td><td>${timeDisplay}</td></tr>`;
|
||||||
<tr>
|
sortKey = isDeparted ? `1-${sortTime}` : `0-${sortTime}`; // 0=live, 1=completed
|
||||||
<td>${aircraftDisplay}</td>
|
} else {
|
||||||
<td>${escapeHtml(toDisplay)}</td>
|
// PPR
|
||||||
<td>${timeDisplay}</td>
|
const aircraftId = departure.ac_call || departure.ac_reg || '';
|
||||||
</tr>
|
const aircraftDisplay = `${escapeHtml(aircraftId)} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(departure.ac_type || '')})</span>`;
|
||||||
`;
|
const toDisplay = await getAirportName(departure.out_to || '');
|
||||||
|
|
||||||
|
let timeDisplay, sortTime;
|
||||||
|
if (departure.status === 'DEPARTED' && departure.departed_dt) {
|
||||||
|
const time = convertToLocalTime(departure.departed_dt);
|
||||||
|
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">DEPARTED</span></div>`;
|
||||||
|
sortTime = departure.departed_dt;
|
||||||
|
} else {
|
||||||
|
timeDisplay = convertToLocalTime(departure.etd);
|
||||||
|
sortTime = departure.etd;
|
||||||
|
}
|
||||||
|
|
||||||
|
html = `<tr><td>${aircraftDisplay}</td><td>${escapeHtml(toDisplay)}</td><td>${timeDisplay}</td></tr>`;
|
||||||
|
sortKey = isDeparted ? `1-${sortTime}` : `0-${sortTime}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { html, sortKey, isDeparted };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
tbody.innerHTML = rows.join('');
|
// Sort: live flights first (0) by ETD ascending, then completed flights (1) by time descending
|
||||||
|
rowsWithData.sort((a, b) => {
|
||||||
|
const aParts = a.sortKey.split('-');
|
||||||
|
const bParts = b.sortKey.split('-');
|
||||||
|
const aType = parseInt(aParts[0]);
|
||||||
|
const bType = parseInt(bParts[0]);
|
||||||
|
|
||||||
|
if (aType !== bType) return aType - bType; // Live before completed
|
||||||
|
|
||||||
|
// Sort by time
|
||||||
|
if (aType === 0) {
|
||||||
|
// Live flights: ascending (earliest first)
|
||||||
|
return aParts[1].localeCompare(bParts[1]);
|
||||||
|
} else {
|
||||||
|
// Completed flights: descending (most recent first)
|
||||||
|
return bParts[1].localeCompare(aParts[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.innerHTML = rowsWithData.map(r => r.html).join('');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading departures:', error);
|
console.error('Error loading departures:', error);
|
||||||
@@ -389,8 +837,21 @@
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map flight type enum to friendly name
|
||||||
|
function getFlightTypeDisplay(flightType) {
|
||||||
|
const typeMap = {
|
||||||
|
'CIRCUITS': 'Circuit Traffic',
|
||||||
|
'LOCAL': 'Local Area',
|
||||||
|
'DEPARTURE': 'Departure'
|
||||||
|
};
|
||||||
|
return typeMap[flightType] || flightType;
|
||||||
|
}
|
||||||
|
|
||||||
// Load data on page load
|
// Load data on page load
|
||||||
window.addEventListener('load', function() {
|
window.addEventListener('load', function() {
|
||||||
|
// Initialize Christmas mode
|
||||||
|
initChristmasMode();
|
||||||
|
|
||||||
loadArrivals();
|
loadArrivals();
|
||||||
loadDepartures();
|
loadDepartures();
|
||||||
|
|
||||||
|
|||||||
503
web/lookups.js
Normal file
503
web/lookups.js
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
/**
|
||||||
|
* Lookup Utilities - Reusable functions for aircraft and airport lookups
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format aircraft registration based on UK rules
|
||||||
|
* - 5 alphabetic chars: add hyphen after first char (GIVYY -> G-IVYY)
|
||||||
|
* - Otherwise: just uppercase (N123AD -> N123AD)
|
||||||
|
*/
|
||||||
|
function formatAircraftRegistration(input) {
|
||||||
|
if (!input) return '';
|
||||||
|
|
||||||
|
const cleaned = input.trim().toUpperCase();
|
||||||
|
|
||||||
|
// If exactly 5 characters and all alphabetic, add hyphen
|
||||||
|
if (cleaned.length === 5 && /^[A-Z]{5}$/.test(cleaned)) {
|
||||||
|
return cleaned[0] + '-' + cleaned.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise just return uppercase version
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reusable lookup handler
|
||||||
|
* @param {string} fieldId - ID of the input field
|
||||||
|
* @param {string} resultsId - ID of the results container
|
||||||
|
* @param {function} selectCallback - Function to call when item is selected
|
||||||
|
* @param {object} options - Additional options (minLength, debounceMs, etc.)
|
||||||
|
*/
|
||||||
|
function createLookup(fieldId, resultsId, selectCallback, options = {}) {
|
||||||
|
const defaults = {
|
||||||
|
minLength: 2,
|
||||||
|
debounceMs: 300,
|
||||||
|
isAirport: false,
|
||||||
|
isAircraft: false,
|
||||||
|
maxResults: 10
|
||||||
|
};
|
||||||
|
const config = { ...defaults, ...options };
|
||||||
|
let debounceTimeout;
|
||||||
|
let currentResults = [];
|
||||||
|
let selectedIndex = -1;
|
||||||
|
let keydownHandlerAttached = false;
|
||||||
|
|
||||||
|
const lookup = {
|
||||||
|
// Main handler called by oninput
|
||||||
|
handle: (value) => {
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
selectedIndex = -1; // Reset selection on new input
|
||||||
|
|
||||||
|
if (!value || value.trim().length < config.minLength) {
|
||||||
|
lookup.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup.showSearching();
|
||||||
|
debounceTimeout = setTimeout(() => {
|
||||||
|
lookup.perform(value);
|
||||||
|
}, config.debounceMs);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Attach keyboard handler once (for airport lookups)
|
||||||
|
attachKeyboardHandler: () => {
|
||||||
|
if (config.isAirport && !keydownHandlerAttached) {
|
||||||
|
try {
|
||||||
|
const inputField = document.getElementById(fieldId);
|
||||||
|
if (inputField) {
|
||||||
|
inputField.addEventListener('keydown', (e) => lookup.handleKeydown(e));
|
||||||
|
keydownHandlerAttached = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error attaching keyboard handler:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Handle keyboard events
|
||||||
|
handleKeydown: (event) => {
|
||||||
|
if (!currentResults || currentResults.length === 0) return;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
selectedIndex = Math.min(selectedIndex + 1, currentResults.length - 1);
|
||||||
|
lookup.updateSelection();
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
selectedIndex = Math.max(selectedIndex - 1, -1);
|
||||||
|
lookup.updateSelection();
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && currentResults[selectedIndex]) {
|
||||||
|
lookup.selectResult(currentResults[selectedIndex]);
|
||||||
|
} else if (currentResults.length === 1) {
|
||||||
|
// Auto-select if only one result and Enter pressed
|
||||||
|
lookup.selectResult(currentResults[0]);
|
||||||
|
}
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
lookup.clear();
|
||||||
|
selectedIndex = -1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update visual selection
|
||||||
|
updateSelection: () => {
|
||||||
|
const resultsDiv = document.getElementById(resultsId);
|
||||||
|
if (!resultsDiv) return;
|
||||||
|
|
||||||
|
const options = resultsDiv.querySelectorAll('.lookup-option');
|
||||||
|
options.forEach((opt, idx) => {
|
||||||
|
if (idx === selectedIndex) {
|
||||||
|
opt.classList.add('lookup-option-selected');
|
||||||
|
opt.scrollIntoView({ block: 'nearest' });
|
||||||
|
} else {
|
||||||
|
opt.classList.remove('lookup-option-selected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Select a result item
|
||||||
|
selectResult: (item) => {
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (field) {
|
||||||
|
field.value = item.icao;
|
||||||
|
}
|
||||||
|
lookup.clear();
|
||||||
|
currentResults = [];
|
||||||
|
selectedIndex = -1;
|
||||||
|
if (selectCallback) selectCallback(item.icao);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Perform the lookup
|
||||||
|
perform: async (searchTerm) => {
|
||||||
|
try {
|
||||||
|
const cleanInput = searchTerm.trim();
|
||||||
|
let endpoint;
|
||||||
|
|
||||||
|
if (config.isAircraft) {
|
||||||
|
const cleaned = cleanInput.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||||
|
if (cleaned.length < config.minLength) {
|
||||||
|
lookup.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
endpoint = `/api/v1/aircraft/lookup/${cleaned}`;
|
||||||
|
} else if (config.isAirport) {
|
||||||
|
endpoint = `/api/v1/airport/lookup/${encodeURIComponent(cleanInput)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endpoint) throw new Error('Invalid lookup type');
|
||||||
|
|
||||||
|
const response = await authenticatedFetch(endpoint);
|
||||||
|
if (!response.ok) throw new Error('Lookup failed');
|
||||||
|
|
||||||
|
const results = await response.json();
|
||||||
|
lookup.display(results, cleanInput);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lookup error:', error);
|
||||||
|
lookup.showError();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Display results
|
||||||
|
display: (results, searchTerm) => {
|
||||||
|
const resultsDiv = document.getElementById(resultsId);
|
||||||
|
|
||||||
|
if (config.isAircraft) {
|
||||||
|
// Aircraft lookup: auto-populate on single match, format input on no match
|
||||||
|
if (!results || results.length === 0) {
|
||||||
|
// Format the aircraft registration and auto-populate
|
||||||
|
const formatted = formatAircraftRegistration(searchTerm);
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (field) {
|
||||||
|
field.value = formatted;
|
||||||
|
}
|
||||||
|
resultsDiv.innerHTML = ''; // Clear results, field is auto-populated
|
||||||
|
} else if (results.length === 1) {
|
||||||
|
// Single match - auto-populate
|
||||||
|
const aircraft = results[0];
|
||||||
|
resultsDiv.innerHTML = `
|
||||||
|
<div class="aircraft-match">
|
||||||
|
✓ ${aircraft.manufacturer_name || ''} ${aircraft.model || aircraft.type_code || ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Auto-populate the form fields
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (field) field.value = aircraft.registration;
|
||||||
|
|
||||||
|
// Also populate type field
|
||||||
|
let typeFieldId;
|
||||||
|
if (fieldId === 'ac_reg') {
|
||||||
|
typeFieldId = 'ac_type';
|
||||||
|
} else if (fieldId === 'local_registration') {
|
||||||
|
typeFieldId = 'local_type';
|
||||||
|
} else if (fieldId === 'book_in_registration') {
|
||||||
|
typeFieldId = 'book_in_type';
|
||||||
|
} else if (fieldId === 'overflight_registration') {
|
||||||
|
typeFieldId = 'overflight_type';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeFieldId) {
|
||||||
|
const typeField = document.getElementById(typeFieldId);
|
||||||
|
if (typeField) typeField.value = aircraft.type_code || '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple matches
|
||||||
|
resultsDiv.innerHTML = `
|
||||||
|
<div class="aircraft-no-match">
|
||||||
|
Multiple matches found (${results.length}) - please be more specific
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Airport lookup: show list of options with keyboard navigation
|
||||||
|
if (!results || results.length === 0) {
|
||||||
|
resultsDiv.innerHTML = '<div class="lookup-no-match">No matches found - will use as entered</div>';
|
||||||
|
currentResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentResults = results.slice(0, config.maxResults);
|
||||||
|
selectedIndex = -1; // Reset selection when showing new results
|
||||||
|
|
||||||
|
const matchText = currentResults.length === 1 ? 'Match found - press ENTER or click to select:' : 'Multiple matches found - use arrow keys and ENTER to select:';
|
||||||
|
|
||||||
|
let html = `<div class="lookup-no-match" style="margin-bottom: 0.5rem;">${matchText}</div><div class="lookup-list">`;
|
||||||
|
|
||||||
|
currentResults.forEach((item, idx) => {
|
||||||
|
html += `
|
||||||
|
<div class="lookup-option" onclick="lookupManager.selectItem('${resultsId}', '${fieldId}', '${item.icao}')">
|
||||||
|
<div class="lookup-code">${item.icao}</div>
|
||||||
|
<div class="lookup-name">${item.name || '-'}</div>
|
||||||
|
${item.city ? `<div class="lookup-location">${item.city}, ${item.country}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
resultsDiv.innerHTML = html;
|
||||||
|
|
||||||
|
// Attach keyboard handler (only once per lookup instance)
|
||||||
|
lookup.attachKeyboardHandler();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Show searching state
|
||||||
|
showSearching: () => {
|
||||||
|
const resultsDiv = document.getElementById(resultsId);
|
||||||
|
if (resultsDiv) {
|
||||||
|
resultsDiv.innerHTML = '<div class="lookup-searching">Searching...</div>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Show error state
|
||||||
|
showError: () => {
|
||||||
|
const resultsDiv = document.getElementById(resultsId);
|
||||||
|
if (resultsDiv) {
|
||||||
|
resultsDiv.innerHTML = '<div class="lookup-no-match">Lookup failed - will use as entered</div>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear results
|
||||||
|
clear: () => {
|
||||||
|
const resultsDiv = document.getElementById(resultsId);
|
||||||
|
if (resultsDiv) {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Set the selected value
|
||||||
|
setValue: (value) => {
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (field) {
|
||||||
|
field.value = value;
|
||||||
|
}
|
||||||
|
lookup.clear();
|
||||||
|
if (selectCallback) selectCallback(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global lookup manager for all lookups on the page
|
||||||
|
*/
|
||||||
|
const lookupManager = {
|
||||||
|
lookups: {},
|
||||||
|
|
||||||
|
// Register a lookup instance
|
||||||
|
register: (name, lookup) => {
|
||||||
|
lookupManager.lookups[name] = lookup;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Generic item selection handler
|
||||||
|
selectItem: (resultsId, fieldId, itemCode) => {
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (field) {
|
||||||
|
field.value = itemCode;
|
||||||
|
}
|
||||||
|
const resultsDiv = document.getElementById(resultsId);
|
||||||
|
if (resultsDiv) {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize all lookups when page loads
|
||||||
|
function initializeLookups() {
|
||||||
|
// Create reusable lookup instances
|
||||||
|
const arrivalAirportLookup = createLookup(
|
||||||
|
'in_from',
|
||||||
|
'arrival-airport-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAirport: true, minLength: 2 }
|
||||||
|
);
|
||||||
|
lookupManager.register('arrival-airport', arrivalAirportLookup);
|
||||||
|
|
||||||
|
const departureAirportLookup = createLookup(
|
||||||
|
'out_to',
|
||||||
|
'departure-airport-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAirport: true, minLength: 2 }
|
||||||
|
);
|
||||||
|
lookupManager.register('departure-airport', departureAirportLookup);
|
||||||
|
|
||||||
|
const localOutToLookup = createLookup(
|
||||||
|
'local_out_to',
|
||||||
|
'local-out-to-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAirport: true, minLength: 2 }
|
||||||
|
);
|
||||||
|
lookupManager.register('local-out-to', localOutToLookup);
|
||||||
|
|
||||||
|
const aircraftLookup = createLookup(
|
||||||
|
'ac_reg',
|
||||||
|
'aircraft-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAircraft: true, minLength: 4, debounceMs: 300 }
|
||||||
|
);
|
||||||
|
lookupManager.register('aircraft', aircraftLookup);
|
||||||
|
|
||||||
|
const localAircraftLookup = createLookup(
|
||||||
|
'local_registration',
|
||||||
|
'local-aircraft-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAircraft: true, minLength: 4, debounceMs: 300 }
|
||||||
|
);
|
||||||
|
lookupManager.register('local-aircraft', localAircraftLookup);
|
||||||
|
|
||||||
|
const bookInAircraftLookup = createLookup(
|
||||||
|
'book_in_registration',
|
||||||
|
'book-in-aircraft-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAircraft: true, minLength: 4, debounceMs: 300 }
|
||||||
|
);
|
||||||
|
lookupManager.register('book-in-aircraft', bookInAircraftLookup);
|
||||||
|
|
||||||
|
const bookInArrivalAirportLookup = createLookup(
|
||||||
|
'book_in_from',
|
||||||
|
'book-in-arrival-airport-lookup-results',
|
||||||
|
null,
|
||||||
|
{ isAirport: true, minLength: 2 }
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on DOM ready or immediately if already loaded
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeLookups);
|
||||||
|
} else {
|
||||||
|
initializeLookups();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience wrapper functions for backward compatibility
|
||||||
|
*/
|
||||||
|
function handleArrivalAirportLookup(value) {
|
||||||
|
const lookup = lookupManager.lookups['arrival-airport'];
|
||||||
|
if (lookup) lookup.handle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDepartureAirportLookup(value) {
|
||||||
|
const lookup = lookupManager.lookups['departure-airport'];
|
||||||
|
if (lookup) lookup.handle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLocalOutToAirportLookup(value) {
|
||||||
|
const lookup = lookupManager.lookups['local-out-to'];
|
||||||
|
if (lookup) lookup.handle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAircraftLookup(value) {
|
||||||
|
const lookup = lookupManager.lookups['aircraft'];
|
||||||
|
if (lookup) lookup.handle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLocalAircraftLookup(value) {
|
||||||
|
const lookup = lookupManager.lookups['local-aircraft'];
|
||||||
|
if (lookup) lookup.handle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearArrivalAirportLookup() {
|
||||||
|
const lookup = lookupManager.lookups['arrival-airport'];
|
||||||
|
if (lookup) lookup.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDepartureAirportLookup() {
|
||||||
|
const lookup = lookupManager.lookups['departure-airport'];
|
||||||
|
if (lookup) lookup.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLocalOutToAirportLookup() {
|
||||||
|
const lookup = lookupManager.lookups['local-out-to'];
|
||||||
|
if (lookup) lookup.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAircraftLookup() {
|
||||||
|
const lookup = lookupManager.lookups['aircraft'];
|
||||||
|
if (lookup) lookup.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLocalAircraftLookup() {
|
||||||
|
const lookup = lookupManager.lookups['local-aircraft'];
|
||||||
|
if (lookup) lookup.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectArrivalAirport(icaoCode) {
|
||||||
|
lookupManager.selectItem('arrival-airport-lookup-results', 'in_from', icaoCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDepartureAirport(icaoCode) {
|
||||||
|
lookupManager.selectItem('departure-airport-lookup-results', 'out_to', icaoCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLocalOutToAirport(icaoCode) {
|
||||||
|
lookupManager.selectItem('local-out-to-lookup-results', 'local_out_to', icaoCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLocalAircraft(registration) {
|
||||||
|
lookupManager.selectItem('local-aircraft-lookup-results', 'local_registration', registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBookInAircraftLookup(value) {
|
||||||
|
const lookup = lookupManager.lookups['book-in-aircraft'];
|
||||||
|
if (lookup) lookup.handle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBookInArrivalAirportLookup(value) {
|
||||||
|
const lookup = lookupManager.lookups['book-in-arrival-airport'];
|
||||||
|
if (lookup) lookup.handle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBookInAircraftLookup() {
|
||||||
|
const lookup = lookupManager.lookups['book-in-aircraft'];
|
||||||
|
if (lookup) lookup.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBookInArrivalAirportLookup() {
|
||||||
|
const lookup = lookupManager.lookups['book-in-arrival-airport'];
|
||||||
|
if (lookup) lookup.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBookInAircraft(registration) {
|
||||||
|
lookupManager.selectItem('book-in-aircraft-lookup-results', 'book_in_registration', registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBookInArrivalAirport(icaoCode) {
|
||||||
|
lookupManager.selectItem('book-in-arrival-airport-lookup-results', 'book_in_from', icaoCode);
|
||||||
|
}
|
||||||
@@ -384,7 +384,6 @@
|
|||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
<option value="100LL">100LL</option>
|
<option value="100LL">100LL</option>
|
||||||
<option value="JET A1">JET A1</option>
|
<option value="JET A1">JET A1</option>
|
||||||
<option value="FULL">Full Tanks</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
736
web/reports.html
736
web/reports.html
@@ -22,27 +22,37 @@
|
|||||||
color: white;
|
color: white;
|
||||||
padding: 0.5rem 2rem;
|
padding: 0.5rem 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
order: 2;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.title h1 {
|
.title h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-buttons {
|
.menu-buttons {
|
||||||
|
order: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-bar .user-info {
|
.top-bar .user-info {
|
||||||
|
order: 3;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -226,6 +236,45 @@
|
|||||||
.status.canceled { background: #ffebee; color: #d32f2f; }
|
.status.canceled { background: #ffebee; color: #d32f2f; }
|
||||||
.status.deleted { background: #f3e5f5; color: #7b1fa2; }
|
.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 {
|
.no-data {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
@@ -312,14 +361,14 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
<div class="title">
|
|
||||||
<h1>📊 PPR Reports</h1>
|
|
||||||
</div>
|
|
||||||
<div class="menu-buttons">
|
<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
|
← Back to Admin
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="title">
|
||||||
|
<h1 id="tower-title">📊 PPR Reports</h1>
|
||||||
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
Logged in as: <span id="current-user">Loading...</span> |
|
Logged in as: <span id="current-user">Loading...</span> |
|
||||||
<a href="#" onclick="logout()" style="color: white;">Logout</a>
|
<a href="#" onclick="logout()" style="color: white;">Logout</a>
|
||||||
@@ -329,18 +378,26 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Filters Section -->
|
<!-- Filters Section -->
|
||||||
<div class="filters-section">
|
<div class="filters-section">
|
||||||
<div class="filters-grid">
|
<div style="display: flex; gap: 0.5rem; align-items: flex-end; flex-wrap: wrap;">
|
||||||
<div class="filter-group">
|
<!-- Quick Filter Buttons -->
|
||||||
<label for="date-from">Date From:</label>
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
<input type="date" id="date-from">
|
<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>
|
||||||
<div class="filter-group">
|
|
||||||
<label for="date-to">Date To:</label>
|
<!-- Custom Date Range (hidden by default) -->
|
||||||
<input type="date" id="date-to">
|
<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>
|
||||||
<div class="filter-group">
|
|
||||||
<label for="status-filter">Status:</label>
|
<!-- Status Filter -->
|
||||||
<select id="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="">All Statuses</option>
|
||||||
<option value="NEW">New</option>
|
<option value="NEW">New</option>
|
||||||
<option value="CONFIRMED">Confirmed</option>
|
<option value="CONFIRMED">Confirmed</option>
|
||||||
@@ -350,23 +407,91 @@
|
|||||||
<option value="DELETED">Deleted</option>
|
<option value="DELETED">Deleted</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
|
||||||
<label for="search-input">Search:</label>
|
<!-- Search Input -->
|
||||||
<input type="text" id="search-input" placeholder="Aircraft reg, callsign, captain, or airport...">
|
<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>
|
||||||
<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
|
🔍 Search
|
||||||
</button>
|
</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
|
🗑️ Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 -->
|
<!-- Reports Table -->
|
||||||
<div class="reports-table">
|
<div class="reports-table" id="ppr-reports-section">
|
||||||
<div class="table-header">
|
<div class="table-header">
|
||||||
<div>
|
<div>
|
||||||
<strong>PPR Records</strong>
|
<strong>PPR Records</strong>
|
||||||
@@ -376,9 +501,6 @@
|
|||||||
<button class="btn btn-success" onclick="exportToCSV()">
|
<button class="btn btn-success" onclick="exportToCSV()">
|
||||||
📊 Export CSV
|
📊 Export CSV
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-success" onclick="exportToXLS()">
|
|
||||||
📋 Export XLS
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -392,7 +514,6 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Aircraft</th>
|
<th>Aircraft</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
@@ -425,6 +546,54 @@
|
|||||||
<p>No records match your current filters.</p>
|
<p>No records match your current filters.</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Success Notification -->
|
<!-- Success Notification -->
|
||||||
@@ -434,22 +603,156 @@
|
|||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let accessToken = null;
|
let accessToken = null;
|
||||||
let currentPPRs = []; // Store current results for export
|
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
|
// Initialize the page
|
||||||
async function initializePage() {
|
async function initializePage() {
|
||||||
|
loadUIConfig(); // Load UI configuration first
|
||||||
await initializeAuth();
|
await initializeAuth();
|
||||||
setupDefaultDateRange();
|
setupDefaultDateRange();
|
||||||
await loadReports();
|
await loadReports();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default date range to current month
|
// Set default date range to today
|
||||||
function setupDefaultDateRange() {
|
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 now = new Date();
|
||||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
|
|
||||||
document.getElementById('date-from').value = firstDay.toISOString().split('T')[0];
|
document.getElementById('date-from').value = firstDay.toISOString().split('T')[0];
|
||||||
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
|
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// 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
|
// Authentication management
|
||||||
@@ -469,7 +772,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No valid cached token, redirect to admin
|
// No valid cached token, redirect to admin
|
||||||
window.location.href = 'admin.html';
|
window.location.href = 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
@@ -478,13 +781,13 @@
|
|||||||
localStorage.removeItem('ppr_token_expiry');
|
localStorage.removeItem('ppr_token_expiry');
|
||||||
accessToken = null;
|
accessToken = null;
|
||||||
currentUser = null;
|
currentUser = null;
|
||||||
window.location.href = 'admin.html';
|
window.location.href = 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced fetch wrapper with token expiry handling
|
// Enhanced fetch wrapper with token expiry handling
|
||||||
async function authenticatedFetch(url, options = {}) {
|
async function authenticatedFetch(url, options = {}) {
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
window.location.href = 'admin.html';
|
window.location.href = 'admin';
|
||||||
throw new Error('No access token available');
|
throw new Error('No access token available');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +814,9 @@
|
|||||||
document.getElementById('reports-loading').style.display = 'block';
|
document.getElementById('reports-loading').style.display = 'block';
|
||||||
document.getElementById('reports-table-content').style.display = 'none';
|
document.getElementById('reports-table-content').style.display = 'none';
|
||||||
document.getElementById('reports-no-data').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 {
|
try {
|
||||||
const dateFrom = document.getElementById('date-from').value;
|
const dateFrom = document.getElementById('date-from').value;
|
||||||
@@ -524,13 +830,20 @@
|
|||||||
if (dateTo) url += `&date_to=${dateTo}`;
|
if (dateTo) url += `&date_to=${dateTo}`;
|
||||||
if (status) url += `&status=${status}`;
|
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');
|
throw new Error('Failed to fetch PPR records');
|
||||||
}
|
}
|
||||||
|
|
||||||
let pprs = await response.json();
|
let pprs = await pprResponse.json();
|
||||||
|
|
||||||
// Apply client-side search filtering
|
// Apply client-side search filtering
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -546,6 +859,78 @@
|
|||||||
|
|
||||||
currentPPRs = pprs; // Store for export
|
currentPPRs = pprs; // Store for export
|
||||||
displayReports(pprs);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error loading reports:', error);
|
console.error('Error loading reports:', error);
|
||||||
if (error.message !== 'Session expired. Please log in again.') {
|
if (error.message !== 'Session expired. Please log in again.') {
|
||||||
@@ -554,6 +939,94 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('reports-loading').style.display = 'none';
|
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
|
// Display reports in table
|
||||||
@@ -568,6 +1041,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by ETA (ascending)
|
||||||
|
pprs.sort((a, b) => {
|
||||||
|
if (!a.eta) return 1;
|
||||||
|
if (!b.eta) return -1;
|
||||||
|
return new Date(a.eta) - new Date(b.eta);
|
||||||
|
});
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
document.getElementById('reports-table-content').style.display = 'block';
|
document.getElementById('reports-table-content').style.display = 'block';
|
||||||
|
|
||||||
@@ -586,7 +1066,6 @@
|
|||||||
const statusText = ppr.status.charAt(0).toUpperCase() + ppr.status.slice(1).toLowerCase();
|
const statusText = ppr.status.charAt(0).toUpperCase() + ppr.status.slice(1).toLowerCase();
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td>${ppr.id}</td>
|
|
||||||
<td><span class="${statusClass}">${statusText}</span></td>
|
<td><span class="${statusClass}">${statusText}</span></td>
|
||||||
<td>${ppr.ac_reg}</td>
|
<td>${ppr.ac_reg}</td>
|
||||||
<td>${ppr.ac_type}</td>
|
<td>${ppr.ac_type}</td>
|
||||||
@@ -612,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) {
|
function formatDateTime(dateStr) {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
let utcDateStr = dateStr;
|
let utcDateStr = dateStr;
|
||||||
@@ -622,7 +1251,15 @@
|
|||||||
utcDateStr += 'Z';
|
utcDateStr += 'Z';
|
||||||
}
|
}
|
||||||
const date = new Date(utcDateStr);
|
const date = new Date(utcDateStr);
|
||||||
return date.toISOString().slice(0, 16).replace('T', ' ');
|
|
||||||
|
// Format as dd/mm/yy hh:mm
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const year = String(date.getFullYear()).slice(-2);
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear filters
|
// Clear filters
|
||||||
@@ -672,16 +1309,33 @@
|
|||||||
downloadCSV(headers, csvData, 'ppr_reports.csv');
|
downloadCSV(headers, csvData, 'ppr_reports.csv');
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportToXLS() {
|
function exportOtherFlightsToCSV() {
|
||||||
if (currentPPRs.length === 0) {
|
if (currentOtherFlights.length === 0) {
|
||||||
showNotification('No data to export', true);
|
showNotification('No data to export', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For XLS export, we'll create a CSV that Excel can open
|
const headers = [
|
||||||
// In a production environment, you'd want to use a proper XLS library
|
'Flight Type', 'Aircraft Registration', 'Aircraft Type', 'Callsign', 'From', 'To',
|
||||||
exportToCSV();
|
'ETA/ETD', 'Landed/Departed', 'Status', 'Circuits'
|
||||||
showNotification('XLS export uses CSV format (compatible with Excel)');
|
];
|
||||||
|
|
||||||
|
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) {
|
function downloadCSV(headers, data, filename) {
|
||||||
|
|||||||
Reference in New Issue
Block a user