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>
This commit is contained in:
@@ -27,6 +27,9 @@ 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;
|
||||
@@ -105,6 +108,7 @@ 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);
|
||||
@@ -193,7 +197,7 @@ export function CommandCenter() {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: flights } = useQuery<VIP['flights']>({
|
||||
const { data: flights } = useQuery<Flight[]>({
|
||||
queryKey: ['flights'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/flights');
|
||||
@@ -201,6 +205,12 @@ export function CommandCenter() {
|
||||
},
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
@@ -330,7 +340,7 @@ export function CommandCenter() {
|
||||
});
|
||||
|
||||
// Upcoming arrivals (next 4 hours) - use best available time (estimated > scheduled)
|
||||
const getFlightArrivalTime = (flight: VIP['flights'][0]) =>
|
||||
const getFlightArrivalTime = (flight: { actualArrival: string | null; estimatedArrival: string | null; scheduledArrival: string | null }) =>
|
||||
flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
|
||||
|
||||
const upcomingArrivals = vips
|
||||
@@ -442,7 +452,7 @@ export function CommandCenter() {
|
||||
const todayEnd = new Date(todayStart);
|
||||
todayEnd.setDate(todayEnd.getDate() + 1);
|
||||
|
||||
flights.forEach((flight: any) => {
|
||||
flights.forEach((flight) => {
|
||||
const arrivalTime = flight.estimatedArrival || flight.scheduledArrival;
|
||||
if (!arrivalTime) return;
|
||||
const arrDate = new Date(arrivalTime);
|
||||
@@ -474,6 +484,33 @@ export function CommandCenter() {
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -560,10 +597,10 @@ export function CommandCenter() {
|
||||
{/* 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' })}
|
||||
{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' })}
|
||||
{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" />
|
||||
@@ -768,7 +805,7 @@ export function CommandCenter() {
|
||||
<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">
|
||||
{new Date(trip.endTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
{formatTime(new Date(trip.endTime))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -874,7 +911,7 @@ export function CommandCenter() {
|
||||
{getTimeUntil(trip.startTime)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(trip.startTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
{formatTime(new Date(trip.startTime))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -910,15 +947,19 @@ export function CommandCenter() {
|
||||
) : (
|
||||
<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 arrival = vip.expectedArrival || (flight && getFlightArrivalTime(flight));
|
||||
const delay = flight ? Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0) : 0;
|
||||
const flightStatus = flight?.status?.toLowerCase();
|
||||
const isCancelled = flightStatus === 'cancelled';
|
||||
const isActive = flightStatus === 'active';
|
||||
const isLanded = flightStatus === 'landed' || !!flight?.actualArrival;
|
||||
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';
|
||||
|
||||
// Color-code: green (on time / landed), amber (delayed), red (cancelled), purple (in flight)
|
||||
const timeColor = isCancelled
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: isLanded
|
||||
@@ -929,7 +970,9 @@ export function CommandCenter() {
|
||||
? 'text-purple-600 dark:text-purple-400'
|
||||
: 'text-blue-600 dark:text-blue-400';
|
||||
|
||||
const borderColor = isCancelled
|
||||
const borderColor = journey?.hasLayoverRisk
|
||||
? 'border-l-orange-500'
|
||||
: isCancelled
|
||||
? 'border-l-red-500'
|
||||
: delay > 30
|
||||
? 'border-l-amber-500'
|
||||
@@ -939,13 +982,24 @@ export function CommandCenter() {
|
||||
? '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>
|
||||
{delay > 15 && (
|
||||
{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
|
||||
@@ -958,18 +1012,16 @@ export function CommandCenter() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
{flight && (
|
||||
<>
|
||||
<span className="font-medium">{flight.flightNumber}</span>
|
||||
<span>{flight.departureAirport} → {flight.arrivalAirport}</span>
|
||||
</>
|
||||
<span>{routeChain}</span>
|
||||
{journey?.isMultiSegment && (
|
||||
<span className="text-muted-foreground/60">{journey.flights.length} legs</span>
|
||||
)}
|
||||
</div>
|
||||
{flight && (flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && (
|
||||
{currentFlight && (currentFlight.arrivalTerminal || currentFlight.arrivalGate || currentFlight.arrivalBaggage) && (
|
||||
<div className="flex gap-2 text-[10px] text-muted-foreground mt-0.5">
|
||||
{flight.arrivalTerminal && <span>T{flight.arrivalTerminal}</span>}
|
||||
{flight.arrivalGate && <span>Gate {flight.arrivalGate}</span>}
|
||||
{flight.arrivalBaggage && <span>Bag {flight.arrivalBaggage}</span>}
|
||||
{currentFlight.arrivalTerminal && <span>T{currentFlight.arrivalTerminal}</span>}
|
||||
{currentFlight.arrivalGate && <span>Gate {currentFlight.arrivalGate}</span>}
|
||||
{currentFlight.arrivalBaggage && <span>Bag {currentFlight.arrivalBaggage}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -979,7 +1031,7 @@ export function CommandCenter() {
|
||||
</p>
|
||||
{arrival && !isCancelled && !isLanded && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{new Date(arrival).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
|
||||
{formatTime(new Date(arrival))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user