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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,8 +80,17 @@ interface VIP {
id: string;
flightNumber: string;
arrivalAirport: string;
departureAirport: string;
scheduledArrival: string | null;
estimatedArrival: string | null;
actualArrival: string | null;
arrivalDelay: number | null;
departureDelay: number | null;
arrivalTerminal: string | null;
arrivalGate: string | null;
arrivalBaggage: string | null;
status: string | null;
airlineName: string | null;
}>;
}
@@ -184,6 +193,14 @@ export function CommandCenter() {
},
});
const { data: flights } = useQuery<VIP['flights']>({
queryKey: ['flights'],
queryFn: async () => {
const { data } = await api.get('/flights');
return data;
},
});
// Compute awaiting confirmation BEFORE any conditional returns (for hooks)
const now = currentTime;
const awaitingConfirmation = (events || []).filter((event) => {
@@ -312,7 +329,10 @@ export function CommandCenter() {
return start <= fifteenMinutes;
});
// Upcoming arrivals (next 4 hours)
// Upcoming arrivals (next 4 hours) - use best available time (estimated > scheduled)
const getFlightArrivalTime = (flight: VIP['flights'][0]) =>
flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
const upcomingArrivals = vips
.filter((vip) => {
if (vip.expectedArrival) {
@@ -320,16 +340,17 @@ export function CommandCenter() {
return arrival > now && arrival <= fourHoursLater;
}
return vip.flights.some((flight) => {
if (flight.scheduledArrival) {
const arrival = new Date(flight.scheduledArrival);
const arrTime = getFlightArrivalTime(flight);
if (arrTime && flight.status?.toLowerCase() !== 'cancelled') {
const arrival = new Date(arrTime);
return arrival > now && arrival <= fourHoursLater;
}
return false;
});
})
.sort((a, b) => {
const aTime = a.expectedArrival || a.flights[0]?.scheduledArrival || '';
const bTime = b.expectedArrival || b.flights[0]?.scheduledArrival || '';
const aTime = a.expectedArrival || getFlightArrivalTime(a.flights[0]) || '';
const bTime = b.expectedArrival || getFlightArrivalTime(b.flights[0]) || '';
return new Date(aTime).getTime() - new Date(bTime).getTime();
});
@@ -414,6 +435,45 @@ export function CommandCenter() {
});
}
// Flight alerts: cancelled, diverted, or significantly delayed
if (flights) {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date(todayStart);
todayEnd.setDate(todayEnd.getDate() + 1);
flights.forEach((flight: any) => {
const arrivalTime = flight.estimatedArrival || flight.scheduledArrival;
if (!arrivalTime) return;
const arrDate = new Date(arrivalTime);
if (arrDate < todayStart || arrDate > todayEnd) return;
const status = flight.status?.toLowerCase();
const vipName = flight.vip?.name || 'Unknown VIP';
const delay = Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0);
if (status === 'cancelled') {
alerts.push({
type: 'critical',
message: `${flight.flightNumber} (${vipName}): FLIGHT CANCELLED`,
link: '/flights',
});
} else if (status === 'diverted') {
alerts.push({
type: 'critical',
message: `${flight.flightNumber} (${vipName}): FLIGHT DIVERTED`,
link: '/flights',
});
} else if (delay > 30) {
alerts.push({
type: 'warning',
message: `${flight.flightNumber} (${vipName}): Delayed ${delay} minutes`,
link: '/flights',
});
}
});
}
// Get time until event
function getTimeUntil(dateStr: string) {
const eventTime = new Date(dateStr);
@@ -827,9 +887,9 @@ export function CommandCenter() {
</div>
{/* Bottom Row: Resources */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
{/* VIP Arrivals */}
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden col-span-2">
<div className="bg-purple-600 px-3 py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Plane className="h-4 w-4 text-white" />
@@ -841,7 +901,7 @@ export function CommandCenter() {
ref={arrivalsScrollRef}
onWheel={handleUserInteraction}
onTouchMove={handleUserInteraction}
className="max-h-[140px] overflow-y-auto scrollbar-hide"
className="max-h-[180px] overflow-y-auto scrollbar-hide"
>
{upcomingArrivals.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
@@ -850,16 +910,79 @@ export function CommandCenter() {
) : (
<div className="divide-y divide-border">
{upcomingArrivals.map((vip) => {
const arrival = vip.expectedArrival || vip.flights[0]?.scheduledArrival;
const flight = vip.flights[0];
const flight = vip.flights.find(f => f.status?.toLowerCase() !== 'cancelled') || vip.flights[0];
const arrival = vip.expectedArrival || (flight && getFlightArrivalTime(flight));
const delay = flight ? Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0) : 0;
const flightStatus = flight?.status?.toLowerCase();
const isCancelled = flightStatus === 'cancelled';
const isActive = flightStatus === 'active';
const isLanded = flightStatus === 'landed' || !!flight?.actualArrival;
// Color-code: green (on time / landed), amber (delayed), red (cancelled), purple (in flight)
const timeColor = isCancelled
? 'text-red-600 dark:text-red-400'
: isLanded
? 'text-emerald-600 dark:text-emerald-400'
: delay > 15
? 'text-amber-600 dark:text-amber-400'
: isActive
? 'text-purple-600 dark:text-purple-400'
: 'text-blue-600 dark:text-blue-400';
const borderColor = isCancelled
? 'border-l-red-500'
: delay > 30
? 'border-l-amber-500'
: isActive
? 'border-l-purple-500'
: isLanded
? 'border-l-emerald-500'
: 'border-l-blue-500';
return (
<div key={vip.id} className="px-3 py-2">
<div key={vip.id} className={`px-3 py-2 border-l-4 ${borderColor}`}>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<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>
<p className="text-xs font-bold text-purple-600 dark:text-purple-400">{getTimeUntil(arrival!)}</p>
</div>
</div>
);

View File

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

View File

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

View File

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

View File

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