Files
vip-coordinator/frontend/src/pages/CommandCenter.tsx
kyle a4d360aae9 feat: add PDF reports, timezone management, GPS QR codes, and fix GPS tracking gaps
Issue #1: QR button on GPS Devices tab for re-enrollment
Issue #2: App-wide timezone setting with TimezoneContext, useFormattedDate hook,
  and admin timezone selector. All date displays now respect the configured timezone.
Issue #3: PDF export for Accountability Roster using @react-pdf/renderer with
  professional styling matching VIPSchedulePDF. Added Signal send button.
Issue #4: Fixed GPS "teleporting" gaps - syncPositions now fetches position history
  per device instead of only latest position. Changed cron to every 30s, added
  unique constraint on deviceId+timestamp for deduplication, lowered min interval to 10s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 07:36:51 +01:00

1202 lines
51 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Loading } from '@/components/Loading';
import {
Car,
Clock,
MapPin,
Users,
AlertTriangle,
CheckCircle,
Plane,
Radio,
Bell,
AlertCircle,
ArrowRight,
ChevronRight,
Activity,
Calendar,
} from 'lucide-react';
import { useEffect, useState, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import { DriverChatBubble } from '@/components/DriverChatBubble';
import { DriverChatModal } from '@/components/DriverChatModal';
import { GpsIndicatorDot } from '@/components/GpsIndicatorDot';
import { DriverLocationModal } from '@/components/DriverLocationModal';
import { useUnreadCounts, useDriverResponseCheck } from '@/hooks/useSignalMessages';
import { useDriverLocations } from '@/hooks/useGps';
import type { DriverLocation } from '@/types/gps';
import type { Flight } from '@/types';
import { groupFlightsIntoJourneys, formatLayoverDuration } from '@/lib/journeyUtils';
import { useFormattedDate } from '@/hooks/useFormattedDate';
interface Event {
id: string;
title: string;
pickupLocation: string | null;
dropoffLocation: string | null;
location: string | null;
startTime: string;
endTime: string;
actualStartTime: string | null;
status: string;
type: string;
vip: {
id: string;
name: string;
partySize?: number;
} | null;
vips?: Array<{
id: string;
name: string;
partySize: number;
}>;
driver: {
id: string;
name: string;
} | null;
vehicle: {
id: string;
name: string;
type: string;
seatCapacity: number;
} | null;
}
interface Vehicle {
id: string;
name: string;
type: string;
seatCapacity: number;
status: string;
currentDriver: { name: string } | null;
}
interface VIP {
id: string;
name: string;
organization: string | null;
arrivalMode: string;
expectedArrival: string | null;
flights: Array<{
id: string;
flightNumber: string;
arrivalAirport: string;
departureAirport: string;
scheduledArrival: string | null;
estimatedArrival: string | null;
actualArrival: string | null;
arrivalDelay: number | null;
departureDelay: number | null;
arrivalTerminal: string | null;
arrivalGate: string | null;
arrivalBaggage: string | null;
status: string | null;
airlineName: string | null;
}>;
}
interface Driver {
id: string;
name: string;
phone: string;
}
const SCROLL_SPEED = 60; // pixels per second (must be >= 60 for 60fps to register 1px per frame)
const SCROLL_PAUSE_AT_END = 2000; // pause 2 seconds at top/bottom
const USER_INTERACTION_PAUSE = 20000; // pause 20 seconds after user interaction
export function CommandCenter() {
const { formatTime, formatDateTime, timezone } = useFormattedDate();
const [currentTime, setCurrentTime] = useState(new Date());
const [lastRefresh, setLastRefresh] = useState(new Date());
const [chatDriver, setChatDriver] = useState<Driver | null>(null);
const [isChatOpen, setIsChatOpen] = useState(false);
const [locationDriver, setLocationDriver] = useState<DriverLocation | null>(null);
const [isLocationOpen, setIsLocationOpen] = useState(false);
// Refs for smooth auto-scrolling
const activeScrollRef = useRef<HTMLDivElement>(null);
const upcomingScrollRef = useRef<HTMLDivElement>(null);
const arrivalsScrollRef = useRef<HTMLDivElement>(null);
const availableScrollRef = useRef<HTMLDivElement>(null);
// Track user interaction to pause auto-scroll
const lastInteractionRef = useRef<number>(0);
// Track scroll directions (persists across renders)
const scrollDirectionsRef = useRef<number[]>([1, 1, 1, 1]);
const handleUserInteraction = () => {
lastInteractionRef.current = Date.now();
};
const { data: unreadCounts } = useUnreadCounts();
const openChat = (driver: { id: string; name: string; phone?: string }) => {
// Find full driver info including phone
const fullDriver = drivers?.find(d => d.id === driver.id);
if (fullDriver) {
setChatDriver(fullDriver);
setIsChatOpen(true);
}
};
const closeChat = () => {
setIsChatOpen(false);
setChatDriver(null);
};
const openLocationModal = (loc: DriverLocation) => {
setLocationDriver(loc);
setIsLocationOpen(true);
};
const closeLocationModal = () => {
setIsLocationOpen(false);
setLocationDriver(null);
};
// GPS driver locations
const { data: driverLocations } = useDriverLocations();
const driverLocationMap = useMemo(() => {
const map = new Map<string, DriverLocation>();
driverLocations?.forEach(loc => map.set(loc.driverId, loc));
return map;
}, [driverLocations]);
const { data: events, refetch: refetchEvents } = useQuery<Event[]>({
queryKey: ['events'],
queryFn: async () => {
const { data } = await api.get('/events');
return data;
},
});
const { data: vehicles, refetch: refetchVehicles } = useQuery<Vehicle[]>({
queryKey: ['vehicles'],
queryFn: async () => {
const { data } = await api.get('/vehicles');
return data;
},
});
const { data: vips, refetch: refetchVips } = useQuery<VIP[]>({
queryKey: ['vips'],
queryFn: async () => {
const { data } = await api.get('/vips');
return data;
},
});
const { data: drivers } = useQuery<Driver[]>({
queryKey: ['drivers'],
queryFn: async () => {
const { data } = await api.get('/drivers');
return data;
},
});
const { data: flights } = useQuery<Flight[]>({
queryKey: ['flights'],
queryFn: async () => {
const { data } = await api.get('/flights');
return data;
},
});
// Group flights into journeys for connection risk detection
const journeys = useMemo(() => {
if (!flights || flights.length === 0) return [];
return groupFlightsIntoJourneys(flights);
}, [flights]);
// Compute awaiting confirmation BEFORE any conditional returns (for hooks)
const now = currentTime;
const awaitingConfirmation = (events || []).filter((event) => {
if (event.status !== 'SCHEDULED' || event.type !== 'TRANSPORT') return false;
const start = new Date(event.startTime);
return start <= now;
});
// Check which awaiting events have driver responses since the event started
// MUST be called before any conditional returns to satisfy React's rules of hooks
const { data: respondedEventIds } = useDriverResponseCheck(awaitingConfirmation);
// Update clock every second
useEffect(() => {
const clockInterval = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(clockInterval);
}, []);
// Auto-refresh data every 30 seconds
useEffect(() => {
const interval = setInterval(() => {
refetchEvents();
refetchVehicles();
refetchVips();
setLastRefresh(new Date());
}, 30000);
return () => clearInterval(interval);
}, [refetchEvents, refetchVehicles, refetchVips]);
// Smooth auto-scrolling for Active, Upcoming, Arrivals, and Available sections
useEffect(() => {
const pauseTimers: (NodeJS.Timeout | null)[] = [null, null, null, null];
const directions = scrollDirectionsRef.current;
const animate = () => {
// Get current refs (they may change)
const scrollContainers = [
activeScrollRef.current,
upcomingScrollRef.current,
arrivalsScrollRef.current,
availableScrollRef.current,
];
// Check if user has interacted recently
const timeSinceInteraction = Date.now() - lastInteractionRef.current;
if (timeSinceInteraction < USER_INTERACTION_PAUSE) {
return; // Pause scrolling while user is interacting
}
scrollContainers.forEach((container, index) => {
if (!container || pauseTimers[index]) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const maxScroll = scrollHeight - clientHeight;
// Only scroll if there's content to scroll
if (maxScroll <= 0) return;
// Calculate new scroll position (ensure at least 1px movement)
const scrollAmount = Math.max(1, Math.round(SCROLL_SPEED / 60)); // 60fps, min 1px
const newScrollTop = scrollTop + (scrollAmount * directions[index]);
// Check if we've reached the bottom
if (directions[index] === 1 && newScrollTop >= maxScroll) {
container.scrollTop = maxScroll;
directions[index] = -1;
// Pause at bottom
pauseTimers[index] = setTimeout(() => {
pauseTimers[index] = null;
}, SCROLL_PAUSE_AT_END);
}
// Check if we've reached the top
else if (directions[index] === -1 && newScrollTop <= 0) {
container.scrollTop = 0;
directions[index] = 1;
// Pause at top
pauseTimers[index] = setTimeout(() => {
pauseTimers[index] = null;
}, SCROLL_PAUSE_AT_END);
}
else {
container.scrollTop = newScrollTop;
}
});
};
const intervalId = setInterval(animate, 1000 / 60); // 60fps
return () => {
clearInterval(intervalId);
pauseTimers.forEach(timer => timer && clearTimeout(timer));
};
}, []); // Run once on mount
if (!events || !vehicles || !vips) {
return <Loading message="Loading Command Center..." />;
}
const fifteenMinutes = new Date(now.getTime() + 15 * 60 * 1000);
const thirtyMinutes = new Date(now.getTime() + 30 * 60 * 1000);
const twoHoursLater = new Date(now.getTime() + 2 * 60 * 60 * 1000);
const fourHoursLater = new Date(now.getTime() + 4 * 60 * 60 * 1000);
// En route trips (IN_PROGRESS)
const enRouteTrips = events.filter(
(event) => event.status === 'IN_PROGRESS' && event.type === 'TRANSPORT'
);
// Upcoming trips in next 2 hours
const upcomingTrips = events
.filter((event) => {
const start = new Date(event.startTime);
return start > now && start <= twoHoursLater && event.type === 'TRANSPORT' && event.status === 'SCHEDULED';
})
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
// ALERTS: Events in next 2 hours without driver or vehicle
const unassignedEvents = upcomingTrips.filter((e) => !e.driver || !e.vehicle);
// ALERTS: Events starting in <15 min
const imminentEvents = upcomingTrips.filter((e) => {
const start = new Date(e.startTime);
return start <= fifteenMinutes;
});
// Upcoming arrivals (next 4 hours) - use best available time (estimated > scheduled)
const getFlightArrivalTime = (flight: { actualArrival: string | null; estimatedArrival: string | null; scheduledArrival: string | null }) =>
flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
const upcomingArrivals = vips
.filter((vip) => {
if (vip.expectedArrival) {
const arrival = new Date(vip.expectedArrival);
return arrival > now && arrival <= fourHoursLater;
}
return vip.flights.some((flight) => {
const arrTime = getFlightArrivalTime(flight);
if (arrTime && flight.status?.toLowerCase() !== 'cancelled') {
const arrival = new Date(arrTime);
return arrival > now && arrival <= fourHoursLater;
}
return false;
});
})
.sort((a, b) => {
const aTime = a.expectedArrival || getFlightArrivalTime(a.flights[0]) || '';
const bTime = b.expectedArrival || getFlightArrivalTime(b.flights[0]) || '';
return new Date(aTime).getTime() - new Date(bTime).getTime();
});
// Vehicle status - calculate dynamically based on active events
// A vehicle is "in use" if it's assigned to an IN_PROGRESS event
const vehiclesInActiveEvents = new Set(
events
.filter((e) => e.status === 'IN_PROGRESS' && e.vehicle)
.map((e) => e.vehicle!.id)
);
// Also mark vehicles assigned to events starting within 15 minutes as "reserved"
const vehiclesReservedSoon = new Set(
events
.filter((e) => {
if (e.status !== 'SCHEDULED' || !e.vehicle) return false;
const start = new Date(e.startTime);
return start > now && start <= fifteenMinutes;
})
.map((e) => e.vehicle!.id)
);
const inUseVehicles = vehicles.filter(
(v) => vehiclesInActiveEvents.has(v.id) || v.status === 'IN_USE'
);
const reservedVehicles = vehicles.filter(
(v) => vehiclesReservedSoon.has(v.id) && !vehiclesInActiveEvents.has(v.id) && v.status !== 'IN_USE'
);
const availableVehicles = vehicles.filter(
(v) =>
v.status !== 'MAINTENANCE' &&
!vehiclesInActiveEvents.has(v.id) &&
!vehiclesReservedSoon.has(v.id) &&
v.status !== 'IN_USE'
);
// Calculate alerts
const alerts: Array<{ type: 'critical' | 'warning' | 'info'; message: string; link?: string }> = [];
// Alert for trips awaiting confirmation (overdue)
awaitingConfirmation.forEach((event) => {
const minutesLate = Math.floor((now.getTime() - new Date(event.startTime).getTime()) / 60000);
if (minutesLate >= 5) {
alerts.push({
type: 'critical',
message: `${formatVipNames(event)}: Trip ${minutesLate}min overdue - awaiting driver confirmation`,
link: `/events`,
});
}
});
unassignedEvents.forEach((event) => {
const vipLabel = formatVipNames(event);
if (!event.driver && !event.vehicle) {
alerts.push({
type: 'critical',
message: `${vipLabel}: No driver or vehicle assigned (${getTimeUntil(event.startTime)})`,
link: `/events`,
});
} else if (!event.driver) {
alerts.push({
type: 'critical',
message: `${vipLabel}: No driver assigned (${getTimeUntil(event.startTime)})`,
link: `/events`,
});
} else if (!event.vehicle) {
alerts.push({
type: 'warning',
message: `${vipLabel}: No vehicle assigned (${getTimeUntil(event.startTime)})`,
link: `/events`,
});
}
});
if (availableVehicles.length === 0 && upcomingTrips.length > 0) {
alerts.push({
type: 'warning',
message: 'No vehicles available - all vehicles in use',
link: `/fleet?tab=vehicles`,
});
}
// Flight alerts: cancelled, diverted, or significantly delayed
if (flights) {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date(todayStart);
todayEnd.setDate(todayEnd.getDate() + 1);
flights.forEach((flight) => {
const arrivalTime = flight.estimatedArrival || flight.scheduledArrival;
if (!arrivalTime) return;
const arrDate = new Date(arrivalTime);
if (arrDate < todayStart || arrDate > todayEnd) return;
const status = flight.status?.toLowerCase();
const vipName = flight.vip?.name || 'Unknown VIP';
const delay = Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0);
if (status === 'cancelled') {
alerts.push({
type: 'critical',
message: `${flight.flightNumber} (${vipName}): FLIGHT CANCELLED`,
link: '/flights',
});
} else if (status === 'diverted') {
alerts.push({
type: 'critical',
message: `${flight.flightNumber} (${vipName}): FLIGHT DIVERTED`,
link: '/flights',
});
} else if (delay > 30) {
alerts.push({
type: 'warning',
message: `${flight.flightNumber} (${vipName}): Delayed ${delay} minutes`,
link: '/flights',
});
}
});
}
// Connection risk alerts from journey analysis
journeys.forEach((journey) => {
if (!journey.hasLayoverRisk) return;
const vipName = journey.vip?.name || 'Unknown VIP';
journey.layovers.forEach((layover) => {
if (layover.risk === 'missed') {
alerts.push({
type: 'critical',
message: `${vipName}: Connection MISSED at ${layover.airport} - arrived ${formatLayoverDuration(Math.abs(layover.effectiveMinutes))} after departure`,
link: '/flights',
});
} else if (layover.risk === 'critical') {
alerts.push({
type: 'critical',
message: `${vipName}: Connection at ${layover.airport} critical - only ${formatLayoverDuration(layover.effectiveMinutes)} layover`,
link: '/flights',
});
} else if (layover.risk === 'warning') {
alerts.push({
type: 'warning',
message: `${vipName}: Connection at ${layover.airport} tight - ${formatLayoverDuration(layover.effectiveMinutes)} layover (was ${formatLayoverDuration(layover.scheduledMinutes)})`,
link: '/flights',
});
}
});
});
// Get time until event
function getTimeUntil(dateStr: string) {
const eventTime = new Date(dateStr);
const diff = eventTime.getTime() - now.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 0) return 'NOW';
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMin = minutes % 60;
return `${hours}h ${remainingMin}m`;
}
function getUrgencyClass(startTime: string) {
const start = new Date(startTime);
const diff = start.getTime() - now.getTime();
const minutes = diff / 60000;
if (minutes <= 15) return 'border-l-red-500 bg-red-50 dark:bg-red-950/30';
if (minutes <= 30) return 'border-l-orange-500 bg-orange-50 dark:bg-orange-950/20';
if (minutes <= 60) return 'border-l-yellow-500 bg-yellow-50 dark:bg-yellow-950/20';
return 'border-l-blue-500';
}
function getTimeColor(startTime: string) {
const start = new Date(startTime);
const diff = start.getTime() - now.getTime();
const minutes = diff / 60000;
if (minutes <= 15) return 'text-red-600 dark:text-red-400';
if (minutes <= 30) return 'text-orange-600 dark:text-orange-400';
if (minutes <= 60) return 'text-yellow-600 dark:text-yellow-400';
return 'text-blue-600 dark:text-blue-400';
}
// Format VIP names for trip cards (show all, with party size indicators)
function formatVipNames(event: Event): string {
if (event.vips && event.vips.length > 0) {
return event.vips.map(v =>
v.partySize > 1 ? `${v.name} (+${v.partySize - 1})` : v.name
).join(', ');
}
return event.vip?.name || 'Unknown VIP';
}
const secondsSinceRefresh = Math.floor((now.getTime() - lastRefresh.getTime()) / 1000);
// System status
const hasAlerts = alerts.length > 0;
const hasCriticalAlerts = alerts.some((a) => a.type === 'critical');
const systemStatus = hasCriticalAlerts ? 'critical' : hasAlerts ? 'warning' : 'operational';
return (
<div className="space-y-4">
{/* Header with Live Clock */}
<div className="bg-card border border-border rounded-lg shadow-soft p-4">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
{/* Title and Status */}
<div className="flex items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Activity className="h-6 w-6" />
Command Center
</h1>
<div className="flex items-center gap-2 mt-1">
<div className={`h-2 w-2 rounded-full ${
systemStatus === 'operational' ? 'bg-green-500' :
systemStatus === 'warning' ? 'bg-yellow-500 animate-pulse' :
'bg-red-500 animate-pulse'
}`} />
<span className={`text-xs font-medium ${
systemStatus === 'operational' ? 'text-green-600 dark:text-green-400' :
systemStatus === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
'text-red-600 dark:text-red-400'
}`}>
{systemStatus === 'operational' ? 'All Systems Operational' :
systemStatus === 'warning' ? 'Attention Required' :
'Critical Issues'}
</span>
</div>
</div>
</div>
{/* Live Clock */}
<div className="text-right">
<div className="text-4xl font-mono font-bold text-foreground tabular-nums">
{currentTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: timezone })}
</div>
<div className="text-sm text-muted-foreground">
{currentTime.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', timeZone: timezone })}
</div>
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground mt-1">
<Radio className="h-3 w-3 text-green-500 animate-pulse" />
Updated {secondsSinceRefresh}s ago
</div>
</div>
</div>
</div>
{/* Alerts Panel */}
{alerts.length > 0 && (
<div className={`rounded-lg border-2 p-4 ${
hasCriticalAlerts
? 'bg-red-50 dark:bg-red-950/30 border-red-300 dark:border-red-800'
: 'bg-yellow-50 dark:bg-yellow-950/30 border-yellow-300 dark:border-yellow-800'
}`}>
<div className="flex items-center gap-2 mb-3">
<Bell className={`h-5 w-5 ${hasCriticalAlerts ? 'text-red-600' : 'text-yellow-600'}`} />
<h2 className={`font-semibold ${hasCriticalAlerts ? 'text-red-800 dark:text-red-200' : 'text-yellow-800 dark:text-yellow-200'}`}>
{alerts.length} Alert{alerts.length > 1 ? 's' : ''} Requiring Attention
</h2>
</div>
<div className="space-y-2">
{alerts.slice(0, 5).map((alert, idx) => (
<div key={idx} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{alert.type === 'critical' ? (
<AlertCircle className="h-4 w-4 text-red-600 flex-shrink-0" />
) : (
<AlertTriangle className="h-4 w-4 text-yellow-600 flex-shrink-0" />
)}
<span className={`text-sm ${
alert.type === 'critical'
? 'text-red-800 dark:text-red-200'
: 'text-yellow-800 dark:text-yellow-200'
}`}>
{alert.message}
</span>
</div>
{alert.link && (
<Link
to={alert.link}
className="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 flex items-center gap-1"
>
Fix <ChevronRight className="h-3 w-3" />
</Link>
)}
</div>
))}
{alerts.length > 5 && (
<p className="text-sm text-muted-foreground">+{alerts.length - 5} more alerts</p>
)}
</div>
</div>
)}
{/* Quick Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-card border border-border p-3 rounded-lg shadow-soft">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide">En Route</p>
<p className="text-3xl font-bold text-green-600 dark:text-green-400">{enRouteTrips.length}</p>
</div>
<div className="h-10 w-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<Car className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
</div>
</div>
<div className="bg-card border border-border p-3 rounded-lg shadow-soft">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide">Upcoming (2h)</p>
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">{upcomingTrips.length}</p>
</div>
<div className="h-10 w-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Clock className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
</div>
</div>
<div className="bg-card border border-border p-3 rounded-lg shadow-soft">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide">Vehicles Free</p>
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
{availableVehicles.length}<span className="text-lg text-muted-foreground">/{vehicles.filter(v => v.status !== 'MAINTENANCE').length}</span>
</p>
{inUseVehicles.length > 0 && (
<p className="text-xs text-blue-600 dark:text-blue-400">{inUseVehicles.length} in use</p>
)}
</div>
<div className="h-10 w-10 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
</div>
</div>
<div className="bg-card border border-border p-3 rounded-lg shadow-soft">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide">VIP Arrivals</p>
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400">{upcomingArrivals.length}</p>
</div>
<div className="h-10 w-10 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<Plane className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
</div>
</div>
</div>
{/* Active & Upcoming - Side by Side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Active NOW - includes both en route and awaiting confirmation */}
{(() => {
const allActiveTrips = [...awaitingConfirmation, ...enRouteTrips];
const awaitingIds = new Set(awaitingConfirmation.map(t => t.id));
return (
<div className={`bg-card border-2 ${awaitingConfirmation.length > 0 ? 'border-orange-500' : 'border-green-500'} rounded-lg shadow-soft overflow-hidden`}>
<div className={`${awaitingConfirmation.length > 0 ? 'bg-orange-500' : 'bg-green-600'} px-4 py-2 flex items-center justify-between`}>
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-white animate-pulse" />
<h2 className="font-bold text-white">ACTIVE NOW</h2>
{awaitingConfirmation.length > 0 && (
<span className="text-xs bg-white/20 px-2 py-0.5 rounded-full">
{awaitingConfirmation.length} awaiting
</span>
)}
</div>
<span className="text-white/80 text-sm">{allActiveTrips.length} trips</span>
</div>
{allActiveTrips.length === 0 ? (
<div className="p-6 text-center text-muted-foreground">
<Car className="h-10 w-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">No active transports</p>
</div>
) : (
<div
ref={activeScrollRef}
onWheel={handleUserInteraction}
onTouchMove={handleUserInteraction}
className="divide-y divide-border max-h-[280px] overflow-y-auto scrollbar-hide"
>
{allActiveTrips.map((trip) => {
const isAwaiting = awaitingIds.has(trip.id);
// Show glow only if awaiting AND driver hasn't responded since event started
const showAwaitingGlow = isAwaiting && !respondedEventIds?.has(trip.id);
return (
<div
key={trip.id}
className="p-3 transition-colors hover:bg-accent/50"
>
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-foreground truncate text-sm">
{formatVipNames(trip)}
</span>
<span className="text-muted-foreground text-xs truncate max-w-[150px]">
{trip.title}
</span>
<Link
to="/events"
state={{ editEventId: trip.id }}
onClick={(e) => e.stopPropagation()}
className="p-1 rounded hover:bg-primary/10 text-muted-foreground hover:text-primary transition-colors"
title="View/Edit Event"
>
<Calendar className="h-3.5 w-3.5" />
</Link>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<MapPin className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{trip.pickupLocation}</span>
<ArrowRight className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{trip.dropoffLocation}</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{trip.driver && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{trip.driver.name}
<GpsIndicatorDot
driverLocation={driverLocationMap.get(trip.driver.id)}
onClick={openLocationModal}
className="ml-0.5"
/>
<DriverChatBubble
unreadCount={unreadCounts?.[trip.driver.id] || 0}
awaitingResponse={showAwaitingGlow}
onClick={() => openChat(trip.driver!)}
className="ml-0.5"
/>
</span>
)}
{trip.vehicle && (
<span className="flex items-center gap-1">
<Car className="h-3 w-3" /> {trip.vehicle.name}
</span>
)}
</div>
</div>
<div className="text-right flex-shrink-0">
<p className="text-xs text-muted-foreground">ETA</p>
<p className="text-lg font-bold text-green-600 dark:text-green-400">
{formatTime(new Date(trip.endTime))}
</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
})()}
{/* Upcoming Trips */}
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
<div className="bg-blue-600 px-4 py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-white" />
<h2 className="font-bold text-white">UPCOMING</h2>
</div>
<span className="text-white/80 text-sm">{upcomingTrips.length} trips</span>
</div>
{upcomingTrips.length === 0 ? (
<div className="p-6 text-center text-muted-foreground">
<CheckCircle className="h-10 w-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">No trips in next 2 hours</p>
</div>
) : (
<div
ref={upcomingScrollRef}
onWheel={handleUserInteraction}
onTouchMove={handleUserInteraction}
className="divide-y divide-border max-h-[280px] overflow-y-auto scrollbar-hide"
>
{upcomingTrips.map((trip) => {
const timeColor = getTimeColor(trip.startTime);
const isImminent = new Date(trip.startTime) <= fifteenMinutes;
return (
<div
key={trip.id}
className={`p-3 border-l-4 ${isImminent ? 'border-l-red-500 bg-red-50 dark:bg-red-950/20' : 'border-l-blue-500'} hover:bg-accent/30 transition-colors`}
>
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{isImminent && <AlertTriangle className="h-3 w-3 text-red-600 flex-shrink-0" />}
<span className="font-semibold text-foreground truncate text-sm">
{formatVipNames(trip)}
</span>
<span className="text-muted-foreground text-xs truncate max-w-[150px]">
{trip.title}
</span>
<Link
to="/events"
state={{ editEventId: trip.id }}
onClick={(e) => e.stopPropagation()}
className="p-1 rounded hover:bg-primary/10 text-muted-foreground hover:text-primary transition-colors"
title="View/Edit Event"
>
<Calendar className="h-3.5 w-3.5" />
</Link>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<MapPin className="h-3 w-3 flex-shrink-0" />
<span className="truncate">
{trip.pickupLocation || trip.location} {trip.dropoffLocation || 'TBD'}
</span>
</div>
<div className="flex items-center gap-3 text-xs">
{trip.driver ? (
<span className="flex items-center gap-1 text-muted-foreground">
<Users className="h-3 w-3" />
{trip.driver.name}
<GpsIndicatorDot
driverLocation={driverLocationMap.get(trip.driver.id)}
onClick={openLocationModal}
className="ml-0.5"
/>
<DriverChatBubble
unreadCount={unreadCounts?.[trip.driver.id] || 0}
onClick={() => openChat(trip.driver!)}
className="ml-0.5"
/>
</span>
) : (
<span className="flex items-center gap-1 text-red-600 font-medium">
<AlertCircle className="h-3 w-3" /> No Driver
</span>
)}
{trip.vehicle ? (
<span className="flex items-center gap-1 text-muted-foreground">
<Car className="h-3 w-3" /> {trip.vehicle.name}
</span>
) : (
<span className="flex items-center gap-1 text-red-600 font-medium">
<AlertCircle className="h-3 w-3" /> No Vehicle
</span>
)}
</div>
</div>
<div className="text-right flex-shrink-0">
<p className={`text-xl font-bold ${timeColor}`}>
{getTimeUntil(trip.startTime)}
</p>
<p className="text-xs text-muted-foreground">
{formatTime(new Date(trip.startTime))}
</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Bottom Row: Resources */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
{/* VIP Arrivals */}
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden col-span-2">
<div className="bg-purple-600 px-3 py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Plane className="h-4 w-4 text-white" />
<h2 className="font-semibold text-white text-sm">Arrivals</h2>
</div>
<span className="text-white/80 text-xs">{upcomingArrivals.length}</span>
</div>
<div
ref={arrivalsScrollRef}
onWheel={handleUserInteraction}
onTouchMove={handleUserInteraction}
className="max-h-[180px] overflow-y-auto scrollbar-hide"
>
{upcomingArrivals.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
<p className="text-xs">No arrivals soon</p>
</div>
) : (
<div className="divide-y divide-border">
{upcomingArrivals.map((vip) => {
// Find this VIP's journey if it exists
const journey = journeys.find(j => j.vipId === vip.id);
const flight = vip.flights.find(f => f.status?.toLowerCase() !== 'cancelled') || vip.flights[0];
const lastFlight = journey ? journey.flights[journey.flights.length - 1] : flight;
const finalArrival = lastFlight ? (lastFlight.actualArrival || lastFlight.estimatedArrival || lastFlight.scheduledArrival) : null;
const arrival = vip.expectedArrival || finalArrival;
const currentFlight = journey ? journey.flights[journey.currentSegmentIndex] : flight;
const delay = currentFlight ? Math.max(currentFlight.arrivalDelay || 0, currentFlight.departureDelay || 0) : 0;
const effectiveStatus = journey?.effectiveStatus || currentFlight?.status?.toLowerCase() || 'scheduled';
const isCancelled = effectiveStatus === 'cancelled';
const isActive = effectiveStatus === 'active';
const isLanded = effectiveStatus === 'landed';
const timeColor = isCancelled
? 'text-red-600 dark:text-red-400'
: isLanded
? 'text-emerald-600 dark:text-emerald-400'
: delay > 15
? 'text-amber-600 dark:text-amber-400'
: isActive
? 'text-purple-600 dark:text-purple-400'
: 'text-blue-600 dark:text-blue-400';
const borderColor = journey?.hasLayoverRisk
? 'border-l-orange-500'
: isCancelled
? 'border-l-red-500'
: delay > 30
? 'border-l-amber-500'
: isActive
? 'border-l-purple-500'
: isLanded
? 'border-l-emerald-500'
: 'border-l-blue-500';
// Build route chain
const routeChain = journey && journey.isMultiSegment
? journey.flights.map(f => f.departureAirport).concat([journey.flights[journey.flights.length - 1].arrivalAirport]).join(' → ')
: flight ? `${flight.departureAirport}${flight.arrivalAirport}` : '';
return (
<div key={vip.id} className={`px-3 py-2 border-l-4 ${borderColor}`}>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="font-medium text-foreground text-xs truncate">{vip.name}</p>
{journey?.hasLayoverRisk && (
<span className="flex items-center gap-0.5 px-1 py-0 text-[10px] rounded bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
<AlertTriangle className="w-2.5 h-2.5" />
risk
</span>
)}
{delay > 15 && !journey?.hasLayoverRisk && (
<span className="flex items-center gap-0.5 px-1 py-0 text-[10px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
<AlertTriangle className="w-2.5 h-2.5" />
+{delay}m
</span>
)}
{isCancelled && (
<span className="px-1 py-0 text-[10px] rounded bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
CANCELLED
</span>
)}
</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span>{routeChain}</span>
{journey?.isMultiSegment && (
<span className="text-muted-foreground/60">{journey.flights.length} legs</span>
)}
</div>
{currentFlight && (currentFlight.arrivalTerminal || currentFlight.arrivalGate || currentFlight.arrivalBaggage) && (
<div className="flex gap-2 text-[10px] text-muted-foreground mt-0.5">
{currentFlight.arrivalTerminal && <span>T{currentFlight.arrivalTerminal}</span>}
{currentFlight.arrivalGate && <span>Gate {currentFlight.arrivalGate}</span>}
{currentFlight.arrivalBaggage && <span>Bag {currentFlight.arrivalBaggage}</span>}
</div>
)}
</div>
<div className="text-right flex-shrink-0">
<p className={`text-xs font-bold ${timeColor}`}>
{isCancelled ? '---' : isLanded ? 'Landed' : arrival ? getTimeUntil(arrival) : '--'}
</p>
{arrival && !isCancelled && !isLanded && (
<p className="text-[10px] text-muted-foreground">
{formatTime(new Date(arrival))}
</p>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Vehicles Available */}
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
<div className="bg-emerald-600 px-3 py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Car className="h-4 w-4 text-white" />
<h2 className="font-semibold text-white text-sm">Available</h2>
</div>
<span className="text-white/80 text-xs">{availableVehicles.length}</span>
</div>
<div
ref={availableScrollRef}
onWheel={handleUserInteraction}
onTouchMove={handleUserInteraction}
className="max-h-[140px] overflow-y-auto scrollbar-hide"
>
{availableVehicles.length === 0 ? (
<div className="p-4 text-center">
<AlertTriangle className="h-6 w-6 mx-auto mb-1 text-yellow-500" />
<p className="text-xs text-muted-foreground">None available</p>
</div>
) : (
<div className="divide-y divide-border">
{availableVehicles.map((vehicle) => (
<div key={vehicle.id} className="px-3 py-2 flex items-center justify-between">
<div>
<p className="text-xs font-medium text-foreground">{vehicle.name}</p>
<p className="text-[10px] text-muted-foreground">{vehicle.seatCapacity} seats</p>
</div>
<CheckCircle className="h-3 w-3 text-emerald-500" />
</div>
))}
</div>
)}
</div>
</div>
{/* Vehicles In Use */}
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
<div className="bg-blue-600 px-3 py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Car className="h-4 w-4 text-white" />
<h2 className="font-semibold text-white text-sm">In Use</h2>
</div>
<span className="text-white/80 text-xs">{inUseVehicles.length}</span>
</div>
<div className="max-h-[140px] overflow-y-auto scrollbar-hide">
{inUseVehicles.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
<p className="text-xs">All available</p>
</div>
) : (
<div className="divide-y divide-border">
{inUseVehicles.slice(0, 4).map((vehicle) => {
const activeEvent = events.find((e) => e.status === 'IN_PROGRESS' && e.vehicle?.id === vehicle.id);
return (
<div key={vehicle.id} className="px-3 py-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">{vehicle.name}</p>
<div className="h-2 w-2 rounded-full bg-blue-500 animate-pulse" />
</div>
<p className="text-[10px] text-muted-foreground truncate flex items-center gap-1">
{activeEvent?.driver?.name || 'Driver'}
{activeEvent?.driver && (
<GpsIndicatorDot
driverLocation={driverLocationMap.get(activeEvent.driver.id)}
onClick={openLocationModal}
/>
)}
</p>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Active Drivers (GPS) */}
{(() => {
const TEN_MINUTES = 10 * 60 * 1000;
const activeDrivers = (driverLocations || []).filter(loc =>
loc.lastActive && (Date.now() - new Date(loc.lastActive).getTime()) < TEN_MINUTES && loc.location
);
return (
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
<div className="bg-teal-600 px-3 py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Radio className="h-4 w-4 text-white" />
<h2 className="font-semibold text-white text-sm">Active Drivers</h2>
</div>
<span className="text-white/80 text-xs">{activeDrivers.length}</span>
</div>
<div className="max-h-[108px] overflow-y-auto scrollbar-hide">
{activeDrivers.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
<p className="text-xs">No active GPS drivers</p>
</div>
) : (
<div className="divide-y divide-border">
{activeDrivers.map((loc) => (
<button
key={loc.driverId}
onClick={() => openLocationModal(loc)}
className="w-full px-3 py-2 text-left hover:bg-accent/50 transition-colors"
>
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground truncate">{loc.driverName}</p>
<span className="text-[10px] text-green-600 dark:text-green-400 font-medium flex-shrink-0">
{loc.location?.speed?.toFixed(0) || 0} mph
</span>
</div>
<p className="text-[10px] text-muted-foreground">
{loc.lastActive && formatDistanceToNow(new Date(loc.lastActive), { addSuffix: true })}
</p>
</button>
))}
</div>
)}
</div>
{/* Quick Action Links - compact icon row */}
<div className="border-t border-border px-2 py-1.5 flex justify-around">
<Link to="/events" className="p-1.5 rounded hover:bg-accent transition-colors" title="Events">
<Calendar className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400" />
</Link>
<Link to="/fleet?tab=drivers" className="p-1.5 rounded hover:bg-accent transition-colors" title="Drivers">
<Users className="h-3.5 w-3.5 text-purple-600 dark:text-purple-400" />
</Link>
<Link to="/vips" className="p-1.5 rounded hover:bg-accent transition-colors" title="VIPs">
<Users className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
</Link>
<Link to="/fleet?tab=vehicles" className="p-1.5 rounded hover:bg-accent transition-colors" title="Vehicles">
<Car className="h-3.5 w-3.5 text-orange-600 dark:text-orange-400" />
</Link>
</div>
</div>
);
})()}
</div>
{/* Driver Chat Modal */}
<DriverChatModal
driver={chatDriver}
isOpen={isChatOpen}
onClose={closeChat}
/>
{/* Driver Location Modal */}
<DriverLocationModal
driverLocation={locationDriver}
isOpen={isLocationOpen}
onClose={closeLocationModal}
/>
</div>
);
}