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

View File

@@ -1,176 +1,119 @@
import { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import { apiCall } from './config/api';
import VipList from './pages/VipList';
import VipDetails from './pages/VipDetails';
import DriverList from './pages/DriverList';
import DriverDashboard from './pages/DriverDashboard';
import Dashboard from './pages/Dashboard';
import AdminDashboard from './pages/AdminDashboard';
import UserManagement from './components/UserManagement';
import Login from './components/Login';
import './App.css';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Auth0Provider } from '@auth0/auth0-react';
import { Toaster } from 'react-hot-toast';
import { AuthProvider } from '@/contexts/AuthContext';
import { AbilityProvider } from '@/contexts/AbilityContext';
import { ProtectedRoute } from '@/components/ProtectedRoute';
import { Layout } from '@/components/Layout';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { Login } from '@/pages/Login';
import { Callback } from '@/pages/Callback';
import { PendingApproval } from '@/pages/PendingApproval';
import { Dashboard } from '@/pages/Dashboard';
import { CommandCenter } from '@/pages/CommandCenter';
import { VIPList } from '@/pages/VIPList';
import { VIPSchedule } from '@/pages/VIPSchedule';
import { DriverList } from '@/pages/DriverList';
import { VehicleList } from '@/pages/VehicleList';
import { EventList } from '@/pages/EventList';
import { FlightList } from '@/pages/FlightList';
import { UserList } from '@/pages/UserList';
import { AdminTools } from '@/pages/AdminTools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
const audience = import.meta.env.VITE_AUTH0_AUDIENCE;
function App() {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check if user is already authenticated
const token = localStorage.getItem('authToken');
if (token) {
apiCall('/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => {
if (res.ok) {
return res.json();
} else {
// Token is invalid, remove it
localStorage.removeItem('authToken');
throw new Error('Invalid token');
}
})
.then(userData => {
setUser(userData);
setLoading(false);
})
.catch(error => {
console.error('Auth check failed:', error);
setLoading(false);
});
} else {
setLoading(false);
}
}, []);
const handleLogin = (userData: any) => {
setUser(userData);
};
const handleLogout = () => {
localStorage.removeItem('authToken');
setUser(null);
// Optionally call logout endpoint
apiCall('/auth/logout', { method: 'POST' })
.catch(error => console.error('Logout error:', error));
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
<div className="bg-white rounded-2xl shadow-xl p-8 flex items-center space-x-4">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<span className="text-lg font-medium text-slate-700">Loading VIP Coordinator...</span>
</div>
</div>
);
}
// Handle OAuth callback route even when not logged in
if (window.location.pathname === '/auth/callback' || window.location.pathname === '/auth/google/callback') {
return <Login onLogin={handleLogin} />;
}
if (!user) {
return <Login onLogin={handleLogin} />;
}
return (
<Router>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
{/* Modern Navigation */}
<nav className="bg-white/80 backdrop-blur-lg border-b border-slate-200/60 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo/Brand */}
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">VC</span>
</div>
<h1 className="text-xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent">
VIP Coordinator
</h1>
</div>
<ErrorBoundary>
<Auth0Provider
domain={domain}
clientId={clientId}
authorizationParams={{
redirect_uri: `${window.location.origin}/callback`,
audience: audience,
}}
useRefreshTokens={true}
cacheLocation="localstorage"
>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<AbilityProvider>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#333',
color: '#fff',
},
success: {
duration: 3000,
iconTheme: {
primary: '#10b981',
secondary: '#fff',
},
},
error: {
duration: 5000,
iconTheme: {
primary: '#ef4444',
secondary: '#fff',
},
},
}}
/>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/callback" element={<Callback />} />
<Route path="/pending-approval" element={<PendingApproval />} />
{/* Navigation Links */}
<div className="hidden md:flex items-center space-x-1">
<Link
to="/"
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all duration-200"
>
Dashboard
</Link>
<Link
to="/vips"
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all duration-200"
>
VIPs
</Link>
<Link
to="/drivers"
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all duration-200"
>
Drivers
</Link>
{(user.role === 'administrator' || user.role === 'coordinator') && (
<Link
to="/admin"
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-all duration-200"
>
Admin
</Link>
)}
{user.role === 'administrator' && (
<Link
to="/users"
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-all duration-200"
>
Users
</Link>
)}
</div>
{/* User Menu */}
<div className="flex items-center space-x-4">
<div className="hidden sm:flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-br from-slate-400 to-slate-600 rounded-full flex items-center justify-center">
<span className="text-white text-xs font-medium">
{user.name.charAt(0).toUpperCase()}
</span>
</div>
<div className="text-sm">
<div className="font-medium text-slate-900">{user.name}</div>
<div className="text-slate-500 capitalize">{user.role}</div>
</div>
</div>
<button
onClick={handleLogout}
className="bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-lg hover:shadow-xl"
>
Logout
</button>
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-6 lg:px-8 py-8">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/vips" element={<VipList />} />
<Route path="/vips/:id" element={<VipDetails />} />
<Route path="/drivers" element={<DriverList />} />
<Route path="/drivers/:driverId" element={<DriverDashboard />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/users" element={<UserManagement currentUser={user} />} />
</Routes>
</main>
</div>
</Router>
<Route
path="/*"
element={
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/command-center" element={<CommandCenter />} />
<Route path="/vips" element={<VIPList />} />
<Route path="/vips/:id/schedule" element={<VIPSchedule />} />
<Route path="/drivers" element={<DriverList />} />
<Route path="/vehicles" element={<VehicleList />} />
<Route path="/events" element={<EventList />} />
<Route path="/flights" element={<FlightList />} />
<Route path="/users" element={<UserList />} />
<Route path="/admin-tools" element={<AdminTools />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
</AbilityProvider>
</AuthProvider>
</QueryClientProvider>
</Auth0Provider>
</ErrorBoundary>
);
}

View File

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

View File

@@ -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();
});
});

View File

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

View File

@@ -0,0 +1,63 @@
import { createContext, useContext, ReactNode } from 'react';
import { createContextualCan } from '@casl/react';
import { defineAbilitiesFor, AppAbility, User } from '@/lib/abilities';
import { useAuth } from './AuthContext';
/**
* CASL Ability Context
*/
const AbilityContext = createContext<AppAbility | undefined>(undefined);
/**
* Can component for conditional rendering based on permissions
*
* @example
* <Can I="create" a="VIP">
* <button>Add VIP</button>
* </Can>
*
* @example
* <Can I="update" a="ScheduleEvent">
* <button>Edit Event</button>
* </Can>
*/
export const Can = createContextualCan(AbilityContext.Consumer);
/**
* Provider component that wraps the app with CASL abilities
*/
export function AbilityProvider({ children }: { children: ReactNode }) {
const { backendUser } = useAuth();
// Build abilities based on current user
const ability = defineAbilitiesFor(backendUser as User | null);
return (
<AbilityContext.Provider value={ability}>
{children}
</AbilityContext.Provider>
);
}
/**
* Hook to access CASL ability in components
*
* @example
* const ability = useAbility();
* if (ability.can('create', 'VIP')) {
* // Show create button
* }
*
* @example
* const ability = useAbility();
* const canEdit = ability.can('update', 'ScheduleEvent');
*/
export function useAbility(): AppAbility {
const ability = useContext(AbilityContext);
if (!ability) {
throw new Error('useAbility must be used within an AbilityProvider');
}
return ability;
}

View File

@@ -0,0 +1,94 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { api } from '@/lib/api';
interface BackendUser {
id: string;
auth0Id: string;
email: string;
name: string | null;
picture: string | null;
role: string;
isApproved: boolean;
}
interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
user: any;
backendUser: BackendUser | null;
isApproved: boolean;
loginWithRedirect: () => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const {
isAuthenticated,
isLoading,
user,
loginWithRedirect,
logout: auth0Logout,
getAccessTokenSilently,
} = useAuth0();
const [backendUser, setBackendUser] = useState<BackendUser | null>(null);
const [fetchingUser, setFetchingUser] = useState(false);
// Set up token and fetch backend user profile
useEffect(() => {
if (isAuthenticated && !fetchingUser) {
setFetchingUser(true);
getAccessTokenSilently()
.then(async (token) => {
localStorage.setItem('auth0_token', token);
// Fetch backend user profile
try {
const response = await api.get('/auth/profile');
setBackendUser(response.data);
} catch (error) {
console.error('[AUTH] Failed to fetch user profile:', error);
setBackendUser(null);
}
})
.catch((error) => {
console.error('[AUTH] Failed to get token:', error);
})
.finally(() => {
setFetchingUser(false);
});
}
}, [isAuthenticated, getAccessTokenSilently]);
const handleLogout = () => {
localStorage.removeItem('auth0_token');
auth0Logout({ logoutParams: { returnTo: window.location.origin } });
};
return (
<AuthContext.Provider
value={{
isAuthenticated,
isLoading: isLoading || fetchingUser,
user,
backendUser,
isApproved: backendUser?.isApproved ?? false,
loginWithRedirect,
logout: handleLogout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,94 @@
import { AbilityBuilder, PureAbility, AbilityClass } from '@casl/ability';
/**
* Define all possible actions in the system
*/
export enum Action {
Manage = 'manage', // Special: allows everything
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
Approve = 'approve', // Special: for user approval
UpdateStatus = 'update-status', // Special: for drivers to update event status
}
/**
* Define all subjects (resources) in the system
*/
export type Subjects =
| 'User'
| 'VIP'
| 'Driver'
| 'ScheduleEvent'
| 'Flight'
| 'Vehicle'
| 'all';
/**
* Define the AppAbility type
*/
export type AppAbility = PureAbility<[Action, Subjects]>;
/**
* User role type (must match backend)
*/
export type UserRole = 'ADMINISTRATOR' | 'COORDINATOR' | 'DRIVER';
/**
* User interface (simplified from backend)
*/
export interface User {
id: string;
role: UserRole;
isApproved: boolean;
driver?: {
id: string;
} | null;
}
/**
* Define abilities for a user based on their role
*/
export function defineAbilitiesFor(user: User | null): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
PureAbility as AbilityClass<AppAbility>,
);
if (!user) {
// Not authenticated - no permissions
return build();
}
// Define permissions based on role
if (user.role === 'ADMINISTRATOR') {
// Administrators can do everything
can(Action.Manage, 'all');
} else if (user.role === 'COORDINATOR') {
// Coordinators have full access except user management
can(Action.Read, 'all');
can(Action.Create, ['VIP', 'Driver', 'ScheduleEvent', 'Flight', 'Vehicle']);
can(Action.Update, ['VIP', 'Driver', 'ScheduleEvent', 'Flight', 'Vehicle']);
can(Action.Delete, ['VIP', 'Driver', 'ScheduleEvent', 'Flight', 'Vehicle']);
// Cannot manage users
cannot(Action.Create, 'User');
cannot(Action.Update, 'User');
cannot(Action.Delete, 'User');
cannot(Action.Approve, 'User');
} else if (user.role === 'DRIVER') {
// Drivers can only read most resources
can(Action.Read, ['VIP', 'Driver', 'ScheduleEvent', 'Vehicle']);
// Drivers can update status of events (specific instance check would need event data)
can(Action.UpdateStatus, 'ScheduleEvent');
// Cannot access flights
cannot(Action.Read, 'Flight');
// Cannot access users
cannot(Action.Read, 'User');
}
return build();
}

91
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,91 @@
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
const DEBUG_MODE = import.meta.env.DEV; // Enable detailed logging in development
export const api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 30000, // 30 second timeout
});
// Request interceptor to add auth token and log requests
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth0_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Log request in development mode
if (DEBUG_MODE) {
console.log(`[API] → ${config.method?.toUpperCase()} ${config.url}`, {
data: config.data,
params: config.params,
});
}
return config;
},
(error) => {
console.error('[API] Request error:', error);
return Promise.reject(error);
}
);
// Response interceptor for logging and error handling
api.interceptors.response.use(
(response) => {
// Log successful response in development mode
if (DEBUG_MODE) {
console.log(`[API] ← ${response.status} ${response.config.method?.toUpperCase()} ${response.config.url}`, {
data: response.data,
});
}
return response;
},
(error) => {
const { config, response } = error;
// Enhanced error logging
if (response) {
// Server responded with error status
console.error(
`[API] ✖ ${response.status} ${config?.method?.toUpperCase()} ${config?.url}`,
{
status: response.status,
statusText: response.statusText,
data: response.data,
requestData: config?.data,
}
);
// Log specific error types
if (response.status === 401) {
console.warn('[API] Authentication required - user may need to log in again');
} else if (response.status === 403) {
console.warn('[API] Permission denied - user lacks required permissions');
} else if (response.status === 404) {
console.warn('[API] Resource not found');
} else if (response.status === 409) {
console.warn('[API] Conflict detected:', response.data.conflicts || response.data.message);
} else if (response.status >= 500) {
console.error('[API] Server error - backend may be experiencing issues');
}
} else if (error.request) {
// Request was made but no response received
console.error('[API] ✖ Network error - no response received', {
method: config?.method?.toUpperCase(),
url: config?.url,
message: error.message,
});
} else {
// Something else happened
console.error('[API] ✖ Request setup error:', error.message);
}
return Promise.reject(error);
}
);

View File

