Better movement reporting

This commit is contained in:
2025-12-18 11:45:24 -05:00
parent dee5d38b58
commit a43ab34a8f
6 changed files with 370 additions and 4 deletions

View File

@@ -79,6 +79,7 @@ def upgrade() -> None:
sa.Column('flight_type', sa.Enum('LOCAL', 'CIRCUITS', 'DEPARTURE', name='localflighttype'), nullable=False), sa.Column('flight_type', sa.Enum('LOCAL', 'CIRCUITS', 'DEPARTURE', name='localflighttype'), nullable=False),
sa.Column('status', sa.Enum('BOOKED_OUT', 'DEPARTED', 'LANDED', 'CANCELLED', name='localflightstatus'), nullable=False, server_default='BOOKED_OUT'), sa.Column('status', sa.Enum('BOOKED_OUT', 'DEPARTED', 'LANDED', 'CANCELLED', name='localflightstatus'), nullable=False, server_default='BOOKED_OUT'),
sa.Column('duration', sa.Integer(), nullable=True, comment='Duration in minutes'), sa.Column('duration', sa.Integer(), nullable=True, comment='Duration in minutes'),
sa.Column('circuits', sa.Integer(), nullable=True, default=0, comment='Actual number of circuits completed'),
sa.Column('notes', sa.Text(), nullable=True), sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('etd', sa.DateTime(), nullable=True), sa.Column('etd', sa.DateTime(), nullable=True),

View File

@@ -5,6 +5,7 @@ from datetime import date, datetime
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, LocalFlightStatusUpdate from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, LocalFlightStatusUpdate
from app.models.journal import EntityType from app.models.journal import EntityType
from app.models.circuit import Circuit
from app.crud.crud_journal import journal from app.crud.crud_journal import journal
@@ -149,6 +150,11 @@ class CRUDLocalFlight:
db_obj.departed_dt = current_time db_obj.departed_dt = current_time
elif status == LocalFlightStatus.LANDED: elif status == LocalFlightStatus.LANDED:
db_obj.landed_dt = current_time db_obj.landed_dt = current_time
# Count circuits from the circuits table and populate the circuits column
circuit_count = db.query(func.count(Circuit.id)).filter(
Circuit.local_flight_id == flight_id
).scalar()
db_obj.circuits = circuit_count
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()

View File

@@ -28,6 +28,7 @@ class LocalFlight(Base):
flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True) flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True)
status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True) status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True)
duration = Column(Integer, nullable=True) # Duration in minutes duration = Column(Integer, nullable=True) # Duration in minutes
circuits = Column(Integer, nullable=True, default=0) # Actual number of circuits completed
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True) created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure

View File

@@ -62,6 +62,7 @@ class LocalFlightUpdate(BaseModel):
status: Optional[LocalFlightStatus] = None status: Optional[LocalFlightStatus] = None
etd: Optional[datetime] = None etd: Optional[datetime] = None
departed_dt: Optional[datetime] = None departed_dt: Optional[datetime] = None
circuits: Optional[int] = None
notes: Optional[str] = None notes: Optional[str] = None
@@ -77,6 +78,7 @@ class LocalFlightInDBBase(LocalFlightBase):
etd: Optional[datetime] = None etd: Optional[datetime] = None
departed_dt: Optional[datetime] = None departed_dt: Optional[datetime] = None
landed_dt: Optional[datetime] = None landed_dt: Optional[datetime] = None
circuits: Optional[int] = None
created_by: Optional[str] = None created_by: Optional[str] = None
updated_at: datetime updated_at: datetime

View File

