PPR ACK and Bulk Logging start

This commit is contained in:
2026-06-15 15:45:58 -04:00
parent 7b2de645db
commit 1952b89ecf
14 changed files with 1710 additions and 19 deletions
@@ -0,0 +1,24 @@
"""Add PPR paper strip acknowledgement fields
Revision ID: 008_ppr_strip_acknowledgement
Revises: 007_ppr_activated_status
Create Date: 2026-06-15 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = '008_ppr_strip_acknowledgement'
down_revision = '007_ppr_activated_status'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('submitted', sa.Column('acknowledged_dt', sa.DateTime(), nullable=True))
op.add_column('submitted', sa.Column('acknowledged_by', sa.String(length=50), nullable=True))
def downgrade() -> None:
op.drop_column('submitted', 'acknowledged_by')
op.drop_column('submitted', 'acknowledged_dt')
+592 -5
View File
@@ -1,12 +1,22 @@
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import date from sqlalchemy import func, or_
from app.api.deps import get_db, get_current_read_user from datetime import date, datetime, time
from app.api.deps import get_db, get_current_operator_user, get_current_read_user
from app.crud.crud_movement import movement as crud_movement from app.crud.crud_movement import movement as crud_movement
from app.schemas.movement import Movement from app.crud.crud_journal import journal as crud_journal
from app.schemas.movement import BulkMovementContext, BulkMovementLog, BulkMovementResult, Movement, MovementCreate
from app.models.ppr import User from app.models.ppr import User
from app.models.movement import MovementType from app.models.arrival import Arrival, ArrivalStatus, SubmissionSource as ArrivalSubmissionSource
from app.models.circuit import Circuit
from app.models.departure import Departure, DepartureStatus, SubmissionSource as DepartureSubmissionSource
from app.models.journal import EntityType
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType, SubmissionSource as LocalSubmissionSource
from app.models.movement import Movement as MovementModel, MovementType
from app.models.overflight import Overflight, OverflightStatus
from app.models.ppr import PPRRecord, PPRStatus
from app.core.utils import get_client_ip
router = APIRouter() router = APIRouter()
@@ -32,6 +42,583 @@ async def get_movements(
return movements return movements
def _clean_reg(registration: str) -> str:
return (registration or "").strip().upper()
def _clean_alnum(value: str) -> str:
return "".join(char for char in (value or "").upper() if char.isalnum())
def _sql_clean_alnum(column):
return func.upper(func.replace(func.replace(column, "-", ""), " ", ""))
def _combine_date_time(movement_date: date, movement_time: str) -> datetime:
if not movement_time:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A time is required for this strip"
)
try:
parsed_time = time.fromisoformat(movement_time)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="movement_time must be HH:MM or HH:MM:SS"
)
return datetime.combine(movement_date, parsed_time)
def _kind_to_movement_type(flight_kind: str) -> MovementType:
kind = (flight_kind or "").strip().upper()
if kind == "ARRIVAL":
return MovementType.LANDING
if kind == "DEPARTURE":
return MovementType.TAKEOFF
if kind == "LOCAL":
return MovementType.TAKEOFF
if kind == "OVERFLIGHT":
return MovementType.OVERFLIGHT
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="flight_kind must be ARRIVAL, DEPARTURE, LOCAL or OVERFLIGHT"
)
def _strip_entity_type(flight_kind: str) -> Optional[str]:
kind = (flight_kind or "").strip().upper()
return {
"LOCAL": "LOCAL_FLIGHT",
"OVERFLIGHT": "OVERFLIGHT",
}.get(kind)
def _compatible_entity_types(flight_kind: str) -> List[str]:
kind = (flight_kind or "").strip().upper()
if kind == "ARRIVAL":
return ["PPR", "ARRIVAL"]
if kind == "DEPARTURE":
return ["PPR", "DEPARTURE"]
if kind == "LOCAL":
return ["LOCAL_FLIGHT"]
if kind == "OVERFLIGHT":
return ["OVERFLIGHT"]
return []
def _ppr_to_dict(ppr: PPRRecord) -> dict:
return {
"id": ppr.id,
"status": ppr.status.value,
"aircraft_registration": ppr.ac_reg,
"aircraft_type": ppr.ac_type,
"callsign": ppr.ac_call,
"from_location": ppr.in_from,
"to_location": ppr.out_to,
"eta": ppr.eta.isoformat() if ppr.eta else None,
"etd": ppr.etd.isoformat() if ppr.etd else None,
"pob_in": ppr.pob_in,
"pob_out": ppr.pob_out,
"notes": ppr.notes,
}
def _movement_to_dict(movement: MovementModel) -> dict:
return {
"id": movement.id,
"movement_type": movement.movement_type.value,
"aircraft_registration": movement.aircraft_registration,
"aircraft_type": movement.aircraft_type,
"callsign": movement.callsign,
"timestamp": movement.timestamp.isoformat() if movement.timestamp else None,
"entity_type": movement.entity_type,
"entity_id": movement.entity_id,
"from_location": movement.from_location,
"to_location": movement.to_location,
"runway": movement.runway,
"wind": movement.wind,
"pressure_setting": movement.pressure_setting,
"notes": movement.notes,
}
def _build_suggestion(pprs: List[PPRRecord], movements: List[MovementModel], flight_kind: str) -> dict:
if movements:
movement = movements[0]
return {
"source": "movement",
"movement_id": movement.id,
"aircraft_registration": movement.aircraft_registration,
"aircraft_type": movement.aircraft_type,
"callsign": movement.callsign,
"movement_time": movement.timestamp.strftime("%H:%M") if movement.timestamp else None,
"from_location": movement.from_location,
"to_location": movement.to_location,
"runway": movement.runway,
"wind": movement.wind,
"pressure_setting": movement.pressure_setting,
"notes": movement.notes,
}
if pprs:
ppr = pprs[0]
is_arrival = flight_kind.upper() == "ARRIVAL"
timestamp = ppr.eta if is_arrival else ppr.etd
return {
"source": "ppr",
"ppr_id": ppr.id,
"aircraft_registration": ppr.ac_reg,
"aircraft_type": ppr.ac_type,
"callsign": ppr.ac_call,
"movement_time": timestamp.strftime("%H:%M") if timestamp else None,
"from_location": ppr.in_from,
"to_location": ppr.out_to,
"pob": ppr.pob_in if is_arrival else (ppr.pob_out or ppr.pob_in),
"notes": ppr.notes,
}
return {}
def _movement_for_entity(
db: Session,
entity_type: str,
entity_id: int,
movement_type: MovementType
) -> Optional[MovementModel]:
return db.query(MovementModel).filter(
MovementModel.entity_type == entity_type,
MovementModel.entity_id == entity_id,
MovementModel.movement_type == movement_type
).order_by(MovementModel.timestamp).first()
def _create_or_update_movement(db: Session, movement_data: MovementCreate) -> MovementModel:
existing = _movement_for_entity(
db,
movement_data.entity_type,
movement_data.entity_id,
movement_data.movement_type
)
if existing:
return crud_movement.update(db, existing, movement_data)
return crud_movement.create(db, movement_data)
def _clear_local_circuit_details(
db: Session,
local_flight: LocalFlight
) -> None:
db.query(Circuit).filter(Circuit.local_flight_id == local_flight.id).delete()
db.query(MovementModel).filter(
MovementModel.entity_type == "LOCAL_FLIGHT",
MovementModel.entity_id == local_flight.id,
MovementModel.movement_type == MovementType.TOUCH_AND_GO
).delete()
db.commit()
@router.get("/bulk-context", response_model=BulkMovementContext)
async def get_bulk_movement_context(
target_date: date,
aircraft_registration: str,
flight_kind: str = "ARRIVAL",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_read_user)
):
"""Find same-day PPRs and movements that may match a bulk entry strip."""
clean_reg = _clean_reg(aircraft_registration)
clean_lookup = _clean_alnum(aircraft_registration)
movement_type = _kind_to_movement_type(flight_kind)
entity_type_filter = _strip_entity_type(flight_kind)
pprs = []
if clean_lookup:
pprs = db.query(PPRRecord).filter(
_sql_clean_alnum(PPRRecord.ac_reg).like(f"{clean_lookup}%"),
or_(
func.date(PPRRecord.eta) == target_date,
func.date(PPRRecord.etd) == target_date
),
PPRRecord.status != PPRStatus.DELETED
).order_by(PPRRecord.eta).limit(10).all()
movements = []
if clean_lookup:
movements = db.query(MovementModel).filter(
func.date(MovementModel.timestamp) == target_date,
_sql_clean_alnum(MovementModel.aircraft_registration).like(f"{clean_lookup}%"),
MovementModel.movement_type == movement_type
).order_by(MovementModel.timestamp.desc()).limit(10).all()
compatible_types = _compatible_entity_types(flight_kind)
if compatible_types:
movements = [movement for movement in movements if movement.entity_type in compatible_types]
return BulkMovementContext(
pprs=[_ppr_to_dict(ppr) for ppr in pprs],
movements=[_movement_to_dict(movement) for movement in movements],
suggested=_build_suggestion(pprs, movements, flight_kind)
)
@router.post("/bulk-log", response_model=BulkMovementResult)
async def bulk_log_movement(
request: Request,
entry: BulkMovementLog,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Create or update one same-day movement from a paper strip."""
client_ip = get_client_ip(request)
username = current_user.username
clean_reg = _clean_reg(entry.aircraft_registration)
clean_lookup = _clean_alnum(entry.aircraft_registration)
if not clean_reg:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Aircraft registration is required")
movement_type = _kind_to_movement_type(entry.flight_kind)
flight_kind = entry.flight_kind.strip().upper()
primary_time = (
entry.landing_time if flight_kind == "ARRIVAL"
else entry.takeoff_time if flight_kind in ("DEPARTURE", "LOCAL")
else entry.contact_time if flight_kind == "OVERFLIGHT"
else entry.movement_time
) or entry.movement_time
timestamp = _combine_date_time(entry.movement_date, primary_time)
existing_movement = crud_movement.get(db, entry.movement_id) if entry.movement_id else None
if not existing_movement:
existing_movement = crud_movement.find_daily_match(
db,
entry.movement_date,
clean_reg,
movement_type,
entity_type=_strip_entity_type(flight_kind),
entity_types=None if _strip_entity_type(flight_kind) else _compatible_entity_types(flight_kind)
)
if existing_movement and existing_movement.entity_type not in _compatible_entity_types(flight_kind):
existing_movement = None
if flight_kind == "LOCAL":
takeoff_dt = _combine_date_time(entry.movement_date, entry.takeoff_time)
landing_dt = _combine_date_time(entry.movement_date, entry.landing_time)
local_type = LocalFlightType.CIRCUITS if (entry.local_nature or "").upper() == "CIRCUITS" else LocalFlightType.LOCAL
local = None
if existing_movement and existing_movement.entity_type == "LOCAL_FLIGHT":
local = db.query(LocalFlight).filter(LocalFlight.id == existing_movement.entity_id).first()
if not local:
local = db.query(LocalFlight).filter(
_sql_clean_alnum(LocalFlight.registration) == clean_lookup,
func.date(LocalFlight.takeoff_dt) == entry.movement_date
).first()
if not local:
local = LocalFlight(
registration=clean_reg,
type=entry.aircraft_type or "",
callsign=entry.callsign,
pob=entry.pob or 1,
flight_type=local_type,
status=LocalFlightStatus.LANDED,
duration=int((landing_dt - takeoff_dt).total_seconds() / 60) if landing_dt > takeoff_dt else None,
circuits=entry.circuits or 0,
notes=entry.notes,
etd=takeoff_dt,
departed_dt=takeoff_dt,
takeoff_dt=takeoff_dt,
landed_dt=landing_dt,
created_by=username,
submitted_via=LocalSubmissionSource.ADMIN
)
db.add(local)
db.commit()
db.refresh(local)
action = "created"
crud_journal.log_change(db, EntityType.LOCAL_FLIGHT, local.id, "Local strip created from bulk flight log", username, client_ip)
else:
local.registration = clean_reg
local.type = entry.aircraft_type or local.type
local.callsign = entry.callsign
local.pob = entry.pob or local.pob
local.flight_type = local_type
local.status = LocalFlightStatus.LANDED
local.duration = int((landing_dt - takeoff_dt).total_seconds() / 60) if landing_dt > takeoff_dt else local.duration
local.circuits = entry.circuits or 0
local.notes = entry.notes
local.etd = takeoff_dt
local.departed_dt = takeoff_dt
local.takeoff_dt = takeoff_dt
local.landed_dt = landing_dt
db.add(local)
db.commit()
db.refresh(local)
action = "updated"
takeoff_movement = _create_or_update_movement(db, MovementCreate(
movement_type=MovementType.TAKEOFF,
aircraft_registration=clean_reg,
aircraft_type=local.type,
callsign=local.callsign,
timestamp=takeoff_dt,
entity_type="LOCAL_FLIGHT",
entity_id=local.id,
runway=entry.runway,
wind=entry.wind,
pressure_setting=entry.pressure_setting,
created_by=username,
ip_address=client_ip,
notes=entry.notes
))
_create_or_update_movement(db, MovementCreate(
movement_type=MovementType.LANDING,
aircraft_registration=clean_reg,
aircraft_type=local.type,
callsign=local.callsign,
timestamp=landing_dt,
entity_type="LOCAL_FLIGHT",
entity_id=local.id,
runway=entry.runway,
wind=entry.wind,
pressure_setting=entry.pressure_setting,
created_by=username,
ip_address=client_ip,
notes=entry.notes
))
_clear_local_circuit_details(db, local)
crud_journal.log_change(db, EntityType.LOCAL_FLIGHT, local.id, f"Bulk local strip {action}: takeoff {takeoff_dt.strftime('%H:%M')}, landing {landing_dt.strftime('%H:%M')}, circuits {entry.circuits or 0}", username, client_ip)
return BulkMovementResult(action=action, movement=takeoff_movement, entity_type="LOCAL_FLIGHT", entity_id=local.id, message=f"Local strip {action} for {clean_reg}")
if flight_kind == "OVERFLIGHT":
contact_dt = _combine_date_time(entry.movement_date, entry.contact_time)
qsy_dt = _combine_date_time(entry.movement_date, entry.qsy_time) if entry.qsy_time else None
overflight = None
if existing_movement and existing_movement.entity_type == "OVERFLIGHT":
overflight = db.query(Overflight).filter(Overflight.id == existing_movement.entity_id).first()
if not overflight:
overflight = db.query(Overflight).filter(
_sql_clean_alnum(Overflight.registration) == clean_lookup,
func.date(Overflight.call_dt) == entry.movement_date
).first()
if not overflight:
overflight = Overflight(
registration=clean_reg,
pob=entry.pob,
type=entry.aircraft_type,
departure_airfield=entry.from_location,
destination_airfield=entry.to_location,
status=OverflightStatus.INACTIVE if qsy_dt else OverflightStatus.ACTIVE,
call_dt=contact_dt,
qsy_dt=qsy_dt,
notes=entry.notes,
created_by=username
)
db.add(overflight)
db.commit()
db.refresh(overflight)
action = "created"
crud_journal.log_change(db, EntityType.OVERFLIGHT, overflight.id, "Overflight strip created from bulk flight log", username, client_ip)
else:
overflight.registration = clean_reg
overflight.pob = entry.pob
overflight.type = entry.aircraft_type
overflight.departure_airfield = entry.from_location
overflight.destination_airfield = entry.to_location
overflight.status = OverflightStatus.INACTIVE if qsy_dt else OverflightStatus.ACTIVE
overflight.call_dt = contact_dt
overflight.qsy_dt = qsy_dt
overflight.notes = entry.notes
db.add(overflight)
db.commit()
db.refresh(overflight)
action = "updated"
movement = _create_or_update_movement(db, MovementCreate(
movement_type=MovementType.OVERFLIGHT,
aircraft_registration=clean_reg,
aircraft_type=entry.aircraft_type,
callsign=entry.callsign,
timestamp=contact_dt,
entity_type="OVERFLIGHT",
entity_id=overflight.id,
from_location=entry.from_location,
to_location=entry.to_location,
runway=entry.runway,
wind=entry.wind,
pressure_setting=entry.pressure_setting,
created_by=username,
ip_address=client_ip,
notes=entry.notes
))
crud_journal.log_change(db, EntityType.OVERFLIGHT, overflight.id, f"Bulk overflight strip {action}: contact {contact_dt.strftime('%H:%M')}" + (f", QSY {qsy_dt.strftime('%H:%M')}" if qsy_dt else ""), username, client_ip)
return BulkMovementResult(action=action, movement=movement, entity_type="OVERFLIGHT", entity_id=overflight.id, message=f"Overflight strip {action} for {clean_reg}")
ppr = None
if entry.ppr_id:
ppr = db.query(PPRRecord).filter(PPRRecord.id == entry.ppr_id).first()
if not ppr and existing_movement and existing_movement.entity_type == "PPR":
ppr = db.query(PPRRecord).filter(PPRRecord.id == existing_movement.entity_id).first()
if not ppr:
ppr = db.query(PPRRecord).filter(
_sql_clean_alnum(PPRRecord.ac_reg) == clean_lookup,
or_(
func.date(PPRRecord.eta) == entry.movement_date,
func.date(PPRRecord.etd) == entry.movement_date
),
PPRRecord.status != PPRStatus.DELETED
).order_by(PPRRecord.eta).first()
entity_type = existing_movement.entity_type if existing_movement else None
entity_id = existing_movement.entity_id if existing_movement else None
if not entity_type:
if ppr:
entity_type = "PPR"
entity_id = ppr.id
elif movement_type == MovementType.LANDING:
arrival = Arrival(
registration=clean_reg,
type=entry.aircraft_type,
callsign=entry.callsign,
pob=entry.pob or 1,
in_from=entry.from_location or "ZZZZ",
status=ArrivalStatus.LANDED,
notes=entry.notes,
eta=timestamp,
landed_dt=timestamp,
created_by=username,
submitted_via=ArrivalSubmissionSource.ADMIN
)
db.add(arrival)
db.commit()
db.refresh(arrival)
entity_type = "ARRIVAL"
entity_id = arrival.id
crud_journal.log_change(db, EntityType.ARRIVAL, arrival.id, "Arrival created from bulk flight log", username, client_ip)
else:
departure = Departure(
registration=clean_reg,
type=entry.aircraft_type,
callsign=entry.callsign,
pob=entry.pob or 1,
out_to=entry.to_location or "ZZZZ",
status=DepartureStatus.DEPARTED,
notes=entry.notes,
etd=timestamp,
takeoff_dt=timestamp,
departed_dt=timestamp,
created_by=username,
submitted_via=DepartureSubmissionSource.ADMIN
)
db.add(departure)
db.commit()
db.refresh(departure)
entity_type = "DEPARTURE"
entity_id = departure.id
crud_journal.log_change(db, EntityType.DEPARTURE, departure.id, "Departure created from bulk flight log", username, client_ip)
movement_data = MovementCreate(
movement_type=movement_type,
aircraft_registration=clean_reg,
aircraft_type=entry.aircraft_type,
callsign=entry.callsign,
timestamp=timestamp,
entity_type=entity_type,
entity_id=entity_id,
from_location=entry.from_location,
to_location=entry.to_location,
runway=entry.runway,
wind=entry.wind,
pressure_setting=entry.pressure_setting,
created_by=username,
ip_address=client_ip,
notes=entry.notes
)
if existing_movement:
movement = crud_movement.update(db, existing_movement, movement_data)
action = "updated"
else:
movement = crud_movement.create(db, movement_data)
action = "created"
if entity_type == "PPR" and ppr:
ppr.ac_type = entry.aircraft_type or ppr.ac_type
ppr.ac_call = entry.callsign or ppr.ac_call
if movement_type == MovementType.LANDING:
ppr.in_from = entry.from_location or ppr.in_from
ppr.pob_in = entry.pob or ppr.pob_in
ppr.landed_dt = timestamp
if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED, PPRStatus.DEPARTED):
ppr.status = PPRStatus.LANDED
else:
ppr.out_to = entry.to_location or ppr.out_to
ppr.pob_out = entry.pob or ppr.pob_out
ppr.departed_dt = timestamp
if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED):
ppr.status = PPRStatus.DEPARTED
if entry.notes:
ppr.notes = entry.notes
db.add(ppr)
db.commit()
crud_journal.log_change(
db,
EntityType.PPR,
ppr.id,
f"Bulk flight log {action}: {movement_type.value} at {timestamp.strftime('%Y-%m-%d %H:%M')}",
username,
client_ip
)
elif entity_type == "ARRIVAL":
arrival = db.query(Arrival).filter(Arrival.id == entity_id).first()
if arrival:
arrival.registration = clean_reg
arrival.type = entry.aircraft_type
arrival.callsign = entry.callsign
arrival.pob = entry.pob or arrival.pob
arrival.in_from = entry.from_location or arrival.in_from
arrival.eta = timestamp
arrival.landed_dt = timestamp
arrival.status = ArrivalStatus.LANDED
arrival.notes = entry.notes
db.add(arrival)
db.commit()
crud_journal.log_change(db, EntityType.ARRIVAL, arrival.id, f"Bulk flight log {action}: landing at {timestamp.strftime('%Y-%m-%d %H:%M')}", username, client_ip)
elif entity_type == "DEPARTURE":
departure = db.query(Departure).filter(Departure.id == entity_id).first()
if departure:
departure.registration = clean_reg
departure.type = entry.aircraft_type
departure.callsign = entry.callsign
departure.pob = entry.pob or departure.pob
departure.out_to = entry.to_location or departure.out_to
departure.etd = timestamp
departure.takeoff_dt = timestamp
departure.departed_dt = timestamp
departure.status = DepartureStatus.DEPARTED
departure.notes = entry.notes
db.add(departure)
db.commit()
crud_journal.log_change(db, EntityType.DEPARTURE, departure.id, f"Bulk flight log {action}: takeoff at {timestamp.strftime('%Y-%m-%d %H:%M')}", username, client_ip)
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "movement_bulk_logged",
"data": {
"id": movement.id,
"aircraft_registration": movement.aircraft_registration,
"movement_type": movement.movement_type.value,
"action": action
}
})
return BulkMovementResult(
action=action,
movement=movement,
entity_type=entity_type,
entity_id=entity_id,
message=f"Movement {action} for {clean_reg}"
)
@router.get("/{movement_id}", response_model=Movement) @router.get("/{movement_id}", response_model=Movement)
async def get_movement( async def get_movement(
movement_id: int, movement_id: int,
+35
View File
@@ -239,6 +239,41 @@ async def update_ppr_status(
return ppr return ppr
@router.post("/{ppr_id}/acknowledge", response_model=PPR)
async def acknowledge_ppr_strip(
request: Request,
ppr_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_operator_user)
):
"""Acknowledge that the operator has created the paper strip for a PPR."""
client_ip = get_client_ip(request)
ppr = crud_ppr.acknowledge_strip(
db,
ppr_id=ppr_id,
user=current_user.username,
user_ip=client_ip
)
if not ppr:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="PPR record not found"
)
if hasattr(request.app.state, 'connection_manager'):
await request.app.state.connection_manager.broadcast({
"type": "ppr_acknowledged",
"data": {
"id": ppr.id,
"ac_reg": ppr.ac_reg,
"acknowledged_dt": ppr.acknowledged_dt.isoformat() if ppr.acknowledged_dt else None,
"acknowledged_by": ppr.acknowledged_by
}
})
return ppr
@router.delete("/{ppr_id}", response_model=PPR) @router.delete("/{ppr_id}", response_model=PPR)
async def delete_ppr( async def delete_ppr(
request: Request, request: Request,
+31
View File
@@ -47,6 +47,37 @@ class CRUDMovement:
db.refresh(db_obj) db.refresh(db_obj)
return db_obj return db_obj
def update(self, db: Session, db_obj: Movement, obj_in: MovementCreate) -> Movement:
update_data = obj_in.dict()
for field, value in update_data.items():
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def find_daily_match(
self,
db: Session,
target_date: date,
aircraft_registration: str,
movement_type: MovementType,
entity_type: Optional[str] = None,
entity_types: Optional[List[str]] = None
) -> Optional[Movement]:
clean_reg = "".join(char for char in aircraft_registration.upper() if char.isalnum())
clean_column = func.upper(func.replace(func.replace(Movement.aircraft_registration, "-", ""), " ", ""))
query = db.query(Movement).filter(
func.date(Movement.timestamp) == target_date,
clean_column == clean_reg,
Movement.movement_type == movement_type
)
if entity_type:
query = query.filter(Movement.entity_type == entity_type)
if entity_types:
query = query.filter(Movement.entity_type.in_(entity_types))
return query.order_by(Movement.timestamp.desc()).first()
def get_movements_by_entity(self, db: Session, entity_type: str, entity_id: int) -> List[Movement]: def get_movements_by_entity(self, db: Session, entity_type: str, entity_id: int) -> List[Movement]:
return db.query(Movement).filter( return db.query(Movement).filter(
and_(Movement.entity_type == entity_type, Movement.entity_id == entity_id) and_(Movement.entity_type == entity_type, Movement.entity_id == entity_id)
+30
View File
@@ -169,6 +169,36 @@ class CRUDPPR:
return db_obj return db_obj
def acknowledge_strip(
self,
db: Session,
ppr_id: int,
user: str = "system",
user_ip: str = "127.0.0.1"
) -> Optional[PPRRecord]:
db_obj = self.get(db, ppr_id)
if not db_obj:
return None
if db_obj.acknowledged_dt:
return db_obj
db_obj.acknowledged_dt = datetime.utcnow()
db_obj.acknowledged_by = user
db.add(db_obj)
db.commit()
db.refresh(db_obj)
crud_journal.log_ppr_change(
db,
db_obj.id,
f"Paper strip acknowledged by {user}",
user,
user_ip
)
return db_obj
def delete(self, db: Session, ppr_id: int, user: str = "system", user_ip: str = "127.0.0.1") -> Optional[PPRRecord]: def delete(self, db: Session, ppr_id: int, user: str = "system", user_ip: str = "127.0.0.1") -> Optional[PPRRecord]:
db_obj = self.get(db, ppr_id) db_obj = self.get(db, ppr_id)
if db_obj: if db_obj:
+2
View File
@@ -43,6 +43,8 @@ class PPRRecord(Base):
departed_dt = Column(DateTime, nullable=True) departed_dt = Column(DateTime, nullable=True)
created_by = Column(String(16), nullable=True, index=True) created_by = Column(String(16), nullable=True, index=True)
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True) submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
acknowledged_dt = Column(DateTime, nullable=True)
acknowledged_by = Column(String(50), nullable=True)
public_token = Column(String(128), nullable=True, unique=True, index=True) public_token = Column(String(128), nullable=True, unique=True, index=True)
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())
+40 -2
View File
@@ -1,6 +1,6 @@
from typing import Optional from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime from datetime import date, datetime
from app.models.movement import MovementType from app.models.movement import MovementType
@@ -26,9 +26,47 @@ class MovementCreate(MovementBase):
pass pass
class BulkMovementLog(BaseModel):
flight_kind: str
movement_date: date
movement_time: Optional[str] = None
takeoff_time: Optional[str] = None
landing_time: Optional[str] = None
contact_time: Optional[str] = None
qsy_time: Optional[str] = None
aircraft_registration: str
aircraft_type: Optional[str] = None
callsign: Optional[str] = None
from_location: Optional[str] = None
to_location: Optional[str] = None
pob: Optional[int] = None
local_nature: Optional[str] = None
circuits: Optional[int] = None
runway: Optional[str] = None
wind: Optional[str] = None
pressure_setting: Optional[str] = None
notes: Optional[str] = None
ppr_id: Optional[int] = None
movement_id: Optional[int] = None
class BulkMovementContext(BaseModel):
pprs: List[dict]
movements: List[dict]
suggested: dict
class Movement(MovementBase): class Movement(MovementBase):
id: int id: int
created_at: datetime created_at: datetime
class Config: class Config:
from_attributes = True from_attributes = True
class BulkMovementResult(BaseModel):
action: str
movement: Movement
entity_type: str
entity_id: int
message: str
+2
View File
@@ -88,6 +88,8 @@ class PPRInDBBase(PPRBase):
departed_dt: Optional[datetime] = None departed_dt: Optional[datetime] = None
created_by: Optional[str] = None created_by: Optional[str] = None
submitted_dt: datetime submitted_dt: datetime
acknowledged_dt: Optional[datetime] = None
acknowledged_by: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
+2
View File
@@ -41,6 +41,8 @@ CREATE TABLE submitted (
departed_dt DATETIME DEFAULT NULL, departed_dt DATETIME DEFAULT NULL,
created_by VARCHAR(16) DEFAULT NULL, created_by VARCHAR(16) DEFAULT NULL,
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
acknowledged_dt DATETIME DEFAULT NULL,
acknowledged_by VARCHAR(50) DEFAULT NULL,
public_token VARCHAR(128) DEFAULT NULL UNIQUE, public_token VARCHAR(128) DEFAULT NULL UNIQUE,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+24
View File
@@ -160,6 +160,15 @@ body {
background-color: #e67e22; background-color: #e67e22;
} }
.btn-ack {
background-color: #8e44ad;
color: white;
}
.btn-ack:hover {
background-color: #71368a;
}
.btn-info { .btn-info {
background-color: #3498db; background-color: #3498db;
color: white; color: white;
@@ -330,6 +339,21 @@ tbody tr:hover {
background-color: #f8f9fa; background-color: #f8f9fa;
} }
tbody tr.ppr-strip-unacknowledged {
background-color: #fff0c2;
box-shadow: inset 4px 0 0 #f39c12;
}
tbody tr.ppr-strip-unacknowledged:hover {
background-color: #ffe6a1;
}
.ack-complete {
color: #1e7e34;
font-size: 0.75rem;
font-weight: 700;
}
.status { .status {
display: inline-block; display: inline-block;
padding: 0.3rem 0.6rem; padding: 0.3rem 0.6rem;
+41
View File
@@ -32,6 +32,7 @@
<a href="#" onclick="window.location.href = '/atc'">🎛️ ATC View</a> <a href="#" onclick="window.location.href = '/atc'">🎛️ ATC View</a>
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a> <a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
<a href="#" onclick="window.location.href = '/movements'">📈 Movements</a> <a href="#" onclick="window.location.href = '/movements'">📈 Movements</a>
<a href="#" onclick="window.location.href = '/bulk-log'">🧾 Bulk Flight Log</a>
<a href="#" onclick="window.location.href = '/journal'">📔 Journal Log</a> <a href="#" onclick="window.location.href = '/journal'">📔 Journal Log</a>
<a href="#" onclick="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a> <a href="#" onclick="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a>
<a href="#" onclick="openUserManagementModal(); closeAdminDropdown()" id="user-management-dropdown" style="display: none;">👥 User Management</a> <a href="#" onclick="openUserManagementModal(); closeAdminDropdown()" id="user-management-dropdown" style="display: none;">👥 User Management</a>
@@ -260,6 +261,7 @@
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">From</th> <th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">From</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">ETA</th> <th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">ETA</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Notes</th> <th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Notes</th>
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">ACK</th>
</tr> </tr>
</thead> </thead>
<tbody id="upcoming-table-body"> <tbody id="upcoming-table-body">
@@ -822,6 +824,9 @@
const row = document.createElement('tr'); const row = document.createElement('tr');
row.onclick = () => openPPRModal(ppr.id); row.onclick = () => openPPRModal(ppr.id);
row.style.cssText = 'font-size: 0.85rem !important;'; row.style.cssText = 'font-size: 0.85rem !important;';
if (pprNeedsStripAck(ppr)) {
row.classList.add('ppr-strip-unacknowledged');
}
// Format date as Day DD/MM (e.g., Wed 11/12) // Format date as Day DD/MM (e.g., Wed 11/12)
const etaDate = new Date(ppr.eta); const etaDate = new Date(ppr.eta);
@@ -835,6 +840,9 @@
<span class="notes-indicator">📝</span> <span class="notes-indicator">📝</span>
<span class="tooltip-text">${ppr.notes}</span> <span class="tooltip-text">${ppr.notes}</span>
</span>` : ''; </span>` : '';
const ackButton = pprNeedsStripAck(ppr)
? `<button class="btn btn-ack btn-icon" onclick='event.stopPropagation(); acknowledgePPRStrip(${ppr.id}, ${JSON.stringify(ppr.ac_reg || 'PPR')})' title="Acknowledge paper strip created">ACK</button>`
: '<span class="ack-complete" title="Paper strip acknowledged">ACK</span>';
row.innerHTML = ` row.innerHTML = `
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${dateDisplay}</td> <td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${dateDisplay}</td>
@@ -844,6 +852,7 @@
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.in_from || '-'}</td> <td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.in_from || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(ppr.eta)}</td> <td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(ppr.eta)}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${notesIndicator}</td> <td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${notesIndicator}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ackButton}</td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);
} }
@@ -917,6 +926,9 @@
const row = document.createElement('tr'); const row = document.createElement('tr');
const isLocal = flight.isLocalFlight; const isLocal = flight.isLocalFlight;
const isBookedIn = flight.isBookedIn; const isBookedIn = flight.isBookedIn;
if (!isLocal && !isBookedIn && pprNeedsStripAck(flight)) {
row.classList.add('ppr-strip-unacknowledged');
}
// Click handler that routes to correct modal // Click handler that routes to correct modal
row.onclick = () => { row.onclick = () => {
@@ -1066,7 +1078,13 @@
eta = formatTimeOnly(flight.eta); eta = formatTimeOnly(flight.eta);
pob = flight.pob_in; pob = flight.pob_in;
fuel = flight.fuel || '-'; fuel = flight.fuel || '-';
const ackButton = pprNeedsStripAck(flight)
? `<button class="btn btn-ack btn-icon" onclick='event.stopPropagation(); acknowledgePPRStrip(${flight.id}, ${JSON.stringify(flight.ac_reg || 'PPR')})' title="Acknowledge paper strip created">
ACK
</button>`
: '';
actionButtons = ` actionButtons = `
${ackButton}
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); activatePPR(${flight.id}, '${flight.ac_reg}', ${flight.out_to ? 'true' : 'false'})" title="Activate PPR - create arrival/departure records"> <button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); activatePPR(${flight.id}, '${flight.ac_reg}', ${flight.out_to ? 'true' : 'false'})" title="Activate PPR - create arrival/departure records">
ACTIVATE ACTIVATE
</button> </button>
@@ -1092,6 +1110,29 @@
setupTooltips(); setupTooltips();
} }
function pprNeedsStripAck(ppr) {
return (ppr.status === 'NEW' || ppr.status === 'CONFIRMED') && !ppr.acknowledged_dt;
}
async function acknowledgePPRStrip(pprId, acReg) {
if (!confirm(`Confirm paper strip has been created for ${acReg}?`)) return;
try {
const response = await authenticatedFetch(`/api/v1/pprs/${pprId}/acknowledge`, { method: 'POST' });
if (!response.ok) {
const err = await response.json().catch(() => ({}));
showNotification(err.detail || 'Failed to acknowledge PPR strip', true);
return;
}
showNotification('PPR strip acknowledged');
await loadPPRs();
} catch (error) {
console.error('Error acknowledging PPR strip:', error);
showNotification('Error acknowledging PPR strip', true);
}
}
async function activatePPR(pprId, acReg, hasDeparture) { async function activatePPR(pprId, acReg, hasDeparture) {
const msg = `Activate PPR for ${acReg}?\nThis will create an INBOUND arrival.` const msg = `Activate PPR for ${acReg}?\nThis will create an INBOUND arrival.`
+ (hasDeparture ? '\nThe outbound departure will appear automatically when the aircraft lands.' : ''); + (hasDeparture ? '\nThe outbound departure will appear automatically when the aircraft lands.' : '');
+1
View File
@@ -233,6 +233,7 @@
<div class="dropdown-menu" id="adminDropdownMenu"> <div class="dropdown-menu" id="adminDropdownMenu">
<a href="#" onclick="window.location.href = '/admin'">🏠 Admin View</a> <a href="#" onclick="window.location.href = '/admin'">🏠 Admin View</a>
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a> <a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
<a href="#" onclick="window.location.href = '/bulk-log'">🧾 Bulk Flight Log</a>
<a href="#" onclick="window.location.href = '/journal'">📔 Journal Log</a> <a href="#" onclick="window.location.href = '/journal'">📔 Journal Log</a>
<a href="#" onclick="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a> <a href="#" onclick="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a>
</div> </div>
+857
View File
@@ -0,0 +1,857 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bulk Flight Log - PPR System</title>
<link rel="stylesheet" href="admin.css">
<style>
.bulk-toolbar {
display: flex;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 1rem;
margin-bottom: 1rem;
}
.bulk-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.bulk-field label {
font-weight: 600;
color: #495057;
font-size: 0.85rem;
}
.bulk-field input,
.bulk-field select,
.bulk-field textarea {
border: 1px solid #d7dde3;
border-radius: 5px;
font-size: 0.95rem;
padding: 0.55rem 0.65rem;
min-width: 110px;
}
.bulk-entry {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 1rem;
margin-bottom: 1rem;
overflow-x: auto;
}
.strip-type-picker {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.strip-type-button {
border: 2px solid transparent;
border-radius: 6px;
cursor: pointer;
font-weight: 700;
padding: 0.65rem;
text-align: left;
}
.strip-type-button.active {
border-color: #222;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.55);
}
.strip-yellow { background: #ffe98a; color: #403400; }
.strip-blue { background: #9bd0ff; color: #0f3454; }
.strip-pink { background: #ffb3d1; color: #552039; }
.strip-green { background: #a7e7ac; color: #1d4923; }
.virtual-strip {
border-radius: 6px;
border: 1px solid rgba(0,0,0,0.18);
padding: 1rem;
box-shadow: inset 0 -3px 0 rgba(0,0,0,0.08);
}
.bulk-grid {
display: grid;
grid-template-columns: 140px 120px 120px 120px 120px 120px 120px 120px 1fr;
gap: 0.75rem;
align-items: end;
}
.bulk-grid .notes-field {
grid-column: span 2;
}
.strip-kind-local {
display: inline-block;
padding: 0;
overflow: hidden;
width: auto;
}
.strip-kind-local .bulk-grid {
grid-template-columns: 180px 110px 130px 72px 90px 240px;
grid-template-rows: 60px 60px;
gap: 0;
align-items: stretch;
}
.strip-kind-local .bulk-field {
border-right: 1px solid rgba(0,0,0,0.82);
border-bottom: 1px solid rgba(0,0,0,0.82);
gap: 0.15rem;
justify-content: center;
padding: 0.45rem;
position: relative;
}
.strip-kind-local .bulk-field label {
color: #8f98a3;
font-size: 0.95rem;
font-weight: 500;
letter-spacing: 0;
text-transform: uppercase;
}
.strip-kind-local .bulk-field input,
.strip-kind-local .bulk-field select,
.strip-kind-local .bulk-field textarea {
background: rgba(255,255,255,0.28);
border: 0;
border-radius: 0;
color: #222;
font-size: 1rem;
font-weight: 650;
min-width: 0;
padding: 0.2rem;
text-transform: uppercase;
width: 100%;
}
.strip-kind-local .local-slash-cell {
grid-column: 1;
grid-row: 1 / span 2;
}
.strip-kind-local .local-slash-cell::after {
content: "";
position: absolute;
width: 78px;
height: 2px;
background: #9aa1a9;
right: 12px;
bottom: 32px;
transform: rotate(-36deg);
transform-origin: right center;
}
.strip-kind-local .local-nature-cell {
grid-column: 2;
grid-row: 1;
border-right: 0;
}
.strip-kind-local .type-cell {
grid-column: 3;
grid-row: 1;
}
.strip-kind-local .strip-registration {
grid-column: 2 / span 2;
grid-row: 2;
align-items: center;
justify-content: flex-start;
}
.strip-kind-local .strip-registration label {
align-self: center;
font-size: 1.25rem;
}
.strip-kind-local .strip-registration input {
background: rgba(255,255,255,0.55);
outline: 2px solid rgba(0,0,0,0.2);
}
.strip-kind-local .callsign-cell {
display: none !important;
}
.strip-kind-local .local-to-cell {
grid-column: 4;
grid-row: 1;
}
.strip-kind-local .local-ldg-cell {
grid-column: 4;
grid-row: 2;
}
.strip-kind-local .local-circuit-cell {
grid-column: 5;
grid-row: 1;
display: flex;
padding: 0.45rem;
}
.strip-kind-local .local-circuit-cell label {
padding: 0;
}
.strip-kind-local .local-circuit-cell input {
align-self: center;
text-align: center;
}
.strip-kind-local .pob-cell {
grid-column: 5;
grid-row: 2;
align-items: flex-end;
justify-content: flex-end;
}
.strip-kind-local .notes-field {
grid-column: 6;
grid-row: 1 / span 2;
align-items: stretch;
justify-content: flex-start;
}
.strip-kind-local .notes-field textarea {
flex: 1;
min-height: 0;
resize: none;
}
.strip-actions {
display: flex;
gap: 0.6rem;
justify-content: flex-start;
margin-top: 0.75rem;
}
.context-panel {
margin-top: 1rem;
border-left: 4px solid #3498db;
background: #eef7ff;
padding: 0.8rem 1rem;
color: #244763;
font-size: 0.95rem;
display: none;
}
.context-panel.warning {
border-left-color: #f39c12;
background: #fff5dc;
color: #6b4b09;
}
.match-pill {
display: inline-block;
padding: 0.25rem 0.45rem;
border-radius: 4px;
background: rgba(52, 73, 94, 0.12);
margin: 0.15rem 0.2rem 0 0;
font-size: 0.85rem;
}
.movement-table table {
min-width: 1050px;
}
@media (max-width: 1200px) {
.bulk-grid {
grid-template-columns: repeat(4, minmax(120px, 1fr));
}
.bulk-grid .notes-field {
grid-column: span 4;
}
}
@media (max-width: 700px) {
.strip-type-picker {
grid-template-columns: 1fr 1fr;
}
.bulk-grid {
grid-template-columns: 1fr;
}
.bulk-grid .notes-field {
grid-column: span 1;
}
}
</style>
</head>
<body>
<div class="top-bar">
<div class="menu-buttons">
<button class="btn btn-secondary" onclick="window.location.href='admin'">Back to Admin</button>
<button class="btn btn-secondary" onclick="window.location.href='movements'">Movements</button>
</div>
<div class="title">
<h1 id="tower-title">Bulk Flight Log</h1>
</div>
<div class="user-info">
Logged in as: <span id="current-user">Loading...</span> |
<a href="#" onclick="logout()" style="color: white;">Logout</a>
</div>
</div>
<div class="container">
<div class="bulk-toolbar">
<div class="bulk-field">
<label for="log-date">Log date</label>
<input type="date" id="log-date">
</div>
<button class="btn btn-primary" onclick="loadDayMovements()">Load Day</button>
<button class="btn btn-secondary" onclick="resetEntryForm()">Clear Entry</button>
</div>
<div class="bulk-entry">
<div class="strip-type-picker">
<button type="button" class="strip-type-button strip-yellow active" data-kind="ARRIVAL" onclick="setStripKind('ARRIVAL')">ARRIVAL<br><span>Yellow</span></button>
<button type="button" class="strip-type-button strip-blue" data-kind="DEPARTURE" onclick="setStripKind('DEPARTURE')">DEPARTURE<br><span>Blue</span></button>
<button type="button" class="strip-type-button strip-pink" data-kind="LOCAL" onclick="setStripKind('LOCAL')">LOCAL<br><span>Pink</span></button>
<button type="button" class="strip-type-button strip-green" data-kind="OVERFLIGHT" onclick="setStripKind('OVERFLIGHT')">OVERFLIGHT<br><span>Green</span></button>
</div>
<form id="bulk-log-form">
<div id="virtual-strip" class="virtual-strip strip-yellow">
<input type="hidden" id="flight-kind" value="ARRIVAL">
<div class="bulk-grid">
<div class="bulk-field strip-registration">
<label for="aircraft-registration">Registration</label>
<input type="text" id="aircraft-registration" autocomplete="off" required>
<div id="bulk-aircraft-lookup-results"></div>
</div>
<div class="bulk-field strip-field" data-show="ARRIVAL">
<label for="landing-time">LDG</label>
<input type="text" id="landing-time" inputmode="numeric" maxlength="4" placeholder="HHMM">
</div>
<div class="bulk-field strip-field" data-show="DEPARTURE">
<label for="takeoff-time">T/O</label>
<input type="text" id="takeoff-time" inputmode="numeric" maxlength="4" placeholder="HHMM">
</div>
<div class="bulk-field strip-field local-to-cell" data-show="LOCAL">
<label for="local-takeoff-time">T/O</label>
<input type="text" id="local-takeoff-time" inputmode="numeric" maxlength="4" placeholder="HHMM">
</div>
<div class="bulk-field strip-field local-ldg-cell" data-show="LOCAL">
<label for="local-landing-time">LDG</label>
<input type="text" id="local-landing-time" inputmode="numeric" maxlength="4" placeholder="HHMM">
</div>
<div class="bulk-field strip-field" data-show="OVERFLIGHT">
<label for="contact-time">Contact time</label>
<input type="text" id="contact-time" inputmode="numeric" maxlength="4" placeholder="HHMM">
</div>
<div class="bulk-field strip-field" data-show="OVERFLIGHT">
<label for="qsy-time">QSY time</label>
<input type="text" id="qsy-time" inputmode="numeric" maxlength="4" placeholder="HHMM">
</div>
<div class="bulk-field strip-field local-slash-cell" data-show="LOCAL"></div>
<div class="bulk-field type-cell">
<label for="aircraft-type">Type</label>
<input type="text" id="aircraft-type">
</div>
<div class="bulk-field callsign-cell">
<label for="callsign">C/SIGN</label>
<input type="text" id="callsign">
</div>
<div class="bulk-field strip-field" id="from-field" data-show="ARRIVAL OVERFLIGHT">
<label for="from-location">From</label>
<input type="text" id="from-location">
</div>
<div class="bulk-field strip-field" id="to-field" data-show="DEPARTURE OVERFLIGHT">
<label for="to-location">To</label>
<input type="text" id="to-location">
</div>
<div class="bulk-field pob-cell">
<label for="pob">POB</label>
<input type="number" id="pob" min="1">
</div>
<div class="bulk-field strip-field local-nature-cell" data-show="LOCAL">
<label for="local-nature">LOC / CCTS</label>
<select id="local-nature">
<option value="LOCAL">Local</option>
<option value="CIRCUITS">Circuits</option>
</select>
</div>
<div class="bulk-field strip-field local-circuit-cell" data-show="LOCAL">
<label for="circuits">CCTS</label>
<input type="number" id="circuits" min="0">
</div>
<div class="bulk-field strip-field runway-cell" data-show="ARRIVAL DEPARTURE">
<label for="runway">Runway</label>
<input type="text" id="runway">
</div>
<div class="bulk-field strip-field" data-show="ARRIVAL DEPARTURE OVERFLIGHT">
<label for="wind">Wind</label>
<input type="text" id="wind">
</div>
<div class="bulk-field strip-field" data-show="ARRIVAL DEPARTURE OVERFLIGHT">
<label for="pressure-setting">Pressure</label>
<input type="text" id="pressure-setting">
</div>
<div class="bulk-field notes-field">
<label for="notes">Notes</label>
<textarea id="notes" rows="2"></textarea>
</div>
</div>
</div>
<div class="strip-actions">
<button class="btn btn-success" type="submit">Save Movement</button>
</div>
<input type="hidden" id="matched-ppr-id">
<input type="hidden" id="matched-movement-id">
</form>
<div id="context-panel" class="context-panel"></div>
</div>
<div class="ppr-table movement-table">
<div class="table-header">
<span>Logged Movements - <span id="movement-count">0</span></span>
</div>
<div id="movements-loading" class="loading" style="display: none;">
<div class="spinner"></div>
Loading movements...
</div>
<div id="movements-content" style="display: none; overflow-x: auto;">
<table>
<thead>
<tr>
<th>Time</th>
<th>Movement</th>
<th>Registration</th>
<th>Type</th>
<th>Callsign</th>
<th>From</th>
<th>To</th>
<th>Runway</th>
<th>Wind</th>
<th>Pressure</th>
<th>Linked</th>
</tr>
</thead>
<tbody id="movements-table-body"></tbody>
</table>
</div>
<div id="movements-no-data" class="no-data" style="display: none;">
<h3>No movements logged for this day</h3>
</div>
</div>
</div>
<script src="lookups.js"></script>
<script>
const API_BASE = '/api/v1';
let accessToken = null;
let lookupTimer = null;
document.addEventListener('DOMContentLoaded', () => {
accessToken = localStorage.getItem('ppr_access_token');
const username = localStorage.getItem('ppr_username');
if (!accessToken) {
window.location.href = 'admin';
return;
}
document.getElementById('current-user').textContent = username || 'Operator';
document.getElementById('log-date').value = new Date().toISOString().split('T')[0];
updateKindFields();
loadDayMovements();
document.getElementById('log-date').addEventListener('change', () => {
lookupContext();
loadDayMovements();
});
document.getElementById('aircraft-registration').addEventListener('input', () => {
handleBulkAircraftLookupForPage(document.getElementById('aircraft-registration').value);
clearTimeout(lookupTimer);
lookupTimer = setTimeout(lookupContext, 250);
});
document.getElementById('aircraft-registration').addEventListener('blur', formatBulkAircraftRegistration);
document.getElementById('bulk-log-form').addEventListener('submit', saveMovement);
});
async function authFetch(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
...(options.headers || {}),
'Authorization': `Bearer ${accessToken}`
}
});
if (response.status === 401) logout();
return response;
}
async function authenticatedFetch(url, options = {}) {
return authFetch(url, options);
}
function handleBulkAircraftLookupForPage(value) {
if (typeof handleBulkAircraftLookup === 'function') {
handleBulkAircraftLookup(value);
return;
}
const lookup = window.lookupManager && window.lookupManager.lookups
? window.lookupManager.lookups['bulk-aircraft']
: null;
if (lookup) lookup.handle(value);
else fallbackAircraftTypeLookup(value);
}
async function fallbackAircraftTypeLookup(value) {
const cleaned = (value || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
if (cleaned.length < 4) return;
try {
const response = await authFetch(`${API_BASE}/aircraft/lookup/${cleaned}`);
if (!response.ok) return;
const results = await response.json();
if (results.length === 1) {
document.getElementById('aircraft-type').value = results[0].type_code || '';
}
} catch (error) {
console.error('Bulk aircraft lookup failed:', error);
}
}
function formatBulkAircraftRegistration() {
const field = document.getElementById('aircraft-registration');
if (!field || !field.value.trim()) return;
if (typeof formatAircraftRegistration === 'function') {
field.value = formatAircraftRegistration(field.value);
} else {
field.value = field.value.trim().toUpperCase();
}
}
function logout() {
localStorage.removeItem('ppr_access_token');
localStorage.removeItem('ppr_username');
localStorage.removeItem('ppr_token_expiry');
window.location.href = 'admin';
}
function setStripKind(kind) {
document.getElementById('flight-kind').value = kind;
document.querySelectorAll('.strip-type-button').forEach(button => {
button.classList.toggle('active', button.dataset.kind === kind);
});
updateKindFields();
lookupContext();
if (kind === 'LOCAL') {
document.getElementById('aircraft-registration').focus();
}
}
function updateKindFields() {
const kind = document.getElementById('flight-kind').value;
const strip = document.getElementById('virtual-strip');
strip.className = `virtual-strip ${stripClassForKind(kind)} strip-kind-${kind.toLowerCase()}`;
document.querySelector('label[for="aircraft-registration"]').textContent = kind === 'LOCAL' ? 'C/SIGN' : 'Registration';
updateTabOrder(kind);
document.querySelectorAll('.strip-field').forEach(field => {
const visible = field.dataset.show.split(' ').includes(kind);
field.style.display = visible ? 'flex' : 'none';
field.querySelectorAll('input, select, textarea').forEach(input => {
input.required = visible && ['landing-time', 'takeoff-time', 'local-takeoff-time', 'local-landing-time', 'contact-time'].includes(input.id);
});
});
}
function updateTabOrder(kind) {
['aircraft-registration', 'local-takeoff-time', 'local-landing-time', 'circuits', 'pob'].forEach(id => {
const field = document.getElementById(id);
if (field) field.removeAttribute('tabindex');
});
if (kind !== 'LOCAL') return;
[
['aircraft-registration', 1],
['local-takeoff-time', 2],
['local-landing-time', 3],
['circuits', 4],
['pob', 5]
].forEach(([id, index]) => {
const field = document.getElementById(id);
if (field) field.tabIndex = index;
});
}
function stripClassForKind(kind) {
return {
ARRIVAL: 'strip-yellow',
DEPARTURE: 'strip-blue',
LOCAL: 'strip-pink',
OVERFLIGHT: 'strip-green'
}[kind] || 'strip-yellow';
}
function resetEntryForm() {
const kind = document.getElementById('flight-kind').value;
document.getElementById('bulk-log-form').reset();
document.getElementById('flight-kind').value = kind;
document.getElementById('matched-ppr-id').value = '';
document.getElementById('matched-movement-id').value = '';
hideContext();
updateKindFields();
document.getElementById('aircraft-registration').focus();
}
async function lookupContext() {
const reg = document.getElementById('aircraft-registration').value.trim().toUpperCase();
const targetDate = document.getElementById('log-date').value;
const kind = document.getElementById('flight-kind').value;
document.getElementById('matched-ppr-id').value = '';
document.getElementById('matched-movement-id').value = '';
if (reg.length < 2 || !targetDate) {
hideContext();
return;
}
const response = await authFetch(`${API_BASE}/movements/bulk-context?target_date=${targetDate}&aircraft_registration=${encodeURIComponent(reg)}&flight_kind=${kind}`);
if (!response.ok) return;
const context = await response.json();
applySuggestion(context.suggested || {});
renderContext(context);
}
function applySuggestion(suggestion) {
if (!suggestion.source) return;
if (suggestion.movement_id) document.getElementById('matched-movement-id').value = suggestion.movement_id;
if (suggestion.ppr_id) document.getElementById('matched-ppr-id').value = suggestion.ppr_id;
fillSuggestedTime(suggestion.movement_time);
fillIfEmpty('aircraft-type', suggestion.aircraft_type);
fillIfEmpty('callsign', suggestion.callsign);
fillIfEmpty('from-location', suggestion.from_location);
fillIfEmpty('to-location', suggestion.to_location);
fillIfEmpty('pob', suggestion.pob);
fillIfEmpty('runway', suggestion.runway);
fillIfEmpty('wind', suggestion.wind);
fillIfEmpty('pressure-setting', suggestion.pressure_setting);
fillIfEmpty('notes', suggestion.notes);
}
function fillIfEmpty(id, value) {
if (value === null || value === undefined || value === '') return;
const field = document.getElementById(id);
if (!field.value) field.value = value;
}
function fillSuggestedTime(value) {
if (!value) return;
const kind = document.getElementById('flight-kind').value;
const target = {
ARRIVAL: 'landing-time',
DEPARTURE: 'takeoff-time',
LOCAL: 'local-takeoff-time',
OVERFLIGHT: 'contact-time'
}[kind];
fillIfEmpty(target, compactTime(value));
}
function renderContext(context) {
const panel = document.getElementById('context-panel');
const pprs = context.pprs || [];
const movements = context.movements || [];
if (!pprs.length && !movements.length) {
hideContext();
return;
}
panel.className = `context-panel ${movements.length ? 'warning' : ''}`;
const parts = [];
if (movements.length) {
parts.push(`<strong>Existing movement found.</strong> Saving will update it instead of adding another.`);
parts.push(movements.map(m => `<span class="match-pill">${formatTime(m.timestamp)} ${m.movement_type} #${m.id}</span>`).join(''));
}
if (pprs.length) {
parts.push(`<strong>Matching PPR${pprs.length > 1 ? 's' : ''}:</strong>`);
parts.push(pprs.map(p => {
const encoded = encodeURIComponent(JSON.stringify(p));
return `<button type="button" class="match-pill" onclick='selectPPR("${encoded}")'>#${p.id} ${p.aircraft_registration} ${p.from_location || ''}${p.to_location ? ' to ' + p.to_location : ''}</button>`;
}).join(''));
}
panel.innerHTML = parts.join('<br>');
panel.style.display = 'block';
}
function hideContext() {
const panel = document.getElementById('context-panel');
panel.style.display = 'none';
panel.innerHTML = '';
}
function selectPPR(encoded) {
const ppr = JSON.parse(decodeURIComponent(encoded));
document.getElementById('matched-ppr-id').value = ppr.id;
document.getElementById('aircraft-registration').value = ppr.aircraft_registration || '';
document.getElementById('aircraft-type').value = ppr.aircraft_type || '';
document.getElementById('callsign').value = ppr.callsign || '';
document.getElementById('from-location').value = ppr.from_location || '';
document.getElementById('to-location').value = ppr.to_location || '';
const kind = document.getElementById('flight-kind').value;
document.getElementById('pob').value = kind === 'ARRIVAL' ? (ppr.pob_in || '') : (ppr.pob_out || ppr.pob_in || '');
if (kind === 'ARRIVAL' && ppr.eta) document.getElementById('landing-time').value = compactTime(ppr.eta.slice(11, 16));
if (kind === 'DEPARTURE' && ppr.etd) document.getElementById('takeoff-time').value = compactTime(ppr.etd.slice(11, 16));
document.getElementById('notes').value = ppr.notes || '';
}
async function saveMovement(event) {
event.preventDefault();
formatBulkAircraftRegistration();
const kind = document.getElementById('flight-kind').value;
const payload = {
flight_kind: kind,
movement_date: document.getElementById('log-date').value,
movement_time: primaryTimeForKind(kind),
takeoff_time: kind === 'DEPARTURE' ? normalizedTime('takeoff-time') : (kind === 'LOCAL' ? normalizedTime('local-takeoff-time') : null),
landing_time: kind === 'ARRIVAL' ? normalizedTime('landing-time') : (kind === 'LOCAL' ? normalizedTime('local-landing-time') : null),
contact_time: kind === 'OVERFLIGHT' ? normalizedTime('contact-time') : null,
qsy_time: kind === 'OVERFLIGHT' ? normalizedTime('qsy-time') : null,
aircraft_registration: document.getElementById('aircraft-registration').value.trim().toUpperCase(),
aircraft_type: nullableValue('aircraft-type'),
callsign: nullableValue('callsign'),
from_location: ['ARRIVAL', 'OVERFLIGHT'].includes(kind) ? nullableValue('from-location') : null,
to_location: ['DEPARTURE', 'OVERFLIGHT'].includes(kind) ? nullableValue('to-location') : null,
pob: nullableNumber('pob'),
local_nature: kind === 'LOCAL' ? nullableValue('local-nature') : null,
circuits: kind === 'LOCAL' ? nullableNumber('circuits') : null,
runway: nullableValue('runway'),
wind: nullableValue('wind'),
pressure_setting: nullableValue('pressure-setting'),
notes: nullableValue('notes'),
ppr_id: nullableNumber('matched-ppr-id'),
movement_id: nullableNumber('matched-movement-id')
};
const response = await authFetch(`${API_BASE}/movements/bulk-log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
alert(result.detail || 'Unable to save movement');
return;
}
resetEntryForm();
await loadDayMovements();
}
function primaryTimeForKind(kind) {
return {
ARRIVAL: normalizedTime('landing-time'),
DEPARTURE: normalizedTime('takeoff-time'),
LOCAL: normalizedTime('local-takeoff-time'),
OVERFLIGHT: normalizedTime('contact-time')
}[kind];
}
function compactTime(value) {
return (value || '').replace(':', '').slice(0, 4);
}
function normalizedTime(id) {
const raw = nullableValue(id);
if (!raw) return null;
const digits = raw.replace(/\D/g, '');
if (digits.length === 3) return `0${digits[0]}:${digits.slice(1)}`;
if (digits.length === 4) return `${digits.slice(0, 2)}:${digits.slice(2)}`;
return raw;
}
function nullableValue(id) {
const value = document.getElementById(id).value.trim();
return value || null;
}
function nullableNumber(id) {
const value = document.getElementById(id).value;
return value ? Number(value) : null;
}
async function loadDayMovements() {
const targetDate = document.getElementById('log-date').value;
if (!targetDate) return;
document.getElementById('movements-loading').style.display = 'block';
document.getElementById('movements-content').style.display = 'none';
document.getElementById('movements-no-data').style.display = 'none';
const response = await authFetch(`${API_BASE}/movements/?date_from=${targetDate}&date_to=${targetDate}&limit=1000`);
document.getElementById('movements-loading').style.display = 'none';
if (!response.ok) {
alert('Unable to load movements');
return;
}
const movements = await response.json();
document.getElementById('movement-count').textContent = movements.length;
renderMovements(movements);
}
function renderMovements(movements) {
const tbody = document.getElementById('movements-table-body');
tbody.innerHTML = '';
if (!movements.length) {
document.getElementById('movements-no-data').style.display = 'block';
return;
}
movements
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
.forEach(movement => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${formatTime(movement.timestamp)}</td>
<td>${movement.movement_type || '-'}</td>
<td><strong>${movement.aircraft_registration || '-'}</strong></td>
<td>${movement.aircraft_type || '-'}</td>
<td>${movement.callsign || '-'}</td>
<td>${movement.from_location || '-'}</td>
<td>${movement.to_location || '-'}</td>
<td>${movement.runway || '-'}</td>
<td>${movement.wind || '-'}</td>
<td>${movement.pressure_setting || '-'}</td>
<td>${movement.entity_type || '-'} #${movement.entity_id || '-'}</td>
`;
tbody.appendChild(row);
});
document.getElementById('movements-content').style.display = 'block';
}
function formatTime(value) {
if (!value) return '-';
return new Date(value.endsWith('Z') ? value : value + 'Z').toISOString().slice(11, 16);
}
</script>
</body>
</html>
+19 -2
View File
@@ -168,7 +168,7 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
// Format the aircraft registration // Format the aircraft registration
const formatted = formatAircraftRegistration(searchTerm); const formatted = formatAircraftRegistration(searchTerm);
const field = document.getElementById(fieldId); const field = document.getElementById(fieldId);
if (field) { if (field && fieldId !== 'aircraft-registration') {
field.value = formatted; field.value = formatted;
// Mark the form for auto-saving this aircraft // Mark the form for auto-saving this aircraft
const form = field.closest('form'); const form = field.closest('form');
@@ -194,7 +194,7 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
// Auto-populate the form fields // Auto-populate the form fields
const field = document.getElementById(fieldId); const field = document.getElementById(fieldId);
if (field) { if (field && fieldId !== 'aircraft-registration') {
field.value = aircraft.registration; field.value = aircraft.registration;
// Clear the unsaved aircraft flag since we found a match // Clear the unsaved aircraft flag since we found a match
const form = field.closest('form'); const form = field.closest('form');
@@ -213,6 +213,8 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
typeFieldId = 'book_in_type'; typeFieldId = 'book_in_type';
} else if (fieldId === 'overflight_registration') { } else if (fieldId === 'overflight_registration') {
typeFieldId = 'overflight_type'; typeFieldId = 'overflight_type';
} else if (fieldId === 'aircraft-registration') {
typeFieldId = 'aircraft-type';
} }
if (typeFieldId) { if (typeFieldId) {
@@ -330,6 +332,8 @@ const lookupManager = {
} }
}; };
window.lookupManager = lookupManager;
// Initialize all lookups when page loads // Initialize all lookups when page loads
function initializeLookups() { function initializeLookups() {
// Create reusable lookup instances // Create reusable lookup instances
@@ -413,6 +417,14 @@ function initializeLookups() {
); );
lookupManager.register('overflight-destination', overflightDestinationLookup); lookupManager.register('overflight-destination', overflightDestinationLookup);
const bulkAircraftLookup = createLookup(
'aircraft-registration',
'bulk-aircraft-lookup-results',
null,
{ isAircraft: true, minLength: 4, debounceMs: 300 }
);
lookupManager.register('bulk-aircraft', bulkAircraftLookup);
// Attach keyboard handlers to airport input fields // Attach keyboard handlers to airport input fields
setTimeout(() => { setTimeout(() => {
if (arrivalAirportLookup.attachKeyboardHandler) arrivalAirportLookup.attachKeyboardHandler(); if (arrivalAirportLookup.attachKeyboardHandler) arrivalAirportLookup.attachKeyboardHandler();
@@ -459,6 +471,11 @@ function handleLocalAircraftLookup(value) {
if (lookup) lookup.handle(value); if (lookup) lookup.handle(value);
} }
function handleBulkAircraftLookup(value) {
const lookup = lookupManager.lookups['bulk-aircraft'];
if (lookup) lookup.handle(value);
}
function clearArrivalAirportLookup() { function clearArrivalAirportLookup() {
const lookup = lookupManager.lookups['arrival-airport']; const lookup = lookupManager.lookups['arrival-airport'];
if (lookup) lookup.clear(); if (lookup) lookup.clear();