@@ -0,0 +1,170 @@
import { AxiosError } from 'axios';
import toast from 'react-hot-toast';
/**
* Standard error response from backend
*/
interface BackendError {
statusCode: number;
message: string | string[];
error: string;
details?: any;
conflicts?: any[];
}
/**
* Error handling utility for consistent error messages across the app
*/
export class ErrorHandler {
/**
* Extract user-friendly error message from various error types
*/
static getMessage(error: unknown, fallback: string = 'An error occurred'): string {
// Axios error with backend response
if (this.isAxiosError(error) && error.response?.data) {
const data = error.response.data as BackendError;
// Handle array of messages
if (Array.isArray(data.message)) {
return data.message.join(', ');
}
// Handle single message
if (data.message) {
return data.message;
}
// Handle specific status codes
if (error.response.status === 401) {
return 'Authentication required. Please log in again.';
}
if (error.response.status === 403) {
return 'You do not have permission to perform this action.';
}
if (error.response.status === 404) {
return 'The requested resource was not found.';
}
if (error.response.status === 409) {
return 'A conflict occurred. Please check for overlapping schedules.';
}
if (error.response.status >= 500) {
return 'Server error. Please try again later.';
}
}
// Axios error without response (network error)
if (this.isAxiosError(error) && !error.response) {
return 'Network error. Please check your connection.';
}
// Standard Error object
if (error instanceof Error) {
return error.message;
}
// Unknown error type
return fallback;
}
/**
* Show error toast with extracted message
*/
static showError(error: unknown, fallback?: string): void {
const message = this.getMessage(error, fallback);
toast.error(message);
}
/**
* Log error to console with context
*/
static log(context: string, error: unknown, additionalInfo?: any): void {
const timestamp = new Date().toISOString();
const message = this.getMessage(error);
console.error(`[${timestamp}] [${context}] ${message}`);
if (this.isAxiosError(error)) {
console.error('Request:', {
method: error.config?.method?.toUpperCase(),
url: error.config?.url,
data: error.config?.data,
});
console.error('Response:', error.response?.data);
} else {
console.error('Error details:', error);
}
if (additionalInfo) {
console.error('Additional info:', additionalInfo);
}
}
/**
* Handle error with both toast and logging
*/
static handle(context: string, error: unknown, fallback?: string, additionalInfo?: any): void {
this.log(context, error, additionalInfo);
this.showError(error, fallback);
}
/**
* Type guard for AxiosError
*/
private static isAxiosError(error: unknown): error is AxiosError {
return (error as AxiosError)?.isAxiosError === true;
}
/**
* Extract conflict details if present
*/
static getConflicts(error: unknown): any[] | null {
if (this.isAxiosError(error) && error.response?.data) {
const data = error.response.data as BackendError;
return data.conflicts || null;
}
return null;
}
/**
* Check if error is a specific status code
*/
static isStatus(error: unknown, statusCode: number): boolean {
return this.isAxiosError(error) && error.response?.status === statusCode;
}
/**
* Check if error is authentication-related
*/
static isAuthError(error: unknown): boolean {
return this.isStatus(error, 401) || this.isStatus(error, 403);
}
/**
* Check if error is a conflict (409)
*/
static isConflict(error: unknown): boolean {
return this.isStatus(error, 409);
}
/**
* Check if error is validation-related (400)
*/
static isValidationError(error: unknown): boolean {
return this.isStatus(error, 400);
}
}
/**
* Convenience function for handling errors in try-catch blocks
*
* @example
* try {
* await api.createVIP(data);
* toast.success('VIP created successfully');
* } catch (error) {
* handleError('VIP Creation', error, 'Failed to create VIP');
* }
*/
export function handleError(context: string, error: unknown, fallback?: string, additionalInfo?: any): void {
ErrorHandler.handle(context, error, fallback, additionalInfo);
}

34
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,34 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
export function formatDateTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function formatTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
}

View File

