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:
210
frontend/src/components/FlightCard.tsx
Normal file
210
frontend/src/components/FlightCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
frontend/src/components/FlightProgressBar.tsx
Normal file
244
frontend/src/components/FlightProgressBar.tsx
Normal 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">
|
||||
✕
|
||||
</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>
|
||||
);
|
||||
}
|
||||
65
frontend/src/hooks/useFlights.ts
Normal file
65
frontend/src/hooks/useFlights.ts
Normal 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');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user