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

@@ -1,72 +0,0 @@
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

@@ -1,110 +1,159 @@
import React, { useState } from 'react';
interface DriverFormData {
name: string;
phone: string;
vehicleCapacity: number;
}
import { useState } from 'react';
import { X } from 'lucide-react';
interface DriverFormProps {
onSubmit: (driverData: DriverFormData) => void;
driver?: Driver | null;
onSubmit: (data: DriverFormData) => void;
onCancel: () => void;
isSubmitting: boolean;
}
const DriverForm: React.FC<DriverFormProps> = ({ onSubmit, onCancel }) => {
interface Driver {
id: string;
name: string;
phone: string;
department: string | null;
userId: string | null;
}
export interface DriverFormData {
name: string;
phone: string;
department?: string;
userId?: string;
}
export function DriverForm({ driver, onSubmit, onCancel, isSubmitting }: DriverFormProps) {
const [formData, setFormData] = useState<DriverFormData>({
name: '',
phone: '',
vehicleCapacity: 4
name: driver?.name || '',
phone: driver?.phone || '',
department: driver?.department || 'OFFICE_OF_DEVELOPMENT',
userId: driver?.userId || '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(formData);
// Clean up the data - remove empty strings for optional fields
const cleanedData = {
...formData,
department: formData.department || undefined,
userId: formData.userId || undefined,
};
onSubmit(cleanedData);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
setFormData(prev => ({
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'number' || name === 'vehicleCapacity' ? parseInt(value) || 0 : value
[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">Add New Driver</h2>
<p className="text-slate-600 mt-2">Enter driver contact information</p>
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-2xl font-bold text-gray-900">
{driver ? 'Edit Driver' : 'Add New Driver'}
</h2>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-6 w-6" />
</button>
</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>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Full Name *
</label>
<input
type="text"
name="name"
required
value={formData.name}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</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>
{/* Phone */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Phone Number *
</label>
<input
type="tel"
name="phone"
required
value={formData.phone}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</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>
{/* Department */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Department
</label>
<select
name="department"
value={formData.department}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">Select Department</option>
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
<option value="ADMIN">Admin</option>
</select>
</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>
{/* User ID (optional, for linking driver to user account) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
User Account ID (Optional)
</label>
<input
type="text"
name="userId"
value={formData.userId}
onChange={handleChange}
placeholder="Leave blank for standalone driver"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
<p className="mt-1 text-xs text-gray-500">
Link this driver to a user account for login access
</p>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 bg-primary text-white py-2 px-4 rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : driver ? 'Update Driver' : 'Create Driver'}
</button>
<button
type="button"
onClick={onCancel}
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
};
export default DriverForm;
}

View File

@@ -1,368 +0,0 @@
import React, { useState, useEffect } from 'react';
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 [availability, setAvailability] = useState<DriverAvailability[]>([]);
const [loading, setLoading] = useState(false);
const [showConflictModal, setShowConflictModal] = useState(false);
const [selectedDriver, setSelectedDriver] = useState<DriverAvailability | null>(null);
useEffect(() => {
if (eventTime.startTime && eventTime.endTime) {
checkDriverAvailability();
}
}, [eventTime.startTime, eventTime.endTime, eventTime.location]);
const checkDriverAvailability = async () => {
setLoading(true);
try {
const token = localStorage.getItem('authToken');
const response = await apiCall('/api/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

@@ -1,188 +0,0 @@
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

@@ -1,541 +0,0 @@
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

@@ -1,14 +1,14 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import React, { Component, ReactNode } from 'react';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
errorInfo: React.ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
@@ -17,7 +17,7 @@ export class ErrorBoundary extends Component<Props, State> {
this.state = {
hasError: false,
error: null,
errorInfo: null
errorInfo: null,
};
}
@@ -25,83 +25,72 @@ export class ErrorBoundary extends Component<Props, State> {
return {
hasError: true,
error,
errorInfo: null
errorInfo: null,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[ERROR BOUNDARY] Caught error:', error, errorInfo);
this.setState({
errorInfo
error,
errorInfo,
});
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null
});
handleReload = () => {
window.location.reload();
};
handleGoHome = () => {
window.location.href = '/';
};
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 className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
<div className="flex justify-center mb-6">
<div className="rounded-full bg-red-100 p-4">
<AlertTriangle className="h-12 w-12 text-red-600" />
</div>
</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.
<h1 className="text-2xl font-bold text-gray-900 text-center mb-4">
Something went wrong
</h1>
<p className="text-gray-600 text-center mb-6">
The application encountered an unexpected error. Please try refreshing the page or returning to the home page.
</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>
{process.env.NODE_ENV === 'development' && this.state.error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg overflow-auto">
<p className="text-sm font-mono text-red-800 mb-2">
{this.state.error.toString()}
</p>
{this.state.errorInfo && (
<pre className="text-xs text-red-700 overflow-auto max-h-40">
{this.state.errorInfo.componentStack}
</pre>
)}
</div>
)}
<div className="flex space-x-4">
<div className="flex gap-3">
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
onClick={this.handleReload}
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90"
>
Refresh Page
<RefreshCw className="h-4 w-4 mr-2" />
Reload Page
</button>
<button
onClick={this.handleReset}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors"
onClick={this.handleGoHome}
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Try Again
<Home className="h-4 w-4 mr-2" />
Go Home
</button>
</div>
</div>
@@ -111,4 +100,4 @@ export class ErrorBoundary extends Component<Props, State> {
return this.props.children;
}
}
}

View File

@@ -1,53 +1,36 @@
import React from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
interface ErrorMessageProps {
title?: string;
message: string;
onDismiss?: () => void;
className?: string;
onRetry?: () => void;
}
export const ErrorMessage: React.FC<ErrorMessageProps> = ({
message,
onDismiss,
className = ''
}) => {
export function ErrorMessage({
title = 'Error',
message,
onRetry
}: ErrorMessageProps) {
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 className="flex items-center justify-center py-12">
<div className="text-center max-w-md">
<div className="flex justify-center mb-4">
<div className="rounded-full bg-red-100 p-3">
<AlertCircle className="h-8 w-8 text-red-600" />
</div>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">{title}</h3>
<p className="text-gray-600 mb-4">{message}</p>
{onRetry && (
<button
onClick={onRetry}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90"
>
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</button>
)}
</div>
</div>
);
};
}

View File

@@ -0,0 +1,312 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { X } from 'lucide-react';
import { api } from '@/lib/api';
interface EventFormProps {
event?: ScheduleEvent | null;
onSubmit: (data: EventFormData) => void;
onCancel: () => void;
isSubmitting: boolean;
}
interface ScheduleEvent {
id: string;
vipId: string;
title: string;
location: string | null;
startTime: string;
endTime: string;
description: string | null;
type: string;
status: string;
driverId: string | null;
}
interface VIP {
id: string;
name: string;
organization: string | null;
}
interface Driver {
id: string;
name: string;
phone: string;
}
export interface EventFormData {
vipId: string;
title: string;
location?: string;
startTime: string;
endTime: string;
description?: string;
type: string;
status: string;
driverId?: string;
}
export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventFormProps) {
// Helper to convert ISO datetime to datetime-local format
const toDatetimeLocal = (isoString: string | null | undefined) => {
if (!isoString) return '';
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
const [formData, setFormData] = useState<EventFormData>({
vipId: event?.vipId || '',
title: event?.title || '',
location: event?.location || '',
startTime: toDatetimeLocal(event?.startTime),
endTime: toDatetimeLocal(event?.endTime),
description: event?.description || '',
type: event?.type || 'TRANSPORT',
status: event?.status || 'SCHEDULED',
driverId: event?.driverId || '',
});
// Fetch VIPs for dropdown
const { data: vips } = useQuery<VIP[]>({
queryKey: ['vips'],
queryFn: async () => {
const { data } = await api.get('/vips');
return data;
},
});
// Fetch Drivers for dropdown
const { data: drivers } = useQuery<Driver[]>({
queryKey: ['drivers'],
queryFn: async () => {
const { data } = await api.get('/drivers');
return data;
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Clean up the data - remove empty strings for optional fields and convert datetimes to ISO
const cleanedData = {
...formData,
startTime: new Date(formData.startTime).toISOString(),
endTime: new Date(formData.endTime).toISOString(),
location: formData.location || undefined,
description: formData.description || undefined,
driverId: formData.driverId || undefined,
};
onSubmit(cleanedData);
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-2xl font-bold text-gray-900">
{event ? 'Edit Event' : 'Add New Event'}
</h2>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* VIP Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
VIP *
</label>
<select
name="vipId"
required
value={formData.vipId}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">Select VIP</option>
{vips?.map((vip) => (
<option key={vip.id} value={vip.id}>
{vip.name} {vip.organization ? `(${vip.organization})` : ''}
</option>
))}
</select>
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Event Title *
</label>
<input
type="text"
name="title"
required
value={formData.title}
onChange={handleChange}
placeholder="e.g., Airport Pickup, Lunch Meeting"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{/* Location */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Location
</label>
<input
type="text"
name="location"
value={formData.location}
onChange={handleChange}
placeholder="e.g., LaGuardia Airport, Main Conference Room"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{/* Start & End Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Time *
</label>
<input
type="datetime-local"
name="startTime"
required
value={formData.startTime}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Time *
</label>
<input
type="datetime-local"
name="endTime"
required
value={formData.endTime}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Event Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Event Type *
</label>
<select
name="type"
required
value={formData.type}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<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>
{/* Status */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status *
</label>
<select
name="status"
required
value={formData.status}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="SCHEDULED">Scheduled</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="COMPLETED">Completed</option>
<option value="CANCELLED">Cancelled</option>
</select>
</div>
{/* Driver Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Assigned Driver
</label>
<select
name="driverId"
value={formData.driverId}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">No driver assigned</option>
{drivers?.map((driver) => (
<option key={driver.id} value={driver.id}>
{driver.name} ({driver.phone})
</option>
))}
</select>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
placeholder="Additional notes or instructions"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 bg-primary text-white py-2 px-4 rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : event ? 'Update Event' : 'Create Event'}
</button>
<button
type="button"
onClick={onCancel}
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,345 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { X } from 'lucide-react';
import { api } from '@/lib/api';
interface FlightFormProps {
flight?: Flight | null;
onSubmit: (data: FlightFormData) => void;
onCancel: () => void;
isSubmitting: boolean;
}
interface Flight {
id: string;
vipId: string;
flightNumber: string;
flightDate: string;
segment: number;
departureAirport: string;
arrivalAirport: string;
scheduledDeparture: string | null;
scheduledArrival: string | null;
actualDeparture: string | null;
actualArrival: string | null;
status: string | null;
}
interface VIP {
id: string;
name: string;
organization: string | null;
}
export interface FlightFormData {
vipId: string;
flightNumber: string;
flightDate: string;
segment?: number;
departureAirport: string;
arrivalAirport: string;
scheduledDeparture?: string;
scheduledArrival?: string;
actualDeparture?: string;
actualArrival?: string;
status?: string;
}
export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightFormProps) {
// Helper to convert ISO datetime to datetime-local format
const toDatetimeLocal = (isoString: string | null | undefined) => {
if (!isoString) return '';
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
const toDateOnly = (isoString: string | null | undefined) => {
if (!isoString) return '';
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const [formData, setFormData] = useState<FlightFormData>({
vipId: flight?.vipId || '',
flightNumber: flight?.flightNumber || '',
flightDate: toDateOnly(flight?.flightDate),
segment: flight?.segment || 1,
departureAirport: flight?.departureAirport || '',
arrivalAirport: flight?.arrivalAirport || '',
scheduledDeparture: toDatetimeLocal(flight?.scheduledDeparture),
scheduledArrival: toDatetimeLocal(flight?.scheduledArrival),
actualDeparture: toDatetimeLocal(flight?.actualDeparture),
actualArrival: toDatetimeLocal(flight?.actualArrival),
status: flight?.status || 'scheduled',
});
// Fetch VIPs for dropdown
const { data: vips } = useQuery<VIP[]>({
queryKey: ['vips'],
queryFn: async () => {
const { data } = await api.get('/vips');
return data;
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Clean up the data - remove empty strings and convert datetimes to ISO
const cleanedData: FlightFormData = {
vipId: formData.vipId,
flightNumber: formData.flightNumber,
flightDate: new Date(formData.flightDate).toISOString(),
segment: formData.segment,
departureAirport: formData.departureAirport,
arrivalAirport: formData.arrivalAirport,
scheduledDeparture: formData.scheduledDeparture
? new Date(formData.scheduledDeparture).toISOString()
: undefined,
scheduledArrival: formData.scheduledArrival
? new Date(formData.scheduledArrival).toISOString()
: undefined,
actualDeparture: formData.actualDeparture
? new Date(formData.actualDeparture).toISOString()
: undefined,
actualArrival: formData.actualArrival
? new Date(formData.actualArrival).toISOString()
: undefined,
status: formData.status || undefined,
};
onSubmit(cleanedData);
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value, type } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'number' ? Number(value) : value,
}));
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-2xl font-bold text-gray-900">
{flight ? 'Edit Flight' : 'Add New Flight'}
</h2>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* VIP Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
VIP *
</label>
<select
name="vipId"
required
value={formData.vipId}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">Select VIP</option>
{vips?.map((vip) => (
<option key={vip.id} value={vip.id}>
{vip.name} {vip.organization ? `(${vip.organization})` : ''}
</option>
))}
</select>
</div>
{/* Flight Number & Date */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Flight Number *
</label>
<input
type="text"
name="flightNumber"
required
value={formData.flightNumber}
onChange={handleChange}
placeholder="e.g., AA123"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Flight Date *
</label>
<input
type="date"
name="flightDate"
required
value={formData.flightDate}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Airports & Segment */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
From (IATA) *
</label>
<input
type="text"
name="departureAirport"
required
value={formData.departureAirport}
onChange={handleChange}
placeholder="JFK"
maxLength={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary uppercase"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
To (IATA) *
</label>
<input
type="text"
name="arrivalAirport"
required
value={formData.arrivalAirport}
onChange={handleChange}
placeholder="LAX"
maxLength={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary uppercase"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Segment
</label>
<input
type="number"
name="segment"
min="1"
value={formData.segment}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Scheduled Times */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Scheduled Departure
</label>
<input
type="datetime-local"
name="scheduledDeparture"
value={formData.scheduledDeparture}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Scheduled Arrival
</label>
<input
type="datetime-local"
name="scheduledArrival"
value={formData.scheduledArrival}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Actual Times */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Actual Departure
</label>
<input
type="datetime-local"
name="actualDeparture"
value={formData.actualDeparture}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Actual Arrival
</label>
<input
type="datetime-local"
name="actualArrival"
value={formData.actualArrival}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Status */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
name="status"
value={formData.status}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="scheduled">Scheduled</option>
<option value="boarding">Boarding</option>
<option value="departed">Departed</option>
<option value="en-route">En Route</option>
<option value="landed">Landed</option>
<option value="delayed">Delayed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 bg-primary text-white py-2 px-4 rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : flight ? 'Update Flight' : 'Create Flight'}
</button>
<button
type="button"
onClick={onCancel}
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,149 +0,0 @@
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

@@ -1,281 +0,0 @@
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

@@ -1,104 +0,0 @@
import { useEffect, useRef } from 'react';
interface GoogleLoginProps {
onSuccess: (user: any, token: string) => void;
onError: (error: string) => void;
}
// Helper to decode JWT token
function parseJwt(token: string) {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
} catch (e) {
return null;
}
}
declare global {
interface Window {
google: any;
}
}
const GoogleLogin: React.FC<GoogleLoginProps> = ({ onSuccess, onError }) => {
const buttonRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Initialize Google Sign-In
const initializeGoogleSignIn = () => {
if (!window.google) {
setTimeout(initializeGoogleSignIn, 100);
return;
}
window.google.accounts.id.initialize({
client_id: '308004695553-6k34bbq22frc4e76kejnkgq8mncepbbg.apps.googleusercontent.com',
callback: handleCredentialResponse,
auto_select: false,
cancel_on_tap_outside: true,
});
// Render the button
if (buttonRef.current) {
window.google.accounts.id.renderButton(
buttonRef.current,
{
theme: 'outline',
size: 'large',
text: 'signin_with',
shape: 'rectangular',
logo_alignment: 'center',
width: 300,
}
);
}
};
const handleCredentialResponse = async (response: any) => {
try {
// Send the Google credential to our backend
const res = await fetch('/auth/google/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ credential: response.credential }),
});
const data = await res.json();
if (!res.ok) {
if (data.error === 'pending_approval') {
// User created but needs approval - still successful login
onSuccess(data.user, data.token);
} else {
throw new Error(data.error || 'Authentication failed');
}
} else {
onSuccess(data.user, data.token);
}
} catch (error) {
console.error('Error during authentication:', error);
onError(error instanceof Error ? error.message : 'Failed to process authentication');
}
};
initializeGoogleSignIn();
}, [onSuccess, onError]);
return (
<div className="flex flex-col items-center">
<div ref={buttonRef} className="google-signin-button"></div>
<p className="mt-4 text-sm text-gray-600">
Sign in with your Google account to continue
</p>
</div>
);
};
export default GoogleLogin;

View File

@@ -1,45 +0,0 @@
import React from 'react';
import { apiCall } from '../utils/api';
interface GoogleOAuthButtonProps {
onSuccess: (user: any, token: string) => void;
onError: (error: string) => void;
}
const GoogleOAuthButton: React.FC<GoogleOAuthButtonProps> = ({ onSuccess, onError }) => {
const handleGoogleLogin = async () => {
try {
// Get the OAuth URL from backend
const { data } = await apiCall('/auth/google/url');
if (data && data.url) {
// Redirect to Google OAuth (no popup to avoid CORS issues)
window.location.href = data.url;
} else {
onError('Failed to get authentication URL');
}
} catch (error) {
console.error('Error initiating Google login:', error);
onError('Failed to start authentication');
}
};
return (
<button
onClick={handleGoogleLogin}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-lg shadow-sm bg-white hover:bg-gray-50 transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
<path fill="#4285F4" d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"/>
<path fill="#34A853" d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"/>
<path fill="#FBBC05" d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"/>
<path fill="#EA4335" d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"/>
</g>
</svg>
<span className="text-sm font-medium text-gray-700">Sign in with Google</span>
</button>
);
};
export default GoogleOAuthButton;

View File

@@ -0,0 +1,115 @@
import { ReactNode } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { useAbility } from '@/contexts/AbilityContext';
import { Action } from '@/lib/abilities';
import {
Plane,
Users,
Car,
Truck,
Calendar,
UserCog,
LogOut,
LayoutDashboard,
Settings,
Radio,
} from 'lucide-react';
interface LayoutProps {
children: ReactNode;
}
export function Layout({ children }: LayoutProps) {
const { user, backendUser, logout } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const ability = useAbility();
// Define navigation items with permission requirements
const allNavigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, alwaysShow: true },
{ name: 'War Room', href: '/command-center', icon: Radio, requireRead: 'ScheduleEvent' as const },
{ name: 'VIPs', href: '/vips', icon: Users, requireRead: 'VIP' as const },
{ name: 'Drivers', href: '/drivers', icon: Car, requireRead: 'Driver' as const },
{ name: 'Vehicles', href: '/vehicles', icon: Truck, requireRead: 'Vehicle' as const },
{ name: 'Schedule', href: '/events', icon: Calendar, requireRead: 'ScheduleEvent' as const },
{ name: 'Flights', href: '/flights', icon: Plane, requireRead: 'Flight' as const },
{ name: 'Users', href: '/users', icon: UserCog, requireRead: 'User' as const },
{ name: 'Admin Tools', href: '/admin-tools', icon: Settings, requireRead: 'User' as const },
];
// Filter navigation based on CASL permissions
const navigation = allNavigation.filter((item) => {
if (item.alwaysShow) return true;
if (item.requireRead) {
return ability.can(Action.Read, item.requireRead);
}
return true;
});
const isActive = (path: string) => location.pathname === path;
return (
<div className="min-h-screen bg-gray-50">
{/* Top Navigation */}
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<Plane className="h-8 w-8 text-primary" />
<span className="ml-2 text-xl font-bold text-gray-900">
VIP Coordinator
</span>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
{navigation.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.name}
to={item.href}
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${
isActive(item.href)
? 'border-primary text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
}`}
>
<Icon className="h-4 w-4 mr-2" />
{item.name}
</Link>
);
})}
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-sm font-medium text-gray-900">
{backendUser?.name || user?.name || user?.email}
</div>
{backendUser?.role && (
<div className="text-xs text-gray-500">
{backendUser.role}
</div>
)}
</div>
<button
onClick={logout}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90"
>
<LogOut className="h-4 w-4 mr-2" />
Sign Out
</button>
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { Loader2 } from 'lucide-react';
interface LoadingProps {
message?: string;
fullPage?: boolean;
}
export function Loading({ message = 'Loading...', fullPage = false }: LoadingProps) {
if (fullPage) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<Loader2 className="h-12 w-12 text-primary animate-spin mx-auto mb-4" />
<p className="text-gray-600 text-lg">{message}</p>
</div>
</div>
);
}
return (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Loader2 className="h-8 w-8 text-primary animate-spin mx-auto mb-3" />
<p className="text-gray-600">{message}</p>
</div>
</div>
);
}

View File

@@ -1,45 +0,0 @@
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

@@ -1,126 +0,0 @@
.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

@@ -1,184 +0,0 @@
import React, { useEffect, useState } from 'react';
import { apiCall } from '../config/api';
import './Login.css';
interface LoginProps {
onLogin: (user: any) => void;
}
const Login: React.FC<LoginProps> = ({ onLogin }) => {
const [setupStatus, setSetupStatus] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check system setup status
apiCall('/auth/setup')
.then(res => res.json())
.then(data => {
setSetupStatus(data);
setLoading(false);
})
.catch(error => {
console.error('Error checking setup status:', error);
setLoading(false);
});
// Check for OAuth callback code in URL
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const error = urlParams.get('error');
const token = urlParams.get('token');
if (code && (window.location.pathname === '/auth/google/callback' || window.location.pathname === '/auth/callback')) {
// Exchange code for token
apiCall('/auth/google/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ code })
})
.then(res => {
if (!res.ok) {
throw new Error('Failed to exchange code for token');
}
return res.json();
})
.then(({ token, user }) => {
localStorage.setItem('authToken', token);
onLogin(user);
// Clean up URL and redirect to dashboard
window.history.replaceState({}, document.title, '/');
})
.catch(error => {
console.error('OAuth exchange failed:', error);
alert('Login failed. Please try again.');
// Clean up URL
window.history.replaceState({}, document.title, '/');
});
} else if (token && (window.location.pathname === '/auth/callback' || window.location.pathname === '/auth/google/callback')) {
// Direct token from URL (from backend redirect)
localStorage.setItem('authToken', token);
apiCall('/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => {
if (!res.ok) {
throw new Error(`Failed to get user info: ${res.status} ${res.statusText}`);
}
return res.json();
})
.then(user => {
onLogin(user);
// Clean up URL and redirect to dashboard
window.history.replaceState({}, document.title, '/');
})
.catch(error => {
console.error('Error getting user info:', error);
alert('Login failed. Please try again.');
localStorage.removeItem('authToken');
// Clean up URL
window.history.replaceState({}, document.title, '/');
});
} else if (error) {
console.error('Authentication error:', error);
alert(`Login error: ${error}`);
// Clean up URL
window.history.replaceState({}, document.title, '/');
}
}, [onLogin]);
const handleGoogleLogin = async () => {
try {
// Get OAuth URL from backend
const response = await apiCall('/auth/google/url');
const { url } = await response.json();
// Redirect to Google OAuth
window.location.href = url;
} catch (error) {
console.error('Failed to get OAuth URL:', error);
alert('Login failed. Please try again.');
}
};
if (loading) {
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?.firstAdminCreated && (
<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={handleGoogleLogin}
disabled={false}
>
<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>
Continue with Google
</button>
<div className="login-info">
<p>
{setupStatus?.firstAdminCreated
? "Sign in with your Google account to access the VIP Coordinator."
: "Sign in with Google to set up your administrator account."
}
</p>
</div>
{setupStatus && !setupStatus.setupCompleted && (
<div style={{
marginTop: '1rem',
padding: '1rem',
backgroundColor: '#fff3cd',
borderRadius: '6px',
border: '1px solid #ffeaa7',
fontSize: '0.9rem'
}}>
<strong> Setup Required:</strong>
<p style={{ margin: '0.5rem 0 0 0' }}>
Google OAuth credentials need to be configured. If the login doesn't work,
please follow the setup guide in <code>GOOGLE_OAUTH_SETUP.md</code> to configure
your Google Cloud Console credentials in the admin dashboard.
</p>
</div>
)}
</div>
<div className="login-footer">
<p>Secure authentication powered by Google OAuth</p>
</div>
</div>
</div>
);
};
export default Login;

View File

@@ -1,109 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { apiCall } from '../utils/api';
const OAuthCallback: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(true);
useEffect(() => {
const handleCallback = async () => {
// Check for errors from OAuth provider
const errorParam = searchParams.get('error');
if (errorParam) {
setError(`Authentication failed: ${errorParam}`);
setProcessing(false);
return;
}
// Get the authorization code
const code = searchParams.get('code');
if (!code) {
setError('No authorization code received');
setProcessing(false);
return;
}
try {
// Exchange the code for a token
const response = await apiCall('/auth/google/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code }),
});
if (response.error === 'pending_approval') {
// User needs approval
localStorage.setItem('authToken', response.data.token);
localStorage.setItem('user', JSON.stringify(response.data.user));
navigate('/pending-approval');
return;
}
if (response.data && response.data.token) {
// Success! Store the token and user data
localStorage.setItem('authToken', response.data.token);
localStorage.setItem('user', JSON.stringify(response.data.user));
// Redirect to dashboard
window.location.href = '/';
} else {
setError('Failed to authenticate');
}
} catch (err: any) {
console.error('OAuth callback error:', err);
if (err.message?.includes('pending_approval')) {
// This means the user was created but needs approval
navigate('/');
} else {
setError(err.message || 'Authentication failed');
}
} finally {
setProcessing(false);
}
};
handleCallback();
}, [navigate, searchParams]);
if (processing) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center">
<div className="bg-white rounded-2xl shadow-xl p-8 text-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent mx-auto mb-4"></div>
<p className="text-lg font-medium text-slate-700">Completing sign in...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 className="text-xl font-bold text-slate-800 mb-2">Authentication Failed</h2>
<p className="text-slate-600 mb-4">{error}</p>
<button
onClick={() => navigate('/')}
className="btn btn-primary"
>
Back to Login
</button>
</div>
</div>
);
}
return null;
};
export default OAuthCallback;

View File

@@ -0,0 +1,32 @@
import { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
interface ProtectedRouteProps {
children: ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading, isApproved } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (!isApproved) {
return <Navigate to="/pending-approval" replace />;
}
return <>{children}</>;
}

View File

@@ -1,605 +0,0 @@
import { useState, useEffect } from 'react';
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 [schedule, setSchedule] = useState<ScheduleEvent[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
const [drivers, setDrivers] = useState<any[]>([]);
useEffect(() => {
fetchSchedule();
fetchDrivers();
}, [vipId]);
const fetchSchedule = async () => {
try {
const token = localStorage.getItem('authToken');
const response = await apiCall(`/api/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 () => {
try {
const token = localStorage.getItem('authToken');
const response = await apiCall('/api/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) {
try {
const token = localStorage.getItem('authToken');
const response = await apiCall(`/api/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) {
try {
const token = localStorage.getItem('authToken');
const response = await apiCall(`/api/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) {
try {
const token = localStorage.getItem('authToken');
const response = await apiCall(`/api/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

@@ -1,488 +0,0 @@
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

@@ -1,257 +0,0 @@
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

@@ -1,459 +1,245 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { X } from 'lucide-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;
interface VIPFormProps {
vip?: VIP | null;
onSubmit: (data: VIPFormData) => void;
onCancel: () => void;
isSubmitting: boolean;
}
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: ''
});
interface VIP {
id: string;
name: string;
organization: string | null;
department: string;
arrivalMode: string;
expectedArrival: string | null;
airportPickup: boolean;
venueTransport: boolean;
notes: string | null;
}
const [flightValidating, setFlightValidating] = useState<{ [key: number]: boolean }>({});
const [flightErrors, setFlightErrors] = useState<{ [key: number]: string }>({});
export interface VIPFormData {
name: string;
organization?: string;
department: string;
arrivalMode: string;
expectedArrival?: string;
airportPickup?: boolean;
venueTransport?: boolean;
notes?: string;
}
export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) {
// Helper to convert ISO datetime to datetime-local format
const toDatetimeLocal = (isoString: string | null) => {
if (!isoString) return '';
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
const [formData, setFormData] = useState<VIPFormData>({
name: vip?.name || '',
organization: vip?.organization || '',
department: vip?.department || 'OFFICE_OF_DEVELOPMENT',
arrivalMode: vip?.arrivalMode || 'FLIGHT',
expectedArrival: toDatetimeLocal(vip?.expectedArrival || null),
airportPickup: vip?.airportPickup ?? false,
venueTransport: vip?.venueTransport ?? false,
notes: vip?.notes || '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Only include flights with flight numbers
const validFlights = formData.flights?.filter(f => f.flightNumber) || [];
onSubmit({
// Clean up the data - remove empty strings for optional fields
const cleanedData = {
...formData,
flights: validFlights.length > 0 ? validFlights : undefined
});
organization: formData.organization || undefined,
expectedArrival: formData.expectedArrival
? new Date(formData.expectedArrival).toISOString()
: undefined,
notes: formData.notes || undefined,
};
onSubmit(cleanedData);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
) => {
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 => ({
setFormData((prev) => ({
...prev,
transportMode: mode,
flights: mode === 'flight' ? [{ flightNumber: '', flightDate: '', segment: 1 }] : undefined,
expectedArrival: mode === 'self-driving' ? prev.expectedArrival : '',
needsAirportPickup: mode === 'flight' ? true : false
[name]:
type === 'checkbox'
? (e.target as HTMLInputElement).checked
: value,
}));
// 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
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-2xl font-bold text-gray-900">
{vip ? 'Edit VIP' : 'Add New VIP'}
</h2>
<p className="text-slate-600 mt-2">Enter VIP details and travel information</p>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-6 w-6" />
</button>
</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>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Full Name *
</label>
<input
type="text"
name="name"
required
value={formData.name}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</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>
{/* Organization */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Organization
</label>
<input
type="text"
name="organization"
value={formData.organization}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</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>
{/* Department */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Department *
</label>
<select
name="department"
required
value={formData.department}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
<option value="ADMIN">Admin</option>
</select>
</div>
{/* Arrival Mode */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Arrival Mode *
</label>
<select
name="arrivalMode"
required
value={formData.arrivalMode}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="FLIGHT">Flight</option>
<option value="SELF_DRIVING">Self Driving</option>
</select>
</div>
{/* Expected Arrival */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Expected Arrival
</label>
<input
type="datetime-local"
name="expectedArrival"
value={formData.expectedArrival}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{/* Transport Checkboxes */}
<div className="space-y-2">
<div className="flex items-center">
<input
type="checkbox"
name="airportPickup"
checked={formData.airportPickup}
onChange={handleChange}
className="h-4 w-4 text-primary border-gray-300 rounded focus:ring-primary"
/>
<label className="ml-2 block text-sm text-gray-700">
Airport pickup required
</label>
</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 className="flex items-center">
<input
type="checkbox"
name="venueTransport"
checked={formData.venueTransport}
onChange={handleChange}
className="h-4 w-4 text-primary border-gray-300 rounded focus:ring-primary"
/>
<label className="ml-2 block text-sm text-gray-700">
Venue transport required
</label>
</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>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notes
</label>
<textarea
name="notes"
value={formData.notes}
onChange={handleChange}
rows={3}
placeholder="Any special requirements or notes"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</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>
{/* Actions */}
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 bg-primary text-white py-2 px-4 rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : vip ? 'Update VIP' : 'Create VIP'}
</button>
<button
type="button"
onClick={onCancel}
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
};
export default VipForm;
}

View File

@@ -1,168 +0,0 @@
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

@@ -1,196 +0,0 @@
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();
});
});