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:
@@ -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");
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
465
backend/src/flights/flight-tracking.service.ts
Normal file
465
backend/src/flights/flight-tracking.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
210
frontend/src/components/FlightCard.tsx
Normal file
210
frontend/src/components/FlightCard.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Plane,
|
||||||
|
RefreshCw,
|
||||||
|
Edit3,
|
||||||
|
Trash2,
|
||||||
|
AlertTriangle,
|
||||||
|
Clock,
|
||||||
|
ToggleLeft,
|
||||||
|
ToggleRight,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Flight } from '@/types';
|
||||||
|
import { FlightProgressBar } from './FlightProgressBar';
|
||||||
|
import { useRefreshFlight } from '@/hooks/useFlights';
|
||||||
|
|
||||||
|
interface FlightCardProps {
|
||||||
|
flight: Flight;
|
||||||
|
onEdit?: (flight: Flight) => void;
|
||||||
|
onDelete?: (flight: Flight) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusDotColor(flight: Flight): string {
|
||||||
|
const status = flight.status?.toLowerCase();
|
||||||
|
const delay = flight.arrivalDelay || flight.departureDelay || 0;
|
||||||
|
|
||||||
|
if (status === 'cancelled') return 'bg-red-500';
|
||||||
|
if (status === 'diverted' || status === 'incident') return 'bg-red-500';
|
||||||
|
if (status === 'landed') return 'bg-emerald-500';
|
||||||
|
if (status === 'active') return delay > 15 ? 'bg-amber-500 animate-pulse' : 'bg-purple-500 animate-pulse';
|
||||||
|
if (delay > 30) return 'bg-orange-500';
|
||||||
|
if (delay > 15) return 'bg-amber-500';
|
||||||
|
return 'bg-blue-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlertBanner(flight: Flight): { message: string; color: string } | null {
|
||||||
|
const status = flight.status?.toLowerCase();
|
||||||
|
const delay = Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0);
|
||||||
|
|
||||||
|
if (status === 'cancelled') return { message: 'FLIGHT CANCELLED', color: 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400' };
|
||||||
|
if (status === 'diverted') return { message: 'FLIGHT DIVERTED', color: 'bg-orange-500/10 border-orange-500/30 text-orange-700 dark:text-orange-400' };
|
||||||
|
if (status === 'incident') return { message: 'INCIDENT REPORTED', color: 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400' };
|
||||||
|
if (delay > 60) return { message: `DELAYED ${delay} MINUTES`, color: 'bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400' };
|
||||||
|
if (delay > 30) return { message: `Delayed ${delay} min`, color: 'bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(isoString: string | null): string {
|
||||||
|
if (!isoString) return 'Never';
|
||||||
|
const diff = Date.now() - new Date(isoString).getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
if (minutes < 1) return 'Just now';
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
return `${Math.floor(hours / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const refreshMutation = useRefreshFlight();
|
||||||
|
const alert = getAlertBanner(flight);
|
||||||
|
const dotColor = getStatusDotColor(flight);
|
||||||
|
const isTerminal = ['landed', 'cancelled', 'diverted', 'incident'].includes(flight.status?.toLowerCase() || '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
|
||||||
|
{/* Alert banner */}
|
||||||
|
{alert && (
|
||||||
|
<div className={`px-4 py-1.5 text-xs font-semibold border-b flex items-center gap-2 ${alert.color}`}>
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
|
{alert.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-4 pt-3 pb-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{/* Status dot */}
|
||||||
|
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
|
||||||
|
|
||||||
|
{/* Flight number + airline */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold text-foreground">{flight.flightNumber}</span>
|
||||||
|
{flight.airlineName && (
|
||||||
|
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* VIP name */}
|
||||||
|
{flight.vip && (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
|
<span className="text-muted-foreground/50">|</span>
|
||||||
|
<span className="font-medium text-foreground/80">{flight.vip.name}</span>
|
||||||
|
{flight.vip.partySize > 1 && (
|
||||||
|
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
+{flight.vip.partySize - 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => refreshMutation.mutate(flight.id)}
|
||||||
|
disabled={refreshMutation.isPending}
|
||||||
|
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
title="Refresh from API"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(flight)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||||
|
title="Edit flight"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(flight)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-red-500"
|
||||||
|
title="Delete flight"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="px-4">
|
||||||
|
<FlightProgressBar flight={flight} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer - expandable details */}
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="w-full flex items-center justify-between py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Updated {formatRelativeTime(flight.lastPolledAt)}
|
||||||
|
</span>
|
||||||
|
{flight.pollCount > 0 && (
|
||||||
|
<span>{flight.pollCount} poll{flight.pollCount !== 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-muted text-[10px] uppercase tracking-wider font-medium">
|
||||||
|
{flight.trackingPhase.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="pt-2 pb-1 border-t border-border/50 space-y-2 text-xs">
|
||||||
|
{/* Detailed times */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground mb-1">Departure</div>
|
||||||
|
<div className="space-y-0.5 text-muted-foreground">
|
||||||
|
{flight.scheduledDeparture && <div>Scheduled: {new Date(flight.scheduledDeparture).toLocaleString()}</div>}
|
||||||
|
{flight.estimatedDeparture && <div>Estimated: {new Date(flight.estimatedDeparture).toLocaleString()}</div>}
|
||||||
|
{flight.actualDeparture && <div className="text-foreground">Actual: {new Date(flight.actualDeparture).toLocaleString()}</div>}
|
||||||
|
{flight.departureDelay != null && flight.departureDelay > 0 && (
|
||||||
|
<div className="text-amber-600 dark:text-amber-400">Delay: {flight.departureDelay} min</div>
|
||||||
|
)}
|
||||||
|
{flight.departureTerminal && <div>Terminal: {flight.departureTerminal}</div>}
|
||||||
|
{flight.departureGate && <div>Gate: {flight.departureGate}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground mb-1">Arrival</div>
|
||||||
|
<div className="space-y-0.5 text-muted-foreground">
|
||||||
|
{flight.scheduledArrival && <div>Scheduled: {new Date(flight.scheduledArrival).toLocaleString()}</div>}
|
||||||
|
{flight.estimatedArrival && <div>Estimated: {new Date(flight.estimatedArrival).toLocaleString()}</div>}
|
||||||
|
{flight.actualArrival && <div className="text-foreground">Actual: {new Date(flight.actualArrival).toLocaleString()}</div>}
|
||||||
|
{flight.arrivalDelay != null && flight.arrivalDelay > 0 && (
|
||||||
|
<div className="text-amber-600 dark:text-amber-400">Delay: {flight.arrivalDelay} min</div>
|
||||||
|
)}
|
||||||
|
{flight.arrivalTerminal && <div>Terminal: {flight.arrivalTerminal}</div>}
|
||||||
|
{flight.arrivalGate && <div>Gate: {flight.arrivalGate}</div>}
|
||||||
|
{flight.arrivalBaggage && <div>Baggage: {flight.arrivalBaggage}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aircraft info */}
|
||||||
|
{flight.aircraftType && (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Aircraft: {flight.aircraftType}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
frontend/src/components/FlightProgressBar.tsx
Normal file
244
frontend/src/components/FlightProgressBar.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { useMemo, useEffect, useState } from 'react';
|
||||||
|
import { Plane } from 'lucide-react';
|
||||||
|
import { Flight } from '@/types';
|
||||||
|
|
||||||
|
interface FlightProgressBarProps {
|
||||||
|
flight: Flight;
|
||||||
|
compact?: boolean; // For mini version in dashboard/command center
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateProgress(flight: Flight): number {
|
||||||
|
const status = flight.status?.toLowerCase();
|
||||||
|
|
||||||
|
// Terminal states
|
||||||
|
if (status === 'landed' || flight.actualArrival) return 100;
|
||||||
|
if (status === 'cancelled' || status === 'diverted' || status === 'incident') return 0;
|
||||||
|
|
||||||
|
// Not departed yet
|
||||||
|
if (status === 'scheduled' || (!flight.actualDeparture && !status?.includes('active'))) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In flight - calculate based on time elapsed
|
||||||
|
const departureTime = flight.actualDeparture || flight.estimatedDeparture || flight.scheduledDeparture;
|
||||||
|
const arrivalTime = flight.estimatedArrival || flight.scheduledArrival;
|
||||||
|
|
||||||
|
if (!departureTime || !arrivalTime) return status === 'active' ? 50 : 0;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const dep = new Date(departureTime).getTime();
|
||||||
|
const arr = new Date(arrivalTime).getTime();
|
||||||
|
|
||||||
|
if (now <= dep) return 0;
|
||||||
|
if (now >= arr) return 95; // Past ETA but not confirmed landed
|
||||||
|
|
||||||
|
const totalDuration = arr - dep;
|
||||||
|
const elapsed = now - dep;
|
||||||
|
return Math.min(95, Math.max(5, Math.round((elapsed / totalDuration) * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrackColor(flight: Flight): string {
|
||||||
|
const status = flight.status?.toLowerCase();
|
||||||
|
const delay = flight.arrivalDelay || flight.departureDelay || 0;
|
||||||
|
|
||||||
|
if (status === 'cancelled') return 'bg-red-500';
|
||||||
|
if (status === 'diverted' || status === 'incident') return 'bg-red-500';
|
||||||
|
if (status === 'landed') return delay > 15 ? 'bg-amber-500' : 'bg-emerald-500';
|
||||||
|
if (status === 'active') return delay > 15 ? 'bg-amber-500' : 'bg-purple-500';
|
||||||
|
if (delay > 30) return 'bg-orange-500';
|
||||||
|
if (delay > 15) return 'bg-amber-500';
|
||||||
|
return 'bg-blue-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrackBgColor(flight: Flight): string {
|
||||||
|
const status = flight.status?.toLowerCase();
|
||||||
|
if (status === 'cancelled') return 'bg-red-500/20';
|
||||||
|
if (status === 'diverted' || status === 'incident') return 'bg-red-500/20';
|
||||||
|
if (status === 'landed') return 'bg-emerald-500/20';
|
||||||
|
if (status === 'active') return 'bg-purple-500/20';
|
||||||
|
return 'bg-muted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(isoString: string | null): string {
|
||||||
|
if (!isoString) return '--:--';
|
||||||
|
return new Date(isoString).toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlightProgressBar({ flight, compact = false }: FlightProgressBarProps) {
|
||||||
|
const [progress, setProgress] = useState(() => calculateProgress(flight));
|
||||||
|
const status = flight.status?.toLowerCase();
|
||||||
|
const isActive = status === 'active';
|
||||||
|
const isLanded = status === 'landed' || !!flight.actualArrival;
|
||||||
|
const isCancelled = status === 'cancelled' || status === 'diverted' || status === 'incident';
|
||||||
|
|
||||||
|
// Update progress periodically for active flights
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) {
|
||||||
|
setProgress(calculateProgress(flight));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress(calculateProgress(flight));
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setProgress(calculateProgress(flight));
|
||||||
|
}, 30000); // Update every 30s for active flights
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [flight, isActive]);
|
||||||
|
|
||||||
|
const trackColor = useMemo(() => getTrackColor(flight), [flight]);
|
||||||
|
const trackBgColor = useMemo(() => getTrackBgColor(flight), [flight]);
|
||||||
|
|
||||||
|
const departureTime = flight.actualDeparture || flight.estimatedDeparture || flight.scheduledDeparture;
|
||||||
|
const arrivalTime = flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
|
||||||
|
const hasDelay = (flight.departureDelay || 0) > 0 || (flight.arrivalDelay || 0) > 0;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="font-bold text-foreground">{flight.departureAirport}</span>
|
||||||
|
<div className="flex-1 relative h-1.5 rounded-full overflow-hidden">
|
||||||
|
<div className={`absolute inset-0 ${trackBgColor} rounded-full`} />
|
||||||
|
<div
|
||||||
|
className={`absolute inset-y-0 left-0 ${trackColor} rounded-full transition-all duration-1000`}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
{isActive && (
|
||||||
|
<Plane
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 text-purple-500 transition-all duration-1000"
|
||||||
|
style={{ left: `calc(${progress}% - 6px)` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-foreground">{flight.arrivalAirport}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full py-2">
|
||||||
|
{/* Airport codes and progress track */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Departure airport */}
|
||||||
|
<div className="text-center min-w-[48px]">
|
||||||
|
<div className="text-lg font-bold text-foreground">{flight.departureAirport}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress track */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
{/* Track background */}
|
||||||
|
<div className={`h-2 rounded-full ${trackBgColor} relative overflow-visible`}>
|
||||||
|
{/* Filled progress */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-y-0 left-0 rounded-full ${trackColor} transition-all duration-1000 ease-in-out`}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Departure dot */}
|
||||||
|
<div className={`absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full border-2 border-background ${progress > 0 ? trackColor : 'bg-muted-foreground/40'}`} />
|
||||||
|
|
||||||
|
{/* Arrival dot */}
|
||||||
|
<div className={`absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-3 h-3 rounded-full border-2 border-background ${progress >= 100 ? trackColor : 'bg-muted-foreground/40'}`} />
|
||||||
|
|
||||||
|
{/* Airplane icon */}
|
||||||
|
{!isCancelled && (
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 transition-all duration-1000 ease-in-out z-10"
|
||||||
|
style={{ left: `${Math.max(2, Math.min(98, progress))}%`, transform: `translateX(-50%) translateY(-50%)` }}
|
||||||
|
>
|
||||||
|
<div className={`${isActive ? 'animate-bounce-subtle' : ''}`}>
|
||||||
|
<Plane
|
||||||
|
className={`w-5 h-5 ${
|
||||||
|
isLanded ? 'text-emerald-500' :
|
||||||
|
isActive ? 'text-purple-500' :
|
||||||
|
'text-muted-foreground'
|
||||||
|
} drop-shadow-sm`}
|
||||||
|
style={{ transform: 'rotate(0deg)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cancelled X */}
|
||||||
|
{isCancelled && (
|
||||||
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-red-500 font-bold text-sm">
|
||||||
|
✕
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrival airport */}
|
||||||
|
<div className="text-center min-w-[48px]">
|
||||||
|
<div className="text-lg font-bold text-foreground">{flight.arrivalAirport}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time and detail row */}
|
||||||
|
<div className="flex justify-between mt-2 text-xs">
|
||||||
|
{/* Departure details */}
|
||||||
|
<div className="text-left">
|
||||||
|
{departureTime && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{hasDelay && flight.scheduledDeparture && flight.scheduledDeparture !== departureTime ? (
|
||||||
|
<>
|
||||||
|
<span className="line-through text-muted-foreground">{formatTime(flight.scheduledDeparture)}</span>
|
||||||
|
<span className="text-amber-600 dark:text-amber-400 font-medium">{formatTime(departureTime)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{formatTime(departureTime)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(flight.departureTerminal || flight.departureGate) && (
|
||||||
|
<div className="text-muted-foreground mt-0.5">
|
||||||
|
{flight.departureTerminal && <span>T{flight.departureTerminal}</span>}
|
||||||
|
{flight.departureTerminal && flight.departureGate && <span> </span>}
|
||||||
|
{flight.departureGate && <span>Gate {flight.departureGate}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: flight duration or status */}
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
{isActive && flight.aircraftType && (
|
||||||
|
<span>{flight.aircraftType}</span>
|
||||||
|
)}
|
||||||
|
{isLanded && <span className="text-emerald-600 dark:text-emerald-400 font-medium">Landed</span>}
|
||||||
|
{isCancelled && <span className="text-red-600 dark:text-red-400 font-medium capitalize">{status}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrival details */}
|
||||||
|
<div className="text-right">
|
||||||
|
{arrivalTime && (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{hasDelay && flight.scheduledArrival && flight.scheduledArrival !== arrivalTime ? (
|
||||||
|
<>
|
||||||
|
<span className="line-through text-muted-foreground">{formatTime(flight.scheduledArrival)}</span>
|
||||||
|
<span className="text-amber-600 dark:text-amber-400 font-medium">{formatTime(arrivalTime)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{isLanded ? '' : 'ETA '}{formatTime(arrivalTime)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && (
|
||||||
|
<div className="text-muted-foreground mt-0.5">
|
||||||
|
{flight.arrivalTerminal && <span>T{flight.arrivalTerminal}</span>}
|
||||||
|
{flight.arrivalGate && <span> Gate {flight.arrivalGate}</span>}
|
||||||
|
{flight.arrivalBaggage && <span> Bag {flight.arrivalBaggage}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
frontend/src/hooks/useFlights.ts
Normal file
65
frontend/src/hooks/useFlights.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { Flight, FlightBudget } from '@/types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export function useFlights() {
|
||||||
|
return useQuery<Flight[]>({
|
||||||
|
queryKey: ['flights'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/flights');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
refetchInterval: 60000, // Refresh from DB every 60s (free, no API cost)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFlightBudget() {
|
||||||
|
return useQuery<FlightBudget>({
|
||||||
|
queryKey: ['flights', 'budget'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/flights/tracking/budget');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
refetchInterval: 300000, // Every 5 min
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRefreshFlight() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (flightId: string) => {
|
||||||
|
const { data } = await api.post(`/flights/${flightId}/refresh`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['flights'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['flights', 'budget'] });
|
||||||
|
const status = data.status || 'unknown';
|
||||||
|
toast.success(`Flight updated: ${data.flightNumber} (${status})`);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || 'Failed to refresh flight');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRefreshActiveFlights() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } = await api.post('/flights/refresh-active');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['flights'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['flights', 'budget'] });
|
||||||
|
toast.success(`Refreshed ${data.refreshed} flights (${data.budgetRemaining} API calls remaining)`);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || 'Failed to refresh flights');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -80,8 +80,17 @@ interface VIP {
|
|||||||
id: string;
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
|
||||||
segment: number;
|
function groupFlights(flights: Flight[]): FlightGroup[] {
|
||||||
departureAirport: string;
|
const now = new Date();
|
||||||
arrivalAirport: string;
|
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||||
scheduledDeparture: string | null;
|
const fourHoursFromNow = new Date(now.getTime() + 4 * 60 * 60 * 1000);
|
||||||
scheduledArrival: string | null;
|
|
||||||
actualDeparture: string | null;
|
const groups: FlightGroup[] = [
|
||||||
actualArrival: string | null;
|
{ key: 'alerts', label: 'Alerts', icon: AlertTriangle, flights: [], color: 'text-red-500' },
|
||||||
status: string | null;
|
{ key: 'arriving', label: 'Arriving Soon', icon: Plane, flights: [], color: 'text-purple-500' },
|
||||||
|
{ key: 'active', label: 'In Flight', icon: Plane, flights: [], color: 'text-purple-500' },
|
||||||
|
{ key: 'departing', label: 'Departing Soon', icon: Clock, flights: [], color: 'text-blue-500' },
|
||||||
|
{ key: 'scheduled', label: 'Scheduled', icon: Clock, flights: [], color: 'text-muted-foreground' },
|
||||||
|
{ key: 'completed', label: 'Completed', icon: Plane, flights: [], color: 'text-emerald-500', defaultCollapsed: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const flight of flights) {
|
||||||
|
const status = flight.status?.toLowerCase();
|
||||||
|
const eta = flight.estimatedArrival || flight.scheduledArrival;
|
||||||
|
const departure = flight.estimatedDeparture || flight.scheduledDeparture;
|
||||||
|
|
||||||
|
// Alerts: cancelled, diverted, incident
|
||||||
|
if (status === 'cancelled' || status === 'diverted' || status === 'incident') {
|
||||||
|
groups[0].flights.push(flight);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completed: landed
|
||||||
|
if (status === 'landed' || flight.actualArrival) {
|
||||||
|
groups[5].flights.push(flight);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arriving soon: active flight landing within 2h
|
||||||
|
if (status === 'active' && eta && new Date(eta) <= twoHoursFromNow) {
|
||||||
|
groups[1].flights.push(flight);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In flight: active
|
||||||
|
if (status === 'active') {
|
||||||
|
groups[2].flights.push(flight);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Departing soon: departure within 4h
|
||||||
|
if (departure && new Date(departure) <= fourHoursFromNow && new Date(departure) >= now) {
|
||||||
|
groups[3].flights.push(flight);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else is scheduled
|
||||||
|
groups[4].flights.push(flight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort within groups
|
||||||
|
groups[0].flights.sort((a, b) => (b.arrivalDelay || 0) - (a.arrivalDelay || 0)); // Worst first
|
||||||
|
groups[1].flights.sort((a, b) => {
|
||||||
|
const etaA = a.estimatedArrival || a.scheduledArrival || '';
|
||||||
|
const etaB = b.estimatedArrival || b.scheduledArrival || '';
|
||||||
|
return etaA.localeCompare(etaB);
|
||||||
|
});
|
||||||
|
groups[2].flights.sort((a, b) => {
|
||||||
|
const etaA = a.estimatedArrival || a.scheduledArrival || '';
|
||||||
|
const etaB = b.estimatedArrival || b.scheduledArrival || '';
|
||||||
|
return etaA.localeCompare(etaB);
|
||||||
|
});
|
||||||
|
groups[3].flights.sort((a, b) => {
|
||||||
|
const depA = a.estimatedDeparture || a.scheduledDeparture || '';
|
||||||
|
const depB = b.estimatedDeparture || b.scheduledDeparture || '';
|
||||||
|
return depA.localeCompare(depB);
|
||||||
|
});
|
||||||
|
groups[4].flights.sort((a, b) => {
|
||||||
|
const depA = a.scheduledDeparture || a.flightDate;
|
||||||
|
const depB = b.scheduledDeparture || b.flightDate;
|
||||||
|
return depA.localeCompare(depB);
|
||||||
|
});
|
||||||
|
groups[5].flights.sort((a, b) => {
|
||||||
|
const arrA = a.actualArrival || a.scheduledArrival || '';
|
||||||
|
const arrB = b.actualArrival || b.scheduledArrival || '';
|
||||||
|
return arrB.localeCompare(arrA); // Most recent first
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BudgetIndicator() {
|
||||||
|
const { data: budget } = useFlightBudget();
|
||||||
|
if (!budget) return null;
|
||||||
|
|
||||||
|
const percent = Math.round((budget.used / budget.limit) * 100);
|
||||||
|
const barColor = percent > 80 ? 'bg-red-500' : percent > 50 ? 'bg-amber-500' : 'bg-emerald-500';
|
||||||
|
const textColor = percent > 80 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2 bg-muted/50 rounded-lg border border-border">
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className={`font-medium ${textColor}`}>{budget.remaining}</span>
|
||||||
|
<span className="text-muted-foreground">/{budget.limit} API calls</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${barColor} rounded-full transition-all`}
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FlightList() {
|
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user