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,47 @@
-- AlterTable
ALTER TABLE "flights" ADD COLUMN "aircraftType" TEXT,
ADD COLUMN "airlineIata" TEXT,
ADD COLUMN "airlineName" TEXT,
ADD COLUMN "arrivalBaggage" TEXT,
ADD COLUMN "arrivalDelay" INTEGER,
ADD COLUMN "arrivalGate" TEXT,
ADD COLUMN "arrivalTerminal" TEXT,
ADD COLUMN "autoTrackEnabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "departureDelay" INTEGER,
ADD COLUMN "departureGate" TEXT,
ADD COLUMN "departureTerminal" TEXT,
ADD COLUMN "estimatedArrival" TIMESTAMP(3),
ADD COLUMN "estimatedDeparture" TIMESTAMP(3),
ADD COLUMN "lastApiResponse" JSONB,
ADD COLUMN "lastPolledAt" TIMESTAMP(3),
ADD COLUMN "liveAltitude" DOUBLE PRECISION,
ADD COLUMN "liveDirection" DOUBLE PRECISION,
ADD COLUMN "liveIsGround" BOOLEAN,
ADD COLUMN "liveLatitude" DOUBLE PRECISION,
ADD COLUMN "liveLongitude" DOUBLE PRECISION,
ADD COLUMN "liveSpeed" DOUBLE PRECISION,
ADD COLUMN "liveUpdatedAt" TIMESTAMP(3),
ADD COLUMN "pollCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "trackingPhase" TEXT NOT NULL DEFAULT 'FAR_OUT';
-- CreateTable
CREATE TABLE "flight_api_budget" (
"id" TEXT NOT NULL,
"monthYear" TEXT NOT NULL,
"requestsUsed" INTEGER NOT NULL DEFAULT 0,
"requestLimit" INTEGER NOT NULL DEFAULT 100,
"lastRequestAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "flight_api_budget_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "flight_api_budget_monthYear_key" ON "flight_api_budget"("monthYear");
-- CreateIndex
CREATE INDEX "flights_trackingPhase_idx" ON "flights"("trackingPhase");
-- CreateIndex
CREATE INDEX "flights_scheduledDeparture_idx" ON "flights"("scheduledDeparture");

View File

@@ -73,6 +73,7 @@ model VIP {
enum Department { enum Department {
OFFICE_OF_DEVELOPMENT OFFICE_OF_DEVELOPMENT
ADMIN ADMIN
OTHER
} }
enum ArrivalMode { enum ArrivalMode {
@@ -97,13 +98,70 @@ model Flight {
scheduledArrival DateTime? scheduledArrival DateTime?
actualDeparture DateTime? actualDeparture DateTime?
actualArrival DateTime? actualArrival DateTime?
status String? // scheduled, delayed, landed, etc. status String? // scheduled, active, landed, cancelled, incident, diverted
// Airline info (from AviationStack API)
airlineName String?
airlineIata String? // "AA", "UA", "DL"
// Terminal/gate/baggage (critical for driver dispatch)
departureTerminal String?
departureGate String?
arrivalTerminal String?
arrivalGate String?
arrivalBaggage String?
// Estimated times (updated by API, distinct from scheduled)
estimatedDeparture DateTime?
estimatedArrival DateTime?
// Delay in minutes (from API)
departureDelay Int?
arrivalDelay Int?
// Aircraft info
aircraftType String? // IATA type code e.g. "A321", "B738"
// Live position data (may not be available on free tier)
liveLatitude Float?
liveLongitude Float?
liveAltitude Float?
liveSpeed Float? // horizontal speed
liveDirection Float? // heading in degrees
liveIsGround Boolean?
liveUpdatedAt DateTime?
// Polling metadata
lastPolledAt DateTime?
pollCount Int @default(0)
trackingPhase String @default("FAR_OUT") // FAR_OUT, PRE_DEPARTURE, DEPARTURE_WINDOW, ACTIVE, ARRIVAL_WINDOW, LANDED, TERMINAL
autoTrackEnabled Boolean @default(true)
lastApiResponse Json? // Full AviationStack response for debugging
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@map("flights") @@map("flights")
@@index([vipId]) @@index([vipId])
@@index([flightNumber, flightDate]) @@index([flightNumber, flightDate])
@@index([trackingPhase])
@@index([scheduledDeparture])
}
// ============================================
// Flight API Budget Tracking
// ============================================
model FlightApiBudget {
id String @id @default(uuid())
monthYear String @unique // "2026-02" format
requestsUsed Int @default(0)
requestLimit Int @default(100)
lastRequestAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("flight_api_budget")
} }
// ============================================ // ============================================

View File

@@ -0,0 +1,465 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { firstValueFrom } from 'rxjs';
import { Flight } from '@prisma/client';
// Tracking phases - determines polling priority
const PHASE = {
FAR_OUT: 'FAR_OUT', // >24h before departure - no auto-poll
PRE_DEPARTURE: 'PRE_DEPARTURE', // 6-24h before departure
DEPARTURE_WINDOW: 'DEPARTURE_WINDOW', // 0-6h before departure
ACTIVE: 'ACTIVE', // In flight
ARRIVAL_WINDOW: 'ARRIVAL_WINDOW', // Within 1h of ETA
LANDED: 'LANDED', // Flight has landed
TERMINAL: 'TERMINAL', // Cancelled/diverted/incident - terminal state
} as const;
// Priority scores for each phase (higher = more urgent)
const PHASE_PRIORITY: Record<string, number> = {
[PHASE.ARRIVAL_WINDOW]: 100,
[PHASE.ACTIVE]: 60,
[PHASE.DEPARTURE_WINDOW]: 40,
[PHASE.PRE_DEPARTURE]: 10,
[PHASE.FAR_OUT]: 0,
[PHASE.LANDED]: 0,
[PHASE.TERMINAL]: 0,
};
// Minimum minutes between polls per phase (to prevent wasting budget)
const MIN_POLL_INTERVAL: Record<string, number> = {
[PHASE.ARRIVAL_WINDOW]: 20,
[PHASE.ACTIVE]: 45,
[PHASE.DEPARTURE_WINDOW]: 60,
[PHASE.PRE_DEPARTURE]: 180,
[PHASE.FAR_OUT]: Infinity,
[PHASE.LANDED]: Infinity,
[PHASE.TERMINAL]: Infinity,
};
// Map AviationStack status to our tracking phase
const STATUS_TO_TERMINAL: string[] = ['cancelled', 'incident', 'diverted'];
@Injectable()
export class FlightTrackingService {
private readonly logger = new Logger(FlightTrackingService.name);
private readonly apiKey: string;
private readonly baseUrl = 'http://api.aviationstack.com/v1';
constructor(
private prisma: PrismaService,
private httpService: HttpService,
private configService: ConfigService,
) {
this.apiKey = this.configService.get('AVIATIONSTACK_API_KEY') || '';
if (this.apiKey) {
this.logger.log('AviationStack API key configured - flight tracking enabled');
} else {
this.logger.warn('AviationStack API key not configured - flight tracking disabled');
}
}
// ============================================
// Cron Job: Smart Flight Polling (every 5 min)
// ============================================
@Cron('*/5 * * * *')
async pollFlightsCron(): Promise<void> {
if (!this.apiKey) return;
try {
// 1. Check budget
const budget = await this.getOrCreateBudget();
const budgetPercent = (budget.requestsUsed / budget.requestLimit) * 100;
if (budgetPercent >= 95) {
this.logger.debug('Flight API budget exhausted (>=95%) - skipping auto-poll');
return;
}
// 2. Get all trackable flights (not in terminal states)
const flights = await this.prisma.flight.findMany({
where: {
autoTrackEnabled: true,
trackingPhase: {
notIn: [PHASE.LANDED, PHASE.TERMINAL, PHASE.FAR_OUT],
},
},
include: { vip: true },
});
if (flights.length === 0) return;
// 3. Recalculate phases and score each flight
const candidates: { flight: Flight; phase: string; priority: number }[] = [];
for (const flight of flights) {
const phase = this.calculateTrackingPhase(flight);
// Update phase in DB if changed
if (phase !== flight.trackingPhase) {
await this.prisma.flight.update({
where: { id: flight.id },
data: { trackingPhase: phase },
});
}
// Skip phases that shouldn't be polled
if (PHASE_PRIORITY[phase] === 0) continue;
// Budget conservation: if >80% used, only poll high-priority
if (budgetPercent > 80 && PHASE_PRIORITY[phase] < 60) continue;
// Check minimum polling interval
if (!this.shouldPoll(flight, phase)) continue;
candidates.push({
flight,
phase,
priority: PHASE_PRIORITY[phase],
});
}
if (candidates.length === 0) return;
// 4. Pick the highest-priority candidate
candidates.sort((a, b) => b.priority - a.priority);
const best = candidates[0];
this.logger.log(
`Auto-polling flight ${best.flight.flightNumber} (phase: ${best.phase}, priority: ${best.priority}, budget: ${budget.requestsUsed}/${budget.requestLimit})`,
);
// 5. Poll it
await this.callAviationStackAndUpdate(best.flight);
} catch (error) {
this.logger.error(`Flight polling cron error: ${error.message}`, error.stack);
}
}
// ============================================
// Manual Refresh (coordinator-triggered)
// ============================================
async refreshFlight(flightId: string) {
const flight = await this.prisma.flight.findUnique({
where: { id: flightId },
include: { vip: true },
});
if (!flight) {
throw new NotFoundException(`Flight ${flightId} not found`);
}
if (!this.apiKey) {
return {
message: 'Flight tracking API not configured',
flight,
};
}
const updated = await this.callAviationStackAndUpdate(flight);
return updated;
}
async refreshActiveFlights() {
if (!this.apiKey) {
return { refreshed: 0, skipped: 0, budgetRemaining: 0, message: 'API key not configured' };
}
const budget = await this.getOrCreateBudget();
const remaining = budget.requestLimit - budget.requestsUsed;
// Get active flights that would benefit from refresh
const flights = await this.prisma.flight.findMany({
where: {
trackingPhase: {
in: [PHASE.ACTIVE, PHASE.ARRIVAL_WINDOW, PHASE.DEPARTURE_WINDOW],
},
},
include: { vip: true },
orderBy: { scheduledDeparture: 'asc' },
});
let refreshed = 0;
let skipped = 0;
for (const flight of flights) {
if (refreshed >= remaining) {
skipped += flights.length - refreshed - skipped;
break;
}
try {
await this.callAviationStackAndUpdate(flight);
refreshed++;
} catch (error) {
this.logger.error(`Failed to refresh flight ${flight.flightNumber}: ${error.message}`);
skipped++;
}
}
const updatedBudget = await this.getOrCreateBudget();
return {
refreshed,
skipped,
budgetRemaining: updatedBudget.requestLimit - updatedBudget.requestsUsed,
};
}
// ============================================
// Budget Management
// ============================================
async getBudgetStatus() {
const budget = await this.getOrCreateBudget();
return {
used: budget.requestsUsed,
limit: budget.requestLimit,
remaining: budget.requestLimit - budget.requestsUsed,
month: budget.monthYear,
};
}
private async getOrCreateBudget() {
const monthYear = this.getCurrentMonthYear();
let budget = await this.prisma.flightApiBudget.findUnique({
where: { monthYear },
});
if (!budget) {
budget = await this.prisma.flightApiBudget.create({
data: { monthYear, requestLimit: 100 },
});
}
return budget;
}
private async incrementBudget() {
const monthYear = this.getCurrentMonthYear();
return this.prisma.flightApiBudget.upsert({
where: { monthYear },
update: {
requestsUsed: { increment: 1 },
lastRequestAt: new Date(),
},
create: {
monthYear,
requestsUsed: 1,
requestLimit: 100,
lastRequestAt: new Date(),
},
});
}
private getCurrentMonthYear(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
}
// ============================================
// Phase Calculation
// ============================================
calculateTrackingPhase(flight: Flight): string {
const now = new Date();
const status = flight.status?.toLowerCase();
// Terminal states
if (status === 'landed' || flight.actualArrival) return PHASE.LANDED;
if (STATUS_TO_TERMINAL.includes(status || '')) return PHASE.TERMINAL;
// Active in flight
if (status === 'active') {
// Check if within arrival window
const eta = flight.estimatedArrival || flight.scheduledArrival;
if (eta) {
const minutesToArrival = (new Date(eta).getTime() - now.getTime()) / 60000;
if (minutesToArrival <= 60) return PHASE.ARRIVAL_WINDOW;
}
return PHASE.ACTIVE;
}
// Pre-departure phases based on scheduled departure
const departure = flight.estimatedDeparture || flight.scheduledDeparture;
if (!departure) return PHASE.FAR_OUT;
const hoursUntilDeparture = (new Date(departure).getTime() - now.getTime()) / 3600000;
if (hoursUntilDeparture <= 0) {
// Past scheduled departure but no "active" status from API
// Could be delayed at gate - treat as departure window
return PHASE.DEPARTURE_WINDOW;
}
if (hoursUntilDeparture <= 6) return PHASE.DEPARTURE_WINDOW;
if (hoursUntilDeparture <= 24) return PHASE.PRE_DEPARTURE;
return PHASE.FAR_OUT;
}
// ============================================
// Polling Decision
// ============================================
private shouldPoll(flight: Flight, phase: string): boolean {
const minInterval = MIN_POLL_INTERVAL[phase];
if (!isFinite(minInterval)) return false;
if (!flight.lastPolledAt) return true; // Never polled
const minutesSincePoll = (Date.now() - new Date(flight.lastPolledAt).getTime()) / 60000;
return minutesSincePoll >= minInterval;
}
// ============================================
// AviationStack API Integration
// ============================================
private async callAviationStackAndUpdate(flight: Flight & { vip?: any }): Promise<Flight> {
const flightDate = flight.flightDate
? new Date(flight.flightDate).toISOString().split('T')[0]
: undefined;
try {
const params: any = {
access_key: this.apiKey,
flight_iata: flight.flightNumber,
};
if (flightDate) {
params.flight_date = flightDate;
}
const response = await firstValueFrom(
this.httpService.get(`${this.baseUrl}/flights`, {
params,
timeout: 15000,
}),
);
// Increment budget after successful call
await this.incrementBudget();
const data = response.data as any;
if (data?.error) {
this.logger.warn(`AviationStack API error for ${flight.flightNumber}: ${data.error.message || JSON.stringify(data.error)}`);
// Still update lastPolledAt so we don't spam on errors
return this.prisma.flight.update({
where: { id: flight.id },
data: { lastPolledAt: new Date(), pollCount: { increment: 1 } },
include: { vip: true },
});
}
if (data?.data && data.data.length > 0) {
const apiResult = data.data[0];
const updateData = this.parseAviationStackResponse(apiResult);
// Calculate new phase based on updated data
const tempFlight = { ...flight, ...updateData };
const newPhase = this.calculateTrackingPhase(tempFlight as Flight);
const updated = await this.prisma.flight.update({
where: { id: flight.id },
data: {
...updateData,
trackingPhase: newPhase,
lastPolledAt: new Date(),
pollCount: { increment: 1 },
lastApiResponse: apiResult,
},
include: { vip: true },
});
this.logger.log(
`Updated flight ${flight.flightNumber}: status=${updated.status}, phase=${newPhase}, delay=${updated.arrivalDelay || 0}min`,
);
return updated;
}
// Flight not found in API
this.logger.warn(`Flight ${flight.flightNumber} not found in AviationStack API`);
return this.prisma.flight.update({
where: { id: flight.id },
data: { lastPolledAt: new Date(), pollCount: { increment: 1 } },
include: { vip: true },
});
} catch (error) {
this.logger.error(`AviationStack API call failed for ${flight.flightNumber}: ${error.message}`);
// Still update lastPolledAt on error to prevent rapid retries
return this.prisma.flight.update({
where: { id: flight.id },
data: { lastPolledAt: new Date() },
include: { vip: true },
});
}
}
// ============================================
// Response Parser
// ============================================
private parseAviationStackResponse(apiData: any): Partial<Flight> {
const update: any = {};
// Flight status
if (apiData.flight_status) {
update.status = apiData.flight_status;
}
// Departure info
if (apiData.departure) {
const dep = apiData.departure;
if (dep.terminal) update.departureTerminal = dep.terminal;
if (dep.gate) update.departureGate = dep.gate;
if (dep.delay != null) update.departureDelay = dep.delay;
if (dep.scheduled) update.scheduledDeparture = new Date(dep.scheduled);
if (dep.estimated) update.estimatedDeparture = new Date(dep.estimated);
if (dep.actual) update.actualDeparture = new Date(dep.actual);
// Store departure airport name if we only had IATA code
if (dep.iata && !update.departureAirport) update.departureAirport = dep.iata;
}
// Arrival info
if (apiData.arrival) {
const arr = apiData.arrival;
if (arr.terminal) update.arrivalTerminal = arr.terminal;
if (arr.gate) update.arrivalGate = arr.gate;
if (arr.baggage) update.arrivalBaggage = arr.baggage;
if (arr.delay != null) update.arrivalDelay = arr.delay;
if (arr.scheduled) update.scheduledArrival = new Date(arr.scheduled);
if (arr.estimated) update.estimatedArrival = new Date(arr.estimated);
if (arr.actual) update.actualArrival = new Date(arr.actual);
if (arr.iata && !update.arrivalAirport) update.arrivalAirport = arr.iata;
}
// Airline info
if (apiData.airline) {
if (apiData.airline.name) update.airlineName = apiData.airline.name;
if (apiData.airline.iata) update.airlineIata = apiData.airline.iata;
}
// Aircraft info
if (apiData.aircraft?.iata) {
update.aircraftType = apiData.aircraft.iata;
}
// Live tracking data (may not be available on free tier)
if (apiData.live) {
const live = apiData.live;
if (live.latitude != null) update.liveLatitude = live.latitude;
if (live.longitude != null) update.liveLongitude = live.longitude;
if (live.altitude != null) update.liveAltitude = live.altitude;
if (live.speed_horizontal != null) update.liveSpeed = live.speed_horizontal;
if (live.direction != null) update.liveDirection = live.direction;
if (live.is_ground != null) update.liveIsGround = live.is_ground;
if (live.updated) update.liveUpdatedAt = new Date(live.updated);
}
return update;
}
}

View File

@@ -10,6 +10,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { FlightsService } from './flights.service'; import { FlightsService } from './flights.service';
import { FlightTrackingService } from './flight-tracking.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
@@ -19,7 +20,10 @@ import { CreateFlightDto, UpdateFlightDto } from './dto';
@Controller('flights') @Controller('flights')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
export class FlightsController { export class FlightsController {
constructor(private readonly flightsService: FlightsService) {} constructor(
private readonly flightsService: FlightsService,
private readonly flightTrackingService: FlightTrackingService,
) {}
@Post() @Post()
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR) @Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
@@ -33,6 +37,20 @@ export class FlightsController {
return this.flightsService.findAll(); return this.flightsService.findAll();
} }
// --- Tracking Endpoints (must come before :id param routes) ---
@Get('tracking/budget')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
getBudgetStatus() {
return this.flightTrackingService.getBudgetStatus();
}
@Post('refresh-active')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
refreshActiveFlights() {
return this.flightTrackingService.refreshActiveFlights();
}
@Get('status/:flightNumber') @Get('status/:flightNumber')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR) @Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
getFlightStatus( getFlightStatus(
@@ -54,6 +72,12 @@ export class FlightsController {
return this.flightsService.findOne(id); return this.flightsService.findOne(id);
} }
@Post(':id/refresh')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
refreshFlight(@Param('id') id: string) {
return this.flightTrackingService.refreshFlight(id);
}
@Patch(':id') @Patch(':id')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR) @Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
update(@Param('id') id: string, @Body() updateFlightDto: UpdateFlightDto) { update(@Param('id') id: string, @Body() updateFlightDto: UpdateFlightDto) {

View File

@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { FlightsController } from './flights.controller'; import { FlightsController } from './flights.controller';
import { FlightsService } from './flights.service'; import { FlightsService } from './flights.service';
import { FlightTrackingService } from './flight-tracking.service';
@Module({ @Module({
imports: [HttpModule], imports: [HttpModule],
controllers: [FlightsController], controllers: [FlightsController],
providers: [FlightsService], providers: [FlightsService, FlightTrackingService],
exports: [FlightsService], exports: [FlightsService, FlightTrackingService],
}) })
export class FlightsModule {} export class FlightsModule {}

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; id: string;
flightNumber: string; flightNumber: string;
arrivalAirport: string; arrivalAirport: string;
departureAirport: string;
scheduledArrival: string | null; 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; 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) // Compute awaiting confirmation BEFORE any conditional returns (for hooks)
const now = currentTime; const now = currentTime;
const awaitingConfirmation = (events || []).filter((event) => { const awaitingConfirmation = (events || []).filter((event) => {
@@ -312,7 +329,10 @@ export function CommandCenter() {
return start <= fifteenMinutes; 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 const upcomingArrivals = vips
.filter((vip) => { .filter((vip) => {
if (vip.expectedArrival) { if (vip.expectedArrival) {
@@ -320,16 +340,17 @@ export function CommandCenter() {
return arrival > now && arrival <= fourHoursLater; return arrival > now && arrival <= fourHoursLater;
} }
return vip.flights.some((flight) => { return vip.flights.some((flight) => {
if (flight.scheduledArrival) { const arrTime = getFlightArrivalTime(flight);
const arrival = new Date(flight.scheduledArrival); if (arrTime && flight.status?.toLowerCase() !== 'cancelled') {
const arrival = new Date(arrTime);
return arrival > now && arrival <= fourHoursLater; return arrival > now && arrival <= fourHoursLater;
} }
return false; return false;
}); });
}) })
.sort((a, b) => { .sort((a, b) => {
const aTime = a.expectedArrival || a.flights[0]?.scheduledArrival || ''; const aTime = a.expectedArrival || getFlightArrivalTime(a.flights[0]) || '';
const bTime = b.expectedArrival || b.flights[0]?.scheduledArrival || ''; const bTime = b.expectedArrival || getFlightArrivalTime(b.flights[0]) || '';
return new Date(aTime).getTime() - new Date(bTime).getTime(); 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 // Get time until event
function getTimeUntil(dateStr: string) { function getTimeUntil(dateStr: string) {
const eventTime = new Date(dateStr); const eventTime = new Date(dateStr);
@@ -827,9 +887,9 @@ export function CommandCenter() {
</div> </div>
{/* Bottom Row: Resources */} {/* 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 */} {/* 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="bg-purple-600 px-3 py-2 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Plane className="h-4 w-4 text-white" /> <Plane className="h-4 w-4 text-white" />
@@ -841,7 +901,7 @@ export function CommandCenter() {
ref={arrivalsScrollRef} ref={arrivalsScrollRef}
onWheel={handleUserInteraction} onWheel={handleUserInteraction}
onTouchMove={handleUserInteraction} onTouchMove={handleUserInteraction}
className="max-h-[140px] overflow-y-auto scrollbar-hide" className="max-h-[180px] overflow-y-auto scrollbar-hide"
> >
{upcomingArrivals.length === 0 ? ( {upcomingArrivals.length === 0 ? (
<div className="p-4 text-center text-muted-foreground"> <div className="p-4 text-center text-muted-foreground">
@@ -850,16 +910,79 @@ export function CommandCenter() {
) : ( ) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{upcomingArrivals.map((vip) => { {upcomingArrivals.map((vip) => {
const arrival = vip.expectedArrival || vip.flights[0]?.scheduledArrival; const flight = vip.flights.find(f => f.status?.toLowerCase() !== 'cancelled') || vip.flights[0];
const flight = 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 ( 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="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="font-medium text-foreground text-xs truncate">{vip.name}</p> <p className="font-medium text-foreground text-xs truncate">{vip.name}</p>
{flight && <p className="text-[10px] text-muted-foreground">{flight.flightNumber}</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> </div>
<p className="text-xs font-bold text-purple-600 dark:text-purple-400">{getTimeUntil(arrival!)}</p>
</div> </div>
</div> </div>
); );

View File

@@ -1,27 +1,9 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Users, Car, Plane, Clock } from 'lucide-react'; import { Users, Car, Plane, Clock, AlertTriangle } from 'lucide-react';
import { VIP, Driver, ScheduleEvent } from '@/types'; import { VIP, Driver, ScheduleEvent, Flight } from '@/types';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
import { FlightProgressBar } from '@/components/FlightProgressBar';
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;
}
export function Dashboard() { export function Dashboard() {
const { data: vips } = useQuery<VIP[]>({ const { data: vips } = useQuery<VIP[]>({
@@ -195,55 +177,121 @@ export function Dashboard() {
)} )}
</div> </div>
{/* Upcoming Flights */} {/* Flight Status Overview */}
<div className="bg-card shadow-medium rounded-lg p-6 mb-8 border border-border"> <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"> <h2 className="text-lg font-medium text-foreground mb-4 flex items-center gap-2">
Upcoming Flights <Plane className="h-5 w-5" />
Flight Status
</h2> </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 ? ( {upcomingFlights.length > 0 ? (
<div className="space-y-4"> <div className="space-y-3">
{upcomingFlights.map((flight) => ( <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 <div
key={flight.id} key={flight.id}
className="border-l-4 border-indigo-500 pl-4 py-2 hover:bg-accent transition-colors rounded-r" className={`border-l-4 ${borderColor} pl-4 py-2 hover:bg-accent transition-colors rounded-r`}
> >
<div className="flex justify-between items-start"> <div className="flex justify-between items-start mb-1">
<div> <div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-foreground flex items-center gap-2"> <span className="text-sm font-medium text-foreground">{flight.flightNumber}</span>
<Plane className="h-4 w-4" /> {flight.airlineName && (
{flight.flightNumber} <span className="text-xs text-muted-foreground">{flight.airlineName}</span>
</h3> )}
<p className="text-sm text-muted-foreground"> <span className="text-muted-foreground/50">|</span>
{flight.vip?.name} {flight.departureAirport} {flight.arrivalAirport} <span className="text-sm text-foreground/80">{flight.vip?.name}</span>
</p> </div>
{flight.scheduledDeparture && ( <div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground mt-1"> {delay > 15 && (
Departs: {formatDateTime(flight.scheduledDeparture)} <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">
</p> <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 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>
</div>
</div>
))}
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground text-center py-4"> <p className="text-sm text-muted-foreground text-center py-4">

View File

@@ -1,32 +1,144 @@
import { useState, useMemo } from 'react'; 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 toast from 'react-hot-toast';
import { api } from '@/lib/api'; 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 { FlightForm, FlightFormData } from '@/components/FlightForm';
import { FlightCard } from '@/components/FlightCard';
import { TableSkeleton } from '@/components/Skeleton'; import { TableSkeleton } from '@/components/Skeleton';
import { ErrorMessage } from '@/components/ErrorMessage'; import { ErrorMessage } from '@/components/ErrorMessage';
import { FilterModal } from '@/components/FilterModal'; import { FilterModal } from '@/components/FilterModal';
import { FilterChip } from '@/components/FilterChip'; import { FilterChip } from '@/components/FilterChip';
import { useDebounce } from '@/hooks/useDebounce'; import { useDebounce } from '@/hooks/useDebounce';
import { useFlights, useFlightBudget, useRefreshActiveFlights } from '@/hooks/useFlights';
import { Flight } from '@/types';
interface Flight { type FlightGroup = {
id: string; key: string;
vipId: string; label: string;
vip?: { icon: typeof AlertTriangle;
name: string; flights: Flight[];
organization: string | null; color: string;
defaultCollapsed?: boolean;
}; };
flightNumber: string;
flightDate: string; function groupFlights(flights: Flight[]): FlightGroup[] {
segment: number; const now = new Date();
departureAirport: string; const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
arrivalAirport: string; const fourHoursFromNow = new Date(now.getTime() + 4 * 60 * 60 * 1000);
scheduledDeparture: string | null;
scheduledArrival: string | null; const groups: FlightGroup[] = [
actualDeparture: string | null; { key: 'alerts', label: 'Alerts', icon: AlertTriangle, flights: [], color: 'text-red-500' },
actualArrival: string | null; { key: 'arriving', label: 'Arriving Soon', icon: Plane, flights: [], color: 'text-purple-500' },
status: string | null; { 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() { export function FlightList() {
@@ -34,26 +146,17 @@ export function FlightList() {
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingFlight, setEditingFlight] = useState<Flight | null>(null); const [editingFlight, setEditingFlight] = useState<Flight | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set(['completed']));
// Search and filter state // Search and filter state
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]); const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
const [filterModalOpen, setFilterModalOpen] = useState(false); 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 debouncedSearchTerm = useDebounce(searchTerm, 300);
const { data: flights, isLoading, isError, error, refetch } = useQuery<Flight[]>({ const { data: flights, isLoading, isError, error, refetch } = useFlights();
queryKey: ['flights'], const refreshActiveMutation = useRefreshActiveFlights();
queryFn: async () => {
const { data } = await api.get('/flights');
return data;
},
});
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async (data: FlightFormData) => { mutationFn: async (data: FlightFormData) => {
@@ -66,7 +169,6 @@ export function FlightList() {
toast.success('Flight created successfully'); toast.success('Flight created successfully');
}, },
onError: (error: any) => { onError: (error: any) => {
console.error('[FLIGHT] Failed to create:', error);
setIsSubmitting(false); setIsSubmitting(false);
toast.error(error.response?.data?.message || 'Failed to create flight'); toast.error(error.response?.data?.message || 'Failed to create flight');
}, },
@@ -84,7 +186,6 @@ export function FlightList() {
toast.success('Flight updated successfully'); toast.success('Flight updated successfully');
}, },
onError: (error: any) => { onError: (error: any) => {
console.error('[FLIGHT] Failed to update:', error);
setIsSubmitting(false); setIsSubmitting(false);
toast.error(error.response?.data?.message || 'Failed to update flight'); toast.error(error.response?.data?.message || 'Failed to update flight');
}, },
@@ -99,52 +200,46 @@ export function FlightList() {
toast.success('Flight deleted successfully'); toast.success('Flight deleted successfully');
}, },
onError: (error: any) => { onError: (error: any) => {
console.error('[FLIGHT] Failed to delete:', error);
toast.error(error.response?.data?.message || 'Failed to delete flight'); toast.error(error.response?.data?.message || 'Failed to delete flight');
}, },
}); });
// Filter and sort flights // Filter flights
const filteredFlights = useMemo(() => { const filteredFlights = useMemo(() => {
if (!flights) return []; if (!flights) return [];
// First filter return flights.filter((flight) => {
let filtered = flights.filter((flight) => {
// Search by flight number, VIP name, or route (using debounced term)
const matchesSearch = debouncedSearchTerm === '' || const matchesSearch = debouncedSearchTerm === '' ||
flight.flightNumber.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || 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.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 || const matchesStatus = selectedStatuses.length === 0 ||
(flight.status && selectedStatuses.includes(flight.status.toLowerCase())); (flight.status && selectedStatuses.includes(flight.status.toLowerCase()));
return matchesSearch && matchesStatus; return matchesSearch && matchesStatus;
}); });
}, [flights, debouncedSearchTerm, selectedStatuses]);
// Then sort const flightGroups = useMemo(() => groupFlights(filteredFlights), [filteredFlights]);
filtered.sort((a, b) => {
let aValue = a[sortColumn] || '';
let bValue = b[sortColumn] || '';
if (typeof aValue === 'string') aValue = aValue.toLowerCase(); const toggleGroup = (key: string) => {
if (typeof bValue === 'string') bValue = bValue.toLowerCase(); setCollapsedGroups(prev => {
const next = new Set(prev);
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1; if (next.has(key)) {
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1; next.delete(key);
return 0; } else {
next.add(key);
}
return next;
}); });
};
return filtered;
}, [flights, debouncedSearchTerm, selectedStatuses, sortColumn, sortDirection]);
const handleStatusToggle = (status: string) => { const handleStatusToggle = (status: string) => {
setSelectedStatuses((prev) => setSelectedStatuses((prev) =>
prev.includes(status) prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
? prev.filter((s) => s !== status)
: [...prev, status]
); );
}; };
@@ -153,15 +248,6 @@ export function FlightList() {
setSelectedStatuses([]); setSelectedStatuses([]);
}; };
const handleSort = (column: typeof sortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
const handleRemoveStatusFilter = (status: string) => { const handleRemoveStatusFilter = (status: string) => {
setSelectedStatuses((prev) => prev.filter((s) => s !== status)); setSelectedStatuses((prev) => prev.filter((s) => s !== status));
}; };
@@ -169,12 +255,11 @@ export function FlightList() {
const getFilterLabel = (value: string) => { const getFilterLabel = (value: string) => {
const labels: Record<string, string> = { const labels: Record<string, string> = {
'scheduled': 'Scheduled', 'scheduled': 'Scheduled',
'boarding': 'Boarding', 'active': 'Active / In Flight',
'departed': 'Departed',
'en-route': 'En Route',
'landed': 'Landed', 'landed': 'Landed',
'delayed': 'Delayed',
'cancelled': 'Cancelled', 'cancelled': 'Cancelled',
'diverted': 'Diverted',
'incident': 'Incident',
}; };
return labels[value] || value; return labels[value] || value;
}; };
@@ -189,9 +274,9 @@ export function FlightList() {
setShowForm(true); setShowForm(true);
}; };
const handleDelete = (id: string, flightNumber: string) => { const handleDelete = (flight: Flight) => {
if (confirm(`Delete flight ${flightNumber}? This action cannot be undone.`)) { if (confirm(`Delete flight ${flight.flightNumber}? This action cannot be undone.`)) {
deleteMutation.mutate(id); deleteMutation.mutate(flight.id);
} }
}; };
@@ -210,48 +295,22 @@ export function FlightList() {
setIsSubmitting(false); setIsSubmitting(false);
}; };
const formatTime = (isoString: string | null) => { // Stats
if (!isoString) return '-'; const stats = useMemo(() => {
return new Date(isoString).toLocaleString('en-US', { if (!flights) return { active: 0, delayed: 0, onTime: 0, landed: 0 };
month: 'short', return {
day: 'numeric', active: flights.filter(f => f.status?.toLowerCase() === 'active').length,
hour: '2-digit', delayed: flights.filter(f => (f.arrivalDelay || f.departureDelay || 0) > 15).length,
minute: '2-digit', onTime: flights.filter(f => f.status === 'scheduled' && !(f.departureDelay && f.departureDelay > 15)).length,
}); landed: flights.filter(f => f.status?.toLowerCase() === 'landed' || f.actualArrival).length,
};
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';
}
}; };
}, [flights]);
if (isLoading) { if (isLoading) {
return ( return (
<div> <div>
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-foreground">Flights</h1> <h1 className="text-3xl font-bold text-foreground">Flight Tracking</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>
</div> </div>
<TableSkeleton rows={8} /> <TableSkeleton rows={8} />
</div> </div>
@@ -270,8 +329,42 @@ export function FlightList() {
return ( return (
<div> <div>
<div className="flex justify-between items-center mb-6"> {/* Header */}
<h1 className="text-3xl font-bold text-foreground">Flights</h1> <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 <button
onClick={handleAdd} 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" 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"
@@ -280,25 +373,23 @@ export function FlightList() {
Add Flight Add Flight
</button> </button>
</div> </div>
</div>
{/* Search and Filter Section */} {/* Search and Filter */}
{flights && flights.length > 0 && ( {flights && flights.length > 0 && (
<div className="bg-card shadow-soft border border-border rounded-lg p-4 mb-6"> <div className="bg-card shadow-soft border border-border rounded-lg p-4 mb-6">
<div className="flex gap-3"> <div className="flex gap-3">
{/* Search */}
<div className="flex-1 relative"> <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" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground pointer-events-none" />
<input <input
type="text" type="text"
placeholder="Search by flight number, VIP, or route..." placeholder="Search by flight number, VIP, airline, or airport..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} 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" 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' }} style={{ minHeight: '44px' }}
/> />
</div> </div>
{/* Filter Button */}
<button <button
onClick={() => setFilterModalOpen(true)} 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" 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> </button>
</div> </div>
{/* Active Filter Chips */}
{selectedStatuses.length > 0 && ( {selectedStatuses.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-border"> <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> <span className="text-sm text-muted-foreground py-1.5">Active filters:</span>
@@ -328,11 +418,9 @@ export function FlightList() {
</div> </div>
)} )}
{/* Results count */}
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border"> <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">
<div className="text-sm text-muted-foreground"> <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 Showing <span className="font-medium text-foreground">{filteredFlights.length}</span> of <span className="font-medium text-foreground">{flights?.length || 0}</span> flights
{debouncedSearchTerm !== searchTerm && <span className="ml-2 text-muted-foreground/70">(searching...)</span>}
</div> </div>
{(searchTerm || selectedStatuses.length > 0) && ( {(searchTerm || selectedStatuses.length > 0) && (
<button <button
@@ -347,118 +435,52 @@ export function FlightList() {
</div> </div>
)} )}
{/* Flight Groups */}
{flights && flights.length > 0 ? ( {flights && flights.length > 0 ? (
<div className="bg-card shadow-medium border border-border rounded-lg overflow-hidden"> <div className="space-y-6">
<table className="min-w-full divide-y divide-border"> {flightGroups.map((group) => {
<thead className="bg-muted/30"> if (group.flights.length === 0) return null;
<tr> const isCollapsed = collapsedGroups.has(group.key);
<th const Icon = group.icon;
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')} 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"> {isCollapsed ? (
Flight <ChevronRight className="w-4 h-4 text-muted-foreground" />
<ArrowUpDown className="h-4 w-4" /> ) : (
{sortColumn === 'flightNumber' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>} <ChevronDown className="w-4 h-4 text-muted-foreground" />
</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> <Icon className={`w-4 h-4 ${group.color}`} />
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground"> <span className={`text-sm font-semibold uppercase tracking-wider ${group.color}`}>
<div className="flex items-center"> {group.label}
<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> </span>
</td> <span className="text-xs text-muted-foreground font-normal">
<td className="px-6 py-4 whitespace-nowrap text-sm"> ({group.flights.length})
<div className="flex gap-2"> </span>
<button <div className="flex-1 border-t border-border/50 ml-2" />
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>
<button
onClick={() => handleDelete(flight.id, flight.flightNumber)} {/* Flight cards */}
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" {!isCollapsed && (
> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Trash2 className="h-4 w-4 mr-1" /> {group.flights.map((flight) => (
Delete <FlightCard
</button> key={flight.id}
</div> flight={flight}
</td> onEdit={handleEdit}
</tr> onDelete={handleDelete}
/>
))} ))}
</tbody> </div>
</table> )}
</div>
);
})}
</div> </div>
) : ( ) : (
<div className="bg-card shadow-soft border border-border rounded-lg p-12 text-center"> <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 <FilterModal
isOpen={filterModalOpen} isOpen={filterModalOpen}
onClose={() => setFilterModalOpen(false)} onClose={() => setFilterModalOpen(false)}
@@ -492,12 +513,11 @@ export function FlightList() {
label: 'Flight Status', label: 'Flight Status',
options: [ options: [
{ value: 'scheduled', label: 'Scheduled' }, { value: 'scheduled', label: 'Scheduled' },
{ value: 'boarding', label: 'Boarding' }, { value: 'active', label: 'Active / In Flight' },
{ value: 'departed', label: 'Departed' },
{ value: 'en-route', label: 'En Route' },
{ value: 'landed', label: 'Landed' }, { value: 'landed', label: 'Landed' },
{ value: 'delayed', label: 'Delayed' },
{ value: 'cancelled', label: 'Cancelled' }, { value: 'cancelled', label: 'Cancelled' },
{ value: 'diverted', label: 'Diverted' },
{ value: 'incident', label: 'Incident' },
], ],
selectedValues: selectedStatuses, selectedValues: selectedStatuses,
onToggle: handleStatusToggle, onToggle: handleStatusToggle,

View File

@@ -23,6 +23,7 @@ export interface User {
export enum Department { export enum Department {
OFFICE_OF_DEVELOPMENT = 'OFFICE_OF_DEVELOPMENT', OFFICE_OF_DEVELOPMENT = 'OFFICE_OF_DEVELOPMENT',
ADMIN = 'ADMIN', ADMIN = 'ADMIN',
OTHER = 'OTHER',
} }
export enum ArrivalMode { export enum ArrivalMode {
@@ -144,6 +145,9 @@ export interface ScheduleEvent {
} }
// Flight types // 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 { export interface Flight {
id: string; id: string;
vipId: string; vipId: string;
@@ -158,6 +162,51 @@ export interface Flight {
actualDeparture: string | null; actualDeparture: string | null;
actualArrival: string | null; actualArrival: string | null;
status: 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; createdAt: string;
updatedAt: 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)', md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)', 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: [], plugins: [],