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>
1202 lines
51 KiB
TypeScript
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>
|
|
);
|
|
}
|