Flow improvements

This commit is contained in:
2026-06-29 07:15:01 -04:00
parent 8d8cb9ccad
commit 0a49dfe219
16 changed files with 281 additions and 102 deletions
@@ -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'"
)
+1 -1
View File
@@ -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:
+11 -3
View File
@@ -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"
+2 -1
View File
@@ -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
+5 -1
View File
@@ -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()
+3 -1
View File
@@ -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)
+5 -2
View File
@@ -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:
+3 -2
View File
@@ -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`)
+28
View File
@@ -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",
+3 -2
View File
@@ -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,
+94 -36
View File
@@ -49,11 +49,11 @@
<div class="container">
<!-- Local Flights Table -->
<!-- Local Traffic Table -->
<div class="ppr-table">
<div class="table-header">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>🛩️ Today's Local Flights - <span id="local-flights-count">0</span></span>
<span>🛩️ Local Traffic - <span id="local-flights-count">0</span></span>
<span class="info-icon" onclick="showTableHelp('local-flights')" title="What is this?"></span>
</div>
</div>
@@ -84,7 +84,7 @@
</div>
<div id="local-flights-no-data" class="no-data" style="display: none;">
<h3>No Local Flights</h3>
<h3>No Local Traffic</h3>
</div>
</div>
@@ -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 @@
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"><span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span></td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.ac_call || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.qsy_dt)}</td>
`;
}
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()
? `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`
: `<strong>${flight.registration}</strong>`;
const typeIcon = flight.submitted_via === 'PUBLIC'
const aircraftDisplay = isPPR
? (flight.ac_call && flight.ac_call.trim()
? `<strong>${flight.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.ac_reg}</span>`
: `<strong>${flight.ac_reg}</strong>`)
: isDeparture
? (flight.callsign && flight.callsign.trim()
? `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`
: `<strong>${flight.registration}</strong>`)
: (flight.callsign && flight.callsign.trim()
? `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`
: `<strong>${flight.registration}</strong>`);
const typeIcon = isPPR
? '<span style="color: #032cfc; font-weight: bold; font-size: 0.9em;" title="From PPR">P</span>'
: isDeparture
? (flight.submitted_via === 'PUBLIC'
? '<span style="color: #b8860b; font-weight: bold; font-size: 0.9em;" title="Submitted by Pilot Online">O</span>'
: '<span style="color: #6f42c1; font-weight: bold; font-size: 0.9em;" title="Airport departure">D</span>')
: flight.submitted_via === 'PUBLIC'
? '<span style="color: #b8860b; font-weight: bold; font-size: 0.9em;" title="Submitted by Pilot Online">O</span>'
: '<span style="color: #228b22; font-weight: bold; font-size: 0.9em;" title="Local flight">L</span>';
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 = `
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
QSY
</button>
`;
} else if (isDeparture) {
actionButtons = `
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentDepartureId = ${flight.id}; showTimestampModal('DEPARTED', ${flight.id}, false, true)" title="Mark as Departed">
QSY
</button>
`;
} else if (flight.status === 'BOOKED_OUT') {
actionButtons = `
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('GROUND', ${flight.id}, true)" title="Contact Pilot">
CONTACT
@@ -1246,10 +1294,10 @@
row.innerHTML = `
<td>${aircraftDisplay}</td>
<td style="text-align: center; width: 30px;">${typeIcon}</td>
<td>${flight.type || '-'}</td>
<td>${isPPR ? flight.ac_type || '-' : flight.type || '-'}</td>
<td>${flightType}</td>
<td>${etd}</td>
<td>${flight.pob || '-'}</td>
<td>${isPPR ? (flight.pob_out || flight.pob_in || '-') : (flight.pob || '-')}</td>
<td>${localFlightStatusBadge(flight.status)}</td>
<td>${circuits}</td>
<td style="white-space: nowrap;">${actionButtons}</td>
@@ -1495,11 +1543,21 @@
fuel = flight.fuel || '-';
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
TAKE OFF
</button>
`;
if (flight.status === 'LANDED') {
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('LOCAL', ${flight.id})" title="Mark as Local">
TAKE OFF
</button>
`;
} else if (flight.status === 'LOCAL') {
actionButtons = `
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
QSY
</button>
`;
} else {
actionButtons = '<span style="color: #999;">-</span>';
}
}
row.innerHTML = `
+67 -41
View File
@@ -255,11 +255,11 @@
</div>
</div>
<!-- Row 1: Local Area -->
<!-- Row 1: Local Traffic -->
<div class="atc-section">
<h2>📍 Local Area <span class="count" id="local-count">0</span></h2>
<h2>📍 Local Traffic <span class="count" id="local-count">0</span></h2>
<div class="aircraft-list" id="local-list">
<div class="no-aircraft">No aircraft in local area</div>
<div class="no-aircraft">No local traffic</div>
</div>
</div>
@@ -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 @@
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"><span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span></td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.ac_call || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.qsy_dt)}</td>
`;
}
tbody.appendChild(row);
@@ -1088,7 +1084,7 @@
T&G
</button>`;
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Area">
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Traffic">
LOCAL
</button>
${circuitButton}
@@ -1216,12 +1212,13 @@
</button>
`;
} else if (flight.status === 'GROUND') {
const takeoffStatus = flight.flight_type === 'CIRCUITS' ? 'CIRCUIT' : 'LOCAL';
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('DEPARTED', ${flight.id}, true)" title="Mark as Departed">
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('${takeoffStatus}', ${flight.id}, true)" title="Mark as airborne">
TAKE OFF
</button>
`;
} 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 = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
T&G
@@ -1293,11 +1290,21 @@
fuel = flight.fuel || '-';
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
TAKE OFF
</button>
`;
if (flight.status === 'LANDED') {
actionButtons = `
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('LOCAL', ${flight.id})" title="Mark as Local">
TAKE OFF
</button>
`;
} else if (flight.status === 'LOCAL') {
actionButtons = `
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
QSY
</button>
`;
} else {
actionButtons = '<span style="color: #999;">-</span>';
}
}
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 `
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${clickType}')">
@@ -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 = '<div class="no-aircraft">No aircraft in local area</div>';
container.innerHTML = '<div class="no-aircraft">No local traffic</div>';
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 = `<button class="status-btn" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${ac.id})">QSY</button>`;
} else if (isDeparture) {
// Departure in LOCAL status - show QSY and REJOIN buttons
buttons = `
<button class="status-btn" onclick="event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('DEPARTED', ${ac.id}, false, true)">QSY</button>
@@ -1537,9 +1563,9 @@
// Overflight in ACTIVE status - show QSY button
buttons = `<button class="status-btn" onclick="event.stopPropagation(); currentOverflightId = '${ac.id}'; showOverflightQSYModal()">QSY</button>`;
}
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 `
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${entityType}')">
+1 -1
View File
@@ -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);
+3 -3
View File
@@ -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 = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">DEPARTED</span></div>`;
sortTime = departure.departed_dt;
sortTime = departure.qsy_dt;
} else {
timeDisplay = convertToLocalTime(departure.etd);
sortTime = departure.etd;
+7 -5
View File
@@ -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 || '',
+14 -3
View File
@@ -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",