Major Enhancement: NestJS Migration + CASL Authorization + Error Handling
Some checks failed
CI/CD Pipeline / Backend Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Tests (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled

Complete rewrite from Express to NestJS with enterprise-grade features:

## Backend Improvements
- Migrated from Express to NestJS 11.0.1 with TypeScript
- Implemented Prisma ORM 7.3.0 for type-safe database access
- Added CASL authorization system replacing role-based guards
- Created global exception filters with structured logging
- Implemented Auth0 JWT authentication with Passport.js
- Added vehicle management with conflict detection
- Enhanced event scheduling with driver/vehicle assignment
- Comprehensive error handling and logging

## Frontend Improvements
- Upgraded to React 19.2.0 with Vite 7.2.4
- Implemented CASL-based permission system
- Added AbilityContext for declarative permissions
- Created ErrorHandler utility for consistent error messages
- Enhanced API client with request/response logging
- Added War Room (Command Center) dashboard
- Created VIP Schedule view with complete itineraries
- Implemented Vehicle Management UI
- Added mock data generators for testing (288 events across 20 VIPs)

## New Features
- Vehicle fleet management (types, capacity, status tracking)
- Complete 3-day Jamboree schedule generation
- Individual VIP schedule pages with PDF export (planned)
- Real-time War Room dashboard with auto-refresh
- Permission-based navigation filtering
- First user auto-approval as administrator

## Documentation
- Created CASL_AUTHORIZATION.md (comprehensive guide)
- Created ERROR_HANDLING.md (error handling patterns)
- Updated CLAUDE.md with new architecture
- Added migration guides and best practices

## Technical Debt Resolved
- Removed custom authentication in favor of Auth0
- Replaced role checks with CASL abilities
- Standardized error responses across API
- Implemented proper TypeScript typing
- Added comprehensive logging

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 08:50:25 +01:00
parent 8ace1ab2c1
commit 868f7efc23
351 changed files with 44997 additions and 6276 deletions

View File

@@ -0,0 +1,75 @@
# Multi-stage build for development and production
FROM node:22-slim AS base
WORKDIR /app
# Copy package files
COPY package*.json ./
# Development stage
FROM base AS development
RUN npm install
# Rebuild native modules for the container architecture
RUN npm rebuild esbuild
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
# Build stage
FROM base AS build
# Accept build argument for API URL
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
# Install build dependencies for native modules (Debian-based)
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies
RUN npm ci --only=production && npm cache clean --force
RUN npm install typescript @vitejs/plugin-react vite
# Copy source code
COPY . .
# Build the application with environment variable available
RUN npm run build
# Production stage
FROM nginx:alpine AS production
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Copy built application from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Create non-root user for security
RUN addgroup -g 1001 -S appuser && \
adduser -S appuser -u 1001 -G appuser
# Set proper permissions and create necessary directories
RUN chown -R appuser:appuser /usr/share/nginx/html && \
chown -R appuser:appuser /var/cache/nginx && \
chown -R appuser:appuser /var/log/nginx && \
chown -R appuser:appuser /etc/nginx/conf.d && \
mkdir -p /tmp/nginx && \
chown -R appuser:appuser /tmp/nginx && \
touch /tmp/nginx/nginx.pid && \
chown appuser:appuser /tmp/nginx/nginx.pid
# Switch to non-root user
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

218
frontend-old-20260125/dist/README-API.md vendored Normal file
View File

@@ -0,0 +1,218 @@
# VIP Coordinator API Documentation
## 📚 Overview
This document provides comprehensive API documentation for the VIP Coordinator system using **OpenAPI 3.0** (Swagger) specification. The API enables management of VIP transportation coordination, including flight tracking, driver management, and event scheduling.
## 🚀 Quick Start
### View API Documentation
1. **Interactive Documentation (Recommended):**
```bash
# Open the interactive Swagger UI documentation
open vip-coordinator/api-docs.html
```
Or visit: `file:///path/to/vip-coordinator/api-docs.html`
2. **Raw OpenAPI Specification:**
```bash
# View the YAML specification file
cat vip-coordinator/api-documentation.yaml
```
### Test the API
The interactive documentation includes a "Try it out" feature that allows you to test endpoints directly:
1. Open `api-docs.html` in your browser
2. Click on any endpoint to expand it
3. Click "Try it out" button
4. Fill in parameters and request body
5. Click "Execute" to make the API call
## 📋 API Categories
### 🏥 Health
- `GET /api/health` - System health check
### 👥 VIPs
- `GET /api/vips` - Get all VIPs
- `POST /api/vips` - Create new VIP
- `PUT /api/vips/{id}` - Update VIP
- `DELETE /api/vips/{id}` - Delete VIP
### 🚗 Drivers
- `GET /api/drivers` - Get all drivers
- `POST /api/drivers` - Create new driver
- `PUT /api/drivers/{id}` - Update driver
- `DELETE /api/drivers/{id}` - Delete driver
- `GET /api/drivers/{driverId}/schedule` - Get driver's schedule
- `POST /api/drivers/availability` - Check driver availability
- `POST /api/drivers/{driverId}/conflicts` - Check driver conflicts
### ✈️ Flights
- `GET /api/flights/{flightNumber}` - Get flight information
- `POST /api/flights/{flightNumber}/track` - Start flight tracking
- `DELETE /api/flights/{flightNumber}/track` - Stop flight tracking
- `POST /api/flights/batch` - Get multiple flights info
- `GET /api/flights/tracking/status` - Get tracking status
### 📅 Schedule
- `GET /api/vips/{vipId}/schedule` - Get VIP's schedule
- `POST /api/vips/{vipId}/schedule` - Add event to schedule
- `PUT /api/vips/{vipId}/schedule/{eventId}` - Update event
- `DELETE /api/vips/{vipId}/schedule/{eventId}` - Delete event
- `PATCH /api/vips/{vipId}/schedule/{eventId}/status` - Update event status
### ⚙️ Admin
- `POST /api/admin/authenticate` - Admin authentication
- `GET /api/admin/settings` - Get admin settings
- `POST /api/admin/settings` - Update admin settings
## 💡 Example API Calls
### Create a VIP with Flight
```bash
curl -X POST http://localhost:3000/api/vips \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"organization": "Tech Corp",
"transportMode": "flight",
"flights": [
{
"flightNumber": "UA1234",
"flightDate": "2025-06-26",
"segment": 1
}
],
"needsAirportPickup": true,
"needsVenueTransport": true,
"notes": "CEO - requires executive transport"
}'
```
### Add Event to VIP Schedule
```bash
curl -X POST http://localhost:3000/api/vips/{vipId}/schedule \
-H "Content-Type: application/json" \
-d '{
"title": "Meeting with CEO",
"location": "Hyatt Regency Denver",
"startTime": "2025-06-26T11:00:00",
"endTime": "2025-06-26T12:30:00",
"type": "meeting",
"assignedDriverId": "1748780965562",
"description": "Important strategic meeting"
}'
```
### Check Driver Availability
```bash
curl -X POST http://localhost:3000/api/drivers/availability \
-H "Content-Type: application/json" \
-d '{
"startTime": "2025-06-26T11:00:00",
"endTime": "2025-06-26T12:30:00",
"location": "Denver Convention Center"
}'
```
### Get Flight Information
```bash
curl "http://localhost:3000/api/flights/UA1234?date=2025-06-26"
```
## 🔧 Tools for API Documentation
### 1. **Swagger UI (Recommended)**
- **What it is:** Interactive web-based API documentation
- **Features:**
- Try endpoints directly in browser
- Auto-generated from OpenAPI spec
- Beautiful, responsive interface
- Request/response examples
- **Access:** Open `api-docs.html` in your browser
### 2. **OpenAPI Specification**
- **What it is:** Industry-standard API specification format
- **Features:**
- Machine-readable API definition
- Can generate client SDKs
- Supports validation and testing
- Compatible with many tools
- **File:** `api-documentation.yaml`
### 3. **Alternative Tools**
You can use the OpenAPI specification with other tools:
#### Postman
1. Import `api-documentation.yaml` into Postman
2. Automatically creates a collection with all endpoints
3. Includes examples and validation
#### Insomnia
1. Import the OpenAPI spec
2. Generate requests automatically
3. Built-in environment management
#### VS Code Extensions
- **OpenAPI (Swagger) Editor** - Edit and preview API specs
- **REST Client** - Test APIs directly in VS Code
## 📖 Documentation Best Practices
### Why OpenAPI/Swagger?
1. **Industry Standard:** Most widely adopted API documentation format
2. **Interactive:** Users can test APIs directly in the documentation
3. **Code Generation:** Can generate client libraries in multiple languages
4. **Validation:** Ensures API requests/responses match specification
5. **Tooling:** Extensive ecosystem of tools and integrations
### Documentation Features
- **Comprehensive:** All endpoints, parameters, and responses documented
- **Examples:** Real-world examples for all operations
- **Schemas:** Detailed data models with validation rules
- **Error Handling:** Clear error response documentation
- **Authentication:** Security requirements clearly specified
## 🔗 Integration Examples
### Frontend Integration
```javascript
// Example: Fetch VIPs in React
const fetchVips = async () => {
const response = await fetch('/api/vips');
const vips = await response.json();
return vips;
};
```
### Backend Integration
```bash
# Example: Using curl to test endpoints
curl -X GET http://localhost:3000/api/health
curl -X GET http://localhost:3000/api/vips
curl -X GET http://localhost:3000/api/drivers
```
## 🚀 Next Steps
1. **Explore the Interactive Docs:** Open `api-docs.html` and try the endpoints
2. **Test with Real Data:** Use the populated test data to explore functionality
3. **Build Integrations:** Use the API specification to build client applications
4. **Extend the API:** Add new endpoints following the established patterns
## 📞 Support
For questions about the API:
- Review the interactive documentation
- Check the OpenAPI specification for detailed schemas
- Test endpoints using the "Try it out" feature
- Refer to the example requests and responses
The API documentation is designed to be self-service and comprehensive, providing everything needed to integrate with the VIP Coordinator system.

148
frontend-old-20260125/dist/api-docs.html vendored Normal file
View File

@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VIP Coordinator API Documentation</title>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css" />
<style>
html {
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*, *:before, *:after {
box-sizing: inherit;
}
body {
margin:0;
background: #fafafa;
}
.swagger-ui .topbar {
background-color: #3498db;
}
.swagger-ui .topbar .download-url-wrapper .select-label {
color: white;
}
.swagger-ui .topbar .download-url-wrapper input[type=text] {
border: 2px solid #2980b9;
}
.swagger-ui .info .title {
color: #2c3e50;
}
.custom-header {
background: linear-gradient(135deg, #3498db, #2980b9);
color: white;
padding: 20px;
text-align: center;
margin-bottom: 20px;
}
.custom-header h1 {
margin: 0;
font-size: 2.5em;
font-weight: 300;
}
.custom-header p {
margin: 10px 0 0 0;
font-size: 1.2em;
opacity: 0.9;
}
.quick-links {
background: white;
padding: 20px;
margin: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.quick-links h3 {
color: #2c3e50;
margin-top: 0;
}
.quick-links ul {
list-style: none;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 10px;
}
.quick-links li {
background: #ecf0f1;
padding: 10px 15px;
border-radius: 5px;
border-left: 4px solid #3498db;
}
.quick-links li strong {
color: #2c3e50;
}
.quick-links li code {
background: #34495e;
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}
</style>
</head>
<body>
<div class="custom-header">
<h1>🚗 VIP Coordinator API</h1>
<p>Comprehensive API for managing VIP transportation coordination</p>
</div>
<div class="quick-links">
<h3>🚀 Quick Start Examples</h3>
<ul>
<li><strong>Health Check:</strong> <code>GET /api/health</code></li>
<li><strong>Get All VIPs:</strong> <code>GET /api/vips</code></li>
<li><strong>Get All Drivers:</strong> <code>GET /api/drivers</code></li>
<li><strong>Flight Info:</strong> <code>GET /api/flights/UA1234?date=2025-06-26</code></li>
<li><strong>VIP Schedule:</strong> <code>GET /api/vips/{vipId}/schedule</code></li>
<li><strong>Driver Availability:</strong> <code>POST /api/drivers/availability</code></li>
</ul>
</div>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: '/api-documentation.yaml',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout",
tryItOutEnabled: true,
requestInterceptor: function(request) {
// Add base URL if not present
if (request.url.startsWith('/api/')) {
request.url = 'http://localhost:3000' + request.url;
}
return request;
},
onComplete: function() {
console.log('VIP Coordinator API Documentation loaded successfully!');
},
docExpansion: 'list',
defaultModelsExpandDepth: 2,
defaultModelExpandDepth: 2,
showExtensions: true,
showCommonExtensions: true,
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch'],
validatorUrl: null
});
// End Swagger UI call region
window.ui = ui;
};
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

15
frontend-old-20260125/dist/index.html vendored Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VIP Coordinator Dashboard</title>
<script type="module" crossorigin src="/assets/index-b3227753.js"></script>
<link rel="stylesheet" href="/assets/index-a63a7bce.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VIP Coordinator Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,54 @@
# Custom PID file location for non-root user
pid /tmp/nginx/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Handle client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# API proxy to backend
location /api/ {
proxy_pass http://backend:3000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}

3951
frontend-old-20260125/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
{
"name": "vip-coordinator-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=22.0.0",
"npm": ">=10.0.0"
},
"scripts": {
"dev": "node ./node_modules/vite/bin/vite.js",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"keycloak-js": "^24.0.5",
"leaflet": "^1.9.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.15.0"
},
"devDependencies": {
"@types/leaflet": "^1.9.4",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.14",
"eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"postcss": "^8.5.4",
"tailwindcss": "^3.4.1",
"typescript": "^5.6.0",
"vite": "^5.4.10"
}
}

View File

@@ -0,0 +1,9 @@
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
export default {
plugins: [
tailwindcss,
autoprefixer,
]
}

View File

@@ -0,0 +1,218 @@
# VIP Coordinator API Documentation
## 📚 Overview
This document provides comprehensive API documentation for the VIP Coordinator system using **OpenAPI 3.0** (Swagger) specification. The API enables management of VIP transportation coordination, including flight tracking, driver management, and event scheduling.
## 🚀 Quick Start
### View API Documentation
1. **Interactive Documentation (Recommended):**
```bash
# Open the interactive Swagger UI documentation
open vip-coordinator/api-docs.html
```
Or visit: `file:///path/to/vip-coordinator/api-docs.html`
2. **Raw OpenAPI Specification:**
```bash
# View the YAML specification file
cat vip-coordinator/api-documentation.yaml
```
### Test the API
The interactive documentation includes a "Try it out" feature that allows you to test endpoints directly:
1. Open `api-docs.html` in your browser
2. Click on any endpoint to expand it
3. Click "Try it out" button
4. Fill in parameters and request body
5. Click "Execute" to make the API call
## 📋 API Categories
### 🏥 Health
- `GET /api/health` - System health check
### 👥 VIPs
- `GET /api/vips` - Get all VIPs
- `POST /api/vips` - Create new VIP
- `PUT /api/vips/{id}` - Update VIP
- `DELETE /api/vips/{id}` - Delete VIP
### 🚗 Drivers
- `GET /api/drivers` - Get all drivers
- `POST /api/drivers` - Create new driver
- `PUT /api/drivers/{id}` - Update driver
- `DELETE /api/drivers/{id}` - Delete driver
- `GET /api/drivers/{driverId}/schedule` - Get driver's schedule
- `POST /api/drivers/availability` - Check driver availability
- `POST /api/drivers/{driverId}/conflicts` - Check driver conflicts
### ✈️ Flights
- `GET /api/flights/{flightNumber}` - Get flight information
- `POST /api/flights/{flightNumber}/track` - Start flight tracking
- `DELETE /api/flights/{flightNumber}/track` - Stop flight tracking
- `POST /api/flights/batch` - Get multiple flights info
- `GET /api/flights/tracking/status` - Get tracking status
### 📅 Schedule
- `GET /api/vips/{vipId}/schedule` - Get VIP's schedule
- `POST /api/vips/{vipId}/schedule` - Add event to schedule
- `PUT /api/vips/{vipId}/schedule/{eventId}` - Update event
- `DELETE /api/vips/{vipId}/schedule/{eventId}` - Delete event
- `PATCH /api/vips/{vipId}/schedule/{eventId}/status` - Update event status
### ⚙️ Admin
- `POST /api/admin/authenticate` - Admin authentication
- `GET /api/admin/settings` - Get admin settings
- `POST /api/admin/settings` - Update admin settings
## 💡 Example API Calls
### Create a VIP with Flight
```bash
curl -X POST http://localhost:3000/api/vips \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"organization": "Tech Corp",
"transportMode": "flight",
"flights": [
{
"flightNumber": "UA1234",
"flightDate": "2025-06-26",
"segment": 1
}
],
"needsAirportPickup": true,
"needsVenueTransport": true,
"notes": "CEO - requires executive transport"
}'
```
### Add Event to VIP Schedule
```bash
curl -X POST http://localhost:3000/api/vips/{vipId}/schedule \
-H "Content-Type: application/json" \
-d '{
"title": "Meeting with CEO",
"location": "Hyatt Regency Denver",
"startTime": "2025-06-26T11:00:00",
"endTime": "2025-06-26T12:30:00",
"type": "meeting",
"assignedDriverId": "1748780965562",
"description": "Important strategic meeting"
}'
```
### Check Driver Availability
```bash
curl -X POST http://localhost:3000/api/drivers/availability \
-H "Content-Type: application/json" \
-d '{
"startTime": "2025-06-26T11:00:00",
"endTime": "2025-06-26T12:30:00",
"location": "Denver Convention Center"
}'
```
### Get Flight Information
```bash
curl "http://localhost:3000/api/flights/UA1234?date=2025-06-26"
```
## 🔧 Tools for API Documentation
### 1. **Swagger UI (Recommended)**
- **What it is:** Interactive web-based API documentation
- **Features:**
- Try endpoints directly in browser
- Auto-generated from OpenAPI spec
- Beautiful, responsive interface
- Request/response examples
- **Access:** Open `api-docs.html` in your browser
### 2. **OpenAPI Specification**
- **What it is:** Industry-standard API specification format
- **Features:**
- Machine-readable API definition
- Can generate client SDKs
- Supports validation and testing
- Compatible with many tools
- **File:** `api-documentation.yaml`
### 3. **Alternative Tools**
You can use the OpenAPI specification with other tools:
#### Postman
1. Import `api-documentation.yaml` into Postman
2. Automatically creates a collection with all endpoints
3. Includes examples and validation
#### Insomnia
1. Import the OpenAPI spec
2. Generate requests automatically
3. Built-in environment management
#### VS Code Extensions
- **OpenAPI (Swagger) Editor** - Edit and preview API specs
- **REST Client** - Test APIs directly in VS Code
## 📖 Documentation Best Practices
### Why OpenAPI/Swagger?
1. **Industry Standard:** Most widely adopted API documentation format
2. **Interactive:** Users can test APIs directly in the documentation
3. **Code Generation:** Can generate client libraries in multiple languages
4. **Validation:** Ensures API requests/responses match specification
5. **Tooling:** Extensive ecosystem of tools and integrations
### Documentation Features
- **Comprehensive:** All endpoints, parameters, and responses documented
- **Examples:** Real-world examples for all operations
- **Schemas:** Detailed data models with validation rules
- **Error Handling:** Clear error response documentation
- **Authentication:** Security requirements clearly specified
## 🔗 Integration Examples
### Frontend Integration
```javascript
// Example: Fetch VIPs in React
const fetchVips = async () => {
const response = await fetch('/api/vips');
const vips = await response.json();
return vips;
};
```
### Backend Integration
```bash
# Example: Using curl to test endpoints
curl -X GET http://localhost:3000/api/health
curl -X GET http://localhost:3000/api/vips
curl -X GET http://localhost:3000/api/drivers
```
## 🚀 Next Steps
1. **Explore the Interactive Docs:** Open `api-docs.html` and try the endpoints
2. **Test with Real Data:** Use the populated test data to explore functionality
3. **Build Integrations:** Use the API specification to build client applications
4. **Extend the API:** Add new endpoints following the established patterns
## 📞 Support
For questions about the API:
- Review the interactive documentation
- Check the OpenAPI specification for detailed schemas
- Test endpoints using the "Try it out" feature
- Refer to the example requests and responses
The API documentation is designed to be self-service and comprehensive, providing everything needed to integrate with the VIP Coordinator system.

View File

@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VIP Coordinator API Documentation</title>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css" />
<style>
html {
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*, *:before, *:after {
box-sizing: inherit;
}
body {
margin:0;
background: #fafafa;
}
.swagger-ui .topbar {
background-color: #3498db;
}
.swagger-ui .topbar .download-url-wrapper .select-label {
color: white;
}
.swagger-ui .topbar .download-url-wrapper input[type=text] {
border: 2px solid #2980b9;
}
.swagger-ui .info .title {
color: #2c3e50;
}
.custom-header {
background: linear-gradient(135deg, #3498db, #2980b9);
color: white;
padding: 20px;
text-align: center;
margin-bottom: 20px;
}
.custom-header h1 {
margin: 0;
font-size: 2.5em;
font-weight: 300;
}
.custom-header p {
margin: 10px 0 0 0;
font-size: 1.2em;
opacity: 0.9;
}
.quick-links {
background: white;
padding: 20px;
margin: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.quick-links h3 {
color: #2c3e50;
margin-top: 0;
}
.quick-links ul {
list-style: none;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 10px;
}
.quick-links li {
background: #ecf0f1;
padding: 10px 15px;
border-radius: 5px;
border-left: 4px solid #3498db;
}
.quick-links li strong {
color: #2c3e50;
}
.quick-links li code {
background: #34495e;
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}
</style>
</head>
<body>
<div class="custom-header">
<h1>🚗 VIP Coordinator API</h1>
<p>Comprehensive API for managing VIP transportation coordination</p>
</div>
<div class="quick-links">
<h3>🚀 Quick Start Examples</h3>
<ul>
<li><strong>Health Check:</strong> <code>GET /api/health</code></li>
<li><strong>Get All VIPs:</strong> <code>GET /api/vips</code></li>
<li><strong>Get All Drivers:</strong> <code>GET /api/drivers</code></li>
<li><strong>Flight Info:</strong> <code>GET /api/flights/UA1234?date=2025-06-26</code></li>
<li><strong>VIP Schedule:</strong> <code>GET /api/vips/{vipId}/schedule</code></li>
<li><strong>Driver Availability:</strong> <code>POST /api/drivers/availability</code></li>
</ul>
</div>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: '/api-documentation.yaml',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout",
tryItOutEnabled: true,
requestInterceptor: function(request) {
// Add base URL if not present
if (request.url.startsWith('/api/')) {
request.url = 'http://localhost:3000' + request.url;
}
return request;
},
onComplete: function() {
console.log('VIP Coordinator API Documentation loaded successfully!');
},
docExpansion: 'list',
defaultModelsExpandDepth: 2,
defaultModelExpandDepth: 2,
showExtensions: true,
showCommonExtensions: true,
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch'],
validatorUrl: null
});
// End Swagger UI call region
window.ui = ui;
};
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<title>Silent Check SSO</title>
</head>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>

View File

@@ -0,0 +1,190 @@
/* Modern App-specific styles using Tailwind utilities */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Enhanced button styles */
@layer components {
.btn-modern {
@apply px-6 py-3 rounded-xl font-semibold text-sm transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5;
}
.btn-gradient-blue {
@apply bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white;
}
.btn-gradient-green {
@apply bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white;
}
.btn-gradient-purple {
@apply bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white;
}
.btn-gradient-amber {
@apply bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white;
}
}
/* Status badges */
@layer components {
.status-badge {
@apply inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold;
}
.status-scheduled {
@apply bg-blue-100 text-blue-800 border border-blue-200;
}
.status-in-progress {
@apply bg-amber-100 text-amber-800 border border-amber-200;
}
.status-completed {
@apply bg-green-100 text-green-800 border border-green-200;
}
.status-cancelled {
@apply bg-red-100 text-red-800 border border-red-200;
}
}
/* Card enhancements */
@layer components {
.card-modern {
@apply bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden backdrop-blur-sm;
}
.card-header {
@apply bg-gradient-to-r from-slate-50 to-slate-100 px-6 py-4 border-b border-slate-200/60;
}
.card-content {
@apply p-6;
}
}
/* Loading states */
@layer components {
.loading-spinner {
@apply animate-spin rounded-full border-4 border-blue-600 border-t-transparent;
}
.loading-text {
@apply text-slate-600 animate-pulse;
}
.skeleton {
@apply animate-pulse bg-slate-200 rounded;
}
}
/* Form enhancements */
@layer components {
.form-modern {
@apply space-y-6;
}
.form-group-modern {
@apply space-y-2;
}
.form-label-modern {
@apply block text-sm font-semibold text-slate-700;
}
.form-input-modern {
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200;
}
.form-select-modern {
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white transition-all duration-200;
}
}
/* Animation utilities */
@layer utilities {
.animate-fade-in {
animation: fadeIn 0.5s ease-in-out;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.2s ease-out;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes scaleIn {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
/* Responsive utilities */
@media (max-width: 768px) {
.mobile-stack {
@apply flex-col space-y-4 space-x-0;
}
.mobile-full {
@apply w-full;
}
.mobile-text-center {
@apply text-center;
}
}
/* Glass morphism effect */
@layer utilities {
.glass {
@apply bg-white/80 backdrop-blur-lg border border-white/20;
}
.glass-dark {
@apply bg-slate-900/80 backdrop-blur-lg border border-slate-700/20;
}
}
/* Hover effects */
@layer utilities {
.hover-lift {
@apply transition-transform duration-200 hover:-translate-y-1;
}
.hover-glow {
@apply transition-shadow duration-200 hover:shadow-2xl;
}
.hover-scale {
@apply transition-transform duration-200 hover:scale-105;
}
}

View File

@@ -0,0 +1,214 @@
import { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import { useKeycloak } from './contexts/KeycloakContext';
import { apiCall } from './config/api';
import VipList from './pages/VipList';
import VipDetails from './pages/VipDetails';
import DriverList from './pages/DriverList';
import DriverDashboard from './pages/DriverDashboard';
import Dashboard from './pages/Dashboard';
import AdminDashboard from './pages/AdminDashboard';
import UserManagement from './components/UserManagement';
import Login from './components/Login';
import PendingApproval from './pages/PendingApproval';
import './App.css';
function App() {
const { authenticated, loading, logout, getToken, keycloak } = useKeycloak();
const [user, setUser] = useState<any>(null);
const [loadingUser, setLoadingUser] = useState(true);
useEffect(() => {
if (authenticated && keycloak) {
// Fetch user details from backend
const fetchUser = async () => {
try {
const token = await getToken();
if (!token) {
setLoadingUser(false);
return;
}
const response = await apiCall('/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const userData = await response.json();
setUser(userData.user);
} else {
console.error('Failed to fetch user data');
}
} catch (error) {
console.error('Error fetching user:', error);
} finally {
setLoadingUser(false);
}
};
fetchUser();
} else if (!authenticated && !loading) {
setLoadingUser(false);
localStorage.removeItem('authToken');
}
}, [authenticated, keycloak, loading, getToken]);
const handleLogout = () => {
logout();
};
// Show loading state
if (loading || (authenticated && loadingUser)) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
<div className="bg-white rounded-2xl shadow-xl p-8 flex items-center space-x-4">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<span className="text-lg font-medium text-slate-700">Loading VIP Coordinator...</span>
</div>
</div>
);
}
// Show login if not authenticated
if (!authenticated) {
return <Login />;
}
// Show pending approval page if user is pending
if (user?.approval_status === 'pending') {
return <PendingApproval user={user} onLogout={handleLogout} />;
}
// Show access denied if user was denied
if (user?.approval_status === 'denied') {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md">
<div className="text-center">
<div className="text-6xl mb-4">🚫</div>
<h2 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h2>
<p className="text-slate-600 mb-6">
Your access to VIP Coordinator has been denied by an administrator.
Please contact your system administrator for more information.
</p>
<button
onClick={handleLogout}
className="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg font-medium transition"
>
Logout
</button>
</div>
</div>
</div>
);
}
// Main app for approved users
return (
<Router>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
{/* Modern Navigation */}
<nav className="bg-white/80 backdrop-blur-lg border-b border-slate-200/60 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo/Brand */}
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">VC</span>
</div>
<h1 className="text-xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent">
VIP Coordinator
</h1>
</div>
{/* Navigation Links */}
<div className="hidden md:flex items-center space-x-1">
<Link
to="/"
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all duration-200"
>
Dashboard
</Link>
<Link
to="/vips"
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all duration-200"
>
VIPs
</Link>
<Link
to="/drivers"
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all duration-200"
>
Drivers
</Link>
{(user?.role === 'administrator' || user?.role === 'coordinator') && (
<Link
to="/admin"
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-all duration-200"
>
Admin
</Link>
)}
{user?.role === 'administrator' && (
<Link
to="/users"
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-all duration-200"
>
Users
</Link>
)}
</div>
{/* User Menu */}
<div className="flex items-center space-x-4">
<div className="hidden sm:flex items-center space-x-3">
{user?.profile_picture_url ? (
<img
src={user.profile_picture_url}
alt={user.name}
className="w-8 h-8 rounded-full"
/>
) : (
<div className="w-8 h-8 bg-gradient-to-br from-slate-400 to-slate-600 rounded-full flex items-center justify-center">
<span className="text-white text-xs font-medium">
{user?.name?.charAt(0).toUpperCase() || 'U'}
</span>
</div>
)}
<div className="text-sm">
<div className="font-medium text-slate-900">{user?.name || keycloak?.tokenParsed?.name || keycloak?.tokenParsed?.preferred_username}</div>
<div className="text-slate-500 capitalize">{user?.role || 'driver'}</div>
</div>
</div>
<button
onClick={handleLogout}
className="bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-lg hover:shadow-xl"
>
Logout
</button>
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-6 lg:px-8 py-8">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/vips" element={<VipList />} />
<Route path="/vips/:id" element={<VipDetails />} />
<Route path="/drivers" element={<DriverList />} />
<Route path="/drivers/:driverId" element={<DriverDashboard />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/users" element={<UserManagement currentUser={user} />} />
</Routes>
</main>
</div>
</Router>
);
}
export default App;

View File

@@ -0,0 +1,41 @@
// Auth0-aware API client wrapper
// This file provides helper functions that automatically inject Auth0 tokens
import { api } from './client';
// Token provider function - will be set by App.tsx
let tokenProvider: (() => Promise<string>) | null = null;
export function setTokenProvider(provider: () => Promise<string>) {
tokenProvider = provider;
}
// Wrapper that automatically adds Auth0 token to requests
async function makeAuthenticatedRequest<T>(
requestFn: (headers: HeadersInit) => Promise<T>
): Promise<T> {
if (!tokenProvider) {
// Fallback to localStorage for non-Auth0 flows (shouldn't happen)
const token = localStorage.getItem('authToken');
const headers = token ? { Authorization: `Bearer ${token}` } : {};
return requestFn(headers);
}
try {
const token = await tokenProvider();
return requestFn({ Authorization: `Bearer ${token}` });
} catch (error) {
console.error('Failed to get access token:', error);
throw error;
}
}
// Re-export all API methods (they already handle authorization headers)
export { api, vipApi, driverApi, scheduleApi, authApi } from './client';
// Note: The original ApiClient in client.ts already reads from localStorage
// and adds the Authorization header. Auth0 SDK stores tokens in localStorage
// by default (cacheLocation: "localstorage"), so everything should work seamlessly.
//
// For more advanced use cases (e.g., using Auth0's memory cache or handling
// token refresh explicitly), you would use the tokenProvider approach above.

View File

@@ -0,0 +1,109 @@
// Simplified API client that handles all the complexity in one place
// Use empty string for relative URLs when no API URL is specified
const API_BASE_URL = import.meta.env.VITE_API_URL || '';
class ApiClient {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
private getAuthHeaders(): HeadersInit {
const token = localStorage.getItem('authToken');
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` })
};
}
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error?.message || error.error || `Request failed: ${response.status}`);
}
return response.json();
}
// Generic request method
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
...this.getAuthHeaders(),
...options.headers
}
});
return this.handleResponse<T>(response);
}
// Convenience methods
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint);
}
async post<T>(endpoint: string, data?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined
});
}
async put<T>(endpoint: string, data?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined
});
}
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
async patch<T>(endpoint: string, data?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined
});
}
}
// Export a singleton instance
export const api = new ApiClient(API_BASE_URL);
// Export specific API methods for better type safety and convenience
export const vipApi = {
list: () => api.get<any[]>('/vips'),
get: (id: string) => api.get<any>(`/vips/${id}`),
create: (data: any) => api.post<any>('/vips', data),
update: (id: string, data: any) => api.put<any>(`/vips/${id}`, data),
delete: (id: string) => api.delete<any>(`/vips/${id}`),
getSchedule: (id: string) => api.get<any[]>(`/vips/${id}/schedule`)
};
export const driverApi = {
list: () => api.get<any[]>('/drivers'),
get: (id: string) => api.get<any>(`/drivers/${id}`),
create: (data: any) => api.post<any>('/drivers', data),
update: (id: string, data: any) => api.put<any>(`/drivers/${id}`, data),
delete: (id: string) => api.delete<any>(`/drivers/${id}`),
getSchedule: (id: string) => api.get<any[]>(`/drivers/${id}/schedule`)
};
export const scheduleApi = {
create: (vipId: string, data: any) => api.post<any>(`/vips/${vipId}/schedule`, data),
update: (vipId: string, eventId: string, data: any) =>
api.put<any>(`/vips/${vipId}/schedule/${eventId}`, data),
delete: (vipId: string, eventId: string) =>
api.delete<any>(`/vips/${vipId}/schedule/${eventId}`),
updateStatus: (vipId: string, eventId: string, status: string) =>
api.patch<any>(`/vips/${vipId}/schedule/${eventId}/status`, { status })
};
export const authApi = {
me: () => api.get<any>('/auth/me'),
logout: () => api.post<void>('/auth/logout'),
setup: () => api.get<any>('/auth/setup'),
googleCallback: (code: string) => api.post<any>('/auth/google/callback', { code })
};

View File

@@ -0,0 +1,72 @@
import React, { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
onError?: (error: Error) => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class AsyncErrorBoundary extends Component<Props, State> {
state: State = {
hasError: false,
error: null
};
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error
};
}
componentDidCatch(error: Error) {
console.error('AsyncErrorBoundary caught an error:', error);
this.props.onError?.(error);
}
retry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<svg
className="w-5 h-5 text-red-400 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h3 className="text-sm font-medium text-red-800">
Failed to load data
</h3>
</div>
<p className="mt-2 text-sm text-red-700">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={this.retry}
className="mt-3 text-sm text-red-600 hover:text-red-500 underline"
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,110 @@
import React, { useState } from 'react';
interface DriverFormData {
name: string;
phone: string;
vehicleCapacity: number;
}
interface DriverFormProps {
onSubmit: (driverData: DriverFormData) => void;
onCancel: () => void;
}
const DriverForm: React.FC<DriverFormProps> = ({ onSubmit, onCancel }) => {
const [formData, setFormData] = useState<DriverFormData>({
name: '',
phone: '',
vehicleCapacity: 4
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'number' || name === 'vehicleCapacity' ? parseInt(value) || 0 : value
}));
};
return (
<div className="modal-overlay">
<div className="modal-content">
{/* Modal Header */}
<div className="modal-header">
<h2 className="text-2xl font-bold text-slate-800">Add New Driver</h2>
<p className="text-slate-600 mt-2">Enter driver contact information</p>
</div>
{/* Modal Body */}
<div className="modal-body">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="form-group">
<label htmlFor="name" className="form-label">Driver Name *</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="form-input"
placeholder="Enter driver's full name"
required
/>
</div>
<div className="form-group">
<label htmlFor="phone" className="form-label">Phone Number *</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
className="form-input"
placeholder="Enter phone number"
required
/>
</div>
<div className="form-group">
<label htmlFor="vehicleCapacity" className="form-label">Vehicle Capacity *</label>
<select
id="vehicleCapacity"
name="vehicleCapacity"
value={formData.vehicleCapacity}
onChange={handleChange}
className="form-input"
required
>
<option value={2}>2 passengers (Sedan/Coupe)</option>
<option value={4}>4 passengers (Standard Car)</option>
<option value={6}>6 passengers (SUV/Van)</option>
<option value={8}>8 passengers (Large Van)</option>
<option value={12}>12 passengers (Mini Bus)</option>
</select>
<p className="text-sm text-slate-600 mt-1">
🚗 Select the maximum number of passengers this vehicle can accommodate
</p>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={onCancel}>
Cancel
</button>
<button type="submit" className="btn btn-primary">
Add Driver
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default DriverForm;

View File

@@ -0,0 +1,371 @@
import React, { useState, useEffect } from 'react';
import { useAuthToken } from '../hooks/useAuthToken';
import { apiCall } from '../config/api';
interface DriverAvailability {
driverId: string;
driverName: string;
vehicleCapacity: number;
status: 'available' | 'scheduled' | 'overlapping' | 'tight_turnaround';
assignmentCount: number;
conflicts: ConflictInfo[];
currentAssignments: ScheduleEvent[];
}
interface ConflictInfo {
type: 'overlap' | 'tight_turnaround' | 'back_to_back';
severity: 'low' | 'medium' | 'high';
message: string;
conflictingEvent: ScheduleEvent;
timeDifference?: number;
}
interface ScheduleEvent {
id: string;
title: string;
location: string;
startTime: string;
endTime: string;
assignedDriverId?: string;
vipId: string;
vipName: string;
}
interface DriverSelectorProps {
selectedDriverId: string;
onDriverSelect: (driverId: string) => void;
eventTime: {
startTime: string;
endTime: string;
location: string;
};
}
const DriverSelector: React.FC<DriverSelectorProps> = ({
selectedDriverId,
onDriverSelect,
eventTime
}) => {
const token = useAuthToken();
const [availability, setAvailability] = useState<DriverAvailability[]>([]);
const [loading, setLoading] = useState(false);
const [showConflictModal, setShowConflictModal] = useState(false);
const [selectedDriver, setSelectedDriver] = useState<DriverAvailability | null>(null);
useEffect(() => {
if (token && eventTime.startTime && eventTime.endTime) {
checkDriverAvailability();
}
}, [eventTime.startTime, eventTime.endTime, eventTime.location, token]);
const checkDriverAvailability = async () => {
if (!token) return;
setLoading(true);
try {
const response = await apiCall('/drivers/availability', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(eventTime),
});
if (response.ok) {
const data = await response.json();
setAvailability(data);
}
} catch (error) {
console.error('Error checking driver availability:', error);
} finally {
setLoading(false);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'available': return '🟢';
case 'scheduled': return '🟡';
case 'tight_turnaround': return '⚡';
case 'overlapping': return '🔴';
default: return '⚪';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'available': return 'bg-green-50 border-green-200 text-green-800';
case 'scheduled': return 'bg-amber-50 border-amber-200 text-amber-800';
case 'tight_turnaround': return 'bg-orange-50 border-orange-200 text-orange-800';
case 'overlapping': return 'bg-red-50 border-red-200 text-red-800';
default: return 'bg-slate-50 border-slate-200 text-slate-800';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'available': return 'Available';
case 'scheduled': return 'Busy';
case 'tight_turnaround': return 'Tight Schedule';
case 'overlapping': return 'Conflict';
default: return 'Unknown';
}
};
const handleDriverClick = (driver: DriverAvailability) => {
if (driver.conflicts.length > 0) {
setSelectedDriver(driver);
setShowConflictModal(true);
} else {
onDriverSelect(driver.driverId);
}
};
const confirmDriverAssignment = () => {
if (selectedDriver) {
onDriverSelect(selectedDriver.driverId);
setShowConflictModal(false);
setSelectedDriver(null);
}
};
const formatTime = (timeString: string) => {
return new Date(timeString).toLocaleString([], {
hour: '2-digit',
minute: '2-digit'
});
};
if (loading) {
return (
<div className="bg-slate-50 rounded-xl p-6 border border-slate-200/60">
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<span className="text-slate-700 font-medium">Checking driver availability...</span>
</div>
</div>
);
}
return (
<div className="bg-slate-50 rounded-xl p-6 border border-slate-200/60">
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2">
🚗 Assign Driver
</h3>
{availability.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-slate-200 rounded-full flex items-center justify-center mx-auto mb-3">
<span className="text-xl">🚗</span>
</div>
<p className="text-slate-500 font-medium">No drivers available</p>
<p className="text-slate-400 text-sm">Check the time and try again</p>
</div>
) : (
<div className="space-y-3">
{availability.map((driver) => (
<div
key={driver.driverId}
className={`relative rounded-xl border-2 p-4 cursor-pointer transition-all duration-200 hover:shadow-lg ${
selectedDriverId === driver.driverId
? 'border-blue-500 bg-blue-50 shadow-lg'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
onClick={() => handleDriverClick(driver)}
>
{selectedDriverId === driver.driverId && (
<div className="absolute top-2 right-2 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs font-bold"></span>
</div>
)}
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="text-xl">{getStatusIcon(driver.status)}</span>
<div>
<h4 className="font-bold text-slate-900">{driver.driverName}</h4>
<div className="flex items-center gap-2 mt-1">
<span className={`px-2 py-1 rounded-full text-xs font-medium border ${getStatusColor(driver.status)}`}>
{getStatusText(driver.status)}
</span>
<span className="bg-slate-100 text-slate-700 px-2 py-1 rounded-full text-xs font-medium">
🚗 {driver.vehicleCapacity} seats
</span>
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs font-medium">
{driver.assignmentCount} assignments
</span>
</div>
</div>
</div>
{driver.conflicts.length > 0 && (
<div className="space-y-2 mb-3">
{driver.conflicts.map((conflict, index) => (
<div key={index} className={`p-3 rounded-lg border ${
conflict.severity === 'high'
? 'bg-red-50 border-red-200'
: 'bg-amber-50 border-amber-200'
}`}>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm">
{conflict.type === 'overlap' ? '🔴' : '⚡'}
</span>
<span className={`text-sm font-medium ${
conflict.severity === 'high' ? 'text-red-800' : 'text-amber-800'
}`}>
{conflict.type === 'overlap' ? 'Time Overlap' : 'Tight Turnaround'}
</span>
</div>
<p className={`text-sm ${
conflict.severity === 'high' ? 'text-red-700' : 'text-amber-700'
}`}>
{conflict.message}
</p>
</div>
))}
</div>
)}
{driver.currentAssignments.length > 0 && driver.conflicts.length === 0 && (
<div className="bg-slate-100 rounded-lg p-3">
<p className="text-sm font-medium text-slate-700 mb-1">Next Assignment:</p>
<p className="text-sm text-slate-600">
{driver.currentAssignments[0]?.title} at {formatTime(driver.currentAssignments[0]?.startTime)}
</p>
</div>
)}
</div>
{driver.conflicts.length > 0 && (
<div className="ml-4">
<span className="bg-amber-100 text-amber-800 px-3 py-1 rounded-full text-xs font-bold">
CONFLICTS
</span>
</div>
)}
</div>
</div>
))}
{selectedDriverId && (
<button
onClick={() => onDriverSelect('')}
className="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 px-4 py-3 rounded-lg font-medium transition-colors border border-slate-200"
>
Clear Driver Assignment
</button>
)}
</div>
)}
{/* Conflict Resolution Modal */}
{showConflictModal && selectedDriver && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl border border-slate-200/60 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="bg-gradient-to-r from-amber-50 to-orange-50 px-8 py-6 border-b border-slate-200/60">
<h3 className="text-xl font-bold text-slate-800 flex items-center gap-2">
Driver Assignment Conflict
</h3>
<p className="text-slate-600 mt-1">
<strong>{selectedDriver.driverName}</strong> has scheduling conflicts that need your attention
</p>
</div>
<div className="p-8 space-y-6">
{/* Driver Info */}
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200">
<div className="flex items-center gap-3 mb-2">
<span className="text-2xl">🚗</span>
<div>
<h4 className="font-bold text-slate-900">{selectedDriver.driverName}</h4>
<p className="text-sm text-slate-600">
Vehicle Capacity: {selectedDriver.vehicleCapacity} passengers
Current Assignments: {selectedDriver.assignmentCount}
</p>
</div>
</div>
</div>
{/* Conflicts */}
<div>
<h4 className="font-bold text-slate-800 mb-3">Scheduling Conflicts:</h4>
<div className="space-y-3">
{selectedDriver.conflicts.map((conflict, index) => (
<div key={index} className={`p-4 rounded-xl border ${
conflict.severity === 'high'
? 'bg-red-50 border-red-200'
: 'bg-amber-50 border-amber-200'
}`}>
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">
{conflict.type === 'overlap' ? '🔴' : '⚡'}
</span>
<span className={`font-bold ${
conflict.severity === 'high' ? 'text-red-800' : 'text-amber-800'
}`}>
{conflict.type === 'overlap' ? 'Time Overlap' : 'Tight Turnaround'}
</span>
</div>
<p className={`mb-2 ${
conflict.severity === 'high' ? 'text-red-700' : 'text-amber-700'
}`}>
{conflict.message}
</p>
<div className="text-sm text-slate-600 bg-white/50 rounded-lg p-2">
<strong>Conflicting event:</strong> {conflict.conflictingEvent.title}<br/>
<strong>Time:</strong> {formatTime(conflict.conflictingEvent.startTime)} - {formatTime(conflict.conflictingEvent.endTime)}<br/>
<strong>VIP:</strong> {conflict.conflictingEvent.vipName}
</div>
</div>
))}
</div>
</div>
{/* Current Schedule */}
<div>
<h4 className="font-bold text-slate-800 mb-3">Current Schedule:</h4>
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200">
{selectedDriver.currentAssignments.length === 0 ? (
<p className="text-slate-500 text-sm">No current assignments</p>
) : (
<div className="space-y-2">
{selectedDriver.currentAssignments.map((assignment, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
<span className="font-medium">{assignment.title}</span>
<span className="text-slate-500">
({formatTime(assignment.startTime)} - {formatTime(assignment.endTime)})
</span>
<span className="text-slate-400"> {assignment.vipName}</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
<div className="flex justify-end gap-4 p-8 border-t border-slate-200">
<button
className="px-6 py-3 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors font-medium"
onClick={() => setShowConflictModal(false)}
>
Choose Different Driver
</button>
<button
className="bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white px-6 py-3 rounded-lg font-medium transition-all duration-200 shadow-lg hover:shadow-xl"
onClick={confirmDriverAssignment}
>
Assign Anyway
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default DriverSelector;

View File

@@ -0,0 +1,188 @@
import { useState } from 'react';
interface Driver {
id: string;
name: string;
phone: string;
currentLocation: { lat: number; lng: number };
assignedVipIds: string[];
vehicleCapacity?: number;
}
interface EditDriverFormProps {
driver: Driver;
onSubmit: (driverData: any) => void;
onCancel: () => void;
}
const EditDriverForm: React.FC<EditDriverFormProps> = ({ driver, onSubmit, onCancel }) => {
const [formData, setFormData] = useState({
name: driver.name,
phone: driver.phone,
vehicleCapacity: driver.vehicleCapacity || 4,
currentLocation: {
lat: driver.currentLocation.lat,
lng: driver.currentLocation.lng
}
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
...formData,
id: driver.id
});
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
if (name === 'lat' || name === 'lng') {
setFormData(prev => ({
...prev,
currentLocation: {
...prev.currentLocation,
[name]: parseFloat(value) || 0
}
}));
} else if (name === 'vehicleCapacity') {
setFormData(prev => ({ ...prev, [name]: parseInt(value) || 0 }));
} else {
setFormData(prev => ({ ...prev, [name]: value }));
}
};
return (
<div className="modal-overlay">
<div className="modal-content">
{/* Modal Header */}
<div className="modal-header">
<h2 className="text-2xl font-bold text-slate-800">Edit Driver</h2>
<p className="text-slate-600 mt-2">Update driver information for {driver.name}</p>
</div>
{/* Modal Body */}
<div className="modal-body">
<form onSubmit={handleSubmit} className="space-y-8">
{/* Basic Information Section */}
<div className="form-section">
<div className="form-section-header">
<h3 className="form-section-title">Basic Information</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="form-group">
<label htmlFor="name" className="form-label">Driver Name *</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="form-input"
placeholder="Enter driver's full name"
required
/>
</div>
<div className="form-group">
<label htmlFor="phone" className="form-label">Phone Number *</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
className="form-input"
placeholder="Enter phone number"
required
/>
</div>
</div>
<div className="form-group">
<label htmlFor="vehicleCapacity" className="form-label">Vehicle Capacity *</label>
<select
id="vehicleCapacity"
name="vehicleCapacity"
value={formData.vehicleCapacity}
onChange={handleChange}
className="form-input"
required
>
<option value={2}>2 passengers (Sedan/Coupe)</option>
<option value={4}>4 passengers (Standard Car)</option>
<option value={6}>6 passengers (SUV/Van)</option>
<option value={8}>8 passengers (Large Van)</option>
<option value={12}>12 passengers (Mini Bus)</option>
</select>
<p className="text-sm text-slate-600 mt-1">
🚗 Select the maximum number of passengers this vehicle can accommodate
</p>
</div>
</div>
{/* Location Section */}
<div className="form-section">
<div className="form-section-header">
<h3 className="form-section-title">Current Location</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="form-group">
<label htmlFor="lat" className="form-label">Latitude *</label>
<input
type="number"
id="lat"
name="lat"
value={formData.currentLocation.lat}
onChange={handleChange}
className="form-input"
placeholder="Enter latitude"
step="any"
required
/>
</div>
<div className="form-group">
<label htmlFor="lng" className="form-label">Longitude *</label>
<input
type="number"
id="lng"
name="lng"
value={formData.currentLocation.lng}
onChange={handleChange}
className="form-input"
placeholder="Enter longitude"
step="any"
required
/>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-700">
<strong>Current coordinates:</strong> {formData.currentLocation.lat.toFixed(6)}, {formData.currentLocation.lng.toFixed(6)}
</p>
<p className="text-xs text-blue-600 mt-1">
You can use GPS coordinates or get them from a mapping service
</p>
</div>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={onCancel}>
Cancel
</button>
<button type="submit" className="btn btn-primary">
Update Driver
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default EditDriverForm;

View File

@@ -0,0 +1,541 @@
import { useState } from 'react';
interface Flight {
flightNumber: string;
flightDate: string;
segment: number;
validated?: boolean;
validationData?: any;
}
interface VipData {
id: string;
name: string;
organization: string;
transportMode: 'flight' | 'self-driving';
flightNumber?: string; // Legacy
flightDate?: string; // Legacy
flights?: Flight[]; // New
expectedArrival?: string;
arrivalTime?: string;
needsAirportPickup?: boolean;
needsVenueTransport: boolean;
notes: string;
}
interface EditVipFormProps {
vip: VipData;
onSubmit: (vipData: VipData) => void;
onCancel: () => void;
}
const EditVipForm: React.FC<EditVipFormProps> = ({ vip, onSubmit, onCancel }) => {
// Convert legacy single flight to new format if needed
const initialFlights = vip.flights || (vip.flightNumber ? [{
flightNumber: vip.flightNumber,
flightDate: vip.flightDate || '',
segment: 1
}] : [{ flightNumber: '', flightDate: '', segment: 1 }]);
const [formData, setFormData] = useState<VipData>({
id: vip.id,
name: vip.name,
organization: vip.organization,
transportMode: vip.transportMode || 'flight',
flights: initialFlights,
expectedArrival: vip.expectedArrival ? vip.expectedArrival.slice(0, 16) : '',
arrivalTime: vip.arrivalTime ? vip.arrivalTime.slice(0, 16) : '',
needsAirportPickup: vip.needsAirportPickup !== false,
needsVenueTransport: vip.needsVenueTransport !== false,
notes: vip.notes || ''
});
const [flightValidating, setFlightValidating] = useState<{ [key: number]: boolean }>({});
const [flightErrors, setFlightErrors] = useState<{ [key: number]: string }>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
// Only include flights with flight numbers
const validFlights = formData.flights?.filter(f => f.flightNumber) || [];
await onSubmit({
...formData,
flights: validFlights.length > 0 ? validFlights : undefined
});
} catch (error) {
console.error('Error updating VIP:', error);
} finally {
setIsSubmitting(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
if (type === 'checkbox') {
const checked = (e.target as HTMLInputElement).checked;
setFormData(prev => ({
...prev,
[name]: checked
}));
} else {
setFormData(prev => ({
...prev,
[name]: value
}));
}
};
const handleTransportModeChange = (mode: 'flight' | 'self-driving') => {
setFormData(prev => ({
...prev,
transportMode: mode,
flights: mode === 'flight' ? (prev.flights || [{ flightNumber: '', flightDate: '', segment: 1 }]) : undefined,
expectedArrival: mode === 'self-driving' ? prev.expectedArrival : '',
needsAirportPickup: mode === 'flight' ? true : false
}));
// Clear flight errors when switching away from flight mode
if (mode !== 'flight') {
setFlightErrors({});
}
};
const handleFlightChange = (index: number, field: 'flightNumber' | 'flightDate', value: string) => {
setFormData(prev => ({
...prev,
flights: prev.flights?.map((flight, i) =>
i === index ? { ...flight, [field]: value, validated: false } : flight
) || []
}));
// Clear validation for this flight when it changes
setFlightErrors(prev => ({ ...prev, [index]: '' }));
};
const addConnectingFlight = () => {
const currentFlights = formData.flights || [];
if (currentFlights.length < 3) {
setFormData(prev => ({
...prev,
flights: [...currentFlights, {
flightNumber: '',
flightDate: currentFlights[currentFlights.length - 1]?.flightDate || '',
segment: currentFlights.length + 1
}]
}));
}
};
const removeConnectingFlight = (index: number) => {
setFormData(prev => ({
...prev,
flights: prev.flights?.filter((_, i) => i !== index).map((flight, i) => ({
...flight,
segment: i + 1
})) || []
}));
// Clear errors for removed flight
setFlightErrors(prev => {
const newErrors = { ...prev };
delete newErrors[index];
return newErrors;
});
};
const validateFlight = async (index: number) => {
const flight = formData.flights?.[index];
if (!flight || !flight.flightNumber || !flight.flightDate) {
setFlightErrors(prev => ({ ...prev, [index]: 'Please enter flight number and date' }));
return;
}
setFlightValidating(prev => ({ ...prev, [index]: true }));
setFlightErrors(prev => ({ ...prev, [index]: '' }));
try {
const url = `/api/flights/${flight.flightNumber}?date=${flight.flightDate}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
// Update flight with validation data
setFormData(prev => ({
...prev,
flights: prev.flights?.map((f, i) =>
i === index ? { ...f, validated: true, validationData: data } : f
) || []
}));
setFlightErrors(prev => ({ ...prev, [index]: '' }));
} else {
const errorData = await response.json();
setFlightErrors(prev => ({
...prev,
[index]: errorData.error || 'Invalid flight number'
}));
}
} catch (error) {
setFlightErrors(prev => ({
...prev,
[index]: 'Error validating flight'
}));
} finally {
setFlightValidating(prev => ({ ...prev, [index]: false }));
}
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl border border-slate-200/60 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
Edit VIP: {vip.name}
</h2>
<p className="text-slate-600 mt-1">Update VIP information and travel arrangements</p>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-8">
{/* Basic Information */}
<div className="bg-slate-50 rounded-xl p-6 border border-slate-200/60">
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2">
👤 Basic Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-slate-700 mb-2">
Full Name
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="Enter full name"
required
/>
</div>
<div>
<label htmlFor="organization" className="block text-sm font-medium text-slate-700 mb-2">
Organization
</label>
<input
type="text"
id="organization"
name="organization"
value={formData.organization}
onChange={handleChange}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="Enter organization"
required
/>
</div>
</div>
</div>
{/* Transportation Mode */}
<div className="bg-slate-50 rounded-xl p-6 border border-slate-200/60">
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2">
🚗 Transportation
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
How are you arriving?
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className={`relative flex items-center p-4 border-2 rounded-xl cursor-pointer transition-all duration-200 ${
formData.transportMode === 'flight'
? 'border-blue-500 bg-blue-50'
: 'border-slate-300 bg-white hover:border-slate-400'
}`}>
<input
type="radio"
name="transportMode"
value="flight"
checked={formData.transportMode === 'flight'}
onChange={() => handleTransportModeChange('flight')}
className="sr-only"
/>
<div className="flex items-center gap-3">
<span className="text-2xl"></span>
<div>
<div className="font-semibold text-slate-900">Arriving by Flight</div>
<div className="text-sm text-slate-600">Commercial airline travel</div>
</div>
</div>
{formData.transportMode === 'flight' && (
<div className="absolute top-2 right-2 w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs"></span>
</div>
)}
</label>
<label className={`relative flex items-center p-4 border-2 rounded-xl cursor-pointer transition-all duration-200 ${
formData.transportMode === 'self-driving'
? 'border-green-500 bg-green-50'
: 'border-slate-300 bg-white hover:border-slate-400'
}`}>
<input
type="radio"
name="transportMode"
value="self-driving"
checked={formData.transportMode === 'self-driving'}
onChange={() => handleTransportModeChange('self-driving')}
className="sr-only"
/>
<div className="flex items-center gap-3">
<span className="text-2xl">🚗</span>
<div>
<div className="font-semibold text-slate-900">Self-Driving</div>
<div className="text-sm text-slate-600">Personal vehicle</div>
</div>
</div>
{formData.transportMode === 'self-driving' && (
<div className="absolute top-2 right-2 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs"></span>
</div>
)}
</label>
</div>
</div>
</div>
</div>
{/* Flight Information */}
{formData.transportMode === 'flight' && formData.flights && (
<div className="bg-blue-50 rounded-xl p-6 border border-blue-200/60">
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2">
Flight Information
</h3>
<div className="space-y-6">
{formData.flights.map((flight, index) => (
<div key={index} className="bg-white rounded-xl border border-blue-200 p-6">
<div className="flex justify-between items-center mb-4">
<h4 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
{index === 0 ? (
<> Primary Flight</>
) : (
<>🔄 Connecting Flight {index}</>
)}
</h4>
{index > 0 && (
<button
type="button"
onClick={() => removeConnectingFlight(index)}
className="text-red-600 hover:text-red-700 font-medium text-sm flex items-center gap-1"
>
Remove
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label htmlFor={`flightNumber-${index}`} className="block text-sm font-medium text-slate-700 mb-2">
Flight Number
</label>
<input
type="text"
id={`flightNumber-${index}`}
value={flight.flightNumber}
onChange={(e) => handleFlightChange(index, 'flightNumber', e.target.value)}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="e.g., AA123"
required={index === 0}
/>
</div>
<div>
<label htmlFor={`flightDate-${index}`} className="block text-sm font-medium text-slate-700 mb-2">
Flight Date
</label>
<input
type="date"
id={`flightDate-${index}`}
value={flight.flightDate}
onChange={(e) => handleFlightChange(index, 'flightDate', e.target.value)}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
required={index === 0}
min={new Date().toISOString().split('T')[0]}
/>
</div>
</div>
<button
type="button"
onClick={() => validateFlight(index)}
disabled={flightValidating[index] || !flight.flightNumber || !flight.flightDate}
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 disabled:from-slate-400 disabled:to-slate-500 text-white px-4 py-3 rounded-lg font-medium transition-all duration-200 shadow-lg hover:shadow-xl disabled:cursor-not-allowed"
>
{flightValidating[index] ? (
<span className="flex items-center justify-center gap-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
🔍 Validating...
</span>
) : (
'🔍 Validate Flight'
)}
</button>
{/* Flight Validation Results */}
{flightErrors[index] && (
<div className="mt-4 bg-red-50 border border-red-200 rounded-xl p-4">
<div className="text-red-800 font-medium flex items-center gap-2">
{flightErrors[index]}
</div>
</div>
)}
{flight.validated && flight.validationData && (
<div className="mt-4 bg-green-50 border border-green-200 rounded-xl p-4">
<div className="text-green-800 font-medium flex items-center gap-2 mb-2">
Valid Flight: {flight.validationData.airline || 'Flight'} - {flight.validationData.departure?.airport} {flight.validationData.arrival?.airport}
</div>
{flight.validationData.flightDate !== flight.flightDate && (
<div className="text-green-700 text-sm">
Live tracking starts 4 hours before departure on {new Date(flight.flightDate).toLocaleDateString()}
</div>
)}
</div>
)}
</div>
))}
{formData.flights.length < 3 && (
<button
type="button"
onClick={addConnectingFlight}
className="w-full bg-gradient-to-r from-slate-500 to-slate-600 hover:from-slate-600 hover:to-slate-700 text-white px-4 py-3 rounded-lg font-medium transition-all duration-200 shadow-lg hover:shadow-xl"
>
+ Add Connecting Flight
</button>
)}
<div className="bg-white rounded-xl border border-blue-200 p-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
name="needsAirportPickup"
checked={formData.needsAirportPickup || false}
onChange={handleChange}
className="w-5 h-5 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<div>
<div className="font-medium text-slate-900"> Needs Airport Pickup</div>
<div className="text-sm text-slate-600">Pickup from final destination airport</div>
</div>
</label>
</div>
</div>
</div>
)}
{/* Self-Driving Information */}
{formData.transportMode === 'self-driving' && (
<div className="bg-green-50 rounded-xl p-6 border border-green-200/60">
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2">
🚗 Arrival Information
</h3>
<div>
<label htmlFor="expectedArrival" className="block text-sm font-medium text-slate-700 mb-2">
Expected Arrival Time
</label>
<input
type="datetime-local"
id="expectedArrival"
name="expectedArrival"
value={formData.expectedArrival}
onChange={handleChange}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
required
/>
</div>
</div>
)}
{/* Transportation Options */}
<div className="bg-slate-50 rounded-xl p-6 border border-slate-200/60">
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2">
🚐 Transportation Options
</h3>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
name="needsVenueTransport"
checked={formData.needsVenueTransport}
onChange={handleChange}
className="w-5 h-5 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<div>
<div className="font-medium text-slate-900">🚐 Needs Transportation Between Venues</div>
<div className="text-sm text-slate-600">Check this if the VIP needs rides between different event locations</div>
</div>
</label>
</div>
</div>
{/* Additional Notes */}
<div className="bg-slate-50 rounded-xl p-6 border border-slate-200/60">
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2">
📝 Additional Notes
</h3>
<div>
<label htmlFor="notes" className="block text-sm font-medium text-slate-700 mb-2">
Special Requirements
</label>
<textarea
id="notes"
name="notes"
value={formData.notes}
onChange={handleChange}
rows={4}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="Special requirements, dietary restrictions, accessibility needs, security details, etc."
/>
</div>
</div>
{/* Form Actions */}
<div className="flex justify-end gap-4 pt-6 border-t border-slate-200">
<button
type="button"
className="px-6 py-3 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors font-medium"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-6 py-3 rounded-lg font-medium transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
{isSubmitting ? (
<span className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Updating VIP...
</span>
) : (
'✏️ Update VIP'
)}
</button>
</div>
</form>
</div>
</div>
);
};
export default EditVipForm;

View File

@@ -0,0 +1,114 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorInfo: null
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.setState({
errorInfo
});
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null
});
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return <>{this.props.fallback}</>;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md max-w-2xl w-full">
<div className="flex items-center mb-4">
<svg
className="w-8 h-8 text-red-500 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<h1 className="text-2xl font-bold text-gray-800">Something went wrong</h1>
</div>
<p className="text-gray-600 mb-6">
We're sorry, but something unexpected happened. Please try refreshing the page or contact support if the problem persists.
</p>
{import.meta.env.DEV && this.state.error && (
<details className="mb-6">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
Error details (development mode only)
</summary>
<div className="mt-2 p-4 bg-gray-100 rounded text-xs">
<p className="font-mono text-red-600 mb-2">{this.state.error.toString()}</p>
{this.state.errorInfo && (
<pre className="text-gray-700 overflow-auto">
{this.state.errorInfo.componentStack}
</pre>
)}
</div>
</details>
)}
<div className="flex space-x-4">
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Refresh Page
</button>
<button
onClick={this.handleReset}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors"
>
Try Again
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
interface ErrorMessageProps {
message: string;
onDismiss?: () => void;
className?: string;
}
export const ErrorMessage: React.FC<ErrorMessageProps> = ({
message,
onDismiss,
className = ''
}) => {
return (
<div className={`bg-red-50 border border-red-200 rounded-lg p-4 ${className}`}>
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3 flex-1">
<p className="text-sm text-red-700">{message}</p>
</div>
{onDismiss && (
<div className="ml-auto pl-3">
<button
onClick={onDismiss}
className="inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50"
>
<span className="sr-only">Dismiss</span>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,149 @@
import React, { useState, useEffect } from 'react';
interface FlightData {
flightNumber: string;
status: string;
departure: {
airport: string;
scheduled: string;
estimated?: string;
actual?: string;
};
arrival: {
airport: string;
scheduled: string;
estimated?: string;
actual?: string;
};
delay?: number;
gate?: string;
}
interface FlightStatusProps {
flightNumber: string;
flightDate?: string;
}
const FlightStatus: React.FC<FlightStatusProps> = ({ flightNumber, flightDate }) => {
const [flightData, setFlightData] = useState<FlightData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchFlightData = async () => {
try {
setLoading(true);
const url = flightDate
? `/api/flights/${flightNumber}?date=${flightDate}`
: `/api/flights/${flightNumber}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
setFlightData(data);
setError(null);
} else {
setError('Flight not found');
}
} catch (err) {
setError('Failed to fetch flight data');
} finally {
setLoading(false);
}
};
if (flightNumber) {
fetchFlightData();
// Auto-refresh every 5 minutes
const interval = setInterval(fetchFlightData, 5 * 60 * 1000);
return () => clearInterval(interval);
}
}, [flightNumber, flightDate]);
if (loading) {
return <div className="flight-status loading">Loading flight data...</div>;
}
if (error) {
return <div className="flight-status error"> {error}</div>;
}
if (!flightData) {
return null;
}
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case 'active': return '#2ecc71';
case 'scheduled': return '#3498db';
case 'delayed': return '#f39c12';
case 'cancelled': return '#e74c3c';
default: return '#95a5a6';
}
};
const formatTime = (timeString: string) => {
return new Date(timeString).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
};
return (
<div className="flight-status">
<div className="flight-header">
<h4> Flight {flightData.flightNumber}</h4>
<span
className="flight-status-badge"
style={{
backgroundColor: getStatusColor(flightData.status),
color: 'white',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
fontSize: '0.8rem',
textTransform: 'uppercase'
}}
>
{flightData.status}
</span>
</div>
<div className="flight-details">
<div className="flight-route">
<div className="departure">
<strong>{flightData.departure.airport}</strong>
<div>Scheduled: {formatTime(flightData.departure.scheduled)}</div>
{flightData.departure.estimated && (
<div>Estimated: {formatTime(flightData.departure.estimated)}</div>
)}
</div>
<div className="route-arrow"></div>
<div className="arrival">
<strong>{flightData.arrival.airport}</strong>
<div>Scheduled: {formatTime(flightData.arrival.scheduled)}</div>
{flightData.arrival.estimated && (
<div>Estimated: {formatTime(flightData.arrival.estimated)}</div>
)}
</div>
</div>
{flightData.delay && flightData.delay > 0 && (
<div className="delay-info" style={{ color: '#f39c12', marginTop: '0.5rem' }}>
Delayed by {flightData.delay} minutes
</div>
)}
{flightData.gate && (
<div className="gate-info" style={{ marginTop: '0.5rem' }}>
🚪 Gate: {flightData.gate}
</div>
)}
</div>
</div>
);
};
export default FlightStatus;

View File

@@ -0,0 +1,281 @@
interface GanttEvent {
id: string;
title: string;
startTime: string;
endTime: string;
type: 'transport' | 'meeting' | 'event' | 'meal' | 'accommodation';
vipName: string;
location: string;
status: 'scheduled' | 'in-progress' | 'completed' | 'cancelled';
}
interface GanttChartProps {
events: GanttEvent[];
driverName: string;
}
const GanttChart: React.FC<GanttChartProps> = ({ events, driverName }) => {
// Helper functions
const getTypeColor = (type: string) => {
switch (type) {
case 'transport': return '#3498db';
case 'meeting': return '#9b59b6';
case 'event': return '#e74c3c';
case 'meal': return '#f39c12';
case 'accommodation': return '#2ecc71';
default: return '#95a5a6';
}
};
const getStatusOpacity = (status: string) => {
switch (status) {
case 'completed': return 0.5;
case 'cancelled': return 0.3;
case 'in-progress': return 1;
case 'scheduled': return 0.8;
default: return 0.8;
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'transport': return '🚗';
case 'meeting': return '🤝';
case 'event': return '🎉';
case 'meal': return '🍽️';
case 'accommodation': return '🏨';
default: return '📅';
}
};
const formatTime = (timeString: string) => {
return new Date(timeString).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
};
// Calculate time range for the chart
const getTimeRange = () => {
if (events.length === 0) return { start: new Date(), end: new Date() };
const times = events.flatMap(event => [
new Date(event.startTime),
new Date(event.endTime)
]);
const minTime = new Date(Math.min(...times.map(t => t.getTime())));
const maxTime = new Date(Math.max(...times.map(t => t.getTime())));
// Add padding (30 minutes before and after)
const startTime = new Date(minTime.getTime() - 30 * 60 * 1000);
const endTime = new Date(maxTime.getTime() + 30 * 60 * 1000);
return { start: startTime, end: endTime };
};
// Calculate position and width for each event
const calculateEventPosition = (event: GanttEvent, timeRange: { start: Date; end: Date }) => {
const totalDuration = timeRange.end.getTime() - timeRange.start.getTime();
const eventStart = new Date(event.startTime);
const eventEnd = new Date(event.endTime);
const startOffset = eventStart.getTime() - timeRange.start.getTime();
const eventDuration = eventEnd.getTime() - eventStart.getTime();
const leftPercent = (startOffset / totalDuration) * 100;
const widthPercent = (eventDuration / totalDuration) * 100;
return { left: leftPercent, width: widthPercent };
};
// Generate time labels
const generateTimeLabels = (timeRange: { start: Date; end: Date }) => {
const labels = [];
const current = new Date(timeRange.start);
current.setMinutes(0, 0, 0); // Round to nearest hour
while (current <= timeRange.end) {
labels.push(new Date(current));
current.setHours(current.getHours() + 1);
}
return labels;
};
if (events.length === 0) {
return (
<div className="card">
<h3>📊 Schedule Gantt Chart</h3>
<p>No events to display in Gantt chart.</p>
</div>
);
}
const timeRange = getTimeRange();
const timeLabels = generateTimeLabels(timeRange);
const totalDuration = timeRange.end.getTime() - timeRange.start.getTime();
return (
<div className="card">
<h3>📊 Schedule Gantt Chart - {driverName}</h3>
<div style={{ marginBottom: '1rem', fontSize: '0.9rem', color: '#666' }}>
Timeline: {timeRange.start.toLocaleDateString()} {formatTime(timeRange.start.toISOString())} - {formatTime(timeRange.end.toISOString())}
</div>
<div style={{
border: '1px solid #ddd',
borderRadius: '6px',
overflow: 'hidden',
backgroundColor: '#fff'
}}>
{/* Time axis */}
<div style={{
display: 'flex',
borderBottom: '2px solid #333',
backgroundColor: '#f8f9fa',
position: 'relative',
height: '40px',
alignItems: 'center'
}}>
{timeLabels.map((time, index) => {
const position = ((time.getTime() - timeRange.start.getTime()) / totalDuration) * 100;
return (
<div
key={index}
style={{
position: 'absolute',
left: `${position}%`,
transform: 'translateX(-50%)',
fontSize: '0.8rem',
fontWeight: 'bold',
color: '#333',
whiteSpace: 'nowrap'
}}
>
{formatTime(time.toISOString())}
</div>
);
})}
</div>
{/* Events */}
<div style={{ padding: '1rem 0' }}>
{events.map((event) => {
const position = calculateEventPosition(event, timeRange);
return (
<div
key={event.id}
style={{
position: 'relative',
height: '60px',
marginBottom: '8px',
borderRadius: '4px',
border: '1px solid #e9ecef'
}}
>
{/* Event bar */}
<div
style={{
position: 'absolute',
left: `${position.left}%`,
width: `${position.width}%`,
height: '100%',
backgroundColor: getTypeColor(event.type),
opacity: getStatusOpacity(event.status),
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
padding: '0 8px',
color: 'white',
fontSize: '0.8rem',
fontWeight: 'bold',
overflow: 'hidden',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
cursor: 'pointer',
transition: 'transform 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.02)';
e.currentTarget.style.zIndex = '10';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.zIndex = '1';
}}
title={`${event.title}\n${event.location}\n${event.vipName}\n${formatTime(event.startTime)} - ${formatTime(event.endTime)}`}
>
<span style={{ marginRight: '4px' }}>{getTypeIcon(event.type)}</span>
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1
}}>
{event.title}
</span>
</div>
{/* Event details (shown to the right of short events) */}
{position.width < 15 && (
<div
style={{
position: 'absolute',
left: `${position.left + position.width + 1}%`,
top: '50%',
transform: 'translateY(-50%)',
fontSize: '0.7rem',
color: '#666',
whiteSpace: 'nowrap',
backgroundColor: '#f8f9fa',
padding: '2px 6px',
borderRadius: '3px',
border: '1px solid #e9ecef'
}}
>
{getTypeIcon(event.type)} {event.title} - {event.vipName}
</div>
)}
</div>
);
})}
</div>
{/* Legend */}
<div style={{
borderTop: '1px solid #ddd',
padding: '1rem',
backgroundColor: '#f8f9fa'
}}>
<div style={{ fontSize: '0.8rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
Event Types:
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}>
{[
{ type: 'transport', label: 'Transport' },
{ type: 'meeting', label: 'Meetings' },
{ type: 'meal', label: 'Meals' },
{ type: 'event', label: 'Events' },
{ type: 'accommodation', label: 'Accommodation' }
].map(({ type, label }) => (
<div key={type} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<div
style={{
width: '16px',
height: '16px',
backgroundColor: getTypeColor(type),
borderRadius: '2px'
}}
/>
<span style={{ fontSize: '0.7rem' }}>{getTypeIcon(type)} {label}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default GanttChart;

View File

@@ -0,0 +1,45 @@
import React from 'react';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
message?: string;
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'md',
message
}) => {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12'
};
return (
<div className="flex flex-col items-center justify-center p-4">
<svg
className={`animate-spin ${sizeClasses[size]} text-blue-500`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{message && (
<p className="mt-2 text-sm text-gray-600">{message}</p>
)}
</div>
);
};

View File

@@ -0,0 +1,126 @@
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-card {
background: white;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
max-width: 400px;
width: 100%;
text-align: center;
}
.login-header h1 {
color: #333;
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 600;
}
.login-header p {
color: #666;
margin: 0 0 30px 0;
font-size: 16px;
}
.setup-notice {
background: #f0f9ff;
border: 1px solid #0ea5e9;
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
}
.setup-notice h3 {
color: #0369a1;
margin: 0 0 8px 0;
font-size: 18px;
}
.setup-notice p {
color: #0369a1;
margin: 0;
font-size: 14px;
}
.login-content {
margin-bottom: 30px;
}
.google-login-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
padding: 12px 24px;
background: white;
border: 2px solid #dadce0;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
color: #3c4043;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 20px;
}
.google-login-btn:hover:not(:disabled) {
border-color: #1a73e8;
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.2);
}
.google-login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.google-icon {
width: 20px;
height: 20px;
}
.login-info p {
color: #666;
font-size: 14px;
line-height: 1.5;
margin: 0;
}
.login-footer {
border-top: 1px solid #eee;
padding-top: 20px;
}
.login-footer p {
color: #999;
font-size: 12px;
margin: 0;
}
.loading {
color: #666;
font-size: 16px;
padding: 40px;
}
@media (max-width: 480px) {
.login-container {
padding: 10px;
}
.login-card {
padding: 30px 20px;
}
.login-header h1 {
font-size: 24px;
}
}

View File

@@ -0,0 +1,87 @@
import React, { useEffect, useState } from 'react';
import { useKeycloak } from '../contexts/KeycloakContext';
import { apiCall } from '../config/api';
import './Login.css';
const Login: React.FC = () => {
const { login, loading, keycloak } = useKeycloak();
const [setupStatus, setSetupStatus] = useState<any>(null);
const [loadingSetup, setLoadingSetup] = useState(true);
useEffect(() => {
// Check system setup status (if no users exist, first login becomes admin)
apiCall('/auth/setup')
.then(res => res.json())
.then(data => {
setSetupStatus(data);
setLoadingSetup(false);
})
.catch(error => {
console.error('Error checking setup status:', error);
setLoadingSetup(false);
});
}, []);
const handleLogin = () => {
login();
};
if (loading || loadingSetup) {
return (
<div className="login-container">
<div className="login-card">
<div className="loading">Loading...</div>
</div>
</div>
);
}
return (
<div className="login-container">
<div className="login-card">
<div className="login-header">
<h1>VIP Coordinator</h1>
<p>Secure access required</p>
</div>
{setupStatus?.needsFirstUser && (
<div className="setup-notice">
<h3>🚀 First Time Setup</h3>
<p>The first person to log in will become the system administrator.</p>
</div>
)}
<div className="login-content">
<button
className="google-login-btn"
onClick={handleLogin}
disabled={loading || !keycloak || !(keycloak as any)?.kc}
>
<svg className="google-icon" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Sign In with Keycloak
</button>
<div className="login-info">
<p>
{setupStatus?.setupComplete
? "Sign in to access the VIP Coordinator."
: "Sign in to set up your administrator account."
}
</p>
</div>
</div>
<div className="login-footer">
<p>Secure authentication powered by Keycloak</p>
</div>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,610 @@
import { useState, useEffect } from 'react';
import { useAuthToken } from '../hooks/useAuthToken';
import { apiCall } from '../config/api';
import DriverSelector from './DriverSelector';
interface ScheduleEvent {
id: string;
title: string;
location: string;
startTime: string;
endTime: string;
description?: string;
assignedDriverId?: string;
status: 'scheduled' | 'in-progress' | 'completed' | 'cancelled';
type: 'transport' | 'meeting' | 'event' | 'meal' | 'accommodation';
}
interface ScheduleManagerProps {
vipId: string;
vipName: string;
}
const ScheduleManager: React.FC<ScheduleManagerProps> = ({ vipId, vipName }) => {
const token = useAuthToken();
const [schedule, setSchedule] = useState<ScheduleEvent[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
const [drivers, setDrivers] = useState<any[]>([]);
useEffect(() => {
if (token) {
fetchSchedule();
fetchDrivers();
}
}, [vipId, token]);
const fetchSchedule = async () => {
if (!token) return;
try {
const response = await apiCall(`/vips/${vipId}/schedule`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
setSchedule(data);
}
} catch (error) {
console.error('Error fetching schedule:', error);
}
};
const fetchDrivers = async () => {
if (!token) return;
try {
const response = await apiCall('/drivers', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
setDrivers(data);
}
} catch (error) {
console.error('Error fetching drivers:', error);
}
};
const getDriverName = (driverId: string) => {
const driver = drivers.find(d => d.id === driverId);
return driver ? driver.name : `Driver ID: ${driverId}`;
};
const getStatusColor = (status: string) => {
switch (status) {
case 'scheduled': return '#3498db';
case 'in-progress': return '#f39c12';
case 'completed': return '#2ecc71';
case 'cancelled': return '#e74c3c';
default: return '#95a5a6';
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'transport': return '🚗';
case 'meeting': return '🤝';
case 'event': return '🎉';
case 'meal': return '🍽️';
case 'accommodation': return '🏨';
default: return '📅';
}
};
const formatTime = (timeString: string) => {
try {
const date = new Date(timeString);
if (isNaN(date.getTime())) {
return 'Invalid Time';
}
// Safari-compatible time formatting
const hours = date.getHours();
const minutes = date.getMinutes();
const ampm = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
const displayMinutes = minutes.toString().padStart(2, '0');
return `${displayHours}:${displayMinutes} ${ampm}`;
} catch (error) {
console.error('Error formatting time:', error, timeString);
return 'Time Error';
}
};
const groupEventsByDay = (events: ScheduleEvent[]) => {
const grouped: { [key: string]: ScheduleEvent[] } = {};
events.forEach(event => {
const date = new Date(event.startTime).toDateString();
if (!grouped[date]) {
grouped[date] = [];
}
grouped[date].push(event);
});
// Sort events within each day by start time
Object.keys(grouped).forEach(date => {
grouped[date].sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
});
return grouped;
};
const groupedSchedule = groupEventsByDay(schedule);
return (
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-purple-50 to-pink-50 px-8 py-6 border-b border-slate-200/60">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
📅 Schedule for {vipName}
</h2>
<p className="text-slate-600 mt-1">Manage daily events and activities</p>
</div>
<button
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-6 py-3 rounded-lg font-medium transition-all duration-200 shadow-lg hover:shadow-xl flex items-center gap-2"
onClick={() => setShowAddForm(true)}
>
Add Event
</button>
</div>
</div>
<div className="p-8">
{Object.keys(groupedSchedule).length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl">📅</span>
</div>
<p className="text-slate-500 font-medium mb-2">No scheduled events</p>
<p className="text-slate-400 text-sm">Click "Add Event" to get started with scheduling</p>
</div>
) : (
<div className="space-y-8">
{Object.entries(groupedSchedule).map(([date, events]) => (
<div key={date} className="space-y-4">
<div className="bg-gradient-to-r from-slate-600 to-slate-700 text-white px-6 py-3 rounded-xl shadow-lg">
<h3 className="text-lg font-bold">
{new Date(date).toLocaleDateString([], {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</h3>
</div>
<div className="grid gap-4">
{events.map((event) => (
<div key={event.id} className="bg-gradient-to-r from-slate-50 to-slate-100 rounded-xl border border-slate-200/60 p-6 hover:shadow-lg transition-all duration-200">
<div className="flex items-start gap-6">
{/* Time Column */}
<div className="flex-shrink-0 text-center">
<div className="bg-white rounded-lg border border-slate-200 p-3 shadow-sm">
<div className="text-sm font-bold text-slate-900">
{formatTime(event.startTime)}
</div>
<div className="text-xs text-slate-500 mt-1">
to
</div>
<div className="text-sm font-bold text-slate-900">
{formatTime(event.endTime)}
</div>
</div>
</div>
{/* Event Content */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<span className="text-2xl">{getTypeIcon(event.type)}</span>
<h4 className="text-lg font-bold text-slate-900">{event.title}</h4>
<span
className="px-3 py-1 rounded-full text-xs font-bold text-white shadow-sm"
style={{ backgroundColor: getStatusColor(event.status) }}
>
{event.status.toUpperCase()}
</span>
</div>
<div className="flex items-center gap-2 text-slate-600 mb-2">
<span>📍</span>
<span className="font-medium">{event.location}</span>
</div>
{event.description && (
<div className="text-slate-600 mb-3 bg-white/50 rounded-lg p-3 border border-slate-200/50">
{event.description}
</div>
)}
{event.assignedDriverId ? (
<div className="flex items-center gap-2 text-slate-600 mb-4">
<span>👤</span>
<span className="font-medium">Driver: {getDriverName(event.assignedDriverId)}</span>
</div>
) : (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
<div className="flex items-center gap-2 text-amber-800 mb-2">
<span></span>
<span className="font-medium text-sm">No Driver Assigned</span>
</div>
<p className="text-amber-700 text-xs mb-2">This event needs a driver to ensure VIP transportation</p>
<button
className="bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white px-3 py-1 rounded-lg text-xs font-medium transition-all duration-200 shadow-sm hover:shadow-md"
onClick={() => setEditingEvent(event)}
>
🚗 Assign Driver
</button>
</div>
)}
<div className="flex items-center gap-3">
<button
className="bg-gradient-to-r from-slate-500 to-slate-600 hover:from-slate-600 hover:to-slate-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-lg hover:shadow-xl"
onClick={() => setEditingEvent(event)}
>
Edit
</button>
{event.status === 'scheduled' && (
<button
className="bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-lg hover:shadow-xl"
onClick={() => updateEventStatus(event.id, 'in-progress')}
>
Start
</button>
)}
{event.status === 'in-progress' && (
<button
className="bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-lg hover:shadow-xl"
onClick={() => updateEventStatus(event.id, 'completed')}
>
Complete
</button>
)}
{event.status === 'completed' && (
<span className="bg-green-100 text-green-800 px-3 py-1 rounded-full text-xs font-medium">
Completed
</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
{showAddForm && (
<ScheduleEventForm
vipId={vipId}
onSubmit={handleAddEvent}
onCancel={() => setShowAddForm(false)}
/>
)}
{editingEvent && (
<ScheduleEventForm
vipId={vipId}
event={editingEvent}
onSubmit={handleEditEvent}
onCancel={() => setEditingEvent(null)}
/>
)}
</div>
);
async function handleAddEvent(eventData: any) {
if (!token) return;
try {
const response = await apiCall(`/vips/${vipId}/schedule`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(eventData),
});
if (response.ok) {
await fetchSchedule();
setShowAddForm(false);
} else {
const errorData = await response.json();
throw errorData;
}
} catch (error) {
console.error('Error adding event:', error);
throw error;
}
}
async function handleEditEvent(eventData: any) {
if (!token) return;
try {
const response = await apiCall(`/vips/${vipId}/schedule/${eventData.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(eventData),
});
if (response.ok) {
await fetchSchedule();
setEditingEvent(null);
} else {
const errorData = await response.json();
throw errorData;
}
} catch (error) {
console.error('Error updating event:', error);
throw error;
}
}
async function updateEventStatus(eventId: string, status: string) {
if (!token) return;
try {
const response = await apiCall(`/vips/${vipId}/schedule/${eventId}/status`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ status }),
});
if (response.ok) {
await fetchSchedule();
}
} catch (error) {
console.error('Error updating event status:', error);
}
}
};
// Modern Schedule Event Form Component
interface ScheduleEventFormProps {
vipId: string;
event?: ScheduleEvent;
onSubmit: (eventData: any) => void;
onCancel: () => void;
}
const ScheduleEventForm: React.FC<ScheduleEventFormProps> = ({ event, onSubmit, onCancel }) => {
const [formData, setFormData] = useState({
title: event?.title || '',
location: event?.location || '',
startTime: event?.startTime?.slice(0, 16) || '',
endTime: event?.endTime?.slice(0, 16) || '',
description: event?.description || '',
type: event?.type || 'event',
assignedDriverId: event?.assignedDriverId || ''
});
const [validationErrors, setValidationErrors] = useState<any[]>([]);
const [warnings, setWarnings] = useState<any[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setValidationErrors([]);
setWarnings([]);
try {
await onSubmit({
...formData,
id: event?.id,
startTime: new Date(formData.startTime).toISOString(),
endTime: new Date(formData.endTime).toISOString(),
status: event?.status || 'scheduled'
});
} catch (error: any) {
if (error.validationErrors) {
setValidationErrors(error.validationErrors);
}
if (error.warnings) {
setWarnings(error.warnings);
}
} finally {
setIsSubmitting(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl border border-slate-200/60 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800">
{event ? '✏️ Edit Event' : ' Add New Event'}
</h2>
<p className="text-slate-600 mt-1">
{event ? 'Update event details' : 'Create a new schedule event'}
</p>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
{validationErrors.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
<h4 className="text-red-800 font-semibold mb-2"> Validation Errors:</h4>
<ul className="text-red-700 space-y-1">
{validationErrors.map((error, index) => (
<li key={index} className="text-sm"> {error.message}</li>
))}
</ul>
</div>
)}
{warnings.length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<h4 className="text-amber-800 font-semibold mb-2"> Warnings:</h4>
<ul className="text-amber-700 space-y-1">
{warnings.map((warning, index) => (
<li key={index} className="text-sm"> {warning.message}</li>
))}
</ul>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label htmlFor="title" className="block text-sm font-medium text-slate-700 mb-2">
Event Title
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleChange}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="Enter event title"
required
/>
</div>
<div>
<label htmlFor="type" className="block text-sm font-medium text-slate-700 mb-2">
Event Type
</label>
<select
id="type"
name="type"
value={formData.type}
onChange={handleChange}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
required
>
<option value="transport">🚗 Transport</option>
<option value="meeting">🤝 Meeting</option>
<option value="event">🎉 Event</option>
<option value="meal">🍽 Meal</option>
<option value="accommodation">🏨 Accommodation</option>
</select>
</div>
<div>
<label htmlFor="location" className="block text-sm font-medium text-slate-700 mb-2">
Location
</label>
<input
type="text"
id="location"
name="location"
value={formData.location}
onChange={handleChange}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="Enter location"
required
/>
</div>
<div>
<label htmlFor="startTime" className="block text-sm font-medium text-slate-700 mb-2">
Start Time
</label>
<input
type="datetime-local"
id="startTime"
name="startTime"
value={formData.startTime}
onChange={handleChange}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
required
/>
</div>
<div>
<label htmlFor="endTime" className="block text-sm font-medium text-slate-700 mb-2">
End Time
</label>
<input
type="datetime-local"
id="endTime"
name="endTime"
value={formData.endTime}
onChange={handleChange}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
required
/>
</div>
<div className="md:col-span-2">
<label htmlFor="description" className="block text-sm font-medium text-slate-700 mb-2">
Description
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="Enter event description (optional)"
/>
</div>
<div className="md:col-span-2">
<DriverSelector
selectedDriverId={formData.assignedDriverId}
onDriverSelect={(driverId) => setFormData(prev => ({ ...prev, assignedDriverId: driverId }))}
eventTime={{
startTime: formData.startTime ? new Date(formData.startTime).toISOString() : '',
endTime: formData.endTime ? new Date(formData.endTime).toISOString() : '',
location: formData.location
}}
/>
</div>
</div>
<div className="flex justify-end gap-4 pt-6 border-t border-slate-200">
<button
type="button"
className="px-6 py-3 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors font-medium"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-6 py-3 rounded-lg font-medium transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
{isSubmitting ? (
<span className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
{event ? 'Updating...' : 'Creating...'}
</span>
) : (
event ? '✏️ Update Event' : ' Create Event'
)}
</button>
</div>
</form>
</div>
</div>
);
};
export default ScheduleManager;

View File

@@ -0,0 +1,488 @@
import { useState, useEffect } from 'react';
import { API_BASE_URL } from '../config/api';
interface User {
id: string;
email: string;
name: string;
picture: string;
role: string;
created_at: string;
last_sign_in_at?: string;
provider: string;
}
interface UserManagementProps {
currentUser: any;
}
const UserManagement: React.FC<UserManagementProps> = ({ currentUser }) => {
const [users, setUsers] = useState<User[]>([]);
const [pendingUsers, setPendingUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'all' | 'pending'>('all');
const [updatingUser, setUpdatingUser] = useState<string | null>(null);
// Check if current user is admin
if (currentUser?.role !== 'administrator') {
return (
<div className="p-6 bg-red-50 border border-red-200 rounded-lg">
<h2 className="text-xl font-semibold text-red-800 mb-2">Access Denied</h2>
<p className="text-red-600">You need administrator privileges to access user management.</p>
</div>
);
}
const fetchUsers = async () => {
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${API_BASE_URL}/auth/users`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const userData = await response.json();
setUsers(userData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch users');
} finally {
setLoading(false);
}
};
const fetchPendingUsers = async () => {
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${API_BASE_URL}/auth/users/pending/list`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to fetch pending users');
}
const pendingData = await response.json();
setPendingUsers(pendingData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch pending users');
}
};
const updateUserRole = async (userEmail: string, newRole: string) => {
setUpdatingUser(userEmail);
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}/role`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ role: newRole })
});
if (!response.ok) {
throw new Error('Failed to update user role');
}
// Refresh users list
await fetchUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update user role');
} finally {
setUpdatingUser(null);
}
};
const deleteUser = async (userEmail: string, userName: string) => {
if (!confirm(`Are you sure you want to delete user "${userName}"? This action cannot be undone.`)) {
return;
}
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to delete user');
}
// Refresh users list
await fetchUsers();
await fetchPendingUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user');
}
};
const approveUser = async (userEmail: string) => {
setUpdatingUser(userEmail);
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}/approval`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ status: 'approved' })
});
if (!response.ok) {
throw new Error('Failed to approve user');
}
// Refresh both lists
await fetchUsers();
await fetchPendingUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to approve user');
} finally {
setUpdatingUser(null);
}
};
const denyUser = async (userEmail: string, userName: string) => {
if (!confirm(`Are you sure you want to deny access for "${userName}"?`)) {
return;
}
setUpdatingUser(userEmail);
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}/approval`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ status: 'denied' })
});
if (!response.ok) {
throw new Error('Failed to deny user');
}
// Refresh both lists
await fetchUsers();
await fetchPendingUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to deny user');
} finally {
setUpdatingUser(null);
}
};
useEffect(() => {
fetchUsers();
fetchPendingUsers();
}, []);
useEffect(() => {
if (activeTab === 'pending') {
fetchPendingUsers();
}
}, [activeTab]);
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'administrator':
return 'bg-red-100 text-red-800 border-red-200';
case 'coordinator':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'driver':
return 'bg-green-100 text-green-800 border-green-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
if (loading) {
return (
<div className="p-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded-lg w-1/4 mb-6"></div>
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-20 bg-gray-200 rounded-lg"></div>
))}
</div>
</div>
</div>
);
}
return (
<div className="p-6">
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2">User Management</h2>
<p className="text-gray-600">Manage user accounts and permissions (PostgreSQL Database)</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600">{error}</p>
<button
onClick={() => setError(null)}
className="mt-2 text-sm text-red-500 hover:text-red-700"
>
Dismiss
</button>
</div>
)}
{/* Tab Navigation */}
<div className="mb-6">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('all')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'all'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
👥 All Users ({users.length})
</button>
<button
onClick={() => setActiveTab('pending')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'pending'
? 'border-orange-500 text-orange-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Pending Approval ({pendingUsers.length})
{pendingUsers.length > 0 && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
{pendingUsers.length}
</span>
)}
</button>
</nav>
</div>
</div>
{/* Content based on active tab */}
{activeTab === 'all' && (
<div className="bg-white shadow-sm border border-gray-200 rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">
All Users ({users.length})
</h3>
</div>
<div className="divide-y divide-gray-200">
{users.map((user) => (
<div key={user.email} className="p-6 hover:bg-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
{user.picture ? (
<img
src={user.picture}
alt={user.name}
className="w-12 h-12 rounded-full"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-300 flex items-center justify-center">
<span className="text-gray-600 font-medium">
{user.name.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<h4 className="text-lg font-medium text-gray-900">{user.name}</h4>
<p className="text-gray-600">{user.email}</p>
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span>Joined: {formatDate(user.created_at)}</span>
{user.last_sign_in_at && (
<span>Last login: {formatDate(user.last_sign_in_at)}</span>
)}
<span className="capitalize">via {user.provider}</span>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">Role:</span>
<select
value={user.role}
onChange={(e) => updateUserRole(user.email, e.target.value)}
disabled={updatingUser === user.email || user.email === currentUser.email}
className={`px-3 py-1 border rounded-md text-sm font-medium ${getRoleBadgeColor(user.role)} ${
updatingUser === user.email ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-opacity-80'
}`}
>
<option value="coordinator">Coordinator</option>
<option value="administrator">Administrator</option>
<option value="driver">Driver</option>
</select>
</div>
{user.email !== currentUser.email && (
<button
onClick={() => deleteUser(user.email, user.name)}
className="px-3 py-1 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md border border-red-200 transition-colors"
>
🗑 Delete
</button>
)}
{user.email === currentUser.email && (
<span className="px-3 py-1 text-sm text-blue-600 bg-blue-50 rounded-md border border-blue-200">
👤 You
</span>
)}
</div>
</div>
</div>
))}
</div>
{users.length === 0 && (
<div className="p-6 text-center text-gray-500">
No users found.
</div>
)}
</div>
)}
{/* Pending Users Tab */}
{activeTab === 'pending' && (
<div className="bg-white shadow-sm border border-gray-200 rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-orange-50">
<h3 className="text-lg font-medium text-gray-900">
Pending Approval ({pendingUsers.length})
</h3>
<p className="text-sm text-gray-600 mt-1">
Users waiting for administrator approval to access the system
</p>
</div>
<div className="divide-y divide-gray-200">
{pendingUsers.map((user) => (
<div key={user.email} className="p-6 hover:bg-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
{user.picture ? (
<img
src={user.picture}
alt={user.name}
className="w-12 h-12 rounded-full"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-300 flex items-center justify-center">
<span className="text-gray-600 font-medium">
{user.name.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<h4 className="text-lg font-medium text-gray-900">{user.name}</h4>
<p className="text-gray-600">{user.email}</p>
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span>Requested: {formatDate(user.created_at)}</span>
<span className="capitalize">via {user.provider}</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
getRoleBadgeColor(user.role)
}`}>
{user.role}
</span>
</div>
</div>
</div>
<div className="flex items-center space-x-3">
<button
onClick={() => approveUser(user.email)}
disabled={updatingUser === user.email}
className={`px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-md transition-colors ${
updatingUser === user.email ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{updatingUser === user.email ? '⏳ Approving...' : '✅ Approve'}
</button>
<button
onClick={() => denyUser(user.email, user.name)}
disabled={updatingUser === user.email}
className={`px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors ${
updatingUser === user.email ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{updatingUser === user.email ? '⏳ Denying...' : '❌ Deny'}
</button>
</div>
</div>
</div>
))}
</div>
{pendingUsers.length === 0 && (
<div className="p-6 text-center text-gray-500">
<div className="text-6xl mb-4"></div>
<p className="text-lg font-medium mb-2">No pending approvals</p>
<p className="text-sm">All users have been processed.</p>
</div>
)}
</div>
)}
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="font-medium text-blue-900 mb-2">Role Descriptions:</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li><strong>Administrator:</strong> Full access to all features including user management</li>
<li><strong>Coordinator:</strong> Can manage VIPs, drivers, and schedules</li>
<li><strong>Driver:</strong> Can view assigned schedules and update status</li>
</ul>
</div>
<div className="mt-4 p-4 bg-orange-50 border border-orange-200 rounded-lg">
<h4 className="font-medium text-orange-900 mb-2">🔐 User Approval System:</h4>
<p className="text-sm text-orange-800">
New users (except the first administrator) require approval before accessing the system.
Users with pending approval will see a "pending approval" message when they try to sign in.
</p>
</div>
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<h4 className="font-medium text-green-900 mb-2"> PostgreSQL Database:</h4>
<p className="text-sm text-green-800">
User data is stored in your PostgreSQL database with proper indexing and relationships.
All user management operations are transactional and fully persistent across server restarts.
</p>
</div>
</div>
);
};
export default UserManagement;

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiCall } from '../utils/api';
import { useToast } from '../contexts/ToastContext';
import { LoadingSpinner } from './LoadingSpinner';
interface OnboardingData {
requestedRole: 'coordinator' | 'driver' | 'viewer';
phone: string;
organization: string;
reason: string;
// Driver-specific fields
vehicleType?: string;
vehicleCapacity?: number;
licensePlate?: string;
}
const UserOnboarding: React.FC = () => {
const navigate = useNavigate();
const { showToast } = useToast();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<OnboardingData>({
requestedRole: 'viewer',
phone: '',
organization: '',
reason: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const token = localStorage.getItem('authToken');
const response = await apiCall('/api/users/complete-onboarding', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
onboardingData: formData,
phone: formData.phone,
organization: formData.organization,
}),
});
if (response.ok) {
showToast('Onboarding completed! Your account is pending approval.', 'success');
navigate('/pending-approval');
} else {
showToast('Failed to complete onboarding. Please try again.', 'error');
}
} catch (error) {
showToast('An error occurred. Please try again.', 'error');
} finally {
setLoading(false);
}
};
const handleRoleChange = (role: 'coordinator' | 'driver' | 'viewer') => {
setFormData(prev => ({
...prev,
requestedRole: role,
// Clear driver fields if not driver
vehicleType: role === 'driver' ? prev.vehicleType : undefined,
vehicleCapacity: role === 'driver' ? prev.vehicleCapacity : undefined,
licensePlate: role === 'driver' ? prev.licensePlate : undefined,
}));
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-slate-800 mb-2">Welcome to VIP Coordinator</h1>
<p className="text-slate-600">Please complete your profile to request access</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Role Selection */}
<div className="form-section">
<label className="block text-sm font-medium text-slate-700 mb-3">
What type of access do you need?
</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
type="button"
onClick={() => handleRoleChange('coordinator')}
className={`p-4 rounded-lg border-2 transition-all ${
formData.requestedRole === 'coordinator'
? 'border-amber-500 bg-amber-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="text-2xl mb-2">📋</div>
<div className="font-semibold text-slate-800">Coordinator</div>
<div className="text-xs text-slate-600 mt-1">Manage VIPs & schedules</div>
</button>
<button
type="button"
onClick={() => handleRoleChange('driver')}
className={`p-4 rounded-lg border-2 transition-all ${
formData.requestedRole === 'driver'
? 'border-amber-500 bg-amber-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="text-2xl mb-2">🚗</div>
<div className="font-semibold text-slate-800">Driver</div>
<div className="text-xs text-slate-600 mt-1">Transport VIPs</div>
</button>
<button
type="button"
onClick={() => handleRoleChange('viewer')}
className={`p-4 rounded-lg border-2 transition-all ${
formData.requestedRole === 'viewer'
? 'border-amber-500 bg-amber-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="text-2xl mb-2">👁</div>
<div className="font-semibold text-slate-800">Viewer</div>
<div className="text-xs text-slate-600 mt-1">View-only access</div>
</button>
</div>
</div>
{/* Common Fields */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Phone Number *
</label>
<input
type="tel"
required
value={formData.phone}
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
className="form-input w-full"
placeholder="+1 (555) 123-4567"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Organization *
</label>
<input
type="text"
required
value={formData.organization}
onChange={(e) => setFormData(prev => ({ ...prev, organization: e.target.value }))}
className="form-input w-full"
placeholder="Your company or department"
/>
</div>
</div>
{/* Driver-specific Fields */}
{formData.requestedRole === 'driver' && (
<div className="space-y-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
<h3 className="font-semibold text-slate-800 mb-3">Driver Information</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Vehicle Type *
</label>
<select
required
value={formData.vehicleType || ''}
onChange={(e) => setFormData(prev => ({ ...prev, vehicleType: e.target.value }))}
className="form-select w-full"
>
<option value="">Select vehicle type</option>
<option value="sedan">Sedan</option>
<option value="suv">SUV</option>
<option value="van">Van</option>
<option value="minibus">Minibus</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Passenger Capacity *
</label>
<input
type="number"
required
min="1"
max="20"
value={formData.vehicleCapacity || ''}
onChange={(e) => setFormData(prev => ({ ...prev, vehicleCapacity: parseInt(e.target.value) }))}
className="form-input w-full"
placeholder="4"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
License Plate *
</label>
<input
type="text"
required
value={formData.licensePlate || ''}
onChange={(e) => setFormData(prev => ({ ...prev, licensePlate: e.target.value }))}
className="form-input w-full"
placeholder="ABC-1234"
/>
</div>
</div>
</div>
)}
{/* Reason for Access */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Why do you need access? *
</label>
<textarea
required
rows={3}
value={formData.reason}
onChange={(e) => setFormData(prev => ({ ...prev, reason: e.target.value }))}
className="form-textarea w-full"
placeholder="Please explain your role and why you need access to the VIP Coordinator system..."
/>
</div>
{/* Submit Button */}
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={() => navigate('/')}
className="btn btn-secondary"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="btn btn-primary"
>
{loading ? <LoadingSpinner size="sm" /> : 'Submit Request'}
</button>
</div>
</form>
</div>
</div>
);
};
export default UserOnboarding;

View File

@@ -0,0 +1,459 @@
import React, { useState } from 'react';
interface Flight {
flightNumber: string;
flightDate: string;
segment: number;
validated?: boolean;
validationData?: any;
}
interface VipFormData {
name: string;
organization: string;
department: 'Office of Development' | 'Admin';
transportMode: 'flight' | 'self-driving';
flights?: Flight[];
expectedArrival?: string;
needsAirportPickup?: boolean;
needsVenueTransport: boolean;
notes: string;
}
interface VipFormProps {
onSubmit: (vipData: VipFormData) => void;
onCancel: () => void;
}
const VipForm: React.FC<VipFormProps> = ({ onSubmit, onCancel }) => {
const [formData, setFormData] = useState<VipFormData>({
name: '',
organization: '',
department: 'Office of Development',
transportMode: 'flight',
flights: [{ flightNumber: '', flightDate: '', segment: 1 }],
expectedArrival: '',
needsAirportPickup: true,
needsVenueTransport: true,
notes: ''
});
const [flightValidating, setFlightValidating] = useState<{ [key: number]: boolean }>({});
const [flightErrors, setFlightErrors] = useState<{ [key: number]: string }>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Only include flights with flight numbers
const validFlights = formData.flights?.filter(f => f.flightNumber) || [];
onSubmit({
...formData,
flights: validFlights.length > 0 ? validFlights : undefined
});
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
if (type === 'checkbox') {
const checked = (e.target as HTMLInputElement).checked;
setFormData(prev => ({
...prev,
[name]: checked
}));
} else {
setFormData(prev => ({
...prev,
[name]: value
}));
}
};
const handleTransportModeChange = (mode: 'flight' | 'self-driving') => {
setFormData(prev => ({
...prev,
transportMode: mode,
flights: mode === 'flight' ? [{ flightNumber: '', flightDate: '', segment: 1 }] : undefined,
expectedArrival: mode === 'self-driving' ? prev.expectedArrival : '',
needsAirportPickup: mode === 'flight' ? true : false
}));
// Clear flight errors when switching away from flight mode
if (mode !== 'flight') {
setFlightErrors({});
}
};
const handleFlightChange = (index: number, field: 'flightNumber' | 'flightDate', value: string) => {
setFormData(prev => ({
...prev,
flights: prev.flights?.map((flight, i) =>
i === index ? { ...flight, [field]: value, validated: false } : flight
) || []
}));
// Clear validation for this flight when it changes
setFlightErrors(prev => ({ ...prev, [index]: '' }));
};
const addConnectingFlight = () => {
const currentFlights = formData.flights || [];
if (currentFlights.length < 3) {
setFormData(prev => ({
...prev,
flights: [...currentFlights, {
flightNumber: '',
flightDate: currentFlights[currentFlights.length - 1]?.flightDate || '',
segment: currentFlights.length + 1
}]
}));
}
};
const removeConnectingFlight = (index: number) => {
setFormData(prev => ({
...prev,
flights: prev.flights?.filter((_, i) => i !== index).map((flight, i) => ({
...flight,
segment: i + 1
})) || []
}));
// Clear errors for removed flight
setFlightErrors(prev => {
const newErrors = { ...prev };
delete newErrors[index];
return newErrors;
});
};
const validateFlight = async (index: number) => {
const flight = formData.flights?.[index];
if (!flight || !flight.flightNumber || !flight.flightDate) {
setFlightErrors(prev => ({ ...prev, [index]: 'Please enter flight number and date' }));
return;
}
setFlightValidating(prev => ({ ...prev, [index]: true }));
setFlightErrors(prev => ({ ...prev, [index]: '' }));
try {
const url = `/api/flights/${flight.flightNumber}?date=${flight.flightDate}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
// Update flight with validation data
setFormData(prev => ({
...prev,
flights: prev.flights?.map((f, i) =>
i === index ? { ...f, validated: true, validationData: data } : f
) || []
}));
setFlightErrors(prev => ({ ...prev, [index]: '' }));
} else {
const errorData = await response.json();
setFlightErrors(prev => ({
...prev,
[index]: errorData.error || 'Invalid flight number'
}));
}
} catch (error) {
setFlightErrors(prev => ({
...prev,
[index]: 'Error validating flight'
}));
} finally {
setFlightValidating(prev => ({ ...prev, [index]: false }));
}
};
return (
<div className="modal-overlay">
<div className="modal-content">
{/* Modal Header */}
<div className="modal-header">
<h2 className="text-2xl font-bold text-slate-800">
Add New VIP
</h2>
<p className="text-slate-600 mt-2">Enter VIP details and travel information</p>
</div>
{/* Modal Body */}
<div className="modal-body">
<form onSubmit={handleSubmit} className="space-y-8">
{/* Basic Information Section */}
<div className="form-section">
<div className="form-section-header">
<h3 className="form-section-title">Basic Information</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="form-group">
<label htmlFor="name" className="form-label">Full Name *</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="form-input"
placeholder="Enter full name"
required
/>
</div>
<div className="form-group">
<label htmlFor="organization" className="form-label">Organization *</label>
<input
type="text"
id="organization"
name="organization"
value={formData.organization}
onChange={handleChange}
className="form-input"
placeholder="Enter organization name"
required
/>
</div>
</div>
<div className="form-group">
<label htmlFor="department" className="form-label">Department *</label>
<select
id="department"
name="department"
value={formData.department}
onChange={handleChange}
className="form-select"
required
>
<option value="Office of Development">Office of Development</option>
<option value="Admin">Admin</option>
</select>
</div>
</div>
{/* Transportation Section */}
<div className="form-section">
<div className="form-section-header">
<h3 className="form-section-title">Transportation Details</h3>
</div>
<div className="form-group">
<label className="form-label">How are you arriving? *</label>
<div className="radio-group">
<div
className={`radio-option ${formData.transportMode === 'flight' ? 'selected' : ''}`}
onClick={() => handleTransportModeChange('flight')}
>
<input
type="radio"
name="transportMode"
value="flight"
checked={formData.transportMode === 'flight'}
onChange={() => handleTransportModeChange('flight')}
className="form-radio mr-3"
/>
<span className="font-medium">Arriving by Flight</span>
</div>
<div
className={`radio-option ${formData.transportMode === 'self-driving' ? 'selected' : ''}`}
onClick={() => handleTransportModeChange('self-driving')}
>
<input
type="radio"
name="transportMode"
value="self-driving"
checked={formData.transportMode === 'self-driving'}
onChange={() => handleTransportModeChange('self-driving')}
className="form-radio mr-3"
/>
<span className="font-medium">Self-Driving</span>
</div>
</div>
</div>
{/* Flight Mode Fields */}
{formData.transportMode === 'flight' && formData.flights && (
<div className="space-y-6">
{formData.flights.map((flight, index) => (
<div key={index} className="bg-white border-2 border-blue-200 rounded-xl p-6 shadow-sm">
<div className="flex justify-between items-center mb-4">
<h4 className="text-lg font-bold text-slate-800">
{index === 0 ? 'Primary Flight' : `Connecting Flight ${index}`}
</h4>
{index > 0 && (
<button
type="button"
onClick={() => removeConnectingFlight(index)}
className="text-red-500 hover:text-red-700 font-medium text-sm bg-red-50 hover:bg-red-100 px-3 py-1 rounded-lg transition-colors duration-200"
>
Remove
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="form-group">
<label htmlFor={`flightNumber-${index}`} className="form-label">Flight Number *</label>
<input
type="text"
id={`flightNumber-${index}`}
value={flight.flightNumber}
onChange={(e) => handleFlightChange(index, 'flightNumber', e.target.value)}
className="form-input"
placeholder="e.g., AA123"
required={index === 0}
/>
</div>
<div className="form-group">
<label htmlFor={`flightDate-${index}`} className="form-label">Flight Date *</label>
<input
type="date"
id={`flightDate-${index}`}
value={flight.flightDate}
onChange={(e) => handleFlightChange(index, 'flightDate', e.target.value)}
className="form-input"
required={index === 0}
min={new Date().toISOString().split('T')[0]}
/>
</div>
</div>
<button
type="button"
className="btn btn-secondary w-full"
onClick={() => validateFlight(index)}
disabled={flightValidating[index] || !flight.flightNumber || !flight.flightDate}
>
{flightValidating[index] ? (
<>
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full mr-2"></span>
Validating Flight...
</>
) : (
<>Validate Flight</>
)}
</button>
{/* Flight Validation Results */}
{flightErrors[index] && (
<div className="mt-4 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="text-red-700 font-medium">{flightErrors[index]}</div>
</div>
)}
{flight.validated && flight.validationData && (
<div className="mt-4 bg-green-50 border border-green-200 rounded-lg p-4">
<div className="text-green-700 font-medium mb-2">
Valid: {flight.validationData.airline || 'Flight'} - {flight.validationData.departure?.airport} {flight.validationData.arrival?.airport}
</div>
{flight.validationData.flightDate !== flight.flightDate && (
<div className="text-sm text-green-600">
Live tracking starts 4 hours before departure on {new Date(flight.flightDate).toLocaleDateString()}
</div>
)}
</div>
)}
</div>
))}
{formData.flights.length < 3 && (
<button
type="button"
className="btn btn-secondary w-full"
onClick={addConnectingFlight}
>
Add Connecting Flight
</button>
)}
<div className="checkbox-option checked">
<input
type="checkbox"
name="needsAirportPickup"
checked={formData.needsAirportPickup || false}
onChange={handleChange}
className="form-checkbox mr-3"
/>
<span className="font-medium">Needs Airport Pickup (from final destination)</span>
</div>
</div>
)}
{/* Self-Driving Mode Fields */}
{formData.transportMode === 'self-driving' && (
<div className="form-group">
<label htmlFor="expectedArrival" className="form-label">Expected Arrival *</label>
<input
type="datetime-local"
id="expectedArrival"
name="expectedArrival"
value={formData.expectedArrival}
onChange={handleChange}
className="form-input"
required
/>
</div>
)}
{/* Universal Transportation Option */}
<div className={`checkbox-option ${formData.needsVenueTransport ? 'checked' : ''}`}>
<input
type="checkbox"
name="needsVenueTransport"
checked={formData.needsVenueTransport}
onChange={handleChange}
className="form-checkbox mr-3"
/>
<div>
<span className="font-medium">Needs Transportation Between Venues</span>
<div className="text-sm text-slate-500 mt-1">
Check this if the VIP needs rides between different event locations
</div>
</div>
</div>
</div>
{/* Additional Information Section */}
<div className="form-section">
<div className="form-section-header">
<h3 className="form-section-title">Additional Information</h3>
</div>
<div className="form-group">
<label htmlFor="notes" className="form-label">Additional Notes</label>
<textarea
id="notes"
name="notes"
value={formData.notes}
onChange={handleChange}
rows={4}
className="form-textarea"
placeholder="Special requirements, dietary restrictions, accessibility needs, etc."
/>
</div>
</div>
{/* Form Actions */}
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={onCancel}>
Cancel
</button>
<button type="submit" className="btn btn-primary">
Add VIP
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default VipForm;

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '../../tests/test-utils';
import GoogleLogin from '../GoogleLogin';
describe('GoogleLogin', () => {
const mockOnSuccess = vi.fn();
const mockOnError = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('renders login button', () => {
render(
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
);
// Check if Google button container is rendered
const buttonContainer = screen.getByTestId('google-signin-button');
expect(buttonContainer).toBeInTheDocument();
});
it('initializes Google Identity Services on mount', () => {
render(
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
);
expect(google.accounts.id.initialize).toHaveBeenCalledWith({
client_id: expect.any(String),
callback: expect.any(Function),
auto_select: true,
cancel_on_tap_outside: false,
});
expect(google.accounts.id.renderButton).toHaveBeenCalled();
});
it('handles successful login', async () => {
// Get the callback function passed to initialize
let googleCallback: any;
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
googleCallback = config.callback;
});
render(
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
);
// Mock successful server response
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({
token: 'test-jwt-token',
user: {
id: '123',
email: 'test@example.com',
name: 'Test User',
role: 'coordinator',
},
}),
});
// Simulate Google credential response
const mockCredential = { credential: 'mock-google-credential' };
await googleCallback(mockCredential);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/auth/google/verify'),
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ credential: 'mock-google-credential' }),
})
);
});
await waitFor(() => {
expect(mockOnSuccess).toHaveBeenCalledWith({
token: 'test-jwt-token',
user: {
id: '123',
email: 'test@example.com',
name: 'Test User',
role: 'coordinator',
},
});
});
});
it('handles login error', async () => {
let googleCallback: any;
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
googleCallback = config.callback;
});
render(
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
);
// Mock error response
(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ error: 'Invalid credential' }),
});
const mockCredential = { credential: 'invalid-credential' };
await googleCallback(mockCredential);
await waitFor(() => {
expect(mockOnError).toHaveBeenCalledWith('Authentication failed');
});
});
it('handles network error', async () => {
let googleCallback: any;
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
googleCallback = config.callback;
});
render(
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
);
// Mock network error
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
const mockCredential = { credential: 'mock-credential' };
await googleCallback(mockCredential);
await waitFor(() => {
expect(mockOnError).toHaveBeenCalledWith('Network error. Please try again.');
});
});
it('displays loading state during authentication', async () => {
let googleCallback: any;
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
googleCallback = config.callback;
});
render(
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
);
// Mock a delayed response
(global.fetch as any).mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve({
ok: true,
json: async () => ({ token: 'test-token', user: {} }),
}), 100))
);
const mockCredential = { credential: 'mock-credential' };
googleCallback(mockCredential);
// Check for loading state
expect(screen.getByText('Authenticating...')).toBeInTheDocument();
// Wait for authentication to complete
await waitFor(() => {
expect(screen.queryByText('Authenticating...')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,196 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '../../tests/test-utils';
import userEvent from '@testing-library/user-event';
import VipForm from '../VipForm';
describe('VipForm', () => {
const mockOnSubmit = vi.fn();
const mockOnCancel = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('renders all form fields', () => {
render(
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
);
expect(screen.getByLabelText(/full name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
expect(screen.getByLabelText(/organization/i)).toBeInTheDocument();
expect(screen.getByLabelText(/contact information/i)).toBeInTheDocument();
expect(screen.getByLabelText(/arrival date/i)).toBeInTheDocument();
expect(screen.getByLabelText(/departure date/i)).toBeInTheDocument();
expect(screen.getByLabelText(/transportation mode/i)).toBeInTheDocument();
expect(screen.getByLabelText(/hotel/i)).toBeInTheDocument();
expect(screen.getByLabelText(/room number/i)).toBeInTheDocument();
expect(screen.getByLabelText(/additional notes/i)).toBeInTheDocument();
});
it('shows flight-specific fields when flight mode is selected', async () => {
const user = userEvent.setup();
render(
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
);
const transportSelect = screen.getByLabelText(/transportation mode/i);
await user.selectOptions(transportSelect, 'flight');
expect(screen.getByLabelText(/airport/i)).toBeInTheDocument();
expect(screen.getByLabelText(/flight number/i)).toBeInTheDocument();
});
it('hides flight fields when self-driving mode is selected', async () => {
const user = userEvent.setup();
render(
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
);
// First select flight to show fields
const transportSelect = screen.getByLabelText(/transportation mode/i);
await user.selectOptions(transportSelect, 'flight');
expect(screen.getByLabelText(/airport/i)).toBeInTheDocument();
// Then switch to self-driving
await user.selectOptions(transportSelect, 'self_driving');
expect(screen.queryByLabelText(/airport/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/flight number/i)).not.toBeInTheDocument();
});
it('submits form with valid data', async () => {
const user = userEvent.setup();
render(
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
);
// Fill out the form
await user.type(screen.getByLabelText(/full name/i), 'John Doe');
await user.type(screen.getByLabelText(/title/i), 'CEO');
await user.type(screen.getByLabelText(/organization/i), 'Test Corp');
await user.type(screen.getByLabelText(/contact information/i), '+1234567890');
await user.type(screen.getByLabelText(/arrival date/i), '2025-01-15T10:00');
await user.type(screen.getByLabelText(/departure date/i), '2025-01-16T14:00');
await user.selectOptions(screen.getByLabelText(/transportation mode/i), 'flight');
await user.type(screen.getByLabelText(/airport/i), 'LAX');
await user.type(screen.getByLabelText(/flight number/i), 'AA123');
await user.type(screen.getByLabelText(/hotel/i), 'Hilton');
await user.type(screen.getByLabelText(/room number/i), '1234');
await user.type(screen.getByLabelText(/additional notes/i), 'VIP guest');
// Submit the form
const submitButton = screen.getByRole('button', { name: /add vip/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith({
name: 'John Doe',
title: 'CEO',
organization: 'Test Corp',
contact_info: '+1234567890',
arrival_datetime: '2025-01-15T10:00',
departure_datetime: '2025-01-16T14:00',
transportation_mode: 'flight',
airport: 'LAX',
flight_number: 'AA123',
hotel: 'Hilton',
room_number: '1234',
notes: 'VIP guest',
status: 'scheduled',
});
});
});
it('validates required fields', async () => {
const user = userEvent.setup();
render(
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
);
// Try to submit empty form
const submitButton = screen.getByRole('button', { name: /add vip/i });
await user.click(submitButton);
// Check that onSubmit was not called
expect(mockOnSubmit).not.toHaveBeenCalled();
// Check for HTML5 validation (browser will show validation messages)
const nameInput = screen.getByLabelText(/full name/i) as HTMLInputElement;
expect(nameInput.validity.valid).toBe(false);
});
it('calls onCancel when cancel button is clicked', async () => {
const user = userEvent.setup();
render(
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalled();
});
it('pre-fills form when editing existing VIP', () => {
const existingVip = {
id: '123',
name: 'Jane Smith',
title: 'VP Sales',
organization: 'Another Corp',
contact_info: '+0987654321',
arrival_datetime: '2025-01-15T14:00',
departure_datetime: '2025-01-16T10:00',
transportation_mode: 'self_driving' as const,
hotel: 'Marriott',
room_number: '567',
status: 'scheduled' as const,
notes: 'Arrives by car',
};
render(
<VipForm
vip={existingVip}
onSubmit={mockOnSubmit}
onCancel={mockOnCancel}
/>
);
expect(screen.getByDisplayValue('Jane Smith')).toBeInTheDocument();
expect(screen.getByDisplayValue('VP Sales')).toBeInTheDocument();
expect(screen.getByDisplayValue('Another Corp')).toBeInTheDocument();
expect(screen.getByDisplayValue('+0987654321')).toBeInTheDocument();
expect(screen.getByDisplayValue('2025-01-15T14:00')).toBeInTheDocument();
expect(screen.getByDisplayValue('2025-01-16T10:00')).toBeInTheDocument();
expect(screen.getByDisplayValue('self_driving')).toBeInTheDocument();
expect(screen.getByDisplayValue('Marriott')).toBeInTheDocument();
expect(screen.getByDisplayValue('567')).toBeInTheDocument();
expect(screen.getByDisplayValue('Arrives by car')).toBeInTheDocument();
// Should show "Update VIP" instead of "Add VIP"
expect(screen.getByRole('button', { name: /update vip/i })).toBeInTheDocument();
});
it('validates departure date is after arrival date', async () => {
const user = userEvent.setup();
render(
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
);
// Set departure before arrival
await user.type(screen.getByLabelText(/arrival date/i), '2025-01-16T14:00');
await user.type(screen.getByLabelText(/departure date/i), '2025-01-15T10:00');
// Fill other required fields
await user.type(screen.getByLabelText(/full name/i), 'John Doe');
await user.type(screen.getByLabelText(/title/i), 'CEO');
await user.type(screen.getByLabelText(/organization/i), 'Test Corp');
const submitButton = screen.getByRole('button', { name: /add vip/i });
await user.click(submitButton);
// Form should not submit
expect(mockOnSubmit).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,13 @@
// API Configuration
// VITE_API_URL must be set at build time - no fallback to prevent production issues
export const API_BASE_URL = (import.meta as any).env.VITE_API_URL;
if (!API_BASE_URL) {
throw new Error('VITE_API_URL environment variable is required');
}
// Helper function for API calls
export const apiCall = (endpoint: string, options?: RequestInit) => {
const url = endpoint.startsWith('/') ? `${API_BASE_URL}${endpoint}` : endpoint;
return fetch(url, options);
};

View File

@@ -0,0 +1,175 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import Keycloak from 'keycloak-js';
interface KeycloakContextType {
keycloak: Keycloak | null;
authenticated: boolean;
loading: boolean;
login: () => void;
logout: () => void;
getToken: () => Promise<string | undefined>;
}
const KeycloakContext = createContext<KeycloakContextType | undefined>(undefined);
interface KeycloakProviderProps {
children: ReactNode;
}
// Singleton instance to prevent multiple initializations
let keycloakInstance: Keycloak | null = null;
let initPromise: Promise<boolean> | null = null;
function getKeycloakInstance(): Keycloak {
if (!keycloakInstance) {
keycloakInstance = new Keycloak({
url: import.meta.env.VITE_KEYCLOAK_URL || 'http://localhost:8080',
realm: import.meta.env.VITE_KEYCLOAK_REALM || 'vip-coordinator',
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'vip-coordinator-frontend',
});
}
return keycloakInstance;
}
export const KeycloakProvider: React.FC<KeycloakProviderProps> = ({ children }) => {
const [keycloak, setKeycloak] = useState<Keycloak | null>(null);
const [authenticated, setAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const instance = getKeycloakInstance();
setKeycloak(instance);
// Set up event handlers BEFORE init (as per Keycloak docs)
instance.onReady = (authenticated) => {
setAuthenticated(authenticated);
setLoading(false);
if (authenticated && instance.token) {
localStorage.setItem('authToken', instance.token);
}
};
instance.onAuthSuccess = () => {
setAuthenticated(true);
setLoading(false);
if (instance.token) {
localStorage.setItem('authToken', instance.token);
}
};
instance.onAuthError = () => {
setAuthenticated(false);
setLoading(false);
localStorage.removeItem('authToken');
};
instance.onTokenExpired = () => {
instance
.updateToken(30)
.then((refreshed) => {
if (refreshed && instance.token) {
localStorage.setItem('authToken', instance.token);
}
})
.catch(() => {
instance.logout();
});
};
instance.onAuthLogout = () => {
setAuthenticated(false);
localStorage.removeItem('authToken');
};
// Only initialize if not already initialized
if (!initPromise) {
// Check if we're returning from Keycloak (have auth code)
const urlHash = window.location.hash;
const urlSearch = window.location.search;
const hasAuthCode = urlHash.includes('code=') || urlHash.includes('access_token=') || urlSearch.includes('code=');
// Initialize Keycloak - use check-sso for silent check, login-required only if we have auth code
initPromise = instance.init({
onLoad: hasAuthCode ? 'login-required' : 'check-sso',
checkLoginIframe: false,
pkceMethod: 'S256',
redirectUri: window.location.origin,
enableLogging: true, // Enable logging to debug
})
.then((authenticated) => {
// Clear URL params after successful auth
if (hasAuthCode && authenticated) {
window.history.replaceState({}, document.title, window.location.pathname);
}
return authenticated;
})
.catch((error) => {
console.error('Keycloak initialization error:', error);
setLoading(false);
setAuthenticated(false);
initPromise = null; // Reset on error so we can retry
throw error;
});
}
// Wait for initialization to complete
initPromise
.then((authenticated) => {
setAuthenticated(authenticated);
setLoading(false);
})
.catch(() => {
// Error already handled in catch above
});
}, []);
const login = () => {
if (!keycloak) {
console.error('Keycloak instance not available');
return;
}
keycloak.login({
redirectUri: window.location.origin,
});
};
const logout = () => {
keycloak?.logout({
redirectUri: window.location.origin + '/login',
});
};
const getToken = async (): Promise<string | undefined> => {
if (!keycloak) return undefined;
try {
await keycloak.updateToken(30);
return keycloak.token || undefined;
} catch (error) {
console.error('Error getting token:', error);
return undefined;
}
};
return (
<KeycloakContext.Provider
value={{
keycloak,
authenticated,
loading,
login,
logout,
getToken,
}}
>
{children}
</KeycloakContext.Provider>
);
};
export const useKeycloak = () => {
const context = useContext(KeycloakContext);
if (context === undefined) {
throw new Error('useKeycloak must be used within a KeycloakProvider');
}
return context;
};

View File

@@ -0,0 +1,95 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'info' | 'warning';
duration?: number;
}
interface ToastContextType {
showToast: (message: string, type: Toast['type'], duration?: number) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
const showToast = useCallback((message: string, type: Toast['type'], duration = 5000) => {
const id = Date.now().toString();
const toast: Toast = { id, message, type, duration };
setToasts(prev => [...prev, toast]);
if (duration > 0) {
setTimeout(() => removeToast(id), duration);
}
}, [removeToast]);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="fixed top-4 right-4 z-50 space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
className={`
max-w-sm p-4 rounded-lg shadow-lg transform transition-all duration-300 ease-in-out
${toast.type === 'success' ? 'bg-green-500 text-white' : ''}
${toast.type === 'error' ? 'bg-red-500 text-white' : ''}
${toast.type === 'info' ? 'bg-blue-500 text-white' : ''}
${toast.type === 'warning' ? 'bg-amber-500 text-white' : ''}
`}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
{toast.type === 'success' && (
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
)}
{toast.type === 'error' && (
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
)}
{toast.type === 'info' && (
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
)}
{toast.type === 'warning' && (
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
)}
<span className="text-sm font-medium">{toast.message}</span>
</div>
<button
onClick={() => removeToast(toast.id)}
className="ml-4 text-white hover:text-gray-200 transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
))}
</div>
</ToastContext.Provider>
);
};

View File

@@ -0,0 +1,56 @@
import { useState, useEffect, useCallback } from 'react';
// Simple hook for API calls that handles loading, error, and data states
export function useApi<T>(
apiCall: () => Promise<T>,
dependencies: any[] = []
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const result = await apiCall();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
setData(null);
} finally {
setLoading(false);
}
}, dependencies);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
// Hook for mutations (POST, PUT, DELETE)
export function useMutation<TData = any, TVariables = any>(
mutationFn: (variables: TVariables) => Promise<TData>
) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const mutate = useCallback(async (variables: TVariables) => {
try {
setLoading(true);
setError(null);
const result = await mutationFn(variables);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'An error occurred';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, [mutationFn]);
return { mutate, loading, error };
}

View File

@@ -0,0 +1,41 @@
import { useKeycloak } from '../contexts/KeycloakContext';
import { useEffect, useState } from 'react';
/**
* Custom hook to safely get the Keycloak access token
* Returns the token if authenticated, null otherwise
* Also stores token in localStorage for backward compatibility
*/
export function useAuthToken(): string | null {
const { authenticated, getToken } = useKeycloak();
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
const fetchToken = async () => {
if (authenticated) {
try {
const accessToken = await getToken();
// Store in localStorage for backward compatibility with existing API calls
if (accessToken) {
localStorage.setItem('authToken', accessToken);
setToken(accessToken);
} else {
localStorage.removeItem('authToken');
setToken(null);
}
} catch (error) {
console.error('Error getting access token:', error);
localStorage.removeItem('authToken');
setToken(null);
}
} else {
localStorage.removeItem('authToken');
setToken(null);
}
};
fetchToken();
}, [authenticated, getToken]);
return token;
}

View File

@@ -0,0 +1,74 @@
import { useState, useCallback } from 'react';
export interface ApiError {
message: string;
code?: string;
details?: unknown;
}
export const useError = () => {
const [error, setError] = useState<ApiError | null>(null);
const [isError, setIsError] = useState(false);
const clearError = useCallback(() => {
setError(null);
setIsError(false);
}, []);
const handleError = useCallback((error: unknown) => {
console.error('API Error:', error);
let apiError: ApiError;
if (error instanceof Error) {
// Check if it's our custom ApiError
if ('status' in error && 'code' in error) {
apiError = {
message: error.message,
code: (error as any).code,
details: (error as any).details
};
} else {
// Regular Error
apiError = {
message: error.message,
code: 'ERROR'
};
}
} else if (typeof error === 'object' && error !== null) {
// Check for axios-like error structure
const err = error as any;
if (err.response?.data?.error) {
apiError = {
message: err.response.data.error.message || err.response.data.error,
code: err.response.data.error.code,
details: err.response.data.error.details
};
} else {
apiError = {
message: 'An unexpected error occurred',
code: 'UNKNOWN_ERROR',
details: error
};
}
} else {
// Unknown error type
apiError = {
message: 'An unexpected error occurred',
code: 'UNKNOWN_ERROR'
};
}
setError(apiError);
setIsError(true);
return apiError;
}, []);
return {
error,
isError,
clearError,
handleError
};
};

View File

@@ -0,0 +1,352 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom base styles */
@layer base {
:root {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
line-height: 1.6;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
color: #1e293b;
}
#root {
width: 100%;
margin: 0 auto;
text-align: left;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Focus styles */
*:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
/* Custom component styles */
@layer components {
/* Modern Button Styles */
.btn {
padding-left: 1.5rem;
padding-right: 1.5rem;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
border-radius: 0.75rem;
font-weight: 600;
font-size: 0.875rem;
transition: all 0.2s;
outline: none;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
transform: translateY(0);
}
.btn:focus {
ring: 2px;
ring-offset: 2px;
}
.btn:hover {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
transform: translateY(-0.125rem);
}
.btn-primary {
background: linear-gradient(to right, #3b82f6, #2563eb);
color: white;
}
.btn-primary:hover {
background: linear-gradient(to right, #2563eb, #1d4ed8);
}
.btn-primary:focus {
ring-color: #3b82f6;
}
.btn-secondary {
background: linear-gradient(to right, #64748b, #475569);
color: white;
}
.btn-secondary:hover {
background: linear-gradient(to right, #475569, #334155);
}
.btn-secondary:focus {
ring-color: #64748b;
}
.btn-danger {
background: linear-gradient(to right, #ef4444, #dc2626);
color: white;
}
.btn-danger:hover {
background: linear-gradient(to right, #dc2626, #b91c1c);
}
.btn-danger:focus {
ring-color: #ef4444;
}
.btn-success {
background: linear-gradient(to right, #22c55e, #16a34a);
color: white;
}
.btn-success:hover {
background: linear-gradient(to right, #16a34a, #15803d);
}
.btn-success:focus {
ring-color: #22c55e;
}
/* Modern Card Styles */
.card {
background-color: white;
border-radius: 1rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(226, 232, 240, 0.6);
overflow: hidden;
backdrop-filter: blur(4px);
}
/* Modern Form Styles */
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #334155;
margin-bottom: 0.75rem;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
background-color: white;
transition: all 0.2s;
}
.form-input:focus {
outline: none;
ring: 2px;
ring-color: #3b82f6;
border-color: #3b82f6;
}
.form-select {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
background-color: white;
transition: all 0.2s;
}
.form-select:focus {
outline: none;
ring: 2px;
ring-color: #3b82f6;
border-color: #3b82f6;
}
.form-textarea {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
background-color: white;
transition: all 0.2s;
resize: none;
}
.form-textarea:focus {
outline: none;
ring: 2px;
ring-color: #3b82f6;
border-color: #3b82f6;
}
.form-checkbox {
width: 1.25rem;
height: 1.25rem;
color: #2563eb;
border: 1px solid #cbd5e1;
border-radius: 0.25rem;
}
.form-checkbox:focus {
ring: 2px;
ring-color: #3b82f6;
}
.form-radio {
width: 1rem;
height: 1rem;
color: #2563eb;
border: 1px solid #cbd5e1;
}
.form-radio:focus {
ring: 2px;
ring-color: #3b82f6;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
justify-content: center;
align-items: center;
z-index: 50;
padding: 1rem;
}
.modal-content {
background-color: white;
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
max-width: 56rem;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
background: linear-gradient(to right, #eff6ff, #eef2ff);
padding: 1.5rem 2rem;
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
}
.modal-body {
padding: 2rem;
}
.modal-footer {
background-color: #f8fafc;
padding: 1.5rem 2rem;
border-top: 1px solid rgba(226, 232, 240, 0.6);
display: flex;
justify-content: flex-end;
gap: 1rem;
}
/* Form Actions */
.form-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(226, 232, 240, 0.6);
margin-top: 2rem;
}
/* Form Sections */
.form-section {
background-color: #f8fafc;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid rgba(226, 232, 240, 0.6);
}
.form-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.form-section-title {
font-size: 1.125rem;
font-weight: 700;
color: #1e293b;
}
/* Radio Group */
.radio-group {
display: flex;
gap: 1.5rem;
margin-top: 0.75rem;
}
.radio-option {
display: flex;
align-items: center;
cursor: pointer;
background-color: white;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
border: 1px solid #e2e8f0;
transition: all 0.2s;
}
.radio-option:hover {
border-color: #93c5fd;
background-color: #eff6ff;
}
.radio-option.selected {
border-color: #3b82f6;
background-color: #eff6ff;
ring: 2px;
ring-color: #bfdbfe;
}
/* Checkbox Group */
.checkbox-option {
display: flex;
align-items: center;
cursor: pointer;
background-color: white;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
border: 1px solid #e2e8f0;
transition: all 0.2s;
}
.checkbox-option:hover {
border-color: #93c5fd;
background-color: #eff6ff;
}
.checkbox-option.checked {
border-color: #3b82f6;
background-color: #eff6ff;
}
}

View File

@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { KeycloakProvider } from './contexts/KeycloakContext'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<KeycloakProvider>
<App />
</KeycloakProvider>
)

View File

@@ -0,0 +1,834 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiCall } from '../config/api';
import { generateTestVips, getTestOrganizations, generateVipSchedule } from '../utils/testVipData';
interface ApiKeys {
aviationStackKey?: string;
googleMapsKey?: string;
twilioKey?: string;
googleClientId?: string;
googleClientSecret?: string;
}
interface SystemSettings {
defaultPickupLocation?: string;
defaultDropoffLocation?: string;
timeZone?: string;
notificationsEnabled?: boolean;
}
const AdminDashboard: React.FC = () => {
const navigate = useNavigate();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [adminPassword, setAdminPassword] = useState('');
const [apiKeys, setApiKeys] = useState<ApiKeys>({});
const [systemSettings, setSystemSettings] = useState<SystemSettings>({});
const [testResults, setTestResults] = useState<{ [key: string]: string }>({});
const [loading, setLoading] = useState(false);
const [saveStatus, setSaveStatus] = useState<string | null>(null);
const [showKeys, setShowKeys] = useState<{ [key: string]: boolean }>({});
const [savedKeys, setSavedKeys] = useState<{ [key: string]: boolean }>({});
const [testDataLoading, setTestDataLoading] = useState(false);
const [testDataStatus, setTestDataStatus] = useState<string | null>(null);
useEffect(() => {
// Check if already authenticated
const authStatus = sessionStorage.getItem('adminAuthenticated');
if (authStatus === 'true') {
setIsAuthenticated(true);
loadSettings();
}
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch('/api/admin/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: adminPassword })
});
if (response.ok) {
setIsAuthenticated(true);
sessionStorage.setItem('adminAuthenticated', 'true');
loadSettings();
} else {
alert('Invalid admin password');
}
} catch (error) {
alert('Authentication failed');
}
};
const loadSettings = async () => {
try {
const response = await fetch('/api/admin/settings', {
headers: {
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
}
});
if (response.ok) {
const data = await response.json();
// Track which keys are already saved (masked keys start with ***)
const saved: { [key: string]: boolean } = {};
if (data.apiKeys) {
Object.entries(data.apiKeys).forEach(([key, value]) => {
if (value && (value as string).startsWith('***')) {
saved[key] = true;
}
});
}
setSavedKeys(saved);
// Don't load masked keys as actual values - keep them empty
const cleanedApiKeys: ApiKeys = {};
if (data.apiKeys) {
Object.entries(data.apiKeys).forEach(([key, value]) => {
// Only set the value if it's not a masked key
if (value && !(value as string).startsWith('***')) {
cleanedApiKeys[key as keyof ApiKeys] = value as string;
}
});
}
setApiKeys(cleanedApiKeys);
setSystemSettings(data.systemSettings || {});
}
} catch (error) {
console.error('Failed to load settings:', error);
}
};
const handleApiKeyChange = (key: keyof ApiKeys, value: string) => {
setApiKeys(prev => ({ ...prev, [key]: value }));
// If user is typing a new key, mark it as not saved anymore
if (value && !value.startsWith('***')) {
setSavedKeys(prev => ({ ...prev, [key]: false }));
}
};
const handleSettingChange = (key: keyof SystemSettings, value: any) => {
setSystemSettings(prev => ({ ...prev, [key]: value }));
};
const testApiConnection = async (apiType: string) => {
setTestResults(prev => ({ ...prev, [apiType]: 'Testing...' }));
try {
const response = await fetch(`/api/admin/test-api/${apiType}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
},
body: JSON.stringify({
apiKey: apiKeys[apiType as keyof ApiKeys]
})
});
const result = await response.json();
if (response.ok) {
setTestResults(prev => ({
...prev,
[apiType]: `Success: ${result.message}`
}));
} else {
setTestResults(prev => ({
...prev,
[apiType]: `Failed: ${result.error}`
}));
}
} catch (error) {
setTestResults(prev => ({
...prev,
[apiType]: 'Connection error'
}));
}
};
const saveSettings = async () => {
setLoading(true);
setSaveStatus(null);
try {
const response = await fetch('/api/admin/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
},
body: JSON.stringify({
apiKeys,
systemSettings
})
});
if (response.ok) {
setSaveStatus('Settings saved successfully!');
// Mark keys as saved if they have values
const newSavedKeys: { [key: string]: boolean } = {};
Object.entries(apiKeys).forEach(([key, value]) => {
if (value && !value.startsWith('***')) {
newSavedKeys[key] = true;
}
});
setSavedKeys(prev => ({ ...prev, ...newSavedKeys }));
// Clear the input fields after successful save
setApiKeys({});
setTimeout(() => setSaveStatus(null), 3000);
} else {
setSaveStatus('Failed to save settings');
}
} catch (error) {
setSaveStatus('Error saving settings');
} finally {
setLoading(false);
}
};
const handleLogout = () => {
sessionStorage.removeItem('adminAuthenticated');
setIsAuthenticated(false);
navigate('/');
};
// Test VIP functions
const createTestVips = async () => {
setTestDataLoading(true);
setTestDataStatus('Creating test VIPs and schedules...');
try {
const token = localStorage.getItem('authToken');
const testVips = generateTestVips();
let vipSuccessCount = 0;
let vipErrorCount = 0;
let scheduleSuccessCount = 0;
let scheduleErrorCount = 0;
const createdVipIds: string[] = [];
// First, create all VIPs
for (const vipData of testVips) {
try {
const response = await apiCall('/vips', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(vipData),
});
if (response.ok) {
const createdVip = await response.json();
createdVipIds.push(createdVip.id);
vipSuccessCount++;
} else {
vipErrorCount++;
console.error(`Failed to create VIP: ${vipData.name}`);
}
} catch (error) {
vipErrorCount++;
console.error(`Error creating VIP ${vipData.name}:`, error);
}
}
setTestDataStatus(`Created ${vipSuccessCount} VIPs, now creating schedules...`);
// Then, create schedules for each successfully created VIP
for (let i = 0; i < createdVipIds.length; i++) {
const vipId = createdVipIds[i];
const vipData = testVips[i];
try {
const scheduleEvents = generateVipSchedule(vipData.department, vipData.transportMode);
for (const event of scheduleEvents) {
try {
const eventWithId = {
...event,
id: Date.now().toString() + Math.random().toString(36).substr(2, 9)
};
const scheduleResponse = await apiCall(`/api/vips/${vipId}/schedule`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(eventWithId),
});
if (scheduleResponse.ok) {
scheduleSuccessCount++;
} else {
scheduleErrorCount++;
console.error(`Failed to create schedule event for ${vipData.name}: ${event.title}`);
}
} catch (error) {
scheduleErrorCount++;
console.error(`Error creating schedule event for ${vipData.name}:`, error);
}
}
} catch (error) {
console.error(`Error generating schedule for ${vipData.name}:`, error);
}
}
setTestDataStatus(`✅ Created ${vipSuccessCount} VIPs with ${scheduleSuccessCount} schedule events! ${vipErrorCount > 0 || scheduleErrorCount > 0 ? `(${vipErrorCount + scheduleErrorCount} failed)` : ''}`);
} catch (error) {
setTestDataStatus('❌ Failed to create test VIPs and schedules');
console.error('Error creating test data:', error);
} finally {
setTestDataLoading(false);
setTimeout(() => setTestDataStatus(null), 8000);
}
};
const removeTestVips = async () => {
if (!confirm('Are you sure you want to remove all test VIPs? This will delete VIPs from the test organizations.')) {
return;
}
setTestDataLoading(true);
setTestDataStatus('Removing test VIPs...');
try {
const token = localStorage.getItem('authToken');
// First, get all VIPs
const response = await apiCall('/vips', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to fetch VIPs');
}
const allVips = await response.json();
// Filter test VIPs by organization names
const testOrganizations = getTestOrganizations();
const testVips = allVips.filter((vip: any) => testOrganizations.includes(vip.organization));
let successCount = 0;
let errorCount = 0;
for (const vip of testVips) {
try {
const deleteResponse = await apiCall(`/api/vips/${vip.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (deleteResponse.ok) {
successCount++;
} else {
errorCount++;
console.error(`Failed to delete VIP: ${vip.name}`);
}
} catch (error) {
errorCount++;
console.error(`Error deleting VIP ${vip.name}:`, error);
}
}
setTestDataStatus(`🗑️ Removed ${successCount} test VIPs successfully! ${errorCount > 0 ? `(${errorCount} failed)` : ''}`);
} catch (error) {
setTestDataStatus('❌ Failed to remove test VIPs');
console.error('Error removing test VIPs:', error);
} finally {
setTestDataLoading(false);
setTimeout(() => setTestDataStatus(null), 5000);
}
};
if (!isAuthenticated) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md border border-slate-200/60">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-amber-500 to-orange-600 rounded-full flex items-center justify-center mx-auto mb-4">
<div className="w-8 h-8 bg-white rounded-full flex items-center justify-center">
<div className="w-4 h-4 bg-amber-500 rounded-full"></div>
</div>
</div>
<h2 className="text-2xl font-bold text-slate-800">Admin Login</h2>
<p className="text-slate-600 mt-2">Enter your admin password to continue</p>
</div>
<form onSubmit={handleLogin} className="space-y-6">
<div className="form-group">
<label htmlFor="password" className="form-label">Admin Password</label>
<input
type="password"
id="password"
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
className="form-input"
placeholder="Enter admin password"
required
/>
</div>
<button type="submit" className="btn btn-primary w-full">
Login
</button>
</form>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="bg-white rounded-2xl shadow-lg p-8 border border-slate-200/60">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent">
Admin Dashboard
</h1>
<p className="text-slate-600 mt-2">System configuration and API management</p>
</div>
<div className="flex items-center space-x-4">
<button
className="btn btn-secondary"
onClick={() => navigate('/')}
>
Back to Dashboard
</button>
<button
className="btn btn-danger"
onClick={handleLogout}
>
Logout
</button>
</div>
</div>
</div>
{/* API Keys Section */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800">API Key Management</h2>
<p className="text-slate-600 mt-1">Configure external service integrations</p>
</div>
<div className="p-8 space-y-8">
{/* AviationStack API */}
<div className="form-section">
<div className="form-section-header">
<h3 className="form-section-title">AviationStack API</h3>
{savedKeys.aviationStackKey && (
<span className="bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
Configured
</span>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 items-end">
<div className="lg:col-span-2">
<label className="form-label">API Key</label>
<div className="relative">
<input
type={showKeys.aviationStackKey ? 'text' : 'password'}
placeholder={savedKeys.aviationStackKey ? 'Key saved (enter new key to update)' : 'Enter AviationStack API key'}
value={apiKeys.aviationStackKey || ''}
onChange={(e) => handleApiKeyChange('aviationStackKey', e.target.value)}
className="form-input pr-12"
/>
{savedKeys.aviationStackKey && (
<button
type="button"
onClick={() => setShowKeys(prev => ({ ...prev, aviationStackKey: !prev.aviationStackKey }))}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.aviationStackKey ? 'Hide' : 'Show'}
</button>
)}
</div>
<p className="text-xs text-slate-500 mt-1">
Get your key from: https://aviationstack.com/dashboard
</p>
</div>
<div>
<button
className="btn btn-secondary w-full"
onClick={() => testApiConnection('aviationStackKey')}
>
Test Connection
</button>
</div>
<div>
{testResults.aviationStackKey && (
<div className={`p-3 rounded-lg text-sm ${
testResults.aviationStackKey.includes('Success')
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}>
{testResults.aviationStackKey}
</div>
)}
</div>
</div>
</div>
{/* Google OAuth Credentials */}
<div className="form-section">
<div className="form-section-header">
<h3 className="form-section-title">Google OAuth Credentials</h3>
{(savedKeys.googleClientId && savedKeys.googleClientSecret) && (
<span className="bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
Configured
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="form-group">
<label className="form-label">Client ID</label>
<div className="relative">
<input
type={showKeys.googleClientId ? 'text' : 'password'}
placeholder={savedKeys.googleClientId ? 'Client ID saved' : 'Enter Google OAuth Client ID'}
value={apiKeys.googleClientId || ''}
onChange={(e) => handleApiKeyChange('googleClientId', e.target.value)}
className="form-input pr-12"
/>
{savedKeys.googleClientId && (
<button
type="button"
onClick={() => setShowKeys(prev => ({ ...prev, googleClientId: !prev.googleClientId }))}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.googleClientId ? 'Hide' : 'Show'}
</button>
)}
</div>
</div>
<div className="form-group">
<label className="form-label">Client Secret</label>
<div className="relative">
<input
type={showKeys.googleClientSecret ? 'text' : 'password'}
placeholder={savedKeys.googleClientSecret ? 'Client Secret saved' : 'Enter Google OAuth Client Secret'}
value={apiKeys.googleClientSecret || ''}
onChange={(e) => handleApiKeyChange('googleClientSecret', e.target.value)}
className="form-input pr-12"
/>
{savedKeys.googleClientSecret && (
<button
type="button"
onClick={() => setShowKeys(prev => ({ ...prev, googleClientSecret: !prev.googleClientSecret }))}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.googleClientSecret ? 'Hide' : 'Show'}
</button>
)}
</div>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-4">
<h4 className="font-semibold text-blue-900 mb-2">Setup Instructions</h4>
<ol className="text-sm text-blue-800 space-y-1 list-decimal list-inside">
<li>Go to Google Cloud Console</li>
<li>Create or select a project</li>
<li>Enable the Google+ API</li>
<li>Go to "Credentials" "Create Credentials" "OAuth 2.0 Client IDs"</li>
<li>Set authorized redirect URI: https://your-domain.com/auth/google/callback</li>
<li>Set authorized JavaScript origins: https://your-domain.com</li>
</ol>
</div>
</div>
{/* Future APIs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 opacity-50">
<div className="form-section">
<div className="form-section-header">
<h3 className="form-section-title">Google Maps API</h3>
<span className="bg-gray-100 text-gray-600 text-xs font-medium px-2.5 py-0.5 rounded-full">
Coming Soon
</span>
</div>
<input
type="password"
placeholder="Google Maps API key (not yet implemented)"
disabled
className="form-input"
/>
</div>
<div className="form-section">
<div className="form-section-header">
<h3 className="form-section-title">Twilio API</h3>
<span className="bg-gray-100 text-gray-600 text-xs font-medium px-2.5 py-0.5 rounded-full">
Coming Soon
</span>
</div>
<input
type="password"
placeholder="Twilio API key (not yet implemented)"
disabled
className="form-input"
/>
</div>
</div>
</div>
</div>
{/* System Settings Section */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-green-50 to-emerald-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800">System Settings</h2>
<p className="text-slate-600 mt-1">Configure default system behavior</p>
</div>
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="form-group">
<label htmlFor="defaultPickup" className="form-label">Default Pickup Location</label>
<input
type="text"
id="defaultPickup"
value={systemSettings.defaultPickupLocation || ''}
onChange={(e) => handleSettingChange('defaultPickupLocation', e.target.value)}
placeholder="e.g., JFK Airport Terminal 4"
className="form-input"
/>
</div>
<div className="form-group">
<label htmlFor="defaultDropoff" className="form-label">Default Dropoff Location</label>
<input
type="text"
id="defaultDropoff"
value={systemSettings.defaultDropoffLocation || ''}
onChange={(e) => handleSettingChange('defaultDropoffLocation', e.target.value)}
placeholder="e.g., Hilton Downtown"
className="form-input"
/>
</div>
<div className="form-group">
<label htmlFor="timezone" className="form-label">Time Zone</label>
<select
id="timezone"
value={systemSettings.timeZone || 'America/New_York'}
onChange={(e) => handleSettingChange('timeZone', e.target.value)}
className="form-select"
>
<option value="America/New_York">Eastern Time</option>
<option value="America/Chicago">Central Time</option>
<option value="America/Denver">Mountain Time</option>
<option value="America/Los_Angeles">Pacific Time</option>
<option value="UTC">UTC</option>
</select>
</div>
<div className="form-group">
<div className="checkbox-option">
<input
type="checkbox"
checked={systemSettings.notificationsEnabled || false}
onChange={(e) => handleSettingChange('notificationsEnabled', e.target.checked)}
className="form-checkbox mr-3"
/>
<span className="font-medium">Enable Email/SMS Notifications</span>
</div>
</div>
</div>
</div>
</div>
{/* Test VIP Data Section */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-orange-50 to-red-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800">Test VIP Data Management</h2>
<p className="text-slate-600 mt-1">Create and manage test VIP data for application testing</p>
</div>
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
<h3 className="text-lg font-bold text-slate-800 mb-3">Create Test VIPs</h3>
<p className="text-slate-600 mb-4">
Generate 20 diverse test VIPs (10 Admin department, 10 Office of Development) with realistic data including flights, transport modes, and special requirements.
</p>
<ul className="text-sm text-slate-600 mb-4 space-y-1">
<li> Mixed flight and self-driving transport modes</li>
<li> Single flights, connecting flights, and multi-segment journeys</li>
<li> Diverse organizations and special requirements</li>
<li> Realistic arrival dates (tomorrow and day after)</li>
</ul>
<button
className="btn btn-success w-full"
onClick={createTestVips}
disabled={testDataLoading}
>
{testDataLoading ? (
<>
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full mr-2"></span>
Creating Test VIPs...
</>
) : (
'🎭 Create 20 Test VIPs'
)}
</button>
</div>
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
<h3 className="text-lg font-bold text-slate-800 mb-3">Remove Test VIPs</h3>
<p className="text-slate-600 mb-4">
Remove all test VIPs from the system. This will delete VIPs from the following test organizations:
</p>
<div className="text-xs text-slate-500 mb-4 max-h-20 overflow-y-auto">
<div className="grid grid-cols-1 gap-1">
{getTestOrganizations().slice(0, 8).map(org => (
<div key={org}> {org}</div>
))}
<div className="text-slate-400">... and 12 more organizations</div>
</div>
</div>
<button
className="btn btn-danger w-full"
onClick={removeTestVips}
disabled={testDataLoading}
>
{testDataLoading ? (
<>
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full mr-2"></span>
Removing Test VIPs...
</>
) : (
'🗑️ Remove All Test VIPs'
)}
</button>
</div>
</div>
{testDataStatus && (
<div className={`mt-6 p-4 rounded-lg text-center font-medium ${
testDataStatus.includes('✅') || testDataStatus.includes('🗑️')
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}>
{testDataStatus}
</div>
)}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-6">
<h4 className="font-semibold text-blue-900 mb-2">💡 Test Data Details</h4>
<div className="text-sm text-blue-800 space-y-1">
<p><strong>Admin Department (10 VIPs):</strong> University officials, ambassadors, ministers, and executives</p>
<p><strong>Office of Development (10 VIPs):</strong> Donors, foundation leaders, and philanthropists</p>
<p><strong>Transport Modes:</strong> Mix of flights (single, connecting, multi-segment) and self-driving</p>
<p><strong>Special Requirements:</strong> Dietary restrictions, accessibility needs, security details, interpreters</p>
<p><strong>Full Day Schedules:</strong> Each VIP gets 5-7 realistic events including meetings, meals, tours, and presentations</p>
<p><strong>Schedule Types:</strong> Airport pickup, welcome breakfast, department meetings, working lunches, campus tours, receptions</p>
</div>
</div>
</div>
</div>
{/* API Documentation Section */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-purple-50 to-pink-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800">API Documentation</h2>
<p className="text-slate-600 mt-1">Developer resources and API testing</p>
</div>
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
<h3 className="text-lg font-bold text-slate-800 mb-3">Interactive API Documentation</h3>
<p className="text-slate-600 mb-4">
Explore and test all API endpoints with the interactive Swagger UI documentation.
</p>
<button
className="btn btn-primary w-full mb-2"
onClick={() => window.open('http://localhost:3000/api-docs.html', '_blank')}
>
Open API Documentation
</button>
<p className="text-xs text-slate-500">
Opens in a new tab with full endpoint documentation and testing capabilities
</p>
</div>
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
<h3 className="text-lg font-bold text-slate-800 mb-3">Quick API Examples</h3>
<div className="space-y-2 text-sm">
<div>
<span className="font-medium">Health Check:</span>
<code className="ml-2 bg-white px-2 py-1 rounded text-xs">GET /api/health</code>
</div>
<div>
<span className="font-medium">Get VIPs:</span>
<code className="ml-2 bg-white px-2 py-1 rounded text-xs">GET /api/vips</code>
</div>
<div>
<span className="font-medium">Get Drivers:</span>
<code className="ml-2 bg-white px-2 py-1 rounded text-xs">GET /api/drivers</code>
</div>
<div>
<span className="font-medium">Flight Info:</span>
<code className="ml-2 bg-white px-2 py-1 rounded text-xs">GET /api/flights/UA1234</code>
</div>
</div>
<button
className="btn btn-secondary w-full mt-4"
onClick={() => window.open('/README-API.md', '_blank')}
>
View API Guide
</button>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mt-6">
<p className="text-amber-800">
<strong>Pro Tip:</strong> The interactive documentation allows you to test API endpoints directly in your browser.
Perfect for developers integrating with the VIP Coordinator system!
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="text-center">
<button
className="btn btn-success text-lg px-8 py-4"
onClick={saveSettings}
disabled={loading}
>
{loading ? 'Saving...' : 'Save All Settings'}
</button>
{saveStatus && (
<div className={`mt-4 p-4 rounded-lg ${
saveStatus.includes('successfully')
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}>
{saveStatus}
</div>
)}
</div>
</div>
);
};
export default AdminDashboard;

View File

@@ -0,0 +1,801 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiCall } from '../utils/api';
import { generateTestVips, getTestOrganizations, generateVipSchedule } from '../utils/testVipData';
interface User {
id: string;
email: string;
name: string;
role: string;
}
interface ApiKeys {
aviationStackKey?: string;
googleMapsKey?: string;
twilioKey?: string;
googleClientId?: string;
googleClientSecret?: string;
}
interface SystemSettings {
defaultPickupLocation?: string;
defaultDropoffLocation?: string;
timeZone?: string;
notificationsEnabled?: boolean;
}
const AdminDashboard: React.FC = () => {
const navigate = useNavigate();
const [user, setUser] = useState<User | null>(null);
const [apiKeys, setApiKeys] = useState<ApiKeys>({});
const [systemSettings, setSystemSettings] = useState<SystemSettings>({});
const [testResults, setTestResults] = useState<{ [key: string]: string }>({});
const [loading, setLoading] = useState(false);
const [saveStatus, setSaveStatus] = useState<string | null>(null);
const [showKeys, setShowKeys] = useState<{ [key: string]: boolean }>({});
const [savedKeys, setSavedKeys] = useState<{ [key: string]: boolean }>({});
const [testDataLoading, setTestDataLoading] = useState(false);
const [testDataStatus, setTestDataStatus] = useState<string | null>(null);
useEffect(() => {
// Check if user is authenticated and has admin role
const authToken = localStorage.getItem('authToken');
const userData = localStorage.getItem('user');
if (!authToken || !userData) {
navigate('/');
return;
}
const parsedUser = JSON.parse(userData);
if (parsedUser.role !== 'administrator' && parsedUser.role !== 'coordinator') {
navigate('/dashboard');
return;
}
setUser(parsedUser);
loadSettings();
}, [navigate]);
const loadSettings = async () => {
try {
const response = await apiCall('/admin/settings');
if (response.ok) {
const data = await response.json();
// Track which keys are already saved (masked keys start with ***)
const saved: { [key: string]: boolean } = {};
if (data.apiKeys) {
Object.entries(data.apiKeys).forEach(([key, value]) => {
if (value && (value as string).startsWith('***')) {
saved[key] = true;
}
});
}
setSavedKeys(saved);
// Don't load masked keys as actual values - keep them empty
const cleanedApiKeys: ApiKeys = {};
if (data.apiKeys) {
Object.entries(data.apiKeys).forEach(([key, value]) => {
// Only set the value if it's not a masked key
if (value && !(value as string).startsWith('***')) {
cleanedApiKeys[key as keyof ApiKeys] = value as string;
}
});
}
setApiKeys(cleanedApiKeys);
setSystemSettings(data.systemSettings || {});
}
} catch (error) {
console.error('Failed to load settings:', error);
}
};
const handleApiKeyChange = (key: keyof ApiKeys, value: string) => {
setApiKeys(prev => ({ ...prev, [key]: value }));
// If user is typing a new key, mark it as not saved anymore
if (value && !value.startsWith('***')) {
setSavedKeys(prev => ({ ...prev, [key]: false }));
}
};
const handleSettingChange = (key: keyof SystemSettings, value: any) => {
setSystemSettings(prev => ({ ...prev, [key]: value }));
};
const testApiConnection = async (apiType: string) => {
setTestResults(prev => ({ ...prev, [apiType]: 'Testing...' }));
try {
const response = await fetch(`/api/admin/test-api/${apiType}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
},
body: JSON.stringify({
apiKey: apiKeys[apiType as keyof ApiKeys]
})
});
const result = await response.json();
if (response.ok) {
setTestResults(prev => ({
...prev,
[apiType]: `Success: ${result.message}`
}));
} else {
setTestResults(prev => ({
...prev,
[apiType]: `Failed: ${result.error}`
}));
}
} catch (error) {
setTestResults(prev => ({
...prev,
[apiType]: 'Connection error'
}));
}
};
const saveSettings = async () => {
setLoading(true);
setSaveStatus(null);
try {
const response = await fetch('/api/admin/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
},
body: JSON.stringify({
apiKeys,
systemSettings
})
});
if (response.ok) {
setSaveStatus('Settings saved successfully!');
// Mark keys as saved if they have values
const newSavedKeys: { [key: string]: boolean } = {};
Object.entries(apiKeys).forEach(([key, value]) => {
if (value && !value.startsWith('***')) {
newSavedKeys[key] = true;
}
});
setSavedKeys(prev => ({ ...prev, ...newSavedKeys }));
// Clear the input fields after successful save
setApiKeys({});
setTimeout(() => setSaveStatus(null), 3000);
} else {
setSaveStatus('Failed to save settings');
}
} catch (error) {
setSaveStatus('Error saving settings');
} finally {
setLoading(false);
}
};
const handleLogout = () => {
sessionStorage.removeItem('adminAuthenticated');
setIsAuthenticated(false);
navigate('/');
};
// Test VIP functions
const createTestVips = async () => {
setTestDataLoading(true);
setTestDataStatus('Creating test VIPs and schedules...');
try {
const token = localStorage.getItem('authToken');
const testVips = generateTestVips();
let vipSuccessCount = 0;
let vipErrorCount = 0;
let scheduleSuccessCount = 0;
let scheduleErrorCount = 0;
const createdVipIds: string[] = [];
// First, create all VIPs
for (const vipData of testVips) {
try {
const response = await apiCall('/vips', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(vipData),
});
if (response.ok) {
const createdVip = await response.json();
createdVipIds.push(createdVip.id);
vipSuccessCount++;
} else {
vipErrorCount++;
console.error(`Failed to create VIP: ${vipData.name}`);
}
} catch (error) {
vipErrorCount++;
console.error(`Error creating VIP ${vipData.name}:`, error);
}
}
setTestDataStatus(`Created ${vipSuccessCount} VIPs, now creating schedules...`);
// Then, create schedules for each successfully created VIP
for (let i = 0; i < createdVipIds.length; i++) {
const vipId = createdVipIds[i];
const vipData = testVips[i];
try {
const scheduleEvents = generateVipSchedule(vipData.department, vipData.transportMode);
for (const event of scheduleEvents) {
try {
const eventWithId = {
...event,
id: Date.now().toString() + Math.random().toString(36).substr(2, 9)
};
const scheduleResponse = await apiCall(`/api/vips/${vipId}/schedule`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(eventWithId),
});
if (scheduleResponse.ok) {
scheduleSuccessCount++;
} else {
scheduleErrorCount++;
console.error(`Failed to create schedule event for ${vipData.name}: ${event.title}`);
}
} catch (error) {
scheduleErrorCount++;
console.error(`Error creating schedule event for ${vipData.name}:`, error);
}
}
} catch (error) {
console.error(`Error generating schedule for ${vipData.name}:`, error);
}
}
setTestDataStatus(`✅ Created ${vipSuccessCount} VIPs with ${scheduleSuccessCount} schedule events! ${vipErrorCount > 0 || scheduleErrorCount > 0 ? `(${vipErrorCount + scheduleErrorCount} failed)` : ''}`);
} catch (error) {
setTestDataStatus('❌ Failed to create test VIPs and schedules');
console.error('Error creating test data:', error);
} finally {
setTestDataLoading(false);
setTimeout(() => setTestDataStatus(null), 8000);
}
};
const removeTestVips = async () => {
if (!confirm('Are you sure you want to remove all test VIPs? This will delete VIPs from the test organizations.')) {
return;
}
setTestDataLoading(true);
setTestDataStatus('Removing test VIPs...');
try {
const token = localStorage.getItem('authToken');
// First, get all VIPs
const response = await apiCall('/vips', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to fetch VIPs');
}
const allVips = await response.json();
// Filter test VIPs by organization names
const testOrganizations = getTestOrganizations();
const testVips = allVips.filter((vip: any) => testOrganizations.includes(vip.organization));
let successCount = 0;
let errorCount = 0;
for (const vip of testVips) {
try {
const deleteResponse = await apiCall(`/api/vips/${vip.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (deleteResponse.ok) {
successCount++;
} else {
errorCount++;
console.error(`Failed to delete VIP: ${vip.name}`);
}
} catch (error) {
errorCount++;
console.error(`Error deleting VIP ${vip.name}:`, error);
}
}
setTestDataStatus(`🗑️ Removed ${successCount} test VIPs successfully! ${errorCount > 0 ? `(${errorCount} failed)` : ''}`);
} catch (error) {
setTestDataStatus('❌ Failed to remove test VIPs');
console.error('Error removing test VIPs:', error);
} finally {
setTestDataLoading(false);
setTimeout(() => setTestDataStatus(null), 5000);
}
};
if (!user) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-amber-500 border-t-transparent mx-auto"></div>
<p className="mt-4 text-slate-600">Loading...</p>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="bg-white rounded-2xl shadow-lg p-8 border border-slate-200/60">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent">
Admin Dashboard
</h1>
<p className="text-slate-600 mt-2">System configuration and API management</p>
</div>
<div className="flex items-center space-x-4">
<button
className="btn btn-secondary"
onClick={() => navigate('/')}
>
Back to Dashboard
</button>
<button
className="btn btn-danger"
onClick={handleLogout}
>
Logout
</button>
</div>
</div>
</div>
{/* API Keys Section */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800">API Key Management</h2>
<p className="text-slate-600 mt-1">Configure external service integrations</p>
</div>
<div className="p-8 space-y-8">
{/* AviationStack API */}
<div className="form-section">
<div className="form-section-header">
<h3 className="form-section-title">AviationStack API</h3>
{savedKeys.aviationStackKey && (
<span className="bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
Configured
</span>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 items-end">
<div className="lg:col-span-2">
<label className="form-label">API Key</label>
<div className="relative">
<input
type={showKeys.aviationStackKey ? 'text' : 'password'}
placeholder={savedKeys.aviationStackKey ? 'Key saved (enter new key to update)' : 'Enter AviationStack API key'}
value={apiKeys.aviationStackKey || ''}
onChange={(e) => handleApiKeyChange('aviationStackKey', e.target.value)}
className="form-input pr-12"
/>
{savedKeys.aviationStackKey && (
<button
type="button"
onClick={() => setShowKeys(prev => ({ ...prev, aviationStackKey: !prev.aviationStackKey }))}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.aviationStackKey ? 'Hide' : 'Show'}
</button>
)}
</div>
<p className="text-xs text-slate-500 mt-1">
Get your key from: https://aviationstack.com/dashboard
</p>
</div>
<div>
<button
className="btn btn-secondary w-full"
onClick={() => testApiConnection('aviationStackKey')}
>
Test Connection
</button>
</div>
<div>
{testResults.aviationStackKey && (
<div className={`p-3 rounded-lg text-sm ${
testResults.aviationStackKey.includes('Success')
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}>
{testResults.aviationStackKey}
</div>
)}
</div>
</div>
</div>
{/* Google OAuth Credentials */}
<div className="form-section">
<div className="form-section-header">
<h3 className="form-section-title">Google OAuth Credentials</h3>
{(savedKeys.googleClientId && savedKeys.googleClientSecret) && (
<span className="bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
Configured
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="form-group">
<label className="form-label">Client ID</label>
<div className="relative">
<input
type={showKeys.googleClientId ? 'text' : 'password'}
placeholder={savedKeys.googleClientId ? 'Client ID saved' : 'Enter Google OAuth Client ID'}
value={apiKeys.googleClientId || ''}
onChange={(e) => handleApiKeyChange('googleClientId', e.target.value)}
className="form-input pr-12"
/>
{savedKeys.googleClientId && (
<button
type="button"
onClick={() => setShowKeys(prev => ({ ...prev, googleClientId: !prev.googleClientId }))}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.googleClientId ? 'Hide' : 'Show'}
</button>
)}
</div>
</div>
<div className="form-group">
<label className="form-label">Client Secret</label>
<div className="relative">
<input
type={showKeys.googleClientSecret ? 'text' : 'password'}
placeholder={savedKeys.googleClientSecret ? 'Client Secret saved' : 'Enter Google OAuth Client Secret'}
value={apiKeys.googleClientSecret || ''}
onChange={(e) => handleApiKeyChange('googleClientSecret', e.target.value)}
className="form-input pr-12"
/>
{savedKeys.googleClientSecret && (
<button
type="button"
onClick={() => setShowKeys(prev => ({ ...prev, googleClientSecret: !prev.googleClientSecret }))}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.googleClientSecret ? 'Hide' : 'Show'}
</button>
)}
</div>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-4">
<h4 className="font-semibold text-blue-900 mb-2">Setup Instructions</h4>
<ol className="text-sm text-blue-800 space-y-1 list-decimal list-inside">
<li>Go to Google Cloud Console</li>
<li>Create or select a project</li>
<li>Enable the Google+ API</li>
<li>Go to "Credentials" "Create Credentials" "OAuth 2.0 Client IDs"</li>
<li>Set authorized redirect URI: https://your-domain.com/auth/google/callback</li>
<li>Set authorized JavaScript origins: https://your-domain.com</li>
</ol>
</div>
</div>
{/* Future APIs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 opacity-50">
<div className="form-section">
<div className="form-section-header">
<h3 className="form-section-title">Google Maps API</h3>
<span className="bg-gray-100 text-gray-600 text-xs font-medium px-2.5 py-0.5 rounded-full">
Coming Soon
</span>
</div>
<input
type="password"
placeholder="Google Maps API key (not yet implemented)"
disabled
className="form-input"
/>
</div>
<div className="form-section">
<div className="form-section-header">
<h3 className="form-section-title">Twilio API</h3>
<span className="bg-gray-100 text-gray-600 text-xs font-medium px-2.5 py-0.5 rounded-full">
Coming Soon
</span>
</div>
<input
type="password"
placeholder="Twilio API key (not yet implemented)"
disabled
className="form-input"
/>
</div>
</div>
</div>
</div>
{/* System Settings Section */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-green-50 to-emerald-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800">System Settings</h2>
<p className="text-slate-600 mt-1">Configure default system behavior</p>
</div>
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="form-group">
<label htmlFor="defaultPickup" className="form-label">Default Pickup Location</label>
<input
type="text"
id="defaultPickup"
value={systemSettings.defaultPickupLocation || ''}
onChange={(e) => handleSettingChange('defaultPickupLocation', e.target.value)}
placeholder="e.g., JFK Airport Terminal 4"
className="form-input"
/>
</div>
<div className="form-group">
<label htmlFor="defaultDropoff" className="form-label">Default Dropoff Location</label>
<input
type="text"
id="defaultDropoff"
value={systemSettings.defaultDropoffLocation || ''}
onChange={(e) => handleSettingChange('defaultDropoffLocation', e.target.value)}
placeholder="e.g., Hilton Downtown"
className="form-input"
/>
</div>
<div className="form-group">
<label htmlFor="timezone" className="form-label">Time Zone</label>
<select
id="timezone"
value={systemSettings.timeZone || 'America/New_York'}
onChange={(e) => handleSettingChange('timeZone', e.target.value)}
className="form-select"
>
<option value="America/New_York">Eastern Time</option>
<option value="America/Chicago">Central Time</option>
<option value="America/Denver">Mountain Time</option>
<option value="America/Los_Angeles">Pacific Time</option>
<option value="UTC">UTC</option>
</select>
</div>
<div className="form-group">
<div className="checkbox-option">
<input
type="checkbox"
checked={systemSettings.notificationsEnabled || false}
onChange={(e) => handleSettingChange('notificationsEnabled', e.target.checked)}
className="form-checkbox mr-3"
/>
<span className="font-medium">Enable Email/SMS Notifications</span>
</div>
</div>
</div>
</div>
</div>
{/* Test VIP Data Section */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-orange-50 to-red-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800">Test VIP Data Management</h2>
<p className="text-slate-600 mt-1">Create and manage test VIP data for application testing</p>
</div>
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
<h3 className="text-lg font-bold text-slate-800 mb-3">Create Test VIPs</h3>
<p className="text-slate-600 mb-4">
Generate 20 diverse test VIPs (10 Admin department, 10 Office of Development) with realistic data including flights, transport modes, and special requirements.
</p>
<ul className="text-sm text-slate-600 mb-4 space-y-1">
<li> Mixed flight and self-driving transport modes</li>
<li> Single flights, connecting flights, and multi-segment journeys</li>
<li> Diverse organizations and special requirements</li>
<li> Realistic arrival dates (tomorrow and day after)</li>
</ul>
<button
className="btn btn-success w-full"
onClick={createTestVips}
disabled={testDataLoading}
>
{testDataLoading ? (
<>
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full mr-2"></span>
Creating Test VIPs...
</>
) : (
'🎭 Create 20 Test VIPs'
)}
</button>
</div>
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
<h3 className="text-lg font-bold text-slate-800 mb-3">Remove Test VIPs</h3>
<p className="text-slate-600 mb-4">
Remove all test VIPs from the system. This will delete VIPs from the following test organizations:
</p>
<div className="text-xs text-slate-500 mb-4 max-h-20 overflow-y-auto">
<div className="grid grid-cols-1 gap-1">
{getTestOrganizations().slice(0, 8).map(org => (
<div key={org}> {org}</div>
))}
<div className="text-slate-400">... and 12 more organizations</div>
</div>
</div>
<button
className="btn btn-danger w-full"
onClick={removeTestVips}
disabled={testDataLoading}
>
{testDataLoading ? (
<>
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full mr-2"></span>
Removing Test VIPs...
</>
) : (
'🗑️ Remove All Test VIPs'
)}
</button>
</div>
</div>
{testDataStatus && (
<div className={`mt-6 p-4 rounded-lg text-center font-medium ${
testDataStatus.includes('✅') || testDataStatus.includes('🗑️')
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}>
{testDataStatus}
</div>
)}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-6">
<h4 className="font-semibold text-blue-900 mb-2">💡 Test Data Details</h4>
<div className="text-sm text-blue-800 space-y-1">
<p><strong>Admin Department (10 VIPs):</strong> University officials, ambassadors, ministers, and executives</p>
<p><strong>Office of Development (10 VIPs):</strong> Donors, foundation leaders, and philanthropists</p>
<p><strong>Transport Modes:</strong> Mix of flights (single, connecting, multi-segment) and self-driving</p>
<p><strong>Special Requirements:</strong> Dietary restrictions, accessibility needs, security details, interpreters</p>
<p><strong>Full Day Schedules:</strong> Each VIP gets 5-7 realistic events including meetings, meals, tours, and presentations</p>
<p><strong>Schedule Types:</strong> Airport pickup, welcome breakfast, department meetings, working lunches, campus tours, receptions</p>
</div>
</div>
</div>
</div>
{/* API Documentation Section */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-purple-50 to-pink-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800">API Documentation</h2>
<p className="text-slate-600 mt-1">Developer resources and API testing</p>
</div>
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
<h3 className="text-lg font-bold text-slate-800 mb-3">Interactive API Documentation</h3>
<p className="text-slate-600 mb-4">
Explore and test all API endpoints with the interactive Swagger UI documentation.
</p>
<button
className="btn btn-primary w-full mb-2"
onClick={() => window.open('http://localhost:3000/api-docs.html', '_blank')}
>
Open API Documentation
</button>
<p className="text-xs text-slate-500">
Opens in a new tab with full endpoint documentation and testing capabilities
</p>
</div>
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
<h3 className="text-lg font-bold text-slate-800 mb-3">Quick API Examples</h3>
<div className="space-y-2 text-sm">
<div>
<span className="font-medium">Health Check:</span>
<code className="ml-2 bg-white px-2 py-1 rounded text-xs">GET /api/health</code>
</div>
<div>
<span className="font-medium">Get VIPs:</span>
<code className="ml-2 bg-white px-2 py-1 rounded text-xs">GET /api/vips</code>
</div>
<div>
<span className="font-medium">Get Drivers:</span>
<code className="ml-2 bg-white px-2 py-1 rounded text-xs">GET /api/drivers</code>
</div>
<div>
<span className="font-medium">Flight Info:</span>
<code className="ml-2 bg-white px-2 py-1 rounded text-xs">GET /api/flights/UA1234</code>
</div>
</div>
<button
className="btn btn-secondary w-full mt-4"
onClick={() => window.open('/README-API.md', '_blank')}
>
View API Guide
</button>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mt-6">
<p className="text-amber-800">
<strong>Pro Tip:</strong> The interactive documentation allows you to test API endpoints directly in your browser.
Perfect for developers integrating with the VIP Coordinator system!
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="text-center">
<button
className="btn btn-success text-lg px-8 py-4"
onClick={saveSettings}
disabled={loading}
>
{loading ? 'Saving...' : 'Save All Settings'}
</button>
{saveStatus && (
<div className={`mt-4 p-4 rounded-lg ${
saveStatus.includes('successfully')
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}>
{saveStatus}
</div>
)}
</div>
</div>
);
};
export default AdminDashboard;

View File

@@ -0,0 +1,390 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useKeycloak } from '../contexts/KeycloakContext';
import { apiCall } from '../config/api';
interface ScheduleEvent {
id: string;
title: string;
location: string;
startTime: string;
endTime: string;
status: 'scheduled' | 'in-progress' | 'completed' | 'cancelled';
type: 'transport' | 'meeting' | 'event' | 'meal' | 'accommodation';
}
interface Vip {
id: string;
name: string;
organization: string;
transportMode: 'flight' | 'self-driving';
flightNumber?: string;
flights?: Array<{
flightNumber: string;
flightDate: string;
segment: number;
}>;
expectedArrival?: string;
arrivalTime?: string;
needsAirportPickup?: boolean;
needsVenueTransport: boolean;
notes?: string;
currentEvent?: ScheduleEvent;
nextEvent?: ScheduleEvent;
nextEventTime?: string;
}
interface Driver {
id: string;
name: string;
phone: string;
currentLocation: { lat: number; lng: number };
assignedVipIds: string[];
}
const Dashboard: React.FC = () => {
const { authenticated, getToken } = useKeycloak();
const [vips, setVips] = useState<Vip[]>([]);
const [drivers, setDrivers] = useState<Driver[]>([]);
const [loading, setLoading] = useState(true);
// Helper functions for event management
const getCurrentEvent = (events: ScheduleEvent[]) => {
const now = new Date();
return events.find(event =>
new Date(event.startTime) <= now &&
new Date(event.endTime) > now &&
event.status === 'in-progress'
) || null;
};
const getNextEvent = (events: ScheduleEvent[]) => {
const now = new Date();
const upcomingEvents = events.filter(event =>
new Date(event.startTime) > now && event.status === 'scheduled'
).sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
return upcomingEvents.length > 0 ? upcomingEvents[0] : null;
};
const formatTime = (timeString: string) => {
return new Date(timeString).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
useEffect(() => {
const fetchData = async () => {
if (!authenticated) {
setLoading(false);
return;
}
try {
const token = await getToken();
if (!token) {
setLoading(false);
return;
}
const authHeaders = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
const [vipsResponse, driversResponse] = await Promise.all([
apiCall('/vips', { headers: authHeaders }),
apiCall('/drivers', { headers: authHeaders })
]);
if (!vipsResponse.ok || !driversResponse.ok) {
throw new Error('Failed to fetch data');
}
const vipsData = await vipsResponse.json();
const driversData = await driversResponse.json();
// Fetch schedule for each VIP and determine current/next events
const vipsWithSchedules = await Promise.all(
vipsData.map(async (vip: Vip) => {
try {
const scheduleResponse = await apiCall(`/vips/${vip.id}/schedule`, {
headers: authHeaders
});
if (scheduleResponse.ok) {
const scheduleData = await scheduleResponse.json();
const currentEvent = getCurrentEvent(scheduleData);
const nextEvent = getNextEvent(scheduleData);
return {
...vip,
currentEvent,
nextEvent,
nextEventTime: nextEvent ? nextEvent.startTime : null
};
} else {
return { ...vip, currentEvent: null, nextEvent: null, nextEventTime: null };
}
} catch (error) {
console.error(`Error fetching schedule for VIP ${vip.id}:`, error);
return { ...vip, currentEvent: null, nextEvent: null, nextEventTime: null };
}
})
);
// Sort VIPs by next event time (soonest first), then by name
const sortedVips = vipsWithSchedules.sort((a, b) => {
// VIPs with current events first
if (a.currentEvent && !b.currentEvent) return -1;
if (!a.currentEvent && b.currentEvent) return 1;
// Then by next event time (soonest first)
if (a.nextEventTime && b.nextEventTime) {
return new Date(a.nextEventTime).getTime() - new Date(b.nextEventTime).getTime();
}
if (a.nextEventTime && !b.nextEventTime) return -1;
if (!a.nextEventTime && b.nextEventTime) return 1;
// Finally by name if no events
return a.name.localeCompare(b.name);
});
setVips(sortedVips);
setDrivers(driversData);
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, [authenticated, getToken]);
if (loading) {
return (
<div className="flex justify-center items-center min-h-64">
<div className="bg-white rounded-2xl shadow-lg p-8 flex items-center space-x-4">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<span className="text-lg font-medium text-slate-700">Loading dashboard...</span>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="bg-white rounded-2xl shadow-lg p-8 border border-slate-200/60">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent">
VIP Coordinator Dashboard
</h1>
<p className="text-slate-600 mt-2">Real-time overview of VIP activities and coordination</p>
</div>
<div className="flex items-center space-x-4">
<div className="bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium">
{vips.length} Active VIPs
</div>
<div className="bg-gradient-to-r from-green-500 to-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium">
{drivers.length} Drivers
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* VIP Status Dashboard */}
<div className="xl:col-span-2">
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 px-6 py-4 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800 flex items-center">
VIP Status Dashboard
<span className="ml-2 bg-blue-100 text-blue-800 text-sm font-medium px-2.5 py-0.5 rounded-full">
{vips.length} VIPs
</span>
</h2>
</div>
<div className="p-6">
{vips.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<div className="w-8 h-8 bg-slate-300 rounded-full"></div>
</div>
<p className="text-slate-500 font-medium">No VIPs currently scheduled</p>
</div>
) : (
<div className="space-y-4">
{vips.map((vip) => {
const hasCurrentEvent = !!vip.currentEvent;
const hasNextEvent = !!vip.nextEvent;
return (
<div key={vip.id} className={`
relative rounded-xl border-2 p-6 transition-all duration-200 hover:shadow-lg
${hasCurrentEvent
? 'border-amber-300 bg-gradient-to-r from-amber-50 to-orange-50'
: hasNextEvent
? 'border-blue-300 bg-gradient-to-r from-blue-50 to-indigo-50'
: 'border-slate-200 bg-slate-50'
}
`}>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<h3 className="text-lg font-bold text-slate-900">{vip.name}</h3>
{hasCurrentEvent && (
<span className="bg-gradient-to-r from-amber-500 to-orange-500 text-white px-3 py-1 rounded-full text-xs font-bold animate-pulse">
ACTIVE
</span>
)}
</div>
<p className="text-slate-600 text-sm mb-4">{vip.organization}</p>
{/* Current Event */}
{vip.currentEvent && (
<div className="bg-white border border-amber-200 rounded-lg p-4 mb-3 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<span className="text-amber-600 font-bold text-sm">CURRENT EVENT</span>
</div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-slate-900">{vip.currentEvent.title}</span>
</div>
<p className="text-slate-600 text-sm mb-1">Location: {vip.currentEvent.location}</p>
<p className="text-slate-500 text-xs">Until {formatTime(vip.currentEvent.endTime)}</p>
</div>
)}
{/* Next Event */}
{vip.nextEvent && (
<div className="bg-white border border-blue-200 rounded-lg p-4 mb-3 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<span className="text-blue-600 font-bold text-sm">NEXT EVENT</span>
</div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-slate-900">{vip.nextEvent.title}</span>
</div>
<p className="text-slate-600 text-sm mb-1">Location: {vip.nextEvent.location}</p>
<p className="text-slate-500 text-xs">{formatTime(vip.nextEvent.startTime)} - {formatTime(vip.nextEvent.endTime)}</p>
</div>
)}
{/* No Events */}
{!vip.currentEvent && !vip.nextEvent && (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-3">
<p className="text-slate-500 text-sm italic">No scheduled events</p>
</div>
)}
{/* Transport Info */}
<div className="flex items-center gap-2 text-xs text-slate-500 bg-white/50 rounded-lg px-3 py-2">
{vip.transportMode === 'flight' ? (
<span>Flight: {vip.flights && vip.flights.length > 0 ?
vip.flights.map(f => f.flightNumber).join(' → ') :
vip.flightNumber || 'TBD'}
</span>
) : (
<span>Self-driving | Expected: {vip.expectedArrival ? formatTime(vip.expectedArrival) : 'TBD'}</span>
)}
</div>
</div>
<div className="flex flex-col gap-2 ml-6">
<Link
to={`/vips/${vip.id}`}
className="bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 text-center shadow-lg hover:shadow-xl"
>
Details
</Link>
<Link
to={`/vips/${vip.id}#schedule`}
className="bg-gradient-to-r from-slate-500 to-slate-600 hover:from-slate-600 hover:to-slate-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 text-center shadow-lg hover:shadow-xl"
>
Schedule
</Link>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Drivers Card */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-green-50 to-emerald-50 px-6 py-4 border-b border-slate-200/60">
<h2 className="text-lg font-bold text-slate-800 flex items-center">
Available Drivers
<span className="ml-2 bg-green-100 text-green-800 text-sm font-medium px-2.5 py-0.5 rounded-full">
{drivers.length}
</span>
</h2>
</div>
<div className="p-6">
{drivers.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-3">
<div className="w-6 h-6 bg-slate-300 rounded-full"></div>
</div>
<p className="text-slate-500 text-sm">No drivers available</p>
</div>
) : (
<div className="space-y-3">
{drivers.map((driver) => (
<div key={driver.id} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-slate-900">{driver.name}</h4>
<p className="text-slate-600 text-sm">{driver.phone}</p>
</div>
<div className="text-right">
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">
{driver.assignedVipIds.length} VIPs
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Quick Actions Card */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-purple-50 to-pink-50 px-6 py-4 border-b border-slate-200/60">
<h2 className="text-lg font-bold text-slate-800">Quick Actions</h2>
</div>
<div className="p-6 space-y-3">
<Link
to="/vips"
className="block w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-4 py-3 rounded-lg font-medium transition-all duration-200 text-center shadow-lg hover:shadow-xl"
>
Manage VIPs
</Link>
<Link
to="/drivers"
className="block w-full bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white px-4 py-3 rounded-lg font-medium transition-all duration-200 text-center shadow-lg hover:shadow-xl"
>
Manage Drivers
</Link>
</div>
</div>
</div>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,760 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useAuthToken } from '../hooks/useAuthToken';
import { apiCall } from '../config/api';
import GanttChart from '../components/GanttChart';
interface DriverScheduleEvent {
id: string;
title: string;
location: string;
startTime: string;
endTime: string;
description?: string;
status: 'scheduled' | 'in-progress' | 'completed' | 'cancelled';
type: 'transport' | 'meeting' | 'event' | 'meal' | 'accommodation';
vipId: string;
vipName: string;
}
interface Driver {
id: string;
name: string;
phone: string;
}
interface DriverScheduleData {
driver: Driver;
schedule: DriverScheduleEvent[];
}
const DriverDashboard: React.FC = () => {
const { driverId } = useParams<{ driverId: string }>();
const token = useAuthToken();
const [scheduleData, setScheduleData] = useState<DriverScheduleData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (token && driverId) {
fetchDriverSchedule();
} else if (!token) {
setLoading(false);
}
}, [driverId, token]);
const fetchDriverSchedule = async () => {
if (!token) return;
try {
const response = await apiCall(`/drivers/${driverId}/schedule`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
setScheduleData(data);
} else {
setError('Driver not found');
}
} catch (err) {
setError('Error loading driver schedule');
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'scheduled': return '#3498db';
case 'in-progress': return '#f39c12';
case 'completed': return '#2ecc71';
case 'cancelled': return '#e74c3c';
default: return '#95a5a6';
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'transport': return '🚗';
case 'meeting': return '🤝';
case 'event': return '🎉';
case 'meal': return '🍽️';
case 'accommodation': return '🏨';
default: return '📅';
}
};
const formatTime = (timeString: string) => {
return new Date(timeString).toLocaleString([], {
hour: '2-digit',
minute: '2-digit'
});
};
const getNextEvent = () => {
if (!scheduleData?.schedule) return null;
const now = new Date();
const upcomingEvents = scheduleData.schedule.filter(event =>
new Date(event.startTime) > now && event.status === 'scheduled'
).sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
return upcomingEvents.length > 0 ? upcomingEvents[0] : null;
};
const getCurrentEvent = () => {
if (!scheduleData?.schedule) return null;
const now = new Date();
return scheduleData.schedule.find(event =>
new Date(event.startTime) <= now &&
new Date(event.endTime) > now &&
event.status === 'in-progress'
) || null;
};
const groupEventsByDay = (events: DriverScheduleEvent[]) => {
const grouped: { [key: string]: DriverScheduleEvent[] } = {};
events.forEach(event => {
const date = new Date(event.startTime).toDateString();
if (!grouped[date]) {
grouped[date] = [];
}
grouped[date].push(event);
});
// Sort events within each day by start time
Object.keys(grouped).forEach(date => {
grouped[date].sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
});
return grouped;
};
const handlePrintSchedule = () => {
if (!scheduleData) return;
const printWindow = window.open('', '_blank');
if (!printWindow) return;
const groupedSchedule = groupEventsByDay(scheduleData.schedule);
const printContent = `
<!DOCTYPE html>
<html>
<head>
<title>Driver Schedule - ${scheduleData.driver.name}</title>
<meta charset="UTF-8">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #2d3748;
background: #ffffff;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 40px 30px;
}
.header {
text-align: center;
margin-bottom: 40px;
padding-bottom: 30px;
border-bottom: 3px solid #e2e8f0;
background: linear-gradient(135deg, #e53e3e 0%, #c53030 100%);
color: white;
padding: 40px 30px;
border-radius: 15px;
margin: -40px -30px 40px -30px;
}
.header h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 10px;
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header h2 {
font-size: 1.8rem;
font-weight: 600;
margin-bottom: 20px;
opacity: 0.95;
}
.driver-info {
background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%);
padding: 25px;
border-radius: 12px;
margin-bottom: 30px;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.driver-info p {
margin-bottom: 8px;
font-size: 1rem;
}
.driver-info strong {
color: #4a5568;
font-weight: 600;
}
.day-section {
margin-bottom: 40px;
page-break-inside: avoid;
}
.day-header {
background: linear-gradient(135deg, #e53e3e 0%, #c53030 100%);
color: white;
padding: 20px 25px;
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 20px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
text-align: center;
}
.event {
background: white;
border: 1px solid #e2e8f0;
margin-bottom: 15px;
padding: 25px;
border-radius: 12px;
display: flex;
align-items: flex-start;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.event-time {
min-width: 120px;
background: linear-gradient(135deg, #edf2f7 0%, #e2e8f0 100%);
padding: 15px;
border-radius: 8px;
text-align: center;
margin-right: 25px;
border: 1px solid #cbd5e0;
}
.event-time .time {
font-weight: 700;
font-size: 1rem;
color: #2d3748;
display: block;
}
.event-time .separator {
font-size: 0.8rem;
color: #718096;
margin: 5px 0;
}
.event-details {
flex: 1;
}
.event-title {
font-weight: 700;
font-size: 1.2rem;
margin-bottom: 10px;
color: #2d3748;
display: flex;
align-items: center;
gap: 8px;
}
.event-icon {
font-size: 1.3rem;
}
.event-location {
color: #4a5568;
margin-bottom: 8px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
}
.event-vip {
color: #e53e3e;
font-weight: 600;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.event-description {
background: #f7fafc;
padding: 12px 15px;
border-radius: 8px;
font-style: italic;
color: #4a5568;
margin-bottom: 10px;
border-left: 4px solid #cbd5e0;
}
.footer {
margin-top: 50px;
text-align: center;
color: #718096;
font-size: 0.9rem;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
}
.company-logo {
width: 60px;
height: 60px;
background: linear-gradient(135deg, #e53e3e 0%, #c53030 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
color: white;
font-size: 1.5rem;
font-weight: bold;
}
@media print {
body {
margin: 0;
padding: 0;
}
.container {
padding: 20px;
}
.header {
margin: -20px -20px 30px -20px;
}
.day-section {
page-break-inside: avoid;
}
.event {
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="company-logo">🚗</div>
<h1>Driver Schedule</h1>
<h2>${scheduleData.driver.name}</h2>
</div>
<div class="driver-info">
<p><strong>Driver:</strong> ${scheduleData.driver.name}</p>
<p><strong>Phone:</strong> ${scheduleData.driver.phone}</p>
<p><strong>Total Assignments:</strong> ${scheduleData.schedule.length}</p>
</div>
${Object.entries(groupedSchedule).map(([date, events]) => `
<div class="day-section">
<div class="day-header">
${new Date(date).toLocaleDateString([], {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
${events.map(event => `
<div class="event">
<div class="event-time">
<span class="time">${formatTime(event.startTime)}</span>
<div class="separator">to</div>
<span class="time">${formatTime(event.endTime)}</span>
</div>
<div class="event-details">
<div class="event-title">
<span class="event-icon">${getTypeIcon(event.type)}</span>
${event.title}
</div>
<div class="event-vip">
<span>👤</span>
VIP: ${event.vipName}
</div>
<div class="event-location">
<span>📍</span>
${event.location}
</div>
${event.description ? `<div class="event-description">${event.description}</div>` : ''}
</div>
</div>
`).join('')}
</div>
`).join('')}
<div class="footer">
<p><strong>VIP Coordinator System</strong></p>
<p>Generated on ${new Date().toLocaleDateString([], {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}</p>
</div>
</div>
</body>
</html>
`;
printWindow.document.write(printContent);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
printWindow.close();
}, 250);
};
async function updateEventStatus(eventId: string, status: string) {
if (!scheduleData || !token) return;
// Find the event to get the VIP ID
const event = scheduleData.schedule.find(e => e.id === eventId);
if (!event) return;
try {
const response = await apiCall(`/vips/${event.vipId}/schedule/${eventId}/status`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ status }),
});
if (response.ok) {
await fetchDriverSchedule(); // Refresh the schedule
}
} catch (error) {
console.error('Error updating event status:', error);
}
}
if (loading) {
return (
<div className="flex justify-center items-center min-h-64">
<div className="bg-white rounded-2xl shadow-lg p-8 flex items-center space-x-4">
<div className="w-8 h-8 border-4 border-red-600 border-t-transparent rounded-full animate-spin"></div>
<span className="text-lg font-medium text-slate-700">Loading driver schedule...</span>
</div>
</div>
);
}
if (error || !scheduleData) {
return (
<div className="space-y-8">
<div className="bg-white rounded-2xl shadow-lg p-8 border border-slate-200/60 text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl"></span>
</div>
<h1 className="text-2xl font-bold text-slate-800 mb-2">Error</h1>
<p className="text-slate-600 mb-6">{error || 'Driver not found'}</p>
<Link
to="/drivers"
className="bg-gradient-to-r from-slate-500 to-slate-600 hover:from-slate-600 hover:to-slate-700 text-white px-6 py-3 rounded-lg font-medium transition-all duration-200 shadow-lg hover:shadow-xl"
>
Back to Drivers
</Link>
</div>
</div>
);
}
const nextEvent = getNextEvent();
const currentEvent = getCurrentEvent();
const groupedSchedule = groupEventsByDay(scheduleData.schedule);
return (
<div className="space-y-8">
{/* Header */}
<div className="bg-white rounded-2xl shadow-lg p-8 border border-slate-200/60">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent flex items-center gap-3">
🚗 Driver Dashboard: {scheduleData.driver.name}
</h1>
<p className="text-slate-600 mt-2">Real-time schedule and assignment management</p>
</div>
<div className="flex items-center space-x-4">
<button
className="bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white px-6 py-3 rounded-lg font-medium transition-all duration-200 shadow-lg hover:shadow-xl flex items-center gap-2"
onClick={handlePrintSchedule}
>
🖨 Print Schedule
</button>
<Link
to="/drivers"
className="bg-gradient-to-r from-slate-500 to-slate-600 hover:from-slate-600 hover:to-slate-700 text-white px-6 py-3 rounded-lg font-medium transition-all duration-200 shadow-lg hover:shadow-xl"
>
Back to Drivers
</Link>
</div>
</div>
</div>
{/* Current Status */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-green-50 to-emerald-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
📍 Current Status
</h2>
<p className="text-slate-600 mt-1">Real-time driver activity and next assignment</p>
</div>
<div className="p-8 space-y-6">
{currentEvent ? (
<div className="bg-gradient-to-r from-amber-50 to-orange-50 border border-amber-200 rounded-xl p-6">
<div className="flex items-center gap-3 mb-4">
<span className="text-2xl">{getTypeIcon(currentEvent.type)}</span>
<div>
<h3 className="text-lg font-bold text-amber-900">Currently Active</h3>
<p className="text-amber-700 font-semibold">{currentEvent.title}</p>
</div>
<span
className="ml-auto px-3 py-1 rounded-full text-xs font-bold text-white"
style={{ backgroundColor: getStatusColor(currentEvent.status) }}
>
{currentEvent.status.toUpperCase()}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div className="flex items-center gap-2 text-amber-800">
<span>📍</span>
<span>{currentEvent.location}</span>
</div>
<div className="flex items-center gap-2 text-amber-800">
<span>👤</span>
<span>VIP: {currentEvent.vipName}</span>
</div>
<div className="flex items-center gap-2 text-amber-800">
<span></span>
<span>Until {formatTime(currentEvent.endTime)}</span>
</div>
</div>
</div>
) : (
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-6">
<div className="flex items-center gap-3">
<span className="text-2xl"></span>
<div>
<h3 className="text-lg font-bold text-green-900">Currently Available</h3>
<p className="text-green-700">Ready for next assignment</p>
</div>
</div>
</div>
)}
{nextEvent && (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-xl p-6">
<div className="flex items-center gap-3 mb-4">
<span className="text-2xl">{getTypeIcon(nextEvent.type)}</span>
<div>
<h3 className="text-lg font-bold text-blue-900">Next Assignment</h3>
<p className="text-blue-700 font-semibold">{nextEvent.title}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm mb-4">
<div className="flex items-center gap-2 text-blue-800">
<span>📍</span>
<span>{nextEvent.location}</span>
</div>
<div className="flex items-center gap-2 text-blue-800">
<span>👤</span>
<span>VIP: {nextEvent.vipName}</span>
</div>
<div className="flex items-center gap-2 text-blue-800">
<span></span>
<span>{formatTime(nextEvent.startTime)} - {formatTime(nextEvent.endTime)}</span>
</div>
</div>
<button
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-lg hover:shadow-xl flex items-center gap-2"
onClick={() => window.open(`https://maps.google.com/?q=${encodeURIComponent(nextEvent.location)}`, '_blank')}
>
🗺 Get Directions
</button>
</div>
)}
</div>
</div>
{/* Full Schedule */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-purple-50 to-pink-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
📅 Complete Schedule
<span className="bg-purple-100 text-purple-800 text-sm font-medium px-2.5 py-0.5 rounded-full">
{scheduleData.schedule.length} assignments
</span>
</h2>
<p className="text-slate-600 mt-1">All scheduled events and assignments</p>
</div>
<div className="p-8">
{scheduleData.schedule.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl">📅</span>
</div>
<p className="text-slate-500 font-medium">No assignments scheduled</p>
</div>
) : (
<div className="space-y-8">
{Object.entries(groupedSchedule).map(([date, events]) => (
<div key={date} className="space-y-4">
<div className="bg-gradient-to-r from-slate-600 to-slate-700 text-white px-6 py-3 rounded-xl shadow-lg">
<h3 className="text-lg font-bold">
{new Date(date).toLocaleDateString([], {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</h3>
</div>
<div className="grid gap-4">
{events.map((event) => (
<div key={event.id} className="bg-gradient-to-r from-slate-50 to-slate-100 rounded-xl border border-slate-200/60 p-6 hover:shadow-lg transition-all duration-200">
<div className="flex items-start gap-6">
{/* Time Column */}
<div className="flex-shrink-0 text-center">
<div className="bg-white rounded-lg border border-slate-200 p-3 shadow-sm">
<div className="text-sm font-bold text-slate-900">
{formatTime(event.startTime)}
</div>
<div className="text-xs text-slate-500 mt-1">
to
</div>
<div className="text-sm font-bold text-slate-900">
{formatTime(event.endTime)}
</div>
</div>
</div>
{/* Event Content */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<span className="text-2xl">{getTypeIcon(event.type)}</span>
<h4 className="text-lg font-bold text-slate-900">{event.title}</h4>
<span
className="px-3 py-1 rounded-full text-xs font-bold text-white shadow-sm"
style={{ backgroundColor: getStatusColor(event.status) }}
>
{event.status.toUpperCase()}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<div className="flex items-center gap-2 text-slate-600">
<span>📍</span>
<span className="font-medium">{event.location}</span>
</div>
<div className="flex items-center gap-2 text-slate-600">
<span>👤</span>
<span className="font-medium">VIP: {event.vipName}</span>
</div>
</div>
{event.description && (
<div className="text-slate-600 mb-4 bg-white/50 rounded-lg p-3 border border-slate-200/50">
{event.description}
</div>
)}
<div className="flex items-center gap-3">
<button
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-lg hover:shadow-xl flex items-center gap-2"
onClick={() => window.open(`https://maps.google.com/?q=${encodeURIComponent(event.location)}`, '_blank')}
>
🗺 Directions
</button>
{event.status === 'scheduled' && (
<button
className="bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-lg hover:shadow-xl flex items-center gap-2"
onClick={() => updateEventStatus(event.id, 'in-progress')}
>
Start
</button>
)}
{event.status === 'in-progress' && (
<button
className="bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-lg hover:shadow-xl flex items-center gap-2"
onClick={() => updateEventStatus(event.id, 'completed')}
>
Complete
</button>
)}
{event.status === 'completed' && (
<span className="bg-green-100 text-green-800 px-3 py-1 rounded-full text-xs font-medium flex items-center gap-1">
Completed
</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Gantt Chart */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
📊 Schedule Timeline
</h2>
<p className="text-slate-600 mt-1">Visual timeline of all assignments</p>
</div>
<div className="p-8">
<GanttChart
events={scheduleData.schedule}
driverName={scheduleData.driver.name}
/>
</div>
</div>
</div>
);
};
export default DriverDashboard;

View File

@@ -0,0 +1,300 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAuthToken } from '../hooks/useAuthToken';
import { apiCall } from '../config/api';
import DriverForm from '../components/DriverForm';
import EditDriverForm from '../components/EditDriverForm';
interface Driver {
id: string;
name: string;
phone: string;
currentLocation: { lat: number; lng: number };
assignedVipIds: string[];
vehicleCapacity?: number;
}
const DriverList: React.FC = () => {
const [drivers, setDrivers] = useState<Driver[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingDriver, setEditingDriver] = useState<Driver | null>(null);
// Function to extract last name for sorting
const getLastName = (fullName: string) => {
const nameParts = fullName.trim().split(' ');
return nameParts[nameParts.length - 1].toLowerCase();
};
// Function to sort drivers by last name
const sortDriversByLastName = (driverList: Driver[]) => {
return [...driverList].sort((a, b) => {
const lastNameA = getLastName(a.name);
const lastNameB = getLastName(b.name);
return lastNameA.localeCompare(lastNameB);
});
};
useEffect(() => {
if (!token) {
setLoading(false);
return;
}
const fetchDrivers = async () => {
try {
const response = await apiCall('/drivers', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
const sortedDrivers = sortDriversByLastName(data);
setDrivers(sortedDrivers);
} else {
console.error('Failed to fetch drivers:', response.status);
}
} catch (error) {
console.error('Error fetching drivers:', error);
} finally {
setLoading(false);
}
};
fetchDrivers();
}, [token]);
const handleAddDriver = async (driverData: any) => {
if (!token) return;
try {
const response = await apiCall('/drivers', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(driverData),
});
if (response.ok) {
const newDriver = await response.json();
setDrivers(prev => sortDriversByLastName([...prev, newDriver]));
setShowForm(false);
} else {
console.error('Failed to add driver:', response.status);
}
} catch (error) {
console.error('Error adding driver:', error);
}
};
const handleEditDriver = async (driverData: any) => {
if (!token) return;
try {
const response = await apiCall(`/drivers/${driverData.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(driverData),
});
if (response.ok) {
const updatedDriver = await response.json();
setDrivers(prev => sortDriversByLastName(prev.map(driver =>
driver.id === updatedDriver.id ? updatedDriver : driver
)));
setEditingDriver(null);
} else {
console.error('Failed to update driver:', response.status);
}
} catch (error) {
console.error('Error updating driver:', error);
}
};
const handleDeleteDriver = async (driverId: string) => {
if (!confirm('Are you sure you want to delete this driver?')) {
return;
}
if (!token) return;
try {
const response = await apiCall(`/drivers/${driverId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
setDrivers(prev => prev.filter(driver => driver.id !== driverId));
} else {
console.error('Failed to delete driver:', response.status);
}
} catch (error) {
console.error('Error deleting driver:', error);
}
};
if (loading) {
return (
<div className="flex justify-center items-center min-h-64">
<div className="bg-white rounded-2xl shadow-lg p-8 flex items-center space-x-4">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<span className="text-lg font-medium text-slate-700">Loading drivers...</span>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="bg-white rounded-2xl shadow-lg p-8 border border-slate-200/60">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent">
Driver Management
</h1>
<p className="text-slate-600 mt-2">Manage driver profiles and assignments</p>
</div>
<div className="flex items-center space-x-4">
<div className="bg-gradient-to-r from-green-500 to-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium">
{drivers.length} Active Drivers
</div>
<button
className="btn btn-primary"
onClick={() => setShowForm(true)}
>
Add New Driver
</button>
</div>
</div>
</div>
{/* Driver Grid */}
{drivers.length === 0 ? (
<div className="bg-white rounded-2xl shadow-lg p-12 border border-slate-200/60 text-center">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<div className="w-8 h-8 bg-slate-300 rounded-full"></div>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">No Drivers Found</h3>
<p className="text-slate-600 mb-6">Get started by adding your first driver</p>
<button
className="btn btn-primary"
onClick={() => setShowForm(true)}
>
Add New Driver
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{drivers.map((driver) => (
<div key={driver.id} className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden hover:shadow-xl transition-shadow duration-200">
<div className="p-6">
{/* Driver Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-slate-900">{driver.name}</h3>
<div className="w-10 h-10 bg-gradient-to-br from-green-400 to-green-600 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-bold">
{driver.name.charAt(0).toUpperCase()}
</span>
</div>
</div>
{/* Driver Information */}
<div className="space-y-3 mb-6">
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-sm font-medium text-slate-700 mb-1">Contact</div>
<div className="text-slate-600">{driver.phone}</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-sm font-medium text-slate-700 mb-1">Current Location</div>
<div className="text-slate-600 text-sm">
{driver.currentLocation.lat.toFixed(4)}, {driver.currentLocation.lng.toFixed(4)}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-sm font-medium text-slate-700 mb-1">Vehicle Capacity</div>
<div className="flex items-center gap-2 text-slate-600">
<span>🚗</span>
<span className="font-medium">{driver.vehicleCapacity || 4} passengers</span>
</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-sm font-medium text-slate-700 mb-1">Assignments</div>
<div className="flex items-center gap-2">
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">
{driver.assignedVipIds.length} VIPs
</span>
<span className={`text-xs font-medium px-2 py-1 rounded-full ${
driver.assignedVipIds.length === 0
? 'bg-green-100 text-green-800'
: 'bg-amber-100 text-amber-800'
}`}>
{driver.assignedVipIds.length === 0 ? 'Available' : 'Assigned'}
</span>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="space-y-3">
<Link
to={`/drivers/${driver.id}`}
className="bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-sm hover:shadow-md w-full text-center block"
>
View Dashboard
</Link>
<div className="flex gap-2">
<button
className="bg-gradient-to-r from-slate-500 to-slate-600 hover:from-slate-600 hover:to-slate-700 text-white px-3 py-2 rounded-lg text-xs font-medium transition-all duration-200 shadow-sm hover:shadow-md flex-1"
onClick={() => setEditingDriver(driver)}
>
Edit
</button>
<button
className="bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white px-3 py-2 rounded-lg text-xs font-medium transition-all duration-200 shadow-sm hover:shadow-md flex-1"
onClick={() => handleDeleteDriver(driver.id)}
>
Delete
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Modals */}
{showForm && (
<DriverForm
onSubmit={handleAddDriver}
onCancel={() => setShowForm(false)}
/>
)}
{editingDriver && (
<EditDriverForm
driver={editingDriver}
onSubmit={handleEditDriver}
onCancel={() => setEditingDriver(null)}
/>
)}
</div>
);
};
export default DriverList;

View File

@@ -0,0 +1,94 @@
import React, { useEffect, useState } from 'react';
import { useKeycloak } from '../contexts/KeycloakContext';
interface PendingApprovalProps {
user: any;
onLogout: () => void;
}
const PendingApproval: React.FC<PendingApprovalProps> = ({ user, onLogout }) => {
const { getToken } = useKeycloak();
const [checkCount, setCheckCount] = useState(0);
useEffect(() => {
// Check status every 30 seconds
const interval = setInterval(async () => {
try {
const token = await getToken();
if (!token) return;
const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/me`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const userData = await response.json();
// If user is approved, reload the page to trigger re-render
if (userData.user?.approval_status === 'approved') {
window.location.reload();
}
}
setCheckCount(prev => prev + 1);
} catch (error) {
console.error('Error checking user status:', error);
}
}, 30000);
return () => clearInterval(interval);
}, [getToken]);
return (
<div className="min-h-screen bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 flex items-center justify-center p-4">
<div className="bg-white rounded-3xl shadow-2xl max-w-md w-full p-8 text-center relative overflow-hidden">
{/* Decorative element */}
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-amber-200 to-orange-200 rounded-full blur-3xl opacity-30 -translate-y-20 translate-x-20"></div>
<div className="relative z-10">
{/* Icon */}
<div className="w-24 h-24 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg">
<svg className="w-12 h-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
{/* Welcome message */}
<h1 className="text-3xl font-bold bg-gradient-to-r from-amber-600 to-orange-600 bg-clip-text text-transparent mb-2">
Welcome, {user?.name?.split(' ')[0]}!
</h1>
<p className="text-lg text-slate-600 mb-8">
Your account is being reviewed
</p>
{/* Status message */}
<div className="bg-gradient-to-r from-amber-50 to-orange-50 border border-amber-200 rounded-2xl p-6 mb-8">
<p className="text-amber-800 leading-relaxed">
Thank you for signing up! An administrator will review your account request shortly.
We'll notify you once your access has been approved.
</p>
</div>
{/* Auto-refresh notice */}
<p className="text-sm text-slate-500 mb-8">
This page checks for updates automatically
{checkCount > 0 && ` (checked ${checkCount} time${checkCount > 1 ? 's' : ''})`}
</p>
{/* Logout button */}
<button
onClick={onLogout}
className="w-full bg-gradient-to-r from-slate-600 to-slate-700 hover:from-slate-700 hover:to-slate-800 text-white font-medium py-3 px-6 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
Sign Out
</button>
</div>
</div>
</div>
);
};
export default PendingApproval;

View File

@@ -0,0 +1,111 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useApi, useMutation } from '../hooks/useApi';
import { vipApi } from '../api/client';
import VipForm from '../components/VipForm';
import { LoadingSpinner } from '../components/LoadingSpinner';
import { ErrorMessage } from '../components/ErrorMessage';
// Simplified VIP List - no more manual loading states, error handling, or token management
const SimplifiedVipList: React.FC = () => {
const [showForm, setShowForm] = useState(false);
const { data: vips, loading, error, refetch } = useApi(() => vipApi.list());
const createVip = useMutation(vipApi.create);
const deleteVip = useMutation(vipApi.delete);
const handleAddVip = async (vipData: any) => {
try {
await createVip.mutate(vipData);
setShowForm(false);
refetch(); // Refresh the list
} catch (error) {
// Error is already handled by the hook
}
};
const handleDeleteVip = async (id: string) => {
if (!confirm('Are you sure you want to delete this VIP?')) return;
try {
await deleteVip.mutate(id);
refetch(); // Refresh the list
} catch (error) {
// Error is already handled by the hook
}
};
if (loading) return <LoadingSpinner message="Loading VIPs..." />;
if (error) return <ErrorMessage message={error} onDismiss={() => refetch()} />;
if (!vips) return null;
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">VIP Management</h1>
<button
onClick={() => setShowForm(true)}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
Add VIP
</button>
</div>
{createVip.error && (
<ErrorMessage message={createVip.error} className="mb-4" />
)}
{deleteVip.error && (
<ErrorMessage message={deleteVip.error} className="mb-4" />
)}
<div className="grid gap-4">
{vips.map((vip) => (
<div key={vip.id} className="bg-white p-4 rounded-lg shadow">
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold">{vip.name}</h3>
<p className="text-gray-600">{vip.organization}</p>
<p className="text-sm text-gray-500">
{vip.transportMode === 'flight'
? `Flight: ${vip.flights?.[0]?.flightNumber || 'TBD'}`
: `Driving - Arrival: ${new Date(vip.expectedArrival).toLocaleString()}`
}
</p>
</div>
<div className="flex gap-2">
<Link
to={`/vips/${vip.id}`}
className="text-blue-500 hover:text-blue-700"
>
View Details
</Link>
<button
onClick={() => handleDeleteVip(vip.id)}
disabled={deleteVip.loading}
className="text-red-500 hover:text-red-700 disabled:opacity-50"
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
{showForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">Add New VIP</h2>
<VipForm
onSubmit={handleAddVip}
onCancel={() => setShowForm(false)}
/>
</div>
</div>
)}
</div>
);
};
export default SimplifiedVipList;

View File

@@ -0,0 +1,687 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useAuthToken } from '../hooks/useAuthToken';
import { apiCall } from '../config/api';
import FlightStatus from '../components/FlightStatus';
import ScheduleManager from '../components/ScheduleManager';
interface Flight {
flightNumber: string;
flightDate: string;
segment: number;
}
interface Vip {
id: string;
name: string;
organization: string;
transportMode: 'flight' | 'self-driving';
flightNumber?: string; // Legacy
flightDate?: string; // Legacy
flights?: Flight[]; // New
expectedArrival?: string;
arrivalTime?: string; // Legacy
needsAirportPickup?: boolean;
needsVenueTransport: boolean;
notes: string;
assignedDriverIds: string[];
}
const VipDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
const token = useAuthToken();
const [vip, setVip] = useState<Vip | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [schedule, setSchedule] = useState<any[]>([]);
useEffect(() => {
if (!token || !id) {
setLoading(false);
return;
}
const fetchVip = async () => {
try {
const response = await apiCall('/vips', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const vips = await response.json();
const foundVip = vips.find((v: Vip) => v.id === id);
if (foundVip) {
setVip(foundVip);
} else {
setError('VIP not found');
}
} else {
setError('Failed to fetch VIP data');
}
} catch (err) {
setError('Error loading VIP data');
} finally {
setLoading(false);
}
};
fetchVip();
}, [id, token]);
// Fetch schedule data
useEffect(() => {
if (!token || !vip) return;
const fetchSchedule = async () => {
try {
const response = await apiCall(`/vips/${vip.id}/schedule`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const scheduleData = await response.json();
setSchedule(scheduleData);
}
} catch (error) {
console.error('Error fetching schedule:', error);
}
};
fetchSchedule();
}, [vip, token]);
// Auto-scroll to schedule section if accessed via #schedule anchor
useEffect(() => {
if (vip && window.location.hash === '#schedule') {
setTimeout(() => {
const scheduleElement = document.getElementById('schedule-section');
if (scheduleElement) {
scheduleElement.scrollIntoView({ behavior: 'smooth' });
}
}, 100);
}
}, [vip]);
// Helper function to get flight info
const getFlightInfo = () => {
if (!vip) return null;
if (vip.transportMode === 'flight') {
if (vip.flights && vip.flights.length > 0) {
return {
flights: vip.flights,
primaryFlight: vip.flights[0]
};
} else if (vip.flightNumber) {
// Legacy support
return {
flights: [{
flightNumber: vip.flightNumber,
flightDate: vip.flightDate || '',
segment: 1
}],
primaryFlight: {
flightNumber: vip.flightNumber,
flightDate: vip.flightDate || '',
segment: 1
}
};
}
}
return null;
};
const handlePrintSchedule = () => {
if (!vip) return;
const printWindow = window.open('', '_blank');
if (!printWindow) return;
const groupEventsByDay = (events: any[]) => {
const grouped: { [key: string]: any[] } = {};
events.forEach(event => {
const date = new Date(event.startTime).toDateString();
if (!grouped[date]) {
grouped[date] = [];
}
grouped[date].push(event);
});
Object.keys(grouped).forEach(date => {
grouped[date].sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
});
return grouped;
};
const formatTime = (timeString: string) => {
return new Date(timeString).toLocaleString([], {
hour: '2-digit',
minute: '2-digit'
});
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'transport': return '🚗';
case 'meeting': return '🤝';
case 'event': return '🎉';
case 'meal': return '🍽️';
case 'accommodation': return '🏨';
default: return '📅';
}
};
const groupedSchedule = groupEventsByDay(schedule);
const flightInfo = getFlightInfo();
const printContent = `
<!DOCTYPE html>
<html>
<head>
<title>VIP Schedule - ${vip.name}</title>
<meta charset="UTF-8">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #2d3748;
background: #ffffff;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 40px 30px;
}
.header {
text-align: center;
margin-bottom: 40px;
padding-bottom: 30px;
border-bottom: 3px solid #e2e8f0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px 30px;
border-radius: 15px;
margin: -40px -30px 40px -30px;
}
.header h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 10px;
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header h2 {
font-size: 1.8rem;
font-weight: 600;
margin-bottom: 20px;
opacity: 0.95;
}
.vip-info {
background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%);
padding: 25px;
border-radius: 12px;
margin-bottom: 30px;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.vip-info p {
margin-bottom: 8px;
font-size: 1rem;
}
.vip-info strong {
color: #4a5568;
font-weight: 600;
}
.day-section {
margin-bottom: 40px;
page-break-inside: avoid;
}
.day-header {
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
color: white;
padding: 20px 25px;
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 20px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
text-align: center;
}
.event {
background: white;
border: 1px solid #e2e8f0;
margin-bottom: 15px;
padding: 25px;
border-radius: 12px;
display: flex;
align-items: flex-start;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
transition: all 0.2s ease;
}
.event:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.event-time {
min-width: 120px;
background: linear-gradient(135deg, #edf2f7 0%, #e2e8f0 100%);
padding: 15px;
border-radius: 8px;
text-align: center;
margin-right: 25px;
border: 1px solid #cbd5e0;
}
.event-time .time {
font-weight: 700;
font-size: 1rem;
color: #2d3748;
display: block;
}
.event-time .separator {
font-size: 0.8rem;
color: #718096;
margin: 5px 0;
}
.event-details {
flex: 1;
}
.event-title {
font-weight: 700;
font-size: 1.2rem;
margin-bottom: 10px;
color: #2d3748;
display: flex;
align-items: center;
gap: 8px;
}
.event-icon {
font-size: 1.3rem;
}
.event-location {
color: #4a5568;
margin-bottom: 8px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
}
.event-description {
background: #f7fafc;
padding: 12px 15px;
border-radius: 8px;
font-style: italic;
color: #4a5568;
margin-bottom: 10px;
border-left: 4px solid #cbd5e0;
}
.event-driver {
color: #3182ce;
font-weight: 600;
font-size: 0.9rem;
background: #ebf8ff;
padding: 8px 12px;
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-scheduled {
background: #bee3f8;
color: #2b6cb0;
}
.status-in-progress {
background: #fbd38d;
color: #c05621;
}
.status-completed {
background: #c6f6d5;
color: #276749;
}
.footer {
margin-top: 50px;
text-align: center;
color: #718096;
font-size: 0.9rem;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
}
.company-logo {
width: 60px;
height: 60px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
color: white;
font-size: 1.5rem;
font-weight: bold;
}
@media print {
body {
margin: 0;
padding: 0;
}
.container {
padding: 20px;
}
.header {
margin: -20px -20px 30px -20px;
}
.day-section {
page-break-inside: avoid;
}
.event {
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="company-logo">VC</div>
<h1>📅 VIP Schedule</h1>
<h2>${vip.name}</h2>
</div>
<div class="vip-info">
<p><strong>Organization:</strong> ${vip.organization}</p>
${vip.transportMode === 'flight' && flightInfo ? `
<p><strong>Flight Information:</strong> ${flightInfo.flights.map(f => f.flightNumber).join(' → ')}</p>
<p><strong>Flight Date:</strong> ${flightInfo.primaryFlight.flightDate ? new Date(flightInfo.primaryFlight.flightDate).toLocaleDateString() : 'TBD'}</p>
` : vip.transportMode === 'self-driving' ? `
<p><strong>Transport Mode:</strong> 🚗 Self-Driving</p>
<p><strong>Expected Arrival:</strong> ${vip.expectedArrival ? new Date(vip.expectedArrival).toLocaleString() : 'TBD'}</p>
` : ''}
<p><strong>Airport Pickup:</strong> ${vip.needsAirportPickup ? '✅ Required' : '❌ Not Required'}</p>
<p><strong>Venue Transport:</strong> ${vip.needsVenueTransport ? '✅ Required' : '❌ Not Required'}</p>
${vip.notes ? `<p><strong>Special Notes:</strong> ${vip.notes}</p>` : ''}
</div>
${Object.entries(groupedSchedule).map(([date, events]) => `
<div class="day-section">
<div class="day-header">
${new Date(date).toLocaleDateString([], {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
${events.map(event => `
<div class="event">
<div class="event-time">
<span class="time">${formatTime(event.startTime)}</span>
<div class="separator">to</div>
<span class="time">${formatTime(event.endTime)}</span>
</div>
<div class="event-details">
<div class="event-title">
<span class="event-icon">${getTypeIcon(event.type)}</span>
${event.title}
<span class="status-badge status-${event.status}">${event.status}</span>
</div>
<div class="event-location">
<span>📍</span>
${event.location}
</div>
${event.description ? `<div class="event-description">${event.description}</div>` : ''}
${event.assignedDriverId ? `<div class="event-driver"><span>👤</span> Driver: ${event.assignedDriverId}</div>` : ''}
</div>
</div>
`).join('')}
</div>
`).join('')}
<div class="footer">
<p><strong>VIP Coordinator System</strong></p>
<p>Generated on ${new Date().toLocaleDateString([], {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}</p>
</div>
</div>
</body>
</html>
`;
printWindow.document.write(printContent);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
printWindow.close();
}, 250);
};
if (loading) {
return <div>Loading VIP details...</div>;
}
if (error || !vip) {
return (
<div>
<h1>Error</h1>
<p>{error || 'VIP not found'}</p>
<Link to="/vips" className="btn">Back to VIP List</Link>
</div>
);
}
const flightInfo = getFlightInfo();
return (
<div className="space-y-8">
{/* Header */}
<div className="bg-white rounded-2xl shadow-lg p-8 border border-slate-200/60">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent">
VIP Details: {vip.name}
</h1>
<p className="text-slate-600 mt-2">Complete profile and schedule management</p>
</div>
<div className="flex items-center space-x-4">
<button
className="bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white px-6 py-3 rounded-lg font-medium transition-all duration-200 shadow-lg hover:shadow-xl flex items-center gap-2"
onClick={handlePrintSchedule}
>
🖨 Print Schedule
</button>
<Link
to="/vips"
className="bg-gradient-to-r from-slate-500 to-slate-600 hover:from-slate-600 hover:to-slate-700 text-white px-6 py-3 rounded-lg font-medium transition-all duration-200 shadow-lg hover:shadow-xl"
>
Back to VIP List
</Link>
</div>
</div>
</div>
{/* VIP Information Card */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
📋 VIP Information
</h2>
<p className="text-slate-600 mt-1">Personal details and travel arrangements</p>
</div>
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200/60">
<div className="text-sm font-medium text-slate-500 mb-1">Name</div>
<div className="text-lg font-bold text-slate-900">{vip.name}</div>
</div>
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200/60">
<div className="text-sm font-medium text-slate-500 mb-1">Organization</div>
<div className="text-lg font-bold text-slate-900">{vip.organization}</div>
</div>
{vip.transportMode === 'flight' && flightInfo ? (
<>
<div className="bg-blue-50 rounded-xl p-4 border border-blue-200/60">
<div className="text-sm font-medium text-blue-600 mb-1">Flight{flightInfo.flights.length > 1 ? 's' : ''}</div>
<div className="text-lg font-bold text-blue-900">{flightInfo.flights.map(f => f.flightNumber).join(' → ')}</div>
</div>
<div className="bg-blue-50 rounded-xl p-4 border border-blue-200/60">
<div className="text-sm font-medium text-blue-600 mb-1">Flight Date</div>
<div className="text-lg font-bold text-blue-900">
{flightInfo.primaryFlight.flightDate ? new Date(flightInfo.primaryFlight.flightDate).toLocaleDateString() : 'TBD'}
</div>
</div>
</>
) : vip.transportMode === 'self-driving' ? (
<>
<div className="bg-green-50 rounded-xl p-4 border border-green-200/60">
<div className="text-sm font-medium text-green-600 mb-1">Transport Mode</div>
<div className="text-lg font-bold text-green-900 flex items-center gap-2">
🚗 Self-Driving
</div>
</div>
<div className="bg-green-50 rounded-xl p-4 border border-green-200/60">
<div className="text-sm font-medium text-green-600 mb-1">Expected Arrival</div>
<div className="text-lg font-bold text-green-900">
{vip.expectedArrival ? new Date(vip.expectedArrival).toLocaleString() : 'TBD'}
</div>
</div>
</>
) : null}
<div className={`rounded-xl p-4 border ${vip.needsAirportPickup ? 'bg-green-50 border-green-200/60' : 'bg-red-50 border-red-200/60'}`}>
<div className={`text-sm font-medium mb-1 ${vip.needsAirportPickup ? 'text-green-600' : 'text-red-600'}`}>Airport Pickup</div>
<div className={`text-lg font-bold flex items-center gap-2 ${vip.needsAirportPickup ? 'text-green-900' : 'text-red-900'}`}>
{vip.needsAirportPickup ? '✅ Required' : '❌ Not Required'}
</div>
</div>
<div className={`rounded-xl p-4 border ${vip.needsVenueTransport ? 'bg-green-50 border-green-200/60' : 'bg-red-50 border-red-200/60'}`}>
<div className={`text-sm font-medium mb-1 ${vip.needsVenueTransport ? 'text-green-600' : 'text-red-600'}`}>Venue Transport</div>
<div className={`text-lg font-bold flex items-center gap-2 ${vip.needsVenueTransport ? 'text-green-900' : 'text-red-900'}`}>
{vip.needsVenueTransport ? '✅ Required' : '❌ Not Required'}
</div>
</div>
</div>
{vip.notes && (
<div className="mt-6">
<div className="text-sm font-medium text-slate-500 mb-2">Special Notes</div>
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<p className="text-amber-800">{vip.notes}</p>
</div>
</div>
)}
{vip.assignedDriverIds && vip.assignedDriverIds.length > 0 && (
<div className="mt-6">
<div className="text-sm font-medium text-slate-500 mb-2">Assigned Drivers</div>
<div className="flex flex-wrap gap-2">
{vip.assignedDriverIds.map(driverId => (
<span
key={driverId}
className="bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 py-2 rounded-full text-sm font-medium flex items-center gap-2"
>
👤 {driverId}
</span>
))}
</div>
</div>
)}
</div>
</div>
{/* Flight Status */}
{vip.transportMode === 'flight' && flightInfo && (
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-sky-50 to-blue-50 px-8 py-6 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
Flight Information
</h2>
<p className="text-slate-600 mt-1">Real-time flight status and details</p>
</div>
<div className="p-8 space-y-6">
{flightInfo.flights.map((flight, index) => (
<div key={index} className="bg-slate-50 rounded-xl p-6 border border-slate-200/60">
<h3 className="text-lg font-bold text-slate-900 mb-4">
{index === 0 ? 'Primary Flight' : `Connecting Flight ${index}`}: {flight.flightNumber}
</h3>
<FlightStatus
flightNumber={flight.flightNumber}
flightDate={flight.flightDate}
/>
</div>
))}
</div>
</div>
)}
{/* Schedule Management */}
<div id="schedule-section">
<ScheduleManager vipId={vip.id} vipName={vip.name} />
</div>
</div>
);
};
export default VipDetails;

View File

@@ -0,0 +1,319 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAuthToken } from '../hooks/useAuthToken';
import { apiCall } from '../config/api';
import VipForm from '../components/VipForm';
import EditVipForm from '../components/EditVipForm';
import FlightStatus from '../components/FlightStatus';
interface Vip {
id: string;
name: string;
organization: string;
department: 'Office of Development' | 'Admin';
transportMode: 'flight' | 'self-driving';
flightNumber?: string; // Legacy
flightDate?: string; // Legacy
flights?: Array<{
flightNumber: string;
flightDate: string;
segment: number;
}>; // New
expectedArrival?: string;
arrivalTime?: string;
needsAirportPickup?: boolean;
needsVenueTransport: boolean;
notes?: string;
}
const VipList: React.FC = () => {
const token = useAuthToken();
const [vips, setVips] = useState<Vip[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingVip, setEditingVip] = useState<Vip | null>(null);
// Function to extract last name for sorting
const getLastName = (fullName: string) => {
const nameParts = fullName.trim().split(' ');
return nameParts[nameParts.length - 1].toLowerCase();
};
// Function to sort VIPs by last name
const sortVipsByLastName = (vipList: Vip[]) => {
return [...vipList].sort((a, b) => {
const lastNameA = getLastName(a.name);
const lastNameB = getLastName(b.name);
return lastNameA.localeCompare(lastNameB);
});
};
useEffect(() => {
if (!token) {
setLoading(false);
return;
}
const fetchVips = async () => {
try {
const response = await apiCall('/vips', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
const sortedVips = sortVipsByLastName(data);
setVips(sortedVips);
} else {
console.error('Failed to fetch VIPs:', response.status);
}
} catch (error) {
console.error('Error fetching VIPs:', error);
} finally {
setLoading(false);
}
};
fetchVips();
}, [token]);
const handleAddVip = async (vipData: any) => {
if (!token) return;
try {
const response = await apiCall('/vips', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(vipData),
});
if (response.ok) {
const newVip = await response.json();
setVips(prev => sortVipsByLastName([...prev, newVip]));
setShowForm(false);
} else {
console.error('Failed to add VIP:', response.status);
}
} catch (error) {
console.error('Error adding VIP:', error);
}
};
const handleEditVip = async (vipData: any) => {
if (!token) return;
try {
const response = await apiCall(`/vips/${vipData.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(vipData),
});
if (response.ok) {
const updatedVip = await response.json();
setVips(prev => sortVipsByLastName(prev.map(vip => vip.id === updatedVip.id ? updatedVip : vip)));
setEditingVip(null);
} else {
console.error('Failed to update VIP:', response.status);
}
} catch (error) {
console.error('Error updating VIP:', error);
}
};
const handleDeleteVip = async (vipId: string) => {
if (!confirm('Are you sure you want to delete this VIP?')) {
return;
}
if (!token) return;
try {
const response = await apiCall(`/vips/${vipId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
setVips(prev => prev.filter(vip => vip.id !== vipId));
} else {
console.error('Failed to delete VIP:', response.status);
}
} catch (error) {
console.error('Error deleting VIP:', error);
}
};
if (loading) {
return (
<div className="flex justify-center items-center min-h-64">
<div className="bg-white rounded-2xl shadow-lg p-8 flex items-center space-x-4">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<span className="text-lg font-medium text-slate-700">Loading VIPs...</span>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="bg-white rounded-2xl shadow-lg p-8 border border-slate-200/60">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent">
VIP Management
</h1>
<p className="text-slate-600 mt-2">Manage VIP profiles and travel arrangements</p>
</div>
<button
className="btn btn-primary"
onClick={() => setShowForm(true)}
>
Add New VIP
</button>
</div>
</div>
{/* VIP List */}
{vips.length === 0 ? (
<div className="bg-white rounded-2xl shadow-lg p-12 border border-slate-200/60 text-center">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<div className="w-8 h-8 bg-slate-300 rounded-full"></div>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">No VIPs Found</h3>
<p className="text-slate-600 mb-6">Get started by adding your first VIP</p>
<button
className="btn btn-primary"
onClick={() => setShowForm(true)}
>
Add New VIP
</button>
</div>
) : (
<div className="space-y-4">
{vips.map((vip) => (
<div key={vip.id} className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden hover:shadow-xl transition-shadow duration-200">
<div className="p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<h3 className="text-xl font-bold text-slate-900">{vip.name}</h3>
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
{vip.department}
</span>
</div>
<p className="text-slate-600 text-sm mb-4">{vip.organization}</p>
{/* Transport Information */}
<div className="bg-slate-50 rounded-lg p-4 mb-4">
{vip.transportMode === 'flight' ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-slate-700">Flight:</span>
<span className="text-slate-600">
{vip.flights && vip.flights.length > 0 ?
vip.flights.map(f => f.flightNumber).join(' → ') :
vip.flightNumber || 'No flight'}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-slate-700">Airport Pickup:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
vip.needsAirportPickup
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{vip.needsAirportPickup ? 'Required' : 'Not needed'}
</span>
</div>
</div>
) : (
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-slate-700">Self-driving, Expected:</span>
<span className="text-slate-600">
{vip.expectedArrival ? new Date(vip.expectedArrival).toLocaleString() : 'TBD'}
</span>
</div>
)}
<div className="flex items-center gap-2 text-sm mt-2">
<span className="font-medium text-slate-700">Venue Transport:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
vip.needsVenueTransport
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}>
{vip.needsVenueTransport ? 'Required' : 'Not needed'}
</span>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-2 ml-6">
<Link
to={`/vips/${vip.id}`}
className="btn btn-success text-center"
>
View Details
</Link>
<button
className="btn btn-secondary"
onClick={() => setEditingVip(vip)}
>
Edit
</button>
<button
className="btn btn-danger"
onClick={() => handleDeleteVip(vip.id)}
>
Delete
</button>
</div>
</div>
{/* Flight Status */}
{vip.transportMode === 'flight' && vip.flightNumber && (
<div className="mt-4 pt-4 border-t border-slate-200">
<FlightStatus flightNumber={vip.flightNumber} />
</div>
)}
</div>
</div>
))}
</div>
)}
{/* Modals */}
{showForm && (
<VipForm
onSubmit={handleAddVip}
onCancel={() => setShowForm(false)}
/>
)}
{editingVip && (
<EditVipForm
vip={{...editingVip, notes: editingVip.notes || ''}}
onSubmit={handleEditVip}
onCancel={() => setEditingVip(null)}
/>
)}
</div>
);
};
export default VipList;

View File

@@ -0,0 +1,84 @@
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
// Cleanup after each test
afterEach(() => {
cleanup();
});
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock fetch globally
global.fetch = vi.fn();
// Reset mocks before each test
beforeEach(() => {
vi.clearAllMocks();
// Default fetch mock
(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({}),
text: async () => '',
status: 200,
statusText: 'OK',
});
});
// Mock Google Identity Services
(global as any).google = {
accounts: {
id: {
initialize: vi.fn(),
renderButton: vi.fn(),
prompt: vi.fn(),
disableAutoSelect: vi.fn(),
storeCredential: vi.fn(),
cancel: vi.fn(),
onGoogleLibraryLoad: vi.fn(),
revoke: vi.fn(),
},
oauth2: {
initTokenClient: vi.fn(),
initCodeClient: vi.fn(),
hasGrantedAnyScope: vi.fn(),
hasGrantedAllScopes: vi.fn(),
revoke: vi.fn(),
},
},
};
// Mock console methods to reduce test noise
const originalError = console.error;
const originalWarn = console.warn;
beforeAll(() => {
console.error = vi.fn();
console.warn = vi.fn();
});
afterAll(() => {
console.error = originalError;
console.warn = originalWarn;
});

View File

@@ -0,0 +1,195 @@
import React, { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../contexts/ToastContext';
// Mock user data
export const mockUsers = {
admin: {
id: '123',
email: 'admin@test.com',
name: 'Test Admin',
role: 'administrator',
status: 'active',
approval_status: 'approved',
profile_picture_url: 'https://example.com/admin.jpg',
},
coordinator: {
id: '456',
email: 'coordinator@test.com',
name: 'Test Coordinator',
role: 'coordinator',
status: 'active',
approval_status: 'approved',
profile_picture_url: 'https://example.com/coord.jpg',
},
pendingUser: {
id: '789',
email: 'pending@test.com',
name: 'Pending User',
role: 'coordinator',
status: 'pending',
approval_status: 'pending',
profile_picture_url: 'https://example.com/pending.jpg',
},
};
// Mock VIP data
export const mockVips = {
flightVip: {
id: '001',
name: 'John Doe',
title: 'CEO',
organization: 'Test Corp',
contact_info: '+1234567890',
arrival_datetime: '2025-01-15T10:00:00Z',
departure_datetime: '2025-01-16T14:00:00Z',
airport: 'LAX',
flight_number: 'AA123',
hotel: 'Hilton Downtown',
room_number: '1234',
status: 'scheduled',
transportation_mode: 'flight',
notes: 'Requires luxury vehicle',
},
drivingVip: {
id: '002',
name: 'Jane Smith',
title: 'VP Sales',
organization: 'Another Corp',
contact_info: '+0987654321',
arrival_datetime: '2025-01-15T14:00:00Z',
departure_datetime: '2025-01-16T10:00:00Z',
hotel: 'Marriott',
room_number: '567',
status: 'scheduled',
transportation_mode: 'self_driving',
notes: 'Arrives by personal vehicle',
},
};
// Mock driver data
export const mockDrivers = {
available: {
id: 'd001',
name: 'Mike Johnson',
phone: '+1234567890',
email: 'mike@drivers.com',
license_number: 'DL123456',
vehicle_info: '2023 Tesla Model S - Black',
availability_status: 'available',
current_location: 'Downtown Station',
notes: 'Experienced with VIP transport',
},
busy: {
id: 'd002',
name: 'Sarah Williams',
phone: '+0987654321',
email: 'sarah@drivers.com',
license_number: 'DL789012',
vehicle_info: '2023 Mercedes S-Class - Silver',
availability_status: 'busy',
current_location: 'Airport',
notes: 'Currently on assignment',
},
};
// Mock schedule events
export const mockScheduleEvents = {
pickup: {
id: 'e001',
vip_id: '001',
driver_id: 'd001',
event_type: 'pickup',
scheduled_time: '2025-01-15T10:30:00Z',
location: 'LAX Terminal 4',
status: 'scheduled',
notes: 'Meet at baggage claim',
},
dropoff: {
id: 'e002',
vip_id: '001',
driver_id: 'd001',
event_type: 'dropoff',
scheduled_time: '2025-01-16T12:00:00Z',
location: 'LAX Terminal 4',
status: 'scheduled',
notes: 'Departure gate B23',
},
};
// Custom render function that includes providers
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
initialRoute?: string;
user?: typeof mockUsers.admin | null;
}
const AllTheProviders = ({
children,
initialRoute = '/'
}: {
children: React.ReactNode;
initialRoute?: string;
}) => {
return (
<MemoryRouter initialEntries={[initialRoute]}>
<ToastProvider>
{children}
</ToastProvider>
</MemoryRouter>
);
};
export const customRender = (
ui: ReactElement,
{ initialRoute = '/', ...options }: CustomRenderOptions = {}
) => {
return render(ui, {
wrapper: ({ children }) => (
<AllTheProviders initialRoute={initialRoute}>
{children}
</AllTheProviders>
),
...options,
});
};
// Mock API responses
export const mockApiResponses = {
getVips: () => ({
ok: true,
json: async () => [mockVips.flightVip, mockVips.drivingVip],
}),
getVip: (id: string) => ({
ok: true,
json: async () =>
id === '001' ? mockVips.flightVip : mockVips.drivingVip,
}),
getDrivers: () => ({
ok: true,
json: async () => [mockDrivers.available, mockDrivers.busy],
}),
getSchedule: () => ({
ok: true,
json: async () => [mockScheduleEvents.pickup, mockScheduleEvents.dropoff],
}),
getCurrentUser: (user = mockUsers.admin) => ({
ok: true,
json: async () => user,
}),
error: (message = 'Server error') => ({
ok: false,
status: 500,
json: async () => ({ error: message }),
}),
};
// Helper to wait for async operations
export const waitForLoadingToFinish = () =>
new Promise(resolve => setTimeout(resolve, 0));
// Re-export everything from React Testing Library
export * from '@testing-library/react';
// Use custom render by default
export { customRender as render };

View File

@@ -0,0 +1,116 @@
// User types
export interface User {
id: string;
email: string;
name: string;
role: 'administrator' | 'coordinator' | 'driver' | 'viewer';
status?: 'pending' | 'active' | 'deactivated';
organization?: string;
phone?: string;
department?: string;
profilePhoto?: string;
onboardingData?: {
vehicleType?: string;
vehicleCapacity?: number;
licensePlate?: string;
homeLocation?: { lat: number; lng: number };
requestedRole: string;
reason: string;
};
approvedBy?: string;
approvedAt?: string;
createdAt?: string;
updatedAt?: string;
lastLogin?: string;
}
// VIP types
export interface Flight {
flightNumber: string;
airline?: string;
scheduledArrival: string;
scheduledDeparture?: string;
status?: 'scheduled' | 'delayed' | 'cancelled' | 'arrived';
}
export interface VIP {
id: string;
name: string;
organization?: string;
department?: 'Office of Development' | 'Admin';
transportMode: 'flight' | 'self-driving';
flights?: Flight[];
expectedArrival?: string;
needsAirportPickup?: boolean;
needsVenueTransport?: boolean;
notes?: string;
assignedDriverIds?: string[];
schedule?: ScheduleEvent[];
}
// Driver types
export interface Driver {
id: string;
name: string;
email?: string;
phone: string;
vehicleInfo?: string;
status?: 'available' | 'assigned' | 'unavailable';
department?: string;
currentLocation?: { lat: number; lng: number };
assignedVipIds?: string[];
}
// Schedule Event types
export interface ScheduleEvent {
id: string;
vipId?: string;
vipName?: string;
assignedDriverId?: string;
eventTime: string;
eventType: 'pickup' | 'dropoff' | 'custom';
location: string;
notes?: string;
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
warnings?: string[];
}
// Form data types
export interface VipFormData {
name: string;
organization?: string;
department?: 'Office of Development' | 'Admin';
transportMode: 'flight' | 'self-driving';
flights?: Flight[];
expectedArrival?: string;
needsAirportPickup?: boolean;
needsVenueTransport?: boolean;
notes?: string;
}
export interface DriverFormData {
name: string;
email?: string;
phone: string;
vehicleInfo?: string;
status?: 'available' | 'assigned' | 'unavailable';
}
export interface ScheduleEventFormData {
assignedDriverId?: string;
eventTime: string;
eventType: 'pickup' | 'dropoff' | 'custom';
location: string;
notes?: string;
}
// Admin settings
export interface SystemSettings {
aviationStackKey?: string;
googleMapsKey?: string;
twilioKey?: string;
enableFlightTracking?: boolean;
enableSMSNotifications?: boolean;
defaultPickupLocation?: string;
defaultDropoffLocation?: string;
}

View File

@@ -0,0 +1,21 @@
import { apiCall as baseApiCall, API_BASE_URL } from '../config/api';
// Re-export API_BASE_URL for components that need it
export { API_BASE_URL };
// Legacy API call wrapper that returns the response directly
// This maintains backward compatibility with existing code
export const apiCall = async (endpoint: string, options?: RequestInit) => {
const result = await baseApiCall(endpoint, options);
// Return the response object with data attached
const response = result.response;
(response as any).data = result.data;
// Make the response look like it can be used with .json()
if (!response.json) {
(response as any).json = async () => result.data;
}
return response;
};

View File

@@ -0,0 +1,386 @@
// Test VIP data generation utilities
export const generateTestVips = () => {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const dayAfter = new Date(today);
dayAfter.setDate(dayAfter.getDate() + 2);
const formatDate = (date: Date) => date.toISOString().split('T')[0];
const formatDateTime = (date: Date) => {
const d = new Date(date);
d.setHours(14, 30, 0, 0); // 2:30 PM
return d.toISOString().slice(0, 16);
};
return [
// Admin Department VIPs (10)
{
name: 'Dr. Sarah Chen',
organization: 'Stanford University',
department: 'Admin',
transportMode: 'flight',
flights: [
{ flightNumber: 'UA1234', flightDate: formatDate(tomorrow), segment: 1 },
{ flightNumber: 'DL5678', flightDate: formatDate(tomorrow), segment: 2 }
],
needsAirportPickup: true,
needsVenueTransport: true,
notes: 'Vegetarian meals, requires wheelchair assistance'
},
{
name: 'Ambassador Michael Rodriguez',
organization: 'Embassy of Spain',
department: 'Admin',
transportMode: 'self-driving',
expectedArrival: formatDateTime(tomorrow),
needsVenueTransport: true,
notes: 'Security detail required, diplomatic immunity'
},
{
name: 'Prof. Aisha Patel',
organization: 'MIT Technology Review',
department: 'Admin',
transportMode: 'flight',
flights: [{ flightNumber: 'AA9876', flightDate: formatDate(dayAfter), segment: 1 }],
needsAirportPickup: true,
needsVenueTransport: false,
notes: 'Allergic to shellfish, prefers ground floor rooms'
},
{
name: 'CEO James Thompson',
organization: 'TechCorp Industries',
department: 'Admin',
transportMode: 'flight',
flights: [{ flightNumber: 'SW2468', flightDate: formatDate(tomorrow), segment: 1 }],
needsAirportPickup: false,
needsVenueTransport: true,
notes: 'Private jet arrival, has own security team'
},
{
name: 'Dr. Elena Volkov',
organization: 'Russian Academy of Sciences',
department: 'Admin',
transportMode: 'self-driving',
expectedArrival: formatDateTime(dayAfter),
needsVenueTransport: true,
notes: 'Interpreter required, kosher meals'
},
{
name: 'Minister David Kim',
organization: 'South Korean Ministry of Education',
department: 'Admin',
transportMode: 'flight',
flights: [
{ flightNumber: 'KE0123', flightDate: formatDate(tomorrow), segment: 1 },
{ flightNumber: 'UA7890', flightDate: formatDate(tomorrow), segment: 2 },
{ flightNumber: 'DL3456', flightDate: formatDate(tomorrow), segment: 3 }
],
needsAirportPickup: true,
needsVenueTransport: true,
notes: 'Long international flight, may need rest upon arrival'
},
{
name: 'Dr. Maria Santos',
organization: 'University of São Paulo',
department: 'Admin',
transportMode: 'flight',
flights: [{ flightNumber: 'LH4567', flightDate: formatDate(dayAfter), segment: 1 }],
needsAirportPickup: true,
needsVenueTransport: false,
notes: 'Speaks Portuguese and English, lactose intolerant'
},
{
name: 'Sheikh Ahmed Al-Rashid',
organization: 'UAE University',
department: 'Admin',
transportMode: 'self-driving',
expectedArrival: formatDateTime(tomorrow),
needsVenueTransport: true,
notes: 'Halal meals required, prayer room access needed'
},
{
name: 'Prof. Catherine Williams',
organization: 'Oxford University',
department: 'Admin',
transportMode: 'flight',
flights: [{ flightNumber: 'BA1357', flightDate: formatDate(tomorrow), segment: 1 }],
needsAirportPickup: false,
needsVenueTransport: true,
notes: 'Prefers tea over coffee, has mobility issues'
},
{
name: 'Dr. Hiroshi Tanaka',
organization: 'Tokyo Institute of Technology',
department: 'Admin',
transportMode: 'flight',
flights: [
{ flightNumber: 'NH0246', flightDate: formatDate(dayAfter), segment: 1 },
{ flightNumber: 'UA8642', flightDate: formatDate(dayAfter), segment: 2 }
],
needsAirportPickup: true,
needsVenueTransport: true,
notes: 'Jet lag concerns, prefers Japanese cuisine when available'
},
// Office of Development VIPs (10)
{
name: 'Ms. Jennifer Walsh',
organization: 'Walsh Foundation',
department: 'Office of Development',
transportMode: 'self-driving',
expectedArrival: formatDateTime(tomorrow),
needsVenueTransport: false,
notes: 'Major donor, prefers informal settings'
},
{
name: 'Mr. Robert Sterling',
organization: 'Sterling Philanthropies',
department: 'Office of Development',
transportMode: 'flight',
flights: [{ flightNumber: 'JB1122', flightDate: formatDate(tomorrow), segment: 1 }],
needsAirportPickup: true,
needsVenueTransport: true,
notes: 'Potential $10M donation, wine enthusiast'
},
{
name: 'Mrs. Elizabeth Hartwell',
organization: 'Hartwell Family Trust',
department: 'Office of Development',
transportMode: 'flight',
flights: [{ flightNumber: 'AS3344', flightDate: formatDate(dayAfter), segment: 1 }],
needsAirportPickup: false,
needsVenueTransport: true,
notes: 'Alumni donor, interested in scholarship programs'
},
{
name: 'Mr. Charles Montgomery',
organization: 'Montgomery Industries',
department: 'Office of Development',
transportMode: 'self-driving',
expectedArrival: formatDateTime(dayAfter),
needsVenueTransport: true,
notes: 'Corporate partnership opportunity, golf enthusiast'
},
{
name: 'Dr. Patricia Lee',
organization: 'Lee Medical Foundation',
department: 'Office of Development',
transportMode: 'flight',
flights: [
{ flightNumber: 'F91234', flightDate: formatDate(tomorrow), segment: 1 },
{ flightNumber: 'UA5555', flightDate: formatDate(tomorrow), segment: 2 }
],
needsAirportPickup: true,
needsVenueTransport: false,
notes: 'Medical research funding, diabetic dietary needs'
},
{
name: 'Mr. Thomas Anderson',
organization: 'Anderson Capital Group',
department: 'Office of Development',
transportMode: 'flight',
flights: [{ flightNumber: 'VX7788', flightDate: formatDate(tomorrow), segment: 1 }],
needsAirportPickup: true,
needsVenueTransport: true,
notes: 'Venture capital investor, tech startup focus'
},
{
name: 'Mrs. Grace Chen-Williams',
organization: 'Chen-Williams Foundation',
department: 'Office of Development',
transportMode: 'self-driving',
expectedArrival: formatDateTime(tomorrow),
needsVenueTransport: true,
notes: 'Arts and culture patron, vegan diet'
},
{
name: 'Mr. Daniel Foster',
organization: 'Foster Energy Solutions',
department: 'Office of Development',
transportMode: 'flight',
flights: [{ flightNumber: 'WN9999', flightDate: formatDate(dayAfter), segment: 1 }],
needsAirportPickup: false,
needsVenueTransport: false,
notes: 'Renewable energy focus, environmental sustainability'
},
{
name: 'Mrs. Victoria Blackstone',
organization: 'Blackstone Charitable Trust',
department: 'Office of Development',
transportMode: 'flight',
flights: [
{ flightNumber: 'B61111', flightDate: formatDate(dayAfter), segment: 1 },
{ flightNumber: 'AA2222', flightDate: formatDate(dayAfter), segment: 2 }
],
needsAirportPickup: true,
needsVenueTransport: true,
notes: 'Education advocate, prefers luxury accommodations'
},
{
name: 'Mr. Alexander Petrov',
organization: 'Petrov International Holdings',
department: 'Office of Development',
transportMode: 'self-driving',
expectedArrival: formatDateTime(dayAfter),
needsVenueTransport: true,
notes: 'International business, speaks Russian and English'
}
];
};
export const getTestOrganizations = () => [
'Stanford University', 'Embassy of Spain', 'MIT Technology Review', 'TechCorp Industries',
'Russian Academy of Sciences', 'South Korean Ministry of Education', 'University of São Paulo',
'UAE University', 'Oxford University', 'Tokyo Institute of Technology',
'Walsh Foundation', 'Sterling Philanthropies', 'Hartwell Family Trust', 'Montgomery Industries',
'Lee Medical Foundation', 'Anderson Capital Group', 'Chen-Williams Foundation',
'Foster Energy Solutions', 'Blackstone Charitable Trust', 'Petrov International Holdings'
];
// Generate realistic daily schedules for VIPs
export const generateVipSchedule = (department: string, transportMode: string) => {
const today = new Date();
const eventDate = new Date(today);
eventDate.setDate(eventDate.getDate() + 1); // Tomorrow
const formatDateTime = (hour: number, minute: number = 0) => {
const date = new Date(eventDate);
date.setHours(hour, minute, 0, 0);
return date.toISOString();
};
const baseSchedule = [
// Morning arrival and setup
{
title: transportMode === 'flight' ? 'Airport Pickup' : 'Arrival Check-in',
location: transportMode === 'flight' ? 'Airport Terminal' : 'Hotel Lobby',
startTime: formatDateTime(8, 0),
endTime: formatDateTime(9, 0),
description: transportMode === 'flight' ? 'Meet and greet at airport, transport to hotel' : 'Check-in and welcome briefing',
type: 'transport',
status: 'scheduled'
},
{
title: 'Welcome Breakfast',
location: 'Executive Dining Room',
startTime: formatDateTime(9, 0),
endTime: formatDateTime(10, 0),
description: 'Welcome breakfast with key stakeholders and orientation materials',
type: 'meal',
status: 'scheduled'
}
];
// Department-specific schedules
if (department === 'Admin') {
return [
...baseSchedule,
{
title: 'Academic Leadership Meeting',
location: 'Board Room A',
startTime: formatDateTime(10, 30),
endTime: formatDateTime(12, 0),
description: 'Strategic planning session with academic leadership team',
type: 'meeting',
status: 'scheduled'
},
{
title: 'Working Lunch',
location: 'Faculty Club',
startTime: formatDateTime(12, 0),
endTime: formatDateTime(13, 30),
description: 'Lunch meeting with department heads and key faculty',
type: 'meal',
status: 'scheduled'
},
{
title: 'Campus Tour',
location: 'Main Campus',
startTime: formatDateTime(14, 0),
endTime: formatDateTime(15, 30),
description: 'Guided tour of campus facilities and research centers',
type: 'event',
status: 'scheduled'
},
{
title: 'Research Presentation',
location: 'Auditorium B',
startTime: formatDateTime(16, 0),
endTime: formatDateTime(17, 30),
description: 'Presentation of current research initiatives and future plans',
type: 'meeting',
status: 'scheduled'
},
{
title: 'Reception Dinner',
location: 'University Club',
startTime: formatDateTime(19, 0),
endTime: formatDateTime(21, 0),
description: 'Formal dinner reception with university leadership',
type: 'event',
status: 'scheduled'
}
];
} else {
// Office of Development schedule
return [
...baseSchedule,
{
title: 'Donor Relations Meeting',
location: 'Development Office',
startTime: formatDateTime(10, 30),
endTime: formatDateTime(12, 0),
description: 'Private meeting with development team about giving opportunities',
type: 'meeting',
status: 'scheduled'
},
{
title: 'Scholarship Recipients Lunch',
location: 'Student Center',
startTime: formatDateTime(12, 0),
endTime: formatDateTime(13, 30),
description: 'Meet with current scholarship recipients and hear their stories',
type: 'meal',
status: 'scheduled'
},
{
title: 'Facility Naming Ceremony',
location: 'New Science Building',
startTime: formatDateTime(14, 0),
endTime: formatDateTime(15, 0),
description: 'Dedication ceremony for newly named facility',
type: 'event',
status: 'scheduled'
},
{
title: 'Impact Presentation',
location: 'Conference Room C',
startTime: formatDateTime(15, 30),
endTime: formatDateTime(16, 30),
description: 'Presentation on the impact of philanthropic giving',
type: 'meeting',
status: 'scheduled'
},
{
title: 'Private Dinner',
location: 'Presidents House',
startTime: formatDateTime(18, 30),
endTime: formatDateTime(20, 30),
description: 'Intimate dinner with university president and spouse',
type: 'meal',
status: 'scheduled'
},
{
title: 'Evening Cultural Event',
location: 'Arts Center',
startTime: formatDateTime(21, 0),
endTime: formatDateTime(22, 30),
description: 'Special performance by university arts programs',
type: 'event',
status: 'scheduled'
}
];
}
};

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,51 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
],
css: {
postcss: './postcss.config.mjs',
},
server: {
host: '0.0.0.0',
port: 5173,
allowedHosts: [
'localhost',
'127.0.0.1'
],
proxy: {
'/api': {
target: 'http://backend:3000',
changeOrigin: true,
},
// Only proxy specific auth endpoints, not the callback route
'/auth/setup': {
target: 'http://backend:3000',
changeOrigin: true,
},
'/auth/google/url': {
target: 'http://backend:3000',
changeOrigin: true,
},
'/auth/google/exchange': {
target: 'http://backend:3000',
changeOrigin: true,
},
'/auth/me': {
target: 'http://backend:3000',
changeOrigin: true,
},
'/auth/logout': {
target: 'http://backend:3000',
changeOrigin: true,
},
'/auth/status': {
target: 'http://backend:3000',
changeOrigin: true,
},
},
},
})

View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/tests/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/tests/',
'*.config.*',
'src/main.tsx',
'src/vite-env.d.ts',
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});