Better movement reporting
This commit is contained in:
@@ -79,6 +79,7 @@ def upgrade() -> None:
|
||||
sa.Column('flight_type', sa.Enum('LOCAL', 'CIRCUITS', 'DEPARTURE', name='localflighttype'), nullable=False),
|
||||
sa.Column('status', sa.Enum('BOOKED_OUT', 'DEPARTED', 'LANDED', 'CANCELLED', name='localflightstatus'), nullable=False, server_default='BOOKED_OUT'),
|
||||
sa.Column('duration', sa.Integer(), nullable=True, comment='Duration in minutes'),
|
||||
sa.Column('circuits', sa.Integer(), nullable=True, default=0, comment='Actual number of circuits completed'),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('created_dt', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('etd', sa.DateTime(), nullable=True),
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import date, datetime
|
||||
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
|
||||
from app.schemas.local_flight import LocalFlightCreate, LocalFlightUpdate, LocalFlightStatusUpdate
|
||||
from app.models.journal import EntityType
|
||||
from app.models.circuit import Circuit
|
||||
from app.crud.crud_journal import journal
|
||||
|
||||
|
||||
@@ -149,6 +150,11 @@ class CRUDLocalFlight:
|
||||
db_obj.departed_dt = current_time
|
||||
elif status == LocalFlightStatus.LANDED:
|
||||
db_obj.landed_dt = current_time
|
||||
# Count circuits from the circuits table and populate the circuits column
|
||||
circuit_count = db.query(func.count(Circuit.id)).filter(
|
||||
Circuit.local_flight_id == flight_id
|
||||
).scalar()
|
||||
db_obj.circuits = circuit_count
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
|
||||
@@ -28,6 +28,7 @@ class LocalFlight(Base):
|
||||
flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True)
|
||||
status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True)
|
||||
duration = Column(Integer, nullable=True) # Duration in minutes
|
||||
circuits = Column(Integer, nullable=True, default=0) # Actual number of circuits completed
|
||||
notes = Column(Text, nullable=True)
|
||||
created_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
||||
etd = Column(DateTime, nullable=True, index=True) # Estimated Time of Departure
|
||||
|
||||
@@ -62,6 +62,7 @@ class LocalFlightUpdate(BaseModel):
|
||||
status: Optional[LocalFlightStatus] = None
|
||||
etd: Optional[datetime] = None
|
||||
departed_dt: Optional[datetime] = None
|
||||
circuits: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@@ -77,6 +78,7 @@ class LocalFlightInDBBase(LocalFlightBase):
|
||||
etd: Optional[datetime] = None
|
||||
departed_dt: Optional[datetime] = None
|
||||
landed_dt: Optional[datetime] = None
|
||||
circuits: Optional[int] = None
|
||||
created_by: Optional[str] = None
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -2626,7 +2626,7 @@
|
||||
showNotification(`✈️ Circuit recorded at ${formatTimeOnly(circuit.circuit_timestamp)}`);
|
||||
closeCircuitModal();
|
||||
|
||||
// Refresh departures to show updated circuit count
|
||||
// Refresh departures to show updated flight info
|
||||
loadDepartures();
|
||||
} catch (error) {
|
||||
console.error('Error recording circuit:', error);
|
||||
|
||||
362
web/reports.html
362
web/reports.html
@@ -227,6 +227,45 @@
|
||||
.status.canceled { background: #ffebee; color: #d32f2f; }
|
||||
.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 {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
@@ -366,6 +405,64 @@
|
||||
</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 -->
|
||||
<div class="reports-table">
|
||||
<div class="table-header">
|
||||
@@ -422,6 +519,54 @@
|
||||
<p>No records match your current filters.</p>
|
||||
</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>
|
||||
|
||||
<!-- Success Notification -->
|
||||
@@ -431,6 +576,7 @@
|
||||
let currentUser = null;
|
||||
let accessToken = null;
|
||||
let currentPPRs = []; // Store current results for export
|
||||
let currentOtherFlights = []; // Store other flights for export
|
||||
|
||||
// Initialize the page
|
||||
async function initializePage() {
|
||||
@@ -508,6 +654,9 @@
|
||||
document.getElementById('reports-loading').style.display = 'block';
|
||||
document.getElementById('reports-table-content').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 {
|
||||
const dateFrom = document.getElementById('date-from').value;
|
||||
@@ -521,13 +670,19 @@
|
||||
if (dateTo) url += `&date_to=${dateTo}`;
|
||||
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');
|
||||
}
|
||||
|
||||
let pprs = await response.json();
|
||||
let pprs = await pprResponse.json();
|
||||
|
||||
// Apply client-side search filtering
|
||||
if (search) {
|
||||
@@ -543,6 +698,64 @@
|
||||
|
||||
currentPPRs = pprs; // Store for export
|
||||
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) {
|
||||
console.error('Error loading reports:', error);
|
||||
if (error.message !== 'Session expired. Please log in again.') {
|
||||
@@ -551,6 +764,63 @@
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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) {
|
||||
if (!dateStr) return '-';
|
||||
let utcDateStr = dateStr;
|
||||
@@ -683,6 +1010,35 @@
|
||||
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) {
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
|
||||
Reference in New Issue
Block a user