Drone and PPR and operational updates

This commit is contained in:
2026-06-20 05:31:04 -04:00
parent b8d7cdddcd
commit 3e8b3cf4c1
15 changed files with 1727 additions and 39 deletions
+669
View File
@@ -0,0 +1,669 @@
---
const configuredApiBase = import.meta.env.PUBLIC_PPR_API_BASE ?? 'https://ppr.swansea-airport.wales/api/v1';
const pprApiBase = configuredApiBase.replace(/\/$/, '');
---
<section class="ppr-shell surface" aria-labelledby="ppr-heading">
<div class="ppr-head">
<p class="eyebrow">Prior permission request</p>
<h2 id="ppr-heading" class="section-title">Submit a PPR request</h2>
<p class="section-copy">
Complete the details below for flights into Swansea Airport. Requests are accepted by default;
the airport will contact you if additional information is required.
</p>
<p class="ppr-note">
This form is under test. If you have any issues, email
<a href="mailto:james.pattinson@sasalliance.org">james.pattinson@sasalliance.org</a>.
</p>
</div>
<form id="ppr-form" class="ppr-form">
<div class="ppr-grid">
<div class="ppr-field">
<label for="ac_reg">Aircraft Registration <span aria-hidden="true">*</span></label>
<input type="text" id="ac_reg" name="ac_reg" autocomplete="off" required />
<div id="aircraft-lookup-results" class="ppr-lookup" aria-live="polite"></div>
</div>
<div class="ppr-field">
<label for="ac_type">Aircraft Type <span aria-hidden="true">*</span></label>
<input type="text" id="ac_type" name="ac_type" autocomplete="off" required />
</div>
<div class="ppr-field">
<label for="ac_call">Callsign</label>
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration" />
</div>
<div class="ppr-field">
<label for="captain">Captain/Pilot Name <span aria-hidden="true">*</span></label>
<input type="text" id="captain" name="captain" autocomplete="name" required />
</div>
<div class="ppr-field">
<label for="in_from">Arriving From <span aria-hidden="true">*</span></label>
<input type="text" id="in_from" name="in_from" placeholder="ICAO code or airport name" autocomplete="off" required />
<div id="arrival-airport-lookup-results" class="ppr-lookup" aria-live="polite"></div>
</div>
<div class="ppr-field">
<label for="eta-date">Estimated Time of Arrival (Local Time) <span aria-hidden="true">*</span></label>
<div class="ppr-date-time">
<input type="date" id="eta-date" name="eta-date" required />
<select id="eta-time" name="eta-time" required>
<option value="">Select Time</option>
</select>
</div>
</div>
<div class="ppr-field">
<label for="pob_in">Persons on Board (Arrival) <span aria-hidden="true">*</span></label>
<input type="number" id="pob_in" name="pob_in" min="1" inputmode="numeric" required />
</div>
<div class="ppr-field">
<label for="fuel">Fuel Required</label>
<select id="fuel" name="fuel">
<option value="">None</option>
<option value="100LL">100LL</option>
<option value="JET A1">JET A1</option>
</select>
</div>
<div class="ppr-field">
<label for="out_to">Departing To</label>
<input type="text" id="out_to" name="out_to" placeholder="ICAO code or airport name" autocomplete="off" />
<div id="departure-airport-lookup-results" class="ppr-lookup" aria-live="polite"></div>
</div>
<div class="ppr-field">
<label for="etd-date">Estimated Time of Departure (Local Time)</label>
<div class="ppr-date-time">
<input type="date" id="etd-date" name="etd-date" />
<select id="etd-time" name="etd-time">
<option value="">Select Time</option>
</select>
</div>
</div>
<div class="ppr-field">
<label for="pob_out">Persons on Board (Departure)</label>
<input type="number" id="pob_out" name="pob_out" min="1" inputmode="numeric" />
</div>
<div class="ppr-field">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" autocomplete="email" />
</div>
<div class="ppr-field">
<label for="phone">Phone Number</label>
<input type="tel" id="phone" name="phone" autocomplete="tel" />
</div>
<div class="ppr-field ppr-full">
<label for="notes">Additional Notes</label>
<textarea id="notes" name="notes" rows="4" placeholder="Any special requirements, handling instructions, or additional information..."></textarea>
</div>
</div>
<div class="ppr-actions">
<button type="submit" class="button primary" id="submit-btn">Submit PPR Request</button>
</div>
</form>
<div class="ppr-loading" id="loading" role="status" aria-live="polite">
<span class="ppr-spinner" aria-hidden="true"></span>
Submitting your PPR request...
</div>
<div class="ppr-success notice" id="success-message" role="status" aria-live="polite">
<h3>PPR Request Submitted.</h3>
<p>Your PPR request has been submitted. You will receive confirmation via email if provided.</p>
<p><strong>Please note:</strong> PPR requests are accepted by default. We will contact you if additional information is required. Remember to check NOTAMs before your flight.</p>
</div>
</section>
<div id="notification" class="ppr-notification" role="status" aria-live="polite"></div>
<script define:vars={{ pprApiBase }}>
(() => {
const API_BASE = pprApiBase;
let etdManuallyEdited = false;
let aircraftLookupTimeout;
let arrivalAirportLookupTimeout;
let departureAirportLookupTimeout;
const get = (id) => document.getElementById(id);
function setLookupStatus(results, message, className) {
results.replaceChildren();
if (!message) return;
const status = document.createElement('span');
status.className = className;
status.textContent = message;
results.append(status);
}
function createOptionList(items, renderItem) {
const list = document.createElement('div');
list.className = 'ppr-option-list';
items.forEach((item) => list.append(renderItem(item)));
return list;
}
function createAircraftOption(aircraft, fallbackRegistration) {
const registration = aircraft.registration || fallbackRegistration.toUpperCase();
const typeCode = aircraft.type_code || '';
const option = document.createElement('button');
option.type = 'button';
option.className = 'ppr-option';
option.addEventListener('mousedown', (event) => event.preventDefault());
option.addEventListener('click', () => selectAircraft(registration, typeCode));
const code = document.createElement('span');
code.className = 'ppr-option-code';
code.textContent = registration;
const details = document.createElement('span');
details.className = 'ppr-option-detail';
details.textContent = `${typeCode || 'Unknown'}${aircraft.model ? ` (${aircraft.model})` : ''}`;
option.append(code, details);
return option;
}
function createAirportOption(fieldId, airport) {
const option = document.createElement('button');
option.type = 'button';
option.className = 'ppr-option';
option.addEventListener('mousedown', (event) => event.preventDefault());
option.addEventListener('click', () => selectAirport(fieldId, airport.icao));
const code = document.createElement('span');
code.className = 'ppr-option-code';
code.textContent = `${airport.icao}/${airport.iata || ''}`;
const details = document.createElement('span');
details.className = 'ppr-option-detail';
details.textContent = `${airport.name}, ${airport.country}`;
option.append(code, details);
return option;
}
function selectAircraft(registration, typeCode) {
get('ac_reg').value = registration;
get('ac_type').value = typeCode;
get('aircraft-lookup-results').replaceChildren();
get('ac_reg').blur();
}
function selectAirport(fieldId, icaoCode) {
get(fieldId).value = icaoCode;
const resultsId = fieldId === 'in_from' ? 'arrival-airport-lookup-results' : 'departure-airport-lookup-results';
get(resultsId).replaceChildren();
get(fieldId).blur();
}
function initializeTimeDropdowns() {
['eta-time', 'etd-time'].forEach((selectId) => {
const select = get(selectId);
select.replaceChildren(new Option('Select Time', ''));
for (let hour = 0; hour < 24; hour += 1) {
for (let minute = 0; minute < 60; minute += 15) {
const timeString = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
select.append(new Option(timeString, timeString));
}
}
});
}
function formatDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatTime(date) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function updateETDFromETA() {
if (etdManuallyEdited) return;
const etaDate = get('eta-date').value;
const etaTime = get('eta-time').value;
if (!etaDate || !etaTime) return;
const etd = new Date(new Date(`${etaDate}T${etaTime}`).getTime() + 2 * 60 * 60 * 1000);
get('etd-date').value = formatDate(etd);
get('etd-time').value = formatTime(etd);
}
function setDefaultDateTime() {
const nextHour = new Date();
nextHour.setHours(nextHour.getHours() + 1, 0, 0, 0);
const etd = new Date(nextHour);
etd.setHours(nextHour.getHours() + 2);
get('eta-date').value = formatDate(nextHour);
get('eta-time').value = formatTime(nextHour);
get('etd-date').value = formatDate(etd);
get('etd-time').value = formatTime(etd);
}
function showNotification(message, isError = false) {
const notification = get('notification');
notification.textContent = message;
notification.className = `ppr-notification${isError ? ' error' : ''}`;
requestAnimationFrame(() => notification.classList.add('show'));
window.setTimeout(() => notification.classList.remove('show'), 5000);
}
async function handleAircraftLookup(registration) {
clearTimeout(aircraftLookupTimeout);
const results = get('aircraft-lookup-results');
if (!registration || registration.length < 4) {
results.replaceChildren();
return;
}
setLookupStatus(results, 'Searching...', 'ppr-lookup-searching');
aircraftLookupTimeout = window.setTimeout(async () => {
try {
const response = await fetch(`${API_BASE}/aircraft/public/lookup/${registration.toUpperCase()}`);
if (!response.ok) {
results.replaceChildren();
return;
}
const data = await response.json();
if (!data || data.length === 0) {
setLookupStatus(results, 'No aircraft found with this registration', 'ppr-lookup-muted');
return;
}
if (data.length === 1) {
const aircraft = data[0];
get('ac_reg').value = aircraft.registration || registration.toUpperCase();
if (!get('ac_type').value) get('ac_type').value = aircraft.type_code || '';
}
if (data.length <= 10) {
results.replaceChildren(createOptionList(data, (item) => createAircraftOption(item, registration)));
return;
}
setLookupStatus(results, 'Too many matches, please be more specific', 'ppr-lookup-muted');
} catch (error) {
console.error('Aircraft lookup error:', error);
results.replaceChildren();
}
}, 500);
}
async function handleAirportLookup(query, fieldId, resultsId, timeoutSetter) {
const results = get(resultsId);
if (!query || query.length < 2) {
results.replaceChildren();
return;
}
setLookupStatus(results, 'Searching...', 'ppr-lookup-searching');
timeoutSetter(window.setTimeout(async () => {
try {
const response = await fetch(`${API_BASE}/airport/public/lookup/${query.toUpperCase()}`);
if (!response.ok) {
results.replaceChildren();
return;
}
const data = await response.json();
if (!data || data.length === 0) {
setLookupStatus(results, 'No airport found', 'ppr-lookup-muted');
return;
}
if (data.length <= 10) {
results.replaceChildren(createOptionList(data, (airport) => createAirportOption(fieldId, airport)));
return;
}
setLookupStatus(results, 'Too many matches, please be more specific', 'ppr-lookup-muted');
} catch (error) {
console.error('Airport lookup error:', error);
results.replaceChildren();
}
}, 500));
}
function buildPprData(form) {
const formData = new FormData(form);
const pprData = {};
formData.forEach((value, key) => {
const fieldValue = String(value).trim();
if (!fieldValue) return;
if (key === 'pob_in' || key === 'pob_out') {
pprData[key] = Number.parseInt(fieldValue, 10);
} else if (key === 'eta-date' && formData.get('eta-time')) {
pprData.eta = new Date(`${fieldValue}T${formData.get('eta-time')}`).toISOString();
} else if (key === 'etd-date' && formData.get('etd-time')) {
pprData.etd = new Date(`${fieldValue}T${formData.get('etd-time')}`).toISOString();
} else if (key !== 'eta-time' && key !== 'etd-time') {
pprData[key] = fieldValue;
}
});
return pprData;
}
async function handleSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
const submitButton = get('submit-btn');
get('loading').style.display = 'flex';
submitButton.disabled = true;
submitButton.textContent = 'Submitting...';
try {
const response = await fetch(`${API_BASE}/pprs/public`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildPprData(form)),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Submission failed: ${response.status}`);
}
form.style.display = 'none';
get('success-message').style.display = 'block';
showNotification('PPR request submitted successfully!');
} catch (error) {
console.error('Error submitting PPR:', error);
showNotification(`Error submitting PPR: ${error.message}`, true);
} finally {
get('loading').style.display = 'none';
submitButton.disabled = false;
submitButton.textContent = 'Submit PPR Request';
}
}
document.addEventListener('DOMContentLoaded', () => {
initializeTimeDropdowns();
setDefaultDateTime();
get('ac_reg').addEventListener('input', (event) => handleAircraftLookup(event.target.value));
get('ac_reg').addEventListener('blur', () => window.setTimeout(() => get('aircraft-lookup-results').replaceChildren(), 150));
get('in_from').addEventListener('input', (event) => {
clearTimeout(arrivalAirportLookupTimeout);
handleAirportLookup(event.target.value, 'in_from', 'arrival-airport-lookup-results', (timeout) => {
arrivalAirportLookupTimeout = timeout;
});
});
get('in_from').addEventListener('blur', () => window.setTimeout(() => get('arrival-airport-lookup-results').replaceChildren(), 150));
get('out_to').addEventListener('input', (event) => {
clearTimeout(departureAirportLookupTimeout);
handleAirportLookup(event.target.value, 'out_to', 'departure-airport-lookup-results', (timeout) => {
departureAirportLookupTimeout = timeout;
});
});
get('out_to').addEventListener('blur', () => window.setTimeout(() => get('departure-airport-lookup-results').replaceChildren(), 150));
get('eta-date').addEventListener('change', updateETDFromETA);
get('eta-time').addEventListener('change', updateETDFromETA);
get('etd-date').addEventListener('change', () => {
etdManuallyEdited = true;
});
get('etd-time').addEventListener('change', () => {
etdManuallyEdited = true;
});
get('ppr-form').addEventListener('submit', handleSubmit);
});
})();
</script>
<style>
.ppr-shell {
display: grid;
gap: 1.25rem;
margin-block: 1.5rem 0;
}
.ppr-head {
display: grid;
gap: 0.55rem;
}
.ppr-note {
margin: 0;
color: var(--muted);
font-size: 0.95rem;
}
.ppr-form {
display: grid;
gap: 1.2rem;
}
.ppr-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.ppr-field {
display: grid;
gap: 0.4rem;
align-content: start;
}
.ppr-full {
grid-column: 1 / -1;
}
.ppr-field label {
color: var(--text);
font-size: 0.92rem;
font-weight: 800;
}
.ppr-field label span {
color: var(--critical);
}
.ppr-field input,
.ppr-field select,
.ppr-field textarea {
width: 100%;
min-height: 2.85rem;
border: 1px solid rgba(16, 34, 51, 0.16);
border-radius: 0.7rem;
background: rgba(255, 255, 255, 0.88);
color: var(--text);
font: inherit;
font-size: 1rem;
padding: 0.7rem 0.82rem;
}
.ppr-field textarea {
min-height: 7.5rem;
resize: vertical;
}
.ppr-field input:focus,
.ppr-field select:focus,
.ppr-field textarea:focus {
outline: none;
border-color: var(--brand-2);
box-shadow: 0 0 0 0.2rem rgba(29, 118, 184, 0.16);
}
.ppr-date-time {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(8rem, 0.72fr);
gap: 0.6rem;
}
.ppr-lookup {
color: var(--muted);
font-size: 0.88rem;
}
.ppr-lookup-searching {
color: var(--brand-2);
font-weight: 700;
}
.ppr-lookup-muted {
color: var(--muted);
font-style: italic;
}
.ppr-option-list {
display: grid;
max-height: 14rem;
overflow-y: auto;
border: 1px solid var(--line);
border-radius: 0.7rem;
background: var(--panel-strong);
box-shadow: 0 0.7rem 1.8rem rgba(16, 34, 51, 0.08);
}
.ppr-option {
display: flex;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
padding: 0.7rem 0.8rem;
border: 0;
border-bottom: 1px solid rgba(16, 34, 51, 0.08);
background: transparent;
color: var(--text);
cursor: pointer;
font: inherit;
text-align: left;
}
.ppr-option:hover,
.ppr-option:focus {
outline: none;
background: var(--brand-soft);
}
.ppr-option:last-child {
border-bottom: 0;
}
.ppr-option-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-weight: 800;
}
.ppr-option-detail {
color: var(--muted);
font-size: 0.86rem;
text-align: right;
}
.ppr-actions {
display: flex;
justify-content: flex-start;
padding-top: 0.4rem;
}
.ppr-actions .button {
border: 0;
cursor: pointer;
}
.ppr-actions .button:disabled {
cursor: wait;
opacity: 0.7;
}
.ppr-loading {
display: none;
align-items: center;
gap: 0.7rem;
color: var(--brand);
font-weight: 800;
}
.ppr-spinner {
width: 1.45rem;
height: 1.45rem;
border: 3px solid rgba(29, 118, 184, 0.18);
border-top-color: var(--brand-2);
border-radius: 50%;
animation: ppr-spin 0.8s linear infinite;
}
.ppr-success {
display: none;
border-left-color: #257b4c;
}
.ppr-notification {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 60;
max-width: min(24rem, calc(100vw - 2rem));
padding: 0.85rem 1rem;
border-radius: 0.8rem;
background: #257b4c;
color: white;
box-shadow: var(--shadow);
font-weight: 800;
opacity: 0;
pointer-events: none;
transform: translateY(-0.65rem);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.ppr-notification.show {
opacity: 1;
transform: translateY(0);
}
.ppr-notification.error {
background: var(--critical);
}
@keyframes ppr-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 720px) {
.ppr-grid,
.ppr-date-time {
grid-template-columns: 1fr;
}
.ppr-actions,
.ppr-actions .button {
width: 100%;
}
.ppr-option {
display: grid;
gap: 0.2rem;
}
.ppr-option-detail {
text-align: left;
}
}
</style>