diff --git a/backend/prisma/migrations/20260207182746_add_flight_tracking_fields/migration.sql b/backend/prisma/migrations/20260207182746_add_flight_tracking_fields/migration.sql new file mode 100644 index 0000000..7755370 --- /dev/null +++ b/backend/prisma/migrations/20260207182746_add_flight_tracking_fields/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index cc30e34..9d3729c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -73,6 +73,7 @@ model VIP { enum Department { OFFICE_OF_DEVELOPMENT ADMIN + OTHER } enum ArrivalMode { @@ -97,13 +98,70 @@ model Flight { scheduledArrival DateTime? actualDeparture 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()) updatedAt DateTime @updatedAt @@map("flights") @@index([vipId]) @@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") } // ============================================ diff --git a/backend/src/flights/flight-tracking.service.ts b/backend/src/flights/flight-tracking.service.ts new file mode 100644 index 0000000..95f2475 --- /dev/null +++ b/backend/src/flights/flight-tracking.service.ts @@ -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 = { + [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 = { + [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 { + 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 { + 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 { + 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; + } +} diff --git a/backend/src/flights/flights.controller.ts b/backend/src/flights/flights.controller.ts index 62f9a69..3b3d525 100644 --- a/backend/src/flights/flights.controller.ts +++ b/backend/src/flights/flights.controller.ts @@ -10,6 +10,7 @@ import { UseGuards, } from '@nestjs/common'; import { FlightsService } from './flights.service'; +import { FlightTrackingService } from './flight-tracking.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; @@ -19,7 +20,10 @@ import { CreateFlightDto, UpdateFlightDto } from './dto'; @Controller('flights') @UseGuards(JwtAuthGuard, RolesGuard) export class FlightsController { - constructor(private readonly flightsService: FlightsService) {} + constructor( + private readonly flightsService: FlightsService, + private readonly flightTrackingService: FlightTrackingService, + ) {} @Post() @Roles(Role.ADMINISTRATOR, Role.COORDINATOR) @@ -33,6 +37,20 @@ export class FlightsController { 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') @Roles(Role.ADMINISTRATOR, Role.COORDINATOR) getFlightStatus( @@ -54,6 +72,12 @@ export class FlightsController { 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') @Roles(Role.ADMINISTRATOR, Role.COORDINATOR) update(@Param('id') id: string, @Body() updateFlightDto: UpdateFlightDto) { diff --git a/backend/src/flights/flights.module.ts b/backend/src/flights/flights.module.ts index 171b200..5a79e7d 100644 --- a/backend/src/flights/flights.module.ts +++ b/backend/src/flights/flights.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { FlightsController } from './flights.controller'; import { FlightsService } from './flights.service'; +import { FlightTrackingService } from './flight-tracking.service'; @Module({ imports: [HttpModule], controllers: [FlightsController], - providers: [FlightsService], - exports: [FlightsService], + providers: [FlightsService, FlightTrackingService], + exports: [FlightsService, FlightTrackingService], }) export class FlightsModule {} diff --git a/frontend/src/components/FlightCard.tsx b/frontend/src/components/FlightCard.tsx new file mode 100644 index 0000000..e92d46e --- /dev/null +++ b/frontend/src/components/FlightCard.tsx @@ -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 ( +
+ {/* Alert banner */} + {alert && ( +
+ + {alert.message} +
+ )} + + {/* Header */} +
+
+
+ {/* Status dot */} +
+ + {/* Flight number + airline */} +
+ {flight.flightNumber} + {flight.airlineName && ( + {flight.airlineName} + )} +
+ + {/* VIP name */} + {flight.vip && ( +
+ | + {flight.vip.name} + {flight.vip.partySize > 1 && ( + + + +{flight.vip.partySize - 1} + + )} +
+ )} +
+ + {/* Actions */} +
+ + {onEdit && ( + + )} + {onDelete && ( + + )} +
+
+
+ + {/* Progress Bar */} +
+ +
+ + {/* Footer - expandable details */} +
+ + + {expanded && ( +
+ {/* Detailed times */} +
+
+
Departure
+
+ {flight.scheduledDeparture &&
Scheduled: {new Date(flight.scheduledDeparture).toLocaleString()}
} + {flight.estimatedDeparture &&
Estimated: {new Date(flight.estimatedDeparture).toLocaleString()}
} + {flight.actualDeparture &&
Actual: {new Date(flight.actualDeparture).toLocaleString()}
} + {flight.departureDelay != null && flight.departureDelay > 0 && ( +
Delay: {flight.departureDelay} min
+ )} + {flight.departureTerminal &&
Terminal: {flight.departureTerminal}
} + {flight.departureGate &&
Gate: {flight.departureGate}
} +
+
+
+
Arrival
+
+ {flight.scheduledArrival &&
Scheduled: {new Date(flight.scheduledArrival).toLocaleString()}
} + {flight.estimatedArrival &&
Estimated: {new Date(flight.estimatedArrival).toLocaleString()}
} + {flight.actualArrival &&
Actual: {new Date(flight.actualArrival).toLocaleString()}
} + {flight.arrivalDelay != null && flight.arrivalDelay > 0 && ( +
Delay: {flight.arrivalDelay} min
+ )} + {flight.arrivalTerminal &&
Terminal: {flight.arrivalTerminal}
} + {flight.arrivalGate &&
Gate: {flight.arrivalGate}
} + {flight.arrivalBaggage &&
Baggage: {flight.arrivalBaggage}
} +
+
+
+ + {/* Aircraft info */} + {flight.aircraftType && ( +
+ Aircraft: {flight.aircraftType} +
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/FlightProgressBar.tsx b/frontend/src/components/FlightProgressBar.tsx new file mode 100644 index 0000000..c2bd184 --- /dev/null +++ b/frontend/src/components/FlightProgressBar.tsx @@ -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 ( +
+
+ {flight.departureAirport} +
+
+
+ {isActive && ( + + )} +
+ {flight.arrivalAirport} +
+
+ ); + } + + return ( +
+ {/* Airport codes and progress track */} +
+ {/* Departure airport */} +
+
{flight.departureAirport}
+
+ + {/* Progress track */} +
+ {/* Track background */} +
+ {/* Filled progress */} +
+ + {/* Departure dot */} +
0 ? trackColor : 'bg-muted-foreground/40'}`} /> + + {/* Arrival dot */} +
= 100 ? trackColor : 'bg-muted-foreground/40'}`} /> + + {/* Airplane icon */} + {!isCancelled && ( +
+
+ +
+
+ )} + + {/* Cancelled X */} + {isCancelled && ( +
+ ✕ +
+ )} +
+
+ + {/* Arrival airport */} +
+
{flight.arrivalAirport}
+
+
+ + {/* Time and detail row */} +
+ {/* Departure details */} +
+ {departureTime && ( +
+ {hasDelay && flight.scheduledDeparture && flight.scheduledDeparture !== departureTime ? ( + <> + {formatTime(flight.scheduledDeparture)} + {formatTime(departureTime)} + + ) : ( + {formatTime(departureTime)} + )} +
+ )} + {(flight.departureTerminal || flight.departureGate) && ( +
+ {flight.departureTerminal && T{flight.departureTerminal}} + {flight.departureTerminal && flight.departureGate && } + {flight.departureGate && Gate {flight.departureGate}} +
+ )} +
+ + {/* Center: flight duration or status */} +
+ {isActive && flight.aircraftType && ( + {flight.aircraftType} + )} + {isLanded && Landed} + {isCancelled && {status}} +
+ + {/* Arrival details */} +
+ {arrivalTime && ( +
+ {hasDelay && flight.scheduledArrival && flight.scheduledArrival !== arrivalTime ? ( + <> + {formatTime(flight.scheduledArrival)} + {formatTime(arrivalTime)} + + ) : ( + + {isLanded ? '' : 'ETA '}{formatTime(arrivalTime)} + + )} +
+ )} + {(flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && ( +
+ {flight.arrivalTerminal && T{flight.arrivalTerminal}} + {flight.arrivalGate && Gate {flight.arrivalGate}} + {flight.arrivalBaggage && Bag {flight.arrivalBaggage}} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/hooks/useFlights.ts b/frontend/src/hooks/useFlights.ts new file mode 100644 index 0000000..ab3c9ee --- /dev/null +++ b/frontend/src/hooks/useFlights.ts @@ -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({ + 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({ + 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'); + }, + }); +} diff --git a/frontend/src/pages/CommandCenter.tsx b/frontend/src/pages/CommandCenter.tsx index 7933ff1..0eee8b3 100644 --- a/frontend/src/pages/CommandCenter.tsx +++ b/frontend/src/pages/CommandCenter.tsx @@ -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({ + 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() {
{/* Bottom Row: Resources */} -
+
{/* VIP Arrivals */} -
+
@@ -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 ? (
@@ -850,16 +910,79 @@ export function CommandCenter() { ) : (
{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 ( -
+
-

{vip.name}

- {flight &&

{flight.flightNumber}

} +
+

{vip.name}

+ {delay > 15 && ( + + + +{delay}m + + )} + {isCancelled && ( + + CANCELLED + + )} +
+
+ {flight && ( + <> + {flight.flightNumber} + {flight.departureAirport} → {flight.arrivalAirport} + + )} +
+ {flight && (flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && ( +
+ {flight.arrivalTerminal && T{flight.arrivalTerminal}} + {flight.arrivalGate && Gate {flight.arrivalGate}} + {flight.arrivalBaggage && Bag {flight.arrivalBaggage}} +
+ )} +
+
+

+ {isCancelled ? '---' : isLanded ? 'Landed' : arrival ? getTimeUntil(arrival) : '--'} +

+ {arrival && !isCancelled && !isLanded && ( +

+ {new Date(arrival).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} +

+ )}
-

{getTimeUntil(arrival!)}

); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 0d6d885..d046447 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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({ @@ -195,55 +177,121 @@ export function Dashboard() { )}
- {/* Upcoming Flights */} + {/* Flight Status Overview */}
-

- Upcoming Flights +

+ + Flight Status

+ + {/* Status summary */} + {flights && flights.length > 0 && ( +
+ {(() => { + 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 && ( + + + {inFlight} + in flight + + )} + {delayed > 0 && ( + + + {delayed} + delayed + + )} + {cancelled > 0 && ( + + + {cancelled} + cancelled + + )} + + + {scheduled} + scheduled + + + + {landed} + landed + + + ); + })()} +
+ )} + + {/* Arriving soon flights */} {upcomingFlights.length > 0 ? ( -
- {upcomingFlights.map((flight) => ( -
-
-
-

- - {flight.flightNumber} -

-

- {flight.vip?.name} • {flight.departureAirport} → {flight.arrivalAirport} -

- {flight.scheduledDeparture && ( -

- Departs: {formatDateTime(flight.scheduledDeparture)} -

- )} -
-
- - {new Date(flight.flightDate).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} - - - {flight.status || 'Unknown'} - +
+

+ Arriving Soon +

+ {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 ( +
+
+
+ {flight.flightNumber} + {flight.airlineName && ( + {flight.airlineName} + )} + | + {flight.vip?.name} +
+
+ {delay > 15 && ( + + + +{delay}min + + )} + + {flight.status || 'scheduled'} + +
+ + + + {/* Terminal/gate info for arriving flights */} + {(flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && ( +
+ {flight.arrivalTerminal && Terminal {flight.arrivalTerminal}} + {flight.arrivalGate && Gate {flight.arrivalGate}} + {flight.arrivalBaggage && Baggage {flight.arrivalBaggage}} +
+ )}
-
- ))} + ); + })}
) : (

diff --git a/frontend/src/pages/FlightList.tsx b/frontend/src/pages/FlightList.tsx index f325ebe..692f2f0 100644 --- a/frontend/src/pages/FlightList.tsx +++ b/frontend/src/pages/FlightList.tsx @@ -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 ( +

+
+ {budget.remaining} + /{budget.limit} API calls +
+
+
+
+
+ ); } export function FlightList() { @@ -34,26 +146,17 @@ export function FlightList() { const [showForm, setShowForm] = useState(false); const [editingFlight, setEditingFlight] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set(['completed'])); // Search and filter state const [searchTerm, setSearchTerm] = useState(''); const [selectedStatuses, setSelectedStatuses] = useState([]); 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({ - 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 = { '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 (
-

Flights

- +

Flight Tracking

@@ -270,35 +329,67 @@ export function FlightList() { return (
-
-

Flights

- + {/* Header */} +
+
+

Flight Tracking

+ {flights && flights.length > 0 && ( +
+ {stats.active > 0 && ( + + + {stats.active} in flight + + )} + {stats.delayed > 0 && ( + + + {stats.delayed} delayed + + )} + {stats.onTime} scheduled + {stats.landed} landed +
+ )} +
+ +
+ + {flights && flights.length > 0 && ( + + )} + +
- {/* Search and Filter Section */} + {/* Search and Filter */} {flights && flights.length > 0 && (
- {/* Search */}
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' }} />
- - {/* Filter Button */}
- {/* Active Filter Chips */} {selectedStatuses.length > 0 && (
Active filters: @@ -328,11 +418,9 @@ export function FlightList() {
)} - {/* Results count */}
- Showing {filteredFlights.length} of {flights.length} flights - {debouncedSearchTerm !== searchTerm && (searching...)} + Showing {filteredFlights.length} of {flights?.length || 0} flights
{(searchTerm || selectedStatuses.length > 0) && (
) : (
@@ -483,7 +505,6 @@ export function FlightList() { /> )} - {/* Filter Modal */} 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, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c407a1f..420b155 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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; +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 0ea802a..b709e9c 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -64,6 +64,15 @@ export default { md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)', }, + animation: { + 'bounce-subtle': 'bounce-subtle 2s ease-in-out infinite', + }, + keyframes: { + 'bounce-subtle': { + '0%, 100%': { transform: 'translateY(0)' }, + '50%': { transform: 'translateY(-2px)' }, + }, + }, }, }, plugins: [],