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
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
312
frontend/src/components/EventForm.tsx
Normal file
312
frontend/src/components/EventForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
345
frontend/src/components/FlightForm.tsx
Normal file
345
frontend/src/components/FlightForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
115
frontend/src/components/Layout.tsx
Normal file
115
frontend/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/Loading.tsx
Normal file
28
frontend/src/components/Loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
32
frontend/src/components/ProtectedRoute.tsx
Normal file
32
frontend/src/components/ProtectedRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
63
frontend/src/contexts/AbilityContext.tsx
Normal file
63
frontend/src/contexts/AbilityContext.tsx
Normal 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;
|
||||
}
|
||||
94
frontend/src/contexts/AuthContext.tsx
Normal file
94
frontend/src/contexts/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
94
frontend/src/lib/abilities.ts
Normal file
94
frontend/src/lib/abilities.ts
Normal 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
91
frontend/src/lib/api.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
170
frontend/src/lib/errorHandler.ts
Normal file
170
frontend/src/lib/errorHandler.ts
Normal 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
34
frontend/src/lib/utils.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
1153
frontend/src/pages/AdminTools.tsx
Normal file
1153
frontend/src/pages/AdminTools.tsx
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/src/pages/Callback.tsx
Normal file
23
frontend/src/pages/Callback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
501
frontend/src/pages/CommandCenter.tsx
Normal file
501
frontend/src/pages/CommandCenter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
205
frontend/src/pages/EventList.tsx
Normal file
205
frontend/src/pages/EventList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
296
frontend/src/pages/FlightList.tsx
Normal file
296
frontend/src/pages/FlightList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
frontend/src/pages/Login.tsx
Normal file
45
frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
267
frontend/src/pages/UserList.tsx
Normal file
267
frontend/src/pages/UserList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
404
frontend/src/pages/VIPSchedule.tsx
Normal file
404
frontend/src/pages/VIPSchedule.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
442
frontend/src/pages/VehicleList.tsx
Normal file
442
frontend/src/pages/VehicleList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
12
frontend/src/vite-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user