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.
|
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
|
## API Base URL
|
||||||
- Development: `http://localhost:8001/api/v1`
|
- Development: `http://localhost:8001/api/v1`
|
||||||
- Authentication: Bearer token required for most endpoints
|
- 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)
|
**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)
|
## Public Endpoints (No Authentication Required)
|
||||||
|
|
||||||
### Get Today's Arrivals
|
### 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.
|
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
|
## Data Models
|
||||||
|
|
||||||
### PPR Status Enum
|
### PPR Status Enum
|
||||||
@@ -256,10 +352,14 @@ The system automatically logs:
|
|||||||
|
|
||||||
## Web Interfaces
|
## Web Interfaces
|
||||||
|
|
||||||
### Public Arrivals/Departures Board
|
### Public PPR Forms
|
||||||
- **URL:** http://localhost:8082
|
- **URL:** http://localhost:8082
|
||||||
- **Features:** Real-time arrivals and departures display
|
- **Features:**
|
||||||
- **Authentication:** None required
|
- 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
|
### Admin Interface
|
||||||
- **URL:** http://localhost:8082/admin.html
|
- **URL:** http://localhost:8082/admin.html
|
||||||
@@ -270,15 +370,80 @@ The system automatically logs:
|
|||||||
- Journal/audit trail viewing
|
- Journal/audit trail viewing
|
||||||
- Quick status updates (Confirm, Land, Depart, Cancel)
|
- Quick status updates (Confirm, Land, Depart, Cancel)
|
||||||
- New PPR entry creation
|
- New PPR entry creation
|
||||||
|
- User management (administrators only)
|
||||||
|
- Real-time WebSocket updates
|
||||||
- **Authentication:** Username/password prompt (uses API token)
|
- **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
|
## Development URLs
|
||||||
|
|
||||||
- API Base: http://localhost:8001/api/v1
|
- API Base: http://localhost:8001/api/v1
|
||||||
- Public Web Interface: http://localhost:8082
|
- Public Web Interface: http://localhost:8082
|
||||||
- Admin Interface: http://localhost:8082/admin.html
|
- Admin Interface: http://localhost:8082/admin.html
|
||||||
|
- Reports Interface: http://localhost:8082/reports.html
|
||||||
- API Documentation: http://localhost:8001/docs
|
- API Documentation: http://localhost:8001/docs
|
||||||
- Database: localhost:3307 (MySQL)
|
- 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
|
## Example Workflow
|
||||||
|
|
||||||
@@ -309,4 +474,90 @@ curl -X PATCH "http://localhost:8001/api/v1/pprs/1/status" \
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-d '{"status":"LANDED"}'
|
-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
|
## Features
|
||||||
|
|
||||||
- 🚀 **Modern API**: RESTful API with automatic OpenAPI documentation
|
- 🚀 **Modern API**: RESTful API with automatic OpenAPI documentation
|
||||||
- 🔐 **Authentication**: JWT-based authentication system
|
- 🔐 **Authentication**: JWT-based authentication system with role-based access
|
||||||
- 📊 **Real-time Updates**: WebSocket support for live tower updates
|
- <EFBFBD> **Transactional Email**: SMTP-based email notifications for PPR submissions and cancellations
|
||||||
- 🗄️ **Self-contained**: Fully dockerized with local database
|
- <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`
|
- 🔍 **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
|
- 📱 **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
|
## Quick Start
|
||||||
|
|
||||||
@@ -33,7 +37,11 @@ cd nextgen
|
|||||||
### 2. Access the Services
|
### 2. Access the Services
|
||||||
- **API Documentation**: http://localhost:8001/docs
|
- **API Documentation**: http://localhost:8001/docs
|
||||||
- **API Base URL**: http://localhost:8001/api/v1
|
- **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)
|
- **Database**: localhost:3307 (user: ppr_user, password: ppr_password123)
|
||||||
|
- **phpMyAdmin**: http://localhost:8083
|
||||||
|
|
||||||
### 3. Default Login
|
### 3. Default Login
|
||||||
- **Username**: admin
|
- **Username**: admin
|
||||||
@@ -45,20 +53,68 @@ cd nextgen
|
|||||||
- `POST /api/v1/auth/login` - Login and get JWT token
|
- `POST /api/v1/auth/login` - Login and get JWT token
|
||||||
|
|
||||||
### PPR Management
|
### 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
|
- `POST /api/v1/pprs` - Create new PPR
|
||||||
- `GET /api/v1/pprs/{id}` - Get specific PPR
|
- `GET /api/v1/pprs/{id}` - Get specific PPR
|
||||||
- `PUT /api/v1/pprs/{id}` - Update 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
|
- `PATCH /api/v1/pprs/{id}/status` - Update PPR status
|
||||||
- `DELETE /api/v1/pprs/{id}` - Delete PPR
|
- `DELETE /api/v1/pprs/{id}` - Delete PPR
|
||||||
|
- `GET /api/v1/pprs/{id}/journal` - Get PPR activity journal
|
||||||
|
|
||||||
### Public Endpoints (No Auth Required)
|
### Public Endpoints (No Auth Required)
|
||||||
- `GET /api/v1/public/arrivals` - Today's arrivals
|
- `GET /api/v1/public/arrivals` - Today's arrivals
|
||||||
- `GET /api/v1/public/departures` - Today's departures
|
- `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
|
### Real-time
|
||||||
- `WebSocket /ws/tower-updates` - Live updates for tower operations
|
- `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
|
## Development
|
||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
@@ -87,6 +143,33 @@ cd backend
|
|||||||
pytest tests/
|
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
|
## Environment Variables
|
||||||
|
|
||||||
Key environment variables (configured in docker-compose.yml):
|
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
|
- `SECRET_KEY` - JWT secret key
|
||||||
- `ACCESS_TOKEN_EXPIRE_MINUTES` - Token expiration time
|
- `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
|
## Database Schema
|
||||||
|
|
||||||
The system uses an improved version of the original schema with:
|
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
|
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])
|
@router.get("/search", response_model=List[AircraftSchema])
|
||||||
async def search_aircraft(
|
async def search_aircraft(
|
||||||
q: Optional[str] = Query(None, description="Search query for registration, type, or manufacturer"),
|
q: Optional[str] = Query(None, description="Search query for registration, type, or manufacturer"),
|
||||||
|
|||||||
@@ -69,3 +69,38 @@ async def search_airports(
|
|||||||
).limit(limit).all()
|
).limit(limit).all()
|
||||||
|
|
||||||
return airports
|
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.schemas.ppr import PPR, PPRCreate, PPRUpdate, PPRStatus, PPRStatusUpdate, Journal
|
||||||
from app.models.ppr import User
|
from app.models.ppr import User
|
||||||
from app.core.utils import get_client_ip
|
from app.core.utils import get_client_ip
|
||||||
|
from app.core.email import email_service
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -56,6 +58,48 @@ async def create_ppr(
|
|||||||
return 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)
|
@router.get("/{ppr_id}", response_model=PPR)
|
||||||
async def get_ppr(
|
async def get_ppr(
|
||||||
ppr_id: int,
|
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
|
return ppr
|
||||||
|
|
||||||
|
|
||||||
@@ -206,6 +264,100 @@ async def delete_ppr(
|
|||||||
return 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])
|
@router.get("/{ppr_id}/journal", response_model=List[Journal])
|
||||||
async def get_ppr_journal(
|
async def get_ppr_journal(
|
||||||
ppr_id: int,
|
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.orm import Session
|
||||||
from sqlalchemy import and_, or_, func, desc
|
from sqlalchemy import and_, or_, func, desc
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
import secrets
|
||||||
from app.models.ppr import PPRRecord, PPRStatus
|
from app.models.ppr import PPRRecord, PPRStatus
|
||||||
from app.schemas.ppr import PPRCreate, PPRUpdate
|
from app.schemas.ppr import PPRCreate, PPRUpdate
|
||||||
from app.crud.crud_journal import journal as crud_journal
|
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]:
|
def get(self, db: Session, ppr_id: int) -> Optional[PPRRecord]:
|
||||||
return db.query(PPRRecord).filter(PPRRecord.id == ppr_id).first()
|
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(
|
def get_multi(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -67,7 +71,8 @@ class CRUDPPR:
|
|||||||
db_obj = PPRRecord(
|
db_obj = PPRRecord(
|
||||||
**obj_in.dict(),
|
**obj_in.dict(),
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
status=PPRStatus.NEW
|
status=PPRStatus.NEW,
|
||||||
|
public_token=secrets.token_urlsafe(64)
|
||||||
)
|
)
|
||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class PPRRecord(Base):
|
|||||||
departed_dt = Column(DateTime, nullable=True)
|
departed_dt = Column(DateTime, nullable=True)
|
||||||
created_by = Column(String(16), nullable=True)
|
created_by = Column(String(16), nullable=True)
|
||||||
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||||
|
public_token = Column(String(128), nullable=True, unique=True)
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
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
|
pytest-asyncio==0.21.1
|
||||||
httpx==0.25.2
|
httpx==0.25.2
|
||||||
redis==5.0.1
|
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,
|
departed_dt DATETIME DEFAULT NULL,
|
||||||
created_by VARCHAR(16) DEFAULT NULL,
|
created_by VARCHAR(16) DEFAULT NULL,
|
||||||
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
public_token VARCHAR(128) DEFAULT NULL UNIQUE,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
-- Indexes for better performance
|
-- Indexes for better performance
|
||||||
@@ -49,7 +50,8 @@ CREATE TABLE submitted (
|
|||||||
INDEX idx_etd (etd),
|
INDEX idx_etd (etd),
|
||||||
INDEX idx_ac_reg (ac_reg),
|
INDEX idx_ac_reg (ac_reg),
|
||||||
INDEX idx_submitted_dt (submitted_dt),
|
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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- Activity journal table with foreign key
|
-- Activity journal table with foreign key
|
||||||
@@ -1,26 +1,19 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# MySQL Database
|
|
||||||
db:
|
db:
|
||||||
image: mysql:8.0
|
build: ./db-init
|
||||||
container_name: ppr_nextgen_db
|
container_name: ppr_nextgen_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: rootpassword123
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||||
MYSQL_DATABASE: ppr_nextgen
|
MYSQL_DATABASE: ${DB_NAME}
|
||||||
MYSQL_USER: ppr_user
|
MYSQL_USER: ${DB_USER}
|
||||||
MYSQL_PASSWORD: ppr_password123
|
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||||
ports:
|
|
||||||
- "3307:3306" # Use different port to avoid conflicts
|
|
||||||
volumes:
|
volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
- 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:
|
networks:
|
||||||
- ppr_network
|
- private_network
|
||||||
|
|
||||||
# FastAPI Backend
|
# FastAPI Backend
|
||||||
api:
|
api:
|
||||||
@@ -28,23 +21,24 @@ services:
|
|||||||
container_name: ppr_nextgen_api
|
container_name: ppr_nextgen_api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: db
|
DB_HOST: ${DB_HOST}
|
||||||
DB_USER: ppr_user
|
DB_USER: ${DB_USER}
|
||||||
DB_PASSWORD: ppr_password123
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
DB_NAME: ppr_nextgen
|
DB_NAME: ${DB_NAME}
|
||||||
DB_PORT: 3306
|
DB_PORT: ${DB_PORT}
|
||||||
SECRET_KEY: super-secret-key-for-nextgen-ppr-system-change-in-production
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: 30
|
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES}
|
||||||
API_V1_STR: /api/v1
|
API_V1_STR: ${API_V1_STR}
|
||||||
PROJECT_NAME: "Airfield PPR API NextGen"
|
PROJECT_NAME: ${PROJECT_NAME}
|
||||||
ports:
|
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:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
networks:
|
networks:
|
||||||
- ppr_network
|
- private_network
|
||||||
|
- public_network
|
||||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
# Redis for caching (optional for now)
|
# Redis for caching (optional for now)
|
||||||
@@ -52,10 +46,8 @@ services:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: ppr_nextgen_redis
|
container_name: ppr_nextgen_redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "6380:6379" # Use different port
|
|
||||||
networks:
|
networks:
|
||||||
- ppr_network
|
- private_network
|
||||||
|
|
||||||
# Nginx web server for public frontend
|
# Nginx web server for public frontend
|
||||||
web:
|
web:
|
||||||
@@ -63,14 +55,14 @@ services:
|
|||||||
container_name: ppr_nextgen_web
|
container_name: ppr_nextgen_web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8082:80" # Public web interface
|
- "${WEB_PORT_EXTERNAL}:80" # Public web interface
|
||||||
volumes:
|
volumes:
|
||||||
- ./web:/usr/share/nginx/html
|
- ./web:/usr/share/nginx/html
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
networks:
|
networks:
|
||||||
- ppr_network
|
- public_network
|
||||||
|
|
||||||
# phpMyAdmin for database management
|
# phpMyAdmin for database management
|
||||||
phpmyadmin:
|
phpmyadmin:
|
||||||
@@ -78,21 +70,24 @@ services:
|
|||||||
container_name: ppr_nextgen_phpmyadmin
|
container_name: ppr_nextgen_phpmyadmin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PMA_HOST: db
|
PMA_HOST: ${PMA_HOST}
|
||||||
PMA_PORT: 3306
|
PMA_PORT: ${DB_PORT}
|
||||||
PMA_USER: ppr_user
|
PMA_USER: ${DB_USER}
|
||||||
PMA_PASSWORD: ppr_password123
|
PMA_PASSWORD: ${DB_PASSWORD}
|
||||||
UPLOAD_LIMIT: 50M
|
UPLOAD_LIMIT: ${UPLOAD_LIMIT}
|
||||||
ports:
|
ports:
|
||||||
- "8083:80" # phpMyAdmin web interface
|
- "${PMA_PORT_EXTERNAL}:80" # phpMyAdmin web interface
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
networks:
|
networks:
|
||||||
- ppr_network
|
- private_network
|
||||||
|
- public_network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
ppr_network:
|
private_network:
|
||||||
|
driver: bridge
|
||||||
|
public_network:
|
||||||
driver: bridge
|
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;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.top-bar {
|
||||||
background: linear-gradient(135deg, #2c3e50, #3498db);
|
background: linear-gradient(135deg, #2c3e50, #3498db);
|
||||||
color: white;
|
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);
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header h1 {
|
.title h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.8rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header .user-info {
|
.menu-buttons {
|
||||||
float: right;
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar .user-info {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -41,26 +51,7 @@
|
|||||||
padding: 2rem;
|
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 {
|
.btn {
|
||||||
padding: 0.7rem 1.5rem;
|
padding: 0.7rem 1.5rem;
|
||||||
@@ -563,29 +554,28 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="top-bar">
|
||||||
<h1>✈️ PPR Administration</h1>
|
<div class="title">
|
||||||
<div class="user-info">
|
<h1>✈️ Swansea PPR</h1>
|
||||||
Logged in as: <span id="current-user">Loading...</span> |
|
|
||||||
<a href="#" onclick="logout()" style="color: white;">Logout</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="clear: both;"></div>
|
<div class="menu-buttons">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="top-menu">
|
|
||||||
<div class="menu-left">
|
|
||||||
<button class="btn btn-success" onclick="openNewPPRModal()">
|
<button class="btn btn-success" onclick="openNewPPRModal()">
|
||||||
➕ New PPR Entry
|
➕ New PPR Entry
|
||||||
</button>
|
</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;">
|
<button class="btn btn-warning" onclick="openUserManagementModal()" id="user-management-btn" style="display: none;">
|
||||||
👥 User Management
|
👥 User Management
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
<div class="menu-right">
|
|
||||||
<button class="btn btn-primary" onclick="loadPPRs()">
|
<button class="btn btn-primary" onclick="loadPPRs()">
|
||||||
🔄 Refresh
|
🔄 Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -702,10 +692,10 @@
|
|||||||
✓ Confirm
|
✓ Confirm
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-landed" class="btn btn-warning btn-sm" onclick="showTimestampModal('LANDED')">
|
<button id="btn-landed" class="btn btn-warning btn-sm" onclick="showTimestampModal('LANDED')">
|
||||||
🛬 Landed
|
🛬 Land
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-departed" class="btn btn-primary btn-sm" onclick="showTimestampModal('DEPARTED')">
|
<button id="btn-departed" class="btn btn-primary btn-sm" onclick="showTimestampModal('DEPARTED')">
|
||||||
🛫 Departed
|
🛫 Depart
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-cancel" class="btn btn-danger btn-sm" onclick="updateStatus('CANCELED')">
|
<button id="btn-cancel" class="btn btn-danger btn-sm" onclick="updateStatus('CANCELED')">
|
||||||
❌ Cancel
|
❌ Cancel
|
||||||
@@ -723,11 +713,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ac_type">Aircraft Type *</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ac_call">Callsign</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="captain">Captain *</label>
|
<label for="captain">Captain *</label>
|
||||||
@@ -740,7 +730,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="eta">ETA (Local Time) *</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="pob_in">POB Inbound *</label>
|
<label for="pob_in">POB Inbound *</label>
|
||||||
@@ -748,7 +743,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="fuel">Fuel Required</label>
|
<label for="fuel">Fuel Required</label>
|
||||||
<select id="fuel" name="fuel">
|
<select id="fuel" name="fuel" tabindex="-1">
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
<option value="100LL">100LL</option>
|
<option value="100LL">100LL</option>
|
||||||
<option value="JET A1">JET A1</option>
|
<option value="JET A1">JET A1</option>
|
||||||
@@ -757,28 +752,33 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="out_to">Departing To</label>
|
<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 id="departure-airport-lookup-results"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="etd">ETD (Local Time)</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="pob_out">POB Outbound</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email</label>
|
<label for="email">Email</label>
|
||||||
<input type="email" id="email" name="email">
|
<input type="email" id="email" name="email" tabindex="-1">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="phone">Phone</label>
|
<label for="phone">Phone</label>
|
||||||
<input type="tel" id="phone" name="phone">
|
<input type="tel" id="phone" name="phone" tabindex="-1">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
<label for="notes">Notes</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -998,12 +998,27 @@
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the application
|
// Initialize time dropdowns
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
function initializeTimeDropdowns() {
|
||||||
initializeAuth();
|
const timeSelects = ['eta-time', 'etd-time'];
|
||||||
setupLoginForm();
|
|
||||||
setupKeyboardShortcuts();
|
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
|
// Authentication management
|
||||||
async function initializeAuth() {
|
async function initializeAuth() {
|
||||||
@@ -1256,40 +1271,57 @@
|
|||||||
document.getElementById('departures-loading').style.display = 'none';
|
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 tbody = document.getElementById('arrivals-table-body');
|
||||||
const recordCount = document.getElementById('arrivals-count');
|
const recordCount = document.getElementById('arrivals-count');
|
||||||
|
|
||||||
recordCount.textContent = arrivals.length;
|
recordCount.textContent = arrivals.length;
|
||||||
|
|
||||||
if (arrivals.length === 0) {
|
if (arrivals.length === 0) {
|
||||||
document.getElementById('arrivals-no-data').style.display = 'block';
|
document.getElementById('arrivals-no-data').style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
document.getElementById('arrivals-table-content').style.display = 'block';
|
document.getElementById('arrivals-table-content').style.display = 'block';
|
||||||
|
for (const ppr of arrivals) {
|
||||||
arrivals.forEach(ppr => {
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.onclick = () => openPPRModal(ppr.id);
|
row.onclick = () => openPPRModal(ppr.id);
|
||||||
|
|
||||||
// Create notes indicator if notes exist
|
// Create notes indicator if notes exist
|
||||||
const notesIndicator = ppr.notes && ppr.notes.trim() ?
|
const notesIndicator = ppr.notes && ppr.notes.trim() ?
|
||||||
`<span class="notes-tooltip">
|
`<span class="notes-tooltip">
|
||||||
<span class="notes-indicator">📝</span>
|
<span class="notes-indicator">📝</span>
|
||||||
<span class="tooltip-text">${ppr.notes}</span>
|
<span class="tooltip-text">${ppr.notes}</span>
|
||||||
</span>` : '';
|
</span>` : '';
|
||||||
|
|
||||||
// Display callsign as main item if present, registration below; otherwise show registration
|
// Display callsign as main item if present, registration below; otherwise show registration
|
||||||
const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ?
|
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_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${ppr.ac_reg}</span>` :
|
||||||
`<strong>${ppr.ac_reg}</strong>`;
|
`<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 = `
|
row.innerHTML = `
|
||||||
<td>${aircraftDisplay}${notesIndicator}</td>
|
<td>${aircraftDisplay}${notesIndicator}</td>
|
||||||
<td>${ppr.ac_type}</td>
|
<td>${ppr.ac_type}</td>
|
||||||
<td>${ppr.in_from}</td>
|
<td>${fromDisplay}</td>
|
||||||
<td>${formatTimeOnly(ppr.eta)}</td>
|
<td>${formatTimeOnly(ppr.eta)}</td>
|
||||||
<td>${ppr.pob_in}</td>
|
<td>${ppr.pob_in}</td>
|
||||||
<td>${ppr.fuel || '-'}</td>
|
<td>${ppr.fuel || '-'}</td>
|
||||||
@@ -1302,45 +1334,42 @@
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayDepartures(departures) {
|
async function displayDepartures(departures) {
|
||||||
const tbody = document.getElementById('departures-table-body');
|
const tbody = document.getElementById('departures-table-body');
|
||||||
const recordCount = document.getElementById('departures-count');
|
const recordCount = document.getElementById('departures-count');
|
||||||
|
|
||||||
recordCount.textContent = departures.length;
|
recordCount.textContent = departures.length;
|
||||||
|
|
||||||
if (departures.length === 0) {
|
if (departures.length === 0) {
|
||||||
document.getElementById('departures-no-data').style.display = 'block';
|
document.getElementById('departures-no-data').style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
document.getElementById('departures-table-content').style.display = 'block';
|
document.getElementById('departures-table-content').style.display = 'block';
|
||||||
|
for (const ppr of departures) {
|
||||||
departures.forEach(ppr => {
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.onclick = () => openPPRModal(ppr.id);
|
row.onclick = () => openPPRModal(ppr.id);
|
||||||
|
|
||||||
// Create notes indicator if notes exist
|
// Create notes indicator if notes exist
|
||||||
const notesIndicator = ppr.notes && ppr.notes.trim() ?
|
const notesIndicator = ppr.notes && ppr.notes.trim() ?
|
||||||
`<span class="notes-tooltip">
|
`<span class="notes-tooltip">
|
||||||
<span class="notes-indicator">📝</span>
|
<span class="notes-indicator">📝</span>
|
||||||
<span class="tooltip-text">${ppr.notes}</span>
|
<span class="tooltip-text">${ppr.notes}</span>
|
||||||
</span>` : '';
|
</span>` : '';
|
||||||
|
|
||||||
// Display callsign as main item if present, registration below; otherwise show registration
|
// Display callsign as main item if present, registration below; otherwise show registration
|
||||||
const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ?
|
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_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${ppr.ac_reg}</span>` :
|
||||||
`<strong>${ppr.ac_reg}</strong>`;
|
`<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 = `
|
row.innerHTML = `
|
||||||
<td>${aircraftDisplay}${notesIndicator}</td>
|
<td>${aircraftDisplay}${notesIndicator}</td>
|
||||||
<td>${ppr.ac_type}</td>
|
<td>${ppr.ac_type}</td>
|
||||||
<td>${ppr.out_to || '-'}</td>
|
<td>${toDisplay}</td>
|
||||||
<td>${ppr.etd ? formatTimeOnly(ppr.etd) : '-'}</td>
|
<td>${ppr.etd ? formatTimeOnly(ppr.etd) : '-'}</td>
|
||||||
<td>${ppr.pob_out || ppr.pob_in}</td>
|
<td>${ppr.pob_out || ppr.pob_in}</td>
|
||||||
<td>${ppr.fuel || '-'}</td>
|
<td>${ppr.fuel || '-'}</td>
|
||||||
@@ -1354,15 +1383,20 @@
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimeOnly(dateStr) {
|
function formatTimeOnly(dateStr) {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
// Ensure the datetime string is treated as UTC
|
// 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);
|
const date = new Date(utcDateStr);
|
||||||
return date.toISOString().slice(11, 16);
|
return date.toISOString().slice(11, 16);
|
||||||
}
|
}
|
||||||
@@ -1370,7 +1404,13 @@
|
|||||||
function formatDateTime(dateStr) {
|
function formatDateTime(dateStr) {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
// Ensure the datetime string is treated as UTC
|
// 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);
|
const date = new Date(utcDateStr);
|
||||||
return date.toISOString().slice(0, 10) + ' ' + date.toISOString().slice(11, 16);
|
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 eta = new Date(now.getTime() + 60 * 60 * 1000); // +1 hour
|
||||||
const etd = new Date(now.getTime() + 2 * 60 * 60 * 1000); // +2 hours
|
const etd = new Date(now.getTime() + 2 * 60 * 60 * 1000); // +2 hours
|
||||||
|
|
||||||
// Format as local datetime-local value
|
// Format date and time for separate inputs
|
||||||
function formatLocalDateTime(date) {
|
function formatDate(date) {
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
return `${year}-${month}-${day}`;
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('eta').value = formatLocalDateTime(eta);
|
function formatTime(date) {
|
||||||
document.getElementById('etd').value = etd ? formatLocalDateTime(etd) : '';
|
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
|
// Clear aircraft lookup results
|
||||||
clearAircraftLookup();
|
clearAircraftLookup();
|
||||||
@@ -1437,6 +1483,28 @@
|
|||||||
|
|
||||||
const ppr = await response.json();
|
const ppr = await response.json();
|
||||||
populateForm(ppr);
|
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
|
await loadJournal(pprId); // Always load journal when opening a PPR
|
||||||
|
|
||||||
document.getElementById('pprModal').style.display = 'block';
|
document.getElementById('pprModal').style.display = 'block';
|
||||||
@@ -1447,24 +1515,55 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function populateForm(ppr) {
|
function populateForm(ppr) {
|
||||||
|
console.log('populateForm called with:', ppr);
|
||||||
Object.keys(ppr).forEach(key => {
|
Object.keys(ppr).forEach(key => {
|
||||||
const field = document.getElementById(key);
|
if (key === 'eta' || key === 'etd') {
|
||||||
if (field) {
|
if (ppr[key]) {
|
||||||
if (key === 'eta' || key === 'etd') {
|
console.log(`Processing ${key}:`, ppr[key]);
|
||||||
if (ppr[key]) {
|
// ppr[key] is UTC datetime string from API (naive, assume UTC)
|
||||||
// ppr[key] is UTC datetime string from API (naive, assume UTC)
|
let utcDateStr = ppr[key];
|
||||||
const utcDateStr = ppr[key].includes('Z') ? ppr[key] : ppr[key] + 'Z';
|
if (!utcDateStr.includes('T')) {
|
||||||
const date = new Date(utcDateStr); // Now correctly parsed as UTC
|
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||||
// Format as local time for datetime-local input
|
}
|
||||||
|
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 year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(date.getDate()).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 hours = String(date.getHours()).padStart(2, '0');
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
const rawMinutes = date.getMinutes();
|
||||||
field.value = `${year}-${month}-${day}T${hours}:${minutes}`;
|
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 {
|
} else {
|
||||||
|
console.log(`${key} is empty`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const field = document.getElementById(key);
|
||||||
|
if (field) {
|
||||||
field.value = ppr[key] || '';
|
field.value = ppr[key] || '';
|
||||||
|
} else {
|
||||||
|
console.log(`Field not found for key: ${key}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1588,6 +1687,7 @@
|
|||||||
closeTimestampModal();
|
closeTimestampModal();
|
||||||
loadPPRs(); // Refresh both tables
|
loadPPRs(); // Refresh both tables
|
||||||
showNotification(`Status updated to ${updatedStatus}`);
|
showNotification(`Status updated to ${updatedStatus}`);
|
||||||
|
closePPRModal(); // Close PPR modal after successful status update
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating status:', error);
|
console.error('Error updating status:', error);
|
||||||
showNotification(`Error updating status: ${error.message}`, true);
|
showNotification(`Error updating status: ${error.message}`, true);
|
||||||
@@ -1607,10 +1707,18 @@
|
|||||||
if (key !== 'id' && value.trim() !== '') {
|
if (key !== 'id' && value.trim() !== '') {
|
||||||
if (key === 'pob_in' || key === 'pob_out') {
|
if (key === 'pob_in' || key === 'pob_out') {
|
||||||
pprData[key] = parseInt(value);
|
pprData[key] = parseInt(value);
|
||||||
} else if (key === 'eta' || key === 'etd') {
|
} else if (key === 'eta-date' && formData.get('eta-time')) {
|
||||||
// Convert local datetime-local to UTC ISO string
|
// Combine date and time for ETA
|
||||||
pprData[key] = new Date(value).toISOString();
|
const dateStr = formData.get('eta-date');
|
||||||
} else {
|
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;
|
pprData[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1656,6 +1764,13 @@
|
|||||||
async function updateStatus(status) {
|
async function updateStatus(status) {
|
||||||
if (!currentPPRId || !accessToken) return;
|
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 {
|
try {
|
||||||
const response = await fetch(`/api/v1/pprs/${currentPPRId}/status`, {
|
const response = await fetch(`/api/v1/pprs/${currentPPRId}/status`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -1673,6 +1788,7 @@
|
|||||||
await loadJournal(currentPPRId); // Refresh journal
|
await loadJournal(currentPPRId); // Refresh journal
|
||||||
loadPPRs(); // Refresh both tables
|
loadPPRs(); // Refresh both tables
|
||||||
showNotification(`Status updated to ${status}`);
|
showNotification(`Status updated to ${status}`);
|
||||||
|
closePPRModal(); // Close modal after successful status update
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating status:', error);
|
console.error('Error updating status:', error);
|
||||||
showNotification('Error updating status', true);
|
showNotification('Error updating status', true);
|
||||||
@@ -2285,6 +2401,14 @@
|
|||||||
document.getElementById('out_to').value = icaoCode;
|
document.getElementById('out_to').value = icaoCode;
|
||||||
clearDepartureAirportLookup();
|
clearDepartureAirportLookup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize the page when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
setupLoginForm();
|
||||||
|
setupKeyboardShortcuts();
|
||||||
|
initializeTimeDropdowns(); // Initialize time dropdowns
|
||||||
|
initializeAuth(); // Start authentication process
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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>`;
|
return `<span class="status ${status.toLowerCase()}">${status}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create PPR item HTML
|
// ICAO code to airport name cache
|
||||||
function createPPRItem(ppr) {
|
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
|
// Display callsign as main item if present, registration below; otherwise show registration
|
||||||
const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ?
|
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_call}<br><span style="font-size: 0.85em; color: #888; font-style: italic;">${ppr.ac_reg}</span>` :
|
||||||
ppr.ac_reg;
|
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 `
|
return `
|
||||||
<div class="ppr-item">
|
<div class="ppr-item">
|
||||||
<div class="ppr-field">
|
<div class="ppr-field">
|
||||||
@@ -269,6 +296,14 @@
|
|||||||
<strong>Type</strong>
|
<strong>Type</strong>
|
||||||
<span>${ppr.ac_type}</span>
|
<span>${ppr.ac_type}</span>
|
||||||
</div>
|
</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">
|
<div class="ppr-field">
|
||||||
<strong>Time</strong>
|
<strong>Time</strong>
|
||||||
<span>${formatDateTime(ppr.eta || ppr.etd)}</span>
|
<span>${formatDateTime(ppr.eta || ppr.etd)}</span>
|
||||||
@@ -298,19 +333,21 @@
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/public/arrivals');
|
const response = await fetch('/api/v1/public/arrivals');
|
||||||
const arrivals = await response.json();
|
const arrivals = await response.json();
|
||||||
|
|
||||||
const loadingEl = document.getElementById('arrivals-loading');
|
const loadingEl = document.getElementById('arrivals-loading');
|
||||||
const listEl = document.getElementById('arrivals-list');
|
const listEl = document.getElementById('arrivals-list');
|
||||||
const countEl = document.getElementById('arrivals-count');
|
const countEl = document.getElementById('arrivals-count');
|
||||||
|
|
||||||
loadingEl.style.display = 'none';
|
loadingEl.style.display = 'none';
|
||||||
listEl.style.display = 'block';
|
listEl.style.display = 'block';
|
||||||
|
|
||||||
if (arrivals.length === 0) {
|
if (arrivals.length === 0) {
|
||||||
listEl.innerHTML = createNoDataMessage('arrivals');
|
listEl.innerHTML = createNoDataMessage('arrivals');
|
||||||
countEl.textContent = '0 flights';
|
countEl.textContent = '0 flights';
|
||||||
} else {
|
} 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' : ''}`;
|
countEl.textContent = `${arrivals.length} flight${arrivals.length !== 1 ? 's' : ''}`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -330,19 +367,21 @@
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/public/departures');
|
const response = await fetch('/api/v1/public/departures');
|
||||||
const departures = await response.json();
|
const departures = await response.json();
|
||||||
|
|
||||||
const loadingEl = document.getElementById('departures-loading');
|
const loadingEl = document.getElementById('departures-loading');
|
||||||
const listEl = document.getElementById('departures-list');
|
const listEl = document.getElementById('departures-list');
|
||||||
const countEl = document.getElementById('departures-count');
|
const countEl = document.getElementById('departures-count');
|
||||||
|
|
||||||
loadingEl.style.display = 'none';
|
loadingEl.style.display = 'none';
|
||||||
listEl.style.display = 'block';
|
listEl.style.display = 'block';
|
||||||
|
|
||||||
if (departures.length === 0) {
|
if (departures.length === 0) {
|
||||||
listEl.innerHTML = createNoDataMessage('departures');
|
listEl.innerHTML = createNoDataMessage('departures');
|
||||||
countEl.textContent = '0 flights';
|
countEl.textContent = '0 flights';
|
||||||
} else {
|
} 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' : ''}`;
|
countEl.textContent = `${departures.length} flight${departures.length !== 1 ? 's' : ''}`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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