2 Commits

Author SHA1 Message Date
0f0f1cbf38 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>
2026-02-07 19:42:52 +01:00
74a292ea93 feat: add Help page with search, streamline copilot, misc UI fixes
Adds searchable Help/User Guide page, trims copilot tool bloat,
adds OTHER department option, and various form/layout improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 19:42:39 +01:00
46 changed files with 3503 additions and 778 deletions

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Department" ADD VALUE 'OTHER';

View File

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

View File

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

View File

@@ -139,6 +139,10 @@ async function main() {
airportPickup: true, airportPickup: true,
venueTransport: true, venueTransport: true,
partySize: 3, // Roger + 2 handlers partySize: 3, // Roger + 2 handlers
phone: '+1 (202) 555-0140',
email: 'roger.mosby@scouting.org',
emergencyContactName: 'Linda Mosby',
emergencyContactPhone: '+1 (202) 555-0141',
notes: 'Chief Scout Executive. Travels with 2 staff handlers. Requires accessible vehicle.', notes: 'Chief Scout Executive. Travels with 2 staff handlers. Requires accessible vehicle.',
flights: { flights: {
create: [ create: [
@@ -167,6 +171,10 @@ async function main() {
airportPickup: true, airportPickup: true,
venueTransport: true, venueTransport: true,
partySize: 2, // Patricia + spouse partySize: 2, // Patricia + spouse
phone: '+1 (404) 555-0230',
email: 'patricia.hawkins@bsaboard.org',
emergencyContactName: 'Richard Hawkins',
emergencyContactPhone: '+1 (404) 555-0231',
notes: 'National Board Chair. Traveling with husband (Richard). Both attend all events.', notes: 'National Board Chair. Traveling with husband (Richard). Both attend all events.',
flights: { flights: {
create: [ create: [
@@ -195,6 +203,10 @@ async function main() {
airportPickup: true, airportPickup: true,
venueTransport: true, venueTransport: true,
partySize: 1, // Solo partySize: 1, // Solo
phone: '+1 (214) 555-0375',
email: 'jwhitfield@whitfieldfoundation.org',
emergencyContactName: 'Catherine Whitfield',
emergencyContactPhone: '+1 (214) 555-0376',
notes: 'Major donor ($2M+). Eagle Scout class of 1978. Very punctual — do not be late.', notes: 'Major donor ($2M+). Eagle Scout class of 1978. Very punctual — do not be late.',
flights: { flights: {
create: [ create: [
@@ -223,6 +235,10 @@ async function main() {
airportPickup: true, airportPickup: true,
venueTransport: true, venueTransport: true,
partySize: 2, // Dr. Baker + assistant partySize: 2, // Dr. Baker + assistant
phone: '+1 (301) 555-0488',
email: 'abaker@natgeo.com',
emergencyContactName: 'Marcus Webb',
emergencyContactPhone: '+1 (301) 555-0489',
notes: 'Keynote speaker, Day 1. Traveling with assistant (Marcus). Needs quiet space before keynote.', notes: 'Keynote speaker, Day 1. Traveling with assistant (Marcus). Needs quiet space before keynote.',
flights: { flights: {
create: [ create: [
@@ -252,6 +268,10 @@ async function main() {
airportPickup: false, airportPickup: false,
venueTransport: true, venueTransport: true,
partySize: 4, // Governor + security officer + aide + driver (their own driver stays) partySize: 4, // Governor + security officer + aide + driver (their own driver stays)
phone: '+1 (303) 555-0100',
email: 'gov.martinez@state.co.us',
emergencyContactName: 'Elena Martinez',
emergencyContactPhone: '+1 (303) 555-0101',
notes: 'Governor arriving by motorcade. Party of 4: Gov, 1 state trooper, 1 aide, 1 advance staff. Their driver does NOT need a seat.', notes: 'Governor arriving by motorcade. Party of 4: Gov, 1 state trooper, 1 aide, 1 advance staff. Their driver does NOT need a seat.',
}, },
}); });
@@ -267,6 +287,10 @@ async function main() {
airportPickup: false, airportPickup: false,
venueTransport: true, venueTransport: true,
partySize: 1, partySize: 1,
phone: '+1 (720) 555-0550',
email: 'somalley@denvercouncil.org',
emergencyContactName: 'Patrick O\'Malley',
emergencyContactPhone: '+1 (720) 555-0551',
notes: 'Local council president. Knows the venue well. Can help with directions if needed.', notes: 'Local council president. Knows the venue well. Can help with directions if needed.',
}, },
}); });

View File

@@ -31,7 +31,7 @@ export class CopilotService {
properties: { properties: {
name: { type: 'string', description: 'VIP name to search for (partial match)' }, name: { type: 'string', description: 'VIP name to search for (partial match)' },
organization: { type: 'string', description: 'Organization name to filter by' }, organization: { type: 'string', description: 'Organization name to filter by' },
department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN'], description: 'Department to filter by' }, department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], description: 'Department to filter by' },
arrivalMode: { type: 'string', enum: ['FLIGHT', 'SELF_DRIVING'], description: 'Arrival mode to filter by' }, arrivalMode: { type: 'string', enum: ['FLIGHT', 'SELF_DRIVING'], description: 'Arrival mode to filter by' },
}, },
required: [], required: [],
@@ -55,7 +55,7 @@ export class CopilotService {
type: 'object' as const, type: 'object' as const,
properties: { properties: {
name: { type: 'string', description: 'Driver name to search for' }, name: { type: 'string', description: 'Driver name to search for' },
department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN'], description: 'Department to filter by' }, department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], description: 'Department to filter by' },
availableOnly: { type: 'boolean', description: 'Only return available drivers' }, availableOnly: { type: 'boolean', description: 'Only return available drivers' },
}, },
required: [], required: [],
@@ -240,31 +240,43 @@ export class CopilotService {
properties: { properties: {
name: { type: 'string', description: 'VIP full name' }, name: { type: 'string', description: 'VIP full name' },
organization: { type: 'string', description: 'Organization/company name' }, organization: { type: 'string', description: 'Organization/company name' },
department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN'], description: 'Department' }, department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], description: 'Department' },
arrivalMode: { type: 'string', enum: ['FLIGHT', 'SELF_DRIVING'], description: 'How VIP will arrive' }, arrivalMode: { type: 'string', enum: ['FLIGHT', 'SELF_DRIVING'], description: 'How VIP will arrive' },
expectedArrival: { type: 'string', description: 'Expected arrival time for self-driving (ISO format)' }, expectedArrival: { type: 'string', description: 'Expected arrival time for self-driving (ISO format)' },
airportPickup: { type: 'boolean', description: 'Whether VIP needs airport pickup' }, airportPickup: { type: 'boolean', description: 'Whether VIP needs airport pickup' },
venueTransport: { type: 'boolean', description: 'Whether VIP needs venue transport' }, venueTransport: { type: 'boolean', description: 'Whether VIP needs venue transport' },
partySize: { type: 'number', description: 'Total party size including VIP plus companions/entourage (default 1)' },
notes: { type: 'string', description: 'Additional notes about the VIP' }, notes: { type: 'string', description: 'Additional notes about the VIP' },
isRosterOnly: { type: 'boolean', description: 'True if VIP is roster-only (accountability tracking, no active transport coordination)' },
phone: { type: 'string', description: 'VIP phone number' },
email: { type: 'string', description: 'VIP email address' },
emergencyContactName: { type: 'string', description: 'Emergency contact name' },
emergencyContactPhone: { type: 'string', description: 'Emergency contact phone' },
}, },
required: ['name', 'department', 'arrivalMode'], required: ['name', 'department', 'arrivalMode'],
}, },
}, },
{ {
name: 'update_vip', name: 'update_vip',
description: 'Update VIP information.', description: 'Update VIP information including party size, contact info, and roster status.',
input_schema: { input_schema: {
type: 'object' as const, type: 'object' as const,
properties: { properties: {
vipId: { type: 'string', description: 'The VIP ID to update' }, vipId: { type: 'string', description: 'The VIP ID to update' },
name: { type: 'string', description: 'New name' }, name: { type: 'string', description: 'New name' },
organization: { type: 'string', description: 'New organization' }, organization: { type: 'string', description: 'New organization' },
department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN'], description: 'New department' }, department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], description: 'New department' },
arrivalMode: { type: 'string', enum: ['FLIGHT', 'SELF_DRIVING'], description: 'New arrival mode' }, arrivalMode: { type: 'string', enum: ['FLIGHT', 'SELF_DRIVING'], description: 'New arrival mode' },
expectedArrival: { type: 'string', description: 'New expected arrival time' }, expectedArrival: { type: 'string', description: 'New expected arrival time' },
airportPickup: { type: 'boolean', description: 'Whether VIP needs airport pickup' }, airportPickup: { type: 'boolean', description: 'Whether VIP needs airport pickup' },
venueTransport: { type: 'boolean', description: 'Whether VIP needs venue transport' }, venueTransport: { type: 'boolean', description: 'Whether VIP needs venue transport' },
partySize: { type: 'number', description: 'Total party size including VIP plus companions/entourage' },
notes: { type: 'string', description: 'New notes' }, notes: { type: 'string', description: 'New notes' },
isRosterOnly: { type: 'boolean', description: 'True if VIP is roster-only (no active transport coordination)' },
phone: { type: 'string', description: 'VIP phone number' },
email: { type: 'string', description: 'VIP email address' },
emergencyContactName: { type: 'string', description: 'Emergency contact name' },
emergencyContactPhone: { type: 'string', description: 'Emergency contact phone' },
}, },
required: ['vipId'], required: ['vipId'],
}, },
@@ -290,7 +302,7 @@ export class CopilotService {
driverId: { type: 'string', description: 'The driver ID to update' }, driverId: { type: 'string', description: 'The driver ID to update' },
name: { type: 'string', description: 'New name' }, name: { type: 'string', description: 'New name' },
phone: { type: 'string', description: 'New phone number' }, phone: { type: 'string', description: 'New phone number' },
department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN'], description: 'New department' }, department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], description: 'New department' },
isAvailable: { type: 'boolean', description: 'Whether driver is available' }, isAvailable: { type: 'boolean', description: 'Whether driver is available' },
shiftStartTime: { type: 'string', description: 'Shift start time (HH:MM format)' }, shiftStartTime: { type: 'string', description: 'Shift start time (HH:MM format)' },
shiftEndTime: { type: 'string', description: 'Shift end time (HH:MM format)' }, shiftEndTime: { type: 'string', description: 'Shift end time (HH:MM format)' },
@@ -358,7 +370,7 @@ export class CopilotService {
properties: { properties: {
startTime: { type: 'string', description: 'Start time of the time range (ISO format)' }, startTime: { type: 'string', description: 'Start time of the time range (ISO format)' },
endTime: { type: 'string', description: 'End time of the time range (ISO format)' }, endTime: { type: 'string', description: 'End time of the time range (ISO format)' },
preferredDepartment: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN'], description: 'Optional: filter by department' }, preferredDepartment: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], description: 'Optional: filter by department' },
}, },
required: ['startTime', 'endTime'], required: ['startTime', 'endTime'],
}, },
@@ -491,71 +503,16 @@ export class CopilotService {
required: ['startDate', 'endDate'], required: ['startDate', 'endDate'],
}, },
}, },
// ==================== SELF-AWARENESS / HELP TOOLS ==================== // ==================== SYSTEM STATUS ====================
{
name: 'get_my_capabilities',
description: 'Get a comprehensive list of all available tools and capabilities. Use this when the user asks "what can you do?" or when you need to understand your own capabilities. Returns tools organized by category with usage examples.',
input_schema: {
type: 'object' as const,
properties: {
category: {
type: 'string',
enum: ['all', 'search', 'create', 'update', 'delete', 'communication', 'analytics', 'scheduling'],
description: 'Filter by category (optional, defaults to all)'
},
},
required: [],
},
},
{
name: 'get_workflow_guide',
description: 'Get step-by-step guidance for common VIP coordination tasks. Use this when you need to understand how to accomplish a complex task or when the user asks "how do I...?" questions.',
input_schema: {
type: 'object' as const,
properties: {
task: {
type: 'string',
enum: [
'schedule_airport_pickup',
'reassign_driver',
'handle_flight_delay',
'create_vip_itinerary',
'morning_briefing',
'send_driver_notifications',
'check_schedule_problems',
'vehicle_assignment',
'bulk_schedule_update'
],
description: 'The task to get guidance for'
},
},
required: ['task'],
},
},
{ {
name: 'get_current_system_status', name: 'get_current_system_status',
description: 'Get a quick status overview of the entire VIP Coordinator system - counts of VIPs, drivers, vehicles, upcoming events, and any immediate issues. Use this to understand the current state of operations.', description: 'Get system overview: VIP/driver/vehicle counts, today\'s events, alerts.',
input_schema: { input_schema: {
type: 'object' as const, type: 'object' as const,
properties: {}, properties: {},
required: [], required: [],
}, },
}, },
{
name: 'get_api_documentation',
description: 'Get documentation for the VIP Coordinator REST API endpoints. Use this when the user asks about API capabilities, how to integrate, or what endpoints are available. Shows all HTTP endpoints with methods, paths, and descriptions.',
input_schema: {
type: 'object' as const,
properties: {
resource: {
type: 'string',
enum: ['all', 'auth', 'users', 'vips', 'drivers', 'events', 'vehicles', 'flights', 'signal'],
description: 'Filter by resource type (optional, defaults to all)'
},
},
required: [],
},
},
]; ];
constructor( constructor(
@@ -662,37 +619,31 @@ User role: ${userRole}
## Your capabilities: ## Your capabilities:
- Search and retrieve information about VIPs, drivers, vehicles, events, and flights - Search and retrieve information about VIPs, drivers, vehicles, events, and flights
- CREATE new VIPs, events, and flights - CREATE new VIPs, events, and flights
- UPDATE existing events, flights, VIP info, and driver assignments - UPDATE existing events, flights, VIP info (including party size, contact info, roster status), and driver assignments
- DELETE events and flights that are no longer needed - DELETE events and flights that are no longer needed
- Assign/reassign drivers and vehicles to events - Assign/reassign drivers and vehicles to events
- Check for scheduling conflicts and identify gaps - Check for scheduling conflicts and identify gaps
- Get VIP itineraries and driver manifests - Get VIP itineraries and driver manifests
- **BULK REASSIGN** events from one driver to another by NAME - Bulk reassign events, send Signal messages/schedules, find available drivers
- **SEND MESSAGES** to drivers via Signal - Suggest vehicles, audit schedules, analyze workloads
- **SEND SCHEDULES** to drivers (PDF/ICS via Signal)
- **FIND AVAILABLE DRIVERS** for specific time ranges
- **SUGGEST VEHICLES** based on capacity and availability
- **AUDIT SCHEDULES** for conflicts, unassigned events, and capacity issues
- **WORKLOAD ANALYSIS** for driver utilization
## IMPORTANT: Use the right tool for the job ## IMPORTANT: Use the right tool for the job
- To MODIFY an existing event → use update_event (don't create a new one) - To MODIFY an existing event → use update_event
- To REMOVE an event → use delete_event - To REMOVE an event → use delete_event
- To CHANGE a flight → use update_flight - To CHANGE a flight → use update_flight; ADD a flight → create_flight
- To ADD a flight → use create_flight - To ASSIGN a driver → assign_driver_to_event; vehicle → assign_vehicle_to_event
- To ASSIGN a driver → use assign_driver_to_event - To REASSIGN events between driversreassign_driver_events (by NAME)
- To ASSIGN a vehicle → use assign_vehicle_to_event - To UPDATE VIP party size, contacts, or roster status → use update_vip
- **To REASSIGN all events from one driver to another**use reassign_driver_events (works by NAME, no IDs needed!) - To SEND a message to a driver → send_driver_notification_via_signal
- **To SEND a message to a driver** → use send_driver_notification_via_signal - To SEND schedules → bulk_send_driver_schedules
- **To SEND schedules to drivers**use bulk_send_driver_schedules - To FIND available drivers → find_available_drivers_for_timerange
- **To FIND available drivers** → use find_available_drivers_for_timerange - To AUDIT schedule → identify_scheduling_gaps
- **To GET a driver's daily schedule** → use get_daily_driver_manifest - To SUGGEST vehicles → suggest_vehicle_for_event
- **To FIND unassigned events** → use find_unassigned_events
- **To CHECK for VIP conflicts** → use check_vip_conflicts ## Party size and companions:
- **To AUDIT the schedule** → use identify_scheduling_gaps - partySize = the VIP + all companions/handlers/entourage
- **To SUGGEST vehicles**use suggest_vehicle_for_event - If user says "add 20 companions" → set partySize to 21 (VIP + 20)
- **To GET vehicle schedule** → use get_vehicle_schedule - Always use update_vip with partySize to change this, not notes
- **To ANALYZE workload** → use get_driver_workload_summary
## CRITICAL: Never ask for IDs - use names! ## CRITICAL: Never ask for IDs - use names!
- You can search for drivers, VIPs, vehicles, and events by NAME - You can search for drivers, VIPs, vehicles, and events by NAME
@@ -703,8 +654,12 @@ User role: ${userRole}
## For actions that MODIFY data (create, update, delete): ## For actions that MODIFY data (create, update, delete):
1. First, search to find the relevant records (use names, not IDs) 1. First, search to find the relevant records (use names, not IDs)
2. Clearly state what changes you're proposing 2. Clearly state what changes you're proposing
3. Ask for confirmation before executing 3. For BULK operations (updating many records, reassigning multiple events, etc.):
4. After execution, show a summary of what was changed - Tell the user upfront: "This is a larger task - I'll be updating X records. Give me a moment to work through them all."
- Then proceed immediately with all the tool calls WITHOUT waiting for confirmation
- Summarize all changes at the end
4. For single-record changes, ask for confirmation before executing
5. After execution, show a summary of what was changed
## When reassigning a driver's events (driver sick, swapping schedules, etc.): ## When reassigning a driver's events (driver sick, swapping schedules, etc.):
1. Use reassign_driver_events with the FROM and TO driver names 1. Use reassign_driver_events with the FROM and TO driver names
@@ -801,14 +756,8 @@ User role: ${userRole}
return await this.getVehicleSchedule(input); return await this.getVehicleSchedule(input);
case 'get_driver_workload_summary': case 'get_driver_workload_summary':
return await this.getDriverWorkloadSummary(input); return await this.getDriverWorkloadSummary(input);
case 'get_my_capabilities':
return await this.getMyCapabilities(input);
case 'get_workflow_guide':
return await this.getWorkflowGuide(input);
case 'get_current_system_status': case 'get_current_system_status':
return await this.getCurrentSystemStatus(); return await this.getCurrentSystemStatus();
case 'get_api_documentation':
return await this.getApiDocumentation(input);
default: default:
return { success: false, error: `Unknown tool: ${name}` }; return { success: false, error: `Unknown tool: ${name}` };
} }
@@ -1344,7 +1293,13 @@ User role: ${userRole}
expectedArrival: input.expectedArrival ? new Date(input.expectedArrival) : null, expectedArrival: input.expectedArrival ? new Date(input.expectedArrival) : null,
airportPickup: input.airportPickup ?? false, airportPickup: input.airportPickup ?? false,
venueTransport: input.venueTransport ?? false, venueTransport: input.venueTransport ?? false,
partySize: input.partySize ?? 1,
notes: input.notes, notes: input.notes,
isRosterOnly: input.isRosterOnly ?? false,
phone: input.phone || null,
email: input.email || null,
emergencyContactName: input.emergencyContactName || null,
emergencyContactPhone: input.emergencyContactPhone || null,
}, },
}); });
@@ -1362,7 +1317,13 @@ User role: ${userRole}
if (updateData.expectedArrival !== undefined) data.expectedArrival = updateData.expectedArrival ? new Date(updateData.expectedArrival) : null; if (updateData.expectedArrival !== undefined) data.expectedArrival = updateData.expectedArrival ? new Date(updateData.expectedArrival) : null;
if (updateData.airportPickup !== undefined) data.airportPickup = updateData.airportPickup; if (updateData.airportPickup !== undefined) data.airportPickup = updateData.airportPickup;
if (updateData.venueTransport !== undefined) data.venueTransport = updateData.venueTransport; if (updateData.venueTransport !== undefined) data.venueTransport = updateData.venueTransport;
if (updateData.partySize !== undefined) data.partySize = updateData.partySize;
if (updateData.notes !== undefined) data.notes = updateData.notes; if (updateData.notes !== undefined) data.notes = updateData.notes;
if (updateData.isRosterOnly !== undefined) data.isRosterOnly = updateData.isRosterOnly;
if (updateData.phone !== undefined) data.phone = updateData.phone || null;
if (updateData.email !== undefined) data.email = updateData.email || null;
if (updateData.emergencyContactName !== undefined) data.emergencyContactName = updateData.emergencyContactName || null;
if (updateData.emergencyContactPhone !== undefined) data.emergencyContactPhone = updateData.emergencyContactPhone || null;
const vip = await this.prisma.vIP.update({ const vip = await this.prisma.vIP.update({
where: { id: vipId }, where: { id: vipId },
@@ -2711,223 +2672,6 @@ User role: ${userRole}
}; };
} }
// ==================== SELF-AWARENESS / HELP TOOLS ====================
private async getMyCapabilities(input: Record<string, any>): Promise<ToolResult> {
const category = input.category || 'all';
const capabilities = {
search: {
description: 'Find and lookup information',
tools: [
{ name: 'search_vips', description: 'Search VIPs by name, organization, department', example: 'Find VIP named John' },
{ name: 'search_drivers', description: 'Search drivers by name, department, availability', example: 'Find available drivers' },
{ name: 'search_events', description: 'Search events by VIP, driver, date, status', example: 'Events for tomorrow' },
{ name: 'get_vip_details', description: 'Get full VIP info with flights and events', example: 'Details for VIP John Smith' },
{ name: 'get_vip_itinerary', description: 'Get complete VIP itinerary', example: 'Itinerary for Dr. Martinez' },
{ name: 'list_all_drivers', description: 'List all drivers in system', example: 'Show all drivers' },
{ name: 'get_available_vehicles', description: 'Find available vehicles by type/capacity', example: 'Available SUVs' },
{ name: 'find_available_drivers_for_timerange', description: 'Find drivers free during specific time', example: 'Who is free 2-4pm tomorrow?' },
{ name: 'get_daily_driver_manifest', description: 'Full daily schedule for a driver', example: "What's Mike's schedule today?" },
{ name: 'get_vehicle_schedule', description: 'Get vehicle assignments for date range', example: 'Blue van schedule this week' },
],
},
create: {
description: 'Create new records',
tools: [
{ name: 'create_vip', description: 'Create a new VIP profile', example: 'Add VIP Jane Doe from Acme Corp' },
{ name: 'create_event', description: 'Schedule a new event/transport', example: 'Schedule pickup at 3pm' },
{ name: 'create_flight', description: 'Add flight for a VIP', example: 'Add flight AA1234 for John' },
],
},
update: {
description: 'Modify existing records',
tools: [
{ name: 'update_vip', description: 'Update VIP information', example: 'Change VIP phone number' },
{ name: 'update_event', description: 'Modify event details, time, location', example: 'Move pickup to 4pm' },
{ name: 'update_flight', description: 'Update flight times/status', example: 'Flight delayed to 5pm' },
{ name: 'update_driver', description: 'Update driver info, availability', example: 'Mark driver as unavailable' },
{ name: 'assign_driver_to_event', description: 'Assign/change driver for event', example: 'Assign Mike to this pickup' },
{ name: 'assign_vehicle_to_event', description: 'Assign/change vehicle for event', example: 'Use the SUV for this trip' },
{ name: 'reassign_driver_events', description: 'Bulk reassign from one driver to another', example: 'Move all of John\'s events to Mike' },
],
},
delete: {
description: 'Remove records (soft delete)',
tools: [
{ name: 'delete_event', description: 'Cancel/delete an event', example: 'Cancel the 3pm pickup' },
{ name: 'delete_flight', description: 'Remove a flight record', example: 'Delete cancelled flight' },
],
},
communication: {
description: 'Driver messaging via Signal',
tools: [
{ name: 'send_driver_notification_via_signal', description: 'Send message to driver', example: 'Tell Mike the pickup is delayed' },
{ name: 'bulk_send_driver_schedules', description: 'Send schedules to all drivers', example: 'Send tomorrow\'s schedules to everyone' },
],
},
analytics: {
description: 'Reports, summaries, and audits',
tools: [
{ name: 'get_todays_summary', description: 'Today\'s events, arrivals, stats', example: "What's happening today?" },
{ name: 'get_weekly_lookahead', description: 'Week-by-week event summary', example: 'What does next week look like?' },
{ name: 'get_driver_workload_summary', description: 'Driver utilization statistics', example: 'Who is overworked this week?' },
{ name: 'identify_scheduling_gaps', description: 'Find problems in schedule', example: 'Any issues with the schedule?' },
{ name: 'find_unassigned_events', description: 'Events without driver/vehicle', example: 'What needs assignment?' },
],
},
scheduling: {
description: 'Conflict detection and scheduling',
tools: [
{ name: 'check_driver_conflicts', description: 'Check driver for time conflicts', example: 'Can Mike do 2-4pm?' },
{ name: 'check_vip_conflicts', description: 'Check VIP for double-booking', example: 'Is VIP free at 3pm?' },
{ name: 'suggest_vehicle_for_event', description: 'Recommend best vehicle', example: 'What vehicle for 6 passengers?' },
{ name: 'get_driver_schedule', description: 'Driver events for date range', example: 'Mike\'s schedule this week' },
],
},
help: {
description: 'Self-help and system info',
tools: [
{ name: 'get_my_capabilities', description: 'List all available tools (this tool)', example: 'What can you do?' },
{ name: 'get_workflow_guide', description: 'Step-by-step task guides', example: 'How do I handle a flight delay?' },
{ name: 'get_current_system_status', description: 'System overview and stats', example: 'System status' },
],
},
};
const validCategory = category as keyof typeof capabilities;
if (category !== 'all' && capabilities[validCategory]) {
return {
success: true,
data: { [category]: capabilities[validCategory] },
message: `Showing ${category} tools. I have ${capabilities[validCategory].tools.length} tools in this category.`,
};
}
const totalTools = Object.values(capabilities).reduce((sum, cat) => sum + cat.tools.length, 0);
return {
success: true,
data: capabilities,
message: `I have ${totalTools} tools available across ${Object.keys(capabilities).length} categories. Ask me about any specific capability!`,
};
}
private async getWorkflowGuide(input: Record<string, any>): Promise<ToolResult> {
const workflows = {
schedule_airport_pickup: {
title: 'Schedule an Airport Pickup',
steps: [
'1. Find the VIP: Use search_vips with the VIP name',
'2. Get flight info: Use get_flights_for_vip or check if flight already exists',
'3. Find available driver: Use find_available_drivers_for_timerange for the arrival time',
'4. Find suitable vehicle: Use suggest_vehicle_for_event or get_available_vehicles',
'5. Create the event: Use create_event with type TRANSPORT, pickup at airport, dropoff at destination',
'6. Notify driver: Use send_driver_notification_via_signal to inform them',
],
tips: ['Add 30 min buffer after flight arrival', 'Check flight status before the day'],
},
reassign_driver: {
title: 'Reassign a Driver\'s Events (Driver Sick/Unavailable)',
steps: [
'1. Use reassign_driver_events with fromDriverName and toDriverName',
'2. Optionally specify a date to only reassign that day\'s events',
'3. Review the returned list of reassigned events',
'4. Use send_driver_notification_via_signal to notify both drivers',
],
tips: ['Check the new driver has no conflicts first', 'Update driver availability with update_driver'],
},
handle_flight_delay: {
title: 'Handle a Flight Delay',
steps: [
'1. Find the VIP: Use search_vips to get VIP info',
'2. Update flight: Use update_flight with new arrival time',
'3. Find affected events: Use search_events filtered by VIP and date',
'4. Update pickup event: Use update_event to adjust start/end times',
'5. Check driver conflicts: Use check_driver_conflicts for new time',
'6. Notify driver: Use send_driver_notification_via_signal about the change',
],
tips: ['Check if other VIPs share the same flight', 'Consider ripple effects on later events'],
},
create_vip_itinerary: {
title: 'Create a Complete VIP Itinerary',
steps: [
'1. Create VIP: Use create_vip with all details',
'2. Add flights: Use create_flight for each flight segment',
'3. Create events: Use create_event for airport pickup, meetings, dinners, etc.',
'4. Assign resources: Use assign_driver_to_event and assign_vehicle_to_event',
'5. Review: Use get_vip_itinerary to see the complete schedule',
],
tips: ['Schedule in chronological order', 'Add buffer time between events'],
},
morning_briefing: {
title: 'Get Morning Briefing',
steps: [
'1. Get today summary: Use get_todays_summary for overview',
'2. Check for problems: Use identify_scheduling_gaps',
'3. Review unassigned: Use find_unassigned_events',
'4. Check workload: Use get_driver_workload_summary for balance',
'5. Send schedules: Use bulk_send_driver_schedules to notify all drivers',
],
tips: ['Do this 30 min before operations start', 'Address gaps before drivers arrive'],
},
send_driver_notifications: {
title: 'Send Notifications to Drivers',
steps: [
'1. For single driver: Use send_driver_notification_via_signal with message',
'2. For all drivers (schedules): Use bulk_send_driver_schedules with date',
'3. Reference specific event: Include relatedEventId for context',
],
tips: ['Keep messages concise', 'Include pickup time and location'],
},
check_schedule_problems: {
title: 'Audit Schedule for Problems',
steps: [
'1. Run full audit: Use identify_scheduling_gaps with lookahead days',
'2. Review driver conflicts: Check the conflicts list',
'3. Review VIP conflicts: Check for double-bookings',
'4. Review unassigned: Check events missing drivers/vehicles',
'5. Fix issues: Use update_event, assign_driver_to_event as needed',
],
tips: ['Run this daily', 'Prioritize same-day issues'],
},
vehicle_assignment: {
title: 'Choose and Assign the Right Vehicle',
steps: [
'1. Get recommendation: Use suggest_vehicle_for_event with eventId',
'2. Or search manually: Use get_available_vehicles with type/capacity filters',
'3. Check availability: Use get_vehicle_schedule to see existing assignments',
'4. Assign: Use assign_vehicle_to_event',
],
tips: ['Consider VIP count for capacity', 'Check vehicle location if multiple stops'],
},
bulk_schedule_update: {
title: 'Handle Bulk Schedule Changes',
steps: [
'1. Search affected events: Use search_events with date/driver/VIP filter',
'2. Update each event: Use update_event for each (or reassign_driver_events for driver swap)',
'3. Notify drivers: Use send_driver_notification_via_signal or bulk_send_driver_schedules',
],
tips: ['Work chronologically', 'Verify no new conflicts after changes'],
},
};
const task = input.task as keyof typeof workflows;
if (!workflows[task]) {
return {
success: false,
error: `Unknown workflow: ${task}. Available: ${Object.keys(workflows).join(', ')}`,
};
}
const workflow = workflows[task];
return {
success: true,
data: workflow,
message: `Here's the step-by-step guide for: ${workflow.title}`,
};
}
private async getCurrentSystemStatus(): Promise<ToolResult> { private async getCurrentSystemStatus(): Promise<ToolResult> {
const now = new Date(); const now = new Date();
const today = new Date(now); const today = new Date(now);
@@ -3011,142 +2755,4 @@ User role: ${userRole}
}; };
} }
private async getApiDocumentation(input: Record<string, any>): Promise<ToolResult> {
const resource = input.resource || 'all';
const apiDocs = {
auth: {
description: 'Authentication endpoints (Auth0 JWT-based)',
baseUrl: '/api/v1/auth',
endpoints: [
{ method: 'GET', path: '/profile', description: 'Get current user profile and role', auth: 'Required' },
],
notes: 'Uses Auth0 for authentication. Token must be included as Bearer token in Authorization header.',
},
users: {
description: 'User management (Admin only)',
baseUrl: '/api/v1/users',
endpoints: [
{ method: 'GET', path: '/', description: 'List all users', auth: 'Admin only' },
{ method: 'GET', path: '/pending', description: 'List users pending approval', auth: 'Admin only' },
{ method: 'GET', path: '/:id', description: 'Get specific user by ID', auth: 'Admin only' },
{ method: 'PATCH', path: '/:id', description: 'Update user details', auth: 'Admin only' },
{ method: 'PATCH', path: '/:id/approve', description: 'Approve a pending user', auth: 'Admin only' },
{ method: 'DELETE', path: '/:id', description: 'Delete user (soft delete)', auth: 'Admin only' },
],
notes: 'First user to register automatically becomes Admin. Other users need approval.',
},
vips: {
description: 'VIP profile management',
baseUrl: '/api/v1/vips',
endpoints: [
{ method: 'GET', path: '/', description: 'List all VIPs', auth: 'All roles' },
{ method: 'GET', path: '/:id', description: 'Get VIP details with flights and events', auth: 'All roles' },
{ method: 'POST', path: '/', description: 'Create new VIP', auth: 'Admin, Coordinator' },
{ method: 'PATCH', path: '/:id', description: 'Update VIP information', auth: 'Admin, Coordinator' },
{ method: 'DELETE', path: '/:id', description: 'Soft delete VIP', auth: 'Admin, Coordinator' },
],
fields: ['name', 'organization', 'department (OFFICE_OF_DEVELOPMENT | ADMIN)', 'arrivalMode (FLIGHT | SELF_DRIVING)', 'expectedArrival', 'airportPickup', 'venueTransport', 'notes'],
},
drivers: {
description: 'Driver resource management',
baseUrl: '/api/v1/drivers',
endpoints: [
{ method: 'GET', path: '/', description: 'List all drivers', auth: 'All roles' },
{ method: 'GET', path: '/me', description: 'Get current user\'s driver profile', auth: 'Driver role' },
{ method: 'GET', path: '/:id', description: 'Get driver details', auth: 'All roles' },
{ method: 'GET', path: '/:id/schedule', description: 'Get driver\'s schedule', auth: 'All roles' },
{ method: 'POST', path: '/', description: 'Create new driver', auth: 'Admin, Coordinator' },
{ method: 'POST', path: '/:id/send-schedule', description: 'Send schedule to driver via Signal (ICS + PDF)', auth: 'Admin, Coordinator' },
{ method: 'POST', path: '/send-all-schedules', description: 'Send schedules to all drivers with events', auth: 'Admin, Coordinator' },
{ method: 'PATCH', path: '/:id', description: 'Update driver', auth: 'Admin, Coordinator' },
{ method: 'PATCH', path: '/me', description: 'Update own profile', auth: 'Driver role' },
{ method: 'DELETE', path: '/:id', description: 'Soft delete driver', auth: 'Admin, Coordinator' },
],
fields: ['name', 'phone', 'department', 'isAvailable', 'shiftStartTime', 'shiftEndTime', 'userId (link to User)'],
},
events: {
description: 'Schedule event management (transports, meetings, etc.)',
baseUrl: '/api/v1/events',
endpoints: [
{ method: 'GET', path: '/', description: 'List events (supports filters: date, driverId, status)', auth: 'All roles' },
{ method: 'GET', path: '/:id', description: 'Get event details', auth: 'All roles' },
{ method: 'POST', path: '/', description: 'Create new event (checks for conflicts)', auth: 'Admin, Coordinator' },
{ method: 'PATCH', path: '/:id', description: 'Update event', auth: 'Admin, Coordinator' },
{ method: 'PATCH', path: '/:id/status', description: 'Update event status only', auth: 'All roles (Drivers can update their events)' },
{ method: 'DELETE', path: '/:id', description: 'Cancel/delete event', auth: 'Admin, Coordinator' },
],
fields: ['vipIds[]', 'title', 'type (TRANSPORT | MEETING | EVENT | MEAL | ACCOMMODATION)', 'status (SCHEDULED | IN_PROGRESS | COMPLETED | CANCELLED)', 'startTime', 'endTime', 'pickupLocation', 'dropoffLocation', 'location', 'driverId', 'vehicleId', 'description', 'notes'],
notes: 'Events support multiple VIPs via vipIds array. Conflict detection runs on create/update.',
},
vehicles: {
description: 'Vehicle fleet management',
baseUrl: '/api/v1/vehicles',
endpoints: [
{ method: 'GET', path: '/', description: 'List all vehicles (supports filters: type, status)', auth: 'All roles' },
{ method: 'GET', path: '/:id', description: 'Get vehicle details', auth: 'All roles' },
{ method: 'POST', path: '/', description: 'Create new vehicle', auth: 'Admin, Coordinator' },
{ method: 'PATCH', path: '/:id', description: 'Update vehicle', auth: 'Admin, Coordinator' },
{ method: 'DELETE', path: '/:id', description: 'Soft delete vehicle', auth: 'Admin, Coordinator' },
],
fields: ['name', 'type (VAN | SUV | SEDAN | BUS | GOLF_CART | TRUCK)', 'licensePlate', 'seatCapacity', 'status (AVAILABLE | IN_USE | MAINTENANCE)', 'notes'],
},
flights: {
description: 'Flight tracking and management',
baseUrl: '/api/v1/flights',
endpoints: [
{ method: 'GET', path: '/', description: 'List all flights', auth: 'All roles' },
{ method: 'GET', path: '/:id', description: 'Get flight details', auth: 'All roles' },
{ method: 'GET', path: '/vip/:vipId', description: 'Get all flights for a VIP', auth: 'All roles' },
{ method: 'POST', path: '/', description: 'Create flight record', auth: 'Admin, Coordinator' },
{ method: 'POST', path: '/track/:flightNumber', description: 'Fetch live flight status from AviationStack', auth: 'Admin, Coordinator' },
{ method: 'PATCH', path: '/:id', description: 'Update flight info', auth: 'Admin, Coordinator' },
{ method: 'DELETE', path: '/:id', description: 'Delete flight', auth: 'Admin, Coordinator' },
],
fields: ['vipId', 'flightNumber', 'flightDate', 'segment', 'departureAirport (IATA code)', 'arrivalAirport', 'scheduledDeparture', 'scheduledArrival', 'actualDeparture', 'actualArrival', 'status'],
},
signal: {
description: 'Signal messaging integration for driver communication',
baseUrl: '/api/v1/signal',
endpoints: [
{ method: 'GET', path: '/status', description: 'Get Signal service status and linked number', auth: 'Admin, Coordinator' },
{ method: 'GET', path: '/messages', description: 'Get message history (supports driverId filter)', auth: 'Admin, Coordinator' },
{ method: 'GET', path: '/messages/unread-counts', description: 'Get unread message counts per driver', auth: 'Admin, Coordinator' },
{ method: 'GET', path: '/messages/driver/:driverId', description: 'Get messages for specific driver', auth: 'Admin, Coordinator' },
{ method: 'POST', path: '/messages/send', description: 'Send message to driver via Signal', auth: 'Admin, Coordinator' },
{ method: 'POST', path: '/messages/mark-read/:driverId', description: 'Mark driver messages as read', auth: 'Admin, Coordinator' },
{ method: 'POST', path: '/messages/check-responses', description: 'Check if drivers responded since event start times', auth: 'Admin, Coordinator' },
],
notes: 'Requires Signal CLI to be running and linked. Messages are stored in database for history.',
},
};
const validResource = resource as keyof typeof apiDocs;
if (resource !== 'all' && apiDocs[validResource]) {
const doc = apiDocs[validResource];
return {
success: true,
data: { [resource]: doc },
message: `API documentation for ${resource} endpoints. Base URL: ${doc.baseUrl}`,
};
}
const totalEndpoints = Object.values(apiDocs).reduce(
(sum, r) => sum + r.endpoints.length,
0
);
return {
success: true,
data: {
overview: {
baseUrl: '/api/v1',
authentication: 'Auth0 JWT Bearer token required on all endpoints',
roles: ['ADMINISTRATOR (full access)', 'COORDINATOR (manage VIPs, drivers, events)', 'DRIVER (view + update own events)'],
},
resources: apiDocs,
},
message: `VIP Coordinator API has ${totalEndpoints} endpoints across ${Object.keys(apiDocs).length} resources. All endpoints require authentication.`,
};
}
} }

