diff --git a/backend/alembic/versions/011_ppr_local_status.py b/backend/alembic/versions/011_ppr_local_status.py new file mode 100644 index 0000000..7b85b3b --- /dev/null +++ b/backend/alembic/versions/011_ppr_local_status.py @@ -0,0 +1,34 @@ +"""Add LOCAL status to PPR records + +Revision ID: 011_ppr_local_status +Revises: 010_drone_request_agl_altitude +Create Date: 2026-06-29 00:00:00.000000 + +""" +from alembic import op + +revision = '011_ppr_local_status' +down_revision = '010_drone_request_agl_altitude' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("ALTER TABLE submitted CHANGE COLUMN departed_dt takeoff_dt DATETIME NULL") + op.execute("ALTER TABLE submitted ADD COLUMN qsy_dt DATETIME NULL AFTER takeoff_dt") + op.execute( + "ALTER TABLE submitted MODIFY COLUMN status " + "ENUM('NEW','CONFIRMED','CANCELED','LANDED','LOCAL','DELETED','DEPARTED','ACTIVATED') " + "NOT NULL DEFAULT 'NEW'" + ) + + +def downgrade() -> None: + op.execute("UPDATE submitted SET status = 'LANDED' WHERE status = 'LOCAL'") + op.execute("ALTER TABLE submitted DROP COLUMN qsy_dt") + op.execute("ALTER TABLE submitted CHANGE COLUMN takeoff_dt departed_dt DATETIME NULL") + op.execute( + "ALTER TABLE submitted MODIFY COLUMN status " + "ENUM('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED','ACTIVATED') " + "NOT NULL DEFAULT 'NEW'" + ) diff --git a/backend/app/api/endpoints/movements.py b/backend/app/api/endpoints/movements.py index b98f193..b5dfd8c 100644 --- a/backend/app/api/endpoints/movements.py +++ b/backend/app/api/endpoints/movements.py @@ -564,7 +564,7 @@ async def bulk_log_movement( else: ppr.out_to = entry.to_location or ppr.out_to ppr.pob_out = entry.pob or ppr.pob_out - ppr.departed_dt = timestamp + ppr.takeoff_dt = timestamp if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED): ppr.status = PPRStatus.DEPARTED if entry.notes: diff --git a/backend/app/api/endpoints/pprs.py b/backend/app/api/endpoints/pprs.py index 1f10587..3c2bf87 100644 --- a/backend/app/api/endpoints/pprs.py +++ b/backend/app/api/endpoints/pprs.py @@ -222,13 +222,21 @@ async def update_ppr_status( # Send real-time update if hasattr(request.app.state, 'connection_manager'): + event_timestamp = None + if ppr.status == PPRStatus.LANDED and ppr.landed_dt: + event_timestamp = ppr.landed_dt.isoformat() + elif ppr.status == PPRStatus.LOCAL and ppr.takeoff_dt: + event_timestamp = ppr.takeoff_dt.isoformat() + elif ppr.status == PPRStatus.DEPARTED and ppr.qsy_dt: + event_timestamp = ppr.qsy_dt.isoformat() + await request.app.state.connection_manager.broadcast({ "type": "status_update", "data": { "id": ppr.id, "ac_reg": ppr.ac_reg, "status": ppr.status.value, - "timestamp": ppr.landed_dt.isoformat() if ppr.landed_dt else (ppr.departed_dt.isoformat() if ppr.departed_dt else None) + "timestamp": event_timestamp } }) @@ -348,7 +356,7 @@ async def update_ppr_public( detail="Invalid or expired token" ) # Only allow editing if not already processed - if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]: + if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.LOCAL, PPRStatus.DEPARTED]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="PPR cannot be edited at this stage" @@ -373,7 +381,7 @@ async def cancel_ppr_public( detail="Invalid or expired token" ) # Only allow canceling if not already processed - if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]: + if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.LOCAL, PPRStatus.DEPARTED]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="PPR cannot be cancelled at this stage" diff --git a/backend/app/api/endpoints/public.py b/backend/app/api/endpoints/public.py index 04fe39e..adc8ab9 100644 --- a/backend/app/api/endpoints/public.py +++ b/backend/app/api/endpoints/public.py @@ -145,7 +145,8 @@ async def get_public_departures(db: Session = Depends(get_db)): 'ac_type': departure.ac_type, 'out_to': departure.out_to, 'etd': departure.etd, - 'departed_dt': departure.departed_dt, + 'takeoff_dt': departure.takeoff_dt, + 'qsy_dt': departure.qsy_dt, 'status': departure.status.value, 'isLocalFlight': False, 'isDeparture': False diff --git a/backend/app/crud/crud_ppr.py b/backend/app/crud/crud_ppr.py index c046848..d74e79c 100644 --- a/backend/app/crud/crud_ppr.py +++ b/backend/app/crud/crud_ppr.py @@ -58,6 +58,7 @@ class CRUDPPR: PPRRecord.status == PPRStatus.NEW, PPRRecord.status == PPRStatus.CONFIRMED, PPRRecord.status == PPRStatus.LANDED, + PPRRecord.status == PPRStatus.LOCAL, PPRRecord.status == PPRStatus.DEPARTED ) ) @@ -71,6 +72,7 @@ class CRUDPPR: func.date(PPRRecord.etd) == today, or_( PPRRecord.status == PPRStatus.LANDED, + PPRRecord.status == PPRStatus.LOCAL, PPRRecord.status == PPRStatus.DEPARTED ) ) @@ -151,8 +153,10 @@ class CRUDPPR: current_time = timestamp if timestamp is not None else datetime.utcnow() if status == PPRStatus.LANDED: db_obj.landed_dt = current_time + elif status == PPRStatus.LOCAL: + db_obj.takeoff_dt = current_time elif status == PPRStatus.DEPARTED: - db_obj.departed_dt = current_time + db_obj.qsy_dt = current_time db.add(db_obj) db.commit() diff --git a/backend/app/models/ppr.py b/backend/app/models/ppr.py index a8ac46c..754378b 100644 --- a/backend/app/models/ppr.py +++ b/backend/app/models/ppr.py @@ -9,6 +9,7 @@ class PPRStatus(str, Enum): CONFIRMED = "CONFIRMED" CANCELED = "CANCELED" LANDED = "LANDED" + LOCAL = "LOCAL" DELETED = "DELETED" DEPARTED = "DEPARTED" ACTIVATED = "ACTIVATED" @@ -40,7 +41,8 @@ class PPRRecord(Base): phone = Column(String(16), nullable=True) notes = Column(Text, nullable=True) landed_dt = Column(DateTime, nullable=True) - departed_dt = Column(DateTime, nullable=True) + takeoff_dt = Column(DateTime, nullable=True) + qsy_dt = Column(DateTime, nullable=True) created_by = Column(String(16), nullable=True, index=True) submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True) acknowledged_dt = Column(DateTime, nullable=True) diff --git a/backend/app/schemas/ppr.py b/backend/app/schemas/ppr.py index ec2bebf..6a327d3 100644 --- a/backend/app/schemas/ppr.py +++ b/backend/app/schemas/ppr.py @@ -9,6 +9,7 @@ class PPRStatus(str, Enum): CONFIRMED = "CONFIRMED" CANCELED = "CANCELED" LANDED = "LANDED" + LOCAL = "LOCAL" DELETED = "DELETED" DEPARTED = "DEPARTED" ACTIVATED = "ACTIVATED" @@ -85,7 +86,8 @@ class PPRInDBBase(PPRBase): id: int status: PPRStatus landed_dt: Optional[datetime] = None - departed_dt: Optional[datetime] = None + takeoff_dt: Optional[datetime] = None + qsy_dt: Optional[datetime] = None created_by: Optional[str] = None submitted_dt: datetime acknowledged_dt: Optional[datetime] = None @@ -111,7 +113,8 @@ class PPRPublic(BaseModel): out_to: Optional[str] = None etd: Optional[datetime] = None landed_dt: Optional[datetime] = None - departed_dt: Optional[datetime] = None + takeoff_dt: Optional[datetime] = None + qsy_dt: Optional[datetime] = None submitted_dt: datetime class Config: diff --git a/backend/schema_dump.sql b/backend/schema_dump.sql index 1731595..e24251c 100644 --- a/backend/schema_dump.sql +++ b/backend/schema_dump.sql @@ -81,7 +81,7 @@ DROP TABLE IF EXISTS `submitted`; /*!40101 SET character_set_client = utf8mb4 */; CREATE TABLE `submitted` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, - `status` enum('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED') CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL DEFAULT 'NEW', + `status` enum('NEW','CONFIRMED','CANCELED','LANDED','LOCAL','DELETED','DEPARTED','ACTIVATED') CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL DEFAULT 'NEW', `ac_reg` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL, `ac_type` varchar(32) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL, `ac_call` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci DEFAULT NULL, @@ -97,7 +97,8 @@ CREATE TABLE `submitted` ( `phone` varchar(16) DEFAULT NULL, `notes` varchar(2000) DEFAULT NULL, `landed_dt` datetime DEFAULT NULL, - `departed_dt` datetime DEFAULT NULL, + `takeoff_dt` datetime DEFAULT NULL, + `qsy_dt` datetime DEFAULT NULL, `created_by` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci DEFAULT NULL, `submitted_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `id` (`id`) diff --git a/backend/tests/test_pprs_api.py b/backend/tests/test_pprs_api.py index df8f313..a4f87d0 100644 --- a/backend/tests/test_pprs_api.py +++ b/backend/tests/test_pprs_api.py @@ -43,6 +43,34 @@ def test_authenticated_user_can_create_read_update_and_audit_ppr(auth_client, pp assert any("Status changed from NEW to LANDED" in entry for entry in entries) +def test_ppr_departure_lifecycle_goes_landed_local_departed(auth_client, ppr_payload): + created = auth_client.post("/api/v1/pprs/", json=ppr_payload).json() + + landed_response = auth_client.patch( + f"/api/v1/pprs/{created['id']}/status", + json={"status": "LANDED", "timestamp": "2026-06-20T10:30:00"}, + ) + local_response = auth_client.patch( + f"/api/v1/pprs/{created['id']}/status", + json={"status": "LOCAL", "timestamp": "2026-06-20T12:55:00"}, + ) + departed_response = auth_client.patch( + f"/api/v1/pprs/{created['id']}/status", + json={"status": "DEPARTED", "timestamp": "2026-06-20T13:05:00"}, + ) + + assert landed_response.status_code == 200 + assert local_response.status_code == 200 + assert local_response.json()["status"] == "LOCAL" + assert local_response.json()["landed_dt"] == "2026-06-20T10:30:00" + assert local_response.json()["takeoff_dt"] == "2026-06-20T12:55:00" + assert local_response.json()["qsy_dt"] is None + assert departed_response.status_code == 200 + assert departed_response.json()["status"] == "DEPARTED" + assert departed_response.json()["takeoff_dt"] == "2026-06-20T12:55:00" + assert departed_response.json()["qsy_dt"] == "2026-06-20T13:05:00" + + def test_ppr_list_supports_status_date_and_pagination_filters(auth_client, ppr_factory): ppr_factory( ac_reg="G-NEW1", diff --git a/db-init/init_db.sql b/db-init/init_db.sql index 45e8dc1..c25d2ad 100644 --- a/db-init/init_db.sql +++ b/db-init/init_db.sql @@ -22,7 +22,7 @@ CREATE TABLE users ( -- Main PPR submissions table with improvements CREATE TABLE submitted ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - status ENUM('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED') NOT NULL DEFAULT 'NEW', + status ENUM('NEW','CONFIRMED','CANCELED','LANDED','LOCAL','DELETED','DEPARTED','ACTIVATED') NOT NULL DEFAULT 'NEW', ac_reg VARCHAR(16) NOT NULL, ac_type VARCHAR(32) NOT NULL, ac_call VARCHAR(16) DEFAULT NULL, @@ -38,7 +38,8 @@ CREATE TABLE submitted ( phone VARCHAR(16) DEFAULT NULL, notes TEXT DEFAULT NULL, landed_dt DATETIME DEFAULT NULL, - departed_dt DATETIME DEFAULT NULL, + takeoff_dt DATETIME DEFAULT NULL, + qsy_dt DATETIME DEFAULT NULL, created_by VARCHAR(16) DEFAULT NULL, submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, acknowledged_dt DATETIME DEFAULT NULL, diff --git a/web/admin.html b/web/admin.html index f8257ad..9ea9134 100644 --- a/web/admin.html +++ b/web/admin.html @@ -49,11 +49,11 @@
- +
- đŸ›Šī¸ Today's Local Flights - 0 + đŸ›Šī¸ Local Traffic - 0 â„šī¸
@@ -84,7 +84,7 @@
@@ -447,12 +447,11 @@ document.getElementById('departures-no-data').style.display = 'none'; try { - // Load PPR departures and airport departures simultaneously - const [pprResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([ + // Load PPR departures and airport departures that are still pending departure + const [pprResponse, depBookedOutResponse, depOutGroundResponse] = await Promise.all([ authenticatedFetch('/api/v1/pprs/?limit=1000'), authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'), - authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'), - authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000') + authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000') ]); if (!pprResponse.ok) { @@ -462,10 +461,9 @@ const allPPRs = await pprResponse.json(); const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : []; const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : []; - const depLocal = depLocalResponse.ok ? await depLocalResponse.json() : []; // Combine departures - const allDepartures = [...depBookedOut, ...depOutGround, ...depLocal]; + const allDepartures = [...depBookedOut, ...depOutGround]; const today = new Date().toISOString().split('T')[0]; // Filter for PPR departures with ETD today and LANDED status only @@ -478,7 +476,7 @@ return etdDate === today; }); - // Add departures to other airports (BOOKED_OUT, GROUND, and LOCAL status) + // Add departures to other airports that are not yet airborne locally const depDepartures = allDepartures.map(flight => ({ ...flight, isDeparture: true // Flag to distinguish from PPR @@ -502,19 +500,41 @@ document.getElementById('local-flights-no-data').style.display = 'none'; try { - const response = await authenticatedFetch('/api/v1/local-flights/?limit=1000'); + const [localResponse, pprResponse, depResponse] = await Promise.all([ + authenticatedFetch('/api/v1/local-flights/?limit=1000'), + authenticatedFetch('/api/v1/pprs/?status=LOCAL&limit=1000'), + authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000') + ]); - if (!response.ok) { + if (!localResponse.ok || !pprResponse.ok || !depResponse.ok) { throw new Error('Failed to fetch local flights'); } const today = new Date().toISOString().split('T')[0]; - const localFlights = (await response.json()).filter(flight => { + const localFlights = (await localResponse.json()).filter(flight => { if (!flight.created_dt || ['CANCELLED', 'LANDED'].includes(flight.status)) return false; return flight.created_dt.split('T')[0] === today; }); + const pprLocalTraffic = (await pprResponse.json()) + .filter(ppr => { + const dateFields = [ppr.etd, ppr.landed_dt, ppr.submitted_dt]; + return dateFields.some(value => value && value.split('T')[0] === today); + }) + .map(ppr => ({ + ...ppr, + isPPRLocalTraffic: true + })); + const departureLocalTraffic = (await depResponse.json()) + .filter(departure => { + const dateFields = [departure.created_dt, departure.etd, departure.takeoff_dt, departure.departed_dt]; + return dateFields.some(value => value && value.split('T')[0] === today); + }) + .map(departure => ({ + ...departure, + isDepartureLocalTraffic: true + })); - displayLocalFlights(localFlights); + displayLocalFlights([...localFlights, ...pprLocalTraffic, ...departureLocalTraffic]); } catch (error) { console.error('Error loading local flights:', error); if (error.message !== 'Session expired. Please log in again.') { @@ -610,7 +630,7 @@ } - // Load departed aircraft (DEPARTED status with departed_dt today) + // Load departed aircraft (DEPARTED status with QSY/departed time today) async function loadDeparted() { document.getElementById('departed-loading').style.display = 'block'; document.getElementById('departed-table-content').style.display = 'none'; @@ -631,10 +651,10 @@ // Filter for PPRs departed today (only PPR'd departures, exclude local/circuits) const departed = allPPRs.filter(ppr => { - if (!ppr.departed_dt || ppr.status !== 'DEPARTED') { + if (!ppr.qsy_dt || ppr.status !== 'DEPARTED') { return false; } - const departedDate = ppr.departed_dt.split('T')[0]; + const departedDate = ppr.qsy_dt.split('T')[0]; return departedDate === today; }); @@ -675,8 +695,8 @@ // Sort by departed time departed.sort((a, b) => { - const aTime = a.departed_dt; - const bTime = b.departed_dt; + const aTime = a.isDeparture ? a.departed_dt : a.qsy_dt; + const bTime = b.isDeparture ? b.departed_dt : b.qsy_dt; return parseUtcDate(aTime) - parseUtcDate(bTime); }); @@ -721,7 +741,7 @@ P ${flight.ac_call || '-'} ${flight.out_to || '-'} - ${formatTimeOnly(flight.departed_dt)} + ${formatTimeOnly(flight.qsy_dt)} `; } tbody.appendChild(row); @@ -1200,24 +1220,52 @@ tbody.innerHTML = ''; document.getElementById('local-flights-table-content').style.display = 'block'; - const circuitCounts = await loadLocalFlightCircuitCounts(localFlights); + const circuitCounts = await loadLocalFlightCircuitCounts(localFlights.filter(flight => !flight.isPPRLocalTraffic && !flight.isDepartureLocalTraffic)); for (const flight of localFlights) { const row = document.createElement('tr'); - row.onclick = () => openLocalFlightEditModal(flight.id); + const isPPR = flight.isPPRLocalTraffic; + const isDeparture = flight.isDepartureLocalTraffic; + row.onclick = () => isPPR ? openPPRModal(flight.id) : (isDeparture ? openDepartureEditModal(flight.id) : openLocalFlightEditModal(flight.id)); - const aircraftDisplay = flight.callsign && flight.callsign.trim() - ? `${flight.callsign}
${flight.registration}` - : `${flight.registration}`; - const typeIcon = flight.submitted_via === 'PUBLIC' + const aircraftDisplay = isPPR + ? (flight.ac_call && flight.ac_call.trim() + ? `${flight.ac_call}
${flight.ac_reg}` + : `${flight.ac_reg}`) + : isDeparture + ? (flight.callsign && flight.callsign.trim() + ? `${flight.callsign}
${flight.registration}` + : `${flight.registration}`) + : (flight.callsign && flight.callsign.trim() + ? `${flight.callsign}
${flight.registration}` + : `${flight.registration}`); + const typeIcon = isPPR + ? 'P' + : isDeparture + ? (flight.submitted_via === 'PUBLIC' + ? 'O' + : 'D') + : flight.submitted_via === 'PUBLIC' ? 'O' : 'L'; - const flightType = flight.flight_type === 'CIRCUITS' ? 'Circuits' : flight.flight_type === 'LOCAL' ? 'Local' : 'Departure'; + const flightType = isPPR ? (flight.out_to ? `To ${flight.out_to}` : 'PPR Departure') : isDeparture ? (flight.out_to ? `To ${flight.out_to}` : 'Departure') : flight.flight_type === 'CIRCUITS' ? 'Circuits' : flight.flight_type === 'LOCAL' ? 'Local' : 'Departure'; const etd = flight.etd ? formatTimeOnly(flight.etd) : (flight.created_dt ? formatTimeOnly(flight.created_dt) : '-'); - const circuits = circuitCounts[flight.id] ?? flight.circuits ?? 0; + const circuits = (isPPR || isDeparture) ? '-' : circuitCounts[flight.id] ?? flight.circuits ?? 0; let actionButtons = ''; - if (flight.status === 'BOOKED_OUT') { + if (isPPR) { + actionButtons = ` + + `; + } else if (isDeparture) { + actionButtons = ` + + `; + } else if (flight.status === 'BOOKED_OUT') { actionButtons = ` - `; + if (flight.status === 'LANDED') { + actionButtons = ` + + `; + } else if (flight.status === 'LOCAL') { + actionButtons = ` + + `; + } else { + actionButtons = '-'; + } } row.innerHTML = ` diff --git a/web/atc.html b/web/atc.html index 709d992..d33b370 100644 --- a/web/atc.html +++ b/web/atc.html @@ -255,11 +255,11 @@ - +
-

📍 Local Area 0

+

📍 Local Traffic 0

-
No aircraft in local area
+
No local traffic
@@ -445,15 +445,13 @@ document.getElementById('departures-no-data').style.display = 'none'; try { - // Load PPR departures, local flight departures, and airport departures simultaneously - const [pprResponse, localBookedOutResponse, localOutGroundResponse, localLocalResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([ + // Load PPR departures, local flight departures, and airport departures that are still pending departure + const [pprResponse, localBookedOutResponse, localOutGroundResponse, depBookedOutResponse, depOutGroundResponse] = await Promise.all([ authenticatedFetch('/api/v1/pprs/?limit=1000'), authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&limit=1000'), authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000'), - authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'), authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'), - authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'), - authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000') + authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000') ]); if (!pprResponse.ok) { @@ -463,15 +461,13 @@ const allPPRs = await pprResponse.json(); const localBookedOut = localBookedOutResponse.ok ? await localBookedOutResponse.json() : []; const localOutGround = localOutGroundResponse.ok ? await localOutGroundResponse.json() : []; - const localLocal = localLocalResponse.ok ? await localLocalResponse.json() : []; const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : []; const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : []; - const depLocal = depLocalResponse.ok ? await depLocalResponse.json() : []; // Combine local flights - const allLocalFlights = [...localBookedOut, ...localOutGround, ...localLocal]; + const allLocalFlights = [...localBookedOut, ...localOutGround]; // Combine departures - const allDepartures = [...depBookedOut, ...depOutGround, ...depLocal]; + const allDepartures = [...depBookedOut, ...depOutGround]; const today = new Date().toISOString().split('T')[0]; // Filter for PPR departures with ETD today and LANDED status only @@ -484,7 +480,7 @@ return etdDate === today; }); - // Add local flights (GROUND and LOCAL status - ready to go) - only those booked out today + // Add local flights that are not yet airborne locally - only those booked out today const localDepartures = allLocalFlights .filter(flight => { // Only include flights booked out today (created_dt) @@ -498,7 +494,7 @@ })); departures.push(...localDepartures); - // Add departures to other airports (BOOKED_OUT, GROUND, and LOCAL status) + // Add departures to other airports that are not yet airborne locally const depDepartures = allDepartures.map(flight => ({ ...flight, isDeparture: true // Flag to distinguish from PPR @@ -602,7 +598,7 @@ } - // Load departed aircraft (DEPARTED status with departed_dt today) + // Load departed aircraft (DEPARTED status with QSY/departed time today) async function loadDeparted() { document.getElementById('departed-loading').style.display = 'block'; document.getElementById('departed-table-content').style.display = 'none'; @@ -623,10 +619,10 @@ // Filter for PPRs departed today (only PPR'd departures, exclude local/circuits) const departed = allPPRs.filter(ppr => { - if (!ppr.departed_dt || ppr.status !== 'DEPARTED') { + if (!ppr.qsy_dt || ppr.status !== 'DEPARTED') { return false; } - const departedDate = ppr.departed_dt.split('T')[0]; + const departedDate = ppr.qsy_dt.split('T')[0]; return departedDate === today; }); @@ -667,8 +663,8 @@ // Sort by departed time departed.sort((a, b) => { - const aTime = a.departed_dt; - const bTime = b.departed_dt; + const aTime = a.isDeparture ? a.departed_dt : a.qsy_dt; + const bTime = b.isDeparture ? b.departed_dt : b.qsy_dt; return parseUtcDate(aTime) - parseUtcDate(bTime); }); @@ -713,7 +709,7 @@ P ${flight.ac_call || '-'} ${flight.out_to || '-'} - ${formatTimeOnly(flight.departed_dt)} + ${formatTimeOnly(flight.qsy_dt)} `; } tbody.appendChild(row); @@ -1088,7 +1084,7 @@ T&G `; actionButtons = ` - ${circuitButton} @@ -1216,12 +1212,13 @@ `; } else if (flight.status === 'GROUND') { + const takeoffStatus = flight.flight_type === 'CIRCUITS' ? 'CIRCUIT' : 'LOCAL'; actionButtons = ` - `; - } else if (flight.status === 'DEPARTED') { + } else if (['DEPARTED', 'LOCAL', 'CIRCUIT', 'CIRCUIT_DOWNWIND', 'CIRCUIT_BASE', 'CIRCUIT_FINAL'].includes(flight.status)) { // Allow touch and go for all local flight types let circuitButton = ` - `; + if (flight.status === 'LANDED') { + actionButtons = ` + + `; + } else if (flight.status === 'LOCAL') { + actionButtons = ` + + `; + } else { + actionButtons = '-'; + } } row.innerHTML = ` @@ -1412,19 +1419,26 @@ // Load departing aircraft (ready to take off) async function loadDepartingAircraft() { try { - const [groundDeparturesResponse, groundLocalResponse] = await Promise.all([ + const [pprResponse, groundDeparturesResponse, groundLocalResponse] = await Promise.all([ + authenticatedFetch('/api/v1/pprs/?limit=1000'), authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'), authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000') ]); let groundAircraft = []; - if (groundDeparturesResponse.ok) groundAircraft = await groundDeparturesResponse.json(); + if (pprResponse.ok) { + const today = getLocalDateString(); + groundAircraft = (await pprResponse.json()) + .filter(ppr => ppr.status === 'LANDED' && ppr.etd && ppr.etd.split('T')[0] === today) + .map(ppr => ({ ...ppr, isPPR: true })); + } + if (groundDeparturesResponse.ok) groundAircraft = groundAircraft.concat(await groundDeparturesResponse.json()); if (groundLocalResponse.ok) groundAircraft = groundAircraft.concat((await groundLocalResponse.json()).map(l => ({ ...l, isLocalFlight: true }))); groundAircraft = groundAircraft.filter(ac => isTodayRecord(ac, ['created_dt', 'etd'])); displayDepartingAircraft(groundAircraft.map(ac => ({ ...ac, - isDeparture: !ac.isLocalFlight + isDeparture: !ac.isLocalFlight && !ac.isPPR }))); } catch (error) { console.error('Error loading departing aircraft:', error); @@ -1447,6 +1461,7 @@ const type = ac.ac_type || ac.type; const dest = ac.out_to; const isLocal = ac.isLocalFlight; + const isPPR = ac.isPPR; // All aircraft in awaiting departure are in GROUND status let takeoffOnclick, buttonText, buttonTitle, clickType; @@ -1457,13 +1472,18 @@ buttonText = 'TAKE OFF'; buttonTitle = takeoffTitle; clickType = 'local'; + } else if (isPPR) { + takeoffOnclick = `event.stopPropagation(); showTimestampModal('LOCAL', ${ac.id})`; + buttonText = 'TAKE OFF'; + buttonTitle = 'Mark as Local'; + clickType = 'ppr'; } else { takeoffOnclick = `event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('LOCAL', ${ac.id}, false, true)`; buttonText = 'TAKE OFF'; buttonTitle = 'Mark as Local'; clickType = 'departure'; } - const itemClass = isLocal ? 'local-flight' : 'departure'; + const itemClass = isLocal ? 'local-flight' : (isPPR ? 'departure' : 'departure'); return `
@@ -1481,6 +1501,7 @@ async function loadLocalAircraft() { try { const response = await Promise.all([ + authenticatedFetch('/api/v1/pprs/?status=LOCAL&limit=1000'), authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'), authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000'), authenticatedFetch('/api/v1/arrivals/?status=LOCAL&limit=1000'), @@ -1488,12 +1509,14 @@ ]); let locals = []; - if (response[0].ok) locals = (await response[0].json()).map(l => ({ ...l, isLocalFlight: true })); - if (response[1].ok) locals = locals.concat((await response[1].json()).map(d => ({ ...d, isDeparture: true }))); - if (response[2].ok) locals = locals.concat((await response[2].json()).map(a => ({ ...a, isArrival: true }))); - if (response[3].ok) locals = locals.concat((await response[3].json()).map(o => ({ ...o, isOverflight: true }))); + if (response[0].ok) locals = (await response[0].json()).map(p => ({ ...p, isPPR: true })); + if (response[1].ok) locals = locals.concat((await response[1].json()).map(l => ({ ...l, isLocalFlight: true }))); + if (response[2].ok) locals = locals.concat((await response[2].json()).map(d => ({ ...d, isDeparture: true }))); + if (response[3].ok) locals = locals.concat((await response[3].json()).map(a => ({ ...a, isArrival: true }))); + if (response[4].ok) locals = locals.concat((await response[4].json()).map(o => ({ ...o, isOverflight: true }))); locals = locals.filter(ac => { if (ac.isOverflight) return true; + if (ac.isPPR) return isTodayRecord(ac, ['etd', 'landed_dt', 'submitted_dt']); if (ac.isLocalFlight) return isTodayRecord(ac, ['created_dt', 'etd', 'takeoff_dt', 'departed_dt']); if (ac.isArrival) return isTodayRecord(ac, ['created_dt', 'eta']); if (ac.isDeparture) return isTodayRecord(ac, ['created_dt', 'etd', 'takeoff_dt', 'departed_dt']); @@ -1513,7 +1536,7 @@ countEl.textContent = aircraft.length; if (aircraft.length === 0) { - container.innerHTML = '
No aircraft in local area
'; + container.innerHTML = '
No local traffic
'; return; } @@ -1522,9 +1545,12 @@ const type = ac.type || ac.ac_type || ac.aircraft_type || ''; const dest = ac.out_to; const isDeparture = ac.isDeparture; + const isPPR = ac.isPPR; let buttons; - if (isDeparture) { + if (isPPR) { + buttons = ``; + } else if (isDeparture) { // Departure in LOCAL status - show QSY and REJOIN buttons buttons = ` @@ -1537,9 +1563,9 @@ // Overflight in ACTIVE status - show QSY button buttons = ``; } - const itemClass = isDeparture ? 'departure' : (ac.isArrival ? 'inbound' : (ac.isOverflight ? 'overflight' : 'local-flight')); - const detailsText = isDeparture ? `${type}${dest ? ` → ${dest}` : ` (Local)`}` : (ac.isOverflight ? `${ac.departure_airfield || '?'} → ${ac.destination_airfield || '?'}` : (ac.isArrival ? `${type} from ${ac.in_from || '?'}` : `${type}${dest ? ` → ${dest}` : ` Local Flight`}`)); - const entityType = isDeparture ? 'departure' : (ac.isArrival ? 'arrival' : (ac.isOverflight ? 'overflight' : 'local')); + const itemClass = isDeparture || isPPR ? 'departure' : (ac.isArrival ? 'inbound' : (ac.isOverflight ? 'overflight' : 'local-flight')); + const detailsText = isDeparture || isPPR ? `${type}${dest ? ` → ${dest}` : ` (Local)`}` : (ac.isOverflight ? `${ac.departure_airfield || '?'} → ${ac.destination_airfield || '?'}` : (ac.isArrival ? `${type} from ${ac.in_from || '?'}` : `${type}${dest ? ` → ${dest}` : ` Local Flight`}`)); + const entityType = isPPR ? 'ppr' : (isDeparture ? 'departure' : (ac.isArrival ? 'arrival' : (ac.isOverflight ? 'overflight' : 'local'))); return `
diff --git a/web/edit.html b/web/edit.html index 40445fe..306c378 100644 --- a/web/edit.html +++ b/web/edit.html @@ -472,7 +472,7 @@ document.getElementById('phone').value = ppr.phone || ''; document.getElementById('notes').value = ppr.notes || ''; - if (['CANCELED', 'DELETED', 'LANDED', 'DEPARTED'].includes(ppr.status)) { + if (['CANCELED', 'DELETED', 'LANDED', 'LOCAL', 'DEPARTED'].includes(ppr.status)) { document.getElementById('update-btn').disabled = true; document.getElementById('cancel-btn').disabled = true; showNotification('This PPR can no longer be edited or cancelled online.', true); diff --git a/web/index.html b/web/index.html index 25063d9..b4f5f2a 100644 --- a/web/index.html +++ b/web/index.html @@ -829,10 +829,10 @@ const toDisplay = await getAirportName(departure.out_to || ''); let timeDisplay, sortTime; - if (departure.status === 'DEPARTED' && departure.departed_dt) { - const time = convertToLocalTime(departure.departed_dt); + if (departure.status === 'DEPARTED' && departure.qsy_dt) { + const time = convertToLocalTime(departure.qsy_dt); timeDisplay = `
${time}DEPARTED
`; - sortTime = departure.departed_dt; + sortTime = departure.qsy_dt; } else { timeDisplay = convertToLocalTime(departure.etd); sortTime = departure.etd; diff --git a/web/reports.html b/web/reports.html index 73795c2..cce0f53 100644 --- a/web/reports.html +++ b/web/reports.html @@ -1181,7 +1181,7 @@ row.className = 'clickable-row'; row.onclick = () => openReportDetail('PPR', ppr.id); - const takeoff = ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '-'; + const takeoff = ppr.takeoff_dt ? formatDateTime(ppr.takeoff_dt) : '-'; const landing = ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '-'; const submitted = ppr.submitted_dt ? formatDateTime(ppr.submitted_dt) : '-'; @@ -1381,7 +1381,7 @@ } function getPPRSortTime(ppr) { - return ppr.landed_dt || ppr.departed_dt || ppr.eta || ppr.etd || ppr.submitted_dt; + return ppr.landed_dt || ppr.qsy_dt || ppr.takeoff_dt || ppr.eta || ppr.etd || ppr.submitted_dt; } const detailConfig = { @@ -1397,7 +1397,8 @@ ['Captain', r => r.captain], ['From', r => r.in_from], ['To', r => r.out_to], - ['Takeoff', r => formatOptionalDateTime(r.departed_dt)], + ['Takeoff', r => formatOptionalDateTime(r.takeoff_dt)], + ['QSY', r => formatOptionalDateTime(r.qsy_dt)], ['Landing', r => formatOptionalDateTime(r.landed_dt)], ['ETA', r => formatOptionalDateTime(r.eta)], ['ETD', r => formatOptionalDateTime(r.etd)], @@ -1571,7 +1572,7 @@ const headers = [ 'ID', 'Status', 'Aircraft Reg', 'Aircraft Type', 'Callsign', 'Captain', - 'From', 'To', 'Takeoff', 'Landing', 'POB In', 'POB Out', 'Fuel', + 'From', 'To', 'Takeoff', 'QSY', 'Landing', 'POB In', 'POB Out', 'Fuel', 'Email', 'Phone', 'Notes', 'Submitted', 'Created By' ]; @@ -1584,7 +1585,8 @@ ppr.captain, ppr.in_from, ppr.out_to || '', - ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '', + ppr.takeoff_dt ? formatDateTime(ppr.takeoff_dt) : '', + ppr.qsy_dt ? formatDateTime(ppr.qsy_dt) : '', ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '', ppr.pob_in, ppr.pob_out || '', diff --git a/web/shared.js b/web/shared.js index fce83eb..eddce87 100644 --- a/web/shared.js +++ b/web/shared.js @@ -777,6 +777,9 @@ const ppr = await response.json(); populateForm(ppr); + const departedBtn = document.getElementById('btn-departed'); + departedBtn.textContent = 'đŸ›Ģ Depart'; + departedBtn.setAttribute('onclick', "showTimestampModal('DEPARTED')"); // Show/hide quick action buttons based on current status if (ppr.status === 'NEW') { @@ -789,8 +792,16 @@ document.getElementById('btn-cancel').style.display = 'inline-block'; } else if (ppr.status === 'LANDED') { document.getElementById('btn-landed').style.display = 'none'; - document.getElementById('btn-departed').style.display = 'inline-block'; + departedBtn.style.display = 'inline-block'; + departedBtn.textContent = 'đŸ›Ģ Take Off'; + departedBtn.setAttribute('onclick', "showTimestampModal('LOCAL')"); document.getElementById('btn-cancel').style.display = 'inline-block'; + } else if (ppr.status === 'LOCAL') { + document.getElementById('btn-landed').style.display = 'none'; + departedBtn.style.display = 'inline-block'; + departedBtn.textContent = 'QSY'; + departedBtn.setAttribute('onclick', "showTimestampModal('DEPARTED')"); + document.getElementById('btn-cancel').style.display = 'none'; } else { // DEPARTED, CANCELED, DELETED - hide all quick actions and cancel button document.querySelector('.quick-actions').style.display = 'none'; @@ -2657,8 +2668,8 @@ text: "Displays visiting aircraft and airport departures that are ready to leave Swansea today. Local flights are shown in their own table." }, "local-flights": { - title: "Today's Local Flights", - text: "Displays local and circuit flights booked out today, with shortcuts for contact, takeoff, circuit work, touch-and-go, and landing." + title: "Local Traffic", + text: "Displays local traffic booked out today, including local flights, circuits, and PPR departures that are airborne locally before QSY." }, overflights: { title: "Active Overflights",