Drone req handling update

This commit is contained in:
2026-06-22 05:58:26 -04:00
parent 05e7859447
commit f33c12f541
15 changed files with 192 additions and 36 deletions
+2 -1
View File
@@ -20,6 +20,7 @@ MAIL_USERNAME=your_mail_username_here
MAIL_PASSWORD=your_mail_password_here MAIL_PASSWORD=your_mail_password_here
MAIL_FROM=your_mail_from_address_here MAIL_FROM=your_mail_from_address_here
MAIL_FROM_NAME=your_mail_from_name_here MAIL_FROM_NAME=your_mail_from_name_here
DRONE_REQUEST_TOWER_EMAIL=tower@example.com
# Application settings # Application settings
BASE_URL=your_base_url_here BASE_URL=your_base_url_here
@@ -38,4 +39,4 @@ WEB_PORT_EXTERNAL=8082
# phpMyAdmin Configuration # phpMyAdmin Configuration
PMA_HOST=db PMA_HOST=db
UPLOAD_LIMIT=50M UPLOAD_LIMIT=50M
PMA_PORT_EXTERNAL=8083 PMA_PORT_EXTERNAL=8083
@@ -18,7 +18,6 @@ drone_status = sa.Enum(
'NEW', 'NEW',
'APPROVED', 'APPROVED',
'DENIED', 'DENIED',
'PENDING',
'CANCELED', 'CANCELED',
'INFLIGHT', 'INFLIGHT',
'COMPLETED', 'COMPLETED',
+39 -4
View File
@@ -17,6 +17,7 @@ from app.schemas.drone_request import (
DroneRequest, DroneRequest,
DroneRequestComment, DroneRequestComment,
DroneRequestCreate, DroneRequestCreate,
DroneRequestPublicSubmission,
DroneRequestStatus, DroneRequestStatus,
DroneRequestStatusUpdate, DroneRequestStatusUpdate,
DroneRequestUpdate, DroneRequestUpdate,
@@ -75,6 +76,32 @@ async def _send_drone_submitted_email(drone_request):
) )
async def _send_drone_tower_notification(drone_request):
tower_email = settings.drone_request_tower_email or settings.mail_from
await email_service.send_email(
to_email=tower_email,
subject=f"Drone flight request awaiting review {drone_request.reference_number}",
template_name="drone_request_tower_notification.html",
template_vars={
"reference_number": drone_request.reference_number,
"operator_name": drone_request.operator_name,
"operator_id": drone_request.operator_id,
"flyer_name": drone_request.flyer_name,
"flyer_id": drone_request.flyer_id,
"email": drone_request.email,
"phone": drone_request.phone,
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
"maximum_elevation_ft_amsl": drone_request.maximum_elevation_ft_amsl,
"inside_frz": "Yes" if drone_request.location_inside_frz else "No",
"notes": drone_request.applicant_notes,
"requests_url": f"{settings.base_url}/drone-requests",
},
reply_to=f"{drone_request.operator_name} <{drone_request.email}>",
)
async def _send_drone_approved_email(drone_request, message: Optional[str] = None): async def _send_drone_approved_email(drone_request, message: Optional[str] = None):
await email_service.send_email( await email_service.send_email(
to_email=drone_request.email, to_email=drone_request.email,
@@ -94,6 +121,13 @@ async def _send_drone_approved_email(drone_request, message: Optional[str] = Non
) )
def _public_submission_response(drone_request):
payload = DroneRequest.model_validate(drone_request, from_attributes=True).model_dump(mode="json")
payload["request_id"] = drone_request.reference_number
payload["secure_link"] = f"{settings.base_url}/drone-request.html?token={drone_request.public_token}"
return payload
@router.get("/", response_model=List[DroneRequest]) @router.get("/", response_model=List[DroneRequest])
async def get_drone_requests( async def get_drone_requests(
skip: int = 0, skip: int = 0,
@@ -114,7 +148,7 @@ async def get_drone_requests(
) )
@router.post("/public", response_model=DroneRequest) @router.post("/public", response_model=DroneRequestPublicSubmission)
async def create_public_drone_request( async def create_public_drone_request(
request: Request, request: Request,
drone_request_in: DroneRequestCreate, drone_request_in: DroneRequestCreate,
@@ -131,7 +165,8 @@ async def create_public_drone_request(
await _broadcast(request, "drone_request_created", drone_request) await _broadcast(request, "drone_request_created", drone_request)
await _send_drone_submitted_email(drone_request) await _send_drone_submitted_email(drone_request)
return drone_request await _send_drone_tower_notification(drone_request)
return _public_submission_response(drone_request)
@router.get("/public/edit/{token}", response_model=DroneRequest) @router.get("/public/edit/{token}", response_model=DroneRequest)
@@ -155,7 +190,7 @@ async def update_drone_request_public(
drone_request = crud_drone_request.get_by_public_token(db, token) drone_request = crud_drone_request.get_by_public_token(db, token)
if not drone_request: if not drone_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.PENDING, DroneRequestStatus.APPROVED]: if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.APPROVED]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Drone request cannot be edited while {drone_request.status.value}", detail=f"Drone request cannot be edited while {drone_request.status.value}",
@@ -182,7 +217,7 @@ async def cancel_drone_request_public(
drone_request = crud_drone_request.get_by_public_token(db, token) drone_request = crud_drone_request.get_by_public_token(db, token)
if not drone_request: if not drone_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.PENDING, DroneRequestStatus.APPROVED]: if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.APPROVED]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Drone request cannot be cancelled while {drone_request.status.value}", detail=f"Drone request cannot be cancelled while {drone_request.status.value}",
+1
View File
@@ -21,6 +21,7 @@ class Settings(BaseSettings):
mail_password: str mail_password: str
mail_from: str mail_from: str
mail_from_name: str mail_from_name: str
drone_request_tower_email: str | None = None
# Application settings # Application settings
api_v1_str: str = "/api/v1" api_v1_str: str = "/api/v1"
-1
View File
@@ -10,7 +10,6 @@ class DroneRequestStatus(str, Enum):
NEW = "NEW" NEW = "NEW"
APPROVED = "APPROVED" APPROVED = "APPROVED"
DENIED = "DENIED" DENIED = "DENIED"
PENDING = "PENDING"
CANCELED = "CANCELED" CANCELED = "CANCELED"
INFLIGHT = "INFLIGHT" INFLIGHT = "INFLIGHT"
COMPLETED = "COMPLETED" COMPLETED = "COMPLETED"
+5 -1
View File
@@ -9,7 +9,6 @@ class DroneRequestStatus(str, Enum):
NEW = "NEW" NEW = "NEW"
APPROVED = "APPROVED" APPROVED = "APPROVED"
DENIED = "DENIED" DENIED = "DENIED"
PENDING = "PENDING"
CANCELED = "CANCELED" CANCELED = "CANCELED"
INFLIGHT = "INFLIGHT" INFLIGHT = "INFLIGHT"
COMPLETED = "COMPLETED" COMPLETED = "COMPLETED"
@@ -104,3 +103,8 @@ class DroneRequest(DroneRequestBase):
class Config: class Config:
from_attributes = True from_attributes = True
class DroneRequestPublicSubmission(DroneRequest):
request_id: str
secure_link: str
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Drone Request Awaiting Review</title>
</head>
<body style="margin: 0; padding: 0; background: #f4f7fa; font-family: Arial, sans-serif; color: #263645;">
<table width="100%" cellpadding="0" cellspacing="0" style="background: #f4f7fa; padding: 24px;">
<tr>
<td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-radius: 8px; overflow: hidden;">
<tr>
<td style="background: #34495e; color: #ffffff; padding: 24px;">
<h1 style="margin: 0; font-size: 24px;">Drone request awaiting review</h1>
<p style="margin: 8px 0 0; font-size: 16px;">{{ reference_number }}</p>
</td>
</tr>
<tr>
<td style="padding: 24px;">
<p style="font-size: 17px; margin: 0 0 18px;">
A new drone flight request has been submitted. Please review it and approve or deny it as soon as practical.
</p>
<p style="margin: 0 0 22px;">
<a href="{{ requests_url }}" style="background: #2f93d1; color: #ffffff; display: inline-block; padding: 12px 18px; border-radius: 5px; text-decoration: none; font-weight: bold;">Open drone requests</a>
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; font-size: 15px;">
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Operator</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ operator_name }}{% if operator_id %} ({{ operator_id }}){% endif %}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Flyer</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ flyer_name or '-' }}{% if flyer_id %} ({{ flyer_id }}){% endif %}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Contact</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ email }}{% if phone %} / {{ phone }}{% endif %}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Takeoff</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ takeoff_time }}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Completion</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ completion_time }}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Location</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ location }}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Inside FRZ</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ inside_frz }}</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Max elevation</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_amsl }} ft AMSL</td></tr>
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Applicant notes</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ notes or '-' }}</td></tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+1
View File
@@ -17,6 +17,7 @@ os.environ.setdefault("MAIL_USERNAME", "test")
os.environ.setdefault("MAIL_PASSWORD", "test") os.environ.setdefault("MAIL_PASSWORD", "test")
os.environ.setdefault("MAIL_FROM", "noreply@example.test") os.environ.setdefault("MAIL_FROM", "noreply@example.test")
os.environ.setdefault("MAIL_FROM_NAME", "PPR Tests") os.environ.setdefault("MAIL_FROM_NAME", "PPR Tests")
os.environ.setdefault("DRONE_REQUEST_TOWER_EMAIL", "tower@swansea-airport.wales")
os.environ.setdefault("BASE_URL", "http://testserver") os.environ.setdefault("BASE_URL", "http://testserver")
os.environ.setdefault("ENVIRONMENT", "test") os.environ.setdefault("ENVIRONMENT", "test")
+5 -3
View File
@@ -47,7 +47,9 @@ def test_public_drone_request_create_edit_cancel_and_journal(client, db, monkeyp
assert created["status"] == "NEW" assert created["status"] == "NEW"
assert created["location_inside_frz"] is True assert created["location_inside_frz"] is True
assert created["created_by"] == "public" assert created["created_by"] == "public"
assert len(sent_emails) == 1 assert len(sent_emails) == 2
assert sent_emails[1]["to_email"] == "tower@swansea-airport.wales"
assert sent_emails[1]["template_name"] == "drone_request_tower_notification.html"
db_request = db.query(DroneRequest).filter(DroneRequest.id == created["id"]).one() db_request = db.query(DroneRequest).filter(DroneRequest.id == created["id"]).one()
assert db_request.public_token assert db_request.public_token
@@ -64,7 +66,7 @@ def test_public_drone_request_create_edit_cancel_and_journal(client, db, monkeyp
assert patch_response.json()["operator_name"] == "Updated Rotor Ops" assert patch_response.json()["operator_name"] == "Updated Rotor Ops"
assert cancel_response.status_code == 200 assert cancel_response.status_code == 200
assert cancel_response.json()["status"] == "CANCELED" assert cancel_response.json()["status"] == "CANCELED"
assert len(sent_emails) == 2 assert len(sent_emails) == 3
blocked_patch = client.patch( blocked_patch = client.patch(
f"/api/v1/drone-requests/public/edit/{db_request.public_token}", f"/api/v1/drone-requests/public/edit/{db_request.public_token}",
@@ -125,7 +127,7 @@ def test_authenticated_drone_request_list_update_status_comment_and_journal(auth
assert any("Drone request" in entry and "created" in entry for entry in entries) assert any("Drone request" in entry and "created" in entry for entry in entries)
assert any("Status changed from NEW to APPROVED" in entry for entry in entries) assert any("Status changed from NEW to APPROVED" in entry for entry in entries)
assert any("Comment added" in entry for entry in entries) assert any("Comment added" in entry for entry in entries)
assert len(sent_emails) == 3 assert len(sent_emails) == 4
def test_drone_request_not_found_and_validation_paths(auth_client, client): def test_drone_request_not_found_and_validation_paths(auth_client, client):
+1
View File
@@ -30,6 +30,7 @@ services:
MAIL_PASSWORD: e2e MAIL_PASSWORD: e2e
MAIL_FROM: e2e@example.com MAIL_FROM: e2e@example.com
MAIL_FROM_NAME: PPR E2E MAIL_FROM_NAME: PPR E2E
DRONE_REQUEST_TOWER_EMAIL: tower@example.com
web: web:
container_name: ppr_e2e_web container_name: ppr_e2e_web
+1
View File
@@ -24,6 +24,7 @@ services:
MAIL_PASSWORD: ${MAIL_PASSWORD} MAIL_PASSWORD: ${MAIL_PASSWORD}
MAIL_FROM: ${MAIL_FROM} MAIL_FROM: ${MAIL_FROM}
MAIL_FROM_NAME: ${MAIL_FROM_NAME} MAIL_FROM_NAME: ${MAIL_FROM_NAME}
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
BASE_URL: ${BASE_URL} BASE_URL: ${BASE_URL}
TAG: ${TAG} TAG: ${TAG}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR} TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
+1
View File
@@ -36,6 +36,7 @@ services:
MAIL_PASSWORD: ${MAIL_PASSWORD} MAIL_PASSWORD: ${MAIL_PASSWORD}
MAIL_FROM: ${MAIL_FROM} MAIL_FROM: ${MAIL_FROM}
MAIL_FROM_NAME: ${MAIL_FROM_NAME} MAIL_FROM_NAME: ${MAIL_FROM_NAME}
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
BASE_URL: ${BASE_URL} BASE_URL: ${BASE_URL}
TOWER_NAME: ${TOWER_NAME} TOWER_NAME: ${TOWER_NAME}
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR} TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
+1 -2
View File
@@ -38,7 +38,6 @@
.status-NEW { background: #3498db; } .status-NEW { background: #3498db; }
.status-APPROVED { background: #27ae60; } .status-APPROVED { background: #27ae60; }
.status-DENIED { background: #c0392b; } .status-DENIED { background: #c0392b; }
.status-PENDING { background: #f39c12; }
.status-CANCELED { background: #7f8c8d; } .status-CANCELED { background: #7f8c8d; }
.status-INFLIGHT { background: #8e44ad; } .status-INFLIGHT { background: #8e44ad; }
.status-COMPLETED { background: #2c3e50; } .status-COMPLETED { background: #2c3e50; }
@@ -237,7 +236,7 @@
setValue('notes', request.notes); setValue('notes', request.notes);
setValue('operator_comments', request.operator_comments); setValue('operator_comments', request.operator_comments);
const locked = !['NEW', 'PENDING', 'APPROVED'].includes(request.status); const locked = !['NEW', 'APPROVED'].includes(request.status);
document.getElementById('locked').style.display = locked ? 'block' : 'none'; document.getElementById('locked').style.display = locked ? 'block' : 'none';
document.getElementById('save-btn').disabled = locked; document.getElementById('save-btn').disabled = locked;
document.getElementById('cancel-btn').disabled = locked; document.getElementById('cancel-btn').disabled = locked;
+89 -20
View File
@@ -123,6 +123,17 @@
background: #eef6fb; background: #eef6fb;
} }
.request-group {
background: #f4f7f9;
border-bottom: 1px solid #dfe6ed;
color: #34495e;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.02em;
padding: 0.55rem 1rem;
text-transform: uppercase;
}
.request-ref { .request-ref {
font-weight: 700; font-weight: 700;
color: #263645; color: #263645;
@@ -150,7 +161,6 @@
.status-NEW { background: #3498db; } .status-NEW { background: #3498db; }
.status-APPROVED { background: #27ae60; } .status-APPROVED { background: #27ae60; }
.status-DENIED { background: #c0392b; } .status-DENIED { background: #c0392b; }
.status-PENDING { background: #f39c12; }
.status-CANCELED { background: #7f8c8d; } .status-CANCELED { background: #7f8c8d; }
.status-INFLIGHT { background: #8e44ad; } .status-INFLIGHT { background: #8e44ad; }
.status-COMPLETED { background: #2c3e50; } .status-COMPLETED { background: #2c3e50; }
@@ -373,15 +383,10 @@
<div class="detail-meta">Requests from the public drone flight form</div> <div class="detail-meta">Requests from the public drone flight form</div>
</div> </div>
<div class="filter-row"> <div class="filter-row">
<select id="status-filter" aria-label="Status filter"> <select id="request-view-filter" aria-label="Request view">
<option value="">All statuses</option> <option value="active">Active queue</option>
<option value="NEW">New</option> <option value="older">Earlier dates</option>
<option value="PENDING">Pending</option> <option value="closed">Denied / canceled</option>
<option value="APPROVED">Approved</option>
<option value="DENIED">Denied</option>
<option value="CANCELED">Canceled</option>
<option value="INFLIGHT">Inflight</option>
<option value="COMPLETED">Completed</option>
</select> </select>
<button class="btn btn-primary" onclick="loadRequests()">Refresh</button> <button class="btn btn-primary" onclick="loadRequests()">Refresh</button>
</div> </div>
@@ -479,6 +484,7 @@
let currentUser = null; let currentUser = null;
let selectedRequest = null; let selectedRequest = null;
let requests = []; let requests = [];
let requestGroups = [];
let map = null; let map = null;
let mapLayers = []; let mapLayers = [];
let frzGeometry = null; let frzGeometry = null;
@@ -486,7 +492,10 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.getElementById('login-form').addEventListener('submit', handleLogin); document.getElementById('login-form').addEventListener('submit', handleLogin);
document.getElementById('message-form').addEventListener('submit', sendMessageFromModal); document.getElementById('message-form').addEventListener('submit', sendMessageFromModal);
document.getElementById('status-filter').addEventListener('change', loadRequests); document.getElementById('request-view-filter').addEventListener('change', () => {
clearSelectedRequest();
loadRequests();
});
initializeAuth(); initializeAuth();
initializeMap(); initializeMap();
connectWebSocket(); connectWebSocket();
@@ -607,15 +616,13 @@
async function loadRequests() { async function loadRequests() {
if (!accessToken) return; if (!accessToken) return;
const status = document.getElementById('status-filter').value; const view = document.getElementById('request-view-filter').value;
const url = status ? `/api/v1/drone-requests/?status=${encodeURIComponent(status)}` : '/api/v1/drone-requests/';
const body = document.getElementById('request-list-body'); const body = document.getElementById('request-list-body');
body.innerHTML = '<div class="empty-state">Loading requests...</div>'; body.innerHTML = '<div class="empty-state">Loading requests...</div>';
try { try {
const response = await authenticatedFetch(url); requestGroups = await loadRequestGroups(view);
if (!response.ok) throw new Error('Failed to load drone requests'); requests = requestGroups.flatMap(group => group.items);
requests = await response.json();
renderRequestList(); renderRequestList();
if (selectedRequest) { if (selectedRequest) {
const fresh = requests.find(r => r.id === selectedRequest.id); const fresh = requests.find(r => r.id === selectedRequest.id);
@@ -634,16 +641,79 @@
} }
} }
async function loadRequestGroups(view) {
const today = getLocalDateString(new Date());
const yesterday = getLocalDateString(addDays(new Date(), -1));
if (view === 'older') {
const older = await fetchDroneRequests({ date_to: yesterday });
return [{ label: 'Earlier dated requests', items: older }];
}
if (view === 'closed') {
const [denied, canceled] = await Promise.all([
fetchDroneRequests({ status: 'DENIED' }),
fetchDroneRequests({ status: 'CANCELED' }),
]);
return [
{ label: 'Denied requests', items: denied },
{ label: 'Canceled requests', items: canceled },
];
}
const [newRequests, approvedToday, approvedUpcoming] = await Promise.all([
fetchDroneRequests({ status: 'NEW' }),
fetchDroneRequests({ status: 'APPROVED', date_from: today, date_to: today }),
fetchDroneRequests({ status: 'APPROVED', date_from: getLocalDateString(addDays(new Date(), 1)) }),
]);
return [
{ label: 'New requests', items: newRequests },
{ label: "Today's approved flights", items: approvedToday },
{ label: 'Upcoming approved flights', items: approvedUpcoming },
];
}
async function fetchDroneRequests(params) {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value) search.set(key, value);
});
const url = `/api/v1/drone-requests/${search.toString() ? `?${search.toString()}` : ''}`;
const response = await authenticatedFetch(url);
if (!response.ok) throw new Error('Failed to load drone requests');
return response.json();
}
function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function getLocalDateString(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function renderRequestList() { function renderRequestList() {
document.getElementById('request-count').textContent = requests.length; document.getElementById('request-count').textContent = requests.length;
setQueueOpen(!selectedRequest || document.getElementById('workspace').classList.contains('queue-open')); setQueueOpen(!selectedRequest || document.getElementById('workspace').classList.contains('queue-open'));
const body = document.getElementById('request-list-body'); const body = document.getElementById('request-list-body');
if (!requests.length) { if (!requestGroups.length) {
body.innerHTML = '<div class="empty-state">No requests match the current filter.</div>'; body.innerHTML = '<div class="empty-state">No requests match the current filter.</div>';
return; return;
} }
body.innerHTML = requests.map(req => ` body.innerHTML = requestGroups.map(group => `
<div class="request-group">${escapeHtml(group.label)} - ${group.items.length}</div>
${group.items.length ? group.items.map(renderRequestRow).join('') : '<div class="empty-state">None</div>'}
`).join('');
}
function renderRequestRow(req) {
return `
<button class="request-row ${selectedRequest && selectedRequest.id === req.id ? 'active' : ''}" onclick="selectRequest(${req.id})"> <button class="request-row ${selectedRequest && selectedRequest.id === req.id ? 'active' : ''}" onclick="selectRequest(${req.id})">
<div> <div>
<div class="request-ref">${escapeHtml(req.reference_number)}</div> <div class="request-ref">${escapeHtml(req.reference_number)}</div>
@@ -656,7 +726,7 @@
</div> </div>
<span class="status-pill status-${req.status}">${req.status}</span> <span class="status-pill status-${req.status}">${req.status}</span>
</button> </button>
`).join(''); `;
} }
async function selectRequest(id, collapseQueue = true) { async function selectRequest(id, collapseQueue = true) {
@@ -753,7 +823,6 @@
actions.innerHTML = ` actions.innerHTML = `
<select id="status-select" class="lifecycle-control" aria-label="New status"> <select id="status-select" class="lifecycle-control" aria-label="New status">
<option value="NEW">NEW</option> <option value="NEW">NEW</option>
<option value="PENDING">PENDING</option>
<option value="APPROVED">APPROVED</option> <option value="APPROVED">APPROVED</option>
<option value="DENIED">DENIED</option> <option value="DENIED">DENIED</option>
<option value="CANCELED">CANCELED</option> <option value="CANCELED">CANCELED</option>
+2 -3
View File
@@ -386,10 +386,12 @@
<body> <body>
<header> <header>
<img src="assets/logo.png" alt="EGFH Logo" class="left-image"> <img src="assets/logo.png" alt="EGFH Logo" class="left-image">
<!-- Booking QR hidden until the Book Out flow is ready.
<div class="qr-code-container"> <div class="qr-code-container">
<img id="bookingQR" alt="Scan to book a flight" title="Scan to book a flight"> <img id="bookingQR" alt="Scan to book a flight" title="Scan to book a flight">
<div class="qr-label">Book Out</div> <div class="qr-label">Book Out</div>
</div> </div>
-->
<h1>Flight Information</h1> <h1>Flight Information</h1>
<img src="assets/flightImg.png" alt="EGFH Logo" class="right-image"> <img src="assets/flightImg.png" alt="EGFH Logo" class="right-image">
</header> </header>
@@ -893,9 +895,6 @@
// Initialize Christmas mode // Initialize Christmas mode
initChristmasMode(); initChristmasMode();
// Load booking QR code
generateBookingQR();
loadArrivals(); loadArrivals();
loadDepartures(); loadDepartures();