View File

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

View File

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

View File

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

630
docs/USER_GUIDE.md Normal file
View File

@@ -0,0 +1,630 @@
# VIP Coordinator - User Guide
A comprehensive guide to using the VIP Coordinator application for managing VIP transportation logistics, driver coordination, event scheduling, and fleet management.
---
## Table of Contents
1. [Getting Started](#getting-started)
- [Logging In](#logging-in)
- [Understanding Your Role](#understanding-your-role)
- [Navigation Overview](#navigation-overview)
2. [Dashboard](#dashboard)
3. [War Room (Command Center)](#war-room-command-center)
4. [Managing VIPs](#managing-vips)
- [Viewing the VIP List](#viewing-the-vip-list)
- [Adding a New VIP](#adding-a-new-vip)
- [Editing a VIP](#editing-a-vip)
- [VIP Contact & Emergency Info](#vip-contact--emergency-info)
- [Deleting a VIP](#deleting-a-vip)
5. [Fleet Management](#fleet-management)
- [Drivers Tab](#drivers-tab)
- [Adding a Driver](#adding-a-driver)
- [Vehicles Tab](#vehicles-tab)
- [Adding a Vehicle](#adding-a-vehicle)
6. [Activities (Events & Scheduling)](#activities-events--scheduling)
- [Viewing Activities](#viewing-activities)
- [Creating an Activity](#creating-an-activity)
- [Activity Types](#activity-types)
- [Conflict Detection](#conflict-detection)
7. [Flight Tracking](#flight-tracking)
- [Viewing Flights](#viewing-flights)
- [Adding a Flight](#adding-a-flight)
8. [GPS Tracking](#gps-tracking)
- [Overview](#gps-overview)
- [Enrolling a Driver for GPS](#enrolling-a-driver-for-gps)
- [Live Map](#live-map)
- [GPS Settings](#gps-settings)
9. [Reports](#reports)
- [VIP Accountability Roster](#vip-accountability-roster)
- [PDF Customization](#pdf-customization)
10. [User Management](#user-management)
- [Approving New Users](#approving-new-users)
- [Changing User Roles](#changing-user-roles)
11. [Admin Tools](#admin-tools)
- [Database Statistics](#database-statistics)
- [PDF Customization](#pdf-customization-settings)
- [Signal Messaging](#signal-messaging)
- [Test Data Management](#test-data-management)
12. [AI Assistant](#ai-assistant)
13. [Driver View (My Schedule)](#driver-view-my-schedule)
---
## Getting Started
### Logging In
1. Navigate to your VIP Coordinator URL (e.g., `https://vip.madeamess.online`).
2. Click the **"Sign in with Auth0"** button on the login page.
![Login Page](screenshots/01-login-page.png)
3. You will be redirected to the Auth0 login screen. Enter your email address and password, then click **Continue**.
![Auth0 Login](screenshots/02-auth0-login.png)
4. After successful authentication, you will be redirected to the application.
- **First-time users:** Your account requires administrator approval before you can access the system. You'll see a "Pending Approval" page until an admin approves your account.
- **Returning users:** You'll land on the Dashboard (or My Schedule if you're a Driver).
> **Tip:** Your login session persists across browser refreshes and tabs. You won't need to log in again unless you explicitly sign out or your session expires.
### Understanding Your Role
VIP Coordinator has three user roles, each with different levels of access:
| Feature | Administrator | Coordinator | Driver |
|---------|:---:|:---:|:---:|
| Dashboard & War Room | Full access | Full access | -- |
| VIP Management | Create, Edit, Delete | Create, Edit, Delete | View only |
| Fleet (Drivers/Vehicles) | Create, Edit, Delete | Create, Edit, Delete | View only |
| Activities/Events | Create, Edit, Delete | Create, Edit, Delete | View & Update status |
| Flight Tracking | Full access | Full access | -- |
| GPS Tracking | Full access | Full access | View own location |
| Reports | Full access | Full access | -- |
| User Management | Full access | -- | -- |
| Admin Tools | Full access | -- | -- |
| AI Assistant | Full access | Full access | -- |
### Navigation Overview
The top navigation bar provides access to all major sections:
- **Dashboard** - Quick overview of today's activities and stats
- **War Room** - Real-time command center for active operations
- **VIPs** - Manage VIP profiles and their travel details
- **Fleet** - Manage drivers and vehicles
- **Activities** - Schedule and track events/transport
- **Flights** - Track flight arrivals and departures
- **Admin** (dropdown) - User Management, GPS Tracking, Reports, Admin Tools
Your user avatar and email appear in the top-right corner. Click it to access your profile or sign out.
---
## Dashboard
The Dashboard is your home base, providing a quick overview of the current situation.
![Dashboard](screenshots/03-dashboard.png)
**What you'll see:**
- **Summary Cards** - Quick counts of VIPs, drivers, vehicles, and today's events
- **Today's Schedule** - A timeline of upcoming activities for the day
- **Recent Activity** - Latest changes and updates in the system
- **Quick Actions** - Shortcuts to common tasks like adding a VIP or creating an event
> **Tip:** The Dashboard automatically refreshes to show you the latest data. It's a great page to keep open as your main monitoring screen.
---
## War Room (Command Center)
The War Room is your real-time operations center, designed for active event coordination.
![War Room](screenshots/04-war-room.png)
**Key Features:**
- **Active Events Panel** - Shows all currently in-progress events with live status
- **Upcoming Events** - Events starting soon, sorted by urgency
- **Driver Status** - Which drivers are currently assigned and available
- **Quick Status Updates** - One-click buttons to mark events as started, completed, or cancelled
**How to use the War Room:**
1. Open the **War Room** from the top navigation.
2. Events are color-coded by status:
- **Red/Urgent** - Events starting in the next 5-15 minutes
- **Blue/In Progress** - Currently active events
- **Green/Completed** - Recently finished events
- **Gray/Scheduled** - Upcoming events
3. Click on any event card to see full details or update its status.
4. Use the **Refresh** button to get the latest data instantly.
> **Tip:** The War Room is ideal for day-of-event coordination. Keep it open on a large screen or dedicated monitor during active operations.
---
## Managing VIPs
### Viewing the VIP List
Navigate to **VIPs** from the top menu to see all VIP profiles.
![VIP List](screenshots/05-vip-list.png)
**Features:**
- **Search** - Filter VIPs by name or organization using the search bar
- **Department Filter** - Filter by department (Office of Development, Admin, Other)
- **Arrival Mode** - See whether each VIP is arriving by flight or self-driving
- **Party Size** - Shows the total number of people in the VIP's group
- **Quick Actions** - Edit or view schedule for each VIP
### Adding a New VIP
1. Click the **"+ Add VIP"** button in the top-right corner of the VIP List page.
2. Fill in the VIP's details:
![VIP Edit Form](screenshots/06-vip-edit-form.png)
**Required fields:**
- **Name** - Full name of the VIP
- **Department** - Which department is hosting (Office of Development, Admin, or Other)
- **Arrival Mode** - How the VIP is arriving:
- **Flight** - Arriving by air (enables flight tracking)
- **Self-Driving** - Arriving by personal vehicle (allows setting expected arrival time)
**Optional fields:**
- **Organization** - The VIP's company or organization
- **Airport Pickup** - Check if the VIP needs airport pickup service
- **Venue Transport** - Check if the VIP needs transportation between venues
- **Party Size** - Total number of people (VIP + entourage, default is 1)
- **Notes** - Any special instructions or requirements
- **Roster Only** - Check this if you're only tracking the VIP for accountability purposes (not active coordination)
3. Click **Save** to create the VIP profile.
### Editing a VIP
1. On the VIP List, click the **Edit** (pencil) icon on any VIP row.
2. The edit form opens with the VIP's current information pre-filled.
3. Make your changes and click **Save**.
### VIP Contact & Emergency Info
Scroll down in the VIP edit form to find the contact and emergency information section.
![VIP Contact Info](screenshots/07-vip-edit-contact-info.png)
**Contact fields:**
- **Phone** - VIP's phone number
- **Email** - VIP's email address
- **Emergency Contact Name** - Name of the VIP's emergency contact
- **Emergency Contact Phone** - Phone number for the emergency contact
> **Important:** Emergency contact information is included in the Accountability Roster report. Filling this in is recommended for all VIPs attending large events.
### Deleting a VIP
1. On the VIP List, click the **Delete** (trash) icon on the VIP's row.
2. Confirm the deletion when prompted.
> **Note:** VIP deletion is a "soft delete" - the record is hidden but preserved in the database for audit purposes.
---
## Fleet Management
The Fleet page manages both **Drivers** and **Vehicles** from a single location.
### Drivers Tab
![Fleet - Drivers](screenshots/08-fleet-drivers.png)
The Drivers tab shows all drivers in the system, including:
- **Name** and **Phone** number
- **Department** assignment
- **Availability Status** - Whether the driver is available for assignments
- **Shift Times** - When the driver's shift starts and ends
- **Linked Account** - Whether the driver has a user account for app login
### Adding a Driver
1. Navigate to **Fleet** and ensure the **Drivers** tab is selected.
2. Click the **"+ Add Driver"** button.
3. Fill in the required information:
- **Full Name** (required)
- **Phone Number** (required)
- **Department** (optional - Office of Development, Admin, or Other)
- **User Account ID** (optional - links the driver to a login account)
4. Click **Create Driver**.
> **Tip:** When you link a driver to a user account, that user will be able to log in and see their own schedule on the "My Schedule" page. Create the user account first (they sign up and get approved), then link it here.
### Vehicles Tab
![Fleet - Vehicles](screenshots/09-fleet-vehicles.png)
The Vehicles tab displays your entire fleet, showing:
- **Vehicle Name** - Descriptive name (e.g., "Blue Van", "Suburban #3")
- **Type** - Van, SUV, Sedan, Bus, Golf Cart, or Truck
- **License Plate** number
- **Seat Capacity** - Total available seats
- **Status** - Available, In Use, Maintenance, or Reserved
- **Current Driver** - Who is currently assigned to the vehicle
### Adding a Vehicle
1. Navigate to **Fleet** and click the **Vehicles** tab.
2. Click the **"+ Add Vehicle"** button.
3. Fill in:
- **Vehicle Name** (required) - Give it a recognizable name
- **Type** (required) - Select the vehicle type
- **License Plate** (optional)
- **Seat Capacity** (required) - Total number of passenger seats
- **Notes** (optional) - Any special notes about the vehicle
4. Click **Create Vehicle**.
> **Tip:** Keep vehicle names simple and distinctive. During hectic operations, coordinators need to quickly identify vehicles. Names like "White Suburban" or "Van #2" work well.
---
## Activities (Events & Scheduling)
### Viewing Activities
Navigate to **Activities** from the top menu to see all scheduled events.
![Activities](screenshots/10-activities.png)
**Features:**
- **Status Filters** - Filter by Scheduled, In Progress, Completed, or Cancelled
- **Date Filtering** - View events for specific dates
- **Type Filtering** - Filter by Transport, Meeting, Event, Meal, or Accommodation
- **Search** - Find events by title, VIP name, or location
### Creating an Activity
1. Click **"+ New Activity"** on the Activities page.
2. Fill in the event details:
- **Title** (required) - Descriptive name for the event
- **Type** - Transport, Meeting, Event, Meal, or Accommodation
- **VIP(s)** - Select one or more VIPs for this event
- **Start Time** and **End Time** (required)
- **Driver** (optional) - Assign a driver
- **Vehicle** (optional) - Assign a vehicle
- **Pickup Location** and **Dropoff Location** (for transport events)
- **Location** (for non-transport events)
- **Description** and **Notes** (optional)
3. Click **Create** to save the event.
### Activity Types
| Type | Use For |
|------|---------|
| **Transport** | Airport pickups, venue-to-venue rides, departure drops |
| **Meeting** | Scheduled meetings between VIPs and hosts |
| **Event** | Conferences, ceremonies, tours, and other events |
| **Meal** | Breakfast, lunch, dinner, and receptions |
| **Accommodation** | Hotel check-in/check-out |
### Conflict Detection
When creating or editing an activity, the system automatically checks for scheduling conflicts:
- **Driver conflicts** - A driver can't be assigned to two events at the same time
- **Vehicle conflicts** - A vehicle can't be double-booked
- **VIP conflicts** - VIPs can't be in two places at once
If a conflict is detected, you'll see a warning with details about the overlapping event. You can choose to proceed anyway or adjust the timing.
---
## Flight Tracking
### Viewing Flights
Navigate to **Flights** from the top menu to see all tracked flights.
![Flights](screenshots/11-flights.png)
**The flights page shows:**
- **Flight Number** - Airline and flight number (e.g., AA1234)
- **Route** - Departure and arrival airports (IATA codes)
- **Date** - Flight date
- **Scheduled Times** - Planned departure and arrival
- **Actual Times** - Real departure and arrival (when available)
- **Status** - Scheduled, Delayed, In Air, Landed, etc.
- **VIP** - Which VIP is on this flight
### Adding a Flight
Flights are typically added through the VIP edit form:
1. Navigate to a VIP's profile (edit the VIP).
2. In the **Flights** section, click **"+ Add Flight"**.
3. Enter:
- **Flight Number** (e.g., "AA1234")
- **Flight Date**
- **Departure Airport** (IATA code, e.g., "JFK")
- **Arrival Airport** (IATA code, e.g., "LAX")
- **Segment** - For multi-leg itineraries (1 for first leg, 2 for second, etc.)
4. The system will attempt to look up real-time flight data if an API key is configured.
> **Tip:** Use standard IATA 3-letter airport codes (e.g., JFK, LAX, ORD, ATL). The system uses these to track flight status automatically.
---
## GPS Tracking
### GPS Overview
The GPS Tracking page provides real-time location monitoring for your driver fleet.
![GPS Tracking](screenshots/15-gps-tracking.png)
**Dashboard cards at the top show:**
- **Total Enrolled** - Number of drivers enrolled in GPS tracking
- **Active Now** - Drivers currently reporting their location
- **Update Interval** - How frequently locations update (e.g., 30 seconds)
- **Shift Hours** - Hours during which tracking is active
The page has four tabs: **Live Map**, **Devices**, **Stats**, and **Settings**.
### Enrolling a Driver for GPS
To enable GPS tracking for a driver, you need to enroll them:
![GPS Devices](screenshots/16-gps-devices.png)
1. Go to **GPS Tracking** and click the **Devices** tab.
2. Click the **"Enroll Driver"** button.
3. Select the driver you want to enroll from the dropdown.
4. The system will create a unique device identifier for that driver.
5. The driver then needs to install the **Traccar Client** app on their phone:
- Available for both **iOS** (App Store) and **Android** (Google Play)
- Search for "Traccar Client" in the app store
6. In the Traccar Client app, configure:
- **Device identifier** - Enter the unique ID shown after enrollment
- **Server URL** - Enter the Traccar server URL provided by your administrator
- **Frequency** - Set to match your GPS settings (e.g., 30 seconds)
- **Location accuracy** - Set to "High"
7. Enable tracking in the app and the driver's location will appear on the Live Map.
> **Important:** GPS tracking respects driver privacy. Tracking only occurs during configured shift hours. Drivers must give consent, and the system clearly shows when tracking is active.
### Live Map
The **Live Map** tab shows all active drivers on an interactive map:
- **Green dots** indicate active drivers currently reporting location
- **Gray dots** indicate enrolled but inactive drivers
- Click on any driver marker to see their name, speed, and last update time
- The map auto-refreshes based on the configured update interval
### GPS Settings
![GPS Settings](screenshots/17-gps-settings.png)
Administrators can configure GPS tracking behavior:
1. Go to **GPS Tracking** and click the **Settings** tab.
2. Adjustable settings:
- **Update Interval** (30-300 seconds) - How often driver phones report location. Lower values = more precise tracking but higher battery usage.
- **Data Retention** (7-90 days) - How long location history is kept before automatic cleanup.
- **Tracking Hours** - Set the start and end time for when GPS tracking is active. Drivers are NOT tracked outside these hours.
3. Click **Save** to apply changes.
> **Tip:** For most events, a 30-60 second update interval provides good tracking while preserving driver phone battery. During critical operations, you can temporarily lower this to 15-30 seconds.
---
## Reports
### VIP Accountability Roster
Navigate to **Reports** under the **Admin** dropdown to access the accountability roster.
![Reports](screenshots/12-reports.png)
The **VIP Accountability Roster** is a comprehensive report designed for event-day accountability. It includes:
- **VIP Name and Organization**
- **Contact Information** (phone, email)
- **Emergency Contact** details
- **Arrival Mode** and expected arrival time
- **Assigned Driver and Vehicle**
- **Flight Details** (for VIPs arriving by air)
- **Party Size**
- **Special Notes**
**To generate the report:**
1. Navigate to **Reports**.
2. The roster is displayed on screen with all active VIPs.
3. Click **"Download PDF"** to generate a professionally formatted PDF document.
4. The PDF uses your configured branding (logo, colors, contact info) from the Admin Tools settings.
> **Tip:** Print the Accountability Roster before each event starts. It serves as a backup reference when technology isn't available and is useful for emergency situations where you need quick access to VIP contact and emergency information.
### PDF Customization
The appearance of generated PDF reports can be fully customized. See [Admin Tools > PDF Customization](#pdf-customization-settings) for details.
---
## User Management
Administrators can manage user accounts from the **Users** page.
![User Management](screenshots/13-users.png)
### Approving New Users
When a new person signs up, their account starts in a "Pending Approval" state:
1. Navigate to **Admin > Users**.
2. Look for users with a **"Pending"** status badge.
3. Click **"Approve"** to grant them access to the system.
4. The user will be able to log in on their next attempt.
> **Note:** The very first user to register is automatically approved and given the Administrator role. All subsequent users require manual approval.
### Changing User Roles
1. On the Users page, find the user whose role you want to change.
2. Use the **Role** dropdown to select:
- **Administrator** - Full system access, can manage users and settings
- **Coordinator** - Can manage VIPs, drivers, events, and view all data
- **Driver** - Limited view, can see their own schedule and update event status
3. The change takes effect immediately.
> **Warning:** Be careful when changing roles. Removing someone's Administrator role cannot be undone by that user - another admin must restore it.
---
## Admin Tools
The Admin Tools page is only accessible to Administrators and provides system management capabilities.
![Admin Tools](screenshots/14-admin-tools.png)
### Database Statistics
At the top of the page, you'll see a live count of all records in the system:
- Number of VIPs, Drivers, Vehicles, Events, Flights, and Users
- Click **Refresh** to update the counts
### PDF Customization Settings
Customize how generated PDF documents look:
**Branding:**
- **Organization Name** - Appears in the PDF header
- **Organization Logo** - Upload your logo (PNG, JPG, or SVG, max 2MB)
- **Accent Color** - The primary color used for headers and section titles
- **Tagline** - Optional text below the organization name
**Contact Information:**
- **Contact Email** and **Phone** - Shown in the PDF footer
- **Secondary Contact** - Optional backup contact
- **Contact Label** - The heading above contact info (e.g., "Questions or Changes?")
**Document Options:**
- **Draft Watermark** - Add a diagonal "DRAFT" watermark
- **Confidential Watermark** - Add a "CONFIDENTIAL" watermark
- **Show Timestamp** - Include generation date/time
- **Page Size** - Letter or A4
**Content Display:**
- Toggle visibility of flight info, driver names, vehicle names, VIP notes, and event descriptions
**Custom Messages:**
- **Header Message** - Custom text at the top of the document
- **Footer Message** - Custom text at the bottom
Click **"Preview Sample PDF"** to see how your settings look before saving, then click **"Save PDF Settings"** to apply.
### Signal Messaging
The Signal Messaging section allows you to communicate with drivers via Signal (encrypted messaging):
- **Connection Status** - Shows whether the Signal service is connected and which phone number is linked
- **Send Test Message** - Send a test message to verify the connection
- **Chat History** - View message statistics and manage chat history
### Test Data Management
For development and demo purposes:
- **Generate Complete Test Data** - Creates a full set of realistic test data (20 VIPs, 8 drivers, 10 vehicles, 100+ events)
- **Refresh Event Times** - Keeps existing VIPs/drivers/vehicles but regenerates all events with fresh timestamps relative to the current time
- **Clear All Data** - Removes all VIPs, drivers, vehicles, events, flights, and messages
> **Warning:** "Clear All Data" is irreversible. Only use it when you want to start completely fresh.
---
## AI Assistant
The AI Assistant is a built-in copilot that can help you with VIP coordination tasks.
![AI Assistant](screenshots/18-ai-assistant.png)
**To open the AI Assistant:**
1. Click the blue **"AI Assistant"** button in the bottom-right corner of any page.
2. The chat panel slides open.
**What the AI Assistant can do:**
- Answer questions about your VIPs, drivers, and events
- Look up what's happening today or at specific times
- Find available drivers for assignments
- Check which VIPs are arriving by flight
- Help you understand the current status of operations
- Process screenshots of emails (upload an image of an email with VIP travel details)
**Example questions you can ask:**
- *"What's happening today?"*
- *"Who are the VIPs arriving by flight?"*
- *"Which drivers are available right now?"*
- *"Show me the schedule for Roger Mosby"*
- *"What events are in progress?"*
**To upload an image:**
1. Click the **image upload** button (camera icon) in the chat input area.
2. Select a screenshot or photo (e.g., an email with travel itinerary details).
3. The AI will read the image and extract relevant information.
> **Tip:** The AI Assistant has access to your live data. It can query VIPs, drivers, events, and more in real-time. Use it as a quick way to get answers without navigating to different pages.
---
## Driver View (My Schedule)
Drivers who have a linked user account see a simplified interface focused on their assignments.
**When a driver logs in, they see:**
- **My Schedule** - Their personal schedule showing only events assigned to them
- **Today's Events** - Quick view of what's coming up
- **Status Updates** - Ability to mark their events as "In Progress" or "Completed"
**How drivers update event status:**
1. On their schedule, find the current event.
2. Click the status button to cycle through:
- **Scheduled** (default) - Not yet started
- **In Progress** - Currently underway (click when you start the pickup/transport)
- **Completed** - Finished (click when the VIP has been dropped off)
3. Coordinators and administrators see these status changes in real-time on the War Room.
> **Tip for Drivers:** Keep your event statuses updated! This helps the coordination team know exactly where VIPs are at all times. Mark "In Progress" when you begin a pickup and "Completed" when the VIP is delivered to their destination.
---
## Frequently Asked Questions
**Q: I just signed up but can't access anything. What do I do?**
A: Your account needs to be approved by an administrator. Contact your team lead and ask them to approve your account in the User Management section.
**Q: I'm a driver but I can't see my schedule. What's wrong?**
A: Make sure your user account is linked to a driver profile. An administrator needs to go to Fleet > Drivers, find your driver record, and enter your User Account ID.
**Q: Can I use the app on my phone?**
A: Yes! The web application is responsive and works on mobile browsers. Simply navigate to the same URL on your phone's browser. For GPS tracking, you'll also need the Traccar Client app.
**Q: How do I change my password?**
A: Passwords are managed through Auth0. Click your profile avatar in the top-right corner, then follow the "Change Password" link, or use the "Forgot Password" option on the login screen.
**Q: What happens if two events conflict?**
A: The system will warn you about scheduling conflicts when creating or editing events. You'll see which driver, vehicle, or VIP has an overlapping booking and can choose to adjust the timing or proceed anyway.
**Q: Is the GPS tracking always on?**
A: No. GPS tracking only operates during the configured Shift Hours (set by administrators). Outside those hours, driver locations are not tracked or recorded.
**Q: How long is location data kept?**
A: Location data is automatically deleted after the configured retention period (default: 30 days). Administrators can adjust this in GPS Settings.
---
*This documentation was generated for VIP Coordinator. For technical support or feature requests, contact your system administrator.*

View File

@@ -16,6 +16,7 @@
"axios": "^1.6.5", "axios": "^1.6.5",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"date-fns": "^3.2.0", "date-fns": "^3.2.0",
"fuse.js": "^7.1.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.309.0", "lucide-react": "^0.309.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
@@ -3442,6 +3443,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/fuse.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",

View File

@@ -23,6 +23,7 @@
"axios": "^1.6.5", "axios": "^1.6.5",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"date-fns": "^3.2.0", "date-fns": "^3.2.0",
"fuse.js": "^7.1.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.309.0", "lucide-react": "^0.309.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -24,6 +24,7 @@ import { DriverProfile } from '@/pages/DriverProfile';
import { MySchedule } from '@/pages/MySchedule'; import { MySchedule } from '@/pages/MySchedule';
import { GpsTracking } from '@/pages/GpsTracking'; import { GpsTracking } from '@/pages/GpsTracking';
import { Reports } from '@/pages/Reports'; import { Reports } from '@/pages/Reports';
import { Help } from '@/pages/Help';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
// Smart redirect based on user role // Smart redirect based on user role
@@ -63,7 +64,7 @@ function App() {
scope: 'openid profile email offline_access', scope: 'openid profile email offline_access',
}} }}
useRefreshTokens={true} useRefreshTokens={true}
cacheLocation="memory" cacheLocation="localstorage"
> >
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider> <AuthProvider>
@@ -124,6 +125,7 @@ function App() {
<Route path="/admin-tools" element={<AdminTools />} /> <Route path="/admin-tools" element={<AdminTools />} />
<Route path="/gps-tracking" element={<GpsTracking />} /> <Route path="/gps-tracking" element={<GpsTracking />} />
<Route path="/reports" element={<Reports />} /> <Route path="/reports" element={<Reports />} />
<Route path="/help" element={<Help />} />
<Route path="/profile" element={<DriverProfile />} /> <Route path="/profile" element={<DriverProfile />} />
<Route path="/my-schedule" element={<MySchedule />} /> <Route path="/my-schedule" element={<MySchedule />} />
<Route path="/" element={<HomeRedirect />} /> <Route path="/" element={<HomeRedirect />} />

View File

@@ -114,6 +114,7 @@ export function DriverForm({ driver, onSubmit, onCancel, isSubmitting }: DriverF
<option value="">Select Department</option> <option value="">Select Department</option>
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option> <option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
<option value="ADMIN">Admin</option> <option value="ADMIN">Admin</option>
<option value="OTHER">Other</option>
</select> </select>
</div> </div>

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

@@ -22,6 +22,7 @@ import {
Phone, Phone,
AlertCircle, AlertCircle,
FileText, FileText,
HelpCircle,
} from 'lucide-react'; } from 'lucide-react';
import { UserMenu } from '@/components/UserMenu'; import { UserMenu } from '@/components/UserMenu';
import { AppearanceMenu } from '@/components/AppearanceMenu'; import { AppearanceMenu } from '@/components/AppearanceMenu';
@@ -98,6 +99,7 @@ export function Layout({ children }: LayoutProps) {
{ name: 'Reports', href: '/reports', icon: FileText }, { name: 'Reports', href: '/reports', icon: FileText },
{ name: 'GPS Tracking', href: '/gps-tracking', icon: Radio }, { name: 'GPS Tracking', href: '/gps-tracking', icon: Radio },
{ name: 'Admin Tools', href: '/admin-tools', icon: Settings }, { name: 'Admin Tools', href: '/admin-tools', icon: Settings },
{ name: 'Help Guide', href: '/help', icon: HelpCircle },
]; ];
// Filter navigation based on role and CASL permissions // Filter navigation based on role and CASL permissions

View File

@@ -196,6 +196,7 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
> >
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option> <option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
<option value="ADMIN">Admin</option> <option value="ADMIN">Admin</option>
<option value="OTHER">Other</option>
</select> </select>
</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

@@ -17,7 +17,7 @@ export const copilotApi = axios.create({
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: 120000, // 2 minute timeout for AI requests timeout: 300000, // 5 minute timeout for AI requests (large tasks need multiple tool calls)
}); });
// Token getter function - set by AuthContext when authenticated // Token getter function - set by AuthContext when authenticated

View File

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

View File

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

View File

@@ -196,6 +196,7 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
const labels = { const labels = {
'OFFICE_OF_DEVELOPMENT': 'Office of Development', 'OFFICE_OF_DEVELOPMENT': 'Office of Development',
'ADMIN': 'Admin', 'ADMIN': 'Admin',
'OTHER': 'Other',
}; };
return labels[value as keyof typeof labels] || value; return labels[value as keyof typeof labels] || value;
}; };
@@ -541,6 +542,7 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
options: [ options: [
{ value: 'OFFICE_OF_DEVELOPMENT', label: 'Office of Development' }, { value: 'OFFICE_OF_DEVELOPMENT', label: 'Office of Development' },
{ value: 'ADMIN', label: 'Admin' }, { value: 'ADMIN', label: 'Admin' },
{ value: 'OTHER', label: 'Other' },
], ],
selectedValues: selectedDepartments, selectedValues: selectedDepartments,
onToggle: handleDepartmentToggle, onToggle: handleDepartmentToggle,

View File

@@ -1,32 +1,144 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Plus, Edit, Trash2, Plane, Search, X, Filter, ArrowUpDown } from 'lucide-react'; import {
Plus,
Search,
X,
Filter,
RefreshCw,
Plane,
AlertTriangle,
Clock,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { FlightForm, FlightFormData } from '@/components/FlightForm'; import { FlightForm, FlightFormData } from '@/components/FlightForm';
import { FlightCard } from '@/components/FlightCard';
import { TableSkeleton } from '@/components/Skeleton'; import { TableSkeleton } from '@/components/Skeleton';
import { ErrorMessage } from '@/components/ErrorMessage'; import { ErrorMessage } from '@/components/ErrorMessage';
import { FilterModal } from '@/components/FilterModal'; import { FilterModal } from '@/components/FilterModal';
import { FilterChip } from '@/components/FilterChip'; import { FilterChip } from '@/components/FilterChip';
import { useDebounce } from '@/hooks/useDebounce'; import { useDebounce } from '@/hooks/useDebounce';
import { useFlights, useFlightBudget, useRefreshActiveFlights } from '@/hooks/useFlights';
import { Flight } from '@/types';
interface Flight { type FlightGroup = {
id: string; key: string;
vipId: string; label: string;
vip?: { icon: typeof AlertTriangle;
name: string; flights: Flight[];
organization: string | null; color: string;
}; defaultCollapsed?: boolean;
flightNumber: string; };
flightDate: string;
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,

1078
frontend/src/pages/Help.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -234,6 +234,7 @@ export function Reports() {
<option value="all">All Departments</option> <option value="all">All Departments</option>
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option> <option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
<option value="ADMIN">Admin</option> <option value="ADMIN">Admin</option>
<option value="OTHER">Other</option>
</select> </select>
</div> </div>
<button <button

View File

@@ -192,6 +192,7 @@ export function VIPList() {
department: { department: {
'OFFICE_OF_DEVELOPMENT': 'Office of Development', 'OFFICE_OF_DEVELOPMENT': 'Office of Development',
'ADMIN': 'Admin', 'ADMIN': 'Admin',
'OTHER': 'Other',
}, },
arrivalMode: { arrivalMode: {
'FLIGHT': 'Flight', 'FLIGHT': 'Flight',
@@ -546,6 +547,7 @@ export function VIPList() {
options: [ options: [
{ value: 'OFFICE_OF_DEVELOPMENT', label: 'Office of Development' }, { value: 'OFFICE_OF_DEVELOPMENT', label: 'Office of Development' },
{ value: 'ADMIN', label: 'Admin' }, { value: 'ADMIN', label: 'Admin' },
{ value: 'OTHER', label: 'Other' },
], ],
selectedValues: selectedDepartments, selectedValues: selectedDepartments,
onToggle: handleDepartmentToggle, onToggle: handleDepartmentToggle,

View File

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

View File

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