feat: add smart flight tracking with AviationStack API + visual progress

- Add 20+ flight fields (terminal, gate, delays, estimated times, etc.)
- Smart polling cron with budget-aware priority queue (100 req/month)
- Tracking phases: FAR_OUT → PRE_DEPARTURE → ACTIVE → LANDED
- Visual FlightProgressBar with animated airplane between airports
- FlightCard with status dots, delay badges, expandable details
- FlightList rewrite: card-based, grouped by status, search/filter
- Dashboard: enriched flight status widget with compact progress bars
- CommandCenter: flight alerts + enriched arrivals with gate/terminal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 19:42:52 +01:00
parent 74a292ea93
commit 0f0f1cbf38
13 changed files with 1688 additions and 325 deletions

View File

@@ -0,0 +1,210 @@
import { useState } from 'react';
import {
Plane,
RefreshCw,
Edit3,
Trash2,
AlertTriangle,
Clock,
ToggleLeft,
ToggleRight,
ChevronDown,
ChevronUp,
Users,
} from 'lucide-react';
import { Flight } from '@/types';
import { FlightProgressBar } from './FlightProgressBar';
import { useRefreshFlight } from '@/hooks/useFlights';
interface FlightCardProps {
flight: Flight;
onEdit?: (flight: Flight) => void;
onDelete?: (flight: Flight) => void;
}
function getStatusDotColor(flight: Flight): string {
const status = flight.status?.toLowerCase();
const delay = flight.arrivalDelay || flight.departureDelay || 0;
if (status === 'cancelled') return 'bg-red-500';
if (status === 'diverted' || status === 'incident') return 'bg-red-500';
if (status === 'landed') return 'bg-emerald-500';
if (status === 'active') return delay > 15 ? 'bg-amber-500 animate-pulse' : 'bg-purple-500 animate-pulse';
if (delay > 30) return 'bg-orange-500';
if (delay > 15) return 'bg-amber-500';
return 'bg-blue-500';
}
function getAlertBanner(flight: Flight): { message: string; color: string } | null {
const status = flight.status?.toLowerCase();
const delay = Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0);
if (status === 'cancelled') return { message: 'FLIGHT CANCELLED', color: 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400' };
if (status === 'diverted') return { message: 'FLIGHT DIVERTED', color: 'bg-orange-500/10 border-orange-500/30 text-orange-700 dark:text-orange-400' };
if (status === 'incident') return { message: 'INCIDENT REPORTED', color: 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400' };
if (delay > 60) return { message: `DELAYED ${delay} MINUTES`, color: 'bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400' };
if (delay > 30) return { message: `Delayed ${delay} min`, color: 'bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400' };
return null;
}
function formatRelativeTime(isoString: string | null): string {
if (!isoString) return 'Never';
const diff = Date.now() - new Date(isoString).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
const [expanded, setExpanded] = useState(false);
const refreshMutation = useRefreshFlight();
const alert = getAlertBanner(flight);
const dotColor = getStatusDotColor(flight);
const isTerminal = ['landed', 'cancelled', 'diverted', 'incident'].includes(flight.status?.toLowerCase() || '');
return (
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
{/* Alert banner */}
{alert && (
<div className={`px-4 py-1.5 text-xs font-semibold border-b flex items-center gap-2 ${alert.color}`}>
<AlertTriangle className="w-3.5 h-3.5" />
{alert.message}
</div>
)}
{/* Header */}
<div className="px-4 pt-3 pb-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
{/* Status dot */}
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
{/* Flight number + airline */}
<div className="flex items-center gap-2">
<span className="font-bold text-foreground">{flight.flightNumber}</span>
{flight.airlineName && (
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
)}
</div>
{/* VIP name */}
{flight.vip && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<span className="text-muted-foreground/50">|</span>
<span className="font-medium text-foreground/80">{flight.vip.name}</span>
{flight.vip.partySize > 1 && (
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
<Users className="w-3 h-3" />
+{flight.vip.partySize - 1}
</span>
)}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<button
onClick={() => refreshMutation.mutate(flight.id)}
disabled={refreshMutation.isPending}
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50"
title="Refresh from API"
>
<RefreshCw className={`w-4 h-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
</button>
{onEdit && (
<button
onClick={() => onEdit(flight)}
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
title="Edit flight"
>
<Edit3 className="w-4 h-4" />
</button>
)}
{onDelete && (
<button
onClick={() => onDelete(flight)}
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-red-500"
title="Delete flight"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
{/* Progress Bar */}
<div className="px-4">
<FlightProgressBar flight={flight} />
</div>
{/* Footer - expandable details */}
<div className="px-4 pb-2">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-between py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
Updated {formatRelativeTime(flight.lastPolledAt)}
</span>
{flight.pollCount > 0 && (
<span>{flight.pollCount} poll{flight.pollCount !== 1 ? 's' : ''}</span>
)}
<span className="px-1.5 py-0.5 rounded bg-muted text-[10px] uppercase tracking-wider font-medium">
{flight.trackingPhase.replace(/_/g, ' ')}
</span>
</div>
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
{expanded && (
<div className="pt-2 pb-1 border-t border-border/50 space-y-2 text-xs">
{/* Detailed times */}
<div className="grid grid-cols-2 gap-4">
<div>
<div className="font-medium text-foreground mb-1">Departure</div>
<div className="space-y-0.5 text-muted-foreground">
{flight.scheduledDeparture && <div>Scheduled: {new Date(flight.scheduledDeparture).toLocaleString()}</div>}
{flight.estimatedDeparture && <div>Estimated: {new Date(flight.estimatedDeparture).toLocaleString()}</div>}
{flight.actualDeparture && <div className="text-foreground">Actual: {new Date(flight.actualDeparture).toLocaleString()}</div>}
{flight.departureDelay != null && flight.departureDelay > 0 && (
<div className="text-amber-600 dark:text-amber-400">Delay: {flight.departureDelay} min</div>
)}
{flight.departureTerminal && <div>Terminal: {flight.departureTerminal}</div>}
{flight.departureGate && <div>Gate: {flight.departureGate}</div>}
</div>
</div>
<div>
<div className="font-medium text-foreground mb-1">Arrival</div>
<div className="space-y-0.5 text-muted-foreground">
{flight.scheduledArrival && <div>Scheduled: {new Date(flight.scheduledArrival).toLocaleString()}</div>}
{flight.estimatedArrival && <div>Estimated: {new Date(flight.estimatedArrival).toLocaleString()}</div>}
{flight.actualArrival && <div className="text-foreground">Actual: {new Date(flight.actualArrival).toLocaleString()}</div>}
{flight.arrivalDelay != null && flight.arrivalDelay > 0 && (
<div className="text-amber-600 dark:text-amber-400">Delay: {flight.arrivalDelay} min</div>
)}
{flight.arrivalTerminal && <div>Terminal: {flight.arrivalTerminal}</div>}
{flight.arrivalGate && <div>Gate: {flight.arrivalGate}</div>}
{flight.arrivalBaggage && <div>Baggage: {flight.arrivalBaggage}</div>}
</div>
</div>
</div>
{/* Aircraft info */}
{flight.aircraftType && (
<div className="text-muted-foreground">
Aircraft: {flight.aircraftType}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,244 @@
import { useMemo, useEffect, useState } from 'react';
import { Plane } from 'lucide-react';
import { Flight } from '@/types';
interface FlightProgressBarProps {
flight: Flight;
compact?: boolean; // For mini version in dashboard/command center
}
function calculateProgress(flight: Flight): number {
const status = flight.status?.toLowerCase();
// Terminal states
if (status === 'landed' || flight.actualArrival) return 100;
if (status === 'cancelled' || status === 'diverted' || status === 'incident') return 0;
// Not departed yet
if (status === 'scheduled' || (!flight.actualDeparture && !status?.includes('active'))) {
return 0;
}
// In flight - calculate based on time elapsed
const departureTime = flight.actualDeparture || flight.estimatedDeparture || flight.scheduledDeparture;
const arrivalTime = flight.estimatedArrival || flight.scheduledArrival;
if (!departureTime || !arrivalTime) return status === 'active' ? 50 : 0;
const now = Date.now();
const dep = new Date(departureTime).getTime();
const arr = new Date(arrivalTime).getTime();
if (now <= dep) return 0;
if (now >= arr) return 95; // Past ETA but not confirmed landed
const totalDuration = arr - dep;
const elapsed = now - dep;
return Math.min(95, Math.max(5, Math.round((elapsed / totalDuration) * 100)));
}
function getTrackColor(flight: Flight): string {
const status = flight.status?.toLowerCase();
const delay = flight.arrivalDelay || flight.departureDelay || 0;
if (status === 'cancelled') return 'bg-red-500';
if (status === 'diverted' || status === 'incident') return 'bg-red-500';
if (status === 'landed') return delay > 15 ? 'bg-amber-500' : 'bg-emerald-500';
if (status === 'active') return delay > 15 ? 'bg-amber-500' : 'bg-purple-500';
if (delay > 30) return 'bg-orange-500';
if (delay > 15) return 'bg-amber-500';
return 'bg-blue-500';
}
function getTrackBgColor(flight: Flight): string {
const status = flight.status?.toLowerCase();
if (status === 'cancelled') return 'bg-red-500/20';
if (status === 'diverted' || status === 'incident') return 'bg-red-500/20';
if (status === 'landed') return 'bg-emerald-500/20';
if (status === 'active') return 'bg-purple-500/20';
return 'bg-muted';
}
function formatTime(isoString: string | null): string {
if (!isoString) return '--:--';
return new Date(isoString).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
}
export function FlightProgressBar({ flight, compact = false }: FlightProgressBarProps) {
const [progress, setProgress] = useState(() => calculateProgress(flight));
const status = flight.status?.toLowerCase();
const isActive = status === 'active';
const isLanded = status === 'landed' || !!flight.actualArrival;
const isCancelled = status === 'cancelled' || status === 'diverted' || status === 'incident';
// Update progress periodically for active flights
useEffect(() => {
if (!isActive) {
setProgress(calculateProgress(flight));
return;
}
setProgress(calculateProgress(flight));
const interval = setInterval(() => {
setProgress(calculateProgress(flight));
}, 30000); // Update every 30s for active flights
return () => clearInterval(interval);
}, [flight, isActive]);
const trackColor = useMemo(() => getTrackColor(flight), [flight]);
const trackBgColor = useMemo(() => getTrackBgColor(flight), [flight]);
const departureTime = flight.actualDeparture || flight.estimatedDeparture || flight.scheduledDeparture;
const arrivalTime = flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
const hasDelay = (flight.departureDelay || 0) > 0 || (flight.arrivalDelay || 0) > 0;
if (compact) {
return (
<div className="w-full">
<div className="flex items-center gap-2 text-xs">
<span className="font-bold text-foreground">{flight.departureAirport}</span>
<div className="flex-1 relative h-1.5 rounded-full overflow-hidden">
<div className={`absolute inset-0 ${trackBgColor} rounded-full`} />
<div
className={`absolute inset-y-0 left-0 ${trackColor} rounded-full transition-all duration-1000`}
style={{ width: `${progress}%` }}
/>
{isActive && (
<Plane
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 text-purple-500 transition-all duration-1000"
style={{ left: `calc(${progress}% - 6px)` }}
/>
)}
</div>
<span className="font-bold text-foreground">{flight.arrivalAirport}</span>
</div>
</div>
);
}
return (
<div className="w-full py-2">
{/* Airport codes and progress track */}
<div className="flex items-center gap-3">
{/* Departure airport */}
<div className="text-center min-w-[48px]">
<div className="text-lg font-bold text-foreground">{flight.departureAirport}</div>
</div>
{/* Progress track */}
<div className="flex-1 relative">
{/* Track background */}
<div className={`h-2 rounded-full ${trackBgColor} relative overflow-visible`}>
{/* Filled progress */}
<div
className={`absolute inset-y-0 left-0 rounded-full ${trackColor} transition-all duration-1000 ease-in-out`}
style={{ width: `${progress}%` }}
/>
{/* Departure dot */}
<div className={`absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full border-2 border-background ${progress > 0 ? trackColor : 'bg-muted-foreground/40'}`} />
{/* Arrival dot */}
<div className={`absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-3 h-3 rounded-full border-2 border-background ${progress >= 100 ? trackColor : 'bg-muted-foreground/40'}`} />
{/* Airplane icon */}
{!isCancelled && (
<div
className="absolute top-1/2 -translate-y-1/2 transition-all duration-1000 ease-in-out z-10"
style={{ left: `${Math.max(2, Math.min(98, progress))}%`, transform: `translateX(-50%) translateY(-50%)` }}
>
<div className={`${isActive ? 'animate-bounce-subtle' : ''}`}>
<Plane
className={`w-5 h-5 ${
isLanded ? 'text-emerald-500' :
isActive ? 'text-purple-500' :
'text-muted-foreground'
} drop-shadow-sm`}
style={{ transform: 'rotate(0deg)' }}
/>
</div>
</div>
)}
{/* Cancelled X */}
{isCancelled && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-red-500 font-bold text-sm">
&#x2715;
</div>
)}
</div>
</div>
{/* Arrival airport */}
<div className="text-center min-w-[48px]">
<div className="text-lg font-bold text-foreground">{flight.arrivalAirport}</div>
</div>
</div>
{/* Time and detail row */}
<div className="flex justify-between mt-2 text-xs">
{/* Departure details */}
<div className="text-left">
{departureTime && (
<div className="flex items-center gap-1">
{hasDelay && flight.scheduledDeparture && flight.scheduledDeparture !== departureTime ? (
<>
<span className="line-through text-muted-foreground">{formatTime(flight.scheduledDeparture)}</span>
<span className="text-amber-600 dark:text-amber-400 font-medium">{formatTime(departureTime)}</span>
</>
) : (
<span className="text-muted-foreground">{formatTime(departureTime)}</span>
)}
</div>
)}
{(flight.departureTerminal || flight.departureGate) && (
<div className="text-muted-foreground mt-0.5">
{flight.departureTerminal && <span>T{flight.departureTerminal}</span>}
{flight.departureTerminal && flight.departureGate && <span> </span>}
{flight.departureGate && <span>Gate {flight.departureGate}</span>}
</div>
)}
</div>
{/* Center: flight duration or status */}
<div className="text-center text-muted-foreground">
{isActive && flight.aircraftType && (
<span>{flight.aircraftType}</span>
)}
{isLanded && <span className="text-emerald-600 dark:text-emerald-400 font-medium">Landed</span>}
{isCancelled && <span className="text-red-600 dark:text-red-400 font-medium capitalize">{status}</span>}
</div>
{/* Arrival details */}
<div className="text-right">
{arrivalTime && (
<div className="flex items-center justify-end gap-1">
{hasDelay && flight.scheduledArrival && flight.scheduledArrival !== arrivalTime ? (
<>
<span className="line-through text-muted-foreground">{formatTime(flight.scheduledArrival)}</span>
<span className="text-amber-600 dark:text-amber-400 font-medium">{formatTime(arrivalTime)}</span>
</>
) : (
<span className="text-muted-foreground">
{isLanded ? '' : 'ETA '}{formatTime(arrivalTime)}
</span>
)}
</div>
)}
{(flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && (
<div className="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>}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Flight, FlightBudget } from '@/types';
import toast from 'react-hot-toast';
export function useFlights() {
return useQuery<Flight[]>({
queryKey: ['flights'],
queryFn: async () => {
const { data } = await api.get('/flights');
return data;
},
refetchInterval: 60000, // Refresh from DB every 60s (free, no API cost)
});
}
export function useFlightBudget() {
return useQuery<FlightBudget>({
queryKey: ['flights', 'budget'],
queryFn: async () => {
const { data } = await api.get('/flights/tracking/budget');
return data;
},
refetchInterval: 300000, // Every 5 min
});
}
export function useRefreshFlight() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (flightId: string) => {
const { data } = await api.post(`/flights/${flightId}/refresh`);
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['flights'] });
queryClient.invalidateQueries({ queryKey: ['flights', 'budget'] });
const status = data.status || 'unknown';
toast.success(`Flight updated: ${data.flightNumber} (${status})`);
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to refresh flight');
},
});
}
export function useRefreshActiveFlights() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const { data } = await api.post('/flights/refresh-active');
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['flights'] });
queryClient.invalidateQueries({ queryKey: ['flights', 'budget'] });
toast.success(`Refreshed ${data.refreshed} flights (${data.budgetRemaining} API calls remaining)`);
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to refresh flights');
},
});
}

View File

@@ -80,8 +80,17 @@ interface VIP {
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;
}>;
}
@@ -184,6 +193,14 @@ export function CommandCenter() {
},
});
const { data: flights } = useQuery<VIP['flights']>({
queryKey: ['flights'],
queryFn: async () => {
const { data } = await api.get('/flights');
return data;
},
});
// Compute awaiting confirmation BEFORE any conditional returns (for hooks)
const now = currentTime;
const awaitingConfirmation = (events || []).filter((event) => {
@@ -312,7 +329,10 @@ export function CommandCenter() {
return start <= fifteenMinutes;
});
// Upcoming arrivals (next 4 hours)
// Upcoming arrivals (next 4 hours) - use best available time (estimated > scheduled)
const getFlightArrivalTime = (flight: VIP['flights'][0]) =>
flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
const upcomingArrivals = vips
.filter((vip) => {
if (vip.expectedArrival) {
@@ -320,16 +340,17 @@ export function CommandCenter() {
return arrival > now && arrival <= fourHoursLater;
}
return vip.flights.some((flight) => {
if (flight.scheduledArrival) {
const arrival = new Date(flight.scheduledArrival);
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 || a.flights[0]?.scheduledArrival || '';
const bTime = b.expectedArrival || b.flights[0]?.scheduledArrival || '';
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();
});
@@ -414,6 +435,45 @@ export function CommandCenter() {
});
}
// 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: any) => {
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',
});
}
});
}
// Get time until event
function getTimeUntil(dateStr: string) {
const eventTime = new Date(dateStr);
@@ -827,9 +887,9 @@ export function CommandCenter() {
</div>
{/* Bottom Row: Resources */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<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">
<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" />
@@ -841,7 +901,7 @@ export function CommandCenter() {
ref={arrivalsScrollRef}
onWheel={handleUserInteraction}
onTouchMove={handleUserInteraction}
className="max-h-[140px] overflow-y-auto scrollbar-hide"
className="max-h-[180px] overflow-y-auto scrollbar-hide"
>
{upcomingArrivals.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
@@ -850,16 +910,79 @@ export function CommandCenter() {
) : (
<div className="divide-y divide-border">
{upcomingArrivals.map((vip) => {
const arrival = vip.expectedArrival || vip.flights[0]?.scheduledArrival;
const flight = vip.flights[0];
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;
// Color-code: green (on time / landed), amber (delayed), red (cancelled), purple (in flight)
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 = 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';
return (
<div key={vip.id} className="px-3 py-2">
<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">
<p className="font-medium text-foreground text-xs truncate">{vip.name}</p>
{flight && <p className="text-[10px] text-muted-foreground">{flight.flightNumber}</p>}
<div className="flex items-center gap-1.5">
<p className="font-medium text-foreground text-xs truncate">{vip.name}</p>
{delay > 15 && (
<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">
{flight && (
<>
<span className="font-medium">{flight.flightNumber}</span>
<span>{flight.departureAirport} {flight.arrivalAirport}</span>
</>
)}
</div>
{flight && (flight.arrivalTerminal || flight.arrivalGate || flight.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>}
</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">
{new Date(arrival).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
</p>
)}
</div>
<p className="text-xs font-bold text-purple-600 dark:text-purple-400">{getTimeUntil(arrival!)}</p>
</div>
</div>
);

View File

@@ -1,27 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Users, Car, Plane, Clock } from 'lucide-react';
import { VIP, Driver, ScheduleEvent } from '@/types';
import { Users, Car, Plane, Clock, AlertTriangle } from 'lucide-react';
import { VIP, Driver, ScheduleEvent, Flight } from '@/types';
import { formatDateTime } from '@/lib/utils';
interface Flight {
id: string;
vipId: string;
vip?: {
name: string;
organization: string | null;
};
flightNumber: string;
flightDate: string;
segment: number;
departureAirport: string;
arrivalAirport: string;
scheduledDeparture: string | null;
scheduledArrival: string | null;
actualDeparture: string | null;
actualArrival: string | null;
status: string | null;
}
import { FlightProgressBar } from '@/components/FlightProgressBar';
export function Dashboard() {
const { data: vips } = useQuery<VIP[]>({
@@ -195,55 +177,121 @@ export function Dashboard() {
)}
</div>
{/* Upcoming Flights */}
{/* Flight Status Overview */}
<div className="bg-card shadow-medium rounded-lg p-6 mb-8 border border-border">
<h2 className="text-lg font-medium text-foreground mb-4">
Upcoming Flights
<h2 className="text-lg font-medium text-foreground mb-4 flex items-center gap-2">
<Plane className="h-5 w-5" />
Flight Status
</h2>
{/* Status summary */}
{flights && flights.length > 0 && (
<div className="flex flex-wrap gap-4 mb-4 pb-4 border-b border-border">
{(() => {
const inFlight = flights.filter(f => f.status?.toLowerCase() === 'active').length;
const delayed = flights.filter(f => (f.arrivalDelay || f.departureDelay || 0) > 15).length;
const cancelled = flights.filter(f => f.status?.toLowerCase() === 'cancelled').length;
const landed = flights.filter(f => f.status?.toLowerCase() === 'landed' || f.actualArrival).length;
const scheduled = flights.filter(f => !['active', 'landed', 'cancelled', 'diverted', 'incident'].includes(f.status?.toLowerCase() || '') && !f.actualArrival).length;
return (
<>
{inFlight > 0 && (
<span className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
<span className="font-medium">{inFlight}</span>
<span className="text-muted-foreground">in flight</span>
</span>
)}
{delayed > 0 && (
<span className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-amber-500" />
<span className="font-medium text-amber-600 dark:text-amber-400">{delayed}</span>
<span className="text-muted-foreground">delayed</span>
</span>
)}
{cancelled > 0 && (
<span className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-red-500" />
<span className="font-medium text-red-600 dark:text-red-400">{cancelled}</span>
<span className="text-muted-foreground">cancelled</span>
</span>
)}
<span className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-blue-500" />
<span className="font-medium">{scheduled}</span>
<span className="text-muted-foreground">scheduled</span>
</span>
<span className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
<span className="font-medium">{landed}</span>
<span className="text-muted-foreground">landed</span>
</span>
</>
);
})()}
</div>
)}
{/* Arriving soon flights */}
{upcomingFlights.length > 0 ? (
<div className="space-y-4">
{upcomingFlights.map((flight) => (
<div
key={flight.id}
className="border-l-4 border-indigo-500 pl-4 py-2 hover:bg-accent transition-colors rounded-r"
>
<div className="flex justify-between items-start">
<div>
<h3 className="text-sm font-medium text-foreground flex items-center gap-2">
<Plane className="h-4 w-4" />
{flight.flightNumber}
</h3>
<p className="text-sm text-muted-foreground">
{flight.vip?.name} {flight.departureAirport} {flight.arrivalAirport}
</p>
{flight.scheduledDeparture && (
<p className="text-xs text-muted-foreground mt-1">
Departs: {formatDateTime(flight.scheduledDeparture)}
</p>
)}
</div>
<div className="text-right">
<span className="text-xs text-muted-foreground block">
{new Date(flight.flightDate).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
<span className={`inline-block mt-1 px-2 py-1 text-xs rounded-full ${
flight.status?.toLowerCase() === 'scheduled' ? 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300' :
flight.status?.toLowerCase() === 'boarding' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950 dark:text-yellow-300' :
flight.status?.toLowerCase() === 'departed' ? 'bg-purple-100 text-purple-800 dark:bg-purple-950 dark:text-purple-300' :
flight.status?.toLowerCase() === 'landed' ? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300' :
flight.status?.toLowerCase() === 'delayed' ? 'bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300' :
'bg-muted text-muted-foreground'
}`}>
{flight.status || 'Unknown'}
</span>
<div className="space-y-3">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Arriving Soon
</h3>
{upcomingFlights.map((flight) => {
const delay = Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0);
const eta = flight.estimatedArrival || flight.scheduledArrival;
const borderColor = delay > 30 ? 'border-amber-500' :
flight.status?.toLowerCase() === 'active' ? 'border-purple-500' :
flight.status?.toLowerCase() === 'cancelled' ? 'border-red-500' :
'border-indigo-500';
return (
<div
key={flight.id}
className={`border-l-4 ${borderColor} pl-4 py-2 hover:bg-accent transition-colors rounded-r`}
>
<div className="flex justify-between items-start mb-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{flight.flightNumber}</span>
{flight.airlineName && (
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
)}
<span className="text-muted-foreground/50">|</span>
<span className="text-sm text-foreground/80">{flight.vip?.name}</span>
</div>
<div className="flex items-center gap-2">
{delay > 15 && (
<span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
<AlertTriangle className="w-3 h-3" />
+{delay}min
</span>
)}
<span className={`px-2 py-0.5 text-xs rounded-full ${
flight.status?.toLowerCase() === 'active' ? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' :
flight.status?.toLowerCase() === 'landed' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' :
flight.status?.toLowerCase() === 'cancelled' ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' :
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
}`}>
{flight.status || 'scheduled'}
</span>
</div>
</div>
<FlightProgressBar flight={flight} compact />
{/* Terminal/gate info for arriving flights */}
{(flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && (
<div className="flex gap-3 mt-1 text-xs text-muted-foreground">
{flight.arrivalTerminal && <span>Terminal {flight.arrivalTerminal}</span>}
{flight.arrivalGate && <span>Gate {flight.arrivalGate}</span>}
{flight.arrivalBaggage && <span>Baggage {flight.arrivalBaggage}</span>}
</div>
)}
</div>
</div>
))}
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">

View File

@@ -1,32 +1,144 @@
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { Plus, Edit, Trash2, Plane, Search, X, Filter, ArrowUpDown } from 'lucide-react';
import {
Plus,
Search,
X,
Filter,
RefreshCw,
Plane,
AlertTriangle,
Clock,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { FlightForm, FlightFormData } from '@/components/FlightForm';
import { FlightCard } from '@/components/FlightCard';
import { TableSkeleton } from '@/components/Skeleton';
import { ErrorMessage } from '@/components/ErrorMessage';
import { FilterModal } from '@/components/FilterModal';
import { FilterChip } from '@/components/FilterChip';
import { useDebounce } from '@/hooks/useDebounce';
import { useFlights, useFlightBudget, useRefreshActiveFlights } from '@/hooks/useFlights';
import { Flight } from '@/types';
interface Flight {
id: string;
vipId: string;
vip?: {
name: string;
organization: string | null;
};
flightNumber: string;
flightDate: string;
segment: number;
departureAirport: string;
arrivalAirport: string;
scheduledDeparture: string | null;
scheduledArrival: string | null;
actualDeparture: string | null;
actualArrival: string | null;
status: string | null;
type FlightGroup = {
key: string;
label: string;
icon: typeof AlertTriangle;
flights: Flight[];
color: string;
defaultCollapsed?: boolean;
};
function groupFlights(flights: Flight[]): FlightGroup[] {
const now = new Date();
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
const fourHoursFromNow = new Date(now.getTime() + 4 * 60 * 60 * 1000);
const groups: FlightGroup[] = [
{ key: 'alerts', label: 'Alerts', icon: AlertTriangle, flights: [], color: 'text-red-500' },
{ key: 'arriving', label: 'Arriving Soon', icon: Plane, flights: [], color: 'text-purple-500' },
{ key: 'active', label: 'In Flight', icon: Plane, flights: [], color: 'text-purple-500' },
{ key: 'departing', label: 'Departing Soon', icon: Clock, flights: [], color: 'text-blue-500' },
{ key: 'scheduled', label: 'Scheduled', icon: Clock, flights: [], color: 'text-muted-foreground' },
{ key: 'completed', label: 'Completed', icon: Plane, flights: [], color: 'text-emerald-500', defaultCollapsed: true },
];
for (const flight of flights) {
const status = flight.status?.toLowerCase();
const eta = flight.estimatedArrival || flight.scheduledArrival;
const departure = flight.estimatedDeparture || flight.scheduledDeparture;
// Alerts: cancelled, diverted, incident
if (status === 'cancelled' || status === 'diverted' || status === 'incident') {
groups[0].flights.push(flight);
continue;
}
// Completed: landed
if (status === 'landed' || flight.actualArrival) {
groups[5].flights.push(flight);
continue;
}
// Arriving soon: active flight landing within 2h
if (status === 'active' && eta && new Date(eta) <= twoHoursFromNow) {
groups[1].flights.push(flight);
continue;
}
// In flight: active
if (status === 'active') {
groups[2].flights.push(flight);
continue;
}
// Departing soon: departure within 4h
if (departure && new Date(departure) <= fourHoursFromNow && new Date(departure) >= now) {
groups[3].flights.push(flight);
continue;
}
// Everything else is scheduled
groups[4].flights.push(flight);
}
// Sort within groups
groups[0].flights.sort((a, b) => (b.arrivalDelay || 0) - (a.arrivalDelay || 0)); // Worst first
groups[1].flights.sort((a, b) => {
const etaA = a.estimatedArrival || a.scheduledArrival || '';
const etaB = b.estimatedArrival || b.scheduledArrival || '';
return etaA.localeCompare(etaB);
});
groups[2].flights.sort((a, b) => {
const etaA = a.estimatedArrival || a.scheduledArrival || '';
const etaB = b.estimatedArrival || b.scheduledArrival || '';
return etaA.localeCompare(etaB);
});
groups[3].flights.sort((a, b) => {
const depA = a.estimatedDeparture || a.scheduledDeparture || '';
const depB = b.estimatedDeparture || b.scheduledDeparture || '';
return depA.localeCompare(depB);
});
groups[4].flights.sort((a, b) => {
const depA = a.scheduledDeparture || a.flightDate;
const depB = b.scheduledDeparture || b.flightDate;
return depA.localeCompare(depB);
});
groups[5].flights.sort((a, b) => {
const arrA = a.actualArrival || a.scheduledArrival || '';
const arrB = b.actualArrival || b.scheduledArrival || '';
return arrB.localeCompare(arrA); // Most recent first
});
return groups;
}
function BudgetIndicator() {
const { data: budget } = useFlightBudget();
if (!budget) return null;
const percent = Math.round((budget.used / budget.limit) * 100);
const barColor = percent > 80 ? 'bg-red-500' : percent > 50 ? 'bg-amber-500' : 'bg-emerald-500';
const textColor = percent > 80 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground';
return (
<div className="flex items-center gap-3 px-3 py-2 bg-muted/50 rounded-lg border border-border">
<div className="text-xs">
<span className={`font-medium ${textColor}`}>{budget.remaining}</span>
<span className="text-muted-foreground">/{budget.limit} API calls</span>
</div>
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
<div
className={`h-full ${barColor} rounded-full transition-all`}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
}
export function FlightList() {
@@ -34,26 +146,17 @@ export function FlightList() {
const [showForm, setShowForm] = useState(false);
const [editingFlight, setEditingFlight] = useState<Flight | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set(['completed']));
// Search and filter state
const [searchTerm, setSearchTerm] = useState('');
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
const [filterModalOpen, setFilterModalOpen] = useState(false);
// Sort state
const [sortColumn, setSortColumn] = useState<'flightNumber' | 'departureAirport' | 'arrivalAirport' | 'status'>('flightNumber');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
// Debounce search term
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const { data: flights, isLoading, isError, error, refetch } = useQuery<Flight[]>({
queryKey: ['flights'],
queryFn: async () => {
const { data } = await api.get('/flights');
return data;
},
});
const { data: flights, isLoading, isError, error, refetch } = useFlights();
const refreshActiveMutation = useRefreshActiveFlights();
const createMutation = useMutation({
mutationFn: async (data: FlightFormData) => {
@@ -66,7 +169,6 @@ export function FlightList() {
toast.success('Flight created successfully');
},
onError: (error: any) => {
console.error('[FLIGHT] Failed to create:', error);
setIsSubmitting(false);
toast.error(error.response?.data?.message || 'Failed to create flight');
},
@@ -84,7 +186,6 @@ export function FlightList() {
toast.success('Flight updated successfully');
},
onError: (error: any) => {
console.error('[FLIGHT] Failed to update:', error);
setIsSubmitting(false);
toast.error(error.response?.data?.message || 'Failed to update flight');
},
@@ -99,52 +200,46 @@ export function FlightList() {
toast.success('Flight deleted successfully');
},
onError: (error: any) => {
console.error('[FLIGHT] Failed to delete:', error);
toast.error(error.response?.data?.message || 'Failed to delete flight');
},
});
// Filter and sort flights
// Filter flights
const filteredFlights = useMemo(() => {
if (!flights) return [];
// First filter
let filtered = flights.filter((flight) => {
// Search by flight number, VIP name, or route (using debounced term)
return flights.filter((flight) => {
const matchesSearch = debouncedSearchTerm === '' ||
flight.flightNumber.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
flight.vip?.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
flight.vip?.name?.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
flight.departureAirport.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
flight.arrivalAirport.toLowerCase().includes(debouncedSearchTerm.toLowerCase());
flight.arrivalAirport.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
flight.airlineName?.toLowerCase().includes(debouncedSearchTerm.toLowerCase());
// Filter by status
const matchesStatus = selectedStatuses.length === 0 ||
(flight.status && selectedStatuses.includes(flight.status.toLowerCase()));
return matchesSearch && matchesStatus;
});
}, [flights, debouncedSearchTerm, selectedStatuses]);
// Then sort
filtered.sort((a, b) => {
let aValue = a[sortColumn] || '';
let bValue = b[sortColumn] || '';
const flightGroups = useMemo(() => groupFlights(filteredFlights), [filteredFlights]);
if (typeof aValue === 'string') aValue = aValue.toLowerCase();
if (typeof bValue === 'string') bValue = bValue.toLowerCase();
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
return 0;
const toggleGroup = (key: string) => {
setCollapsedGroups(prev => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
return filtered;
}, [flights, debouncedSearchTerm, selectedStatuses, sortColumn, sortDirection]);
};
const handleStatusToggle = (status: string) => {
setSelectedStatuses((prev) =>
prev.includes(status)
? prev.filter((s) => s !== status)
: [...prev, status]
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
);
};
@@ -153,15 +248,6 @@ export function FlightList() {
setSelectedStatuses([]);
};
const handleSort = (column: typeof sortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
const handleRemoveStatusFilter = (status: string) => {
setSelectedStatuses((prev) => prev.filter((s) => s !== status));
};
@@ -169,12 +255,11 @@ export function FlightList() {
const getFilterLabel = (value: string) => {
const labels: Record<string, string> = {
'scheduled': 'Scheduled',
'boarding': 'Boarding',
'departed': 'Departed',
'en-route': 'En Route',
'active': 'Active / In Flight',
'landed': 'Landed',
'delayed': 'Delayed',
'cancelled': 'Cancelled',
'diverted': 'Diverted',
'incident': 'Incident',
};
return labels[value] || value;
};
@@ -189,9 +274,9 @@ export function FlightList() {
setShowForm(true);
};
const handleDelete = (id: string, flightNumber: string) => {
if (confirm(`Delete flight ${flightNumber}? This action cannot be undone.`)) {
deleteMutation.mutate(id);
const handleDelete = (flight: Flight) => {
if (confirm(`Delete flight ${flight.flightNumber}? This action cannot be undone.`)) {
deleteMutation.mutate(flight.id);
}
};
@@ -210,48 +295,22 @@ export function FlightList() {
setIsSubmitting(false);
};
const formatTime = (isoString: string | null) => {
if (!isoString) return '-';
return new Date(isoString).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusColor = (status: string | null) => {
switch (status?.toLowerCase()) {
case 'scheduled':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
case 'boarding':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'departed':
case 'en-route':
return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'landed':
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
case 'delayed':
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'cancelled':
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
default:
return 'bg-muted text-muted-foreground';
}
};
// Stats
const stats = useMemo(() => {
if (!flights) return { active: 0, delayed: 0, onTime: 0, landed: 0 };
return {
active: flights.filter(f => f.status?.toLowerCase() === 'active').length,
delayed: flights.filter(f => (f.arrivalDelay || f.departureDelay || 0) > 15).length,
onTime: flights.filter(f => f.status === 'scheduled' && !(f.departureDelay && f.departureDelay > 15)).length,
landed: flights.filter(f => f.status?.toLowerCase() === 'landed' || f.actualArrival).length,
};
}, [flights]);
if (isLoading) {
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-foreground">Flights</h1>
<button
disabled
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary/50 cursor-not-allowed"
>
<Plus className="h-4 w-4 mr-2" />
Add Flight
</button>
<h1 className="text-3xl font-bold text-foreground">Flight Tracking</h1>
</div>
<TableSkeleton rows={8} />
</div>
@@ -270,35 +329,67 @@ export function FlightList() {
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-foreground">Flights</h1>
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90 transition-colors"
>
<Plus className="h-4 w-4 mr-2" />
Add Flight
</button>
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Flight Tracking</h1>
{flights && flights.length > 0 && (
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
{stats.active > 0 && (
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
{stats.active} in flight
</span>
)}
{stats.delayed > 0 && (
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-amber-500" />
{stats.delayed} delayed
</span>
)}
<span>{stats.onTime} scheduled</span>
<span>{stats.landed} landed</span>
</div>
)}
</div>
<div className="flex items-center gap-3">
<BudgetIndicator />
{flights && flights.length > 0 && (
<button
onClick={() => refreshActiveMutation.mutate()}
disabled={refreshActiveMutation.isPending}
className="inline-flex items-center px-3 py-2 border border-border rounded-md text-sm text-foreground bg-card hover:bg-accent transition-colors disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 mr-2 ${refreshActiveMutation.isPending ? 'animate-spin' : ''}`} />
Refresh Active
</button>
)}
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90 transition-colors"
>
<Plus className="h-4 w-4 mr-2" />
Add Flight
</button>
</div>
</div>
{/* Search and Filter Section */}
{/* Search and Filter */}
{flights && flights.length > 0 && (
<div className="bg-card shadow-soft border border-border rounded-lg p-4 mb-6">
<div className="flex gap-3">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground pointer-events-none" />
<input
type="text"
placeholder="Search by flight number, VIP, or route..."
placeholder="Search by flight number, VIP, airline, or airport..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-input rounded-md focus:ring-primary focus:border-primary text-base bg-background transition-colors"
style={{ minHeight: '44px' }}
/>
</div>
{/* Filter Button */}
<button
onClick={() => setFilterModalOpen(true)}
className="inline-flex items-center px-4 py-2 border border-border rounded-md text-foreground bg-card hover:bg-accent font-medium transition-colors"
@@ -314,7 +405,6 @@ export function FlightList() {
</button>
</div>
{/* Active Filter Chips */}
{selectedStatuses.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-border">
<span className="text-sm text-muted-foreground py-1.5">Active filters:</span>
@@ -328,11 +418,9 @@ export function FlightList() {
</div>
)}
{/* Results count */}
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border">
<div className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{filteredFlights.length}</span> of <span className="font-medium text-foreground">{flights.length}</span> flights
{debouncedSearchTerm !== searchTerm && <span className="ml-2 text-muted-foreground/70">(searching...)</span>}
Showing <span className="font-medium text-foreground">{filteredFlights.length}</span> of <span className="font-medium text-foreground">{flights?.length || 0}</span> flights
</div>
{(searchTerm || selectedStatuses.length > 0) && (
<button
@@ -347,118 +435,52 @@ export function FlightList() {
</div>
)}
{/* Flight Groups */}
{flights && flights.length > 0 ? (
<div className="bg-card shadow-medium border border-border rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/30">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
onClick={() => handleSort('flightNumber')}
<div className="space-y-6">
{flightGroups.map((group) => {
if (group.flights.length === 0) return null;
const isCollapsed = collapsedGroups.has(group.key);
const Icon = group.icon;
return (
<div key={group.key}>
{/* Group header */}
<button
onClick={() => toggleGroup(group.key)}
className="flex items-center gap-2 mb-3 w-full text-left group"
>
<div className="flex items-center gap-2">
Flight
<ArrowUpDown className="h-4 w-4" />
{sortColumn === 'flightNumber' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
{isCollapsed ? (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
<Icon className={`w-4 h-4 ${group.color}`} />
<span className={`text-sm font-semibold uppercase tracking-wider ${group.color}`}>
{group.label}
</span>
<span className="text-xs text-muted-foreground font-normal">
({group.flights.length})
</span>
<div className="flex-1 border-t border-border/50 ml-2" />
</button>
{/* Flight cards */}
{!isCollapsed && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{group.flights.map((flight) => (
<FlightCard
key={flight.id}
flight={flight}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
VIP
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
onClick={() => handleSort('departureAirport')}
>
<div className="flex items-center gap-2">
Route
<ArrowUpDown className="h-4 w-4" />
{sortColumn === 'departureAirport' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
Scheduled
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
onClick={() => handleSort('status')}
>
<div className="flex items-center gap-2">
Status
<ArrowUpDown className="h-4 w-4" />
{sortColumn === 'status' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-card divide-y divide-border">
{filteredFlights.map((flight) => (
<tr key={flight.id} className="hover:bg-muted/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Plane className="h-4 w-4 text-muted-foreground mr-2" />
<div>
<div className="text-sm font-medium text-foreground">
{flight.flightNumber}
</div>
<div className="text-xs text-muted-foreground">
Segment {flight.segment}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="font-medium text-foreground">{flight.vip?.name}</div>
{flight.vip?.organization && (
<div className="text-xs text-muted-foreground">{flight.vip.organization}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
<div className="flex items-center">
<span className="font-medium text-foreground">{flight.departureAirport}</span>
<span className="mx-2"></span>
<span className="font-medium text-foreground">{flight.arrivalAirport}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
<div className="text-xs">
<div>Dep: {formatTime(flight.scheduledDeparture)}</div>
<div>Arr: {formatTime(flight.scheduledArrival)}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs rounded-full ${getStatusColor(
flight.status
)}`}
>
{flight.status || 'Unknown'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex gap-2">
<button
onClick={() => handleEdit(flight)}
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80 transition-colors"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</button>
<button
onClick={() => handleDelete(flight.id, flight.flightNumber)}
className="inline-flex items-center px-3 py-1 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 transition-colors"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
})}
</div>
) : (
<div className="bg-card shadow-soft border border-border rounded-lg p-12 text-center">
@@ -483,7 +505,6 @@ export function FlightList() {
/>
)}
{/* Filter Modal */}
<FilterModal
isOpen={filterModalOpen}
onClose={() => setFilterModalOpen(false)}
@@ -492,12 +513,11 @@ export function FlightList() {
label: 'Flight Status',
options: [
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'boarding', label: 'Boarding' },
{ value: 'departed', label: 'Departed' },
{ value: 'en-route', label: 'En Route' },
{ value: 'active', label: 'Active / In Flight' },
{ value: 'landed', label: 'Landed' },
{ value: 'delayed', label: 'Delayed' },
{ value: 'cancelled', label: 'Cancelled' },
{ value: 'diverted', label: 'Diverted' },
{ value: 'incident', label: 'Incident' },
],
selectedValues: selectedStatuses,
onToggle: handleStatusToggle,

View File

@@ -23,6 +23,7 @@ export interface User {
export enum Department {
OFFICE_OF_DEVELOPMENT = 'OFFICE_OF_DEVELOPMENT',
ADMIN = 'ADMIN',
OTHER = 'OTHER',
}
export enum ArrivalMode {
@@ -144,6 +145,9 @@ export interface ScheduleEvent {
}
// Flight types
export type FlightStatus = 'scheduled' | 'active' | 'landed' | 'cancelled' | 'incident' | 'diverted';
export type TrackingPhase = 'FAR_OUT' | 'PRE_DEPARTURE' | 'DEPARTURE_WINDOW' | 'ACTIVE' | 'ARRIVAL_WINDOW' | 'LANDED' | 'TERMINAL';
export interface Flight {
id: string;
vipId: string;
@@ -158,6 +162,51 @@ export interface Flight {
actualDeparture: string | null;
actualArrival: string | null;
status: string | null;
// Airline info
airlineName: string | null;
airlineIata: string | null;
// Terminal/gate/baggage
departureTerminal: string | null;
departureGate: string | null;
arrivalTerminal: string | null;
arrivalGate: string | null;
arrivalBaggage: string | null;
// Estimated times (from API)
estimatedDeparture: string | null;
estimatedArrival: string | null;
// Delays in minutes
departureDelay: number | null;
arrivalDelay: number | null;
// Aircraft
aircraftType: string | null;
// Live position
liveLatitude: number | null;
liveLongitude: number | null;
liveAltitude: number | null;
liveSpeed: number | null;
liveDirection: number | null;
liveIsGround: boolean | null;
liveUpdatedAt: string | null;
// Tracking metadata
lastPolledAt: string | null;
pollCount: number;
trackingPhase: TrackingPhase;
autoTrackEnabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface FlightBudget {
used: number;
limit: number;
remaining: number;
month: string;
}

View File

@@ -64,6 +64,15 @@ export default {
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
animation: {
'bounce-subtle': 'bounce-subtle 2s ease-in-out infinite',
},
keyframes: {
'bounce-subtle': {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-2px)' },
},
},
},
},
plugins: [],