Compare commits
10 Commits
ef273c0c5d
...
77b5080bbd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77b5080bbd | ||
|
|
7643c179fb | ||
|
|
023c238cee | ||
|
|
6e760a3e96 | ||
|
|
d5f05941c9 | ||
|
|
91e820b9a8 | ||
|
|
b6e32eccad | ||
|
|
9d77e11627 | ||
|
|
1223d9e9f9 | ||
|
|
41c7bb352a |
@@ -4,6 +4,16 @@ General Information
|
||||
|
||||
As you perform more tests, document in this file the steps you are taking to avoid repetition.
|
||||
|
||||
## System Overview
|
||||
|
||||
The NextGen PPR system includes:
|
||||
- **FastAPI Backend** with comprehensive REST API
|
||||
- **MySQL Database** with full PPR tracking and audit trails
|
||||
- **Web Interfaces** for public submission, admin management, and reporting
|
||||
- **Email Notifications** for PPR submissions and cancellations
|
||||
- **Real-time Updates** via WebSocket for live tower operations
|
||||
- **Test Data Generation** utilities for development and testing
|
||||
|
||||
## API Base URL
|
||||
- Development: `http://localhost:8001/api/v1`
|
||||
- Authentication: Bearer token required for most endpoints
|
||||
@@ -145,6 +155,58 @@ curl -X POST "http://localhost:8001/api/v1/pprs/" \
|
||||
|
||||
**Endpoint:** `DELETE /api/v1/pprs/{ppr_id}` (soft delete - sets status to DELETED)
|
||||
|
||||
## User Management Endpoints (Admin Only)
|
||||
|
||||
### List Users
|
||||
|
||||
**Endpoint:** `GET /api/v1/auth/users`
|
||||
|
||||
### Create User
|
||||
|
||||
**Endpoint:** `POST /api/v1/auth/users`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"username": "newuser",
|
||||
"password": "securepassword",
|
||||
"role": "OPERATOR"
|
||||
}
|
||||
```
|
||||
|
||||
**Available Roles:**
|
||||
- `ADMINISTRATOR`: Full access including user management
|
||||
- `OPERATOR`: PPR management access
|
||||
- `READ_ONLY`: View-only access
|
||||
|
||||
### Get User Details
|
||||
|
||||
**Endpoint:** `GET /api/v1/auth/users/{user_id}`
|
||||
|
||||
### Update User
|
||||
|
||||
**Endpoint:** `PUT /api/v1/auth/users/{user_id}`
|
||||
|
||||
### Delete User
|
||||
|
||||
**Endpoint:** `DELETE /api/v1/auth/users/{user_id}`
|
||||
|
||||
## Reference Data Endpoints
|
||||
|
||||
### Lookup Airport
|
||||
|
||||
**Endpoint:** `GET /api/v1/airport/lookup/{icao_code}`
|
||||
|
||||
Returns airport details for the given ICAO code.
|
||||
|
||||
### Lookup Aircraft
|
||||
|
||||
**Endpoint:** `GET /api/v1/aircraft/lookup/{registration}`
|
||||
|
||||
Returns aircraft details for the given registration.
|
||||
|
||||
## Reference Data Endpoints
|
||||
|
||||
## Public Endpoints (No Authentication Required)
|
||||
|
||||
### Get Today's Arrivals
|
||||
@@ -159,6 +221,40 @@ Returns all PPRs with status "NEW" and ETA for today.
|
||||
|
||||
Returns all PPRs with status "LANDED" and ETD for today.
|
||||
|
||||
### Submit Public PPR
|
||||
|
||||
**Endpoint:** `POST /api/v1/public/pprs/`
|
||||
|
||||
**Headers:**
|
||||
- `Content-Type: application/json`
|
||||
|
||||
**Request Body:** Same as authenticated PPR creation (see above)
|
||||
|
||||
**Notes:**
|
||||
- No authentication required
|
||||
- Sends confirmation email if email provided
|
||||
- Returns PPR with public edit token
|
||||
|
||||
### Get PPR for Public Editing
|
||||
|
||||
**Endpoint:** `GET /api/v1/public/edit/{token}`
|
||||
|
||||
Returns PPR details for public editing using the token from email.
|
||||
|
||||
### Update PPR Publicly
|
||||
|
||||
**Endpoint:** `PATCH /api/v1/public/edit/{token}`
|
||||
|
||||
**Request Body:** Same as authenticated PPR update
|
||||
|
||||
**Notes:** Only allowed if PPR status is not processed (not LANDED, DEPARTED, CANCELED, or DELETED)
|
||||
|
||||
### Cancel PPR Publicly
|
||||
|
||||
**Endpoint:** `DELETE /api/v1/public/cancel/{token}`
|
||||
|
||||
Cancels PPR using public token and sends cancellation email.
|
||||
|
||||
## Data Models
|
||||
|
||||
### PPR Status Enum
|
||||
@@ -256,10 +352,14 @@ The system automatically logs:
|
||||
|
||||
## Web Interfaces
|
||||
|
||||
### Public Arrivals/Departures Board
|
||||
### Public PPR Forms
|
||||
- **URL:** http://localhost:8082
|
||||
- **Features:** Real-time arrivals and departures display
|
||||
- **Authentication:** None required
|
||||
- **Features:**
|
||||
- PPR submission with intelligent aircraft/airport field lookups
|
||||
- Date/time pickers with 15-minute intervals
|
||||
- Email notifications for submissions
|
||||
- Public editing/cancellation via secure email tokens
|
||||
- Real-time validation and feedback
|
||||
|
||||
### Admin Interface
|
||||
- **URL:** http://localhost:8082/admin.html
|
||||
@@ -270,15 +370,80 @@ The system automatically logs:
|
||||
- Journal/audit trail viewing
|
||||
- Quick status updates (Confirm, Land, Depart, Cancel)
|
||||
- New PPR entry creation
|
||||
- User management (administrators only)
|
||||
- Real-time WebSocket updates
|
||||
- **Authentication:** Username/password prompt (uses API token)
|
||||
|
||||
### Reports Interface
|
||||
- **URL:** http://localhost:8082/reports.html
|
||||
- **Features:**
|
||||
- Comprehensive PPR reporting with date range filtering (defaults to current month)
|
||||
- Search across aircraft registration, callsign, captain name, and airports
|
||||
- Status-based filtering
|
||||
- CSV and XLS export functionality
|
||||
- Responsive table displaying all PPR fields
|
||||
- Real-time data loading with authentication
|
||||
|
||||
## Development URLs
|
||||
|
||||
- API Base: http://localhost:8001/api/v1
|
||||
- Public Web Interface: http://localhost:8082
|
||||
- Admin Interface: http://localhost:8082/admin.html
|
||||
- Reports Interface: http://localhost:8082/reports.html
|
||||
- API Documentation: http://localhost:8001/docs
|
||||
- Database: localhost:3307 (MySQL)
|
||||
- phpMyAdmin: http://localhost:8083
|
||||
|
||||
## Email System
|
||||
|
||||
The system includes transactional email support:
|
||||
|
||||
### Configuration
|
||||
Email settings are configured via environment variables:
|
||||
- `SMTP_SERVER`: SMTP server hostname
|
||||
- `SMTP_PORT`: SMTP server port (default: 587)
|
||||
- `SMTP_USERNAME`: SMTP authentication username
|
||||
- `SMTP_PASSWORD`: SMTP authentication password
|
||||
- `FROM_EMAIL`: Sender email address
|
||||
- `BASE_URL`: Base URL for email links
|
||||
|
||||
### Email Templates
|
||||
- **PPR Submitted**: Sent when public PPR is created
|
||||
- **PPR Cancelled**: Sent when PPR is cancelled (by user or admin)
|
||||
|
||||
### Email Content
|
||||
Emails include:
|
||||
- PPR details (aircraft, times, contact info)
|
||||
- Secure edit/cancel links with tokens
|
||||
- Formatted HTML with system branding
|
||||
|
||||
## Test Data Generation
|
||||
|
||||
The system includes comprehensive test data generation utilities:
|
||||
|
||||
### Generate Test PPRs
|
||||
```bash
|
||||
# Run from host
|
||||
./populate_test_data.sh
|
||||
|
||||
# Or run directly in container
|
||||
docker exec -it ppr_nextgen_api python populate_test_data.py
|
||||
```
|
||||
|
||||
### What It Creates
|
||||
- **93 PPR records** across all statuses (NEW, CONFIRMED, LANDED, DEPARTED, CANCELED)
|
||||
- **Diverse aircraft types** from the loaded aircraft database
|
||||
- **Realistic airport combinations** from the loaded airport data
|
||||
- **Varied captains and contact details**
|
||||
- **Proper date/time distributions** across different time periods
|
||||
- **Status distribution** matching real-world usage patterns
|
||||
|
||||
### Test Data Features
|
||||
- Aircraft registrations with proper formatting
|
||||
- Realistic passenger counts and fuel requirements
|
||||
- Geographic distribution across UK airports
|
||||
- Time-based distribution for testing date filters
|
||||
- Email addresses for testing notification system
|
||||
|
||||
## Example Workflow
|
||||
|
||||
@@ -309,4 +474,90 @@ curl -X PATCH "http://localhost:8001/api/v1/pprs/1/status" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"status":"LANDED"}'
|
||||
|
||||
# Test public PPR submission (no auth required)
|
||||
curl -X POST "http://localhost:8001/api/v1/public/pprs/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ac_reg":"G-PUBLIC","ac_type":"PA28","captain":"Public User","in_from":"EGGW","eta":"2025-10-21T15:00:00","pob_in":1,"email":"test@example.com"}'
|
||||
|
||||
# Test filtering by date range
|
||||
curl -s "http://localhost:8001/api/v1/pprs/?date_from=2025-10-01&date_to=2025-10-31&limit=10" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
|
||||
# Test user management (admin only)
|
||||
curl -s "http://localhost:8001/api/v1/auth/users" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
|
||||
# Generate test data
|
||||
docker exec -it ppr_nextgen_api python populate_test_data.py
|
||||
```
|
||||
|
||||
## Enhanced Features
|
||||
|
||||
### Intelligent Form Lookups
|
||||
- **Aircraft Lookup**: Auto-suggests aircraft type based on registration
|
||||
- **Airport Lookup**: Provides airport name and location for ICAO codes
|
||||
- **Real-time Validation**: Immediate feedback on form inputs
|
||||
|
||||
### Advanced Date/Time Handling
|
||||
- **15-Minute Intervals**: All time pickers use 15-minute increments
|
||||
- **Separate Date/Time Fields**: Improved UX with dedicated date and time inputs
|
||||
- **UTC Storage**: All times stored in UTC for consistency
|
||||
|
||||
### Comprehensive Reporting
|
||||
- **Date Range Filtering**: Filter PPRs by custom date ranges
|
||||
- **Multi-field Search**: Search across registration, callsign, captain, airports
|
||||
- **Export Functionality**: CSV and XLS download with all PPR data
|
||||
- **Responsive Design**: Works on desktop and mobile devices
|
||||
|
||||
### Audit Trail System
|
||||
- **Complete Change History**: Every field change is logged
|
||||
- **User Tracking**: Who made changes and when
|
||||
- **IP Address Logging**: Security tracking for all actions
|
||||
- **Automatic Journal Entries**: No manual logging required
|
||||
|
||||
## Example Complete Workflow
|
||||
|
||||
1. **Public Submission**
|
||||
```bash
|
||||
# User submits PPR via public form
|
||||
curl -X POST "http://localhost:8001/api/v1/public/pprs/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ac_reg":"G-WORKFLOW","ac_type":"C152","captain":"Workflow Test","in_from":"EGKB","eta":"2025-10-21T14:00:00","pob_in":1,"email":"workflow@example.com"}'
|
||||
```
|
||||
|
||||
2. **Email Notification Sent**
|
||||
- User receives confirmation email with edit/cancel links
|
||||
|
||||
3. **Admin Processing**
|
||||
```bash
|
||||
# Admin confirms PPR
|
||||
curl -X PATCH "http://localhost:8001/api/v1/pprs/1/status" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"status":"CONFIRMED"}'
|
||||
```
|
||||
|
||||
4. **Status Updates**
|
||||
```bash
|
||||
# Mark as landed
|
||||
curl -X PATCH "http://localhost:8001/api/v1/pprs/1/status" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"status":"LANDED","timestamp":"2025-10-21T14:05:00Z"}'
|
||||
|
||||
# Mark as departed
|
||||
curl -X PATCH "http://localhost:8001/api/v1/pprs/1/status" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"status":"DEPARTED","timestamp":"2025-10-21T15:30:00Z"}'
|
||||
```
|
||||
|
||||
5. **Public Editing/Cancellation**
|
||||
- User can edit or cancel using token from email
|
||||
- System sends cancellation email if cancelled
|
||||
|
||||
6. **Reporting**
|
||||
- Access reports at http://localhost:8082/reports.html
|
||||
- Filter by date range, status, search terms
|
||||
- Export to CSV/XLS for analysis
|
||||
22
.env.example
Normal file
22
.env.example
Normal file
@@ -0,0 +1,22 @@
|
||||
# Database Configuration
|
||||
MYSQL_ROOT_PASSWORD=your_mysql_root_password_here
|
||||
DB_USER=ppr_user
|
||||
DB_PASSWORD=your_database_password_here
|
||||
DB_NAME=ppr
|
||||
DB_PORT=3306
|
||||
|
||||
# API Configuration
|
||||
DB_HOST=db
|
||||
SECRET_KEY=your_secret_key_here_change_in_production
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
API_V1_STR=/api/v1
|
||||
PROJECT_NAME=Airfield PPR API NextGen
|
||||
API_PORT_EXTERNAL=8001
|
||||
|
||||
# Web Configuration
|
||||
WEB_PORT_EXTERNAL=8082
|
||||
|
||||
# phpMyAdmin Configuration
|
||||
PMA_HOST=db
|
||||
UPLOAD_LIMIT=50M
|
||||
PMA_PORT_EXTERNAL=8083
|
||||
102
README.md
102
README.md
@@ -12,12 +12,16 @@ A modern, containerized Prior Permission Required (PPR) system for aircraft oper
|
||||
## Features
|
||||
|
||||
- 🚀 **Modern API**: RESTful API with automatic OpenAPI documentation
|
||||
- 🔐 **Authentication**: JWT-based authentication system
|
||||
- 📊 **Real-time Updates**: WebSocket support for live tower updates
|
||||
- 🗄️ **Self-contained**: Fully dockerized with local database
|
||||
- 🔐 **Authentication**: JWT-based authentication system with role-based access
|
||||
- <EFBFBD> **Transactional Email**: SMTP-based email notifications for PPR submissions and cancellations
|
||||
- <EFBFBD>📊 **Real-time Updates**: WebSocket support for live tower updates
|
||||
- <20> **Comprehensive Reporting**: Advanced reporting with filtering, search, and CSV/XLS export
|
||||
- <20>🗄️ **Self-contained**: Fully dockerized with local database
|
||||
- 🔍 **Documentation**: Auto-generated API docs at `/docs`
|
||||
- 🧪 **Testing**: Comprehensive test suite
|
||||
- 🧪 **Testing**: Comprehensive test suite with data population utilities
|
||||
- 📱 **Mobile Ready**: Responsive design for tower operations
|
||||
- 🔄 **Public Forms**: Enhanced public PPR submission and editing with intelligent field lookups
|
||||
- 📋 **Audit Trail**: Complete journal system tracking all PPR changes
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -33,7 +37,11 @@ cd nextgen
|
||||
### 2. Access the Services
|
||||
- **API Documentation**: http://localhost:8001/docs
|
||||
- **API Base URL**: http://localhost:8001/api/v1
|
||||
- **Public Web Interface**: http://localhost:8082
|
||||
- **Admin Interface**: http://localhost:8082/admin.html
|
||||
- **Reports Interface**: http://localhost:8082/reports.html
|
||||
- **Database**: localhost:3307 (user: ppr_user, password: ppr_password123)
|
||||
- **phpMyAdmin**: http://localhost:8083
|
||||
|
||||
### 3. Default Login
|
||||
- **Username**: admin
|
||||
@@ -45,20 +53,68 @@ cd nextgen
|
||||
- `POST /api/v1/auth/login` - Login and get JWT token
|
||||
|
||||
### PPR Management
|
||||
- `GET /api/v1/pprs` - List PPR records
|
||||
- `GET /api/v1/pprs` - List PPR records (with filtering by status, date range)
|
||||
- `POST /api/v1/pprs` - Create new PPR
|
||||
- `GET /api/v1/pprs/{id}` - Get specific PPR
|
||||
- `PUT /api/v1/pprs/{id}` - Update PPR
|
||||
- `PATCH /api/v1/pprs/{id}` - Partially update PPR
|
||||
- `PATCH /api/v1/pprs/{id}/status` - Update PPR status
|
||||
- `DELETE /api/v1/pprs/{id}` - Delete PPR
|
||||
- `GET /api/v1/pprs/{id}/journal` - Get PPR activity journal
|
||||
|
||||
### Public Endpoints (No Auth Required)
|
||||
- `GET /api/v1/public/arrivals` - Today's arrivals
|
||||
- `GET /api/v1/public/departures` - Today's departures
|
||||
- `POST /api/v1/public/pprs` - Submit public PPR
|
||||
- `GET /api/v1/public/edit/{token}` - Get PPR for public editing
|
||||
- `PATCH /api/v1/public/edit/{token}` - Update PPR publicly
|
||||
- `DELETE /api/v1/public/cancel/{token}` - Cancel PPR publicly
|
||||
|
||||
### User Management (Admin Only)
|
||||
- `GET /api/v1/auth/users` - List users
|
||||
- `POST /api/v1/auth/users` - Create user
|
||||
- `GET /api/v1/auth/users/{id}` - Get user details
|
||||
- `PUT /api/v1/auth/users/{id}` - Update user
|
||||
- `DELETE /api/v1/auth/users/{id}` - Delete user
|
||||
|
||||
### Reference Data
|
||||
- `GET /api/v1/airport/lookup/{code}` - Lookup airport by ICAO code
|
||||
- `GET /api/v1/aircraft/lookup/{reg}` - Lookup aircraft by registration
|
||||
|
||||
### Real-time
|
||||
- `WebSocket /ws/tower-updates` - Live updates for tower operations
|
||||
|
||||
## Web Interfaces
|
||||
|
||||
### Public PPR Forms
|
||||
- **URL**: http://localhost:8082
|
||||
- **Features**:
|
||||
- PPR submission form with intelligent aircraft/airport lookups
|
||||
- Date/time pickers with 15-minute intervals
|
||||
- Email notifications for submissions
|
||||
- Public editing/cancellation via secure tokens
|
||||
|
||||
### Admin Interface
|
||||
- **URL**: http://localhost:8082/admin.html
|
||||
- **Features**:
|
||||
- Complete PPR management (CRUD operations)
|
||||
- Advanced filtering by status, date range
|
||||
- Inline editing with modal interface
|
||||
- Journal/audit trail viewing
|
||||
- Quick status updates (Confirm, Land, Depart, Cancel)
|
||||
- New PPR entry creation
|
||||
- User management (administrators only)
|
||||
- Real-time WebSocket updates
|
||||
|
||||
### Reports Interface
|
||||
- **URL**: http://localhost:8082/reports.html
|
||||
- **Features**:
|
||||
- Comprehensive PPR reporting with date range filtering
|
||||
- Search across aircraft, captain, and airport fields
|
||||
- Status-based filtering
|
||||
- CSV and XLS export functionality
|
||||
- Responsive table with all PPR details
|
||||
|
||||
## Development
|
||||
|
||||
### Local Development
|
||||
@@ -87,6 +143,33 @@ cd backend
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
## Additional Features
|
||||
|
||||
### Email Notifications
|
||||
The system includes transactional email support for:
|
||||
- **PPR Submissions**: Automatic email confirmation to submitters
|
||||
- **PPR Cancellations**: Notification emails when PPRs are cancelled
|
||||
- **SMTP Configuration**: Configurable SMTP settings via environment variables
|
||||
|
||||
### Test Data Generation
|
||||
A comprehensive test data population script is included:
|
||||
```bash
|
||||
# Generate test PPR records
|
||||
docker exec -it ppr_nextgen_api python populate_test_data.py
|
||||
|
||||
# Or run the convenience script
|
||||
./populate_test_data.sh
|
||||
```
|
||||
|
||||
This creates diverse PPR records across all statuses with realistic aircraft and airport data for testing purposes.
|
||||
|
||||
### Audit Trail
|
||||
Complete activity logging system tracks:
|
||||
- All PPR field changes with before/after values
|
||||
- Status transitions with timestamps
|
||||
- User actions and IP addresses
|
||||
- Automatic journal entries for all modifications
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Key environment variables (configured in docker-compose.yml):
|
||||
@@ -98,6 +181,15 @@ Key environment variables (configured in docker-compose.yml):
|
||||
- `SECRET_KEY` - JWT secret key
|
||||
- `ACCESS_TOKEN_EXPIRE_MINUTES` - Token expiration time
|
||||
|
||||
### Email Configuration
|
||||
- `SMTP_SERVER` - SMTP server hostname
|
||||
- `SMTP_PORT` - SMTP server port (default: 587)
|
||||
- `SMTP_USERNAME` - SMTP authentication username
|
||||
- `SMTP_PASSWORD` - SMTP authentication password
|
||||
- `SMTP_TLS` - Enable TLS (default: true)
|
||||
- `FROM_EMAIL` - Sender email address
|
||||
- `BASE_URL` - Base URL for email links
|
||||
|
||||
## Database Schema
|
||||
|
||||
The system uses an improved version of the original schema with:
|
||||
|
||||
138
backend/README_test_data.md
Normal file
138
backend/README_test_data.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Test Data Population Script
|
||||
|
||||
This script generates and inserts 30 random PPR (Prior Permission Required) records into the database for testing purposes.
|
||||
|
||||
## Features
|
||||
|
||||
- **30 Random PPR Records**: Generates diverse test data with various aircraft, airports, and flight details
|
||||
- **Real Aircraft Data**: Uses actual aircraft registration data from the `aircraft_data.csv` file
|
||||
- **Real Airport Data**: Uses actual airport ICAO codes from the `airports_data_clean.csv` file
|
||||
- **Random Status Distribution**: Includes NEW, CONFIRMED, LANDED, and DEPARTED statuses
|
||||
- **Realistic Timestamps**: Generates ETA/ETD times with 15-minute intervals
|
||||
- **Optional Fields**: Randomly includes email, phone, notes, and departure details
|
||||
- **Duplicate Aircraft**: Some aircraft registrations appear multiple times for realistic testing
|
||||
|
||||
## Usage
|
||||
|
||||
### Prerequisites
|
||||
- Database must be running and accessible
|
||||
- Python environment with required dependencies installed
|
||||
- CSV data files (`aircraft_data.csv` and `airports_data_clean.csv`) in the parent directory
|
||||
|
||||
### Running the Script
|
||||
|
||||
1. **Using the convenience script** (recommended):
|
||||
```bash
|
||||
cd /home/jamesp/docker/pprdev/nextgen
|
||||
./populate_test_data.sh
|
||||
```
|
||||
|
||||
2. **From within the Docker container**:
|
||||
```bash
|
||||
docker exec -it ppr-backend bash
|
||||
cd /app
|
||||
python populate_test_data.py
|
||||
```
|
||||
|
||||
3. **From host machine** (if database is accessible):
|
||||
```bash
|
||||
cd /home/jamesp/docker/pprdev/nextgen/backend
|
||||
python populate_test_data.py
|
||||
```
|
||||
|
||||
## What Gets Generated
|
||||
|
||||
Each PPR record includes:
|
||||
- **Aircraft**: Random registration, type, and callsign from real aircraft data
|
||||
- **Route**: Random arrival airport (from Swansea), optional departure airport
|
||||
- **Times**: ETA between 6 AM - 8 PM, ETD 1-4 hours later (if departing)
|
||||
- **Passengers**: 1-4 POB for arrival, optional for departure
|
||||
- **Contact**: Optional email and phone (70% and 50% chance respectively)
|
||||
- **Fuel**: Random fuel type (100LL, JET A1, FULL) or none
|
||||
- **Notes**: Optional flight purpose notes (various scenarios)
|
||||
- **Status**: Random status distribution (NEW/CONFIRMED/LANDED/DEPARTED)
|
||||
- **Timestamps**: Random submission dates within last 30 days
|
||||
- **Public Token**: Auto-generated for edit/cancel functionality
|
||||
|
||||
### Aircraft Distribution
|
||||
- Uses real aircraft registration data from `aircraft_data.csv`
|
||||
- Includes various aircraft types (C172, PA28, BE36, R44, etc.)
|
||||
- Some aircraft appear multiple times for realistic duplication
|
||||
|
||||
### Airport Distribution
|
||||
- Uses real ICAO airport codes from `airports_data_clean.csv`
|
||||
- Arrival airports are distributed globally
|
||||
- Departure airports (when included) are different from arrival airports
|
||||
|
||||
### Data Quality Notes
|
||||
|
||||
- **Realistic Distribution**: Aircraft and airports are selected from actual aviation data
|
||||
- **Time Constraints**: All times are within reasonable operating hours (6 AM - 8 PM)
|
||||
- **Status Balance**: Roughly equal distribution across different PPR statuses
|
||||
- **Contact Info**: Realistic email patterns and UK phone numbers
|
||||
- **Flight Logic**: Departures only occur when a departure airport is specified
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Database schema matches the PPRRecord model in `app/models/ppr.py`
|
||||
- CSV files are present and properly formatted
|
||||
- Database connection uses settings from `app/core/config.py`
|
||||
- All required dependencies are installed in the Python environment
|
||||
|
||||
### Sample Output
|
||||
|
||||
```
|
||||
Loading aircraft and airport data...
|
||||
Loaded 520000 aircraft records
|
||||
Loaded 43209 airport records
|
||||
Generating and inserting 30 test PPR records...
|
||||
Generated 10 records...
|
||||
Generated 20 records...
|
||||
Generated 30 records...
|
||||
✅ Successfully inserted 30 test PPR records!
|
||||
Total PPR records in database: 42
|
||||
|
||||
Status breakdown:
|
||||
NEW: 8
|
||||
CONFIRMED: 7
|
||||
LANDED: 9
|
||||
DEPARTED: 6
|
||||
```
|
||||
|
||||
## Safety Notes
|
||||
|
||||
- **Non-destructive**: Only adds new records, doesn't modify existing data
|
||||
- **Test Data Only**: All generated data is clearly identifiable as test data
|
||||
- **Easy Cleanup**: Can be easily removed with SQL queries if needed
|
||||
|
||||
## Current Status ✅
|
||||
|
||||
The script is working correctly! It has successfully generated and inserted test data. As of the latest run:
|
||||
|
||||
- **Total PPR records in database**: 93
|
||||
- **Status breakdown**:
|
||||
- NEW: 19
|
||||
- CONFIRMED: 22
|
||||
- CANCELED: 1
|
||||
- LANDED: 35
|
||||
- DEPARTED: 16
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Database Connection**: Ensure the database container is running and accessible
|
||||
- **CSV Files**: The script uses fallback data when CSV files aren't found (which is normal in containerized environments)
|
||||
- **Dependencies**: Ensure all Python requirements are installed
|
||||
- **Permissions**: Script needs database write permissions
|
||||
|
||||
## Recent Fixes
|
||||
|
||||
- ✅ Fixed SQLAlchemy 2.0 `func.count()` import issue
|
||||
- ✅ Script now runs successfully and provides status breakdown
|
||||
- ✅ Uses fallback aircraft/airport data when CSV files aren't accessible
|
||||
|
||||
## Cleanup (if needed)
|
||||
|
||||
To remove all test data:
|
||||
```sql
|
||||
DELETE FROM submitted WHERE submitted_dt > '2025-01-01'; -- Adjust date as needed
|
||||
```
|
||||
@@ -33,6 +33,30 @@ async def lookup_aircraft_by_registration(
|
||||
return aircraft_list
|
||||
|
||||
|
||||
@router.get("/public/lookup/{registration}", response_model=List[AircraftSchema])
|
||||
async def public_lookup_aircraft_by_registration(
|
||||
registration: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Public lookup aircraft by registration (clean match).
|
||||
Removes non-alphanumeric characters from input for matching.
|
||||
No authentication required.
|
||||
"""
|
||||
# Clean the input registration (remove non-alphanumeric characters)
|
||||
clean_input = ''.join(c for c in registration if c.isalnum()).upper()
|
||||
|
||||
if len(clean_input) < 4:
|
||||
return []
|
||||
|
||||
# Query aircraft table using clean_reg column
|
||||
aircraft_list = db.query(Aircraft).filter(
|
||||
Aircraft.clean_reg.like(f"{clean_input}%")
|
||||
).limit(10).all()
|
||||
|
||||
return aircraft_list
|
||||
|
||||
|
||||
@router.get("/search", response_model=List[AircraftSchema])
|
||||
async def search_aircraft(
|
||||
q: Optional[str] = Query(None, description="Search query for registration, type, or manufacturer"),
|
||||
|
||||
@@ -69,3 +69,38 @@ async def search_airports(
|
||||
).limit(limit).all()
|
||||
|
||||
return airports
|
||||
|
||||
|
||||
@router.get("/public/lookup/{code_or_name}", response_model=List[AirportSchema])
|
||||
async def public_lookup_airport_by_code_or_name(
|
||||
code_or_name: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Public lookup airport by ICAO code or name.
|
||||
If input is 4 characters and all uppercase letters, treat as ICAO code.
|
||||
Otherwise, search by name.
|
||||
No authentication required.
|
||||
"""
|
||||
clean_input = code_or_name.strip().upper()
|
||||
|
||||
if len(clean_input) < 2:
|
||||
return []
|
||||
|
||||
# Check if input looks like an ICAO code (4 letters)
|
||||
if len(clean_input) == 4 and clean_input.isalpha():
|
||||
# Exact ICAO match first
|
||||
airport = db.query(Airport).filter(Airport.icao == clean_input).first()
|
||||
if airport:
|
||||
return [airport]
|
||||
# Then search ICAO codes that start with input
|
||||
airports = db.query(Airport).filter(
|
||||
Airport.icao.like(clean_input + "%")
|
||||
).limit(5).all()
|
||||
return airports
|
||||
else:
|
||||
# Search by name (case-insensitive partial match)
|
||||
airports = db.query(Airport).filter(
|
||||
Airport.name.ilike("%" + code_or_name + "%")
|
||||
).limit(10).all()
|
||||
return airports
|
||||
@@ -8,6 +8,8 @@ from app.crud.crud_journal import journal as crud_journal
|
||||
from app.schemas.ppr import PPR, PPRCreate, PPRUpdate, PPRStatus, PPRStatusUpdate, Journal
|
||||
from app.models.ppr import User
|
||||
from app.core.utils import get_client_ip
|
||||
from app.core.email import email_service
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -56,6 +58,48 @@ async def create_ppr(
|
||||
return ppr
|
||||
|
||||
|
||||
@router.post("/public", response_model=PPR)
|
||||
async def create_public_ppr(
|
||||
request: Request,
|
||||
ppr_in: PPRCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new PPR record (public endpoint, no authentication required)"""
|
||||
client_ip = get_client_ip(request)
|
||||
# For public submissions, use a default created_by or None
|
||||
ppr = crud_ppr.create(db, obj_in=ppr_in, created_by="public", user_ip=client_ip)
|
||||
|
||||
# Send real-time update via WebSocket
|
||||
if hasattr(request.app.state, 'connection_manager'):
|
||||
await request.app.state.connection_manager.broadcast({
|
||||
"type": "ppr_created",
|
||||
"data": {
|
||||
"id": ppr.id,
|
||||
"ac_reg": ppr.ac_reg,
|
||||
"status": ppr.status.value
|
||||
}
|
||||
})
|
||||
|
||||
# Send email if email provided
|
||||
if ppr_in.email:
|
||||
await email_service.send_email(
|
||||
to_email=ppr_in.email,
|
||||
subject="PPR Submitted Successfully",
|
||||
template_name="ppr_submitted.html",
|
||||
template_vars={
|
||||
"name": ppr_in.captain,
|
||||
"aircraft": ppr_in.ac_reg,
|
||||
"arrival_time": ppr_in.eta.strftime("%Y-%m-%d %H:%M"),
|
||||
"departure_time": ppr_in.etd.strftime("%Y-%m-%d %H:%M") if ppr_in.etd else "N/A",
|
||||
"purpose": ppr_in.notes or "N/A",
|
||||
"public_token": ppr.public_token,
|
||||
"base_url": settings.base_url
|
||||
}
|
||||
)
|
||||
|
||||
return ppr
|
||||
|
||||
|
||||
@router.get("/{ppr_id}", response_model=PPR)
|
||||
async def get_ppr(
|
||||
ppr_id: int,
|
||||
@@ -174,6 +218,20 @@ async def update_ppr_status(
|
||||
}
|
||||
})
|
||||
|
||||
# Send email if cancelled and email provided
|
||||
if status_update.status == PPRStatus.CANCELED and ppr.email:
|
||||
await email_service.send_email(
|
||||
to_email=ppr.email,
|
||||
subject="PPR Cancelled",
|
||||
template_name="ppr_cancelled.html",
|
||||
template_vars={
|
||||
"name": ppr.captain,
|
||||
"aircraft": ppr.ac_reg,
|
||||
"arrival_time": ppr.eta.strftime("%Y-%m-%d %H:%M"),
|
||||
"departure_time": ppr.etd.strftime("%Y-%m-%d %H:%M") if ppr.etd else "N/A"
|
||||
}
|
||||
)
|
||||
|
||||
return ppr
|
||||
|
||||
|
||||
@@ -206,6 +264,100 @@ async def delete_ppr(
|
||||
return ppr
|
||||
|
||||
|
||||
@router.get("/public/edit/{token}", response_model=PPR)
|
||||
async def get_ppr_for_edit(
|
||||
token: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get PPR details for public editing using token"""
|
||||
ppr = crud_ppr.get_by_public_token(db, token)
|
||||
if not ppr:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Invalid or expired token"
|
||||
)
|
||||
# Only allow editing if not already processed
|
||||
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="PPR cannot be edited at this stage"
|
||||
)
|
||||
return ppr
|
||||
|
||||
|
||||
@router.patch("/public/edit/{token}", response_model=PPR)
|
||||
async def update_ppr_public(
|
||||
token: str,
|
||||
ppr_in: PPRUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update PPR publicly using token"""
|
||||
ppr = crud_ppr.get_by_public_token(db, token)
|
||||
if not ppr:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Invalid or expired token"
|
||||
)
|
||||
# Only allow editing if not already processed
|
||||
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="PPR cannot be edited at this stage"
|
||||
)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
updated_ppr = crud_ppr.update(db, db_obj=ppr, obj_in=ppr_in, user="public", user_ip=client_ip)
|
||||
return updated_ppr
|
||||
|
||||
|
||||
@router.delete("/public/cancel/{token}", response_model=PPR)
|
||||
async def cancel_ppr_public(
|
||||
token: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Cancel PPR publicly using token"""
|
||||
ppr = crud_ppr.get_by_public_token(db, token)
|
||||
if not ppr:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Invalid or expired token"
|
||||
)
|
||||
# Only allow canceling if not already processed
|
||||
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="PPR cannot be cancelled at this stage"
|
||||
)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
# Cancel by setting status to CANCELED
|
||||
cancelled_ppr = crud_ppr.update_status(
|
||||
db,
|
||||
ppr_id=ppr.id,
|
||||
status=PPRStatus.CANCELED,
|
||||
user="public",
|
||||
user_ip=client_ip
|
||||
)
|
||||
|
||||
# Send cancellation email if email provided
|
||||
if cancelled_ppr.email:
|
||||
await email_service.send_email(
|
||||
to_email=cancelled_ppr.email,
|
||||
subject="PPR Cancelled",
|
||||
template_name="ppr_cancelled.html",
|
||||
template_vars={
|
||||
"name": cancelled_ppr.captain,
|
||||
"aircraft": cancelled_ppr.ac_reg,
|
||||
"arrival_time": cancelled_ppr.eta.strftime("%Y-%m-%d %H:%M"),
|
||||
"departure_time": cancelled_ppr.etd.strftime("%Y-%m-%d %H:%M") if cancelled_ppr.etd else "N/A"
|
||||
}
|
||||
)
|
||||
|
||||
return cancelled_ppr
|
||||
|
||||
|
||||
@router.get("/{ppr_id}/journal", response_model=List[Journal])
|
||||
async def get_ppr_journal(
|
||||
ppr_id: int,
|
||||
|
||||
48
backend/app/core/email.py
Normal file
48
backend/app/core/email.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import aiosmtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
import os
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class EmailService:
|
||||
def __init__(self):
|
||||
self.smtp_host = settings.mail_host
|
||||
self.smtp_port = settings.mail_port
|
||||
self.smtp_user = settings.mail_username
|
||||
self.smtp_password = settings.mail_password
|
||||
self.from_email = settings.mail_from
|
||||
self.from_name = settings.mail_from_name
|
||||
|
||||
# Set up Jinja2 environment for templates
|
||||
template_dir = os.path.join(os.path.dirname(__file__), '..', 'templates')
|
||||
self.jinja_env = Environment(loader=FileSystemLoader(template_dir))
|
||||
|
||||
async def send_email(self, to_email: str, subject: str, template_name: str, template_vars: dict):
|
||||
# Render the template
|
||||
template = self.jinja_env.get_template(template_name)
|
||||
html_content = template.render(**template_vars)
|
||||
|
||||
# Create message
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = f"{self.from_name} <{self.from_email}>"
|
||||
msg['To'] = to_email
|
||||
|
||||
# Attach HTML content
|
||||
html_part = MIMEText(html_content, 'html')
|
||||
msg.attach(html_part)
|
||||
|
||||
# Send email
|
||||
try:
|
||||
async with aiosmtplib.SMTP(hostname=self.smtp_host, port=self.smtp_port, use_tls=True) as smtp:
|
||||
await smtp.login(self.smtp_user, self.smtp_password)
|
||||
await smtp.send_message(msg)
|
||||
except Exception as e:
|
||||
# Log error, but for now, print
|
||||
print(f"Failed to send email: {e}")
|
||||
# In production, use logging
|
||||
|
||||
|
||||
email_service = EmailService()
|
||||
@@ -2,6 +2,7 @@ from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_, func, desc
|
||||
from datetime import date, datetime
|
||||
import secrets
|
||||
from app.models.ppr import PPRRecord, PPRStatus
|
||||
from app.schemas.ppr import PPRCreate, PPRUpdate
|
||||
from app.crud.crud_journal import journal as crud_journal
|
||||
@@ -11,6 +12,9 @@ class CRUDPPR:
|
||||
def get(self, db: Session, ppr_id: int) -> Optional[PPRRecord]:
|
||||
return db.query(PPRRecord).filter(PPRRecord.id == ppr_id).first()
|
||||
|
||||
def get_by_public_token(self, db: Session, token: str) -> Optional[PPRRecord]:
|
||||
return db.query(PPRRecord).filter(PPRRecord.public_token == token).first()
|
||||
|
||||
def get_multi(
|
||||
self,
|
||||
db: Session,
|
||||
@@ -67,7 +71,8 @@ class CRUDPPR:
|
||||
db_obj = PPRRecord(
|
||||
**obj_in.dict(),
|
||||
created_by=created_by,
|
||||
status=PPRStatus.NEW
|
||||
status=PPRStatus.NEW,
|
||||
public_token=secrets.token_urlsafe(64)
|
||||
)
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
|
||||
@@ -42,6 +42,7 @@ class PPRRecord(Base):
|
||||
departed_dt = Column(DateTime, nullable=True)
|
||||
created_by = Column(String(16), nullable=True)
|
||||
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
public_token = Column(String(128), nullable=True, unique=True)
|
||||
|
||||
|
||||
class User(Base):
|
||||
|
||||
19
backend/app/templates/ppr_cancelled.html
Normal file
19
backend/app/templates/ppr_cancelled.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>PPR Cancelled</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>PPR Cancelled</h1>
|
||||
<p>Dear {{ name }},</p>
|
||||
<p>Your Prior Permission Request (PPR) has been cancelled.</p>
|
||||
<p><strong>PPR Details:</strong></p>
|
||||
<ul>
|
||||
<li>Aircraft: {{ aircraft }}</li>
|
||||
<li>Original Arrival: {{ arrival_time }}</li>
|
||||
<li>Original Departure: {{ departure_time }}</li>
|
||||
</ul>
|
||||
<p>If this was not intended, please contact us.</p>
|
||||
<p>Best regards,<br>Swansea Airport Team</p>
|
||||
</body>
|
||||
</html>
|
||||
21
backend/app/templates/ppr_submitted.html
Normal file
21
backend/app/templates/ppr_submitted.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>PPR Submitted</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>PPR Submitted Successfully</h1>
|
||||
<p>Dear {{ name }},</p>
|
||||
<p>Your Prior Permission Request (PPR) has been submitted.</p>
|
||||
<p><strong>PPR Details:</strong></p>
|
||||
<ul>
|
||||
<li>Aircraft: {{ aircraft }}</li>
|
||||
<li>Arrival: {{ arrival_time }}</li>
|
||||
<li>Departure: {{ departure_time }}</li>
|
||||
<li>Purpose: {{ purpose }}</li>
|
||||
</ul>
|
||||
<p>You can <a href="{{ base_url }}/edit.html?token={{ public_token }}">edit or cancel</a> your PPR using this secure link.</p>
|
||||
<p>You will receive further updates via email.</p>
|
||||
<p>Best regards,<br>Swansea Airport Team</p>
|
||||
</body>
|
||||
</html>
|
||||
237
backend/populate_test_data.py
Executable file
237
backend/populate_test_data.py
Executable file
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test data population script for PPR database.
|
||||
Generates 30 random PPR records with various aircraft, airports, and other data.
|
||||
"""
|
||||
|
||||
import random
|
||||
import csv
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# Add the app directory to the Python path
|
||||
import sys
|
||||
sys.path.append(str(Path(__file__).parent / 'app'))
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.ppr import PPRRecord, PPRStatus
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def load_aircraft_data():
|
||||
"""Load aircraft data from CSV file."""
|
||||
aircraft = []
|
||||
csv_path = Path(__file__).parent.parent / 'aircraft_data.csv'
|
||||
|
||||
try:
|
||||
with open(csv_path, 'r', encoding='utf-8') as f:
|
||||
reader = csv.reader(f)
|
||||
for row in reader:
|
||||
if len(row) >= 6:
|
||||
aircraft.append({
|
||||
'icao24': row[0],
|
||||
'registration': row[1],
|
||||
'manufacturer': row[2],
|
||||
'type_code': row[3],
|
||||
'manufacturer_name': row[4],
|
||||
'model': row[5]
|
||||
})
|
||||
except FileNotFoundError:
|
||||
print("Aircraft data file not found, using fallback data")
|
||||
# Fallback aircraft data
|
||||
aircraft = [
|
||||
{'registration': 'G-ABCD', 'type_code': 'C172', 'manufacturer_name': 'Cessna', 'model': '172'},
|
||||
{'registration': 'G-EFGH', 'type_code': 'PA28', 'manufacturer_name': 'Piper', 'model': 'PA-28'},
|
||||
{'registration': 'G-IJKL', 'type_code': 'BE36', 'manufacturer_name': 'Beechcraft', 'model': 'Bonanza'},
|
||||
{'registration': 'G-MNOP', 'type_code': 'R44', 'manufacturer_name': 'Robinson', 'model': 'R44'},
|
||||
{'registration': 'G-QRST', 'type_code': 'C152', 'manufacturer_name': 'Cessna', 'model': '152'},
|
||||
{'registration': 'G-UVWX', 'type_code': 'PA38', 'manufacturer_name': 'Piper', 'model': 'Tomahawk'},
|
||||
{'registration': 'G-YZAB', 'type_code': 'C182', 'manufacturer_name': 'Cessna', 'model': '182'},
|
||||
{'registration': 'G-CDEF', 'type_code': 'DR40', 'manufacturer_name': 'Robin', 'model': 'DR400'},
|
||||
{'registration': 'G-GHIJ', 'type_code': 'TB20', 'manufacturer_name': 'Socata', 'model': 'TB-20'},
|
||||
{'registration': 'G-KLMN', 'type_code': 'DA40', 'manufacturer_name': 'Diamond', 'model': 'DA-40'},
|
||||
]
|
||||
|
||||
return aircraft
|
||||
|
||||
|
||||
def load_airport_data():
|
||||
"""Load airport data from CSV file."""
|
||||
airports = []
|
||||
csv_path = Path(__file__).parent.parent / 'airports_data_clean.csv'
|
||||
|
||||
try:
|
||||
with open(csv_path, 'r', encoding='utf-8') as f:
|
||||
reader = csv.reader(f)
|
||||
for row in reader:
|
||||
if len(row) >= 4:
|
||||
airports.append({
|
||||
'icao': row[0],
|
||||
'iata': row[1],
|
||||
'name': row[2],
|
||||
'country': row[3]
|
||||
})
|
||||
except FileNotFoundError:
|
||||
print("Airport data file not found, using fallback data")
|
||||
# Fallback airport data
|
||||
airports = [
|
||||
{'icao': 'EGFH', 'iata': '', 'name': 'Swansea Airport', 'country': 'GB'},
|
||||
{'icao': 'EGFF', 'iata': 'CWL', 'name': 'Cardiff International Airport', 'country': 'GB'},
|
||||
{'icao': 'EGTE', 'iata': 'EXT', 'name': 'Exeter International Airport', 'country': 'GB'},
|
||||
{'icao': 'EGGD', 'iata': 'BRS', 'name': 'Bristol Airport', 'country': 'GB'},
|
||||
{'icao': 'EGHH', 'iata': 'BOH', 'name': 'Bournemouth Airport', 'country': 'GB'},
|
||||
{'icao': 'EGHI', 'iata': 'SOU', 'name': 'Southampton Airport', 'country': 'GB'},
|
||||
{'icao': 'EGSS', 'iata': 'STN', 'name': 'London Stansted Airport', 'country': 'GB'},
|
||||
{'icao': 'EGKK', 'iata': 'LGW', 'name': 'London Gatwick Airport', 'country': 'GB'},
|
||||
{'icao': 'EGLL', 'iata': 'LHR', 'name': 'London Heathrow Airport', 'country': 'GB'},
|
||||
{'icao': 'EIDW', 'iata': 'DUB', 'name': 'Dublin Airport', 'country': 'IE'},
|
||||
]
|
||||
|
||||
return airports
|
||||
|
||||
|
||||
def generate_random_ppr(aircraft_data, airport_data):
|
||||
"""Generate a random PPR record."""
|
||||
# Select random aircraft
|
||||
aircraft = random.choice(aircraft_data)
|
||||
|
||||
# Select random departure airport (not Swansea)
|
||||
departure_airports = [a for a in airport_data if a['icao'] != 'EGFH']
|
||||
arrival_airport = random.choice(departure_airports)
|
||||
|
||||
# Sometimes add a departure airport (50% chance)
|
||||
departure_airport = None
|
||||
if random.random() < 0.5:
|
||||
departure_airports = [a for a in airport_data if a['icao'] != arrival_airport['icao']]
|
||||
departure_airport = random.choice(departure_airports)
|
||||
|
||||
# Generate random times
|
||||
now = datetime.now()
|
||||
base_date = now + timedelta(days=random.randint(-7, 14)) # Past week to 2 weeks future
|
||||
|
||||
# ETA: sometime between 6 AM and 8 PM
|
||||
eta_hour = random.randint(6, 20)
|
||||
eta_minute = random.choice([0, 15, 30, 45])
|
||||
eta = base_date.replace(hour=eta_hour, minute=eta_minute, second=0, microsecond=0)
|
||||
|
||||
# ETD: 1-4 hours after ETA (if departure planned)
|
||||
etd = None
|
||||
if departure_airport:
|
||||
etd_hours = random.randint(1, 4)
|
||||
etd = eta + timedelta(hours=etd_hours)
|
||||
# Round ETD to 15-minute intervals
|
||||
etd_minute = ((etd.minute // 15) * 15) % 60
|
||||
etd = etd.replace(minute=etd_minute, second=0, microsecond=0)
|
||||
|
||||
# Random captain names
|
||||
captains = [
|
||||
'John Smith', 'Sarah Johnson', 'Michael Brown', 'Emma Davis', 'James Wilson',
|
||||
'Olivia Taylor', 'William Anderson', 'Sophia Martinez', 'Benjamin Garcia', 'Isabella Lopez',
|
||||
'Alexander Gonzalez', 'Charlotte Rodriguez', 'Daniel Lee', 'Amelia Walker', 'Matthew Hall'
|
||||
]
|
||||
|
||||
# Random fuel types
|
||||
fuel_types = [None, '100LL', 'JET A1', 'FULL']
|
||||
|
||||
# Random POB
|
||||
pob_in = random.randint(1, 4)
|
||||
pob_out = random.randint(1, 4) if departure_airport else None
|
||||
|
||||
# Random status
|
||||
statuses = [PPRStatus.NEW, PPRStatus.CONFIRMED, PPRStatus.LANDED, PPRStatus.DEPARTED]
|
||||
status = random.choice(statuses)
|
||||
|
||||
# Random contact info (sometimes)
|
||||
email = None
|
||||
phone = None
|
||||
if random.random() < 0.7:
|
||||
email = f"{random.choice(captains).lower().replace(' ', '.')}@example.com"
|
||||
if random.random() < 0.5:
|
||||
phone = f"07{random.randint(100000000, 999999999)}"
|
||||
|
||||
# Random notes (sometimes)
|
||||
notes_options = [
|
||||
None,
|
||||
"Medical flight - priority handling required",
|
||||
"VIP passenger on board",
|
||||
"Technical stop only",
|
||||
"Training flight",
|
||||
"Photo flight - low level operations",
|
||||
"Maintenance ferry flight",
|
||||
"Charter flight",
|
||||
"Private flight"
|
||||
]
|
||||
notes = random.choice(notes_options)
|
||||
|
||||
# Create PPR record
|
||||
ppr = PPRRecord(
|
||||
status=status,
|
||||
ac_reg=aircraft['registration'],
|
||||
ac_type=aircraft['type_code'] or 'UNKNOWN',
|
||||
ac_call=random.choice([None, f"CALL{random.randint(100, 999)}"]),
|
||||
captain=random.choice(captains),
|
||||
fuel=random.choice(fuel_types),
|
||||
in_from=arrival_airport['icao'],
|
||||
eta=eta,
|
||||
pob_in=pob_in,
|
||||
out_to=departure_airport['icao'] if departure_airport else None,
|
||||
etd=etd,
|
||||
pob_out=pob_out,
|
||||
email=email,
|
||||
phone=phone,
|
||||
notes=notes,
|
||||
submitted_dt=now - timedelta(days=random.randint(0, 30)) # Random submission date
|
||||
)
|
||||
|
||||
return ppr
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to populate test data."""
|
||||
print("Loading aircraft and airport data...")
|
||||
|
||||
aircraft_data = load_aircraft_data()
|
||||
airport_data = load_airport_data()
|
||||
|
||||
print(f"Loaded {len(aircraft_data)} aircraft records")
|
||||
print(f"Loaded {len(airport_data)} airport records")
|
||||
|
||||
# Create database session
|
||||
db: Session = SessionLocal()
|
||||
|
||||
try:
|
||||
print("Generating and inserting 30 test PPR records...")
|
||||
|
||||
# Generate and insert 30 PPR records
|
||||
for i in range(30):
|
||||
ppr = generate_random_ppr(aircraft_data, airport_data)
|
||||
db.add(ppr)
|
||||
|
||||
if (i + 1) % 10 == 0:
|
||||
print(f"Generated {i + 1} records...")
|
||||
|
||||
# Commit all changes
|
||||
db.commit()
|
||||
print("✅ Successfully inserted 30 test PPR records!")
|
||||
|
||||
# Print summary
|
||||
total_count = db.query(PPRRecord).count()
|
||||
print(f"Total PPR records in database: {total_count}")
|
||||
|
||||
# Show status breakdown
|
||||
status_counts = db.query(PPRRecord.status, func.count(PPRRecord.id)).group_by(PPRRecord.status).all()
|
||||
print("\nStatus breakdown:")
|
||||
for status, count in status_counts:
|
||||
print(f" {status}: {count}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,3 +15,5 @@ pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
httpx==0.25.2
|
||||
redis==5.0.1
|
||||
aiosmtplib==3.0.1
|
||||
jinja2==3.1.2
|
||||
9
db-init/Dockerfile
Normal file
9
db-init/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM mysql:8.0
|
||||
|
||||
# Copy initialization SQL files
|
||||
COPY init_db.sql /docker-entrypoint-initdb.d/01-schema.sql
|
||||
COPY 02-import-data.sql /docker-entrypoint-initdb.d/02-import-data.sql
|
||||
|
||||
# Copy CSV files for import
|
||||
COPY airports_data_clean.csv /var/lib/mysql-files/airports_data.csv
|
||||
COPY aircraft_data.csv /var/lib/mysql-files/aircraft_data.csv
|
||||
|
Can't render this file because it is too large.
|
|
Can't render this file because it is too large.
|
@@ -41,6 +41,7 @@ CREATE TABLE submitted (
|
||||
departed_dt DATETIME DEFAULT NULL,
|
||||
created_by VARCHAR(16) DEFAULT NULL,
|
||||
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
public_token VARCHAR(128) DEFAULT NULL UNIQUE,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Indexes for better performance
|
||||
@@ -49,7 +50,8 @@ CREATE TABLE submitted (
|
||||
INDEX idx_etd (etd),
|
||||
INDEX idx_ac_reg (ac_reg),
|
||||
INDEX idx_submitted_dt (submitted_dt),
|
||||
INDEX idx_created_by (created_by)
|
||||
INDEX idx_created_by (created_by),
|
||||
INDEX idx_public_token (public_token)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Activity journal table with foreign key
|
||||
@@ -1,26 +1,19 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# MySQL Database
|
||||
db:
|
||||
image: mysql:8.0
|
||||
build: ./db-init
|
||||
container_name: ppr_nextgen_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: rootpassword123
|
||||
MYSQL_DATABASE: ppr_nextgen
|
||||
MYSQL_USER: ppr_user
|
||||
MYSQL_PASSWORD: ppr_password123
|
||||
ports:
|
||||
- "3307:3306" # Use different port to avoid conflicts
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${DB_NAME}
|
||||
MYSQL_USER: ${DB_USER}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./init_db.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
||||
- ./02-import-data.sql:/docker-entrypoint-initdb.d/02-import-data.sql
|
||||
- ./airports_data_clean.csv:/var/lib/mysql-files/airports_data.csv
|
||||
- ./aircraft_data.csv:/var/lib/mysql-files/aircraft_data.csv
|
||||
networks:
|
||||
- ppr_network
|
||||
- private_network
|
||||
|
||||
# FastAPI Backend
|
||||
api:
|
||||
@@ -28,23 +21,24 @@ services:
|
||||
container_name: ppr_nextgen_api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_USER: ppr_user
|
||||
DB_PASSWORD: ppr_password123
|
||||
DB_NAME: ppr_nextgen
|
||||
DB_PORT: 3306
|
||||
SECRET_KEY: super-secret-key-for-nextgen-ppr-system-change-in-production
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: 30
|
||||
API_V1_STR: /api/v1
|
||||
PROJECT_NAME: "Airfield PPR API NextGen"
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_NAME: ${DB_NAME}
|
||||
DB_PORT: ${DB_PORT}
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES}
|
||||
API_V1_STR: ${API_V1_STR}
|
||||
PROJECT_NAME: ${PROJECT_NAME}
|
||||
ports:
|
||||
- "8001:8000" # Use different port to avoid conflicts with existing system
|
||||
- "${API_PORT_EXTERNAL}:8000" # Use different port to avoid conflicts with existing system
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
networks:
|
||||
- ppr_network
|
||||
- private_network
|
||||
- public_network
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Redis for caching (optional for now)
|
||||
@@ -52,10 +46,8 @@ services:
|
||||
image: redis:7-alpine
|
||||
container_name: ppr_nextgen_redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6380:6379" # Use different port
|
||||
networks:
|
||||
- ppr_network
|
||||
- private_network
|
||||
|
||||
# Nginx web server for public frontend
|
||||
web:
|
||||
@@ -63,14 +55,14 @@ services:
|
||||
container_name: ppr_nextgen_web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8082:80" # Public web interface
|
||||
- "${WEB_PORT_EXTERNAL}:80" # Public web interface
|
||||
volumes:
|
||||
- ./web:/usr/share/nginx/html
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
depends_on:
|
||||
- api
|
||||
networks:
|
||||
- ppr_network
|
||||
- public_network
|
||||
|
||||
# phpMyAdmin for database management
|
||||
phpmyadmin:
|
||||
@@ -78,21 +70,24 @@ services:
|
||||
container_name: ppr_nextgen_phpmyadmin
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PMA_HOST: db
|
||||
PMA_PORT: 3306
|
||||
PMA_USER: ppr_user
|
||||
PMA_PASSWORD: ppr_password123
|
||||
UPLOAD_LIMIT: 50M
|
||||
PMA_HOST: ${PMA_HOST}
|
||||
PMA_PORT: ${DB_PORT}
|
||||
PMA_USER: ${DB_USER}
|
||||
PMA_PASSWORD: ${DB_PASSWORD}
|
||||
UPLOAD_LIMIT: ${UPLOAD_LIMIT}
|
||||
ports:
|
||||
- "8083:80" # phpMyAdmin web interface
|
||||
- "${PMA_PORT_EXTERNAL}:80" # phpMyAdmin web interface
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
- ppr_network
|
||||
- private_network
|
||||
- public_network
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
|
||||
networks:
|
||||
ppr_network:
|
||||
private_network:
|
||||
driver: bridge
|
||||
public_network:
|
||||
driver: bridge
|
||||
16
populate_test_data.sh
Executable file
16
populate_test_data.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Script to populate test data in the PPR database
|
||||
# Run this from the nextgen directory
|
||||
|
||||
echo "Populating test data..."
|
||||
|
||||
# Check if we're in the right directory
|
||||
if [ ! -f "docker-compose.yml" ]; then
|
||||
echo "Error: Please run this script from the nextgen directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the population script in the backend container
|
||||
sudo docker compose exec api python populate_test_data.py
|
||||
|
||||
echo "Test data population complete!"
|
||||
328
web/admin.html
328
web/admin.html
@@ -17,22 +17,32 @@
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header {
|
||||
.top-bar {
|
||||
background: linear-gradient(135deg, #2c3e50, #3498db);
|
||||
color: white;
|
||||
padding: 1rem 2rem;
|
||||
padding: 0.5rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
.title h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header .user-info {
|
||||
float: right;
|
||||
.menu-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-bar .user-info {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -41,26 +51,7 @@
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.top-menu {
|
||||
background: #2c3e50;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.menu-left {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menu-right {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.7rem 1.5rem;
|
||||
@@ -563,29 +554,28 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>✈️ PPR Administration</h1>
|
||||
<div class="user-info">
|
||||
Logged in as: <span id="current-user">Loading...</span> |
|
||||
<a href="#" onclick="logout()" style="color: white;">Logout</a>
|
||||
<div class="top-bar">
|
||||
<div class="title">
|
||||
<h1>✈️ Swansea PPR</h1>
|
||||
</div>
|
||||
<div style="clear: both;"></div>
|
||||
</div>
|
||||
|
||||
<div class="top-menu">
|
||||
<div class="menu-left">
|
||||
<div class="menu-buttons">
|
||||
<button class="btn btn-success" onclick="openNewPPRModal()">
|
||||
➕ New PPR Entry
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="window.open('reports.html', '_blank')">
|
||||
📊 Reports
|
||||
</button>
|
||||
<button class="btn btn-warning" onclick="openUserManagementModal()" id="user-management-btn" style="display: none;">
|
||||
👥 User Management
|
||||
</button>
|
||||
</div>
|
||||
<div class="menu-right">
|
||||
<button class="btn btn-primary" onclick="loadPPRs()">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
Logged in as: <span id="current-user">Loading...</span> |
|
||||
<a href="#" onclick="logout()" style="color: white;">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
@@ -702,10 +692,10 @@
|
||||
✓ Confirm
|
||||
</button>
|
||||
<button id="btn-landed" class="btn btn-warning btn-sm" onclick="showTimestampModal('LANDED')">
|
||||
🛬 Landed
|
||||
🛬 Land
|
||||
</button>
|
||||
<button id="btn-departed" class="btn btn-primary btn-sm" onclick="showTimestampModal('DEPARTED')">
|
||||
🛫 Departed
|
||||
🛫 Depart
|
||||
</button>
|
||||
<button id="btn-cancel" class="btn btn-danger btn-sm" onclick="updateStatus('CANCELED')">
|
||||
❌ Cancel
|
||||
@@ -723,11 +713,11 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ac_type">Aircraft Type *</label>
|
||||
<input type="text" id="ac_type" name="ac_type" required>
|
||||
<input type="text" id="ac_type" name="ac_type" required tabindex="-1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ac_call">Callsign</label>
|
||||
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration">
|
||||
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration" tabindex="-1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="captain">Captain *</label>
|
||||
@@ -740,7 +730,12 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="eta">ETA (Local Time) *</label>
|
||||
<input type="datetime-local" id="eta" name="eta" required>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="date" id="eta-date" name="eta-date" required style="flex: 1;">
|
||||
<select id="eta-time" name="eta-time" required style="flex: 1;">
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pob_in">POB Inbound *</label>
|
||||
@@ -748,7 +743,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fuel">Fuel Required</label>
|
||||
<select id="fuel" name="fuel">
|
||||
<select id="fuel" name="fuel" tabindex="-1">
|
||||
<option value="">None</option>
|
||||
<option value="100LL">100LL</option>
|
||||
<option value="JET A1">JET A1</option>
|
||||
@@ -757,28 +752,33 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="out_to">Departing To</label>
|
||||
<input type="text" id="out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleDepartureAirportLookup(this.value)">
|
||||
<input type="text" id="out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleDepartureAirportLookup(this.value)" tabindex="-1">
|
||||
<div id="departure-airport-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="etd">ETD (Local Time)</label>
|
||||
<input type="datetime-local" id="etd" name="etd">
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="date" id="etd-date" name="etd-date" tabindex="-1" style="flex: 1;">
|
||||
<select id="etd-time" name="etd-time" tabindex="-1" style="flex: 1;">
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pob_out">POB Outbound</label>
|
||||
<input type="number" id="pob_out" name="pob_out" min="1">
|
||||
<input type="number" id="pob_out" name="pob_out" min="1" tabindex="-1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email">
|
||||
<input type="email" id="email" name="email" tabindex="-1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="phone">Phone</label>
|
||||
<input type="tel" id="phone" name="phone">
|
||||
<input type="tel" id="phone" name="phone" tabindex="-1">
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea id="notes" name="notes" rows="3"></textarea>
|
||||
<textarea id="notes" name="notes" rows="3" tabindex="-1"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -998,12 +998,27 @@
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Initialize the application
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeAuth();
|
||||
setupLoginForm();
|
||||
setupKeyboardShortcuts();
|
||||
});
|
||||
// Initialize time dropdowns
|
||||
function initializeTimeDropdowns() {
|
||||
const timeSelects = ['eta-time', 'etd-time'];
|
||||
|
||||
timeSelects.forEach(selectId => {
|
||||
const select = document.getElementById(selectId);
|
||||
// Clear existing options except the first one
|
||||
select.innerHTML = '<option value="">Select Time</option>';
|
||||
|
||||
// Add time options in 15-minute intervals
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
const timeString = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
const option = document.createElement('option');
|
||||
option.value = timeString;
|
||||
option.textContent = timeString;
|
||||
select.appendChild(option);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Authentication management
|
||||
async function initializeAuth() {
|
||||
@@ -1256,40 +1271,57 @@
|
||||
document.getElementById('departures-loading').style.display = 'none';
|
||||
}
|
||||
|
||||
function displayArrivals(arrivals) {
|
||||
// ICAO code to airport name cache
|
||||
const airportNameCache = {};
|
||||
|
||||
async function getAirportDisplay(code) {
|
||||
if (!code || code.length !== 4 || !/^[A-Z]{4}$/.test(code)) return code;
|
||||
if (airportNameCache[code]) return `${code}<br><span style="font-size:0.8em;color:#666;font-style:italic;">${airportNameCache[code]}</span>`;
|
||||
try {
|
||||
const resp = await authenticatedFetch(`/api/v1/airport/lookup/${code}`);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (data && data.length && data[0].name) {
|
||||
airportNameCache[code] = data[0].name;
|
||||
return `${code}<br><span style="font-size:0.8em;color:#666;font-style:italic;">${data[0].name}</span>`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return code;
|
||||
}
|
||||
|
||||
async function displayArrivals(arrivals) {
|
||||
const tbody = document.getElementById('arrivals-table-body');
|
||||
const recordCount = document.getElementById('arrivals-count');
|
||||
|
||||
recordCount.textContent = arrivals.length;
|
||||
|
||||
if (arrivals.length === 0) {
|
||||
document.getElementById('arrivals-no-data').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = '';
|
||||
document.getElementById('arrivals-table-content').style.display = 'block';
|
||||
|
||||
arrivals.forEach(ppr => {
|
||||
for (const ppr of arrivals) {
|
||||
const row = document.createElement('tr');
|
||||
row.onclick = () => openPPRModal(ppr.id);
|
||||
|
||||
// Create notes indicator if notes exist
|
||||
const notesIndicator = ppr.notes && ppr.notes.trim() ?
|
||||
`<span class="notes-tooltip">
|
||||
<span class="notes-indicator">📝</span>
|
||||
<span class="tooltip-text">${ppr.notes}</span>
|
||||
</span>` : '';
|
||||
|
||||
// Display callsign as main item if present, registration below; otherwise show registration
|
||||
const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ?
|
||||
`<strong>${ppr.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${ppr.ac_reg}</span>` :
|
||||
`<strong>${ppr.ac_reg}</strong>`;
|
||||
|
||||
// Lookup airport name for in_from
|
||||
let fromDisplay = ppr.in_from;
|
||||
if (ppr.in_from && ppr.in_from.length === 4 && /^[A-Z]{4}$/.test(ppr.in_from)) {
|
||||
fromDisplay = await getAirportDisplay(ppr.in_from);
|
||||
}
|
||||
row.innerHTML = `
|
||||
<td>${aircraftDisplay}${notesIndicator}</td>
|
||||
<td>${ppr.ac_type}</td>
|
||||
<td>${ppr.in_from}</td>
|
||||
<td>${fromDisplay}</td>
|
||||
<td>${formatTimeOnly(ppr.eta)}</td>
|
||||
<td>${ppr.pob_in}</td>
|
||||
<td>${ppr.fuel || '-'}</td>
|
||||
@@ -1302,45 +1334,42 @@
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function displayDepartures(departures) {
|
||||
async function displayDepartures(departures) {
|
||||
const tbody = document.getElementById('departures-table-body');
|
||||
const recordCount = document.getElementById('departures-count');
|
||||
|
||||
recordCount.textContent = departures.length;
|
||||
|
||||
if (departures.length === 0) {
|
||||
document.getElementById('departures-no-data').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = '';
|
||||
document.getElementById('departures-table-content').style.display = 'block';
|
||||
|
||||
departures.forEach(ppr => {
|
||||
for (const ppr of departures) {
|
||||
const row = document.createElement('tr');
|
||||
row.onclick = () => openPPRModal(ppr.id);
|
||||
|
||||
// Create notes indicator if notes exist
|
||||
const notesIndicator = ppr.notes && ppr.notes.trim() ?
|
||||
`<span class="notes-tooltip">
|
||||
<span class="notes-indicator">📝</span>
|
||||
<span class="tooltip-text">${ppr.notes}</span>
|
||||
</span>` : '';
|
||||
|
||||
// Display callsign as main item if present, registration below; otherwise show registration
|
||||
const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ?
|
||||
`<strong>${ppr.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${ppr.ac_reg}</span>` :
|
||||
`<strong>${ppr.ac_reg}</strong>`;
|
||||
|
||||
// Lookup airport name for out_to
|
||||
let toDisplay = ppr.out_to || '-';
|
||||
if (ppr.out_to && ppr.out_to.length === 4 && /^[A-Z]{4}$/.test(ppr.out_to)) {
|
||||
toDisplay = await getAirportDisplay(ppr.out_to);
|
||||
}
|
||||
row.innerHTML = `
|
||||
<td>${aircraftDisplay}${notesIndicator}</td>
|
||||
<td>${ppr.ac_type}</td>
|
||||
<td>${ppr.out_to || '-'}</td>
|
||||
<td>${toDisplay}</td>
|
||||
<td>${ppr.etd ? formatTimeOnly(ppr.etd) : '-'}</td>
|
||||
<td>${ppr.pob_out || ppr.pob_in}</td>
|
||||
<td>${ppr.fuel || '-'}</td>
|
||||
@@ -1354,15 +1383,20 @@
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeOnly(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
// Ensure the datetime string is treated as UTC
|
||||
const utcDateStr = dateStr.includes('Z') ? dateStr : dateStr + 'Z';
|
||||
let utcDateStr = dateStr;
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const date = new Date(utcDateStr);
|
||||
return date.toISOString().slice(11, 16);
|
||||
}
|
||||
@@ -1370,7 +1404,13 @@
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
// Ensure the datetime string is treated as UTC
|
||||
const utcDateStr = dateStr.includes('Z') ? dateStr : dateStr + 'Z';
|
||||
let utcDateStr = dateStr;
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const date = new Date(utcDateStr);
|
||||
return date.toISOString().slice(0, 10) + ' ' + date.toISOString().slice(11, 16);
|
||||
}
|
||||
@@ -1393,18 +1433,24 @@
|
||||
const eta = new Date(now.getTime() + 60 * 60 * 1000); // +1 hour
|
||||
const etd = new Date(now.getTime() + 2 * 60 * 60 * 1000); // +2 hours
|
||||
|
||||
// Format as local datetime-local value
|
||||
function formatLocalDateTime(date) {
|
||||
// Format date and time for separate inputs
|
||||
function formatDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
document.getElementById('eta').value = formatLocalDateTime(eta);
|
||||
document.getElementById('etd').value = etd ? formatLocalDateTime(etd) : '';
|
||||
function formatTime(date) {
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(Math.ceil(date.getMinutes() / 15) * 15 % 60).padStart(2, '0'); // Round up to next 15-minute interval
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
document.getElementById('eta-date').value = formatDate(eta);
|
||||
document.getElementById('eta-time').value = formatTime(eta);
|
||||
document.getElementById('etd-date').value = formatDate(etd);
|
||||
document.getElementById('etd-time').value = formatTime(etd);
|
||||
|
||||
// Clear aircraft lookup results
|
||||
clearAircraftLookup();
|
||||
@@ -1437,6 +1483,28 @@
|
||||
|
||||
const ppr = await response.json();
|
||||
populateForm(ppr);
|
||||
|
||||
// Show/hide quick action buttons based on current status
|
||||
if (ppr.status === 'NEW') {
|
||||
document.getElementById('btn-confirm').style.display = 'inline-block';
|
||||
document.getElementById('btn-landed').style.display = 'none';
|
||||
document.getElementById('btn-departed').style.display = 'none';
|
||||
document.getElementById('btn-cancel').style.display = 'inline-block';
|
||||
} else if (ppr.status === 'CONFIRMED') {
|
||||
document.getElementById('btn-confirm').style.display = 'none';
|
||||
document.getElementById('btn-landed').style.display = 'inline-block';
|
||||
document.getElementById('btn-departed').style.display = 'none';
|
||||
document.getElementById('btn-cancel').style.display = 'inline-block';
|
||||
} else if (ppr.status === 'LANDED') {
|
||||
document.getElementById('btn-confirm').style.display = 'none';
|
||||
document.getElementById('btn-landed').style.display = 'none';
|
||||
document.getElementById('btn-departed').style.display = 'inline-block';
|
||||
document.getElementById('btn-cancel').style.display = 'inline-block';
|
||||
} else {
|
||||
// DEPARTED, CANCELED, DELETED - hide all quick actions
|
||||
document.querySelector('.quick-actions').style.display = 'none';
|
||||
}
|
||||
|
||||
await loadJournal(pprId); // Always load journal when opening a PPR
|
||||
|
||||
document.getElementById('pprModal').style.display = 'block';
|
||||
@@ -1447,24 +1515,55 @@
|
||||
}
|
||||
|
||||
function populateForm(ppr) {
|
||||
console.log('populateForm called with:', ppr);
|
||||
Object.keys(ppr).forEach(key => {
|
||||
const field = document.getElementById(key);
|
||||
if (field) {
|
||||
if (key === 'eta' || key === 'etd') {
|
||||
if (ppr[key]) {
|
||||
// ppr[key] is UTC datetime string from API (naive, assume UTC)
|
||||
const utcDateStr = ppr[key].includes('Z') ? ppr[key] : ppr[key] + 'Z';
|
||||
const date = new Date(utcDateStr); // Now correctly parsed as UTC
|
||||
// Format as local time for datetime-local input
|
||||
if (key === 'eta' || key === 'etd') {
|
||||
if (ppr[key]) {
|
||||
console.log(`Processing ${key}:`, ppr[key]);
|
||||
// ppr[key] is UTC datetime string from API (naive, assume UTC)
|
||||
let utcDateStr = ppr[key];
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const date = new Date(utcDateStr); // Now correctly parsed as UTC
|
||||
console.log(`Parsed date for ${key}:`, date);
|
||||
|
||||
// Split into date and time components for separate inputs
|
||||
const dateField = document.getElementById(`${key}-date`);
|
||||
const timeField = document.getElementById(`${key}-time`);
|
||||
|
||||
if (dateField && timeField) {
|
||||
// Format date
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const dateValue = `${year}-${month}-${day}`;
|
||||
dateField.value = dateValue;
|
||||
console.log(`Set ${key}-date to:`, dateValue);
|
||||
|
||||
// Format time (round to nearest 15-minute interval)
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
field.value = `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
const rawMinutes = date.getMinutes();
|
||||
const roundedMinutes = Math.round(rawMinutes / 15) * 15 % 60;
|
||||
const minutes = String(roundedMinutes).padStart(2, '0');
|
||||
const timeValue = `${hours}:${minutes}`;
|
||||
timeField.value = timeValue;
|
||||
console.log(`Set ${key}-time to:`, timeValue, `(from ${rawMinutes} minutes)`);
|
||||
} else {
|
||||
console.log(`Date/time fields not found for ${key}: dateField=${dateField}, timeField=${timeField}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`${key} is empty`);
|
||||
}
|
||||
} else {
|
||||
const field = document.getElementById(key);
|
||||
if (field) {
|
||||
field.value = ppr[key] || '';
|
||||
} else {
|
||||
console.log(`Field not found for key: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1588,6 +1687,7 @@
|
||||
closeTimestampModal();
|
||||
loadPPRs(); // Refresh both tables
|
||||
showNotification(`Status updated to ${updatedStatus}`);
|
||||
closePPRModal(); // Close PPR modal after successful status update
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error);
|
||||
showNotification(`Error updating status: ${error.message}`, true);
|
||||
@@ -1607,10 +1707,18 @@
|
||||
if (key !== 'id' && value.trim() !== '') {
|
||||
if (key === 'pob_in' || key === 'pob_out') {
|
||||
pprData[key] = parseInt(value);
|
||||
} else if (key === 'eta' || key === 'etd') {
|
||||
// Convert local datetime-local to UTC ISO string
|
||||
pprData[key] = new Date(value).toISOString();
|
||||
} else {
|
||||
} else if (key === 'eta-date' && formData.get('eta-time')) {
|
||||
// Combine date and time for ETA
|
||||
const dateStr = formData.get('eta-date');
|
||||
const timeStr = formData.get('eta-time');
|
||||
pprData.eta = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||||
} else if (key === 'etd-date' && formData.get('etd-time')) {
|
||||
// Combine date and time for ETD
|
||||
const dateStr = formData.get('etd-date');
|
||||
const timeStr = formData.get('etd-time');
|
||||
pprData.etd = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||||
} else if (key !== 'eta-time' && key !== 'etd-time') {
|
||||
// Skip the time fields as they're handled above
|
||||
pprData[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -1656,6 +1764,13 @@
|
||||
async function updateStatus(status) {
|
||||
if (!currentPPRId || !accessToken) return;
|
||||
|
||||
// Show confirmation for cancel actions
|
||||
if (status === 'CANCELED') {
|
||||
if (!confirm('Are you sure you want to cancel this PPR? This action cannot be easily undone.')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/pprs/${currentPPRId}/status`, {
|
||||
method: 'PATCH',
|
||||
@@ -1673,6 +1788,7 @@
|
||||
await loadJournal(currentPPRId); // Refresh journal
|
||||
loadPPRs(); // Refresh both tables
|
||||
showNotification(`Status updated to ${status}`);
|
||||
closePPRModal(); // Close modal after successful status update
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error);
|
||||
showNotification('Error updating status', true);
|
||||
@@ -2285,6 +2401,14 @@
|
||||
document.getElementById('out_to').value = icaoCode;
|
||||
clearDepartureAirportLookup();
|
||||
}
|
||||
|
||||
// Initialize the page when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setupLoginForm();
|
||||
setupKeyboardShortcuts();
|
||||
initializeTimeDropdowns(); // Initialize time dropdowns
|
||||
initializeAuth(); // Start authentication process
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
792
web/edit.html
Normal file
792
web/edit.html
Normal file
@@ -0,0 +1,792 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Edit Swansea PPR Request</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 2rem auto;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #3498db;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
padding: 0.6rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.required::after {
|
||||
content: " *";
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #eee;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.8rem 2rem;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c0392b;
|
||||
}
|
||||
|
||||
.notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
z-index: 10000;
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.notification.error {
|
||||
background-color: #e74c3c;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: none;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #3498db;
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.success-message {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
border-radius: 4px;
|
||||
color: #155724;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.airport-lookup-results {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
min-height: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.aircraft-lookup-results {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
min-height: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.lookup-match {
|
||||
padding: 0.3rem;
|
||||
background-color: #e8f5e8;
|
||||
border: 1px solid #c3e6c3;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.lookup-no-match {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.lookup-searching {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.aircraft-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.aircraft-option {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.aircraft-option:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.aircraft-option:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.aircraft-code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.aircraft-details {
|
||||
color: #6c757d;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>✏️ Edit Swansea PPR Request</h1>
|
||||
<p>Update your Prior Permission Required (PPR) request details below.</p>
|
||||
</div>
|
||||
|
||||
<form id="ppr-form">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="ac_reg" class="required">Aircraft Registration</label>
|
||||
<input type="text" id="ac_reg" name="ac_reg" required oninput="handleAircraftLookup(this.value)">
|
||||
<div id="aircraft-lookup-results" class="aircraft-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ac_type" class="required">Aircraft Type</label>
|
||||
<input type="text" id="ac_type" name="ac_type" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ac_call">Callsign</label>
|
||||
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="captain" class="required">Captain/Pilot Name</label>
|
||||
<input type="text" id="captain" name="captain" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="in_from" class="required">Arriving From</label>
|
||||
<input type="text" id="in_from" name="in_from" required placeholder="ICAO Code or Airport Name" oninput="handleArrivalAirportLookup(this.value)">
|
||||
<div id="arrival-airport-lookup-results" class="airport-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="eta" class="required">Estimated Time of Arrival (Local Time)</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="date" id="eta-date" name="eta-date" required style="flex: 1;">
|
||||
<select id="eta-time" name="eta-time" required style="flex: 1;">
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pob_in" class="required">Persons on Board (Arrival)</label>
|
||||
<input type="number" id="pob_in" name="pob_in" required min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<option value="FULL">Full Tanks</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="out_to">Departing To</label>
|
||||
<input type="text" id="out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleDepartureAirportLookup(this.value)">
|
||||
<div id="departure-airport-lookup-results" class="airport-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="etd">Estimated Time of Departure (Local Time)</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="date" id="etd-date" name="etd-date" style="flex: 1;">
|
||||
<select id="etd-time" name="etd-time" style="flex: 1;">
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pob_out">Persons on Board (Departure)</label>
|
||||
<input type="number" id="pob_out" name="pob_out" min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="phone">Phone Number</label>
|
||||
<input type="tel" id="phone" name="phone">
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<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="form-actions">
|
||||
<button type="submit" class="btn btn-primary" id="update-btn">
|
||||
Update PPR Request
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" id="cancel-btn">
|
||||
Cancel PPR Request
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
Processing your request...
|
||||
</div>
|
||||
|
||||
<div class="success-message" id="success-message">
|
||||
<h3>✅ PPR Request Updated Successfully!</h3>
|
||||
<p>Your changes have been saved.</p>
|
||||
</div>
|
||||
|
||||
<div class="success-message" id="cancel-message">
|
||||
<h3>❌ PPR Request Cancelled</h3>
|
||||
<p>Your PPR request has been cancelled.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Notification -->
|
||||
<div id="notification" class="notification"></div>
|
||||
|
||||
<script>
|
||||
// Get token from URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const token = urlParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
alert('Invalid link. No token provided.');
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
// Initialize time dropdowns
|
||||
function initializeTimeDropdowns() {
|
||||
const timeSelects = ['eta-time', 'etd-time'];
|
||||
|
||||
timeSelects.forEach(selectId => {
|
||||
const select = document.getElementById(selectId);
|
||||
// Clear existing options except the first one
|
||||
select.innerHTML = '<option value="">Select Time</option>';
|
||||
|
||||
// Add time options in 15-minute intervals
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
const timeString = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
const option = document.createElement('option');
|
||||
option.value = timeString;
|
||||
option.textContent = timeString;
|
||||
select.appendChild(option);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Notification system
|
||||
function showNotification(message, isError = false) {
|
||||
const notification = document.getElementById('notification');
|
||||
notification.textContent = message;
|
||||
notification.className = 'notification' + (isError ? ' error' : '');
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.add('show');
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('show');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Load PPR data
|
||||
async function loadPPRData() {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/pprs/public/edit/${token}`);
|
||||
if (response.ok) {
|
||||
const ppr = await response.json();
|
||||
|
||||
// Populate form
|
||||
document.getElementById('ac_reg').value = ppr.ac_reg || '';
|
||||
document.getElementById('ac_type').value = ppr.ac_type || '';
|
||||
document.getElementById('ac_call').value = ppr.ac_call || '';
|
||||
document.getElementById('captain').value = ppr.captain || '';
|
||||
document.getElementById('in_from').value = ppr.in_from || '';
|
||||
|
||||
// Handle ETA date/time separately
|
||||
if (ppr.eta) {
|
||||
let utcDateStr = ppr.eta;
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const etaDate = new Date(utcDateStr);
|
||||
const etaDateStr = etaDate.toISOString().split('T')[0];
|
||||
const etaTimeStr = etaDate.toISOString().slice(11, 16);
|
||||
document.getElementById('eta-date').value = etaDateStr;
|
||||
document.getElementById('eta-time').value = etaTimeStr;
|
||||
}
|
||||
|
||||
document.getElementById('pob_in').value = ppr.pob_in || '';
|
||||
document.getElementById('fuel').value = ppr.fuel || '';
|
||||
document.getElementById('out_to').value = ppr.out_to || '';
|
||||
|
||||
// Handle ETD date/time separately
|
||||
if (ppr.etd) {
|
||||
let utcDateStr = ppr.etd;
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const etdDate = new Date(utcDateStr);
|
||||
const etdDateStr = etdDate.toISOString().split('T')[0];
|
||||
const etdTimeStr = etdDate.toISOString().slice(11, 16);
|
||||
document.getElementById('etd-date').value = etdDateStr;
|
||||
document.getElementById('etd-time').value = etdTimeStr;
|
||||
}
|
||||
|
||||
document.getElementById('pob_out').value = ppr.pob_out || '';
|
||||
document.getElementById('email').value = ppr.email || '';
|
||||
document.getElementById('phone').value = ppr.phone || '';
|
||||
document.getElementById('notes').value = ppr.notes || '';
|
||||
} else {
|
||||
throw new Error('Failed to load PPR data');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading PPR:', error);
|
||||
showNotification('Error loading PPR data', true);
|
||||
}
|
||||
}
|
||||
|
||||
// Aircraft lookup (same as submit form)
|
||||
let aircraftLookupTimeout;
|
||||
async function handleAircraftLookup(registration) {
|
||||
clearTimeout(aircraftLookupTimeout);
|
||||
const resultsDiv = document.getElementById('aircraft-lookup-results');
|
||||
const regField = document.getElementById('ac_reg');
|
||||
|
||||
if (!registration || registration.length < 4) {
|
||||
resultsDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
|
||||
|
||||
aircraftLookupTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/aircraft/public/lookup/${registration.toUpperCase()}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.length > 0) {
|
||||
if (data.length === 1) {
|
||||
const aircraft = data[0];
|
||||
regField.value = aircraft.registration || registration.toUpperCase();
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="lookup-match">
|
||||
${aircraft.registration || registration.toUpperCase()} - ${aircraft.type_code || 'Unknown'} ${aircraft.model ? `(${aircraft.model})` : ''}
|
||||
</div>
|
||||
`;
|
||||
if (!document.getElementById('ac_type').value) {
|
||||
document.getElementById('ac_type').value = aircraft.type_code || '';
|
||||
}
|
||||
} else if (data.length <= 10) {
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="aircraft-list">
|
||||
${data.map(aircraft => `
|
||||
<div class="aircraft-option" onclick="selectAircraft('${aircraft.registration || registration.toUpperCase()}', '${aircraft.type_code || ''}')">
|
||||
<div class="aircraft-code">${aircraft.registration || registration.toUpperCase()}</div>
|
||||
<div class="aircraft-details">${aircraft.type_code || 'Unknown'} ${aircraft.model ? `(${aircraft.model})` : ''}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
|
||||
}
|
||||
} else {
|
||||
resultsDiv.innerHTML = '<span class="lookup-no-match">No aircraft found with this registration</span>';
|
||||
}
|
||||
} else {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Aircraft lookup error:', error);
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function selectAircraft(registration, typeCode) {
|
||||
document.getElementById('ac_reg').value = registration;
|
||||
document.getElementById('ac_type').value = typeCode;
|
||||
document.getElementById('aircraft-lookup-results').innerHTML = '';
|
||||
document.getElementById('ac_reg').blur();
|
||||
}
|
||||
|
||||
document.getElementById('ac_reg').addEventListener('blur', function() {
|
||||
setTimeout(() => {
|
||||
document.getElementById('aircraft-lookup-results').innerHTML = '';
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// Airport lookup functions (same as submit)
|
||||
let arrivalAirportLookupTimeout;
|
||||
async function handleArrivalAirportLookup(query) {
|
||||
clearTimeout(arrivalAirportLookupTimeout);
|
||||
const resultsDiv = document.getElementById('arrival-airport-lookup-results');
|
||||
const inputField = document.getElementById('in_from');
|
||||
|
||||
if (!query || query.length < 2) {
|
||||
resultsDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
|
||||
|
||||
arrivalAirportLookupTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/airport/public/lookup/${query.toUpperCase()}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.length > 0) {
|
||||
if (data.length === 1) {
|
||||
// Single match - auto-populate the input field with ICAO code
|
||||
const airport = data[0];
|
||||
inputField.value = airport.icao;
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="lookup-match">
|
||||
${airport.icao}/${airport.iata || ''} - ${airport.name}, ${airport.country}
|
||||
</div>
|
||||
`;
|
||||
} else if (data.length <= 10) {
|
||||
// Multiple matches - show as clickable list
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="aircraft-list">
|
||||
${data.map(airport => `
|
||||
<div class="aircraft-option" onclick="selectAirport('in_from', '${airport.icao}')">
|
||||
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
|
||||
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Too many matches
|
||||
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
|
||||
}
|
||||
} else {
|
||||
resultsDiv.innerHTML = '<span class="lookup-no-match">No airport found</span>';
|
||||
}
|
||||
} else {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Arrival airport lookup error:', error);
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
let departureAirportLookupTimeout;
|
||||
async function handleDepartureAirportLookup(query) {
|
||||
clearTimeout(departureAirportLookupTimeout);
|
||||
const resultsDiv = document.getElementById('departure-airport-lookup-results');
|
||||
const inputField = document.getElementById('out_to');
|
||||
|
||||
if (!query || query.length < 2) {
|
||||
resultsDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
|
||||
|
||||
departureAirportLookupTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/airport/public/lookup/${query.toUpperCase()}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.length > 0) {
|
||||
if (data.length === 1) {
|
||||
// Single match - auto-populate the input field with ICAO code
|
||||
const airport = data[0];
|
||||
inputField.value = airport.icao;
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="lookup-match">
|
||||
${airport.icao}/${airport.iata || ''} - ${airport.name}, ${airport.country}
|
||||
</div>
|
||||
`;
|
||||
} else if (data.length <= 10) {
|
||||
// Multiple matches - show as clickable list
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="aircraft-list">
|
||||
${data.map(airport => `
|
||||
<div class="aircraft-option" onclick="selectAirport('out_to', '${airport.icao}')">
|
||||
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
|
||||
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Too many matches
|
||||
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
|
||||
}
|
||||
} else {
|
||||
resultsDiv.innerHTML = '<span class="lookup-no-match">No airport found</span>';
|
||||
}
|
||||
} else {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Departure airport lookup error:', error);
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function selectAirport(fieldId, icaoCode) {
|
||||
document.getElementById(fieldId).value = icaoCode;
|
||||
// Clear the results
|
||||
const resultsDivId = fieldId === 'in_from' ? 'arrival-airport-lookup-results' : 'departure-airport-lookup-results';
|
||||
document.getElementById(resultsDivId).innerHTML = '';
|
||||
// Remove focus from the field to hide the dropdown
|
||||
document.getElementById(fieldId).blur();
|
||||
}
|
||||
|
||||
// Clear airport lookup results when input loses focus
|
||||
document.getElementById('in_from').addEventListener('blur', function() {
|
||||
setTimeout(() => {
|
||||
document.getElementById('arrival-airport-lookup-results').innerHTML = '';
|
||||
}, 150);
|
||||
});
|
||||
|
||||
document.getElementById('out_to').addEventListener('blur', function() {
|
||||
setTimeout(() => {
|
||||
document.getElementById('departure-airport-lookup-results').innerHTML = '';
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// Form submission (update)
|
||||
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const pprData = {};
|
||||
|
||||
formData.forEach((value, key) => {
|
||||
if (value.trim() !== '') {
|
||||
if (key === 'pob_in' || key === 'pob_out') {
|
||||
pprData[key] = parseInt(value);
|
||||
} else if (key === 'eta-date' && formData.get('eta-time')) {
|
||||
// Combine date and time for ETA
|
||||
const dateStr = formData.get('eta-date');
|
||||
const timeStr = formData.get('eta-time');
|
||||
pprData.eta = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||||
} else if (key === 'etd-date' && formData.get('etd-time')) {
|
||||
// Combine date and time for ETD
|
||||
const dateStr = formData.get('etd-date');
|
||||
const timeStr = formData.get('etd-time');
|
||||
pprData.etd = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||||
} else if (key !== 'eta-time' && key !== 'etd-time') {
|
||||
// Skip the time fields as they're handled above
|
||||
pprData[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('loading').style.display = 'block';
|
||||
document.getElementById('update-btn').disabled = true;
|
||||
document.getElementById('update-btn').textContent = 'Updating...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/pprs/public/edit/${token}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(pprData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
document.getElementById('ppr-form').style.display = 'none';
|
||||
document.getElementById('success-message').style.display = 'block';
|
||||
showNotification('PPR request updated successfully!');
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `Update failed: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating PPR:', error);
|
||||
showNotification(`Error updating PPR: ${error.message}`, true);
|
||||
} finally {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('update-btn').disabled = false;
|
||||
document.getElementById('update-btn').textContent = 'Update PPR Request';
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel button
|
||||
document.getElementById('cancel-btn').addEventListener('click', async function() {
|
||||
if (!confirm('Are you sure you want to cancel this PPR request?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('loading').style.display = 'block';
|
||||
this.disabled = true;
|
||||
this.textContent = 'Cancelling...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/pprs/public/cancel/${token}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
document.getElementById('ppr-form').style.display = 'none';
|
||||
document.getElementById('cancel-message').style.display = 'block';
|
||||
showNotification('PPR request cancelled successfully!');
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `Cancellation failed: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cancelling PPR:', error);
|
||||
showNotification(`Error cancelling PPR: ${error.message}`, true);
|
||||
} finally {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
this.disabled = false;
|
||||
this.textContent = 'Cancel PPR Request';
|
||||
}
|
||||
});
|
||||
|
||||
// Load data on page load
|
||||
loadPPRData();
|
||||
|
||||
// Initialize the page when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeTimeDropdowns();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -252,13 +252,40 @@
|
||||
return `<span class="status ${status.toLowerCase()}">${status}</span>`;
|
||||
}
|
||||
|
||||
// Create PPR item HTML
|
||||
function createPPRItem(ppr) {
|
||||
// ICAO code to airport name cache
|
||||
const airportNameCache = {};
|
||||
|
||||
async function getAirportDisplay(code) {
|
||||
if (!code || code.length !== 4 || !/^[A-Z]{4}$/.test(code)) return code;
|
||||
if (airportNameCache[code]) return `${code}<br><span style="font-size:0.8em;color:#888;font-style:italic;">${airportNameCache[code]}</span>`;
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/airport/lookup/${code}`);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (data && data.length && data[0].name) {
|
||||
airportNameCache[code] = data[0].name;
|
||||
return `${code}<br><span style="font-size:0.8em;color:#888;font-style:italic;">${data[0].name}</span>`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return code;
|
||||
}
|
||||
|
||||
// Create PPR item HTML (async)
|
||||
async function createPPRItem(ppr) {
|
||||
// Display callsign as main item if present, registration below; otherwise show registration
|
||||
const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ?
|
||||
`${ppr.ac_call}<br><span style="font-size: 0.85em; color: #888; font-style: italic;">${ppr.ac_reg}</span>` :
|
||||
ppr.ac_reg;
|
||||
|
||||
// Lookup airport name for in_from/out_to
|
||||
let fromDisplay = ppr.in_from;
|
||||
if (ppr.in_from && ppr.in_from.length === 4 && /^[A-Z]{4}$/.test(ppr.in_from)) {
|
||||
fromDisplay = await getAirportDisplay(ppr.in_from);
|
||||
}
|
||||
let toDisplay = ppr.out_to;
|
||||
if (ppr.out_to && ppr.out_to.length === 4 && /^[A-Z]{4}$/.test(ppr.out_to)) {
|
||||
toDisplay = await getAirportDisplay(ppr.out_to);
|
||||
}
|
||||
return `
|
||||
<div class="ppr-item">
|
||||
<div class="ppr-field">
|
||||
@@ -269,6 +296,14 @@
|
||||
<strong>Type</strong>
|
||||
<span>${ppr.ac_type}</span>
|
||||
</div>
|
||||
<div class="ppr-field">
|
||||
<strong>From</strong>
|
||||
<span>${fromDisplay || '-'}</span>
|
||||
</div>
|
||||
<div class="ppr-field">
|
||||
<strong>To</strong>
|
||||
<span>${toDisplay || '-'}</span>
|
||||
</div>
|
||||
<div class="ppr-field">
|
||||
<strong>Time</strong>
|
||||
<span>${formatDateTime(ppr.eta || ppr.etd)}</span>
|
||||
@@ -298,19 +333,21 @@
|
||||
try {
|
||||
const response = await fetch('/api/v1/public/arrivals');
|
||||
const arrivals = await response.json();
|
||||
|
||||
const loadingEl = document.getElementById('arrivals-loading');
|
||||
const listEl = document.getElementById('arrivals-list');
|
||||
const countEl = document.getElementById('arrivals-count');
|
||||
|
||||
loadingEl.style.display = 'none';
|
||||
listEl.style.display = 'block';
|
||||
|
||||
if (arrivals.length === 0) {
|
||||
listEl.innerHTML = createNoDataMessage('arrivals');
|
||||
countEl.textContent = '0 flights';
|
||||
} else {
|
||||
listEl.innerHTML = arrivals.map(createPPRItem).join('');
|
||||
// Render each item async
|
||||
const htmlArr = [];
|
||||
for (const ppr of arrivals) {
|
||||
htmlArr.push(await createPPRItem(ppr));
|
||||
}
|
||||
listEl.innerHTML = htmlArr.join('');
|
||||
countEl.textContent = `${arrivals.length} flight${arrivals.length !== 1 ? 's' : ''}`;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -330,19 +367,21 @@
|
||||
try {
|
||||
const response = await fetch('/api/v1/public/departures');
|
||||
const departures = await response.json();
|
||||
|
||||
const loadingEl = document.getElementById('departures-loading');
|
||||
const listEl = document.getElementById('departures-list');
|
||||
const countEl = document.getElementById('departures-count');
|
||||
|
||||
loadingEl.style.display = 'none';
|
||||
listEl.style.display = 'block';
|
||||
|
||||
if (departures.length === 0) {
|
||||
listEl.innerHTML = createNoDataMessage('departures');
|
||||
countEl.textContent = '0 flights';
|
||||
} else {
|
||||
listEl.innerHTML = departures.map(createPPRItem).join('');
|
||||
// Render each item async
|
||||
const htmlArr = [];
|
||||
for (const ppr of departures) {
|
||||
htmlArr.push(await createPPRItem(ppr));
|
||||
}
|
||||
listEl.innerHTML = htmlArr.join('');
|
||||
countEl.textContent = `${departures.length} flight${departures.length !== 1 ? 's' : ''}`;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
694
web/ppr.html
Normal file
694
web/ppr.html
Normal file
@@ -0,0 +1,694 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swansea PPR Request</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 2rem auto;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #3498db;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
padding: 0.6rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.required::after {
|
||||
content: " *";
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #eee;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.8rem 2rem;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #229954;
|
||||
}
|
||||
|
||||
.notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
z-index: 10000;
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.notification.error {
|
||||
background-color: #e74c3c;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: none;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #3498db;
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.success-message {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
border-radius: 4px;
|
||||
color: #155724;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.airport-lookup-results {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
min-height: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.aircraft-lookup-results {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
min-height: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.lookup-match {
|
||||
padding: 0.3rem;
|
||||
background-color: #e8f5e8;
|
||||
border: 1px solid #c3e6c3;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.lookup-no-match {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.lookup-searching {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.aircraft-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.aircraft-option {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.aircraft-option:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.aircraft-option:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.aircraft-code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.aircraft-details {
|
||||
color: #6c757d;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>✈️ Swansea Airport PPR Request</h1>
|
||||
<p>Please fill out the form below to submit a Prior Permission Required (PPR) request for Swansea Airport.</p>
|
||||
</div>
|
||||
|
||||
<form id="ppr-form">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="ac_reg" class="required">Aircraft Registration</label>
|
||||
<input type="text" id="ac_reg" name="ac_reg" required oninput="handleAircraftLookup(this.value)">
|
||||
<div id="aircraft-lookup-results" class="aircraft-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ac_type" class="required">Aircraft Type</label>
|
||||
<input type="text" id="ac_type" name="ac_type" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ac_call">Callsign</label>
|
||||
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="captain" class="required">Captain/Pilot Name</label>
|
||||
<input type="text" id="captain" name="captain" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="in_from" class="required">Arriving From</label>
|
||||
<input type="text" id="in_from" name="in_from" required placeholder="ICAO Code or Airport Name" oninput="handleArrivalAirportLookup(this.value)">
|
||||
<div id="arrival-airport-lookup-results" class="airport-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="eta" class="required">Estimated Time of Arrival (Local Time)</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="date" id="eta-date" name="eta-date" required style="flex: 1;">
|
||||
<select id="eta-time" name="eta-time" required style="flex: 1;">
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pob_in" class="required">Persons on Board (Arrival)</label>
|
||||
<input type="number" id="pob_in" name="pob_in" required min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<option value="FULL">Full Tanks</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="out_to">Departing To</label>
|
||||
<input type="text" id="out_to" name="out_to" placeholder="ICAO Code or Airport Name" oninput="handleDepartureAirportLookup(this.value)">
|
||||
<div id="departure-airport-lookup-results" class="airport-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="etd">Estimated Time of Departure (Local Time)</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="date" id="etd-date" name="etd-date" style="flex: 1;">
|
||||
<select id="etd-time" name="etd-time" style="flex: 1;">
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pob_out">Persons on Board (Departure)</label>
|
||||
<input type="number" id="pob_out" name="pob_out" min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="phone">Phone Number</label>
|
||||
<input type="tel" id="phone" name="phone">
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<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="form-actions">
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">
|
||||
Submit PPR Request
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
Submitting your PPR request...
|
||||
</div>
|
||||
|
||||
<div class="success-message" id="success-message">
|
||||
<h3>✅ PPR Request Submitted Successfully!</h3>
|
||||
<p>Your Prior Permission Required request has been submitted and will be reviewed by airport operations. You will receive confirmation via email if provided.</p>
|
||||
<p><strong>Please note:</strong> This is not confirmation of approval. Airport operations will contact you if additional information is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Notification -->
|
||||
<div id="notification" class="notification"></div>
|
||||
|
||||
<script>
|
||||
// Initialize time dropdowns
|
||||
function initializeTimeDropdowns() {
|
||||
const timeSelects = ['eta-time', 'etd-time'];
|
||||
|
||||
timeSelects.forEach(selectId => {
|
||||
const select = document.getElementById(selectId);
|
||||
// Clear existing options except the first one
|
||||
select.innerHTML = '<option value="">Select Time</option>';
|
||||
|
||||
// Add time options in 15-minute intervals
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
const timeString = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
const option = document.createElement('option');
|
||||
option.value = timeString;
|
||||
option.textContent = timeString;
|
||||
select.appendChild(option);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Notification system
|
||||
function showNotification(message, isError = false) {
|
||||
const notification = document.getElementById('notification');
|
||||
notification.textContent = message;
|
||||
notification.className = 'notification' + (isError ? ' error' : '');
|
||||
|
||||
// Show notification
|
||||
setTimeout(() => {
|
||||
notification.classList.add('show');
|
||||
}, 10);
|
||||
|
||||
// Hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('show');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Aircraft lookup
|
||||
let aircraftLookupTimeout;
|
||||
async function handleAircraftLookup(registration) {
|
||||
clearTimeout(aircraftLookupTimeout);
|
||||
const resultsDiv = document.getElementById('aircraft-lookup-results');
|
||||
const regField = document.getElementById('ac_reg');
|
||||
|
||||
if (!registration || registration.length < 4) {
|
||||
resultsDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
|
||||
|
||||
aircraftLookupTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/aircraft/public/lookup/${registration.toUpperCase()}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.length > 0) {
|
||||
if (data.length === 1) {
|
||||
// Single match - auto-populate the registration field
|
||||
const aircraft = data[0];
|
||||
regField.value = aircraft.registration || registration.toUpperCase();
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="lookup-match">
|
||||
${aircraft.registration || registration.toUpperCase()} - ${aircraft.type_code || 'Unknown'} ${aircraft.model ? `(${aircraft.model})` : ''}
|
||||
</div>
|
||||
`;
|
||||
// Auto-fill aircraft type if not already filled
|
||||
if (!document.getElementById('ac_type').value) {
|
||||
document.getElementById('ac_type').value = aircraft.type_code || '';
|
||||
}
|
||||
} else if (data.length <= 10) {
|
||||
// Multiple matches - show as clickable list
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="aircraft-list">
|
||||
${data.map(aircraft => `
|
||||
<div class="aircraft-option" onclick="selectAircraft('${aircraft.registration || registration.toUpperCase()}', '${aircraft.type_code || ''}')">
|
||||
<div class="aircraft-code">${aircraft.registration || registration.toUpperCase()}</div>
|
||||
<div class="aircraft-details">${aircraft.type_code || 'Unknown'} ${aircraft.model ? `(${aircraft.model})` : ''}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Too many matches
|
||||
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
|
||||
}
|
||||
} else {
|
||||
resultsDiv.innerHTML = '<span class="lookup-no-match">No aircraft found with this registration</span>';
|
||||
}
|
||||
} else {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Aircraft lookup error:', error);
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function selectAircraft(registration, typeCode) {
|
||||
document.getElementById('ac_reg').value = registration;
|
||||
// Always update the aircraft type when a specific aircraft is selected
|
||||
document.getElementById('ac_type').value = typeCode;
|
||||
document.getElementById('aircraft-lookup-results').innerHTML = '';
|
||||
// Remove focus from the field to hide the dropdown
|
||||
document.getElementById('ac_reg').blur();
|
||||
}
|
||||
|
||||
// Clear aircraft lookup results when input loses focus
|
||||
document.getElementById('ac_reg').addEventListener('blur', function() {
|
||||
// Delay clearing to allow click events on options
|
||||
setTimeout(() => {
|
||||
document.getElementById('aircraft-lookup-results').innerHTML = '';
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// Airport lookup functions
|
||||
let arrivalAirportLookupTimeout;
|
||||
async function handleArrivalAirportLookup(query) {
|
||||
clearTimeout(arrivalAirportLookupTimeout);
|
||||
const resultsDiv = document.getElementById('arrival-airport-lookup-results');
|
||||
const inputField = document.getElementById('in_from');
|
||||
|
||||
if (!query || query.length < 2) {
|
||||
resultsDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
|
||||
|
||||
arrivalAirportLookupTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/airport/public/lookup/${query.toUpperCase()}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.length > 0) {
|
||||
if (data.length === 1) {
|
||||
// Single match - auto-populate the input field with ICAO code
|
||||
const airport = data[0];
|
||||
inputField.value = airport.icao;
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="lookup-match">
|
||||
${airport.icao}/${airport.iata || ''} - ${airport.name}, ${airport.country}
|
||||
</div>
|
||||
`;
|
||||
} else if (data.length <= 10) {
|
||||
// Multiple matches - show as clickable list
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="aircraft-list">
|
||||
${data.map(airport => `
|
||||
<div class="aircraft-option" onclick="selectAirport('in_from', '${airport.icao}')">
|
||||
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
|
||||
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Too many matches
|
||||
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
|
||||
}
|
||||
} else {
|
||||
resultsDiv.innerHTML = '<span class="lookup-no-match">No airport found</span>';
|
||||
}
|
||||
} else {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Arrival airport lookup error:', error);
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
let departureAirportLookupTimeout;
|
||||
async function handleDepartureAirportLookup(query) {
|
||||
clearTimeout(departureAirportLookupTimeout);
|
||||
const resultsDiv = document.getElementById('departure-airport-lookup-results');
|
||||
const inputField = document.getElementById('out_to');
|
||||
|
||||
if (!query || query.length < 2) {
|
||||
resultsDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = '<span class="lookup-searching">Searching...</span>';
|
||||
|
||||
departureAirportLookupTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/airport/public/lookup/${query.toUpperCase()}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.length > 0) {
|
||||
if (data.length === 1) {
|
||||
// Single match - auto-populate the input field with ICAO code
|
||||
const airport = data[0];
|
||||
inputField.value = airport.icao;
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="lookup-match">
|
||||
${airport.icao}/${airport.iata || ''} - ${airport.name}, ${airport.country}
|
||||
</div>
|
||||
`;
|
||||
} else if (data.length <= 10) {
|
||||
// Multiple matches - show as clickable list
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="aircraft-list">
|
||||
${data.map(airport => `
|
||||
<div class="aircraft-option" onclick="selectAirport('out_to', '${airport.icao}')">
|
||||
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
|
||||
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Too many matches
|
||||
resultsDiv.innerHTML = '<span class="lookup-no-match">Too many matches, please be more specific</span>';
|
||||
}
|
||||
} else {
|
||||
resultsDiv.innerHTML = '<span class="lookup-no-match">No airport found</span>';
|
||||
}
|
||||
} else {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Departure airport lookup error:', error);
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function selectAirport(fieldId, icaoCode) {
|
||||
document.getElementById(fieldId).value = icaoCode;
|
||||
// Clear the results
|
||||
const resultsDivId = fieldId === 'in_from' ? 'arrival-airport-lookup-results' : 'departure-airport-lookup-results';
|
||||
document.getElementById(resultsDivId).innerHTML = '';
|
||||
// Remove focus from the field to hide the dropdown
|
||||
document.getElementById(fieldId).blur();
|
||||
}
|
||||
|
||||
// Clear airport lookup results when input loses focus
|
||||
document.getElementById('in_from').addEventListener('blur', function() {
|
||||
setTimeout(() => {
|
||||
document.getElementById('arrival-airport-lookup-results').innerHTML = '';
|
||||
}, 150);
|
||||
});
|
||||
|
||||
document.getElementById('out_to').addEventListener('blur', function() {
|
||||
setTimeout(() => {
|
||||
document.getElementById('departure-airport-lookup-results').innerHTML = '';
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// Form submission
|
||||
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const pprData = {};
|
||||
|
||||
formData.forEach((value, key) => {
|
||||
if (value.trim() !== '') {
|
||||
if (key === 'pob_in' || key === 'pob_out') {
|
||||
pprData[key] = parseInt(value);
|
||||
} else if (key === 'eta-date' && formData.get('eta-time')) {
|
||||
// Combine date and time for ETA
|
||||
const dateStr = formData.get('eta-date');
|
||||
const timeStr = formData.get('eta-time');
|
||||
pprData.eta = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||||
} else if (key === 'etd-date' && formData.get('etd-time')) {
|
||||
// Combine date and time for ETD
|
||||
const dateStr = formData.get('etd-date');
|
||||
const timeStr = formData.get('etd-time');
|
||||
pprData.etd = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||||
} else if (key !== 'eta-time' && key !== 'etd-time') {
|
||||
// Skip the time fields as they're handled above
|
||||
pprData[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Show loading
|
||||
document.getElementById('loading').style.display = 'block';
|
||||
document.getElementById('submit-btn').disabled = true;
|
||||
document.getElementById('submit-btn').textContent = 'Submitting...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/pprs/public', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(pprData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('PPR submitted successfully:', result);
|
||||
|
||||
// Hide form and show success message
|
||||
document.getElementById('ppr-form').style.display = 'none';
|
||||
document.getElementById('success-message').style.display = 'block';
|
||||
|
||||
showNotification('PPR request submitted successfully!');
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `Submission failed: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting PPR:', error);
|
||||
showNotification(`Error submitting PPR: ${error.message}`, true);
|
||||
} finally {
|
||||
// Hide loading
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('submit-btn').disabled = false;
|
||||
document.getElementById('submit-btn').textContent = 'Submit PPR Request';
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize the page when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeTimeDropdowns();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
732
web/reports.html
Normal file
732
web/reports.html
Normal file
@@ -0,0 +1,732 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PPR Reports - Swansea PPR</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
background: linear-gradient(135deg, #2c3e50, #3498db);
|
||||
color: white;
|
||||
padding: 0.5rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.title h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.menu-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-bar .user-info {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.7rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #229954;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #95a5a6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #7f8c8d;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c0392b;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.filter-group input, .filter-group select {
|
||||
padding: 0.6rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.reports-table {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
background: #34495e;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-info {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #3498db;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status.new { background: #e3f2fd; color: #1565c0; }
|
||||
.status.confirmed { background: #e8f5e8; color: #2e7d32; }
|
||||
.status.landed { background: #fff3e0; color: #ef6c00; }
|
||||
.status.departed { background: #fce4ec; color: #c2185b; }
|
||||
.status.canceled { background: #ffebee; color: #d32f2f; }
|
||||
.status.deleted { background: #f3e5f5; color: #7b1fa2; }
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.export-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
z-index: 10000;
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.notification.error {
|
||||
background-color: #e74c3c;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.export-buttons {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollable table for mobile */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.table-container table {
|
||||
min-width: 1200px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
<div class="title">
|
||||
<h1>📊 PPR Reports</h1>
|
||||
</div>
|
||||
<div class="menu-buttons">
|
||||
<button class="btn btn-secondary" onclick="window.location.href='admin.html'">
|
||||
← Back to Admin
|
||||
</button>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
Logged in as: <span id="current-user">Loading...</span> |
|
||||
<a href="#" onclick="logout()" style="color: white;">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Filters Section -->
|
||||
<div class="filters-section">
|
||||
<div class="filters-grid">
|
||||
<div class="filter-group">
|
||||
<label for="date-from">Date From:</label>
|
||||
<input type="date" id="date-from">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="date-to">Date To:</label>
|
||||
<input type="date" id="date-to">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="status-filter">Status:</label>
|
||||
<select id="status-filter">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="NEW">New</option>
|
||||
<option value="CONFIRMED">Confirmed</option>
|
||||
<option value="LANDED">Landed</option>
|
||||
<option value="DEPARTED">Departed</option>
|
||||
<option value="CANCELED">Canceled</option>
|
||||
<option value="DELETED">Deleted</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="search-input">Search:</label>
|
||||
<input type="text" id="search-input" placeholder="Aircraft reg, callsign, captain, or airport...">
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button class="btn btn-primary" onclick="loadReports()">
|
||||
🔍 Search
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="clearFilters()">
|
||||
🗑️ Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reports Table -->
|
||||
<div class="reports-table">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<strong>PPR Records</strong>
|
||||
<div class="table-info" id="table-info">Loading...</div>
|
||||
</div>
|
||||
<div class="export-buttons">
|
||||
<button class="btn btn-success" onclick="exportToCSV()">
|
||||
📊 Export CSV
|
||||
</button>
|
||||
<button class="btn btn-success" onclick="exportToXLS()">
|
||||
📋 Export XLS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="reports-loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
Loading reports...
|
||||
</div>
|
||||
|
||||
<div id="reports-table-content" style="display: none;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Status</th>
|
||||
<th>Aircraft</th>
|
||||
<th>Type</th>
|
||||
<th>Callsign</th>
|
||||
<th>Captain</th>
|
||||
<th>From</th>
|
||||
<th>ETA</th>
|
||||
<th>POB In</th>
|
||||
<th>To</th>
|
||||
<th>ETD</th>
|
||||
<th>POB Out</th>
|
||||
<th>Fuel</th>
|
||||
<th>Landed</th>
|
||||
<th>Departed</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Notes</th>
|
||||
<th>Submitted</th>
|
||||
<th>Created By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reports-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="reports-no-data" class="no-data" style="display: none;">
|
||||
<h3>No PPR records found</h3>
|
||||
<p>No records match your current filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Notification -->
|
||||
<div id="notification" class="notification"></div>
|
||||
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let accessToken = null;
|
||||
let currentPPRs = []; // Store current results for export
|
||||
|
||||
// Initialize the page
|
||||
async function initializePage() {
|
||||
await initializeAuth();
|
||||
setupDefaultDateRange();
|
||||
await loadReports();
|
||||
}
|
||||
|
||||
// Set default date range to current month
|
||||
function setupDefaultDateRange() {
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
document.getElementById('date-from').value = firstDay.toISOString().split('T')[0];
|
||||
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Authentication management
|
||||
async function initializeAuth() {
|
||||
const cachedToken = localStorage.getItem('ppr_access_token');
|
||||
const cachedUser = localStorage.getItem('ppr_username');
|
||||
const tokenExpiry = localStorage.getItem('ppr_token_expiry');
|
||||
|
||||
if (cachedToken && cachedUser && tokenExpiry) {
|
||||
const now = new Date().getTime();
|
||||
if (now < parseInt(tokenExpiry)) {
|
||||
accessToken = cachedToken;
|
||||
currentUser = cachedUser;
|
||||
document.getElementById('current-user').textContent = cachedUser;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No valid cached token, redirect to admin
|
||||
window.location.href = 'admin.html';
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('ppr_access_token');
|
||||
localStorage.removeItem('ppr_username');
|
||||
localStorage.removeItem('ppr_token_expiry');
|
||||
accessToken = null;
|
||||
currentUser = null;
|
||||
window.location.href = 'admin.html';
|
||||
}
|
||||
|
||||
// Enhanced fetch wrapper with token expiry handling
|
||||
async function authenticatedFetch(url, options = {}) {
|
||||
if (!accessToken) {
|
||||
window.location.href = 'admin.html';
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
logout();
|
||||
throw new Error('Session expired. Please log in again.');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Load reports data
|
||||
async function loadReports() {
|
||||
document.getElementById('reports-loading').style.display = 'block';
|
||||
document.getElementById('reports-table-content').style.display = 'none';
|
||||
document.getElementById('reports-no-data').style.display = 'none';
|
||||
|
||||
try {
|
||||
const dateFrom = document.getElementById('date-from').value;
|
||||
const dateTo = document.getElementById('date-to').value;
|
||||
const status = document.getElementById('status-filter').value;
|
||||
const search = document.getElementById('search-input').value.trim();
|
||||
|
||||
let url = `/api/v1/pprs/?limit=10000`; // Large limit for reports
|
||||
|
||||
if (dateFrom) url += `&date_from=${dateFrom}`;
|
||||
if (dateTo) url += `&date_to=${dateTo}`;
|
||||
if (status) url += `&status=${status}`;
|
||||
|
||||
const response = await authenticatedFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch PPR records');
|
||||
}
|
||||
|
||||
let pprs = await response.json();
|
||||
|
||||
// Apply client-side search filtering
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
pprs = pprs.filter(ppr =>
|
||||
(ppr.ac_reg && ppr.ac_reg.toLowerCase().includes(searchLower)) ||
|
||||
(ppr.ac_call && ppr.ac_call.toLowerCase().includes(searchLower)) ||
|
||||
(ppr.captain && ppr.captain.toLowerCase().includes(searchLower)) ||
|
||||
(ppr.in_from && ppr.in_from.toLowerCase().includes(searchLower)) ||
|
||||
(ppr.out_to && ppr.out_to.toLowerCase().includes(searchLower))
|
||||
);
|
||||
}
|
||||
|
||||
currentPPRs = pprs; // Store for export
|
||||
displayReports(pprs);
|
||||
} catch (error) {
|
||||
console.error('Error loading reports:', error);
|
||||
if (error.message !== 'Session expired. Please log in again.') {
|
||||
showNotification('Error loading reports', true);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('reports-loading').style.display = 'none';
|
||||
}
|
||||
|
||||
// Display reports in table
|
||||
async function displayReports(pprs) {
|
||||
const tbody = document.getElementById('reports-table-body');
|
||||
const tableInfo = document.getElementById('table-info');
|
||||
|
||||
tableInfo.textContent = `${pprs.length} records found`;
|
||||
|
||||
if (pprs.length === 0) {
|
||||
document.getElementById('reports-no-data').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = '';
|
||||
document.getElementById('reports-table-content').style.display = 'block';
|
||||
|
||||
for (const ppr of pprs) {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
// Format dates
|
||||
const eta = ppr.eta ? formatDateTime(ppr.eta) : '-';
|
||||
const etd = ppr.etd ? formatDateTime(ppr.etd) : '-';
|
||||
const landed = ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '-';
|
||||
const departed = ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '-';
|
||||
const submitted = ppr.submitted_dt ? formatDateTime(ppr.submitted_dt) : '-';
|
||||
|
||||
// Status styling
|
||||
const statusClass = `status ${ppr.status.toLowerCase()}`;
|
||||
const statusText = ppr.status.charAt(0).toUpperCase() + ppr.status.slice(1).toLowerCase();
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${ppr.id}</td>
|
||||
<td><span class="${statusClass}">${statusText}</span></td>
|
||||
<td>${ppr.ac_reg}</td>
|
||||
<td>${ppr.ac_type}</td>
|
||||
<td>${ppr.ac_call || '-'}</td>
|
||||
<td>${ppr.captain}</td>
|
||||
<td>${ppr.in_from}</td>
|
||||
<td>${eta}</td>
|
||||
<td>${ppr.pob_in}</td>
|
||||
<td>${ppr.out_to || '-'}</td>
|
||||
<td>${etd}</td>
|
||||
<td>${ppr.pob_out || '-'}</td>
|
||||
<td>${ppr.fuel || '-'}</td>
|
||||
<td>${landed}</td>
|
||||
<td>${departed}</td>
|
||||
<td>${ppr.email || '-'}</td>
|
||||
<td>${ppr.phone || '-'}</td>
|
||||
<td>${ppr.notes || '-'}</td>
|
||||
<td>${submitted}</td>
|
||||
<td>${ppr.created_by || '-'}</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
let utcDateStr = dateStr;
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const date = new Date(utcDateStr);
|
||||
return date.toISOString().slice(0, 16).replace('T', ' ');
|
||||
}
|
||||
|
||||
// Clear filters
|
||||
function clearFilters() {
|
||||
document.getElementById('status-filter').value = '';
|
||||
document.getElementById('search-input').value = '';
|
||||
setupDefaultDateRange();
|
||||
loadReports();
|
||||
}
|
||||
|
||||
// Export functions
|
||||
function exportToCSV() {
|
||||
if (currentPPRs.length === 0) {
|
||||
showNotification('No data to export', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'ID', 'Status', 'Aircraft Reg', 'Aircraft Type', 'Callsign', 'Captain',
|
||||
'From', 'ETA', 'POB In', 'To', 'ETD', 'POB Out', 'Fuel',
|
||||
'Landed', 'Departed', 'Email', 'Phone', 'Notes', 'Submitted', 'Created By'
|
||||
];
|
||||
|
||||
const csvData = currentPPRs.map(ppr => [
|
||||
ppr.id,
|
||||
ppr.status,
|
||||
ppr.ac_reg,
|
||||
ppr.ac_type,
|
||||
ppr.ac_call || '',
|
||||
ppr.captain,
|
||||
ppr.in_from,
|
||||
ppr.eta ? formatDateTime(ppr.eta) : '',
|
||||
ppr.pob_in,
|
||||
ppr.out_to || '',
|
||||
ppr.etd ? formatDateTime(ppr.etd) : '',
|
||||
ppr.pob_out || '',
|
||||
ppr.fuel || '',
|
||||
ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '',
|
||||
ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '',
|
||||
ppr.email || '',
|
||||
ppr.phone || '',
|
||||
ppr.notes || '',
|
||||
ppr.submitted_dt ? formatDateTime(ppr.submitted_dt) : '',
|
||||
ppr.created_by || ''
|
||||
]);
|
||||
|
||||
downloadCSV(headers, csvData, 'ppr_reports.csv');
|
||||
}
|
||||
|
||||
function exportToXLS() {
|
||||
if (currentPPRs.length === 0) {
|
||||
showNotification('No data to export', true);
|
||||
return;
|
||||
}
|
||||
|
||||
// For XLS export, we'll create a CSV that Excel can open
|
||||
// In a production environment, you'd want to use a proper XLS library
|
||||
exportToCSV();
|
||||
showNotification('XLS export uses CSV format (compatible with Excel)');
|
||||
}
|
||||
|
||||
function downloadCSV(headers, data, filename) {
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...data.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', filename);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showNotification(`Exported ${data.length} records to ${filename}`);
|
||||
}
|
||||
|
||||
// Notification system
|
||||
function showNotification(message, isError = false) {
|
||||
const notification = document.getElementById('notification');
|
||||
notification.textContent = message;
|
||||
notification.className = 'notification' + (isError ? ' error' : '');
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.add('show');
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('show');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Handle Enter key in search input
|
||||
document.getElementById('search-input').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
loadReports();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize when page loads
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user