@@ -1,10 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
);

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth0 } from '@auth0/auth0-react';
export function Callback() {
const { isAuthenticated, isLoading } = useAuth0();
const navigate = useNavigate();
useEffect(() => {
if (!isLoading && isAuthenticated) {
navigate('/dashboard');
}
}, [isAuthenticated, isLoading, navigate]);
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">Completing sign in...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,501 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Loading } from '@/components/Loading';
import {
Car,
Clock,
MapPin,
Users,
AlertTriangle,
CheckCircle,
Plane,
Radio,
} from 'lucide-react';
import { useEffect } from 'react';
interface Event {
id: string;
title: string;
pickupLocation: string | null;
dropoffLocation: string | null;
location: string | null;
startTime: string;
endTime: string;
actualStartTime: string | null;
status: string;
type: string;
vip: {
id: string;
name: string;
};
driver: {
id: string;
name: string;
} | null;
vehicle: {
id: string;
name: string;
type: string;
seatCapacity: number;
} | null;
}
interface Vehicle {
id: string;
name: string;
type: string;
seatCapacity: number;
status: string;
currentDriver: { name: string } | null;
}
interface VIP {
id: string;
name: string;
organization: string | null;
arrivalMode: string;
expectedArrival: string | null;
flights: Array<{
id: string;
flightNumber: string;
arrivalAirport: string;
scheduledArrival: string | null;
status: string | null;
}>;
}
export function CommandCenter() {
const { data: events, refetch: refetchEvents } = useQuery<Event[]>({
queryKey: ['events'],
queryFn: async () => {
const { data } = await api.get('/events');
return data;
},
});
const { data: vehicles, refetch: refetchVehicles } = useQuery<Vehicle[]>({
queryKey: ['vehicles'],
queryFn: async () => {
const { data } = await api.get('/vehicles');
return data;
},
});
const { data: vips, refetch: refetchVips } = useQuery<VIP[]>({
queryKey: ['vips'],
queryFn: async () => {
const { data } = await api.get('/vips');
return data;
},
});
// Auto-refresh every 30 seconds
useEffect(() => {
const interval = setInterval(() => {
refetchEvents();
refetchVehicles();
refetchVips();
}, 30000);
return () => clearInterval(interval);
}, [refetchEvents, refetchVehicles, refetchVips]);
if (!events || !vehicles || !vips) {
return <Loading message="Loading Command Center..." />;
}
const now = new Date();
const twoHoursLater = new Date(now.getTime() + 2 * 60 * 60 * 1000);
const fourHoursLater = new Date(now.getTime() + 4 * 60 * 60 * 1000);
// Active trips (next 2 hours)
const activeTrips = events
.filter((event) => {
const start = new Date(event.startTime);
return start > now && start <= twoHoursLater && event.type === 'TRANSPORT';
})
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
// En route trips (already started, not completed)
const enRouteTrips = events.filter(
(event) =>
event.status === 'IN_PROGRESS' && event.type === 'TRANSPORT'
);
// Upcoming arrivals (next 4 hours)
const upcomingArrivals = vips
.filter((vip) => {
if (vip.expectedArrival) {
const arrival = new Date(vip.expectedArrival);
return arrival > now && arrival <= fourHoursLater;
}
// Check flight arrivals
return vip.flights.some((flight) => {
if (flight.scheduledArrival) {
const arrival = new Date(flight.scheduledArrival);
return arrival > now && arrival <= fourHoursLater;
}
return false;
});
})
.sort((a, b) => {
const aTime = a.expectedArrival || a.flights[0]?.scheduledArrival || '';
const bTime = b.expectedArrival || b.flights[0]?.scheduledArrival || '';
return new Date(aTime).getTime() - new Date(bTime).getTime();
});
// Vehicle status
const availableVehicles = vehicles.filter((v) => v.status === 'AVAILABLE');
const inUseVehicles = vehicles.filter((v) => v.status === 'IN_USE');
// Get time until event
const getTimeUntil = (dateStr: string) => {
const eventTime = new Date(dateStr);
const diff = eventTime.getTime() - now.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 0) return 'NOW';
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
const remainingMin = minutes % 60;
return `${hours}h ${remainingMin}m`;
};
const getStatusColor = (status: string) => {
switch (status) {
case 'SCHEDULED':
return 'bg-blue-100 text-blue-800';
case 'IN_PROGRESS':
return 'bg-green-100 text-green-800';
case 'COMPLETED':
return 'bg-gray-100 text-gray-800';
case 'CANCELLED':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold text-gray-900">Command Center</h1>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Radio className="h-4 w-4 animate-pulse text-green-500" />
Auto-refreshing every 30s
</div>
</div>
{/* Resource Status Summary */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-lg shadow border-l-4 border-green-500">
<div className="flex justify-between items-center">
<div>
<p className="text-sm text-gray-600">Vehicles Available</p>
<p className="text-2xl font-bold text-green-600">
{availableVehicles.length}/{vehicles.length}
</p>
</div>
<Car className="h-8 w-8 text-green-500" />
</div>
</div>
<div className="bg-white p-4 rounded-lg shadow border-l-4 border-blue-500">
<div className="flex justify-between items-center">
<div>
<p className="text-sm text-gray-600">Vehicles In Use</p>
<p className="text-2xl font-bold text-blue-600">{inUseVehicles.length}</p>
</div>
<Car className="h-8 w-8 text-blue-500" />
</div>
</div>
<div className="bg-white p-4 rounded-lg shadow border-l-4 border-yellow-500">
<div className="flex justify-between items-center">
<div>
<p className="text-sm text-gray-600">Upcoming Trips</p>
<p className="text-2xl font-bold text-yellow-600">{activeTrips.length}</p>
</div>
<Clock className="h-8 w-8 text-yellow-500" />
</div>
</div>
<div className="bg-white p-4 rounded-lg shadow border-l-4 border-purple-500">
<div className="flex justify-between items-center">
<div>
<p className="text-sm text-gray-600">VIP Arrivals</p>
<p className="text-2xl font-bold text-purple-600">{upcomingArrivals.length}</p>
</div>
<Plane className="h-8 w-8 text-purple-500" />
</div>
</div>
</div>
{/* En Route Trips */}
{enRouteTrips.length > 0 && (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="bg-green-600 px-6 py-3 flex items-center">
<Car className="h-5 w-5 text-white mr-2" />
<h2 className="text-lg font-semibold text-white">
En Route Now ({enRouteTrips.length})
</h2>
</div>
<div className="divide-y divide-gray-200">
{enRouteTrips.map((trip) => (
<div key={trip.id} className="p-6 hover:bg-gray-50">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(trip.status)}`}>
{trip.status}
</span>
<span className="text-sm font-medium text-gray-900">
{trip.vip.name}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600">
<div className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
{trip.pickupLocation} {trip.dropoffLocation}
</div>
{trip.driver && (
<div className="flex items-center gap-1">
<Users className="h-4 w-4" />
Driver: {trip.driver.name}
</div>
)}
{trip.vehicle && (
<div className="flex items-center gap-1">
<Car className="h-4 w-4" />
{trip.vehicle.name}
</div>
)}
</div>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">
Started {new Date(trip.actualStartTime || trip.startTime).toLocaleTimeString()}
</p>
<p className="text-xs text-gray-400">
ETA: {new Date(trip.endTime).toLocaleTimeString()}
</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Active Trips - Next 2 Hours */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="bg-blue-600 px-6 py-3 flex items-center">
<Clock className="h-5 w-5 text-white mr-2" />
<h2 className="text-lg font-semibold text-white">
Upcoming Trips - Next 2 Hours ({activeTrips.length})
</h2>
</div>
{activeTrips.length === 0 ? (
<div className="p-6 text-center text-gray-500">
<CheckCircle className="h-12 w-12 mx-auto mb-2 text-gray-400" />
<p>No scheduled trips in the next 2 hours</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{activeTrips.map((trip) => {
const timeUntil = getTimeUntil(trip.startTime);
const isUrgent = new Date(trip.startTime).getTime() - now.getTime() < 15 * 60 * 1000;
return (
<div
key={trip.id}
className={`p-6 hover:bg-gray-50 ${isUrgent ? 'bg-yellow-50' : ''}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
{isUrgent && (
<div className="flex items-center gap-1 mb-2">
<AlertTriangle className="h-4 w-4 text-yellow-600" />
<span className="text-xs font-medium text-yellow-600">URGENT</span>
</div>
)}
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(trip.status)}`}>
{trip.status}
</span>
<span className="text-sm font-medium text-gray-900">
{trip.vip.name}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600">
<div className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
{trip.pickupLocation || trip.location} {trip.dropoffLocation || 'Destination'}
</div>
{trip.driver && (
<div className="flex items-center gap-1">
<Users className="h-4 w-4" />
Driver: {trip.driver.name}
</div>
)}
{trip.vehicle && (
<div className="flex items-center gap-1">
<Car className="h-4 w-4" />
{trip.vehicle.name} ({trip.vehicle.seatCapacity} seats)
</div>
)}
{!trip.driver && (
<span className="text-red-600 font-medium"> NO DRIVER ASSIGNED</span>
)}
{!trip.vehicle && (
<span className="text-red-600 font-medium"> NO VEHICLE ASSIGNED</span>
)}
</div>
</div>
<div className="text-right">
<p className="text-lg font-bold text-blue-600">
in {timeUntil}
</p>
<p className="text-sm text-gray-500">
Departs: {new Date(trip.startTime).toLocaleTimeString()}
</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Upcoming VIP Arrivals */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="bg-purple-600 px-6 py-3 flex items-center">
<Plane className="h-5 w-5 text-white mr-2" />
<h2 className="text-lg font-semibold text-white">
Incoming VIPs - Next 4 Hours ({upcomingArrivals.length})
</h2>
</div>
{upcomingArrivals.length === 0 ? (
<div className="p-6 text-center text-gray-500">
<CheckCircle className="h-12 w-12 mx-auto mb-2 text-gray-400" />
<p>No VIP arrivals in the next 4 hours</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{upcomingArrivals.map((vip) => {
const arrival = vip.expectedArrival || vip.flights[0]?.scheduledArrival;
const flight = vip.flights[0];
return (
<div key={vip.id} className="p-6 hover:bg-gray-50">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{vip.arrivalMode === 'FLIGHT' ? (
<Plane className="h-4 w-4 text-purple-600" />
) : (
<Car className="h-4 w-4 text-gray-600" />
)}
<span className="text-sm font-medium text-gray-900">{vip.name}</span>
{vip.organization && (
<span className="text-xs text-gray-500">({vip.organization})</span>
)}
</div>
{flight && (
<div className="text-sm text-gray-600 mb-1">
Flight {flight.flightNumber} {flight.arrivalAirport}
{flight.status && (
<span className="ml-2 text-xs">
Status: {flight.status}
</span>
)}
</div>
)}
<div className="text-sm text-gray-600">
Mode: {vip.arrivalMode.replace('_', ' ')}
</div>
</div>
<div className="text-right">
<p className="text-lg font-bold text-purple-600">
in {getTimeUntil(arrival!)}
</p>
<p className="text-sm text-gray-500">
{new Date(arrival!).toLocaleTimeString()}
</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Available Resources */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Available Vehicles */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="bg-gray-700 px-6 py-3 flex items-center">
<Car className="h-5 w-5 text-white mr-2" />
<h2 className="text-lg font-semibold text-white">
Available Vehicles ({availableVehicles.length})
</h2>
</div>
{availableVehicles.length === 0 ? (
<div className="p-6 text-center text-gray-500">
<AlertTriangle className="h-12 w-12 mx-auto mb-2 text-yellow-500" />
<p>No vehicles currently available</p>
</div>
) : (
<ul className="divide-y divide-gray-200">
{availableVehicles.map((vehicle) => (
<li key={vehicle.id} className="px-6 py-3">
<div className="flex justify-between items-center">
<div>
<p className="text-sm font-medium text-gray-900">{vehicle.name}</p>
<p className="text-xs text-gray-500">
{vehicle.type.replace('_', ' ')} - {vehicle.seatCapacity} seats
</p>
</div>
<CheckCircle className="h-5 w-5 text-green-500" />
</div>
</li>
))}
</ul>
)}
</div>
{/* In-Use Vehicles */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="bg-gray-700 px-6 py-3 flex items-center">
<Car className="h-5 w-5 text-white mr-2" />
<h2 className="text-lg font-semibold text-white">
In-Use Vehicles ({inUseVehicles.length})
</h2>
</div>
{inUseVehicles.length === 0 ? (
<div className="p-6 text-center text-gray-500">
<CheckCircle className="h-12 w-12 mx-auto mb-2 text-gray-400" />
<p>No vehicles currently in use</p>
</div>
) : (
<ul className="divide-y divide-gray-200">
{inUseVehicles.map((vehicle) => (
<li key={vehicle.id} className="px-6 py-3">
<div className="flex justify-between items-center">
<div>
<p className="text-sm font-medium text-gray-900">{vehicle.name}</p>
<p className="text-xs text-gray-500">
{vehicle.type.replace('_', ' ')} - {vehicle.seatCapacity} seats
{vehicle.currentDriver && ` - ${vehicle.currentDriver.name}`}
</p>
</div>
<div className="h-2 w-2 rounded-full bg-blue-500 animate-pulse" />
</div>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,378 +1,334 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { apiCall } from '../config/api';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/AuthContext';
import { Users, Car, Calendar, Plane, UserCheck, Clock } from 'lucide-react';
import { VIP, Driver, ScheduleEvent } from '@/types';
import { formatDateTime } from '@/lib/utils';
interface ScheduleEvent {
interface User {
id: string;
title: string;
location: string;
startTime: string;
endTime: string;
status: 'scheduled' | 'in-progress' | 'completed' | 'cancelled';
type: 'transport' | 'meeting' | 'event' | 'meal' | 'accommodation';
email: string;
name: string | null;
role: string;
isApproved: boolean;
createdAt: string;
}
interface Vip {
interface Flight {
id: string;
name: string;
organization: string;
transportMode: 'flight' | 'self-driving';
flightNumber?: string;
flights?: Array<{
flightNumber: string;
flightDate: string;
segment: number;
}>;
expectedArrival?: string;
arrivalTime?: string;
needsAirportPickup?: boolean;
needsVenueTransport: boolean;
notes?: string;
currentEvent?: ScheduleEvent;
nextEvent?: ScheduleEvent;
nextEventTime?: string;
vipId: string;
vip?: {
name: string;
organization: string | null;
};
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 Driver {
id: string;
name: string;
phone: string;
currentLocation: { lat: number; lng: number };
assignedVipIds: string[];
}
export function Dashboard() {
const { backendUser } = useAuth();
const isAdmin = backendUser?.role === 'ADMINISTRATOR';
const Dashboard: React.FC = () => {
const [vips, setVips] = useState<Vip[]>([]);
const [drivers, setDrivers] = useState<Driver[]>([]);
const [loading, setLoading] = useState(true);
const { data: vips } = useQuery<VIP[]>({
queryKey: ['vips'],
queryFn: async () => {
const { data } = await api.get('/vips');
return data;
},
});
// Helper functions for event management
const getCurrentEvent = (events: ScheduleEvent[]) => {
const now = new Date();
return events.find(event =>
new Date(event.startTime) <= now &&
new Date(event.endTime) > now &&
event.status === 'in-progress'
) || null;
};
const { data: drivers } = useQuery<Driver[]>({
queryKey: ['drivers'],
queryFn: async () => {
const { data } = await api.get('/drivers');
return data;
},
});
const getNextEvent = (events: ScheduleEvent[]) => {
const now = new Date();
const upcomingEvents = events.filter(event =>
new Date(event.startTime) > now && event.status === 'scheduled'
).sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
return upcomingEvents.length > 0 ? upcomingEvents[0] : null;
};
const { data: events } = useQuery<ScheduleEvent[]>({
queryKey: ['events'],
queryFn: async () => {
const { data } = await api.get('/events');
return data;
},
});
const formatTime = (timeString: string) => {
return new Date(timeString).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const { data: users } = useQuery<User[]>({
queryKey: ['users'],
queryFn: async () => {
const { data } = await api.get('/users');
return data;
},
enabled: isAdmin,
});
useEffect(() => {
const fetchData = async () => {
try {
const token = localStorage.getItem('authToken');
const authHeaders = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
const [vipsResponse, driversResponse] = await Promise.all([
apiCall('/api/vips', { headers: authHeaders }),
apiCall('/api/drivers', { headers: authHeaders })
]);
if (!vipsResponse.ok || !driversResponse.ok) {
throw new Error('Failed to fetch data');
}
const vipsData = await vipsResponse.json();
const driversData = await driversResponse.json();
// Fetch schedule for each VIP and determine current/next events
const vipsWithSchedules = await Promise.all(
vipsData.map(async (vip: Vip) => {
try {
const scheduleResponse = await apiCall(`/api/vips/${vip.id}/schedule`, {
headers: authHeaders
});
if (scheduleResponse.ok) {
const scheduleData = await scheduleResponse.json();
const currentEvent = getCurrentEvent(scheduleData);
const nextEvent = getNextEvent(scheduleData);
return {
...vip,
currentEvent,
nextEvent,
nextEventTime: nextEvent ? nextEvent.startTime : null
};
} else {
return { ...vip, currentEvent: null, nextEvent: null, nextEventTime: null };
}
} catch (error) {
console.error(`Error fetching schedule for VIP ${vip.id}:`, error);
return { ...vip, currentEvent: null, nextEvent: null, nextEventTime: null };
}
})
);
// Sort VIPs by next event time (soonest first), then by name
const sortedVips = vipsWithSchedules.sort((a, b) => {
// VIPs with current events first
if (a.currentEvent && !b.currentEvent) return -1;
if (!a.currentEvent && b.currentEvent) return 1;
// Then by next event time (soonest first)
if (a.nextEventTime && b.nextEventTime) {
return new Date(a.nextEventTime).getTime() - new Date(b.nextEventTime).getTime();
}
if (a.nextEventTime && !b.nextEventTime) return -1;
if (!a.nextEventTime && b.nextEventTime) return 1;
// Finally by name if no events
return a.name.localeCompare(b.name);
});
setVips(sortedVips);
setDrivers(driversData);
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
const { data: flights } = useQuery<Flight[]>({
queryKey: ['flights'],
queryFn: async () => {
const { data } = await api.get('/flights');
return data;
},
});
fetchData();
}, []);
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (loading) {
return (
<div className="flex justify-center items-center min-h-64">
<div className="bg-white rounded-2xl shadow-lg p-8 flex items-center space-x-4">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<span className="text-lg font-medium text-slate-700">Loading dashboard...</span>
</div>
</div>
);
}
const eventsToday = events?.filter((e) => {
const eventDate = new Date(e.startTime);
return eventDate >= today && eventDate < tomorrow && e.status !== 'CANCELLED';
}).length || 0;
const flightsToday = flights?.filter((f) => {
const flightDate = new Date(f.flightDate);
return flightDate >= today && flightDate < tomorrow;
}).length || 0;
const pendingApprovals = users?.filter((u) => !u.isApproved).length || 0;
const upcomingEvents = events
?.filter((e) => e.status === 'SCHEDULED' && new Date(e.startTime) >= today)
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime())
.slice(0, 5) || [];
const upcomingFlights = flights
?.filter((f) => {
const flightDate = new Date(f.flightDate);
return flightDate >= today && f.status !== 'cancelled';
})
.sort((a, b) => new Date(a.flightDate).getTime() - new Date(b.flightDate).getTime())
.slice(0, 5) || [];
const stats = [
{
name: 'Total VIPs',
value: vips?.length || 0,
icon: Users,
color: 'bg-blue-500',
},
{
name: 'Active Drivers',
value: drivers?.length || 0,
icon: Car,
color: 'bg-green-500',
},
{
name: 'Events Today',
value: eventsToday,
icon: Clock,
color: 'bg-purple-500',
},
{
name: 'Flights Today',
value: flightsToday,
icon: Plane,
color: 'bg-indigo-500',
},
...(isAdmin ? [{
name: 'Pending Approvals',
value: pendingApprovals,
icon: UserCheck,
color: 'bg-yellow-500',
}] : []),
];
return (
<div className="space-y-8">
{/* Header */}
<div className="bg-white rounded-2xl shadow-lg p-8 border border-slate-200/60">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent">
VIP Coordinator Dashboard
</h1>
<p className="text-slate-600 mt-2">Real-time overview of VIP activities and coordination</p>
</div>
<div className="flex items-center space-x-4">
<div className="bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium">
{vips.length} Active VIPs
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-8">Dashboard</h1>
{/* Stats Grid */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.name}
className="bg-white overflow-hidden shadow rounded-lg"
>
<div className="p-5">
<div className="flex items-center">
<div className={`flex-shrink-0 ${stat.color} rounded-md p-3`}>
<Icon className="h-6 w-6 text-white" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
{stat.name}
</dt>
<dd className="text-3xl font-semibold text-gray-900">
{stat.value}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-gradient-to-r from-green-500 to-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium">
{drivers.length} Drivers
</div>
</div>
</div>
);
})}
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* VIP Status Dashboard */}
<div className="xl:col-span-2">
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 px-6 py-4 border-b border-slate-200/60">
<h2 className="text-xl font-bold text-slate-800 flex items-center">
VIP Status Dashboard
<span className="ml-2 bg-blue-100 text-blue-800 text-sm font-medium px-2.5 py-0.5 rounded-full">
{vips.length} VIPs
</span>
</h2>
</div>
<div className="p-6">
{vips.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<div className="w-8 h-8 bg-slate-300 rounded-full"></div>
</div>
<p className="text-slate-500 font-medium">No VIPs currently scheduled</p>
</div>
) : (
<div className="space-y-4">
{vips.map((vip) => {
const hasCurrentEvent = !!vip.currentEvent;
const hasNextEvent = !!vip.nextEvent;
return (
<div key={vip.id} className={`
relative rounded-xl border-2 p-6 transition-all duration-200 hover:shadow-lg
${hasCurrentEvent
? 'border-amber-300 bg-gradient-to-r from-amber-50 to-orange-50'
: hasNextEvent
? 'border-blue-300 bg-gradient-to-r from-blue-50 to-indigo-50'
: 'border-slate-200 bg-slate-50'
}
`}>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<h3 className="text-lg font-bold text-slate-900">{vip.name}</h3>
{hasCurrentEvent && (
<span className="bg-gradient-to-r from-amber-500 to-orange-500 text-white px-3 py-1 rounded-full text-xs font-bold animate-pulse">
ACTIVE
</span>
)}
</div>
<p className="text-slate-600 text-sm mb-4">{vip.organization}</p>
{/* Current Event */}
{vip.currentEvent && (
<div className="bg-white border border-amber-200 rounded-lg p-4 mb-3 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<span className="text-amber-600 font-bold text-sm">CURRENT EVENT</span>
</div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-slate-900">{vip.currentEvent.title}</span>
</div>
<p className="text-slate-600 text-sm mb-1">Location: {vip.currentEvent.location}</p>
<p className="text-slate-500 text-xs">Until {formatTime(vip.currentEvent.endTime)}</p>
</div>
)}
{/* Next Event */}
{vip.nextEvent && (
<div className="bg-white border border-blue-200 rounded-lg p-4 mb-3 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<span className="text-blue-600 font-bold text-sm">NEXT EVENT</span>
</div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-slate-900">{vip.nextEvent.title}</span>
</div>
<p className="text-slate-600 text-sm mb-1">Location: {vip.nextEvent.location}</p>
<p className="text-slate-500 text-xs">{formatTime(vip.nextEvent.startTime)} - {formatTime(vip.nextEvent.endTime)}</p>
</div>
)}
{/* No Events */}
{!vip.currentEvent && !vip.nextEvent && (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-3">
<p className="text-slate-500 text-sm italic">No scheduled events</p>
</div>
)}
{/* Transport Info */}
<div className="flex items-center gap-2 text-xs text-slate-500 bg-white/50 rounded-lg px-3 py-2">
{vip.transportMode === 'flight' ? (
<span>Flight: {vip.flights && vip.flights.length > 0 ?
vip.flights.map(f => f.flightNumber).join(' → ') :
vip.flightNumber || 'TBD'}
</span>
) : (
<span>Self-driving | Expected: {vip.expectedArrival ? formatTime(vip.expectedArrival) : 'TBD'}</span>
)}
</div>
</div>
<div className="flex flex-col gap-2 ml-6">
<Link
to={`/vips/${vip.id}`}
className="bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 text-center shadow-lg hover:shadow-xl"
>
Details
</Link>
<Link
to={`/vips/${vip.id}#schedule`}
className="bg-gradient-to-r from-slate-500 to-slate-600 hover:from-slate-600 hover:to-slate-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 text-center shadow-lg hover:shadow-xl"
>
Schedule
</Link>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Drivers Card */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-green-50 to-emerald-50 px-6 py-4 border-b border-slate-200/60">
<h2 className="text-lg font-bold text-slate-800 flex items-center">
Available Drivers
<span className="ml-2 bg-green-100 text-green-800 text-sm font-medium px-2.5 py-0.5 rounded-full">
{drivers.length}
</span>
</h2>
</div>
<div className="p-6">
{drivers.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-3">
<div className="w-6 h-6 bg-slate-300 rounded-full"></div>
</div>
<p className="text-slate-500 text-sm">No drivers available</p>
</div>
) : (
<div className="space-y-3">
{drivers.map((driver) => (
<div key={driver.id} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-slate-900">{driver.name}</h4>
<p className="text-slate-600 text-sm">{driver.phone}</p>
</div>
<div className="text-right">
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">
{driver.assignedVipIds.length} VIPs
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Recent VIPs */}
<div className="bg-white shadow rounded-lg p-6 mb-8">
<h2 className="text-lg font-medium text-gray-900 mb-4">Recent VIPs</h2>
{vips && vips.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th className="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Organization
</th>
<th className="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Arrival Mode
</th>
<th className="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Events
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{vips.slice(0, 5).map((vip) => (
<tr key={vip.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{vip.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{vip.organization || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{vip.arrivalMode}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{vip.events?.length || 0}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-gray-500 text-center py-4">
No VIPs yet. Add your first VIP to get started.
</p>
)}
</div>
{/* Quick Actions Card */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
<div className="bg-gradient-to-r from-purple-50 to-pink-50 px-6 py-4 border-b border-slate-200/60">
<h2 className="text-lg font-bold text-slate-800">Quick Actions</h2>
</div>
<div className="p-6 space-y-3">
<Link
to="/vips"
className="block w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-4 py-3 rounded-lg font-medium transition-all duration-200 text-center shadow-lg hover:shadow-xl"
{/* Upcoming Flights */}
<div className="bg-white shadow rounded-lg p-6 mb-8">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Upcoming Flights
</h2>
{upcomingFlights.length > 0 ? (
<div className="space-y-4">
{upcomingFlights.map((flight) => (
<div
key={flight.id}
className="border-l-4 border-indigo-500 pl-4 py-2"
>
Manage VIPs
</Link>
<Link
to="/drivers"
className="block w-full bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white px-4 py-3 rounded-lg font-medium transition-all duration-200 text-center shadow-lg hover:shadow-xl"
>
Manage Drivers
</Link>
</div>
<div className="flex justify-between items-start">
<div>
<h3 className="text-sm font-medium text-gray-900 flex items-center gap-2">
<Plane className="h-4 w-4" />
{flight.flightNumber}
</h3>
<p className="text-sm text-gray-500">
{flight.vip?.name} {flight.departureAirport} {flight.arrivalAirport}
</p>
{flight.scheduledDeparture && (
<p className="text-xs text-gray-400 mt-1">
Departs: {formatDateTime(flight.scheduledDeparture)}
</p>
)}
</div>
<div className="text-right">
<span className="text-xs text-gray-500 block">
{new Date(flight.flightDate).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
<span className={`inline-block mt-1 px-2 py-1 text-xs rounded-full ${
flight.status?.toLowerCase() === 'scheduled' ? 'bg-blue-100 text-blue-800' :
flight.status?.toLowerCase() === 'boarding' ? 'bg-yellow-100 text-yellow-800' :
flight.status?.toLowerCase() === 'departed' ? 'bg-purple-100 text-purple-800' :
flight.status?.toLowerCase() === 'landed' ? 'bg-green-100 text-green-800' :
flight.status?.toLowerCase() === 'delayed' ? 'bg-orange-100 text-orange-800' :
'bg-gray-100 text-gray-800'
}`}>
{flight.status || 'Unknown'}
</span>
</div>
</div>
</div>
))}
</div>
</div>
) : (
<p className="text-sm text-gray-500 text-center py-4">
No upcoming flights tracked.
</p>
)}
</div>
{/* Upcoming Events */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Upcoming Events
</h2>
{upcomingEvents.length > 0 ? (
<div className="space-y-4">
{upcomingEvents.map((event) => (
<div
key={event.id}
className="border-l-4 border-primary pl-4 py-2"
>
<div className="flex justify-between items-start">
<div>
<h3 className="text-sm font-medium text-gray-900">
{event.title}
</h3>
<p className="text-sm text-gray-500">
{event.vip?.name} {event.driver?.name || 'No driver assigned'}
</p>
{event.location && (
<p className="text-xs text-gray-400 mt-1">{event.location}</p>
)}
</div>
<div className="text-right">
<span className="text-xs text-gray-500 block">
{formatDateTime(event.startTime)}
</span>
<span className={`inline-block mt-1 px-2 py-1 text-xs rounded-full ${
event.type === 'TRANSPORT' ? 'bg-blue-100 text-blue-800' :
event.type === 'MEETING' ? 'bg-purple-100 text-purple-800' :
event.type === 'MEAL' ? 'bg-green-100 text-green-800' :
'bg-gray-100 text-gray-800'
}`}>
{event.type}
</span>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-4">
No upcoming events scheduled.
</p>
)}
</div>
</div>
);
};
export default Dashboard;
}

View File

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

View File

@@ -1,293 +1,191 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { apiCall } from '../config/api';
import DriverForm from '../components/DriverForm';
import EditDriverForm from '../components/EditDriverForm';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { Driver } from '@/types';
import { Plus, Edit, Trash2 } from 'lucide-react';
import { DriverForm, DriverFormData } from '@/components/DriverForm';
import { Loading } from '@/components/Loading';
interface Driver {
id: string;
name: string;
phone: string;
currentLocation: { lat: number; lng: number };
assignedVipIds: string[];
vehicleCapacity?: number;
}
const DriverList: React.FC = () => {
const [drivers, setDrivers] = useState<Driver[]>([]);
const [loading, setLoading] = useState(true);
export function DriverList() {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [editingDriver, setEditingDriver] = useState<Driver | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Function to extract last name for sorting
const getLastName = (fullName: string) => {
const nameParts = fullName.trim().split(' ');
return nameParts[nameParts.length - 1].toLowerCase();
const { data: drivers, isLoading } = useQuery<Driver[]>({
queryKey: ['drivers'],
queryFn: async () => {
const { data } = await api.get('/drivers');
return data;
},
});
const createMutation = useMutation({
mutationFn: async (data: DriverFormData) => {
await api.post('/drivers', data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['drivers'] });
setShowForm(false);
setIsSubmitting(false);
toast.success('Driver created successfully');
},
onError: (error: any) => {
console.error('[DRIVER] Failed to create:', error);
setIsSubmitting(false);
toast.error(error.response?.data?.message || 'Failed to create driver');
},
});
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: DriverFormData }) => {
await api.patch(`/drivers/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['drivers'] });
setShowForm(false);
setEditingDriver(null);
setIsSubmitting(false);
toast.success('Driver updated successfully');
},
onError: (error: any) => {
console.error('[DRIVER] Failed to update:', error);
setIsSubmitting(false);
toast.error(error.response?.data?.message || 'Failed to update driver');
},
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/drivers/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['drivers'] });
toast.success('Driver deleted successfully');
},
onError: (error: any) => {
console.error('[DRIVER] Failed to delete:', error);
toast.error(error.response?.data?.message || 'Failed to delete driver');
},
});
const handleAdd = () => {
setEditingDriver(null);
setShowForm(true);
};
// Function to sort drivers by last name
const sortDriversByLastName = (driverList: Driver[]) => {
return [...driverList].sort((a, b) => {
const lastNameA = getLastName(a.name);
const lastNameB = getLastName(b.name);
return lastNameA.localeCompare(lastNameB);
});
const handleEdit = (driver: Driver) => {
setEditingDriver(driver);
setShowForm(true);
};
useEffect(() => {
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();
const sortedDrivers = sortDriversByLastName(data);
setDrivers(sortedDrivers);
} else {
console.error('Failed to fetch drivers:', response.status);
}
} catch (error) {
console.error('Error fetching drivers:', error);
} finally {
setLoading(false);
}
};
fetchDrivers();
}, []);
const handleAddDriver = async (driverData: any) => {
try {
const token = localStorage.getItem('authToken');
const response = await apiCall('/api/drivers', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(driverData),
});
if (response.ok) {
const newDriver = await response.json();
setDrivers(prev => sortDriversByLastName([...prev, newDriver]));
setShowForm(false);
} else {
console.error('Failed to add driver:', response.status);
}
} catch (error) {
console.error('Error adding driver:', error);
const handleDelete = (id: string, name: string) => {
if (confirm(`Delete driver "${name}"? This action cannot be undone.`)) {
deleteMutation.mutate(id);
}
};
const handleEditDriver = async (driverData: any) => {
try {
const token = localStorage.getItem('authToken');
const response = await apiCall(`/api/drivers/${driverData.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(driverData),
});
if (response.ok) {
const updatedDriver = await response.json();
setDrivers(prev => sortDriversByLastName(prev.map(driver =>
driver.id === updatedDriver.id ? updatedDriver : driver
)));
setEditingDriver(null);
} else {
console.error('Failed to update driver:', response.status);
}
} catch (error) {
console.error('Error updating driver:', error);
const handleSubmit = (data: DriverFormData) => {
setIsSubmitting(true);
if (editingDriver) {
updateMutation.mutate({ id: editingDriver.id, data });
} else {
createMutation.mutate(data);
}
};
const handleDeleteDriver = async (driverId: string) => {
if (!confirm('Are you sure you want to delete this driver?')) {
return;
}
try {
const token = localStorage.getItem('authToken');
const response = await apiCall(`/api/drivers/${driverId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
setDrivers(prev => prev.filter(driver => driver.id !== driverId));
} else {
console.error('Failed to delete driver:', response.status);
}
} catch (error) {
console.error('Error deleting driver:', error);
}
const handleCancel = () => {
setShowForm(false);
setEditingDriver(null);
setIsSubmitting(false);
};
if (loading) {
return (
<div className="flex justify-center items-center min-h-64">
<div className="bg-white rounded-2xl shadow-lg p-8 flex items-center space-x-4">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<span className="text-lg font-medium text-slate-700">Loading drivers...</span>
</div>
</div>
);
if (isLoading) {
return <Loading message="Loading drivers..." />;
}
return (
<div className="space-y-8">
{/* Header */}
<div className="bg-white rounded-2xl shadow-lg p-8 border border-slate-200/60">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent">
Driver Management
</h1>
<p className="text-slate-600 mt-2">Manage driver profiles and assignments</p>
</div>
<div className="flex items-center space-x-4">
<div className="bg-gradient-to-r from-green-500 to-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium">
{drivers.length} Active Drivers
</div>
<button
className="btn btn-primary"
onClick={() => setShowForm(true)}
>
Add New Driver
</button>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-900">Drivers</h1>
<button
onClick={handleAdd}
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"
>
<Plus className="h-4 w-4 mr-2" />
Add Driver
</button>
</div>
{/* Driver Grid */}
{drivers.length === 0 ? (
<div className="bg-white rounded-2xl shadow-lg p-12 border border-slate-200/60 text-center">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<div className="w-8 h-8 bg-slate-300 rounded-full"></div>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">No Drivers Found</h3>
<p className="text-slate-600 mb-6">Get started by adding your first driver</p>
<button
className="btn btn-primary"
onClick={() => setShowForm(true)}
>
Add New Driver
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{drivers.map((driver) => (
<div key={driver.id} className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden hover:shadow-xl transition-shadow duration-200">
<div className="p-6">
{/* Driver Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-slate-900">{driver.name}</h3>
<div className="w-10 h-10 bg-gradient-to-br from-green-400 to-green-600 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-bold">
{driver.name.charAt(0).toUpperCase()}
</span>
</div>
</div>
{/* Driver Information */}
<div className="space-y-3 mb-6">
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-sm font-medium text-slate-700 mb-1">Contact</div>
<div className="text-slate-600">{driver.phone}</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-sm font-medium text-slate-700 mb-1">Current Location</div>
<div className="text-slate-600 text-sm">
{driver.currentLocation.lat.toFixed(4)}, {driver.currentLocation.lng.toFixed(4)}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-sm font-medium text-slate-700 mb-1">Vehicle Capacity</div>
<div className="flex items-center gap-2 text-slate-600">
<span>🚗</span>
<span className="font-medium">{driver.vehicleCapacity || 4} passengers</span>
</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-sm font-medium text-slate-700 mb-1">Assignments</div>
<div className="flex items-center gap-2">
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">
{driver.assignedVipIds.length} VIPs
</span>
<span className={`text-xs font-medium px-2 py-1 rounded-full ${
driver.assignedVipIds.length === 0
? 'bg-green-100 text-green-800'
: 'bg-amber-100 text-amber-800'
}`}>
{driver.assignedVipIds.length === 0 ? 'Available' : 'Assigned'}
</span>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="space-y-3">
<Link
to={`/drivers/${driver.id}`}
className="bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-sm hover:shadow-md w-full text-center block"
>
View Dashboard
</Link>
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Phone
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Department
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Assigned Events
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{drivers?.map((driver) => (
<tr key={driver.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{driver.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{driver.phone}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{driver.department || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{driver.events?.length || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex gap-2">
<button
className="bg-gradient-to-r from-slate-500 to-slate-600 hover:from-slate-600 hover:to-slate-700 text-white px-3 py-2 rounded-lg text-xs font-medium transition-all duration-200 shadow-sm hover:shadow-md flex-1"
onClick={() => setEditingDriver(driver)}
<button
onClick={() => handleEdit(driver)}
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</button>
<button
className="bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white px-3 py-2 rounded-lg text-xs font-medium transition-all duration-200 shadow-sm hover:shadow-md flex-1"
onClick={() => handleDeleteDriver(driver.id)}
<button
onClick={() => handleDelete(driver.id, driver.name)}
className="inline-flex items-center px-3 py-1 text-red-600 hover:text-red-800"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Modals */}
{showForm && (
<DriverForm
onSubmit={handleAddDriver}
onCancel={() => setShowForm(false)}
/>
)}
{editingDriver && (
<EditDriverForm
driver={editingDriver}
onSubmit={handleEditDriver}
onCancel={() => setEditingDriver(null)}
onSubmit={handleSubmit}
onCancel={handleCancel}
isSubmitting={isSubmitting}
/>
)}
</div>
);
};
export default DriverList;
}

View File

@@ -0,0 +1,205 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { ScheduleEvent } from '@/types';
import { formatDateTime } from '@/lib/utils';
import { Plus, Edit, Trash2 } from 'lucide-react';
import { EventForm, EventFormData } from '@/components/EventForm';
import { Loading } from '@/components/Loading';
export function EventList() {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: events, isLoading } = useQuery<ScheduleEvent[]>({
queryKey: ['events'],
queryFn: async () => {
const { data } = await api.get('/events');
return data;
},
});
const createMutation = useMutation({
mutationFn: async (data: EventFormData) => {
await api.post('/events', data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
setShowForm(false);
setIsSubmitting(false);
toast.success('Event created successfully');
},
onError: (error: any) => {
console.error('[EVENT] Failed to create:', error);
setIsSubmitting(false);
toast.error(error.response?.data?.message || 'Failed to create event');
},
});
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: EventFormData }) => {
await api.patch(`/events/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
setShowForm(false);
setEditingEvent(null);
setIsSubmitting(false);
toast.success('Event updated successfully');
},
onError: (error: any) => {
console.error('[EVENT] Failed to update:', error);
setIsSubmitting(false);
toast.error(error.response?.data?.message || 'Failed to update event');
},
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/events/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
toast.success('Event deleted successfully');
},
onError: (error: any) => {
console.error('[EVENT] Failed to delete:', error);
toast.error(error.response?.data?.message || 'Failed to delete event');
},
});
const handleAdd = () => {
setEditingEvent(null);
setShowForm(true);
};
const handleEdit = (event: ScheduleEvent) => {
setEditingEvent(event);
setShowForm(true);
};
const handleDelete = (id: string, title: string) => {
if (confirm(`Delete event "${title}"? This action cannot be undone.`)) {
deleteMutation.mutate(id);
}
};
const handleSubmit = (data: EventFormData) => {
setIsSubmitting(true);
if (editingEvent) {
updateMutation.mutate({ id: editingEvent.id, data });
} else {
createMutation.mutate(data);
}
};
const handleCancel = () => {
setShowForm(false);
setEditingEvent(null);
setIsSubmitting(false);
};
if (isLoading) {
return <Loading message="Loading events..." />;
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-900">Schedule</h1>
<button
onClick={handleAdd}
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"
>
<Plus className="h-4 w-4 mr-2" />
Add Event
</button>
</div>
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Title
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
VIP
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Driver
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Start Time
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{events?.map((event) => (
<tr key={event.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{event.title}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{event.vip?.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{event.driver?.name || 'Unassigned'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(event.startTime)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs rounded-full ${
event.status === 'SCHEDULED' ? 'bg-blue-100 text-blue-800' :
event.status === 'IN_PROGRESS' ? 'bg-yellow-100 text-yellow-800' :
event.status === 'COMPLETED' ? 'bg-green-100 text-green-800' :
'bg-gray-100 text-gray-800'
}`}>
{event.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex gap-2">
<button
onClick={() => handleEdit(event)}
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</button>
<button
onClick={() => handleDelete(event.id, event.title)}
className="inline-flex items-center px-3 py-1 text-red-600 hover:text-red-800"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showForm && (
<EventForm
event={editingEvent}
onSubmit={handleSubmit}
onCancel={handleCancel}
isSubmitting={isSubmitting}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,296 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { Plus, Edit, Trash2, Plane } from 'lucide-react';
import { FlightForm, FlightFormData } from '@/components/FlightForm';
import { Loading } from '@/components/Loading';
import { ErrorMessage } from '@/components/ErrorMessage';
interface Flight {
id: string;
vipId: string;
vip?: {
name: string;
organization: string | null;
};
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;
}
export function FlightList() {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [editingFlight, setEditingFlight] = useState<Flight | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: flights, isLoading, isError, error, refetch } = useQuery<Flight[]>({
queryKey: ['flights'],
queryFn: async () => {
const { data } = await api.get('/flights');
return data;
},
});
const createMutation = useMutation({
mutationFn: async (data: FlightFormData) => {
await api.post('/flights', data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['flights'] });
setShowForm(false);
setIsSubmitting(false);
toast.success('Flight created successfully');
},
onError: (error: any) => {
console.error('[FLIGHT] Failed to create:', error);
setIsSubmitting(false);
toast.error(error.response?.data?.message || 'Failed to create flight');
},
});
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: FlightFormData }) => {
await api.patch(`/flights/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['flights'] });
setShowForm(false);
setEditingFlight(null);
setIsSubmitting(false);
toast.success('Flight updated successfully');
},
onError: (error: any) => {
console.error('[FLIGHT] Failed to update:', error);
setIsSubmitting(false);
toast.error(error.response?.data?.message || 'Failed to update flight');
},
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/flights/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['flights'] });
toast.success('Flight deleted successfully');
},
onError: (error: any) => {
console.error('[FLIGHT] Failed to delete:', error);
toast.error(error.response?.data?.message || 'Failed to delete flight');
},
});
const handleAdd = () => {
setEditingFlight(null);
setShowForm(true);
};
const handleEdit = (flight: Flight) => {
setEditingFlight(flight);
setShowForm(true);
};
const handleDelete = (id: string, flightNumber: string) => {
if (confirm(`Delete flight ${flightNumber}? This action cannot be undone.`)) {
deleteMutation.mutate(id);
}
};
const handleSubmit = (data: FlightFormData) => {
setIsSubmitting(true);
if (editingFlight) {
updateMutation.mutate({ id: editingFlight.id, data });
} else {
createMutation.mutate(data);
}
};
const handleCancel = () => {
setShowForm(false);
setEditingFlight(null);
setIsSubmitting(false);
};
const formatTime = (isoString: string | null) => {
if (!isoString) return '-';
return new Date(isoString).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusColor = (status: string | null) => {
switch (status?.toLowerCase()) {
case 'scheduled':
return 'bg-blue-100 text-blue-800';
case 'boarding':
return 'bg-yellow-100 text-yellow-800';
case 'departed':
case 'en-route':
return 'bg-purple-100 text-purple-800';
case 'landed':
return 'bg-green-100 text-green-800';
case 'delayed':
return 'bg-orange-100 text-orange-800';
case 'cancelled':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
if (isLoading) {
return <Loading message="Loading flights..." />;
}
if (isError) {
return (
<ErrorMessage
title="Failed to load flights"
message={(error as any)?.message || 'An error occurred while loading flights'}
onRetry={() => refetch()}
/>
);
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-900">Flights</h1>
<button
onClick={handleAdd}
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"
>
<Plus className="h-4 w-4 mr-2" />
Add Flight
</button>
</div>
{flights && flights.length > 0 ? (
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Flight
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
VIP
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Route
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Scheduled
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{flights.map((flight) => (
<tr key={flight.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Plane className="h-4 w-4 text-gray-400 mr-2" />
<div>
<div className="text-sm font-medium text-gray-900">
{flight.flightNumber}
</div>
<div className="text-xs text-gray-500">
Segment {flight.segment}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="font-medium text-gray-900">{flight.vip?.name}</div>
{flight.vip?.organization && (
<div className="text-xs text-gray-500">{flight.vip.organization}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center">
<span className="font-medium">{flight.departureAirport}</span>
<span className="mx-2"></span>
<span className="font-medium">{flight.arrivalAirport}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="text-xs">
<div>Dep: {formatTime(flight.scheduledDeparture)}</div>
<div>Arr: {formatTime(flight.scheduledArrival)}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs rounded-full ${getStatusColor(
flight.status
)}`}
>
{flight.status || 'Unknown'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex gap-2">
<button
onClick={() => handleEdit(flight)}
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</button>
<button
onClick={() => handleDelete(flight.id, flight.flightNumber)}
className="inline-flex items-center px-3 py-1 text-red-600 hover:text-red-800"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="bg-white shadow rounded-lg p-12 text-center">
<Plane className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500 mb-4">No flights tracked yet.</p>
<button
onClick={handleAdd}
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"
>
<Plus className="h-4 w-4 mr-2" />
Add Your First Flight
</button>
</div>
)}
{showForm && (
<FlightForm
flight={editingFlight}
onSubmit={handleSubmit}
onCancel={handleCancel}
isSubmitting={isSubmitting}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { Plane } from 'lucide-react';
export function Login() {
const { isAuthenticated, loginWithRedirect } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (isAuthenticated) {
navigate('/dashboard');
}
}, [isAuthenticated, navigate]);
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
<div className="text-center mb-8">
<div className="inline-block p-3 bg-primary/10 rounded-full mb-4">
<Plane className="h-12 w-12 text-primary" />
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
VIP Coordinator
</h1>
<p className="text-gray-600">
Transportation logistics and event coordination
</p>
</div>
<button
onClick={() => loginWithRedirect()}
className="w-full bg-primary text-primary-foreground py-3 px-4 rounded-lg font-medium hover:bg-primary/90 transition-colors"
>
Sign In with Auth0
</button>
<div className="mt-6 text-center text-sm text-gray-500">
<p>First user becomes administrator</p>
<p>Subsequent users require admin approval</p>
</div>
</div>
</div>
);
}

View File

@@ -1,105 +1,44 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiCall } from '../utils/api';
import { User } from '../types';
import { useAuth } from '@/contexts/AuthContext';
import { Clock, Mail } from 'lucide-react';
const PendingApproval: React.FC = () => {
const navigate = useNavigate();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Get user data from localStorage
const savedUser = localStorage.getItem('user');
if (savedUser) {
setUser(JSON.parse(savedUser));
}
setLoading(false);
// Check status every 30 seconds
const interval = setInterval(checkUserStatus, 30000);
return () => clearInterval(interval);
}, []);
const checkUserStatus = async () => {
try {
const token = localStorage.getItem('authToken');
const { data: userData } = await apiCall('/auth/users/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (userData) {
setUser(userData);
// If user is approved, redirect to dashboard
if (userData.status === 'active') {
window.location.href = '/';
}
}
} catch (error) {
console.error('Error checking user status:', error);
}
};
const handleLogout = () => {
localStorage.removeItem('authToken');
localStorage.removeItem('user');
window.location.href = '/';
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-amber-500 border-t-transparent mx-auto"></div>
<p className="mt-4 text-slate-600">Loading...</p>
</div>
</div>
);
}
export function PendingApproval() {
const { user, logout } = useAuth();
return (
<div className="min-h-screen bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 flex items-center justify-center p-4">
<div className="bg-white rounded-3xl shadow-2xl max-w-md w-full p-8 text-center relative overflow-hidden">
{/* Decorative element */}
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-amber-200 to-orange-200 rounded-full blur-3xl opacity-30 -translate-y-20 translate-x-20"></div>
<div className="relative z-10">
{/* Icon */}
<div className="w-24 h-24 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg">
<svg className="w-12 h-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
<div className="text-center">
<div className="inline-block p-3 bg-yellow-100 rounded-full mb-4">
<Clock className="h-12 w-12 text-yellow-600" />
</div>
{/* Welcome message */}
<h1 className="text-3xl font-bold bg-gradient-to-r from-amber-600 to-orange-600 bg-clip-text text-transparent mb-2">
Welcome, {user?.name?.split(' ')[0]}!
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Account Pending Approval
</h1>
<p className="text-lg text-slate-600 mb-8">
Your account is being reviewed
<p className="text-gray-600 mb-6">
Your account is awaiting administrator approval. You will be able to access the system once your account has been approved.
</p>
{/* Status message */}
<div className="bg-gradient-to-r from-amber-50 to-orange-50 border border-amber-200 rounded-2xl p-6 mb-8">
<p className="text-amber-800 leading-relaxed">
Thank you for signing up! An administrator will review your account request shortly.
We'll notify you once your access has been approved.
{user?.email && (
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<div className="flex items-center justify-center text-sm text-gray-700">
<Mail className="h-4 w-4 mr-2" />
<span>{user.email}</span>
</div>
</div>
)}
<div className="text-sm text-gray-500 mb-6">
<p>Please contact your administrator if you have any questions.</p>
<p className="mt-2">
<strong>Note:</strong> The first user is automatically approved as Administrator.
</p>
</div>
{/* Auto-refresh notice */}
<p className="text-sm text-slate-500 mb-8">
This page checks for updates automatically
</p>
{/* Logout button */}
<button
onClick={handleLogout}
className="w-full bg-gradient-to-r from-slate-600 to-slate-700 hover:from-slate-700 hover:to-slate-800 text-white font-medium py-3 px-6 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
onClick={() => logout()}
className="w-full bg-gray-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-gray-700 transition-colors"
>
Sign Out
</button>
@@ -107,6 +46,4 @@ const PendingApproval: React.FC = () => {
</div>
</div>
);
};
export default PendingApproval;
}

View File

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

View File

@@ -0,0 +1,267 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { Check, X, UserCheck, UserX, Shield, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Loading } from '@/components/Loading';
interface User {
id: string;
email: string;
name: string | null;
role: string;
isApproved: boolean;
createdAt: string;
}
export function UserList() {
const queryClient = useQueryClient();
const [processingUser, setProcessingUser] = useState<string | null>(null);
const { data: users, isLoading } = useQuery<User[]>({
queryKey: ['users'],
queryFn: async () => {
const { data } = await api.get('/users');
return data;
},
});
const approveMutation = useMutation({
mutationFn: async (userId: string) => {
await api.patch(`/users/${userId}/approve`, { isApproved: true });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setProcessingUser(null);
toast.success('User approved successfully');
},
onError: (error: any) => {
console.error('[USERS] Failed to approve user:', error);
setProcessingUser(null);
toast.error(error.response?.data?.message || 'Failed to approve user');
},
});
const deleteUserMutation = useMutation({
mutationFn: async (userId: string) => {
await api.delete(`/users/${userId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setProcessingUser(null);
toast.success('User deleted successfully');
},
onError: (error: any) => {
console.error('[USERS] Failed to delete user:', error);
setProcessingUser(null);
toast.error(error.response?.data?.message || 'Failed to delete user');
},
});
const changeRoleMutation = useMutation({
mutationFn: async ({ userId, role }: { userId: string; role: string }) => {
await api.patch(`/users/${userId}`, { role });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success('User role updated successfully');
},
onError: (error: any) => {
console.error('[USERS] Failed to update user role:', error);
toast.error(error.response?.data?.message || 'Failed to update user role');
},
});
const handleRoleChange = (userId: string, newRole: string) => {
if (confirm(`Change user role to ${newRole}?`)) {
changeRoleMutation.mutate({ userId, role: newRole });
}
};
const handleApprove = (userId: string) => {
if (confirm('Approve this user?')) {
setProcessingUser(userId);
approveMutation.mutate(userId);
}
};
const handleDeny = (userId: string) => {
if (confirm('Delete this user? This action cannot be undone.')) {
setProcessingUser(userId);
deleteUserMutation.mutate(userId);
}
};
if (isLoading) {
return <Loading message="Loading users..." />;
}
const pendingUsers = users?.filter((u) => !u.isApproved) || [];
const approvedUsers = users?.filter((u) => u.isApproved) || [];
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-6">User Management</h1>
{/* Pending Approval Section */}
{pendingUsers.length > 0 && (
<div className="mb-8">
<div className="flex items-center mb-4">
<UserX className="h-5 w-5 text-yellow-600 mr-2" />
<h2 className="text-xl font-semibold text-gray-900">
Pending Approval ({pendingUsers.length})
</h2>
</div>
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Requested
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{pendingUsers.map((user) => (
<tr key={user.id} className="bg-yellow-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{user.name || 'Unknown User'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span className="px-2 py-1 bg-gray-100 rounded text-xs font-medium">
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex gap-2">
<button
onClick={() => handleApprove(user.id)}
disabled={processingUser === user.id}
className="inline-flex items-center px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
>
<Check className="h-4 w-4 mr-1" />
Approve
</button>
<button
onClick={() => handleDeny(user.id)}
disabled={processingUser === user.id}
className="inline-flex items-center px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
>
<X className="h-4 w-4 mr-1" />
Deny
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Approved Users Section */}
<div>
<div className="flex items-center mb-4">
<UserCheck className="h-5 w-5 text-green-600 mr-2" />
<h2 className="text-xl font-semibold text-gray-900">
Approved Users ({approvedUsers.length})
</h2>
</div>
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{approvedUsers.map((user) => (
<tr key={user.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{user.name || 'Unknown User'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span
className={`px-2 py-1 rounded text-xs font-medium ${
user.role === 'ADMINISTRATOR'
? 'bg-purple-100 text-purple-800'
: user.role === 'COORDINATOR'
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{user.role === 'ADMINISTRATOR' && <Shield className="h-3 w-3 inline mr-1" />}
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 font-medium">
<Check className="h-4 w-4 inline mr-1" />
Active
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex items-center gap-2">
<select
value={user.role}
onChange={(e) => handleRoleChange(user.id, e.target.value)}
className="text-sm border border-gray-300 rounded px-2 py-1 focus:ring-primary focus:border-primary"
>
<option value="DRIVER">Driver</option>
<option value="COORDINATOR">Coordinator</option>
<option value="ADMINISTRATOR">Administrator</option>
</select>
<button
onClick={() => handleDeny(user.id)}
className="inline-flex items-center px-2 py-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded"
title="Delete user"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,404 @@
import { useQuery } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import { Loading } from '@/components/Loading';
import {
ArrowLeft,
Calendar,
Clock,
MapPin,
Car,
User,
Plane,
Download,
Mail,
} from 'lucide-react';
interface VIP {
id: string;
name: string;
organization: string | null;
department: string;
arrivalMode: string;
expectedArrival: string | null;
airportPickup: boolean;
venueTransport: boolean;
notes: string | null;
flights: Array<{
id: string;
flightNumber: string;
departureAirport: string;
arrivalAirport: string;
scheduledDeparture: string | null;
scheduledArrival: string | null;
status: string | null;
}>;
}
interface ScheduleEvent {
id: string;
title: string;
pickupLocation: string | null;
dropoffLocation: string | null;
location: string | null;
startTime: string;
endTime: string;
type: string;
status: string;
description: string | null;
vip: {
id: string;
name: string;
};
driver: {
id: string;
name: string;
} | null;
vehicle: {
id: string;
name: string;
type: string;
seatCapacity: number;
} | null;
}
export function VIPSchedule() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: vip, isLoading: vipLoading } = useQuery<VIP>({
queryKey: ['vip', id],
queryFn: async () => {
const { data } = await api.get(`/vips/${id}`);
return data;
},
});
const { data: events, isLoading: eventsLoading } = useQuery<ScheduleEvent[]>({
queryKey: ['events'],
queryFn: async () => {
const { data } = await api.get('/events');
return data;
},
});
if (vipLoading || eventsLoading) {
return <Loading message="Loading VIP schedule..." />;
}
if (!vip) {
return (
<div className="text-center py-12">
<p className="text-gray-500">VIP not found</p>
</div>
);
}
// Filter events for this VIP
const vipEvents = events?.filter((event) => event.vip.id === id) || [];
// Sort events by start time
const sortedEvents = [...vipEvents].sort(
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
);
// Group events by day
const eventsByDay = sortedEvents.reduce((acc, event) => {
const date = new Date(event.startTime).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
if (!acc[date]) {
acc[date] = [];
}
acc[date].push(event);
return acc;
}, {} as Record<string, ScheduleEvent[]>);
const getEventTypeColor = (type: string) => {
switch (type) {
case 'TRANSPORT':
return 'bg-blue-100 text-blue-800';
case 'MEETING':
return 'bg-purple-100 text-purple-800';
case 'EVENT':
return 'bg-green-100 text-green-800';
case 'MEAL':
return 'bg-orange-100 text-orange-800';
case 'ACCOMMODATION':
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getEventTypeIcon = (type: string) => {
switch (type) {
case 'TRANSPORT':
return <Car className="h-4 w-4" />;
case 'MEETING':
case 'EVENT':
return <Calendar className="h-4 w-4" />;
default:
return <Clock className="h-4 w-4" />;
}
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
});
};
const handleExport = () => {
// TODO: Implement PDF export
alert('PDF export feature coming soon!');
};
const handleEmail = () => {
// TODO: Implement email functionality
alert('Email feature coming soon!');
};
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-6">
<button
onClick={() => navigate('/vips')}
className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to VIPs
</button>
<div className="bg-white rounded-lg shadow-lg p-6">
<div className="flex justify-between items-start mb-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{vip.name}</h1>
{vip.organization && (
<p className="text-lg text-gray-600">{vip.organization}</p>
)}
<p className="text-sm text-gray-500">{vip.department.replace('_', ' ')}</p>
</div>
<div className="flex gap-2">
<button
onClick={handleEmail}
className="inline-flex items-center px-3 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
<Mail className="h-4 w-4 mr-2" />
Email Schedule
</button>
<button
onClick={handleExport}
className="inline-flex items-center px-3 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90"
>
<Download className="h-4 w-4 mr-2" />
Export PDF
</button>
</div>
</div>
{/* Arrival Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6 pt-6 border-t">
<div>
<p className="text-sm text-gray-500 mb-1">Arrival Mode</p>
<div className="flex items-center gap-2">
{vip.arrivalMode === 'FLIGHT' ? (
<Plane className="h-5 w-5 text-blue-600" />
) : (
<Car className="h-5 w-5 text-gray-600" />
)}
<span className="font-medium">{vip.arrivalMode.replace('_', ' ')}</span>
</div>
</div>
{vip.expectedArrival && (
<div>
<p className="text-sm text-gray-500 mb-1">Expected Arrival</p>
<p className="font-medium">
{new Date(vip.expectedArrival).toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}
</p>
</div>
)}
</div>
{/* Flight Information */}
{vip.flights && vip.flights.length > 0 && (
<div className="mt-6 pt-6 border-t">
<h3 className="text-lg font-semibold mb-3 flex items-center">
<Plane className="h-5 w-5 mr-2 text-blue-600" />
Flight Information
</h3>
<div className="space-y-2">
{vip.flights.map((flight) => (
<div
key={flight.id}
className="bg-blue-50 rounded-lg p-3 flex justify-between items-center"
>
<div>
<p className="font-medium text-blue-900">
Flight {flight.flightNumber}
</p>
<p className="text-sm text-blue-700">
{flight.departureAirport} {flight.arrivalAirport}
</p>
</div>
<div className="text-right">
{flight.scheduledArrival && (
<p className="text-sm text-blue-900">
Arrives:{' '}
{new Date(flight.scheduledArrival).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}
</p>
)}
{flight.status && (
<p className="text-xs text-blue-600">Status: {flight.status}</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{vip.notes && (
<div className="mt-6 pt-6 border-t">
<p className="text-sm text-gray-500 mb-1">Notes</p>
<p className="text-gray-700">{vip.notes}</p>
</div>
)}
</div>
</div>
{/* Schedule */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
<Calendar className="h-6 w-6 mr-2 text-primary" />
Schedule & Itinerary
</h2>
{sortedEvents.length === 0 ? (
<div className="text-center py-12">
<Calendar className="h-16 w-16 mx-auto mb-4 text-gray-300" />
<p className="text-gray-500">No scheduled events yet</p>
</div>
) : (
<div className="space-y-8">
{Object.entries(eventsByDay).map(([date, dayEvents]) => (
<div key={date}>
<h3 className="text-lg font-semibold text-gray-900 mb-4 pb-2 border-b">
{date}
</h3>
<div className="space-y-4">
{dayEvents.map((event) => (
<div
key={event.id}
className="flex gap-4 p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
>
{/* Time */}
<div className="flex-shrink-0 w-32">
<div className="flex items-center text-sm font-medium text-gray-900">
<Clock className="h-4 w-4 mr-1" />
{formatTime(event.startTime)}
</div>
<div className="text-xs text-gray-500 ml-5">
to {formatTime(event.endTime)}
</div>
</div>
{/* Event Details */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{getEventTypeIcon(event.type)}
<span
className={`px-2 py-1 rounded text-xs font-medium ${getEventTypeColor(event.type)}`}
>
{event.type}
</span>
<h4 className="font-semibold text-gray-900">{event.title}</h4>
</div>
{/* Location */}
{event.type === 'TRANSPORT' ? (
<div className="flex items-center gap-1 text-sm text-gray-600 mb-2">
<MapPin className="h-4 w-4" />
<span>
{event.pickupLocation || 'Pickup'} {' '}
{event.dropoffLocation || 'Dropoff'}
</span>
</div>
) : (
event.location && (
<div className="flex items-center gap-1 text-sm text-gray-600 mb-2">
<MapPin className="h-4 w-4" />
<span>{event.location}</span>
</div>
)
)}
{/* Description */}
{event.description && (
<p className="text-sm text-gray-600 mb-2">{event.description}</p>
)}
{/* Transport Details */}
{event.type === 'TRANSPORT' && (
<div className="flex gap-4 mt-2">
{event.driver && (
<div className="flex items-center gap-1 text-sm text-gray-600">
<User className="h-4 w-4" />
<span>Driver: {event.driver.name}</span>
</div>
)}
{event.vehicle && (
<div className="flex items-center gap-1 text-sm text-gray-600">
<Car className="h-4 w-4" />
<span>
{event.vehicle.name} ({event.vehicle.type.replace('_', ' ')})
</span>
</div>
)}
</div>
)}
{/* Status */}
<div className="mt-2">
<span
className={`text-xs px-2 py-1 rounded ${
event.status === 'COMPLETED'
? 'bg-green-100 text-green-800'
: event.status === 'IN_PROGRESS'
? 'bg-blue-100 text-blue-800'
: event.status === 'CANCELLED'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{event.status}
</span>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,442 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { useState } from 'react';
import { Loading } from '@/components/Loading';
import { Car, Plus, Edit2, Trash2, CheckCircle, XCircle, Wrench } from 'lucide-react';
interface Vehicle {
id: string;
name: string;
type: string;
licensePlate: string | null;
seatCapacity: number;
status: string;
currentDriver: {
id: string;
name: string;
} | null;
events: any[];
notes: string | null;
createdAt: string;
}
const VEHICLE_TYPES = [
{ value: 'VAN', label: 'Van (7-15 seats)' },
{ value: 'SUV', label: 'SUV (5-8 seats)' },
{ value: 'SEDAN', label: 'Sedan (4-5 seats)' },
{ value: 'BUS', label: 'Bus (15+ seats)' },
{ value: 'GOLF_CART', label: 'Golf Cart (2-6 seats)' },
{ value: 'TRUCK', label: 'Truck' },
];
const VEHICLE_STATUS = [
{ value: 'AVAILABLE', label: 'Available', color: 'green' },
{ value: 'IN_USE', label: 'In Use', color: 'blue' },
{ value: 'MAINTENANCE', label: 'Maintenance', color: 'orange' },
{ value: 'RESERVED', label: 'Reserved', color: 'yellow' },
];
export function VehicleList() {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null);
const [formData, setFormData] = useState({
name: '',
type: 'VAN' as string,
licensePlate: '',
seatCapacity: 8,
status: 'AVAILABLE' as string,
notes: '',
});
const { data: vehicles, isLoading } = useQuery<Vehicle[]>({
queryKey: ['vehicles'],
queryFn: async () => {
const { data } = await api.get('/vehicles');
return data;
},
});
const createMutation = useMutation({
mutationFn: async (vehicleData: any) => {
await api.post('/vehicles', vehicleData);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.success('Vehicle created successfully');
resetForm();
},
onError: (error: any) => {
console.error('[VEHICLES] Failed to create vehicle:', error);
toast.error(error.response?.data?.message || 'Failed to create vehicle');
},
});
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: any }) => {
await api.patch(`/vehicles/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.success('Vehicle updated successfully');
resetForm();
},
onError: (error: any) => {
console.error('[VEHICLES] Failed to update vehicle:', error);
toast.error(error.response?.data?.message || 'Failed to update vehicle');
},
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/vehicles/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.success('Vehicle deleted successfully');
},
onError: (error: any) => {
console.error('[VEHICLES] Failed to delete vehicle:', error);
toast.error(error.response?.data?.message || 'Failed to delete vehicle');
},
});
const resetForm = () => {
setFormData({
name: '',
type: 'VAN',
licensePlate: '',
seatCapacity: 8,
status: 'AVAILABLE',
notes: '',
});
setEditingVehicle(null);
setShowForm(false);
};
const handleEdit = (vehicle: Vehicle) => {
setEditingVehicle(vehicle);
setFormData({
name: vehicle.name,
type: vehicle.type,
licensePlate: vehicle.licensePlate || '',
seatCapacity: vehicle.seatCapacity,
status: vehicle.status,
notes: vehicle.notes || '',
});
setShowForm(true);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const vehicleData = {
...formData,
licensePlate: formData.licensePlate || null,
notes: formData.notes || null,
};
if (editingVehicle) {
updateMutation.mutate({ id: editingVehicle.id, data: vehicleData });
} else {
createMutation.mutate(vehicleData);
}
};
const handleDelete = (id: string, name: string) => {
if (confirm(`Are you sure you want to delete ${name}?`)) {
deleteMutation.mutate(id);
}
};
const getStatusBadge = (status: string) => {
const statusConfig = VEHICLE_STATUS.find((s) => s.value === status);
const colorClasses = {
green: 'bg-green-100 text-green-800',
blue: 'bg-blue-100 text-blue-800',
orange: 'bg-orange-100 text-orange-800',
yellow: 'bg-yellow-100 text-yellow-800',
};
return (
<span className={`px-2 py-1 rounded text-xs font-medium ${colorClasses[statusConfig?.color as keyof typeof colorClasses] || 'bg-gray-100 text-gray-800'}`}>
{statusConfig?.label || status}
</span>
);
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'AVAILABLE':
return <CheckCircle className="h-4 w-4 text-green-600" />;
case 'MAINTENANCE':
return <Wrench className="h-4 w-4 text-orange-600" />;
case 'IN_USE':
return <Car className="h-4 w-4 text-blue-600" />;
default:
return <XCircle className="h-4 w-4 text-gray-600" />;
}
};
if (isLoading) {
return <Loading message="Loading vehicles..." />;
}
const availableVehicles = vehicles?.filter((v) => v.status === 'AVAILABLE') || [];
const inUseVehicles = vehicles?.filter((v) => v.status === 'IN_USE') || [];
const maintenanceVehicles = vehicles?.filter((v) => v.status === 'MAINTENANCE') || [];
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-900">Vehicle Management</h1>
<button
onClick={() => setShowForm(!showForm)}
className="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
>
<Plus className="h-5 w-5 mr-2" />
{showForm ? 'Cancel' : 'Add Vehicle'}
</button>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded-lg shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Vehicles</p>
<p className="text-2xl font-bold text-gray-900">{vehicles?.length || 0}</p>
</div>
<Car className="h-8 w-8 text-gray-400" />
</div>
</div>
<div className="bg-white p-4 rounded-lg shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Available</p>
<p className="text-2xl font-bold text-green-600">{availableVehicles.length}</p>
</div>
<CheckCircle className="h-8 w-8 text-green-400" />
</div>
</div>
<div className="bg-white p-4 rounded-lg shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">In Use</p>
<p className="text-2xl font-bold text-blue-600">{inUseVehicles.length}</p>
</div>
<Car className="h-8 w-8 text-blue-400" />
</div>
</div>
<div className="bg-white p-4 rounded-lg shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Maintenance</p>
<p className="text-2xl font-bold text-orange-600">{maintenanceVehicles.length}</p>
</div>
<Wrench className="h-8 w-8 text-orange-400" />
</div>
</div>
</div>
{/* Add/Edit Form */}
{showForm && (
<div className="bg-white p-6 rounded-lg shadow mb-6">
<h2 className="text-xl font-semibold mb-4">
{editingVehicle ? 'Edit Vehicle' : 'Add New Vehicle'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Vehicle Name *
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Blue Van, Suburban #3"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Vehicle Type *
</label>
<select
required
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
>
{VEHICLE_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
License Plate
</label>
<input
type="text"
value={formData.licensePlate}
onChange={(e) => setFormData({ ...formData, licensePlate: e.target.value })}
placeholder="ABC-1234"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Seat Capacity *
</label>
<input
type="number"
required
min="1"
max="60"
value={formData.seatCapacity}
onChange={(e) => setFormData({ ...formData, seatCapacity: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status *
</label>
<select
required
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
>
{VEHICLE_STATUS.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notes
</label>
<input
type="text"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Optional notes"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
/>
</div>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={createMutation.isPending || updateMutation.isPending}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50"
>
{editingVehicle ? 'Update Vehicle' : 'Create Vehicle'}
</button>
<button
type="button"
onClick={resetForm}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* Vehicle List */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Vehicle
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
License Plate
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Seats
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Current Driver
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Upcoming Trips
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{vehicles?.map((vehicle) => (
<tr key={vehicle.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{getStatusIcon(vehicle.status)}
<span className="ml-2 text-sm font-medium text-gray-900">
{vehicle.name}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{vehicle.type.replace('_', ' ')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{vehicle.licensePlate || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{vehicle.seatCapacity}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(vehicle.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{vehicle.currentDriver?.name || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{vehicle.events?.length || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex gap-2">
<button
onClick={() => handleEdit(vehicle)}
className="text-blue-600 hover:text-blue-800"
title="Edit vehicle"
>
<Edit2 className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(vehicle.id, vehicle.name)}
className="text-red-600 hover:text-red-800"
title="Delete vehicle"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

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

View File

@@ -1,311 +1,339 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { apiCall } from '../config/api';
import VipForm from '../components/VipForm';
import EditVipForm from '../components/EditVipForm';
import FlightStatus from '../components/FlightStatus';
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { VIP } from '@/types';
import { Plus, Edit, Trash2, Search, X, Calendar } from 'lucide-react';
import { VIPForm, VIPFormData } from '@/components/VIPForm';
import { Loading } from '@/components/Loading';
interface Vip {
id: string;
name: string;
organization: string;
department: 'Office of Development' | 'Admin';
transportMode: 'flight' | 'self-driving';
flightNumber?: string; // Legacy
flightDate?: string; // Legacy
flights?: Array<{
flightNumber: string;
flightDate: string;
segment: number;
}>; // New
expectedArrival?: string;
arrivalTime?: string;
needsAirportPickup?: boolean;
needsVenueTransport: boolean;
notes?: string;
}
const VipList: React.FC = () => {
const [vips, setVips] = useState<Vip[]>([]);
const [loading, setLoading] = useState(true);
export function VIPList() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [showForm, setShowForm] = useState(false);
const [editingVip, setEditingVip] = useState<Vip | null>(null);
const [editingVIP, setEditingVIP] = useState<VIP | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Function to extract last name for sorting
const getLastName = (fullName: string) => {
const nameParts = fullName.trim().split(' ');
return nameParts[nameParts.length - 1].toLowerCase();
};
// Search and filter state
const [searchTerm, setSearchTerm] = useState('');
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
const [selectedArrivalModes, setSelectedArrivalModes] = useState<string[]>([]);
// Function to sort VIPs by last name
const sortVipsByLastName = (vipList: Vip[]) => {
return [...vipList].sort((a, b) => {
const lastNameA = getLastName(a.name);
const lastNameB = getLastName(b.name);
return lastNameA.localeCompare(lastNameB);
const { data: vips, isLoading } = useQuery<VIP[]>({
queryKey: ['vips'],
queryFn: async () => {
const { data } = await api.get('/vips');
return data;
},
});
const createMutation = useMutation({
mutationFn: async (data: VIPFormData) => {
await api.post('/vips', data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vips'] });
setShowForm(false);
setIsSubmitting(false);
toast.success('VIP created successfully');
},
onError: (error: any) => {
console.error('[VIP] Failed to create:', error);
setIsSubmitting(false);
toast.error(error.response?.data?.message || 'Failed to create VIP');
},
});
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: VIPFormData }) => {
await api.patch(`/vips/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vips'] });
setShowForm(false);
setEditingVIP(null);
setIsSubmitting(false);
toast.success('VIP updated successfully');
},
onError: (error: any) => {
console.error('[VIP] Failed to update:', error);
setIsSubmitting(false);
toast.error(error.response?.data?.message || 'Failed to update VIP');
},
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/vips/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vips'] });
toast.success('VIP deleted successfully');
},
onError: (error: any) => {
console.error('[VIP] Failed to delete:', error);
toast.error(error.response?.data?.message || 'Failed to delete VIP');
},
});
// Filter VIPs based on search and filters
const filteredVIPs = useMemo(() => {
if (!vips) return [];
return vips.filter((vip) => {
// Search by name
const matchesSearch = searchTerm === '' ||
vip.name.toLowerCase().includes(searchTerm.toLowerCase());
// Filter by department
const matchesDepartment = selectedDepartments.length === 0 ||
selectedDepartments.includes(vip.department);
// Filter by arrival mode
const matchesArrivalMode = selectedArrivalModes.length === 0 ||
selectedArrivalModes.includes(vip.arrivalMode);
return matchesSearch && matchesDepartment && matchesArrivalMode;
});
};
}, [vips, searchTerm, selectedDepartments, selectedArrivalModes]);
useEffect(() => {
const fetchVips = async () => {
try {
const token = localStorage.getItem('authToken');
const response = await apiCall('/api/vips', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
const sortedVips = sortVipsByLastName(data);
setVips(sortedVips);
} else {
console.error('Failed to fetch VIPs:', response.status);
}
} catch (error) {
console.error('Error fetching VIPs:', error);
} finally {
setLoading(false);
}
};
fetchVips();
}, []);
const handleAddVip = async (vipData: any) => {
try {
const token = localStorage.getItem('authToken');
const response = await apiCall('/api/vips', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(vipData),
});
if (response.ok) {
const newVip = await response.json();
setVips(prev => sortVipsByLastName([...prev, newVip]));
setShowForm(false);
} else {
console.error('Failed to add VIP:', response.status);
}
} catch (error) {
console.error('Error adding VIP:', error);
}
};
const handleEditVip = async (vipData: any) => {
try {
const token = localStorage.getItem('authToken');
const response = await apiCall(`/api/vips/${vipData.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(vipData),
});
if (response.ok) {
const updatedVip = await response.json();
setVips(prev => sortVipsByLastName(prev.map(vip => vip.id === updatedVip.id ? updatedVip : vip)));
setEditingVip(null);
} else {
console.error('Failed to update VIP:', response.status);
}
} catch (error) {
console.error('Error updating VIP:', error);
}
};
const handleDeleteVip = async (vipId: string) => {
if (!confirm('Are you sure you want to delete this VIP?')) {
return;
}
try {
const token = localStorage.getItem('authToken');
const response = await apiCall(`/api/vips/${vipId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
setVips(prev => prev.filter(vip => vip.id !== vipId));
} else {
console.error('Failed to delete VIP:', response.status);
}
} catch (error) {
console.error('Error deleting VIP:', error);
}
};
if (loading) {
return (
<div className="flex justify-center items-center min-h-64">
<div className="bg-white rounded-2xl shadow-lg p-8 flex items-center space-x-4">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<span className="text-lg font-medium text-slate-700">Loading VIPs...</span>
</div>
</div>
const handleDepartmentToggle = (department: string) => {
setSelectedDepartments((prev) =>
prev.includes(department)
? prev.filter((d) => d !== department)
: [...prev, department]
);
};
const handleArrivalModeToggle = (mode: string) => {
setSelectedArrivalModes((prev) =>
prev.includes(mode)
? prev.filter((m) => m !== mode)
: [...prev, mode]
);
};
const handleClearFilters = () => {
setSearchTerm('');
setSelectedDepartments([]);
setSelectedArrivalModes([]);
};
const handleAdd = () => {
setEditingVIP(null);
setShowForm(true);
};
const handleEdit = (vip: VIP) => {
setEditingVIP(vip);
setShowForm(true);
};
const handleDelete = (id: string, name: string) => {
if (confirm(`Delete VIP "${name}"? This action cannot be undone.`)) {
deleteMutation.mutate(id);
}
};
const handleSubmit = (data: VIPFormData) => {
setIsSubmitting(true);
if (editingVIP) {
updateMutation.mutate({ id: editingVIP.id, data });
} else {
createMutation.mutate(data);
}
};
const handleCancel = () => {
setShowForm(false);
setEditingVIP(null);
setIsSubmitting(false);
};
if (isLoading) {
return <Loading message="Loading VIPs..." />;
}
return (
<div className="space-y-8">
{/* Header */}
<div className="bg-white rounded-2xl shadow-lg p-8 border border-slate-200/60">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent">
VIP Management
</h1>
<p className="text-slate-600 mt-2">Manage VIP profiles and travel arrangements</p>
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-900">VIPs</h1>
<button
onClick={handleAdd}
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"
>
<Plus className="h-4 w-4 mr-2" />
Add VIP
</button>
</div>
{/* Search and Filter Section */}
<div className="bg-white shadow rounded-lg p-4 mb-6">
<div className="flex flex-col gap-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Search by name..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-primary focus:border-primary"
/>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-6">
{/* Department Filters */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Department
</label>
<div className="flex flex-col gap-2">
<label className="inline-flex items-center">
<input
type="checkbox"
checked={selectedDepartments.includes('OFFICE_OF_DEVELOPMENT')}
onChange={() => handleDepartmentToggle('OFFICE_OF_DEVELOPMENT')}
className="rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="ml-2 text-sm text-gray-700">Office of Development</span>
</label>
<label className="inline-flex items-center">
<input
type="checkbox"
checked={selectedDepartments.includes('ADMIN')}
onChange={() => handleDepartmentToggle('ADMIN')}
className="rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="ml-2 text-sm text-gray-700">Admin</span>
</label>
</div>
</div>
{/* Arrival Mode Filters */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Arrival Mode
</label>
<div className="flex flex-col gap-2">
<label className="inline-flex items-center">
<input
type="checkbox"
checked={selectedArrivalModes.includes('FLIGHT')}
onChange={() => handleArrivalModeToggle('FLIGHT')}
className="rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="ml-2 text-sm text-gray-700">Flight</span>
</label>
<label className="inline-flex items-center">
<input
type="checkbox"
checked={selectedArrivalModes.includes('SELF_DRIVING')}
onChange={() => handleArrivalModeToggle('SELF_DRIVING')}
className="rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="ml-2 text-sm text-gray-700">Self Driving</span>
</label>
</div>
</div>
</div>
{/* Results count and Clear Filters */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
Showing {filteredVIPs.length} of {vips?.length || 0} VIPs
</div>
{(searchTerm || selectedDepartments.length > 0 || selectedArrivalModes.length > 0) && (
<button
onClick={handleClearFilters}
className="inline-flex items-center px-3 py-1 text-sm text-gray-600 hover:text-gray-900 border border-gray-300 rounded-md hover:bg-gray-50"
>
<X className="h-4 w-4 mr-1" />
Clear Filters
</button>
)}
</div>
<button
className="btn btn-primary"
onClick={() => setShowForm(true)}
>
Add New VIP
</button>
</div>
</div>
{/* VIP List */}
{vips.length === 0 ? (
<div className="bg-white rounded-2xl shadow-lg p-12 border border-slate-200/60 text-center">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<div className="w-8 h-8 bg-slate-300 rounded-full"></div>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">No VIPs Found</h3>
<p className="text-slate-600 mb-6">Get started by adding your first VIP</p>
<button
className="btn btn-primary"
onClick={() => setShowForm(true)}
>
Add New VIP
</button>
</div>
) : (
<div className="space-y-4">
{vips.map((vip) => (
<div key={vip.id} className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden hover:shadow-xl transition-shadow duration-200">
<div className="p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<h3 className="text-xl font-bold text-slate-900">{vip.name}</h3>
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
{vip.department}
</span>
</div>
<p className="text-slate-600 text-sm mb-4">{vip.organization}</p>
{/* Transport Information */}
<div className="bg-slate-50 rounded-lg p-4 mb-4">
{vip.transportMode === 'flight' ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-slate-700">Flight:</span>
<span className="text-slate-600">
{vip.flights && vip.flights.length > 0 ?
vip.flights.map(f => f.flightNumber).join(' → ') :
vip.flightNumber || 'No flight'}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-slate-700">Airport Pickup:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
vip.needsAirportPickup
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{vip.needsAirportPickup ? 'Required' : 'Not needed'}
</span>
</div>
</div>
) : (
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-slate-700">Self-driving, Expected:</span>
<span className="text-slate-600">
{vip.expectedArrival ? new Date(vip.expectedArrival).toLocaleString() : 'TBD'}
</span>
</div>
)}
<div className="flex items-center gap-2 text-sm mt-2">
<span className="font-medium text-slate-700">Venue Transport:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
vip.needsVenueTransport
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}>
{vip.needsVenueTransport ? 'Required' : 'Not needed'}
</span>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-2 ml-6">
<Link
to={`/vips/${vip.id}`}
className="btn btn-success text-center"
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Organization
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Department
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Arrival Mode
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredVIPs.map((vip) => (
<tr key={vip.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{vip.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{vip.organization || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{vip.department}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{vip.arrivalMode}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex gap-2">
<button
onClick={() => navigate(`/vips/${vip.id}/schedule`)}
className="inline-flex items-center px-3 py-1 text-blue-600 hover:text-blue-800"
title="View Schedule"
>
View Details
</Link>
<button
className="btn btn-secondary"
onClick={() => setEditingVip(vip)}
<Calendar className="h-4 w-4 mr-1" />
Schedule
</button>
<button
onClick={() => handleEdit(vip)}
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</button>
<button
className="btn btn-danger"
onClick={() => handleDeleteVip(vip.id)}
<button
onClick={() => handleDelete(vip.id, vip.name)}
className="inline-flex items-center px-3 py-1 text-red-600 hover:text-red-800"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
</div>
</div>
{/* Flight Status */}
{vip.transportMode === 'flight' && vip.flightNumber && (
<div className="mt-4 pt-4 border-t border-slate-200">
<FlightStatus flightNumber={vip.flightNumber} />
</div>
)}
</div>
</div>
))}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Modals */}
{showForm && (
<VipForm
onSubmit={handleAddVip}
onCancel={() => setShowForm(false)}
/>
)}
{editingVip && (
<EditVipForm
vip={{...editingVip, notes: editingVip.notes || ''}}
onSubmit={handleEditVip}
onCancel={() => setEditingVip(null)}
<VIPForm
vip={editingVIP}
onSubmit={handleSubmit}
onCancel={handleCancel}
isSubmitting={isSubmitting}
/>
)}
</div>
);
};
export default VipList;
}

View File

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

View File

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

View File

@@ -1,116 +1,116 @@
// User types
export enum Role {
ADMINISTRATOR = 'ADMINISTRATOR',
COORDINATOR = 'COORDINATOR',
DRIVER = 'DRIVER',
}
export interface User {
id: string;
auth0Id: string;
email: string;
name: string;
role: 'administrator' | 'coordinator' | 'driver' | 'viewer';
status?: 'pending' | 'active' | 'deactivated';
organization?: string;
phone?: string;
department?: string;
profilePhoto?: string;
onboardingData?: {
vehicleType?: string;
vehicleCapacity?: number;
licensePlate?: string;
homeLocation?: { lat: number; lng: number };
requestedRole: string;
reason: string;
};
approvedBy?: string;
approvedAt?: string;
createdAt?: string;
updatedAt?: string;
lastLogin?: string;
name: string | null;
picture: string | null;
role: Role;
isApproved: boolean;
driver?: Driver | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
// Department types
export enum Department {
OFFICE_OF_DEVELOPMENT = 'OFFICE_OF_DEVELOPMENT',
ADMIN = 'ADMIN',
}
export enum ArrivalMode {
FLIGHT = 'FLIGHT',
SELF_DRIVING = 'SELF_DRIVING',
}
// VIP types
export interface Flight {
flightNumber: string;
airline?: string;
scheduledArrival: string;
scheduledDeparture?: string;
status?: 'scheduled' | 'delayed' | 'cancelled' | 'arrived';
}
export interface VIP {
id: string;
name: string;
organization?: string;
department?: 'Office of Development' | 'Admin';
transportMode: 'flight' | 'self-driving';
organization: string | null;
department: Department;
arrivalMode: ArrivalMode;
expectedArrival: string | null;
airportPickup: boolean;
venueTransport: boolean;
notes: string | null;
flights?: Flight[];
expectedArrival?: string;
needsAirportPickup?: boolean;
needsVenueTransport?: boolean;
notes?: string;
assignedDriverIds?: string[];
schedule?: ScheduleEvent[];
events?: ScheduleEvent[];
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
// Driver types
export interface Driver {
id: string;
name: string;
email?: string;
phone: string;
vehicleInfo?: string;
status?: 'available' | 'assigned' | 'unavailable';
department?: string;
currentLocation?: { lat: number; lng: number };
assignedVipIds?: string[];
department: Department | null;
userId: string | null;
user?: User | null;
events?: ScheduleEvent[];
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
// Event types
export enum EventType {
TRANSPORT = 'TRANSPORT',
MEETING = 'MEETING',
EVENT = 'EVENT',
MEAL = 'MEAL',
ACCOMMODATION = 'ACCOMMODATION',
}
export enum EventStatus {
SCHEDULED = 'SCHEDULED',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}
// Schedule Event types
export interface ScheduleEvent {
id: string;
vipId?: string;
vipName?: string;
assignedDriverId?: string;
eventTime: string;
eventType: 'pickup' | 'dropoff' | 'custom';
location: string;
notes?: string;
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
warnings?: string[];
vipId: string;
vip?: VIP;
title: string;
location: string | null;
startTime: string;
endTime: string;
description: string | null;
type: EventType;
status: EventStatus;
driverId: string | null;
driver?: Driver | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
// Form data types
export interface VipFormData {
name: string;
organization?: string;
department?: 'Office of Development' | 'Admin';
transportMode: 'flight' | 'self-driving';
flights?: Flight[];
expectedArrival?: string;
needsAirportPickup?: boolean;
needsVenueTransport?: boolean;
notes?: string;
// Flight types
export interface Flight {
id: string;
vipId: string;
vip?: VIP;
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;
createdAt: string;
updatedAt: string;
}
export interface DriverFormData {
name: string;
email?: string;
phone: string;
vehicleInfo?: string;
status?: 'available' | 'assigned' | 'unavailable';
}
export interface ScheduleEventFormData {
assignedDriverId?: string;
eventTime: string;
eventType: 'pickup' | 'dropoff' | 'custom';
location: string;
notes?: string;
}
// Admin settings
export interface SystemSettings {
aviationStackKey?: string;
googleMapsKey?: string;
twilioKey?: string;
enableFlightTracking?: boolean;
enableSMSNotifications?: boolean;
defaultPickupLocation?: string;
defaultDropoffLocation?: string;
}

View File

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

View File

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

12
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_AUTH0_DOMAIN: string;
readonly VITE_AUTH0_CLIENT_ID: string;
readonly VITE_AUTH0_AUDIENCE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}