@@ -2626,7 +2626,7 @@
showNotification(`✈️ Circuit recorded at ${formatTimeOnly(circuit.circuit_timestamp)}`); showNotification(`✈️ Circuit recorded at ${formatTimeOnly(circuit.circuit_timestamp)}`);
closeCircuitModal(); closeCircuitModal();
// Refresh departures to show updated circuit count // Refresh departures to show updated flight info
loadDepartures(); loadDepartures();
} catch (error) { } catch (error) {
console.error('Error recording circuit:', error); console.error('Error recording circuit:', error);

View File

@@ -227,6 +227,45 @@
.status.canceled { background: #ffebee; color: #d32f2f; } .status.canceled { background: #ffebee; color: #d32f2f; }
.status.deleted { background: #f3e5f5; color: #7b1fa2; } .status.deleted { background: #f3e5f5; color: #7b1fa2; }
.summary-box {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.summary-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
}
.summary-item {
background: rgba(255,255,255,0.1);
border-radius: 6px;
padding: 1rem;
border-left: 4px solid rgba(255,255,255,0.3);
}
.summary-item-label {
font-size: 0.85rem;
opacity: 0.9;
margin-bottom: 0.5rem;
}
.summary-item-value {
font-size: 2rem;
font-weight: 700;
}
.no-data { .no-data {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
@@ -366,6 +405,64 @@
</div> </div>
</div> </div>
<!-- Summary Box -->
<div class="summary-box">
<div class="summary-title">📊 Movements Summary</div>
<div class="summary-grid">
<!-- PPR Section -->
<div style="grid-column: 1/-1; padding-bottom: 1rem; border-bottom: 2px solid rgba(255,255,255,0.3);">
<div style="font-size: 0.95rem; font-weight: 600; margin-bottom: 0.6rem;">PPR Movements</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;">
<div class="summary-item" style="padding: 0.6rem;">
<div class="summary-item-label" style="font-size: 0.75rem; margin-bottom: 0.2rem;">Arrivals (Landings)</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="ppr-arrivals">0</div>
</div>
<div class="summary-item" style="padding: 0.6rem;">
<div class="summary-item-label" style="font-size: 0.75rem; margin-bottom: 0.2rem;">Departures (Takeoffs)</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="ppr-departures">0</div>
</div>
<div class="summary-item" style="border-left-color: #ffd700; background: rgba(255,215,0,0.1); padding: 0.6rem;">
<div class="summary-item-label" style="font-weight: 600; font-size: 0.75rem; margin-bottom: 0.2rem;">PPR Total</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="ppr-total">0</div>
</div>
</div>
</div>
<!-- Non-PPR Section -->
<div style="grid-column: 1/-1; padding-bottom: 1rem; border-bottom: 2px solid rgba(255,255,255,0.3);">
<div style="font-size: 0.95rem; font-weight: 600; margin-bottom: 0.6rem;">Non-PPR Movements</div>
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 1rem;">
<div class="summary-item" style="padding: 0.6rem;">
<div class="summary-item-label" style="font-size: 0.75rem; margin-bottom: 0.2rem;">Local Flights</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="local-flights-movements">0</div>
</div>
<div class="summary-item" style="padding: 0.6rem;">
<div class="summary-item-label" style="font-size: 0.75rem; margin-bottom: 0.2rem;">Circuits</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="circuits-movements">0</div>
</div>
<div class="summary-item" style="padding: 0.6rem;">
<div class="summary-item-label" style="font-size: 0.75rem; margin-bottom: 0.2rem;">Arrivals</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="non-ppr-arrivals">0</div>
</div>
<div class="summary-item" style="padding: 0.6rem;">
<div class="summary-item-label" style="font-size: 0.75rem; margin-bottom: 0.2rem;">Departures</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="non-ppr-departures">0</div>
</div>
<div class="summary-item" style="border-left-color: #ffd700; background: rgba(255,215,0,0.1); padding: 0.6rem;">
<div class="summary-item-label" style="font-weight: 600; font-size: 0.75rem; margin-bottom: 0.2rem;">Non-PPR Total</div>
<div class="summary-item-value" style="font-size: 1.3rem;" id="non-ppr-total">0</div>
</div>
</div>
</div>
<!-- Grand Total -->
<div style="grid-column: 1/-1; text-align: center;">
<div style="font-size: 0.85rem; opacity: 0.9; margin-bottom: 0.3rem;">Grand Total Movements</div>
<div style="font-size: 2rem; font-weight: 700;" id="grand-total-movements">0</div>
</div>
</div>
</div>
<!-- Reports Table --> <!-- Reports Table -->
<div class="reports-table"> <div class="reports-table">
<div class="table-header"> <div class="table-header">
@@ -422,6 +519,54 @@
<p>No records match your current filters.</p> <p>No records match your current filters.</p>
</div> </div>
</div> </div>
<!-- Other Flights Table -->
<div class="reports-table" style="margin-top: 2rem;">
<div class="table-header">
<div>
<strong>Other Flights</strong>
<div class="table-info" id="other-flights-info">Loading...</div>
</div>
<div class="export-buttons">
<button class="btn btn-success" onclick="exportOtherFlightsToCSV()">
📊 Export CSV
</button>
</div>
</div>
<div id="other-flights-loading" class="loading">
<div class="spinner"></div>
Loading flights...
</div>
<div id="other-flights-table-content" style="display: none;">
<div class="table-container">
<table>
<thead>
<tr>
<th>Status</th>
<th>Type</th>
<th>Aircraft</th>
<th>Aircraft Type</th>
<th>Callsign</th>
<th>From</th>
<th>To</th>
<th>ETA / ETD</th>
<th>Landed / Departed</th>
<th>Circuits</th>
</tr>
</thead>
<tbody id="other-flights-table-body">
</tbody>
</table>
</div>
</div>
<div id="other-flights-no-data" class="no-data" style="display: none;">
<h3>No other flights found</h3>
<p>No flights match your current filters.</p>
</div>
</div>
</div> </div>
<!-- Success Notification --> <!-- Success Notification -->
@@ -431,6 +576,7 @@
let currentUser = null; let currentUser = null;
let accessToken = null; let accessToken = null;
let currentPPRs = []; // Store current results for export let currentPPRs = []; // Store current results for export
let currentOtherFlights = []; // Store other flights for export
// Initialize the page // Initialize the page
async function initializePage() { async function initializePage() {
@@ -508,6 +654,9 @@
document.getElementById('reports-loading').style.display = 'block'; document.getElementById('reports-loading').style.display = 'block';
document.getElementById('reports-table-content').style.display = 'none'; document.getElementById('reports-table-content').style.display = 'none';
document.getElementById('reports-no-data').style.display = 'none'; document.getElementById('reports-no-data').style.display = 'none';
document.getElementById('other-flights-loading').style.display = 'block';
document.getElementById('other-flights-table-content').style.display = 'none';
document.getElementById('other-flights-no-data').style.display = 'none';
try { try {
const dateFrom = document.getElementById('date-from').value; const dateFrom = document.getElementById('date-from').value;
@@ -521,13 +670,19 @@
if (dateTo) url += `&date_to=${dateTo}`; if (dateTo) url += `&date_to=${dateTo}`;
if (status) url += `&status=${status}`; if (status) url += `&status=${status}`;
const response = await authenticatedFetch(url); // Fetch all data in parallel
const [pprResponse, arrivalsResponse, departuresResponse, localFlightsResponse] = await Promise.all([
authenticatedFetch(url),
authenticatedFetch(`/api/v1/arrivals/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
authenticatedFetch(`/api/v1/departures/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
authenticatedFetch(`/api/v1/local-flights/?limit=10000${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`)
]);
if (!response.ok) { if (!pprResponse.ok) {
throw new Error('Failed to fetch PPR records'); throw new Error('Failed to fetch PPR records');
} }
let pprs = await response.json(); let pprs = await pprResponse.json();
// Apply client-side search filtering // Apply client-side search filtering
if (search) { if (search) {
@@ -543,6 +698,64 @@
currentPPRs = pprs; // Store for export currentPPRs = pprs; // Store for export
displayReports(pprs); displayReports(pprs);
// Process other flights
let otherFlights = [];
if (arrivalsResponse.ok) {
const arrivals = await arrivalsResponse.json();
otherFlights.push(...arrivals.map(f => ({
...f,
flightType: 'ARRIVAL',
aircraft_type: f.type,
timeField: f.eta || f.landed_dt,
fromField: f.in_from,
toField: 'EGFH'
})));
}
if (departuresResponse.ok) {
const departures = await departuresResponse.json();
otherFlights.push(...departures.map(f => ({
...f,
flightType: 'DEPARTURE',
aircraft_type: f.type,
timeField: f.etd || f.departed_dt,
fromField: 'EGFH',
toField: f.out_to
})));
}
if (localFlightsResponse.ok) {
const localFlights = await localFlightsResponse.json();
otherFlights.push(...localFlights.map(f => ({
...f,
flightType: f.flight_type === 'CIRCUITS' ? 'CIRCUIT' : f.flight_type,
aircraft_type: f.type,
circuits: f.circuits,
timeField: f.departed_dt,
fromField: 'EGFH',
toField: 'EGFH'
})));
}
// Apply search filtering to other flights
if (search) {
const searchLower = search.toLowerCase();
otherFlights = otherFlights.filter(f =>
(f.registration && f.registration.toLowerCase().includes(searchLower)) ||
(f.callsign && f.callsign.toLowerCase().includes(searchLower)) ||
(f.fromField && f.fromField.toLowerCase().includes(searchLower)) ||
(f.toField && f.toField.toLowerCase().includes(searchLower))
);
}
currentOtherFlights = otherFlights;
displayOtherFlights(otherFlights);
// Calculate and display movements summary
calculateMovementsSummary(pprs, otherFlights);
} catch (error) { } catch (error) {
console.error('Error loading reports:', error); console.error('Error loading reports:', error);
if (error.message !== 'Session expired. Please log in again.') { if (error.message !== 'Session expired. Please log in again.') {
@@ -551,6 +764,63 @@
} }
document.getElementById('reports-loading').style.display = 'none'; document.getElementById('reports-loading').style.display = 'none';
document.getElementById('other-flights-loading').style.display = 'none';
}
// Calculate and display movements summary
function calculateMovementsSummary(pprs, otherFlights) {
let pprArrivals = 0; // PPR landings
let pprDepartures = 0; // PPR takeoffs
let localFlightsMovements = 0;
let circuitsMovements = 0;
let nonPprArrivals = 0;
let nonPprDepartures = 0;
// PPR movements:
// - LANDED = 1 arrival (landing)
// - DEPARTED = 1 departure + 1 arrival (because departure implies a prior landing)
pprs.forEach(ppr => {
if (ppr.status === 'LANDED') {
pprArrivals += 1;
} else if (ppr.status === 'DEPARTED') {
pprDepartures += 1;
pprArrivals += 1; // Each departure implies a landing happened
}
// CANCELED = 0 movements, not counted
});
// Other flights movements
otherFlights.forEach(flight => {
if (flight.flightType === 'ARRIVAL') {
nonPprArrivals += 1;
} else if (flight.flightType === 'DEPARTURE') {
nonPprDepartures += 1;
} else if (flight.flightType === 'LOCAL') {
// 2 movements (takeoff + landing) for the flight itself
localFlightsMovements += 2;
} else if (flight.flightType === 'CIRCUIT') {
// 2 movements (takeoff + landing) plus the circuit count
const circuits = flight.circuits || 0;
circuitsMovements += 2 + circuits;
}
});
const pprTotal = pprArrivals + pprDepartures;
const nonPprTotal = localFlightsMovements + circuitsMovements + nonPprArrivals + nonPprDepartures;
const grandTotal = pprTotal + nonPprTotal;
// Update the summary display
document.getElementById('ppr-arrivals').textContent = pprArrivals;
document.getElementById('ppr-departures').textContent = pprDepartures;
document.getElementById('ppr-total').textContent = pprTotal;
document.getElementById('local-flights-movements').textContent = localFlightsMovements;
document.getElementById('circuits-movements').textContent = circuitsMovements;
document.getElementById('non-ppr-arrivals').textContent = nonPprArrivals;
document.getElementById('non-ppr-departures').textContent = nonPprDepartures;
document.getElementById('non-ppr-total').textContent = nonPprTotal;
document.getElementById('grand-total-movements').textContent = grandTotal;
} }
// Display reports in table // Display reports in table
@@ -615,6 +885,63 @@
} }
} }
// Display other flights in table
function displayOtherFlights(flights) {
const tbody = document.getElementById('other-flights-table-body');
const tableInfo = document.getElementById('other-flights-info');
tableInfo.textContent = `${flights.length} flights found`;
if (flights.length === 0) {
document.getElementById('other-flights-no-data').style.display = 'block';
return;
}
// Sort by time field (ascending)
flights.sort((a, b) => {
const aTime = a.timeField;
const bTime = b.timeField;
if (!aTime) return 1;
if (!bTime) return -1;
return new Date(aTime) - new Date(bTime);
});
tbody.innerHTML = '';
document.getElementById('other-flights-table-content').style.display = 'block';
for (const flight of flights) {
const row = document.createElement('tr');
const typeLabel = flight.flightType;
const registration = flight.registration || '-';
const aircraftType = flight.aircraft_type || '-';
const callsign = flight.callsign || '-';
const from = flight.fromField || '-';
const to = flight.toField || '-';
const timeDisplay = flight.timeField ? formatDateTime(flight.timeField) : '-';
const actualDisplay = flight.flightType === 'ARRIVAL'
? (flight.landed_dt ? formatDateTime(flight.landed_dt) : '-')
: (flight.departed_dt ? formatDateTime(flight.departed_dt) : '-');
const status = flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING');
const circuits = (flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits > 0 ? flight.circuits : '-') : '-';
row.innerHTML = `
<td><span class="status ${status.toLowerCase()}">${status}</span></td>
<td><strong>${typeLabel}</strong></td>
<td>${registration}</td>
<td>${aircraftType}</td>
<td>${callsign}</td>
<td>${from}</td>
<td>${to}</td>
<td>${timeDisplay}</td>
<td>${actualDisplay}</td>
<td>${circuits}</td>
`;
tbody.appendChild(row);
}
}
function formatDateTime(dateStr) { function formatDateTime(dateStr) {
if (!dateStr) return '-'; if (!dateStr) return '-';
let utcDateStr = dateStr; let utcDateStr = dateStr;
@@ -683,6 +1010,35 @@
downloadCSV(headers, csvData, 'ppr_reports.csv'); downloadCSV(headers, csvData, 'ppr_reports.csv');
} }
function exportOtherFlightsToCSV() {
if (currentOtherFlights.length === 0) {
showNotification('No data to export', true);
return;
}
const headers = [
'Flight Type', 'Aircraft Registration', 'Aircraft Type', 'Callsign', 'From', 'To',
'ETA/ETD', 'Landed/Departed', 'Status', 'Circuits'
];
const csvData = currentOtherFlights.map(flight => [
flight.flightType,
flight.registration || '',
flight.aircraft_type || '',
flight.callsign || '',
flight.fromField || '',
flight.toField || '',
flight.timeField ? formatDateTime(flight.timeField) : '',
flight.flightType === 'ARRIVAL'
? (flight.landed_dt ? formatDateTime(flight.landed_dt) : '')
: (flight.departed_dt ? formatDateTime(flight.departed_dt) : ''),
flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING'),
(flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits || '') : ''
]);
downloadCSV(headers, csvData, 'other_flights_reports.csv');
}
function downloadCSV(headers, data, filename) { function downloadCSV(headers, data, filename) {
const csvContent = [ const csvContent = [
headers.join(','), headers.join(','),