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:
2026-02-08 07:36:51 +01:00
parent 0f0f1cbf38
commit a4d360aae9
33 changed files with 2136 additions and 361 deletions

View File

@@ -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>