Initial commit - Current state of vip-coordinator
This commit is contained in:
110
frontend/src/components/DriverForm.tsx
Normal file
110
frontend/src/components/DriverForm.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface DriverFormData {
|
||||
name: string;
|
||||
phone: string;
|
||||
vehicleCapacity: number;
|
||||
}
|
||||
|
||||
interface DriverFormProps {
|
||||
onSubmit: (driverData: DriverFormData) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const DriverForm: React.FC<DriverFormProps> = ({ onSubmit, onCancel }) => {
|
||||
const [formData, setFormData] = useState<DriverFormData>({
|
||||
name: '',
|
||||
phone: '',
|
||||
vehicleCapacity: 4
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'number' || name === 'vehicleCapacity' ? parseInt(value) || 0 : value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
{/* Modal Header */}
|
||||
<div className="modal-header">
|
||||
<h2 className="text-2xl font-bold text-slate-800">Add New Driver</h2>
|
||||
<p className="text-slate-600 mt-2">Enter driver contact information</p>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="modal-body">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="form-group">
|
||||
<label htmlFor="name" className="form-label">Driver Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="form-input"
|
||||
placeholder="Enter driver's full name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="phone" className="form-label">Phone Number *</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className="form-input"
|
||||
placeholder="Enter phone number"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="vehicleCapacity" className="form-label">Vehicle Capacity *</label>
|
||||
<select
|
||||
id="vehicleCapacity"
|
||||
name="vehicleCapacity"
|
||||
value={formData.vehicleCapacity}
|
||||
onChange={handleChange}
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value={2}>2 passengers (Sedan/Coupe)</option>
|
||||
<option value={4}>4 passengers (Standard Car)</option>
|
||||
<option value={6}>6 passengers (SUV/Van)</option>
|
||||
<option value={8}>8 passengers (Large Van)</option>
|
||||
<option value={12}>12 passengers (Mini Bus)</option>
|
||||
</select>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
🚗 Select the maximum number of passengers this vehicle can accommodate
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Add Driver
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DriverForm;
|
||||
368
frontend/src/components/DriverSelector.tsx
Normal file
368
frontend/src/components/DriverSelector.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
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;
|
||||
188
frontend/src/components/EditDriverForm.tsx
Normal file
188
frontend/src/components/EditDriverForm.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { 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, type } = 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;
|
||||
550
frontend/src/components/EditVipForm.tsx
Normal file
550
frontend/src/components/EditVipForm.tsx
Normal file
@@ -0,0 +1,550 @@
|
||||
import React, { 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 }));
|
||||
}
|
||||
};
|
||||
|
||||
const formatFlightTime = (timeString: string) => {
|
||||
if (!timeString) return '';
|
||||
return new Date(timeString).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
149
frontend/src/components/FlightStatus.tsx
Normal file
149
frontend/src/components/FlightStatus.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface FlightData {
|
||||
flightNumber: string;
|
||||
status: string;
|
||||
departure: {
|
||||
airport: string;
|
||||
scheduled: string;
|
||||
estimated?: string;
|
||||
actual?: string;
|
||||
};
|
||||
arrival: {
|
||||
airport: string;
|
||||
scheduled: string;
|
||||
estimated?: string;
|
||||
actual?: string;
|
||||
};
|
||||
delay?: number;
|
||||
gate?: string;
|
||||
}
|
||||
|
||||
interface FlightStatusProps {
|
||||
flightNumber: string;
|
||||
flightDate?: string;
|
||||
}
|
||||
|
||||
const FlightStatus: React.FC<FlightStatusProps> = ({ flightNumber, flightDate }) => {
|
||||
const [flightData, setFlightData] = useState<FlightData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFlightData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const url = flightDate
|
||||
? `/api/flights/${flightNumber}?date=${flightDate}`
|
||||
: `/api/flights/${flightNumber}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setFlightData(data);
|
||||
setError(null);
|
||||
} else {
|
||||
setError('Flight not found');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to fetch flight data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (flightNumber) {
|
||||
fetchFlightData();
|
||||
|
||||
// Auto-refresh every 5 minutes
|
||||
const interval = setInterval(fetchFlightData, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [flightNumber, flightDate]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flight-status loading">Loading flight data...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="flight-status error">⚠️ {error}</div>;
|
||||
}
|
||||
|
||||
if (!flightData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'active': return '#2ecc71';
|
||||
case 'scheduled': return '#3498db';
|
||||
case 'delayed': return '#f39c12';
|
||||
case 'cancelled': return '#e74c3c';
|
||||
default: return '#95a5a6';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timeString: string) => {
|
||||
return new Date(timeString).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flight-status">
|
||||
<div className="flight-header">
|
||||
<h4>✈️ Flight {flightData.flightNumber}</h4>
|
||||
<span
|
||||
className="flight-status-badge"
|
||||
style={{
|
||||
backgroundColor: getStatusColor(flightData.status),
|
||||
color: 'white',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.8rem',
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
>
|
||||
{flightData.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flight-details">
|
||||
<div className="flight-route">
|
||||
<div className="departure">
|
||||
<strong>{flightData.departure.airport}</strong>
|
||||
<div>Scheduled: {formatTime(flightData.departure.scheduled)}</div>
|
||||
{flightData.departure.estimated && (
|
||||
<div>Estimated: {formatTime(flightData.departure.estimated)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="route-arrow">→</div>
|
||||
|
||||
<div className="arrival">
|
||||
<strong>{flightData.arrival.airport}</strong>
|
||||
<div>Scheduled: {formatTime(flightData.arrival.scheduled)}</div>
|
||||
{flightData.arrival.estimated && (
|
||||
<div>Estimated: {formatTime(flightData.arrival.estimated)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{flightData.delay && flightData.delay > 0 && (
|
||||
<div className="delay-info" style={{ color: '#f39c12', marginTop: '0.5rem' }}>
|
||||
⚠️ Delayed by {flightData.delay} minutes
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flightData.gate && (
|
||||
<div className="gate-info" style={{ marginTop: '0.5rem' }}>
|
||||
🚪 Gate: {flightData.gate}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlightStatus;
|
||||
282
frontend/src/components/GanttChart.tsx
Normal file
282
frontend/src/components/GanttChart.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import React from 'react';
|
||||
|
||||
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, index) => {
|
||||
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;
|
||||
221
frontend/src/components/Login.css
Normal file
221
frontend/src/components/Login.css
Normal file
@@ -0,0 +1,221 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.dev-login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.dev-login-divider::before,
|
||||
.dev-login-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.dev-login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dev-login-form h3 {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dev-login-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dev-login-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.dev-login-form input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #cbd5f5;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.dev-login-form input:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.dev-login-error {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dev-login-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.dev-login-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.dev-login-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
86
frontend/src/components/Login.tsx
Normal file
86
frontend/src/components/Login.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { apiCall } from '../config/api';
|
||||
import './Login.css';
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: () => void;
|
||||
errorMessage?: string | null | undefined;
|
||||
}
|
||||
|
||||
const Login: React.FC<LoginProps> = ({ onLogin, errorMessage }) => {
|
||||
const [setupStatus, setSetupStatus] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
apiCall('/auth/setup')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setSetupStatus(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error checking setup status:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
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 sign in will be promoted to administrator automatically.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="login-content">
|
||||
<button
|
||||
className="google-login-btn"
|
||||
onClick={onLogin}
|
||||
>
|
||||
<svg className="google-icon" viewBox="0 0 24 24">
|
||||
<path fill="#635dff" d="M22 12.07c0-5.52-4.48-10-10-10s-10 4.48-10 10a9.97 9.97 0 006.85 9.48.73.73 0 00.95-.7v-3.05c-2.79.61-3.38-1.19-3.38-1.19-.46-1.17-1.12-1.49-1.12-1.49-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.37 1.09 2.96.83.09-.65.35-1.09.63-1.34-2.23-.25-4.57-1.12-4.57-4.96 0-1.1.39-2 1.03-2.7-.1-.25-.45-1.25.1-2.6 0 0 .84-.27 2.75 1.02a9.53 9.53 0 015 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.35.1 2.6.64.7 1.03 1.6 1.03 2.7 0 3.85-2.34 4.71-4.58 4.95.36.31.69.92.69 1.86v2.75c0 .39.27.71.66.79a10 10 0 007.61-9.71z"/>
|
||||
</svg>
|
||||
Continue with Auth0
|
||||
</button>
|
||||
|
||||
<div className="login-info">
|
||||
<p>
|
||||
{setupStatus?.authProvider === 'auth0'
|
||||
? 'Sign in with your organisation account. We use Auth0 for secure authentication.'
|
||||
: 'Authentication service is being configured. Please try again later.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="dev-login-error" style={{ marginTop: '1rem' }}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="login-footer">
|
||||
<p>Secure authentication powered by Auth0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
605
frontend/src/components/ScheduleManager.tsx
Normal file
605
frontend/src/components/ScheduleManager.tsx
Normal file
@@ -0,0 +1,605 @@
|
||||
import React, { 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> = ({ vipId, 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;
|
||||
488
frontend/src/components/UserManagement.tsx
Normal file
488
frontend/src/components/UserManagement.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
import React, { 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, userName: 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, user.name)}
|
||||
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;
|
||||
459
frontend/src/components/VipForm.tsx
Normal file
459
frontend/src/components/VipForm.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface Flight {
|
||||
flightNumber: string;
|
||||
flightDate: string;
|
||||
segment: number;
|
||||
validated?: boolean;
|
||||
validationData?: any;
|
||||
}
|
||||
|
||||
interface VipFormData {
|
||||
name: string;
|
||||
organization: string;
|
||||
department: 'Office of Development' | 'Admin';
|
||||
transportMode: 'flight' | 'self-driving';
|
||||
flights?: Flight[];
|
||||
expectedArrival?: string;
|
||||
needsAirportPickup?: boolean;
|
||||
needsVenueTransport: boolean;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface VipFormProps {
|
||||
onSubmit: (vipData: VipFormData) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const VipForm: React.FC<VipFormProps> = ({ onSubmit, onCancel }) => {
|
||||
const [formData, setFormData] = useState<VipFormData>({
|
||||
name: '',
|
||||
organization: '',
|
||||
department: 'Office of Development',
|
||||
transportMode: 'flight',
|
||||
flights: [{ flightNumber: '', flightDate: '', segment: 1 }],
|
||||
expectedArrival: '',
|
||||
needsAirportPickup: true,
|
||||
needsVenueTransport: true,
|
||||
notes: ''
|
||||
});
|
||||
|
||||
const [flightValidating, setFlightValidating] = useState<{ [key: number]: boolean }>({});
|
||||
const [flightErrors, setFlightErrors] = useState<{ [key: number]: string }>({});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only include flights with flight numbers
|
||||
const validFlights = formData.flights?.filter(f => f.flightNumber) || [];
|
||||
|
||||
onSubmit({
|
||||
...formData,
|
||||
flights: validFlights.length > 0 ? validFlights : undefined
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (type === 'checkbox') {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: checked
|
||||
}));
|
||||
} else {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTransportModeChange = (mode: 'flight' | 'self-driving') => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
transportMode: mode,
|
||||
flights: mode === 'flight' ? [{ flightNumber: '', flightDate: '', segment: 1 }] : undefined,
|
||||
expectedArrival: mode === 'self-driving' ? prev.expectedArrival : '',
|
||||
needsAirportPickup: mode === 'flight' ? true : false
|
||||
}));
|
||||
|
||||
// Clear flight errors when switching away from flight mode
|
||||
if (mode !== 'flight') {
|
||||
setFlightErrors({});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFlightChange = (index: number, field: 'flightNumber' | 'flightDate', value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
flights: prev.flights?.map((flight, i) =>
|
||||
i === index ? { ...flight, [field]: value, validated: false } : flight
|
||||
) || []
|
||||
}));
|
||||
|
||||
// Clear validation for this flight when it changes
|
||||
setFlightErrors(prev => ({ ...prev, [index]: '' }));
|
||||
};
|
||||
|
||||
const addConnectingFlight = () => {
|
||||
const currentFlights = formData.flights || [];
|
||||
if (currentFlights.length < 3) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
flights: [...currentFlights, {
|
||||
flightNumber: '',
|
||||
flightDate: currentFlights[currentFlights.length - 1]?.flightDate || '',
|
||||
segment: currentFlights.length + 1
|
||||
}]
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const removeConnectingFlight = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
flights: prev.flights?.filter((_, i) => i !== index).map((flight, i) => ({
|
||||
...flight,
|
||||
segment: i + 1
|
||||
})) || []
|
||||
}));
|
||||
|
||||
// Clear errors for removed flight
|
||||
setFlightErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[index];
|
||||
return newErrors;
|
||||
});
|
||||
};
|
||||
|
||||
const validateFlight = async (index: number) => {
|
||||
const flight = formData.flights?.[index];
|
||||
if (!flight || !flight.flightNumber || !flight.flightDate) {
|
||||
setFlightErrors(prev => ({ ...prev, [index]: 'Please enter flight number and date' }));
|
||||
return;
|
||||
}
|
||||
|
||||
setFlightValidating(prev => ({ ...prev, [index]: true }));
|
||||
setFlightErrors(prev => ({ ...prev, [index]: '' }));
|
||||
|
||||
try {
|
||||
const url = `/api/flights/${flight.flightNumber}?date=${flight.flightDate}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Update flight with validation data
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
flights: prev.flights?.map((f, i) =>
|
||||
i === index ? { ...f, validated: true, validationData: data } : f
|
||||
) || []
|
||||
}));
|
||||
|
||||
setFlightErrors(prev => ({ ...prev, [index]: '' }));
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setFlightErrors(prev => ({
|
||||
...prev,
|
||||
[index]: errorData.error || 'Invalid flight number'
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setFlightErrors(prev => ({
|
||||
...prev,
|
||||
[index]: 'Error validating flight'
|
||||
}));
|
||||
} finally {
|
||||
setFlightValidating(prev => ({ ...prev, [index]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
{/* Modal Header */}
|
||||
<div className="modal-header">
|
||||
<h2 className="text-2xl font-bold text-slate-800">
|
||||
Add New VIP
|
||||
</h2>
|
||||
<p className="text-slate-600 mt-2">Enter VIP details and travel information</p>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="modal-body">
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Basic Information Section */}
|
||||
<div className="form-section">
|
||||
<div className="form-section-header">
|
||||
<h3 className="form-section-title">Basic Information</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="form-group">
|
||||
<label htmlFor="name" className="form-label">Full Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="form-input"
|
||||
placeholder="Enter full name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="organization" className="form-label">Organization *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="organization"
|
||||
name="organization"
|
||||
value={formData.organization}
|
||||
onChange={handleChange}
|
||||
className="form-input"
|
||||
placeholder="Enter organization name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="department" className="form-label">Department *</label>
|
||||
<select
|
||||
id="department"
|
||||
name="department"
|
||||
value={formData.department}
|
||||
onChange={handleChange}
|
||||
className="form-select"
|
||||
required
|
||||
>
|
||||
<option value="Office of Development">Office of Development</option>
|
||||
<option value="Admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transportation Section */}
|
||||
<div className="form-section">
|
||||
<div className="form-section-header">
|
||||
<h3 className="form-section-title">Transportation Details</h3>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">How are you arriving? *</label>
|
||||
<div className="radio-group">
|
||||
<div
|
||||
className={`radio-option ${formData.transportMode === 'flight' ? 'selected' : ''}`}
|
||||
onClick={() => handleTransportModeChange('flight')}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="transportMode"
|
||||
value="flight"
|
||||
checked={formData.transportMode === 'flight'}
|
||||
onChange={() => handleTransportModeChange('flight')}
|
||||
className="form-radio mr-3"
|
||||
/>
|
||||
<span className="font-medium">Arriving by Flight</span>
|
||||
</div>
|
||||
<div
|
||||
className={`radio-option ${formData.transportMode === 'self-driving' ? 'selected' : ''}`}
|
||||
onClick={() => handleTransportModeChange('self-driving')}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="transportMode"
|
||||
value="self-driving"
|
||||
checked={formData.transportMode === 'self-driving'}
|
||||
onChange={() => handleTransportModeChange('self-driving')}
|
||||
className="form-radio mr-3"
|
||||
/>
|
||||
<span className="font-medium">Self-Driving</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flight Mode Fields */}
|
||||
{formData.transportMode === 'flight' && formData.flights && (
|
||||
<div className="space-y-6">
|
||||
{formData.flights.map((flight, index) => (
|
||||
<div key={index} className="bg-white border-2 border-blue-200 rounded-xl p-6 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h4 className="text-lg font-bold text-slate-800">
|
||||
{index === 0 ? 'Primary Flight' : `Connecting Flight ${index}`}
|
||||
</h4>
|
||||
{index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeConnectingFlight(index)}
|
||||
className="text-red-500 hover:text-red-700 font-medium text-sm bg-red-50 hover:bg-red-100 px-3 py-1 rounded-lg transition-colors duration-200"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="form-group">
|
||||
<label htmlFor={`flightNumber-${index}`} className="form-label">Flight Number *</label>
|
||||
<input
|
||||
type="text"
|
||||
id={`flightNumber-${index}`}
|
||||
value={flight.flightNumber}
|
||||
onChange={(e) => handleFlightChange(index, 'flightNumber', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="e.g., AA123"
|
||||
required={index === 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor={`flightDate-${index}`} className="form-label">Flight Date *</label>
|
||||
<input
|
||||
type="date"
|
||||
id={`flightDate-${index}`}
|
||||
value={flight.flightDate}
|
||||
onChange={(e) => handleFlightChange(index, 'flightDate', e.target.value)}
|
||||
className="form-input"
|
||||
required={index === 0}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary w-full"
|
||||
onClick={() => validateFlight(index)}
|
||||
disabled={flightValidating[index] || !flight.flightNumber || !flight.flightDate}
|
||||
>
|
||||
{flightValidating[index] ? (
|
||||
<>
|
||||
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full mr-2"></span>
|
||||
Validating Flight...
|
||||
</>
|
||||
) : (
|
||||
<>Validate Flight</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Flight Validation Results */}
|
||||
{flightErrors[index] && (
|
||||
<div className="mt-4 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="text-red-700 font-medium">{flightErrors[index]}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flight.validated && flight.validationData && (
|
||||
<div className="mt-4 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="text-green-700 font-medium mb-2">
|
||||
Valid: {flight.validationData.airline || 'Flight'} - {flight.validationData.departure?.airport} → {flight.validationData.arrival?.airport}
|
||||
</div>
|
||||
{flight.validationData.flightDate !== flight.flightDate && (
|
||||
<div className="text-sm text-green-600">
|
||||
Live tracking starts 4 hours before departure on {new Date(flight.flightDate).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{formData.flights.length < 3 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary w-full"
|
||||
onClick={addConnectingFlight}
|
||||
>
|
||||
Add Connecting Flight
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="checkbox-option checked">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="needsAirportPickup"
|
||||
checked={formData.needsAirportPickup || false}
|
||||
onChange={handleChange}
|
||||
className="form-checkbox mr-3"
|
||||
/>
|
||||
<span className="font-medium">Needs Airport Pickup (from final destination)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Self-Driving Mode Fields */}
|
||||
{formData.transportMode === 'self-driving' && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="expectedArrival" className="form-label">Expected Arrival *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="expectedArrival"
|
||||
name="expectedArrival"
|
||||
value={formData.expectedArrival}
|
||||
onChange={handleChange}
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Universal Transportation Option */}
|
||||
<div className={`checkbox-option ${formData.needsVenueTransport ? 'checked' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="needsVenueTransport"
|
||||
checked={formData.needsVenueTransport}
|
||||
onChange={handleChange}
|
||||
className="form-checkbox mr-3"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium">Needs Transportation Between Venues</span>
|
||||
<div className="text-sm text-slate-500 mt-1">
|
||||
Check this if the VIP needs rides between different event locations
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Information Section */}
|
||||
<div className="form-section">
|
||||
<div className="form-section-header">
|
||||
<h3 className="form-section-title">Additional Information</h3>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="notes" className="form-label">Additional Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
value={formData.notes}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="form-textarea"
|
||||
placeholder="Special requirements, dietary restrictions, accessibility needs, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Add VIP
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VipForm;
|
||||
Reference in New Issue
Block a user