Compare commits
16 Commits
b35c14fddc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 139cb4aebe | |||
| 14c6c9506f | |||
| 53eb82c4d2 | |||
| b80ffd3ca1 | |||
| cc3375ef85 | |||
| cb4a070ad9 | |||
| 12b9361ae0 | |||
| 33fda57cc6 | |||
| d93919910b | |||
| 4dbb899409 | |||
| 3bc9cd0bca | |||
| f2b3f34a72 | |||
| 806b67954e | |||
| a4d360aae9 | |||
| 0f0f1cbf38 | |||
| 74a292ea93 |
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "Department" ADD VALUE 'OTHER';
|
||||||
@@ -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");
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Delete duplicate rows keeping the first entry (by id) for each deviceId+timestamp pair
|
||||||
|
DELETE FROM "gps_location_history" a
|
||||||
|
USING "gps_location_history" b
|
||||||
|
WHERE a."id" > b."id"
|
||||||
|
AND a."deviceId" = b."deviceId"
|
||||||
|
AND a."timestamp" = b."timestamp";
|
||||||
|
|
||||||
|
-- Drop the existing index that covered deviceId+timestamp (non-unique)
|
||||||
|
DROP INDEX IF EXISTS "gps_location_history_deviceId_timestamp_idx";
|
||||||
|
|
||||||
|
-- CreateIndex (unique constraint replaces the old non-unique index)
|
||||||
|
CREATE UNIQUE INDEX "gps_location_history_deviceId_timestamp_key" ON "gps_location_history"("deviceId", "timestamp");
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TripStatus" AS ENUM ('ACTIVE', 'COMPLETED', 'PROCESSING', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "gps_trips" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"deviceId" TEXT NOT NULL,
|
||||||
|
"status" "TripStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"startTime" TIMESTAMP(3) NOT NULL,
|
||||||
|
"endTime" TIMESTAMP(3),
|
||||||
|
"startLatitude" DOUBLE PRECISION NOT NULL,
|
||||||
|
"startLongitude" DOUBLE PRECISION NOT NULL,
|
||||||
|
"endLatitude" DOUBLE PRECISION,
|
||||||
|
"endLongitude" DOUBLE PRECISION,
|
||||||
|
"distanceMiles" DOUBLE PRECISION,
|
||||||
|
"durationSeconds" INTEGER,
|
||||||
|
"topSpeedMph" DOUBLE PRECISION,
|
||||||
|
"averageSpeedMph" DOUBLE PRECISION,
|
||||||
|
"pointCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"matchedRoute" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "gps_trips_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "gps_trips_deviceId_startTime_idx" ON "gps_trips"("deviceId", "startTime");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "gps_trips_status_idx" ON "gps_trips"("status");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "gps_trips" ADD CONSTRAINT "gps_trips_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "gps_devices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -347,6 +405,7 @@ model GpsDevice {
|
|||||||
|
|
||||||
// Location history
|
// Location history
|
||||||
locationHistory GpsLocationHistory[]
|
locationHistory GpsLocationHistory[]
|
||||||
|
trips GpsTrip[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -362,7 +421,7 @@ model GpsLocationHistory {
|
|||||||
latitude Float
|
latitude Float
|
||||||
longitude Float
|
longitude Float
|
||||||
altitude Float?
|
altitude Float?
|
||||||
speed Float? // km/h
|
speed Float? // mph (converted from knots during sync)
|
||||||
course Float? // Bearing in degrees
|
course Float? // Bearing in degrees
|
||||||
accuracy Float? // Meters
|
accuracy Float? // Meters
|
||||||
battery Float? // Battery percentage (0-100)
|
battery Float? // Battery percentage (0-100)
|
||||||
@@ -372,10 +431,49 @@ model GpsLocationHistory {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@map("gps_location_history")
|
@@map("gps_location_history")
|
||||||
@@index([deviceId, timestamp])
|
@@unique([deviceId, timestamp]) // Prevent duplicate position records
|
||||||
@@index([timestamp]) // For cleanup job
|
@@index([timestamp]) // For cleanup job
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TripStatus {
|
||||||
|
ACTIVE // Currently in progress
|
||||||
|
COMPLETED // Finished, OSRM route computed
|
||||||
|
PROCESSING // OSRM computation in progress
|
||||||
|
FAILED // OSRM computation failed
|
||||||
|
}
|
||||||
|
|
||||||
|
model GpsTrip {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
deviceId String
|
||||||
|
device GpsDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
status TripStatus @default(ACTIVE)
|
||||||
|
|
||||||
|
startTime DateTime
|
||||||
|
endTime DateTime?
|
||||||
|
startLatitude Float
|
||||||
|
startLongitude Float
|
||||||
|
endLatitude Float?
|
||||||
|
endLongitude Float?
|
||||||
|
|
||||||
|
// Pre-computed stats (filled on completion)
|
||||||
|
distanceMiles Float?
|
||||||
|
durationSeconds Int?
|
||||||
|
topSpeedMph Float?
|
||||||
|
averageSpeedMph Float?
|
||||||
|
pointCount Int @default(0)
|
||||||
|
|
||||||
|
// Pre-computed OSRM route (stored as JSON for instant display)
|
||||||
|
matchedRoute Json? // { coordinates: [lat,lng][], distance, duration, confidence }
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("gps_trips")
|
||||||
|
@@index([deviceId, startTime])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
model GpsSettings {
|
model GpsSettings {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|
||||||
|
|||||||
@@ -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.',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ export class AuthService {
|
|||||||
const name = payload[`${namespace}/name`] || payload.name || 'Unknown User';
|
const name = payload[`${namespace}/name`] || payload.name || 'Unknown User';
|
||||||
const picture = payload[`${namespace}/picture`] || payload.picture;
|
const picture = payload[`${namespace}/picture`] || payload.picture;
|
||||||
|
|
||||||
// Check if user exists (exclude soft-deleted users)
|
// Check if user exists (soft-deleted users automatically excluded by middleware)
|
||||||
let user = await this.prisma.user.findFirst({
|
let user = await this.prisma.user.findFirst({
|
||||||
where: { auth0Id, deletedAt: null },
|
where: { auth0Id },
|
||||||
include: { driver: true },
|
include: { driver: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export class AuthService {
|
|||||||
// where two simultaneous registrations both become admin
|
// where two simultaneous registrations both become admin
|
||||||
user = await this.prisma.$transaction(async (tx) => {
|
user = await this.prisma.$transaction(async (tx) => {
|
||||||
const approvedUserCount = await tx.user.count({
|
const approvedUserCount = await tx.user.count({
|
||||||
where: { isApproved: true, deletedAt: null },
|
where: { isApproved: true },
|
||||||
});
|
});
|
||||||
const isFirstUser = approvedUserCount === 0;
|
const isFirstUser = approvedUserCount === 0;
|
||||||
|
|
||||||
@@ -63,11 +63,11 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current user profile (excludes soft-deleted users)
|
* Get current user profile (soft-deleted users automatically excluded by middleware)
|
||||||
*/
|
*/
|
||||||
async getCurrentUser(auth0Id: string) {
|
async getCurrentUser(auth0Id: string) {
|
||||||
return this.prisma.user.findFirst({
|
return this.prisma.user.findFirst({
|
||||||
where: { auth0Id, deletedAt: null },
|
where: { auth0Id },
|
||||||
include: { driver: true },
|
include: { driver: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
1
backend/src/common/pipes/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './parse-boolean.pipe';
|
||||||
49
backend/src/common/pipes/parse-boolean.pipe.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms query string values to proper booleans.
|
||||||
|
*
|
||||||
|
* Handles common boolean string representations:
|
||||||
|
* - 'true', '1', 'yes', 'on' → true
|
||||||
|
* - 'false', '0', 'no', 'off' → false
|
||||||
|
* - undefined, null, '' → false (default)
|
||||||
|
* - Any other value → BadRequestException
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Delete(':id')
|
||||||
|
* async remove(
|
||||||
|
* @Param('id') id: string,
|
||||||
|
* @Query('hard', ParseBooleanPipe) hard: boolean,
|
||||||
|
* ) {
|
||||||
|
* return this.service.remove(id, hard);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ParseBooleanPipe implements PipeTransform<string | undefined, boolean> {
|
||||||
|
transform(value: string | undefined): boolean {
|
||||||
|
// Handle undefined, null, or empty string as false (default)
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize to lowercase for comparison
|
||||||
|
const normalized = value.toLowerCase().trim();
|
||||||
|
|
||||||
|
// True values
|
||||||
|
if (['true', '1', 'yes', 'on'].includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// False values
|
||||||
|
if (['false', '0', 'no', 'off'].includes(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid value
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid boolean value: "${value}". Expected: true, false, 1, 0, yes, no, on, off`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
backend/src/common/utils/date.utils.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Date utility functions to consolidate common date manipulation patterns
|
||||||
|
* across the VIP Coordinator application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Date object to ISO date string format (YYYY-MM-DD).
|
||||||
|
* Replaces the repetitive pattern: date.toISOString().split('T')[0]
|
||||||
|
*
|
||||||
|
* @param date - The date to convert
|
||||||
|
* @returns ISO date string in YYYY-MM-DD format
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const dateStr = toDateString(new Date('2024-01-15T10:30:00Z'));
|
||||||
|
* // Returns: '2024-01-15'
|
||||||
|
*/
|
||||||
|
export function toDateString(date: Date): string {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a Date object to the start of the day (00:00:00.000).
|
||||||
|
* Replaces the pattern: date.setHours(0, 0, 0, 0)
|
||||||
|
*
|
||||||
|
* @param date - The date to normalize
|
||||||
|
* @returns A new Date object set to the start of the day
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const dayStart = startOfDay(new Date('2024-01-15T15:45:30Z'));
|
||||||
|
* // Returns: Date object at 2024-01-15T00:00:00.000
|
||||||
|
*/
|
||||||
|
export function startOfDay(date: Date): Date {
|
||||||
|
const normalized = new Date(date);
|
||||||
|
normalized.setHours(0, 0, 0, 0);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a Date object to the end of the day (23:59:59.999).
|
||||||
|
*
|
||||||
|
* @param date - The date to normalize
|
||||||
|
* @returns A new Date object set to the end of the day
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const dayEnd = endOfDay(new Date('2024-01-15T10:30:00Z'));
|
||||||
|
* // Returns: Date object at 2024-01-15T23:59:59.999
|
||||||
|
*/
|
||||||
|
export function endOfDay(date: Date): Date {
|
||||||
|
const normalized = new Date(date);
|
||||||
|
normalized.setHours(23, 59, 59, 999);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts optional date string fields to Date objects for multiple fields at once.
|
||||||
|
* Useful for DTO to Prisma data transformation where only provided fields should be converted.
|
||||||
|
*
|
||||||
|
* @param obj - The object containing date string fields
|
||||||
|
* @param fields - Array of field names that should be converted to Date objects if present
|
||||||
|
* @returns New object with specified fields converted to Date objects
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const dto = {
|
||||||
|
* name: 'Flight 123',
|
||||||
|
* scheduledDeparture: '2024-01-15T10:00:00Z',
|
||||||
|
* scheduledArrival: '2024-01-15T12:00:00Z',
|
||||||
|
* actualDeparture: undefined,
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* const data = convertOptionalDates(dto, [
|
||||||
|
* 'scheduledDeparture',
|
||||||
|
* 'scheduledArrival',
|
||||||
|
* 'actualDeparture',
|
||||||
|
* 'actualArrival'
|
||||||
|
* ]);
|
||||||
|
*
|
||||||
|
* // Result: {
|
||||||
|
* // name: 'Flight 123',
|
||||||
|
* // scheduledDeparture: Date object,
|
||||||
|
* // scheduledArrival: Date object,
|
||||||
|
* // actualDeparture: undefined,
|
||||||
|
* // actualArrival: undefined
|
||||||
|
* // }
|
||||||
|
*/
|
||||||
|
export function convertOptionalDates<T extends Record<string, any>>(
|
||||||
|
obj: T,
|
||||||
|
fields: (keyof T)[],
|
||||||
|
): T {
|
||||||
|
const result = { ...obj };
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
const value = obj[field];
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
result[field] = new Date(value as any) as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
78
backend/src/common/utils/hard-delete.utils.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { ForbiddenException, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforces hard-delete authorization and executes the appropriate delete operation.
|
||||||
|
*
|
||||||
|
* @param options Configuration object
|
||||||
|
* @param options.id Entity ID to delete
|
||||||
|
* @param options.hardDelete Whether to perform hard delete (true) or soft delete (false)
|
||||||
|
* @param options.userRole User's role (required for hard delete authorization)
|
||||||
|
* @param options.findOne Function to find and verify entity exists
|
||||||
|
* @param options.performHardDelete Function to perform hard delete (e.g., prisma.model.delete)
|
||||||
|
* @param options.performSoftDelete Function to perform soft delete (e.g., prisma.model.update)
|
||||||
|
* @param options.entityName Name of entity for logging (e.g., 'VIP', 'Driver')
|
||||||
|
* @param options.logger Logger instance for the service
|
||||||
|
* @returns Promise resolving to the deleted entity
|
||||||
|
* @throws {ForbiddenException} If non-admin attempts hard delete
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* async remove(id: string, hardDelete = false, userRole?: string) {
|
||||||
|
* return executeHardDelete({
|
||||||
|
* id,
|
||||||
|
* hardDelete,
|
||||||
|
* userRole,
|
||||||
|
* findOne: async (id) => this.findOne(id),
|
||||||
|
* performHardDelete: async (id) => this.prisma.vIP.delete({ where: { id } }),
|
||||||
|
* performSoftDelete: async (id) => this.prisma.vIP.update({
|
||||||
|
* where: { id },
|
||||||
|
* data: { deletedAt: new Date() },
|
||||||
|
* }),
|
||||||
|
* entityName: 'VIP',
|
||||||
|
* logger: this.logger,
|
||||||
|
* });
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function executeHardDelete<T>(options: {
|
||||||
|
id: string;
|
||||||
|
hardDelete: boolean;
|
||||||
|
userRole?: string;
|
||||||
|
findOne: (id: string) => Promise<T & { id: string; name?: string }>;
|
||||||
|
performHardDelete: (id: string) => Promise<any>;
|
||||||
|
performSoftDelete: (id: string) => Promise<any>;
|
||||||
|
entityName: string;
|
||||||
|
logger: Logger;
|
||||||
|
}): Promise<any> {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
hardDelete,
|
||||||
|
userRole,
|
||||||
|
findOne,
|
||||||
|
performHardDelete,
|
||||||
|
performSoftDelete,
|
||||||
|
entityName,
|
||||||
|
logger,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Authorization check: only administrators can hard delete
|
||||||
|
if (hardDelete && userRole !== 'ADMINISTRATOR') {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Only administrators can permanently delete records',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify entity exists
|
||||||
|
const entity = await findOne(id);
|
||||||
|
|
||||||
|
// Perform the appropriate delete operation
|
||||||
|
if (hardDelete) {
|
||||||
|
const entityLabel = entity.name || entity.id;
|
||||||
|
logger.log(`Hard deleting ${entityName}: ${entityLabel}`);
|
||||||
|
return performHardDelete(entity.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityLabel = entity.name || entity.id;
|
||||||
|
logger.log(`Soft deleting ${entityName}: ${entityLabel}`);
|
||||||
|
return performSoftDelete(entity.id);
|
||||||
|
}
|
||||||
7
backend/src/common/utils/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Common utility functions used throughout the application.
|
||||||
|
* Export all utilities from this central location for easier imports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './date.utils';
|
||||||
|
export * from './hard-delete.utils';
|
||||||
462
backend/src/copilot/copilot-fleet.service.ts
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { toDateString, startOfDay } from '../common/utils/date.utils';
|
||||||
|
|
||||||
|
interface ToolResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: any;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CopilotFleetService {
|
||||||
|
private readonly logger = new Logger(CopilotFleetService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getAvailableVehicles(filters: Record<string, any>): Promise<ToolResult> {
|
||||||
|
const where: any = { deletedAt: null, status: 'AVAILABLE' };
|
||||||
|
|
||||||
|
if (filters.type) {
|
||||||
|
where.type = filters.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.minSeats) {
|
||||||
|
where.seatCapacity = { gte: filters.minSeats };
|
||||||
|
}
|
||||||
|
|
||||||
|
const vehicles = await this.prisma.vehicle.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ type: 'asc' }, { seatCapacity: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: vehicles,
|
||||||
|
message: `Found ${vehicles.length} available vehicle(s).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignVehicleToEvent(eventId: string, vehicleId: string): Promise<ToolResult> {
|
||||||
|
const event = await this.prisma.scheduleEvent.findFirst({
|
||||||
|
where: { id: eventId, deletedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return { success: false, error: `Event with ID ${eventId} not found.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If vehicleId is null, we're unassigning
|
||||||
|
if (vehicleId === null || vehicleId === 'null') {
|
||||||
|
const updatedEvent = await this.prisma.scheduleEvent.update({
|
||||||
|
where: { id: eventId },
|
||||||
|
data: { vehicleId: null },
|
||||||
|
include: {
|
||||||
|
driver: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: updatedEvent,
|
||||||
|
message: `Vehicle unassigned from event "${updatedEvent.title}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify vehicle exists
|
||||||
|
const vehicle = await this.prisma.vehicle.findFirst({
|
||||||
|
where: { id: vehicleId, deletedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!vehicle) {
|
||||||
|
return { success: false, error: `Vehicle with ID ${vehicleId} not found.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedEvent = await this.prisma.scheduleEvent.update({
|
||||||
|
where: { id: eventId },
|
||||||
|
data: { vehicleId },
|
||||||
|
include: {
|
||||||
|
driver: true,
|
||||||
|
vehicle: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: updatedEvent,
|
||||||
|
message: `Vehicle ${vehicle.name} assigned to event "${updatedEvent.title}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async suggestVehicleForEvent(input: Record<string, any>): Promise<ToolResult> {
|
||||||
|
const { eventId } = input;
|
||||||
|
|
||||||
|
const event = await this.prisma.scheduleEvent.findFirst({
|
||||||
|
where: { id: eventId, deletedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return { success: false, error: `Event with ID ${eventId} not found.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch VIP info to determine party size
|
||||||
|
const vips = await this.prisma.vIP.findMany({
|
||||||
|
where: { id: { in: event.vipIds } },
|
||||||
|
select: { id: true, name: true, partySize: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine required capacity based on total party size
|
||||||
|
const requiredSeats = vips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||||
|
|
||||||
|
// Find vehicles not in use during this event time
|
||||||
|
const busyVehicleIds = await this.prisma.scheduleEvent.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
id: { not: eventId },
|
||||||
|
status: { not: 'CANCELLED' },
|
||||||
|
vehicleId: { not: null },
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
startTime: { lte: event.startTime },
|
||||||
|
endTime: { gt: event.startTime },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startTime: { lt: event.endTime },
|
||||||
|
endTime: { gte: event.endTime },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { vehicleId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const busyIds = busyVehicleIds.map((e) => e.vehicleId).filter((id): id is string => id !== null);
|
||||||
|
|
||||||
|
// Find available vehicles with sufficient capacity
|
||||||
|
const suitableVehicles = await this.prisma.vehicle.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
seatCapacity: { gte: requiredSeats },
|
||||||
|
id: { notIn: busyIds },
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ seatCapacity: 'asc' }, // Prefer smallest suitable vehicle
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
eventId,
|
||||||
|
eventTitle: event.title,
|
||||||
|
vipNames: vips.map((v) => v.name),
|
||||||
|
requiredSeats,
|
||||||
|
suggestions: suitableVehicles.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
name: v.name,
|
||||||
|
type: v.type,
|
||||||
|
seatCapacity: v.seatCapacity,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
message:
|
||||||
|
suitableVehicles.length > 0
|
||||||
|
? `Found ${suitableVehicles.length} suitable vehicle(s) for this event (requires ${requiredSeats} seat(s)).`
|
||||||
|
: `No available vehicles found with capacity for ${requiredSeats} passenger(s) during this time.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVehicleSchedule(input: Record<string, any>): Promise<ToolResult> {
|
||||||
|
const { vehicleName, vehicleId, startDate, endDate } = input;
|
||||||
|
|
||||||
|
let vehicle;
|
||||||
|
|
||||||
|
if (vehicleId) {
|
||||||
|
vehicle = await this.prisma.vehicle.findFirst({
|
||||||
|
where: { id: vehicleId, deletedAt: null },
|
||||||
|
});
|
||||||
|
} else if (vehicleName) {
|
||||||
|
const vehicles = await this.prisma.vehicle.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
name: { contains: vehicleName, mode: 'insensitive' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (vehicles.length === 0) {
|
||||||
|
return { success: false, error: `No vehicle found matching "${vehicleName}".` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vehicles.length > 1) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Multiple vehicles match "${vehicleName}": ${vehicles.map((v) => v.name).join(', ')}. Please be more specific.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vehicle = vehicles[0];
|
||||||
|
} else {
|
||||||
|
return { success: false, error: 'Either vehicleName or vehicleId is required.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vehicle) {
|
||||||
|
return { success: false, error: 'Vehicle not found.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStart = startOfDay(new Date(startDate));
|
||||||
|
const dateEnd = new Date(endDate);
|
||||||
|
dateEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const events = await this.prisma.scheduleEvent.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
vehicleId: vehicle.id,
|
||||||
|
startTime: { gte: dateStart, lte: dateEnd },
|
||||||
|
status: { not: 'CANCELLED' },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
driver: true,
|
||||||
|
},
|
||||||
|
orderBy: { startTime: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch VIP names for all events
|
||||||
|
const allVipIds = events.flatMap((e) => e.vipIds);
|
||||||
|
const uniqueVipIds = [...new Set(allVipIds)];
|
||||||
|
const vips = await this.prisma.vIP.findMany({
|
||||||
|
where: { id: { in: uniqueVipIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
|
||||||
|
|
||||||
|
const totalHours =
|
||||||
|
events.reduce((sum, e) => {
|
||||||
|
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||||
|
}, 0) / 3600000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
vehicle: {
|
||||||
|
id: vehicle.id,
|
||||||
|
name: vehicle.name,
|
||||||
|
type: vehicle.type,
|
||||||
|
seatCapacity: vehicle.seatCapacity,
|
||||||
|
status: vehicle.status,
|
||||||
|
},
|
||||||
|
dateRange: {
|
||||||
|
start: toDateString(dateStart),
|
||||||
|
end: toDateString(dateEnd),
|
||||||
|
},
|
||||||
|
eventCount: events.length,
|
||||||
|
totalHours: Math.round(totalHours * 10) / 10,
|
||||||
|
events: events.map((e) => ({
|
||||||
|
eventId: e.id,
|
||||||
|
title: e.title,
|
||||||
|
type: e.type,
|
||||||
|
startTime: e.startTime,
|
||||||
|
endTime: e.endTime,
|
||||||
|
vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
|
||||||
|
driverName: e.driver?.name || null,
|
||||||
|
pickupLocation: e.pickupLocation,
|
||||||
|
dropoffLocation: e.dropoffLocation,
|
||||||
|
location: e.location,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
message: `Vehicle ${vehicle.name} has ${events.length} scheduled event(s) (${Math.round(totalHours * 10) / 10} hours total).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchDrivers(filters: Record<string, any>): Promise<ToolResult> {
|
||||||
|
const where: any = { deletedAt: null };
|
||||||
|
|
||||||
|
if (filters.name) {
|
||||||
|
where.name = { contains: filters.name, mode: 'insensitive' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.department) {
|
||||||
|
where.department = filters.department;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.availableOnly) {
|
||||||
|
where.isAvailable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drivers = await this.prisma.driver.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: drivers,
|
||||||
|
message: `Found ${drivers.length} driver(s) matching the criteria.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDriverSchedule(
|
||||||
|
driverId: string,
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string,
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const driver = await this.prisma.driver.findFirst({
|
||||||
|
where: { id: driverId, deletedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!driver) {
|
||||||
|
return { success: false, error: `Driver with ID ${driverId} not found.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: any = {
|
||||||
|
deletedAt: null,
|
||||||
|
driverId,
|
||||||
|
status: { not: 'CANCELLED' },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
where.startTime = { gte: new Date(startDate) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
where.endTime = { lte: new Date(endDate) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await this.prisma.scheduleEvent.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
vehicle: true,
|
||||||
|
},
|
||||||
|
orderBy: { startTime: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch VIP names for all events
|
||||||
|
const allVipIds = events.flatMap((e) => e.vipIds);
|
||||||
|
const uniqueVipIds = [...new Set(allVipIds)];
|
||||||
|
const vips = await this.prisma.vIP.findMany({
|
||||||
|
where: { id: { in: uniqueVipIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
|
||||||
|
|
||||||
|
const eventsWithVipNames = events.map((event) => ({
|
||||||
|
...event,
|
||||||
|
vipNames: event.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
driver,
|
||||||
|
events: eventsWithVipNames,
|
||||||
|
eventCount: events.length,
|
||||||
|
},
|
||||||
|
message: `Driver ${driver.name} has ${events.length} scheduled event(s).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAllDrivers(input: Record<string, any>): Promise<ToolResult> {
|
||||||
|
const { includeUnavailable = true } = input;
|
||||||
|
|
||||||
|
const where: any = { deletedAt: null };
|
||||||
|
|
||||||
|
if (!includeUnavailable) {
|
||||||
|
where.isAvailable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drivers = await this.prisma.driver.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
phone: true,
|
||||||
|
department: true,
|
||||||
|
isAvailable: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: drivers,
|
||||||
|
message: `Found ${drivers.length} driver(s) in the system.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAvailableDriversForTimerange(input: Record<string, any>): Promise<ToolResult> {
|
||||||
|
const { startTime, endTime, preferredDepartment } = input;
|
||||||
|
|
||||||
|
// Get all drivers
|
||||||
|
const where: any = { deletedAt: null, isAvailable: true };
|
||||||
|
|
||||||
|
if (preferredDepartment) {
|
||||||
|
where.department = preferredDepartment;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDrivers = await this.prisma.driver.findMany({
|
||||||
|
where,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find drivers with conflicting events
|
||||||
|
const busyDriverIds = await this.prisma.scheduleEvent.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
driverId: { not: null },
|
||||||
|
status: { not: 'CANCELLED' },
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
startTime: { lte: new Date(startTime) },
|
||||||
|
endTime: { gt: new Date(startTime) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startTime: { lt: new Date(endTime) },
|
||||||
|
endTime: { gte: new Date(endTime) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { driverId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const busyIds = new Set(busyDriverIds.map((e) => e.driverId));
|
||||||
|
|
||||||
|
const availableDrivers = allDrivers.filter((d) => !busyIds.has(d.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: availableDrivers,
|
||||||
|
message: `Found ${availableDrivers.length} available driver(s) for the specified time range.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDriver(input: Record<string, any>): Promise<ToolResult> {
|
||||||
|
const { driverId, ...updates } = input;
|
||||||
|
|
||||||
|
const existingDriver = await this.prisma.driver.findFirst({
|
||||||
|
where: { id: driverId, deletedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingDriver) {
|
||||||
|
return { success: false, error: `Driver with ID ${driverId} not found.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (updates.name !== undefined) updateData.name = updates.name;
|
||||||
|
if (updates.phone !== undefined) updateData.phone = updates.phone;
|
||||||
|
if (updates.department !== undefined) updateData.department = updates.department;
|
||||||
|
if (updates.isAvailable !== undefined) updateData.isAvailable = updates.isAvailable;
|
||||||
|
if (updates.shiftStartTime !== undefined) updateData.shiftStartTime = updates.shiftStartTime;
|
||||||
|
if (updates.shiftEndTime !== undefined) updateData.shiftEndTime = updates.shiftEndTime;
|
||||||
|
|
||||||
|
const driver = await this.prisma.driver.update({
|
||||||
|
where: { id: driverId },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Driver updated: ${driverId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: driver,
|
||||||
|
message: `Driver ${driver.name} updated successfully.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
304
backend/src/copilot/copilot-reports.service.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { toDateString, startOfDay } from '../common/utils/date.utils';
|
||||||
|
|
||||||
|
interface ToolResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: any;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CopilotReportsService {
|
||||||
|
private readonly logger = new Logger(CopilotReportsService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getDriverWorkloadSummary(input: Record<string, any>): Promise<ToolResult> {
|
||||||
|
const { startDate, endDate } = input;
|
||||||
|
|
||||||
|
const dateStart = startOfDay(new Date(startDate));
|
||||||
|
const dateEnd = new Date(endDate);
|
||||||
|
dateEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
// Get all drivers
|
||||||
|
const drivers = await this.prisma.driver.findMany({
|
||||||
|
where: { deletedAt: null },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all events in range
|
||||||
|
const events = await this.prisma.scheduleEvent.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
startTime: { gte: dateStart, lte: dateEnd },
|
||||||
|
status: { not: 'CANCELLED' },
|
||||||
|
driverId: { not: null },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
driver: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate workload for each driver
|
||||||
|
const workloadData = drivers.map((driver) => {
|
||||||
|
const driverEvents = events.filter((e) => e.driverId === driver.id);
|
||||||
|
|
||||||
|
const totalHours =
|
||||||
|
driverEvents.reduce((sum, e) => {
|
||||||
|
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||||
|
}, 0) / 3600000;
|
||||||
|
|
||||||
|
const totalDays = Math.ceil(
|
||||||
|
(dateEnd.getTime() - dateStart.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
|
||||||
|
const eventsByType = driverEvents.reduce(
|
||||||
|
(acc, e) => {
|
||||||
|
acc[e.type] = (acc[e.type] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
driverId: driver.id,
|
||||||
|
driverName: driver.name,
|
||||||
|
department: driver.department,
|
||||||
|
isAvailable: driver.isAvailable,
|
||||||
|
eventCount: driverEvents.length,
|
||||||
|
totalHours: Math.round(totalHours * 10) / 10,
|
||||||
|
averageHoursPerDay: Math.round((totalHours / totalDays) * 10) / 10,
|
||||||
|
eventsByType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by total hours descending
|
||||||
|
workloadData.sort((a, b) => b.totalHours - a.totalHours);
|
||||||
|
|
||||||
|
const totalEvents = events.length;
|
||||||
|
const totalHours =
|
||||||
|
events.reduce((sum, e) => {
|
||||||
|
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||||
|
}, 0) / 3600000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
dateRange: {
|
||||||
|
start: toDateString(dateStart),
|
||||||
|
end: toDateString(dateEnd),
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
totalDrivers: drivers.length,
|
||||||
|
totalEvents,
|
||||||
|
totalHours: Math.round(totalHours * 10) / 10,
|
||||||
|
averageEventsPerDriver: Math.round((totalEvents / drivers.length) * 10) / 10,
|
||||||
|
},
|
||||||
|
driverWorkloads: workloadData,
|
||||||
|
},
|
||||||
|
message: `Workload summary for ${drivers.length} driver(s) from ${toDateString(dateStart)} to ${toDateString(dateEnd)}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentSystemStatus(): Promise<ToolResult> {
|
||||||
|
const now = new Date();
|
||||||
|
const today = startOfDay(now);
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const nextWeek = new Date(today);
|
||||||
|
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||||
|
|
||||||
|
const [
|
||||||
|
vipCount,
|
||||||
|
vehicleCount,
|
||||||
|
driverCount,
|
||||||
|
todaysEvents,
|
||||||
|
upcomingEvents,
|
||||||
|
unassignedEvents,
|
||||||
|
availableDrivers,
|
||||||
|
availableVehicles,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.prisma.vIP.count({ where: { deletedAt: null } }),
|
||||||
|
this.prisma.vehicle.count({ where: { deletedAt: null } }),
|
||||||
|
this.prisma.driver.count({ where: { deletedAt: null } }),
|
||||||
|
this.prisma.scheduleEvent.count({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
startTime: { gte: today, lt: tomorrow },
|
||||||
|
status: { not: 'CANCELLED' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.scheduleEvent.count({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
startTime: { gte: tomorrow, lt: nextWeek },
|
||||||
|
status: { not: 'CANCELLED' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.scheduleEvent.count({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
startTime: { gte: now },
|
||||||
|
status: { in: ['SCHEDULED'] },
|
||||||
|
OR: [{ driverId: null }, { vehicleId: null }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.driver.count({ where: { deletedAt: null, isAvailable: true } }),
|
||||||
|
this.prisma.vehicle.count({ where: { deletedAt: null, status: 'AVAILABLE' } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const status = {
|
||||||
|
timestamp: now.toISOString(),
|
||||||
|
resources: {
|
||||||
|
vips: vipCount,
|
||||||
|
drivers: { total: driverCount, available: availableDrivers },
|
||||||
|
vehicles: { total: vehicleCount, available: availableVehicles },
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
today: todaysEvents,
|
||||||
|
next7Days: upcomingEvents,
|
||||||
|
needingAttention: unassignedEvents,
|
||||||
|
},
|
||||||
|
alerts: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add alerts for issues
|
||||||
|
if (unassignedEvents > 0) {
|
||||||
|
status.alerts.push(`${unassignedEvents} upcoming event(s) need driver/vehicle assignment`);
|
||||||
|
}
|
||||||
|
if (availableDrivers === 0) {
|
||||||
|
status.alerts.push('No drivers currently marked as available');
|
||||||
|
}
|
||||||
|
if (availableVehicles === 0) {
|
||||||
|
status.alerts.push('No vehicles currently available');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: status,
|
||||||
|
message:
|
||||||
|
status.alerts.length > 0
|
||||||
|
? `System status retrieved. ATTENTION: ${status.alerts.length} alert(s) require attention.`
|
||||||
|
: 'System status retrieved. No immediate issues.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTodaysSummary(): Promise<ToolResult> {
|
||||||
|
const today = startOfDay(new Date());
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
// Get today's events
|
||||||
|
const events = await this.prisma.scheduleEvent.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
startTime: { gte: today, lt: tomorrow },
|
||||||
|
status: { not: 'CANCELLED' },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
driver: true,
|
||||||
|
vehicle: true,
|
||||||
|
},
|
||||||
|
orderBy: { startTime: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch VIP names for all events
|
||||||
|
const allVipIds = events.flatMap((e) => e.vipIds);
|
||||||
|
const uniqueVipIds = [...new Set(allVipIds)];
|
||||||
|
const vips = await this.prisma.vIP.findMany({
|
||||||
|
where: { id: { in: uniqueVipIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
|
||||||
|
|
||||||
|
// Get VIPs arriving today (flights or self-driving)
|
||||||
|
const arrivingVips = await this.prisma.vIP.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
expectedArrival: { gte: today, lt: tomorrow },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
flights: {
|
||||||
|
some: {
|
||||||
|
scheduledArrival: { gte: today, lt: tomorrow },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
flights: {
|
||||||
|
where: {
|
||||||
|
scheduledArrival: { gte: today, lt: tomorrow },
|
||||||
|
},
|
||||||
|
orderBy: { scheduledArrival: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get driver assignments
|
||||||
|
const driversOnDuty = events
|
||||||
|
.filter((e) => e.driver)
|
||||||
|
.reduce((acc, e) => {
|
||||||
|
if (e.driver && !acc.find((d) => d.id === e.driver!.id)) {
|
||||||
|
acc.push(e.driver);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as NonNullable<typeof events[0]['driver']>[]);
|
||||||
|
|
||||||
|
// Unassigned events
|
||||||
|
const unassigned = events.filter((e) => !e.driverId || !e.vehicleId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
date: toDateString(today),
|
||||||
|
summary: {
|
||||||
|
totalEvents: events.length,
|
||||||
|
arrivingVips: arrivingVips.length,
|
||||||
|
driversOnDuty: driversOnDuty.length,
|
||||||
|
unassignedEvents: unassigned.length,
|
||||||
|
},
|
||||||
|
events: events.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
time: e.startTime,
|
||||||
|
title: e.title,
|
||||||
|
type: e.type,
|
||||||
|
vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
|
||||||
|
driverName: e.driver?.name || 'UNASSIGNED',
|
||||||
|
vehicleName: e.vehicle?.name || 'UNASSIGNED',
|
||||||
|
location: e.location || e.pickupLocation,
|
||||||
|
})),
|
||||||
|
arrivingVips: arrivingVips.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
name: v.name,
|
||||||
|
arrivalMode: v.arrivalMode,
|
||||||
|
expectedArrival: v.expectedArrival,
|
||||||
|
flights: v.flights.map((f) => ({
|
||||||
|
flightNumber: f.flightNumber,
|
||||||
|
scheduledArrival: f.scheduledArrival,
|
||||||
|
arrivalAirport: f.arrivalAirport,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
driversOnDuty: driversOnDuty.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
eventCount: events.filter((e) => e.driverId === d.id).length,
|
||||||
|
})),
|
||||||
|
unassignedEvents: unassigned.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
time: e.startTime,
|
||||||
|
title: e.title,
|
||||||
|
vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
|
||||||
|
needsDriver: !e.driverId,
|
||||||
|
needsVehicle: !e.vehicleId,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
message: `Today's summary: ${events.length} event(s), ${arrivingVips.length} VIP(s) arriving, ${unassigned.length} unassigned.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
1282
backend/src/copilot/copilot-schedule.service.ts
Normal file
275
backend/src/copilot/copilot-vip.service.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
interface ToolResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: any;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CopilotVipService {
|
||||||
|
private readonly logger = new Logger(CopilotVipService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async searchVips(filters: Record<string, any>): Promise<ToolResult> {
|
||||||
|
const where: any = { deletedAt: null };
|
||||||
|
|
||||||
|
if (filters.name) {
|
||||||
|
where.name = { contains: filters.name, mode: 'insensitive' };
|
||||||
|
}
|
||||||
|
if (filters.organization) {
|
||||||
|
where.organization = { contains: filters.organization, mode: 'insensitive' };
|
||||||
|
}
|
||||||
|
if (filters.department) {
|
||||||
|
where.department = filters.department;
|
||||||
|
}
|
||||||
|
if (filters.arrivalMode) {
|
||||||
|
where.arrivalMode = filters.arrivalMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vips = await this.prisma.vIP.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
flights: true,
|
||||||
|
},
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch events for these VIPs
|
||||||
|
const vipIds = vips.map((v) => v.id);
|
||||||
|
const events = await this.prisma.scheduleEvent.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
vipIds: { hasSome: vipIds },
|
||||||
|
},
|
||||||
|
orderBy: { startTime: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach events to VIPs
|
||||||
|
const vipsWithEvents = vips.map((vip) => ({
|
||||||
|
...vip,
|
||||||
|
events: events.filter((e) => e.vipIds.includes(vip.id)).slice(0, 5),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { success: true, data: vipsWithEvents };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVipDetails(vipId: string): Promise<ToolResult> {
|
||||||
|
const vip = await this.prisma.vIP.findUnique({
|
||||||
|
where: { id: vipId },
|
||||||
|
include: {
|
||||||
|
flights: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!vip) {
|
||||||
|
return { success: false, error: 'VIP not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch events for this VIP
|
||||||
|
const events = await this.prisma.scheduleEvent.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
vipIds: { has: vipId },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
driver: true,
|
||||||
|
vehicle: true,
|
||||||
|
},
|
||||||
|
orderBy: { startTime: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: { ...vip, events } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createVip(input: Record<string, any>): Promise<ToolResult> {
|
||||||
|
const vip = await this.prisma.vIP.create({
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
organization: input.organization,
|
||||||
|
department: input.department,
|
||||||
|
arrivalMode: input.arrivalMode,
|
||||||
|
expectedArrival: input.expectedArrival ? new Date(input.expectedArrival) : null,
|
||||||
|
airportPickup: input.airportPickup ?? false,
|
||||||
|
venueTransport: input.venueTransport ?? false,
|
||||||
|
partySize: input.partySize ?? 1,
|
||||||
|
notes: input.notes,
|
||||||
|
isRosterOnly: input.isRosterOnly ?? false,
|
||||||
|
phone: input.phone || null,
|
||||||
|
email: input.email || null,
|
||||||
|
emergencyContactName: input.emergencyContactName || null,
|
||||||
|
emergencyContactPhone: input.emergencyContactPhone || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: vip };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateVip(input: Record<string, any>): Promise<ToolResult> {
|
||||||
|
const { vipId, ...updateData } = input;
|
||||||
|
|
||||||
|
const data: any = {};
|
||||||
|
if (updateData.name !== undefined) data.name = updateData.name;
|
||||||
|
if (updateData.organization !== undefined) data.organization = updateData.organization;
|
||||||
|
if (updateData.department !== undefined) data.department = updateData.department;
|
||||||
|
if (updateData.arrivalMode !== undefined) data.arrivalMode = updateData.arrivalMode;
|
||||||
|
if (updateData.expectedArrival !== undefined)
|
||||||
|
data.expectedArrival = updateData.expectedArrival
|
||||||
|
? new Date(updateData.expectedArrival)
|
||||||
|
: null;
|
||||||
|
if (updateData.airportPickup !== undefined) data.airportPickup = updateData.airportPickup;
|
||||||
|
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.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({
|
||||||
|
where: { id: vipId },
|
||||||
|
data,
|
||||||
|
include: { flights: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: vip };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVipItinerary(input: Record<string, any>): Promise<ToolResult> {
|
||||||
|
const { vipId, startDate, endDate } = input;
|
||||||
|
|
||||||
|
const vip = await this.prisma.vIP.findUnique({
|
||||||
|
where: { id: vipId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!vip) {
|
||||||
|
return { success: false, error: 'VIP not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build date filters
|
||||||
|
const dateFilter: any = {};
|
||||||
|
if (startDate) dateFilter.gte = new Date(startDate);
|
||||||
|
if (endDate) dateFilter.lte = new Date(endDate);
|
||||||
|
|
||||||
|
// Get flights
|
||||||
|
const flightsWhere: any = { vipId };
|
||||||
|
if (startDate || endDate) {
|
||||||
|
flightsWhere.flightDate = dateFilter;
|
||||||
|
}
|
||||||
|
const flights = await this.prisma.flight.findMany({
|
||||||
|
where: flightsWhere,
|
||||||
|
orderBy: { scheduledDeparture: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get events
|
||||||
|
const eventsWhere: any = {
|
||||||
|
deletedAt: null,
|
||||||
|
vipIds: { has: vipId },
|
||||||
|
};
|
||||||
|
if (startDate || endDate) {
|
||||||
|
eventsWhere.startTime = dateFilter;
|
||||||
|
}
|
||||||
|
const events = await this.prisma.scheduleEvent.findMany({
|
||||||
|
where: eventsWhere,
|
||||||
|
include: {
|
||||||
|
driver: true,
|
||||||
|
vehicle: true,
|
||||||
|
},
|
||||||
|
orderBy: { startTime: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine and sort chronologically
|
||||||
|
const itineraryItems: any[] = [
|
||||||
|
...flights.map((f) => ({
|
||||||
|
type: 'FLIGHT',
|
||||||
|
time: f.scheduledDeparture || f.flightDate,
|
||||||
|
data: f,
|
||||||
|
})),
|
||||||
|
...events.map((e) => ({
|
||||||
|
type: 'EVENT',
|
||||||
|
time: e.startTime,
|
||||||
|
data: e,
|
||||||
|
})),
|
||||||
|
].sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
vip,
|
||||||
|
itinerary: itineraryItems,
|
||||||
|
summary: {
|
||||||
|
totalFlights: flights.length,
|
||||||
|
totalEvents: events.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFlightsForVip(vipId: string): Promise<ToolResult> {
|
||||||
|
const flights = await this.prisma.flight.findMany({
|
||||||
|
where: { vipId },
|
||||||
|
orderBy: { flightDate: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: flights };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFlight(input: Record<string, any>): Promise<ToolResult> {
|
||||||
|
const flight = await this.prisma.flight.create({
|
||||||
|
data: {
|
||||||
|
vipId: input.vipId,
|
||||||
|
flightNumber: input.flightNumber,
|
||||||
|
flightDate: new Date(input.flightDate),
|
||||||
|
departureAirport: input.departureAirport,
|
||||||
|
arrivalAirport: input.arrivalAirport,
|
||||||
|
scheduledDeparture: input.scheduledDeparture
|
||||||
|
? new Date(input.scheduledDeparture)
|
||||||
|
: null,
|
||||||
|
scheduledArrival: input.scheduledArrival ? new Date(input.scheduledArrival) : null,
|
||||||
|
segment: input.segment || 1,
|
||||||
|
},
|
||||||
|
include: { vip: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: flight };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFlight(input: Record<string, any>): Promise<ToolResult> {
|
||||||
|
const { flightId, ...updateData } = input;
|
||||||
|
|
||||||
|
const flight = await this.prisma.flight.update({
|
||||||
|
where: { id: flightId },
|
||||||
|
data: updateData,
|
||||||
|
include: { vip: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: flight };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFlight(flightId: string): Promise<ToolResult> {
|
||||||
|
const flight = await this.prisma.flight.findUnique({
|
||||||
|
where: { id: flightId },
|
||||||
|
include: { vip: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!flight) {
|
||||||
|
return { success: false, error: 'Flight not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.flight.delete({
|
||||||
|
where: { id: flightId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { deleted: true, flight },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CopilotController } from './copilot.controller';
|
import { CopilotController } from './copilot.controller';
|
||||||
import { CopilotService } from './copilot.service';
|
import { CopilotService } from './copilot.service';
|
||||||
|
import { CopilotVipService } from './copilot-vip.service';
|
||||||
|
import { CopilotScheduleService } from './copilot-schedule.service';
|
||||||
|
import { CopilotFleetService } from './copilot-fleet.service';
|
||||||
|
import { CopilotReportsService } from './copilot-reports.service';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import { SignalModule } from '../signal/signal.module';
|
import { SignalModule } from '../signal/signal.module';
|
||||||
import { DriversModule } from '../drivers/drivers.module';
|
import { DriversModule } from '../drivers/drivers.module';
|
||||||
@@ -8,6 +12,12 @@ import { DriversModule } from '../drivers/drivers.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, SignalModule, DriversModule],
|
imports: [PrismaModule, SignalModule, DriversModule],
|
||||||
controllers: [CopilotController],
|
controllers: [CopilotController],
|
||||||
providers: [CopilotService],
|
providers: [
|
||||||
|
CopilotService,
|
||||||
|
CopilotVipService,
|
||||||
|
CopilotScheduleService,
|
||||||
|
CopilotFleetService,
|
||||||
|
CopilotReportsService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class CopilotModule {}
|
export class CopilotModule {}
|
||||||
|
|||||||
13
backend/src/drivers/decorators/current-driver.decorator.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameter decorator that extracts the current driver from the request.
|
||||||
|
* Should be used in conjunction with @UseInterceptors(ResolveDriverInterceptor)
|
||||||
|
* to ensure the driver is pre-resolved and attached to the request.
|
||||||
|
*/
|
||||||
|
export const CurrentDriver = createParamDecorator(
|
||||||
|
(data: unknown, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
return request.driver;
|
||||||
|
},
|
||||||
|
);
|
||||||
1
backend/src/drivers/decorators/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './current-driver.decorator';
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DriversService } from './drivers.service';
|
import { DriversService } from './drivers.service';
|
||||||
@@ -16,8 +17,12 @@ 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';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { CurrentDriver } from './decorators';
|
||||||
|
import { ResolveDriverInterceptor } from './interceptors';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { CreateDriverDto, UpdateDriverDto } from './dto';
|
import { CreateDriverDto, UpdateDriverDto } from './dto';
|
||||||
|
import { toDateString } from '../common/utils/date.utils';
|
||||||
|
import { ParseBooleanPipe } from '../common/pipes';
|
||||||
|
|
||||||
@Controller('drivers')
|
@Controller('drivers')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@@ -41,11 +46,8 @@ export class DriversController {
|
|||||||
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
async getMyDriverProfile(@CurrentUser() user: any) {
|
@UseInterceptors(ResolveDriverInterceptor)
|
||||||
const driver = await this.driversService.findByUserId(user.id);
|
getMyDriverProfile(@CurrentDriver() driver: any) {
|
||||||
if (!driver) {
|
|
||||||
throw new NotFoundException('Driver profile not found for current user');
|
|
||||||
}
|
|
||||||
return driver;
|
return driver;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,22 +57,19 @@ export class DriversController {
|
|||||||
*/
|
*/
|
||||||
@Get('me/schedule/ics')
|
@Get('me/schedule/ics')
|
||||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
|
@UseInterceptors(ResolveDriverInterceptor)
|
||||||
async getMyScheduleICS(
|
async getMyScheduleICS(
|
||||||
@CurrentUser() user: any,
|
@CurrentDriver() driver: any,
|
||||||
@Query('date') dateStr?: string,
|
@Query('date') dateStr?: string,
|
||||||
@Query('fullSchedule') fullScheduleStr?: string,
|
@Query('fullSchedule') fullScheduleStr?: string,
|
||||||
) {
|
) {
|
||||||
const driver = await this.driversService.findByUserId(user.id);
|
|
||||||
if (!driver) {
|
|
||||||
throw new NotFoundException('Driver profile not found for current user');
|
|
||||||
}
|
|
||||||
const date = dateStr ? new Date(dateStr) : new Date();
|
const date = dateStr ? new Date(dateStr) : new Date();
|
||||||
// Default to full schedule (true) unless explicitly set to false
|
// Default to full schedule (true) unless explicitly set to false
|
||||||
const fullSchedule = fullScheduleStr !== 'false';
|
const fullSchedule = fullScheduleStr !== 'false';
|
||||||
const icsContent = await this.scheduleExportService.generateICS(driver.id, date, fullSchedule);
|
const icsContent = await this.scheduleExportService.generateICS(driver.id, date, fullSchedule);
|
||||||
const filename = fullSchedule
|
const filename = fullSchedule
|
||||||
? `full-schedule-${new Date().toISOString().split('T')[0]}.ics`
|
? `full-schedule-${toDateString(new Date())}.ics`
|
||||||
: `schedule-${date.toISOString().split('T')[0]}.ics`;
|
: `schedule-${toDateString(date)}.ics`;
|
||||||
return { ics: icsContent, filename };
|
return { ics: icsContent, filename };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,22 +79,19 @@ export class DriversController {
|
|||||||
*/
|
*/
|
||||||
@Get('me/schedule/pdf')
|
@Get('me/schedule/pdf')
|
||||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
|
@UseInterceptors(ResolveDriverInterceptor)
|
||||||
async getMySchedulePDF(
|
async getMySchedulePDF(
|
||||||
@CurrentUser() user: any,
|
@CurrentDriver() driver: any,
|
||||||
@Query('date') dateStr?: string,
|
@Query('date') dateStr?: string,
|
||||||
@Query('fullSchedule') fullScheduleStr?: string,
|
@Query('fullSchedule') fullScheduleStr?: string,
|
||||||
) {
|
) {
|
||||||
const driver = await this.driversService.findByUserId(user.id);
|
|
||||||
if (!driver) {
|
|
||||||
throw new NotFoundException('Driver profile not found for current user');
|
|
||||||
}
|
|
||||||
const date = dateStr ? new Date(dateStr) : new Date();
|
const date = dateStr ? new Date(dateStr) : new Date();
|
||||||
// Default to full schedule (true) unless explicitly set to false
|
// Default to full schedule (true) unless explicitly set to false
|
||||||
const fullSchedule = fullScheduleStr !== 'false';
|
const fullSchedule = fullScheduleStr !== 'false';
|
||||||
const pdfBuffer = await this.scheduleExportService.generatePDF(driver.id, date, fullSchedule);
|
const pdfBuffer = await this.scheduleExportService.generatePDF(driver.id, date, fullSchedule);
|
||||||
const filename = fullSchedule
|
const filename = fullSchedule
|
||||||
? `full-schedule-${new Date().toISOString().split('T')[0]}.pdf`
|
? `full-schedule-${toDateString(new Date())}.pdf`
|
||||||
: `schedule-${date.toISOString().split('T')[0]}.pdf`;
|
: `schedule-${toDateString(date)}.pdf`;
|
||||||
return { pdf: pdfBuffer.toString('base64'), filename };
|
return { pdf: pdfBuffer.toString('base64'), filename };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,14 +101,11 @@ export class DriversController {
|
|||||||
*/
|
*/
|
||||||
@Post('me/send-schedule')
|
@Post('me/send-schedule')
|
||||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
|
@UseInterceptors(ResolveDriverInterceptor)
|
||||||
async sendMySchedule(
|
async sendMySchedule(
|
||||||
@CurrentUser() user: any,
|
@CurrentDriver() driver: any,
|
||||||
@Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both'; fullSchedule?: boolean },
|
@Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both'; fullSchedule?: boolean },
|
||||||
) {
|
) {
|
||||||
const driver = await this.driversService.findByUserId(user.id);
|
|
||||||
if (!driver) {
|
|
||||||
throw new NotFoundException('Driver profile not found for current user');
|
|
||||||
}
|
|
||||||
const date = body.date ? new Date(body.date) : new Date();
|
const date = body.date ? new Date(body.date) : new Date();
|
||||||
const format = body.format || 'both';
|
const format = body.format || 'both';
|
||||||
// Default to full schedule (true) unless explicitly set to false
|
// Default to full schedule (true) unless explicitly set to false
|
||||||
@@ -122,11 +115,8 @@ export class DriversController {
|
|||||||
|
|
||||||
@Patch('me')
|
@Patch('me')
|
||||||
@Roles(Role.DRIVER)
|
@Roles(Role.DRIVER)
|
||||||
async updateMyProfile(@CurrentUser() user: any, @Body() updateDriverDto: UpdateDriverDto) {
|
@UseInterceptors(ResolveDriverInterceptor)
|
||||||
const driver = await this.driversService.findByUserId(user.id);
|
updateMyProfile(@CurrentDriver() driver: any, @Body() updateDriverDto: UpdateDriverDto) {
|
||||||
if (!driver) {
|
|
||||||
throw new NotFoundException('Driver profile not found for current user');
|
|
||||||
}
|
|
||||||
return this.driversService.update(driver.id, updateDriverDto);
|
return this.driversService.update(driver.id, updateDriverDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,10 +209,9 @@ export class DriversController {
|
|||||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
remove(
|
remove(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Query('hard') hard?: string,
|
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||||
@CurrentUser() user?: any,
|
@CurrentUser() user?: any,
|
||||||
) {
|
) {
|
||||||
const isHardDelete = hard === 'true';
|
return this.driversService.remove(id, hard, user?.role);
|
||||||
return this.driversService.remove(id, isHardDelete, user?.role);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateDriverDto, UpdateDriverDto } from './dto';
|
import { CreateDriverDto, UpdateDriverDto } from './dto';
|
||||||
|
import { executeHardDelete } from '../common/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DriversService {
|
export class DriversService {
|
||||||
private readonly logger = new Logger(DriversService.name);
|
private readonly logger = new Logger(DriversService.name);
|
||||||
|
|
||||||
|
private readonly driverInclude = {
|
||||||
|
user: true,
|
||||||
|
events: {
|
||||||
|
include: { vehicle: true, driver: true },
|
||||||
|
orderBy: { startTime: 'asc' as const },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async create(createDriverDto: CreateDriverDto) {
|
async create(createDriverDto: CreateDriverDto) {
|
||||||
@@ -19,30 +28,15 @@ export class DriversService {
|
|||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
return this.prisma.driver.findMany({
|
return this.prisma.driver.findMany({
|
||||||
where: { deletedAt: null },
|
include: this.driverInclude,
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
events: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { vehicle: true, driver: true },
|
|
||||||
orderBy: { startTime: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: { id, deletedAt: null },
|
where: { id },
|
||||||
include: {
|
include: this.driverInclude,
|
||||||
user: true,
|
|
||||||
events: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { vehicle: true, driver: true },
|
|
||||||
orderBy: { startTime: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
@@ -54,15 +48,8 @@ export class DriversService {
|
|||||||
|
|
||||||
async findByUserId(userId: string) {
|
async findByUserId(userId: string) {
|
||||||
return this.prisma.driver.findFirst({
|
return this.prisma.driver.findFirst({
|
||||||
where: { userId, deletedAt: null },
|
where: { userId },
|
||||||
include: {
|
include: this.driverInclude,
|
||||||
user: true,
|
|
||||||
events: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { vehicle: true, driver: true },
|
|
||||||
orderBy: { startTime: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,23 +66,19 @@ export class DriversService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string, hardDelete = false, userRole?: string) {
|
async remove(id: string, hardDelete = false, userRole?: string) {
|
||||||
if (hardDelete && userRole !== 'ADMINISTRATOR') {
|
return executeHardDelete({
|
||||||
throw new ForbiddenException('Only administrators can permanently delete records');
|
id,
|
||||||
}
|
hardDelete,
|
||||||
|
userRole,
|
||||||
const driver = await this.findOne(id);
|
findOne: (id) => this.findOne(id),
|
||||||
|
performHardDelete: (id) => this.prisma.driver.delete({ where: { id } }),
|
||||||
if (hardDelete) {
|
performSoftDelete: (id) =>
|
||||||
this.logger.log(`Hard deleting driver: ${driver.name}`);
|
this.prisma.driver.update({
|
||||||
return this.prisma.driver.delete({
|
where: { id },
|
||||||
where: { id: driver.id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Soft deleting driver: ${driver.name}`);
|
|
||||||
return this.prisma.driver.update({
|
|
||||||
where: { id: driver.id },
|
|
||||||
data: { deletedAt: new Date() },
|
data: { deletedAt: new Date() },
|
||||||
|
}),
|
||||||
|
entityName: 'Driver',
|
||||||
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
backend/src/drivers/interceptors/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './resolve-driver.interceptor';
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
ExecutionContext,
|
||||||
|
CallHandler,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { DriversService } from '../drivers.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interceptor that resolves the current driver from the authenticated user
|
||||||
|
* and attaches it to the request object for /me routes.
|
||||||
|
* This prevents multiple calls to findByUserId() in each route handler.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ResolveDriverInterceptor implements NestInterceptor {
|
||||||
|
constructor(private readonly driversService: DriversService) {}
|
||||||
|
|
||||||
|
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve driver from user ID and attach to request
|
||||||
|
const driver = await this.driversService.findByUserId(user.id);
|
||||||
|
|
||||||
|
if (!driver) {
|
||||||
|
throw new NotFoundException('Driver profile not found for current user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach driver to request for use in route handlers
|
||||||
|
request.driver = driver;
|
||||||
|
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { SignalService } from '../signal/signal.service';
|
import { SignalService } from '../signal/signal.service';
|
||||||
import * as ics from 'ics';
|
import * as ics from 'ics';
|
||||||
import * as PDFDocument from 'pdfkit';
|
import * as PDFDocument from 'pdfkit';
|
||||||
|
import { toDateString, startOfDay } from '../common/utils/date.utils';
|
||||||
|
|
||||||
interface ScheduleEventWithDetails {
|
interface ScheduleEventWithDetails {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,8 +37,7 @@ export class ScheduleExportService {
|
|||||||
driverId: string,
|
driverId: string,
|
||||||
date: Date,
|
date: Date,
|
||||||
): Promise<ScheduleEventWithDetails[]> {
|
): Promise<ScheduleEventWithDetails[]> {
|
||||||
const startOfDay = new Date(date);
|
const dayStart = startOfDay(date);
|
||||||
startOfDay.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const endOfDay = new Date(date);
|
const endOfDay = new Date(date);
|
||||||
endOfDay.setHours(23, 59, 59, 999);
|
endOfDay.setHours(23, 59, 59, 999);
|
||||||
@@ -45,9 +45,8 @@ export class ScheduleExportService {
|
|||||||
const events = await this.prisma.scheduleEvent.findMany({
|
const events = await this.prisma.scheduleEvent.findMany({
|
||||||
where: {
|
where: {
|
||||||
driverId,
|
driverId,
|
||||||
deletedAt: null,
|
|
||||||
startTime: {
|
startTime: {
|
||||||
gte: startOfDay,
|
gte: dayStart,
|
||||||
lte: endOfDay,
|
lte: endOfDay,
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
@@ -71,13 +70,11 @@ export class ScheduleExportService {
|
|||||||
async getDriverFullSchedule(
|
async getDriverFullSchedule(
|
||||||
driverId: string,
|
driverId: string,
|
||||||
): Promise<ScheduleEventWithDetails[]> {
|
): Promise<ScheduleEventWithDetails[]> {
|
||||||
const now = new Date();
|
const now = startOfDay(new Date()); // Start of today
|
||||||
now.setHours(0, 0, 0, 0); // Start of today
|
|
||||||
|
|
||||||
const events = await this.prisma.scheduleEvent.findMany({
|
const events = await this.prisma.scheduleEvent.findMany({
|
||||||
where: {
|
where: {
|
||||||
driverId,
|
driverId,
|
||||||
deletedAt: null,
|
|
||||||
endTime: {
|
endTime: {
|
||||||
gte: now, // Include events that haven't ended yet
|
gte: now, // Include events that haven't ended yet
|
||||||
},
|
},
|
||||||
@@ -134,7 +131,7 @@ export class ScheduleExportService {
|
|||||||
*/
|
*/
|
||||||
async generateICS(driverId: string, date: Date, fullSchedule = false): Promise<string> {
|
async generateICS(driverId: string, date: Date, fullSchedule = false): Promise<string> {
|
||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: { id: driverId, deletedAt: null },
|
where: { id: driverId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
@@ -211,7 +208,7 @@ export class ScheduleExportService {
|
|||||||
*/
|
*/
|
||||||
async generatePDF(driverId: string, date: Date, fullSchedule = false): Promise<Buffer> {
|
async generatePDF(driverId: string, date: Date, fullSchedule = false): Promise<Buffer> {
|
||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: { id: driverId, deletedAt: null },
|
where: { id: driverId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
@@ -358,7 +355,7 @@ export class ScheduleExportService {
|
|||||||
fullSchedule = false,
|
fullSchedule = false,
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: { id: driverId, deletedAt: null },
|
where: { id: driverId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
@@ -411,8 +408,8 @@ export class ScheduleExportService {
|
|||||||
const icsContent = await this.generateICS(driverId, date, fullSchedule);
|
const icsContent = await this.generateICS(driverId, date, fullSchedule);
|
||||||
const icsBase64 = Buffer.from(icsContent).toString('base64');
|
const icsBase64 = Buffer.from(icsContent).toString('base64');
|
||||||
const filename = fullSchedule
|
const filename = fullSchedule
|
||||||
? `full-schedule-${new Date().toISOString().split('T')[0]}.ics`
|
? `full-schedule-${toDateString(new Date())}.ics`
|
||||||
: `schedule-${date.toISOString().split('T')[0]}.ics`;
|
: `schedule-${toDateString(date)}.ics`;
|
||||||
|
|
||||||
await this.signalService.sendMessageWithAttachment(
|
await this.signalService.sendMessageWithAttachment(
|
||||||
fromNumber,
|
fromNumber,
|
||||||
@@ -435,8 +432,8 @@ export class ScheduleExportService {
|
|||||||
const pdfBuffer = await this.generatePDF(driverId, date, fullSchedule);
|
const pdfBuffer = await this.generatePDF(driverId, date, fullSchedule);
|
||||||
const pdfBase64 = pdfBuffer.toString('base64');
|
const pdfBase64 = pdfBuffer.toString('base64');
|
||||||
const filename = fullSchedule
|
const filename = fullSchedule
|
||||||
? `full-schedule-${new Date().toISOString().split('T')[0]}.pdf`
|
? `full-schedule-${toDateString(new Date())}.pdf`
|
||||||
: `schedule-${date.toISOString().split('T')[0]}.pdf`;
|
: `schedule-${toDateString(date)}.pdf`;
|
||||||
|
|
||||||
await this.signalService.sendMessageWithAttachment(
|
await this.signalService.sendMessageWithAttachment(
|
||||||
fromNumber,
|
fromNumber,
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export class EventStatusService implements OnModuleInit, OnModuleDestroy {
|
|||||||
startTime: { lte: twentyMinutesFromNow, gt: now },
|
startTime: { lte: twentyMinutesFromNow, gt: now },
|
||||||
reminder20MinSent: false,
|
reminder20MinSent: false,
|
||||||
driverId: { not: null },
|
driverId: { not: null },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
driver: true,
|
driver: true,
|
||||||
@@ -110,7 +109,6 @@ export class EventStatusService implements OnModuleInit, OnModuleDestroy {
|
|||||||
startTime: { lte: fiveMinutesFromNow, gt: now },
|
startTime: { lte: fiveMinutesFromNow, gt: now },
|
||||||
reminder5MinSent: false,
|
reminder5MinSent: false,
|
||||||
driverId: { not: null },
|
driverId: { not: null },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
driver: true,
|
driver: true,
|
||||||
@@ -218,7 +216,6 @@ Reply:
|
|||||||
where: {
|
where: {
|
||||||
status: EventStatus.SCHEDULED,
|
status: EventStatus.SCHEDULED,
|
||||||
startTime: { lte: now },
|
startTime: { lte: now },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
driver: true,
|
driver: true,
|
||||||
@@ -264,7 +261,6 @@ Reply:
|
|||||||
where: {
|
where: {
|
||||||
status: EventStatus.IN_PROGRESS,
|
status: EventStatus.IN_PROGRESS,
|
||||||
endTime: { lte: gracePeriodAgo },
|
endTime: { lte: gracePeriodAgo },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
driver: true,
|
driver: true,
|
||||||
@@ -347,7 +343,6 @@ Reply with 1, 2, or 3`;
|
|||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: {
|
where: {
|
||||||
phone: { contains: driverPhone.replace(/\D/g, '').slice(-10) },
|
phone: { contains: driverPhone.replace(/\D/g, '').slice(-10) },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -360,7 +355,6 @@ Reply with 1, 2, or 3`;
|
|||||||
where: {
|
where: {
|
||||||
driverId: driver.id,
|
driverId: driver.id,
|
||||||
status: EventStatus.IN_PROGRESS,
|
status: EventStatus.IN_PROGRESS,
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
include: { vehicle: true },
|
include: { vehicle: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
|
|||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
|
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
|
||||||
|
import { ParseBooleanPipe } from '../common/pipes';
|
||||||
|
|
||||||
@Controller('events')
|
@Controller('events')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@@ -59,10 +60,9 @@ export class EventsController {
|
|||||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
remove(
|
remove(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Query('hard') hard?: string,
|
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||||
@CurrentUser() user?: any,
|
@CurrentUser() user?: any,
|
||||||
) {
|
) {
|
||||||
const isHardDelete = hard === 'true';
|
return this.eventsService.remove(id, hard, user?.role);
|
||||||
return this.eventsService.remove(id, isHardDelete, user?.role);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,23 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
|
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
|
||||||
|
import { executeHardDelete } from '../common/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EventsService {
|
export class EventsService {
|
||||||
private readonly logger = new Logger(EventsService.name);
|
private readonly logger = new Logger(EventsService.name);
|
||||||
|
|
||||||
|
private readonly eventInclude = {
|
||||||
|
driver: true,
|
||||||
|
vehicle: true,
|
||||||
|
masterEvent: {
|
||||||
|
select: { id: true, title: true, type: true, startTime: true, endTime: true },
|
||||||
|
},
|
||||||
|
childEvents: {
|
||||||
|
select: { id: true, title: true, type: true },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async create(createEventDto: CreateEventDto) {
|
async create(createEventDto: CreateEventDto) {
|
||||||
@@ -22,7 +34,6 @@ export class EventsService {
|
|||||||
const vips = await this.prisma.vIP.findMany({
|
const vips = await this.prisma.vIP.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: createEventDto.vipIds },
|
id: { in: createEventDto.vipIds },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,17 +80,7 @@ export class EventsService {
|
|||||||
startTime: new Date(createEventDto.startTime),
|
startTime: new Date(createEventDto.startTime),
|
||||||
endTime: new Date(createEventDto.endTime),
|
endTime: new Date(createEventDto.endTime),
|
||||||
},
|
},
|
||||||
include: {
|
include: this.eventInclude,
|
||||||
driver: true,
|
|
||||||
vehicle: true,
|
|
||||||
masterEvent: {
|
|
||||||
select: { id: true, title: true, type: true, startTime: true, endTime: true },
|
|
||||||
},
|
|
||||||
childEvents: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
select: { id: true, title: true, type: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.enrichEventWithVips(event);
|
return this.enrichEventWithVips(event);
|
||||||
@@ -87,38 +88,45 @@ export class EventsService {
|
|||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
const events = await this.prisma.scheduleEvent.findMany({
|
const events = await this.prisma.scheduleEvent.findMany({
|
||||||
where: { deletedAt: null },
|
include: this.eventInclude,
|
||||||
include: {
|
|
||||||
driver: true,
|
|
||||||
vehicle: true,
|
|
||||||
masterEvent: {
|
|
||||||
select: { id: true, title: true, type: true, startTime: true, endTime: true },
|
|
||||||
},
|
|
||||||
childEvents: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
select: { id: true, title: true, type: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { startTime: 'asc' },
|
orderBy: { startTime: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(events.map((event) => this.enrichEventWithVips(event)));
|
// Collect all unique VIP IDs from all events
|
||||||
|
const allVipIds = new Set<string>();
|
||||||
|
events.forEach((event) => {
|
||||||
|
event.vipIds?.forEach((vipId) => allVipIds.add(vipId));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all VIPs in a single query (eliminates N+1)
|
||||||
|
const vipsMap = new Map();
|
||||||
|
if (allVipIds.size > 0) {
|
||||||
|
const vips = await this.prisma.vIP.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: Array.from(allVipIds) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vips.forEach((vip) => vipsMap.set(vip.id, vip));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich each event with its VIPs from the map (no additional queries)
|
||||||
|
return events.map((event) => {
|
||||||
|
if (!event.vipIds || event.vipIds.length === 0) {
|
||||||
|
return { ...event, vips: [], vip: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const vips = event.vipIds
|
||||||
|
.map((vipId) => vipsMap.get(vipId))
|
||||||
|
.filter((vip) => vip !== undefined);
|
||||||
|
|
||||||
|
return { ...event, vips, vip: vips[0] || null };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
const event = await this.prisma.scheduleEvent.findFirst({
|
const event = await this.prisma.scheduleEvent.findFirst({
|
||||||
where: { id, deletedAt: null },
|
where: { id },
|
||||||
include: {
|
include: this.eventInclude,
|
||||||
driver: true,
|
|
||||||
vehicle: true,
|
|
||||||
masterEvent: {
|
|
||||||
select: { id: true, title: true, type: true, startTime: true, endTime: true },
|
|
||||||
},
|
|
||||||
childEvents: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
select: { id: true, title: true, type: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
@@ -136,7 +144,6 @@ export class EventsService {
|
|||||||
const vips = await this.prisma.vIP.findMany({
|
const vips = await this.prisma.vIP.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: updateEventDto.vipIds },
|
id: { in: updateEventDto.vipIds },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,17 +214,7 @@ export class EventsService {
|
|||||||
const updatedEvent = await this.prisma.scheduleEvent.update({
|
const updatedEvent = await this.prisma.scheduleEvent.update({
|
||||||
where: { id: event.id },
|
where: { id: event.id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
include: {
|
include: this.eventInclude,
|
||||||
driver: true,
|
|
||||||
vehicle: true,
|
|
||||||
masterEvent: {
|
|
||||||
select: { id: true, title: true, type: true, startTime: true, endTime: true },
|
|
||||||
},
|
|
||||||
childEvents: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
select: { id: true, title: true, type: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.enrichEventWithVips(updatedEvent);
|
return this.enrichEventWithVips(updatedEvent);
|
||||||
@@ -233,40 +230,27 @@ export class EventsService {
|
|||||||
const updatedEvent = await this.prisma.scheduleEvent.update({
|
const updatedEvent = await this.prisma.scheduleEvent.update({
|
||||||
where: { id: event.id },
|
where: { id: event.id },
|
||||||
data: { status: updateEventStatusDto.status },
|
data: { status: updateEventStatusDto.status },
|
||||||
include: {
|
include: this.eventInclude,
|
||||||
driver: true,
|
|
||||||
vehicle: true,
|
|
||||||
masterEvent: {
|
|
||||||
select: { id: true, title: true, type: true, startTime: true, endTime: true },
|
|
||||||
},
|
|
||||||
childEvents: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
select: { id: true, title: true, type: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.enrichEventWithVips(updatedEvent);
|
return this.enrichEventWithVips(updatedEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string, hardDelete = false, userRole?: string) {
|
async remove(id: string, hardDelete = false, userRole?: string) {
|
||||||
if (hardDelete && userRole !== 'ADMINISTRATOR') {
|
return executeHardDelete({
|
||||||
throw new ForbiddenException('Only administrators can permanently delete records');
|
id,
|
||||||
}
|
hardDelete,
|
||||||
|
userRole,
|
||||||
const event = await this.findOne(id);
|
findOne: (id) => this.findOne(id),
|
||||||
|
performHardDelete: (id) =>
|
||||||
if (hardDelete) {
|
this.prisma.scheduleEvent.delete({ where: { id } }),
|
||||||
this.logger.log(`Hard deleting event: ${event.title}`);
|
performSoftDelete: (id) =>
|
||||||
return this.prisma.scheduleEvent.delete({
|
this.prisma.scheduleEvent.update({
|
||||||
where: { id: event.id },
|
where: { id },
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Soft deleting event: ${event.title}`);
|
|
||||||
return this.prisma.scheduleEvent.update({
|
|
||||||
where: { id: event.id },
|
|
||||||
data: { deletedAt: new Date() },
|
data: { deletedAt: new Date() },
|
||||||
|
}),
|
||||||
|
entityName: 'Event',
|
||||||
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +259,7 @@ export class EventsService {
|
|||||||
*/
|
*/
|
||||||
private async checkVehicleCapacity(vehicleId: string, vipIds: string[]) {
|
private async checkVehicleCapacity(vehicleId: string, vipIds: string[]) {
|
||||||
const vehicle = await this.prisma.vehicle.findFirst({
|
const vehicle = await this.prisma.vehicle.findFirst({
|
||||||
where: { id: vehicleId, deletedAt: null },
|
where: { id: vehicleId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!vehicle) {
|
if (!vehicle) {
|
||||||
@@ -283,7 +267,7 @@ export class EventsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const vips = await this.prisma.vIP.findMany({
|
const vips = await this.prisma.vIP.findMany({
|
||||||
where: { id: { in: vipIds }, deletedAt: null },
|
where: { id: { in: vipIds } },
|
||||||
select: { partySize: true },
|
select: { partySize: true },
|
||||||
});
|
});
|
||||||
const totalPeople = vips.reduce((sum, v) => sum + v.partySize, 0);
|
const totalPeople = vips.reduce((sum, v) => sum + v.partySize, 0);
|
||||||
@@ -313,7 +297,6 @@ export class EventsService {
|
|||||||
return this.prisma.scheduleEvent.findMany({
|
return this.prisma.scheduleEvent.findMany({
|
||||||
where: {
|
where: {
|
||||||
driverId,
|
driverId,
|
||||||
deletedAt: null,
|
|
||||||
id: excludeEventId ? { not: excludeEventId } : undefined,
|
id: excludeEventId ? { not: excludeEventId } : undefined,
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
@@ -354,7 +337,6 @@ export class EventsService {
|
|||||||
const vips = await this.prisma.vIP.findMany({
|
const vips = await this.prisma.vIP.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: event.vipIds },
|
id: { in: event.vipIds },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
466
backend/src/flights/flight-tracking.service.ts
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
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';
|
||||||
|
import { toDateString } from '../common/utils/date.utils';
|
||||||
|
|
||||||
|
// 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
|
||||||
|
? toDateString(new Date(flight.flightDate))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
access_key: this.apiKey,
|
||||||
|
flight_iata: flight.flightNumber,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (flightDate) {
|
||||||
|
params.flight_date = flightDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.get(`${this.baseUrl}/flights`, {
|
||||||
|
params,
|
||||||
|
timeout: 15000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Increment budget after successful call
|
||||||
|
await this.incrementBudget();
|
||||||
|
|
||||||
|
const data = response.data as any;
|
||||||
|
|
||||||
|
if (data?.error) {
|
||||||
|
this.logger.warn(`AviationStack API error for ${flight.flightNumber}: ${data.error.message || JSON.stringify(data.error)}`);
|
||||||
|
// Still update lastPolledAt so we don't spam on errors
|
||||||
|
return this.prisma.flight.update({
|
||||||
|
where: { id: flight.id },
|
||||||
|
data: { lastPolledAt: new Date(), pollCount: { increment: 1 } },
|
||||||
|
include: { vip: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.data && data.data.length > 0) {
|
||||||
|
const apiResult = data.data[0];
|
||||||
|
const updateData = this.parseAviationStackResponse(apiResult);
|
||||||
|
|
||||||
|
// Calculate new phase based on updated data
|
||||||
|
const tempFlight = { ...flight, ...updateData };
|
||||||
|
const newPhase = this.calculateTrackingPhase(tempFlight as Flight);
|
||||||
|
|
||||||
|
const updated = await this.prisma.flight.update({
|
||||||
|
where: { id: flight.id },
|
||||||
|
data: {
|
||||||
|
...updateData,
|
||||||
|
trackingPhase: newPhase,
|
||||||
|
lastPolledAt: new Date(),
|
||||||
|
pollCount: { increment: 1 },
|
||||||
|
lastApiResponse: apiResult,
|
||||||
|
},
|
||||||
|
include: { vip: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Updated flight ${flight.flightNumber}: status=${updated.status}, phase=${newPhase}, delay=${updated.arrivalDelay || 0}min`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flight not found in API
|
||||||
|
this.logger.warn(`Flight ${flight.flightNumber} not found in AviationStack API`);
|
||||||
|
return this.prisma.flight.update({
|
||||||
|
where: { id: flight.id },
|
||||||
|
data: { lastPolledAt: new Date(), pollCount: { increment: 1 } },
|
||||||
|
include: { vip: true },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`AviationStack API call failed for ${flight.flightNumber}: ${error.message}`);
|
||||||
|
// Still update lastPolledAt on error to prevent rapid retries
|
||||||
|
return this.prisma.flight.update({
|
||||||
|
where: { id: flight.id },
|
||||||
|
data: { lastPolledAt: new Date() },
|
||||||
|
include: { vip: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Response Parser
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private parseAviationStackResponse(apiData: any): Partial<Flight> {
|
||||||
|
const update: any = {};
|
||||||
|
|
||||||
|
// Flight status
|
||||||
|
if (apiData.flight_status) {
|
||||||
|
update.status = apiData.flight_status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Departure info
|
||||||
|
if (apiData.departure) {
|
||||||
|
const dep = apiData.departure;
|
||||||
|
if (dep.terminal) update.departureTerminal = dep.terminal;
|
||||||
|
if (dep.gate) update.departureGate = dep.gate;
|
||||||
|
if (dep.delay != null) update.departureDelay = dep.delay;
|
||||||
|
if (dep.scheduled) update.scheduledDeparture = new Date(dep.scheduled);
|
||||||
|
if (dep.estimated) update.estimatedDeparture = new Date(dep.estimated);
|
||||||
|
if (dep.actual) update.actualDeparture = new Date(dep.actual);
|
||||||
|
// Store departure airport name if we only had IATA code
|
||||||
|
if (dep.iata && !update.departureAirport) update.departureAirport = dep.iata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrival info
|
||||||
|
if (apiData.arrival) {
|
||||||
|
const arr = apiData.arrival;
|
||||||
|
if (arr.terminal) update.arrivalTerminal = arr.terminal;
|
||||||
|
if (arr.gate) update.arrivalGate = arr.gate;
|
||||||
|
if (arr.baggage) update.arrivalBaggage = arr.baggage;
|
||||||
|
if (arr.delay != null) update.arrivalDelay = arr.delay;
|
||||||
|
if (arr.scheduled) update.scheduledArrival = new Date(arr.scheduled);
|
||||||
|
if (arr.estimated) update.estimatedArrival = new Date(arr.estimated);
|
||||||
|
if (arr.actual) update.actualArrival = new Date(arr.actual);
|
||||||
|
if (arr.iata && !update.arrivalAirport) update.arrivalAirport = arr.iata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Airline info
|
||||||
|
if (apiData.airline) {
|
||||||
|
if (apiData.airline.name) update.airlineName = apiData.airline.name;
|
||||||
|
if (apiData.airline.iata) update.airlineIata = apiData.airline.iata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aircraft info
|
||||||
|
if (apiData.aircraft?.iata) {
|
||||||
|
update.aircraftType = apiData.aircraft.iata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live tracking data (may not be available on free tier)
|
||||||
|
if (apiData.live) {
|
||||||
|
const live = apiData.live;
|
||||||
|
if (live.latitude != null) update.liveLatitude = live.latitude;
|
||||||
|
if (live.longitude != null) update.liveLongitude = live.longitude;
|
||||||
|
if (live.altitude != null) update.liveAltitude = live.altitude;
|
||||||
|
if (live.speed_horizontal != null) update.liveSpeed = live.speed_horizontal;
|
||||||
|
if (live.direction != null) update.liveDirection = live.direction;
|
||||||
|
if (live.is_ground != null) update.liveIsGround = live.is_ground;
|
||||||
|
if (live.updated) update.liveUpdatedAt = new Date(live.updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
return update;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,16 +10,21 @@ 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';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { CreateFlightDto, UpdateFlightDto } from './dto';
|
import { CreateFlightDto, UpdateFlightDto } from './dto';
|
||||||
|
import { ParseBooleanPipe } from '../common/pipes';
|
||||||
|
|
||||||
@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 +38,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 +73,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) {
|
||||||
@@ -64,9 +89,8 @@ export class FlightsController {
|
|||||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
remove(
|
remove(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Query('hard') hard?: string,
|
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||||
) {
|
) {
|
||||||
const isHardDelete = hard === 'true';
|
return this.flightsService.remove(id, hard);
|
||||||
return this.flightsService.remove(id, isHardDelete);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateFlightDto, UpdateFlightDto } from './dto';
|
import { CreateFlightDto, UpdateFlightDto } from './dto';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { convertOptionalDates } from '../common/utils/date.utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FlightsService {
|
export class FlightsService {
|
||||||
@@ -24,17 +25,16 @@ export class FlightsService {
|
|||||||
`Creating flight: ${createFlightDto.flightNumber} for VIP ${createFlightDto.vipId}`,
|
`Creating flight: ${createFlightDto.flightNumber} for VIP ${createFlightDto.vipId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.prisma.flight.create({
|
const data = convertOptionalDates(
|
||||||
data: {
|
{
|
||||||
...createFlightDto,
|
...createFlightDto,
|
||||||
flightDate: new Date(createFlightDto.flightDate),
|
flightDate: new Date(createFlightDto.flightDate),
|
||||||
scheduledDeparture: createFlightDto.scheduledDeparture
|
|
||||||
? new Date(createFlightDto.scheduledDeparture)
|
|
||||||
: undefined,
|
|
||||||
scheduledArrival: createFlightDto.scheduledArrival
|
|
||||||
? new Date(createFlightDto.scheduledArrival)
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
|
['scheduledDeparture', 'scheduledArrival'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.prisma.flight.create({
|
||||||
|
data,
|
||||||
include: { vip: true },
|
include: { vip: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -71,24 +71,13 @@ export class FlightsService {
|
|||||||
|
|
||||||
this.logger.log(`Updating flight ${id}: ${flight.flightNumber}`);
|
this.logger.log(`Updating flight ${id}: ${flight.flightNumber}`);
|
||||||
|
|
||||||
const updateData: any = { ...updateFlightDto };
|
const updateData = convertOptionalDates(updateFlightDto, [
|
||||||
const dto = updateFlightDto as any; // Type assertion to work around PartialType
|
'flightDate',
|
||||||
|
'scheduledDeparture',
|
||||||
if (dto.flightDate) {
|
'scheduledArrival',
|
||||||
updateData.flightDate = new Date(dto.flightDate);
|
'actualDeparture',
|
||||||
}
|
'actualArrival',
|
||||||
if (dto.scheduledDeparture) {
|
]);
|
||||||
updateData.scheduledDeparture = new Date(dto.scheduledDeparture);
|
|
||||||
}
|
|
||||||
if (dto.scheduledArrival) {
|
|
||||||
updateData.scheduledArrival = new Date(dto.scheduledArrival);
|
|
||||||
}
|
|
||||||
if (dto.actualDeparture) {
|
|
||||||
updateData.actualDeparture = new Date(dto.actualDeparture);
|
|
||||||
}
|
|
||||||
if (dto.actualArrival) {
|
|
||||||
updateData.actualArrival = new Date(dto.actualArrival);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prisma.flight.update({
|
return this.prisma.flight.update({
|
||||||
where: { id: flight.id },
|
where: { id: flight.id },
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export class DriverStatsDto {
|
|||||||
averageSpeedMph: number;
|
averageSpeedMph: number;
|
||||||
totalTrips: number;
|
totalTrips: number;
|
||||||
totalDrivingMinutes: number;
|
totalDrivingMinutes: number;
|
||||||
|
distanceMethod?: string; // 'osrm' or 'haversine'
|
||||||
};
|
};
|
||||||
recentLocations: LocationDataDto[];
|
recentLocations: LocationDataDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ export class GpsController {
|
|||||||
return this.gpsService.getEnrolledDevices();
|
return this.gpsService.getEnrolledDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get QR code info for an enrolled device
|
||||||
|
*/
|
||||||
|
@Get('devices/:driverId/qr')
|
||||||
|
@Roles(Role.ADMINISTRATOR)
|
||||||
|
async getDeviceQr(@Param('driverId') driverId: string) {
|
||||||
|
return this.gpsService.getDeviceQrInfo(driverId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enroll a driver for GPS tracking
|
* Enroll a driver for GPS tracking
|
||||||
*/
|
*/
|
||||||
@@ -100,7 +109,7 @@ export class GpsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all active driver locations (Admin map view)
|
* Get all active driver locations (used by CommandCenter)
|
||||||
*/
|
*/
|
||||||
@Get('locations')
|
@Get('locations')
|
||||||
@Roles(Role.ADMINISTRATOR)
|
@Roles(Role.ADMINISTRATOR)
|
||||||
@@ -108,34 +117,6 @@ export class GpsController {
|
|||||||
return this.gpsService.getActiveDriverLocations();
|
return this.gpsService.getActiveDriverLocations();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a specific driver's location
|
|
||||||
*/
|
|
||||||
@Get('locations/:driverId')
|
|
||||||
@Roles(Role.ADMINISTRATOR)
|
|
||||||
async getDriverLocation(@Param('driverId') driverId: string) {
|
|
||||||
const location = await this.gpsService.getDriverLocation(driverId);
|
|
||||||
if (!location) {
|
|
||||||
throw new NotFoundException('Driver not found or not enrolled for GPS tracking');
|
|
||||||
}
|
|
||||||
return location;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a driver's stats (Admin viewing any driver)
|
|
||||||
*/
|
|
||||||
@Get('stats/:driverId')
|
|
||||||
@Roles(Role.ADMINISTRATOR)
|
|
||||||
async getDriverStats(
|
|
||||||
@Param('driverId') driverId: string,
|
|
||||||
@Query('from') fromStr?: string,
|
|
||||||
@Query('to') toStr?: string,
|
|
||||||
) {
|
|
||||||
const from = fromStr ? new Date(fromStr) : undefined;
|
|
||||||
const to = toStr ? new Date(toStr) : undefined;
|
|
||||||
return this.gpsService.getDriverStats(driverId, from, to);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Traccar Admin Access
|
// Traccar Admin Access
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -196,14 +196,18 @@ export class GpsService implements OnModuleInit {
|
|||||||
const settings = await this.getSettings();
|
const settings = await this.getSettings();
|
||||||
|
|
||||||
// Build QR code URL for Traccar Client app
|
// Build QR code URL for Traccar Client app
|
||||||
// Format: https://server:5055?id=DEVICE_ID&interval=SECONDS
|
|
||||||
// The Traccar Client app parses this as: server URL (origin) + query params (id, interval, etc.)
|
|
||||||
const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055;
|
const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055;
|
||||||
const traccarPublicUrl = this.traccarClient.getTraccarUrl();
|
const traccarPublicUrl = this.traccarClient.getTraccarUrl();
|
||||||
const qrUrl = new URL(traccarPublicUrl);
|
const qrUrl = new URL(traccarPublicUrl);
|
||||||
qrUrl.port = String(devicePort);
|
qrUrl.port = String(devicePort);
|
||||||
qrUrl.searchParams.set('id', actualDeviceId);
|
qrUrl.searchParams.set('id', actualDeviceId);
|
||||||
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
|
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
|
||||||
|
qrUrl.searchParams.set('accuracy', 'highest');
|
||||||
|
qrUrl.searchParams.set('distance', '0');
|
||||||
|
qrUrl.searchParams.set('angle', '30');
|
||||||
|
qrUrl.searchParams.set('heartbeat', '300');
|
||||||
|
qrUrl.searchParams.set('stop_detection', 'false');
|
||||||
|
qrUrl.searchParams.set('buffer', 'true');
|
||||||
const qrCodeUrl = qrUrl.toString();
|
const qrCodeUrl = qrUrl.toString();
|
||||||
|
|
||||||
this.logger.log(`QR code URL for driver: ${qrCodeUrl}`);
|
this.logger.log(`QR code URL for driver: ${qrCodeUrl}`);
|
||||||
@@ -215,15 +219,21 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
|||||||
- iOS: https://apps.apple.com/app/traccar-client/id843156974
|
- iOS: https://apps.apple.com/app/traccar-client/id843156974
|
||||||
- Android: https://play.google.com/store/apps/details?id=org.traccar.client
|
- Android: https://play.google.com/store/apps/details?id=org.traccar.client
|
||||||
|
|
||||||
2. Open the app and configure:
|
2. Open the app and scan the QR code (or configure manually):
|
||||||
- Device identifier: ${actualDeviceId}
|
- Device identifier: ${actualDeviceId}
|
||||||
- Server URL: ${serverUrl}
|
- Server URL: ${serverUrl}
|
||||||
|
- Location accuracy: Highest
|
||||||
- Frequency: ${settings.updateIntervalSeconds} seconds
|
- Frequency: ${settings.updateIntervalSeconds} seconds
|
||||||
- Location accuracy: High
|
- Distance: 0
|
||||||
|
- Angle: 30
|
||||||
|
|
||||||
3. Tap "Service Status" to start tracking.
|
3. IMPORTANT iPhone Settings:
|
||||||
|
- Settings > Privacy > Location Services > Traccar Client > "Always"
|
||||||
|
- Settings > General > Background App Refresh > ON for Traccar Client
|
||||||
|
- Do NOT swipe the app away from the app switcher
|
||||||
|
- Low Power Mode should be OFF while driving
|
||||||
|
|
||||||
Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}:00 - ${settings.shiftEndHour}:00).
|
4. Tap "Service Status" to start tracking.
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
let signalMessageSent = false;
|
let signalMessageSent = false;
|
||||||
@@ -248,7 +258,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
deviceIdentifier: actualDeviceId, // Return what Traccar actually stored
|
deviceIdentifier: actualDeviceId,
|
||||||
serverUrl,
|
serverUrl,
|
||||||
qrCodeUrl,
|
qrCodeUrl,
|
||||||
instructions,
|
instructions,
|
||||||
@@ -256,6 +266,50 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get QR code info for an already-enrolled device
|
||||||
|
*/
|
||||||
|
async getDeviceQrInfo(driverId: string): Promise<{
|
||||||
|
driverName: string;
|
||||||
|
deviceIdentifier: string;
|
||||||
|
serverUrl: string;
|
||||||
|
qrCodeUrl: string;
|
||||||
|
updateIntervalSeconds: number;
|
||||||
|
}> {
|
||||||
|
const device = await this.prisma.gpsDevice.findUnique({
|
||||||
|
where: { driverId },
|
||||||
|
include: { driver: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw new NotFoundException('Driver is not enrolled for GPS tracking');
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await this.getSettings();
|
||||||
|
const serverUrl = this.traccarClient.getDeviceServerUrl();
|
||||||
|
|
||||||
|
const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055;
|
||||||
|
const traccarPublicUrl = this.traccarClient.getTraccarUrl();
|
||||||
|
const qrUrl = new URL(traccarPublicUrl);
|
||||||
|
qrUrl.port = String(devicePort);
|
||||||
|
qrUrl.searchParams.set('id', device.deviceIdentifier);
|
||||||
|
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
|
||||||
|
qrUrl.searchParams.set('accuracy', 'highest');
|
||||||
|
qrUrl.searchParams.set('distance', '0');
|
||||||
|
qrUrl.searchParams.set('angle', '30');
|
||||||
|
qrUrl.searchParams.set('heartbeat', '300');
|
||||||
|
qrUrl.searchParams.set('stop_detection', 'false');
|
||||||
|
qrUrl.searchParams.set('buffer', 'true');
|
||||||
|
|
||||||
|
return {
|
||||||
|
driverName: device.driver.name,
|
||||||
|
deviceIdentifier: device.deviceIdentifier,
|
||||||
|
serverUrl,
|
||||||
|
qrCodeUrl: qrUrl.toString(),
|
||||||
|
updateIntervalSeconds: settings.updateIntervalSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unenroll a driver from GPS tracking
|
* Unenroll a driver from GPS tracking
|
||||||
*/
|
*/
|
||||||
@@ -331,15 +385,12 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all active driver locations (Admin only)
|
* Get all active driver locations (used by CommandCenter + GPS page)
|
||||||
*/
|
*/
|
||||||
async getActiveDriverLocations(): Promise<DriverLocationDto[]> {
|
async getActiveDriverLocations(): Promise<DriverLocationDto[]> {
|
||||||
const devices = await this.prisma.gpsDevice.findMany({
|
const devices = await this.prisma.gpsDevice.findMany({
|
||||||
where: {
|
where: {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
driver: {
|
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
driver: {
|
driver: {
|
||||||
@@ -387,7 +438,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific driver's location
|
* Get a specific driver's location (used by driver self-service)
|
||||||
*/
|
*/
|
||||||
async getDriverLocation(driverId: string): Promise<DriverLocationDto | null> {
|
async getDriverLocation(driverId: string): Promise<DriverLocationDto | null> {
|
||||||
const device = await this.prisma.gpsDevice.findUnique({
|
const device = await this.prisma.gpsDevice.findUnique({
|
||||||
@@ -437,7 +488,98 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get driver's own stats (for driver self-view)
|
* Calculate distance between two GPS coordinates using Haversine formula
|
||||||
|
* Returns distance in miles
|
||||||
|
*/
|
||||||
|
private calculateHaversineDistance(
|
||||||
|
lat1: number,
|
||||||
|
lon1: number,
|
||||||
|
lat2: number,
|
||||||
|
lon2: number,
|
||||||
|
): number {
|
||||||
|
const R = 3958.8; // Earth's radius in miles
|
||||||
|
const dLat = this.toRadians(lat2 - lat1);
|
||||||
|
const dLon = this.toRadians(lon2 - lon1);
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(this.toRadians(lat1)) *
|
||||||
|
Math.cos(this.toRadians(lat2)) *
|
||||||
|
Math.sin(dLon / 2) *
|
||||||
|
Math.sin(dLon / 2);
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toRadians(degrees: number): number {
|
||||||
|
return degrees * (Math.PI / 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total distance from position history
|
||||||
|
*/
|
||||||
|
private async calculateDistanceFromHistory(
|
||||||
|
deviceId: string,
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
): Promise<number> {
|
||||||
|
const positions = await this.prisma.gpsLocationHistory.findMany({
|
||||||
|
where: {
|
||||||
|
deviceId,
|
||||||
|
timestamp: {
|
||||||
|
gte: from,
|
||||||
|
lte: to,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { timestamp: 'asc' },
|
||||||
|
select: {
|
||||||
|
latitude: true,
|
||||||
|
longitude: true,
|
||||||
|
timestamp: true,
|
||||||
|
speed: true,
|
||||||
|
accuracy: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (positions.length < 2) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalMiles = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i < positions.length; i++) {
|
||||||
|
const prev = positions[i - 1];
|
||||||
|
const curr = positions[i];
|
||||||
|
|
||||||
|
const timeDiffMs = curr.timestamp.getTime() - prev.timestamp.getTime();
|
||||||
|
const timeDiffMinutes = timeDiffMs / 60000;
|
||||||
|
|
||||||
|
// Skip if gap is too large (more than 10 minutes)
|
||||||
|
if (timeDiffMinutes > 10) continue;
|
||||||
|
|
||||||
|
const distance = this.calculateHaversineDistance(
|
||||||
|
prev.latitude,
|
||||||
|
prev.longitude,
|
||||||
|
curr.latitude,
|
||||||
|
curr.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sanity check: skip unrealistic distances (> 100 mph equivalent)
|
||||||
|
const maxPossibleDistance = (timeDiffMinutes / 60) * 100;
|
||||||
|
if (distance > maxPossibleDistance) continue;
|
||||||
|
|
||||||
|
// Filter out GPS jitter (movements < 0.01 miles / ~50 feet)
|
||||||
|
if (distance < 0.01) continue;
|
||||||
|
|
||||||
|
totalMiles += distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalMiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get driver stats (used by driver self-service via me/stats)
|
||||||
*/
|
*/
|
||||||
async getDriverStats(
|
async getDriverStats(
|
||||||
driverId: string,
|
driverId: string,
|
||||||
@@ -464,58 +606,54 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
const to = toDate || new Date();
|
const to = toDate || new Date();
|
||||||
const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000);
|
const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
// Get summary from Traccar
|
const totalMiles = await this.calculateDistanceFromHistory(device.id, from, to);
|
||||||
let totalMiles = 0;
|
|
||||||
|
// Get all positions for speed/time analysis
|
||||||
|
const allPositions = await this.prisma.gpsLocationHistory.findMany({
|
||||||
|
where: {
|
||||||
|
deviceId: device.id,
|
||||||
|
timestamp: {
|
||||||
|
gte: from,
|
||||||
|
lte: to,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { timestamp: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
let topSpeedMph = 0;
|
let topSpeedMph = 0;
|
||||||
let topSpeedTimestamp: Date | null = null;
|
let topSpeedTimestamp: Date | null = null;
|
||||||
let totalTrips = 0;
|
|
||||||
let totalDrivingMinutes = 0;
|
let totalDrivingMinutes = 0;
|
||||||
|
let currentTripStart: Date | null = null;
|
||||||
|
let totalTrips = 0;
|
||||||
|
|
||||||
try {
|
for (const pos of allPositions) {
|
||||||
const summary = await this.traccarClient.getSummaryReport(
|
const speedMph = pos.speed || 0;
|
||||||
device.traccarDeviceId,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (summary.length > 0) {
|
if (speedMph > topSpeedMph) {
|
||||||
const report = summary[0];
|
topSpeedMph = speedMph;
|
||||||
// Distance is in meters, convert to miles
|
topSpeedTimestamp = pos.timestamp;
|
||||||
totalMiles = (report.distance || 0) / 1609.344;
|
|
||||||
topSpeedMph = this.traccarClient.knotsToMph(report.maxSpeed || 0);
|
|
||||||
// Engine hours in milliseconds, convert to minutes
|
|
||||||
totalDrivingMinutes = Math.round((report.engineHours || 0) / 60000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get trips for additional stats
|
if (speedMph > 5) {
|
||||||
const trips = await this.traccarClient.getTripReport(
|
if (!currentTripStart) {
|
||||||
device.traccarDeviceId,
|
currentTripStart = pos.timestamp;
|
||||||
from,
|
totalTrips++;
|
||||||
to,
|
}
|
||||||
);
|
} else if (currentTripStart) {
|
||||||
totalTrips = trips.length;
|
const tripDurationMs = pos.timestamp.getTime() - currentTripStart.getTime();
|
||||||
|
totalDrivingMinutes += tripDurationMs / 60000;
|
||||||
// Find top speed timestamp from positions
|
currentTripStart = null;
|
||||||
const positions = await this.traccarClient.getPositionHistory(
|
|
||||||
device.traccarDeviceId,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
);
|
|
||||||
|
|
||||||
let maxSpeed = 0;
|
|
||||||
for (const pos of positions) {
|
|
||||||
const speedMph = this.traccarClient.knotsToMph(pos.speed || 0);
|
|
||||||
if (speedMph > maxSpeed) {
|
|
||||||
maxSpeed = speedMph;
|
|
||||||
topSpeedTimestamp = new Date(pos.deviceTime);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
topSpeedMph = maxSpeed;
|
|
||||||
} catch (error) {
|
// Close last trip if still driving
|
||||||
this.logger.warn(`Failed to fetch stats from Traccar: ${error}`);
|
if (currentTripStart && allPositions.length > 0) {
|
||||||
|
const lastPos = allPositions[allPositions.length - 1];
|
||||||
|
const tripDurationMs = lastPos.timestamp.getTime() - currentTripStart.getTime();
|
||||||
|
totalDrivingMinutes += tripDurationMs / 60000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get recent locations from our database
|
// Get recent locations for display (last 100)
|
||||||
const recentLocations = await this.prisma.gpsLocationHistory.findMany({
|
const recentLocations = await this.prisma.gpsLocationHistory.findMany({
|
||||||
where: {
|
where: {
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
@@ -528,6 +666,11 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
take: 100,
|
take: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const averageSpeedMph =
|
||||||
|
totalDrivingMinutes > 0
|
||||||
|
? totalMiles / (totalDrivingMinutes / 60)
|
||||||
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
driverId,
|
driverId,
|
||||||
driverName: device.driver.name,
|
driverName: device.driver.name,
|
||||||
@@ -539,11 +682,9 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
totalMiles: Math.round(totalMiles * 10) / 10,
|
totalMiles: Math.round(totalMiles * 10) / 10,
|
||||||
topSpeedMph: Math.round(topSpeedMph),
|
topSpeedMph: Math.round(topSpeedMph),
|
||||||
topSpeedTimestamp,
|
topSpeedTimestamp,
|
||||||
averageSpeedMph: totalDrivingMinutes > 0
|
averageSpeedMph: Math.round(averageSpeedMph * 10) / 10,
|
||||||
? Math.round((totalMiles / (totalDrivingMinutes / 60)) * 10) / 10
|
|
||||||
: 0,
|
|
||||||
totalTrips,
|
totalTrips,
|
||||||
totalDrivingMinutes,
|
totalDrivingMinutes: Math.round(totalDrivingMinutes),
|
||||||
},
|
},
|
||||||
recentLocations: recentLocations.map((loc) => ({
|
recentLocations: recentLocations.map((loc) => ({
|
||||||
latitude: loc.latitude,
|
latitude: loc.latitude,
|
||||||
@@ -562,7 +703,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
* Sync positions from Traccar to our database (for history/stats)
|
* Sync positions from Traccar to our database (for history/stats)
|
||||||
* Called periodically via cron job
|
* Called periodically via cron job
|
||||||
*/
|
*/
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
@Cron(CronExpression.EVERY_30_SECONDS)
|
||||||
async syncPositions(): Promise<void> {
|
async syncPositions(): Promise<void> {
|
||||||
const devices = await this.prisma.gpsDevice.findMany({
|
const devices = await this.prisma.gpsDevice.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -570,41 +711,67 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (devices.length === 0) return;
|
if (devices.length === 0) {
|
||||||
|
this.logger.debug('[GPS Sync] No active devices to sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const now = new Date();
|
||||||
const positions = await this.traccarClient.getAllPositions();
|
this.logger.log(`[GPS Sync] Starting sync for ${devices.length} active devices`);
|
||||||
|
|
||||||
for (const device of devices) {
|
for (const device of devices) {
|
||||||
const position = positions.find((p) => p.deviceId === device.traccarDeviceId);
|
try {
|
||||||
if (!position) continue;
|
const since = device.lastActive
|
||||||
|
? new Date(device.lastActive.getTime() - 30000)
|
||||||
|
: new Date(now.getTime() - 120000);
|
||||||
|
|
||||||
// Update last active timestamp
|
const positions = await this.traccarClient.getPositionHistory(
|
||||||
|
device.traccarDeviceId,
|
||||||
|
since,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`[GPS Sync] Device ${device.traccarDeviceId}: Retrieved ${positions.length} positions from Traccar`);
|
||||||
|
|
||||||
|
if (positions.length === 0) continue;
|
||||||
|
|
||||||
|
const insertResult = await this.prisma.gpsLocationHistory.createMany({
|
||||||
|
data: positions.map((p) => ({
|
||||||
|
deviceId: device.id,
|
||||||
|
latitude: p.latitude,
|
||||||
|
longitude: p.longitude,
|
||||||
|
altitude: p.altitude || null,
|
||||||
|
speed: this.traccarClient.knotsToMph(p.speed || 0),
|
||||||
|
course: p.course || null,
|
||||||
|
accuracy: p.accuracy || null,
|
||||||
|
battery: p.attributes?.batteryLevel || null,
|
||||||
|
timestamp: new Date(p.deviceTime),
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const inserted = insertResult.count;
|
||||||
|
const skipped = positions.length - inserted;
|
||||||
|
this.logger.log(
|
||||||
|
`[GPS Sync] Device ${device.traccarDeviceId}: ` +
|
||||||
|
`Inserted ${inserted} new positions, skipped ${skipped} duplicates`
|
||||||
|
);
|
||||||
|
|
||||||
|
const latestPosition = positions.reduce((latest, p) =>
|
||||||
|
new Date(p.deviceTime) > new Date(latest.deviceTime) ? p : latest
|
||||||
|
);
|
||||||
await this.prisma.gpsDevice.update({
|
await this.prisma.gpsDevice.update({
|
||||||
where: { id: device.id },
|
where: { id: device.id },
|
||||||
data: { lastActive: new Date(position.deviceTime) },
|
data: { lastActive: new Date(latestPosition.deviceTime) },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store in history
|
|
||||||
await this.prisma.gpsLocationHistory.create({
|
|
||||||
data: {
|
|
||||||
deviceId: device.id,
|
|
||||||
latitude: position.latitude,
|
|
||||||
longitude: position.longitude,
|
|
||||||
altitude: position.altitude || null,
|
|
||||||
speed: this.traccarClient.knotsToMph(position.speed || 0),
|
|
||||||
course: position.course || null,
|
|
||||||
accuracy: position.accuracy || null,
|
|
||||||
battery: position.attributes?.batteryLevel || null,
|
|
||||||
timestamp: new Date(position.deviceTime),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to sync positions: ${error}`);
|
this.logger.error(`[GPS Sync] Failed to sync positions for device ${device.traccarDeviceId}: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.log('[GPS Sync] Sync completed');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up old location history (runs daily at 2 AM)
|
* Clean up old location history (runs daily at 2 AM)
|
||||||
*/
|
*/
|
||||||
@@ -629,11 +796,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
// Traccar User Sync (VIP Admin -> Traccar Admin)
|
// Traccar User Sync (VIP Admin -> Traccar Admin)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a secure password for Traccar user
|
|
||||||
*/
|
|
||||||
private generateTraccarPassword(userId: string): string {
|
private generateTraccarPassword(userId: string): string {
|
||||||
// Generate deterministic but secure password based on user ID + secret
|
|
||||||
const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-sync';
|
const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-sync';
|
||||||
return crypto
|
return crypto
|
||||||
.createHmac('sha256', secret)
|
.createHmac('sha256', secret)
|
||||||
@@ -642,11 +805,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
.substring(0, 24);
|
.substring(0, 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a secure token for Traccar auto-login
|
|
||||||
*/
|
|
||||||
private generateTraccarToken(userId: string): string {
|
private generateTraccarToken(userId: string): string {
|
||||||
// Generate deterministic token for auto-login
|
|
||||||
const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-token';
|
const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-token';
|
||||||
return crypto
|
return crypto
|
||||||
.createHmac('sha256', secret + '-token')
|
.createHmac('sha256', secret + '-token')
|
||||||
@@ -655,9 +814,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
.substring(0, 32);
|
.substring(0, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync a VIP user to Traccar
|
|
||||||
*/
|
|
||||||
async syncUserToTraccar(user: User): Promise<boolean> {
|
async syncUserToTraccar(user: User): Promise<boolean> {
|
||||||
if (!user.email) return false;
|
if (!user.email) return false;
|
||||||
|
|
||||||
@@ -671,7 +827,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
user.name || user.email,
|
user.name || user.email,
|
||||||
password,
|
password,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
token, // Include token for auto-login
|
token,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Synced user ${user.email} to Traccar (admin: ${isAdmin})`);
|
this.logger.log(`Synced user ${user.email} to Traccar (admin: ${isAdmin})`);
|
||||||
@@ -682,15 +838,11 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync all VIP admins to Traccar
|
|
||||||
*/
|
|
||||||
async syncAllAdminsToTraccar(): Promise<{ synced: number; failed: number }> {
|
async syncAllAdminsToTraccar(): Promise<{ synced: number; failed: number }> {
|
||||||
const admins = await this.prisma.user.findMany({
|
const admins = await this.prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
role: 'ADMINISTRATOR',
|
role: 'ADMINISTRATOR',
|
||||||
isApproved: true,
|
isApproved: true,
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -707,9 +859,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
return { synced, failed };
|
return { synced, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get auto-login URL for Traccar (for admin users)
|
|
||||||
*/
|
|
||||||
async getTraccarAutoLoginUrl(user: User): Promise<{
|
async getTraccarAutoLoginUrl(user: User): Promise<{
|
||||||
url: string;
|
url: string;
|
||||||
directAccess: boolean;
|
directAccess: boolean;
|
||||||
@@ -718,30 +867,22 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
throw new BadRequestException('Only administrators can access Traccar admin');
|
throw new BadRequestException('Only administrators can access Traccar admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure user is synced to Traccar (this also sets up their token)
|
|
||||||
await this.syncUserToTraccar(user);
|
await this.syncUserToTraccar(user);
|
||||||
|
|
||||||
// Get the token for auto-login
|
|
||||||
const token = this.generateTraccarToken(user.id);
|
const token = this.generateTraccarToken(user.id);
|
||||||
const baseUrl = this.traccarClient.getTraccarUrl();
|
const baseUrl = this.traccarClient.getTraccarUrl();
|
||||||
|
|
||||||
// Return URL with token parameter for auto-login
|
|
||||||
// Traccar supports ?token=xxx for direct authentication
|
|
||||||
return {
|
return {
|
||||||
url: `${baseUrl}?token=${token}`,
|
url: `${baseUrl}?token=${token}`,
|
||||||
directAccess: true,
|
directAccess: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Traccar session cookie for a user (for proxy/iframe auth)
|
|
||||||
*/
|
|
||||||
async getTraccarSessionForUser(user: User): Promise<string | null> {
|
async getTraccarSessionForUser(user: User): Promise<string | null> {
|
||||||
if (user.role !== 'ADMINISTRATOR') {
|
if (user.role !== 'ADMINISTRATOR') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure user is synced
|
|
||||||
await this.syncUserToTraccar(user);
|
await this.syncUserToTraccar(user);
|
||||||
|
|
||||||
const password = this.generateTraccarPassword(user.id);
|
const password = this.generateTraccarPassword(user.id);
|
||||||
@@ -750,9 +891,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
return session?.cookie || null;
|
return session?.cookie || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Traccar needs initial setup
|
|
||||||
*/
|
|
||||||
async checkTraccarSetup(): Promise<{
|
async checkTraccarSetup(): Promise<{
|
||||||
needsSetup: boolean;
|
needsSetup: boolean;
|
||||||
isAvailable: boolean;
|
isAvailable: boolean;
|
||||||
@@ -766,11 +904,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
return { needsSetup, isAvailable };
|
return { needsSetup, isAvailable };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform initial Traccar setup
|
|
||||||
*/
|
|
||||||
async performTraccarSetup(adminEmail: string): Promise<boolean> {
|
async performTraccarSetup(adminEmail: string): Promise<boolean> {
|
||||||
// Generate a secure password for the service account
|
|
||||||
const servicePassword = crypto.randomBytes(16).toString('hex');
|
const servicePassword = crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
const success = await this.traccarClient.performInitialSetup(
|
const success = await this.traccarClient.performInitialSetup(
|
||||||
@@ -779,7 +913,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Save the service account credentials to settings
|
|
||||||
await this.updateSettings({
|
await this.updateSettings({
|
||||||
traccarAdminUser: adminEmail,
|
traccarAdminUser: adminEmail,
|
||||||
traccarAdminPassword: servicePassword,
|
traccarAdminPassword: servicePassword,
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ export class TraccarClientService implements OnModuleInit {
|
|||||||
deviceId: number,
|
deviceId: number,
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date,
|
to: Date,
|
||||||
): Promise<any[]> {
|
): Promise<TraccarTrip[]> {
|
||||||
const fromStr = from.toISOString();
|
const fromStr = from.toISOString();
|
||||||
const toStr = to.toISOString();
|
const toStr = to.toISOString();
|
||||||
return this.request('get', `/api/reports/trips?deviceId=${deviceId}&from=${fromStr}&to=${toStr}`);
|
return this.request('get', `/api/reports/trips?deviceId=${deviceId}&from=${fromStr}&to=${toStr}`);
|
||||||
@@ -567,3 +567,27 @@ export interface TraccarUser {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
attributes: Record<string, any>;
|
attributes: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TraccarTrip {
|
||||||
|
deviceId: number;
|
||||||
|
deviceName: string;
|
||||||
|
distance: number; // meters
|
||||||
|
averageSpeed: number; // knots
|
||||||
|
maxSpeed: number; // knots
|
||||||
|
spentFuel: number;
|
||||||
|
startOdometer: number;
|
||||||
|
endOdometer: number;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
startPositionId: number;
|
||||||
|
endPositionId: number;
|
||||||
|
startLat: number;
|
||||||
|
startLon: number;
|
||||||
|
endLat: number;
|
||||||
|
endLon: number;
|
||||||
|
startAddress: string | null;
|
||||||
|
endAddress: string | null;
|
||||||
|
duration: number; // milliseconds
|
||||||
|
driverUniqueId: string | null;
|
||||||
|
driverName: string | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
// Models that have soft delete (deletedAt field)
|
||||||
|
const SOFT_DELETE_MODELS = ['User', 'VIP', 'Driver', 'ScheduleEvent', 'Vehicle'];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(PrismaService.name);
|
private readonly logger = new Logger(PrismaService.name);
|
||||||
@@ -9,18 +12,69 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
|||||||
super({
|
super({
|
||||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Apply soft-delete middleware
|
||||||
|
this.applySoftDeleteMiddleware();
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
try {
|
try {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
this.logger.log('✅ Database connected successfully');
|
this.logger.log('✅ Database connected successfully');
|
||||||
|
this.logger.log('✅ Soft-delete middleware active for: ' + SOFT_DELETE_MODELS.join(', '));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('❌ Database connection failed', error);
|
this.logger.error('❌ Database connection failed', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply Prisma middleware to automatically filter out soft-deleted records
|
||||||
|
*
|
||||||
|
* This middleware automatically adds `deletedAt: null` to where clauses for models
|
||||||
|
* that have a deletedAt field, preventing soft-deleted records from being returned.
|
||||||
|
*
|
||||||
|
* Escape hatches:
|
||||||
|
* - Pass `{ deletedAt: { not: null } }` to query ONLY deleted records
|
||||||
|
* - Pass `{ deletedAt: undefined }` or any explicit deletedAt filter to bypass middleware
|
||||||
|
* - Hard delete operations (delete, deleteMany) are not affected
|
||||||
|
*/
|
||||||
|
private applySoftDeleteMiddleware() {
|
||||||
|
this.$use(async (params, next) => {
|
||||||
|
// Only apply to models with soft delete
|
||||||
|
if (!SOFT_DELETE_MODELS.includes(params.model || '')) {
|
||||||
|
return next(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operations to apply soft-delete filter to
|
||||||
|
const operations = ['findUnique', 'findFirst', 'findMany', 'count', 'aggregate'];
|
||||||
|
|
||||||
|
if (operations.includes(params.action)) {
|
||||||
|
// Initialize where clause if it doesn't exist
|
||||||
|
params.args.where = params.args.where || {};
|
||||||
|
|
||||||
|
// Only apply filter if deletedAt is not already specified
|
||||||
|
// This allows explicit queries for deleted records: { deletedAt: { not: null } }
|
||||||
|
// or to bypass middleware: { deletedAt: undefined }
|
||||||
|
if (!('deletedAt' in params.args.where)) {
|
||||||
|
params.args.where.deletedAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For update/updateMany, ensure we don't accidentally update soft-deleted records
|
||||||
|
if (params.action === 'update' || params.action === 'updateMany') {
|
||||||
|
params.args.where = params.args.where || {};
|
||||||
|
|
||||||
|
// Only apply if not explicitly specified
|
||||||
|
if (!('deletedAt' in params.args.where)) {
|
||||||
|
params.args.where.deletedAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(params);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
await this.$disconnect();
|
await this.$disconnect();
|
||||||
this.logger.log('Database disconnected');
|
this.logger.log('Database disconnected');
|
||||||
|
|||||||
@@ -215,60 +215,181 @@ export class SeedService {
|
|||||||
|
|
||||||
private getFlightData(vips: any[]) {
|
private getFlightData(vips: any[]) {
|
||||||
const flights: any[] = [];
|
const flights: any[] = [];
|
||||||
const airlines = ['AA', 'UA', 'DL', 'SW', 'AS', 'JB'];
|
|
||||||
const origins = ['JFK', 'LAX', 'ORD', 'DFW', 'ATL', 'SFO', 'SEA', 'BOS', 'DEN', 'MIA'];
|
|
||||||
const destination = 'SLC';
|
const destination = 'SLC';
|
||||||
|
|
||||||
vips.forEach((vip, index) => {
|
// Build a name->vip lookup for named scenarios
|
||||||
const airline = airlines[index % airlines.length];
|
const vipByName = new Map<string, any>();
|
||||||
const flightNum = `${airline}${1000 + index * 123}`;
|
vips.forEach(v => vipByName.set(v.name, v));
|
||||||
const origin = origins[index % origins.length];
|
|
||||||
|
|
||||||
// Arrival flight - times relative to now
|
// Helper: create a flight record
|
||||||
const arrivalOffset = (index % 8) * 30 - 60;
|
const makeFlight = (vipId: string, opts: any) => ({
|
||||||
const scheduledArrival = this.relativeTime(arrivalOffset);
|
vipId,
|
||||||
const scheduledDeparture = new Date(scheduledArrival.getTime() - 3 * 60 * 60 * 1000);
|
flightDate: new Date(),
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
|
||||||
let status = 'scheduled';
|
// ============================================================
|
||||||
let actualArrival = null;
|
// NAMED MULTI-SEGMENT SCENARIOS
|
||||||
if (arrivalOffset < -30) {
|
// ============================================================
|
||||||
status = 'landed';
|
|
||||||
actualArrival = new Date(scheduledArrival.getTime() + (Math.random() * 20 - 10) * 60000);
|
// Roger Krone: 3-segment journey, all landed cleanly
|
||||||
} else if (arrivalOffset < 0) {
|
// BWI -> ORD -> DEN -> SLC
|
||||||
status = 'landing';
|
const krone = vipByName.get('Roger A. Krone');
|
||||||
} else if (index % 5 === 0) {
|
if (krone) {
|
||||||
status = 'delayed';
|
flights.push(makeFlight(krone.id, {
|
||||||
|
segment: 1, flightNumber: 'UA410', departureAirport: 'BWI', arrivalAirport: 'ORD',
|
||||||
|
scheduledDeparture: this.relativeTime(-360), scheduledArrival: this.relativeTime(-240),
|
||||||
|
actualDeparture: this.relativeTime(-358), actualArrival: this.relativeTime(-235),
|
||||||
|
status: 'landed',
|
||||||
|
}));
|
||||||
|
flights.push(makeFlight(krone.id, {
|
||||||
|
segment: 2, flightNumber: 'UA672', departureAirport: 'ORD', arrivalAirport: 'DEN',
|
||||||
|
scheduledDeparture: this.relativeTime(-150), scheduledArrival: this.relativeTime(-60),
|
||||||
|
actualDeparture: this.relativeTime(-148), actualArrival: this.relativeTime(-55),
|
||||||
|
status: 'landed',
|
||||||
|
}));
|
||||||
|
flights.push(makeFlight(krone.id, {
|
||||||
|
segment: 3, flightNumber: 'UA1190', departureAirport: 'DEN', arrivalAirport: destination,
|
||||||
|
scheduledDeparture: this.relativeTime(-20), scheduledArrival: this.relativeTime(40),
|
||||||
|
actualDeparture: this.relativeTime(-18), actualArrival: null,
|
||||||
|
status: 'active',
|
||||||
|
arrivalTerminal: '2', arrivalGate: 'B7',
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
flights.push({
|
// Sarah Chen: 2-segment, leg 1 landed, leg 2 active/arriving
|
||||||
vipId: vip.id,
|
// JFK -> ORD -> SLC
|
||||||
flightNumber: flightNum,
|
const chen = vipByName.get('Sarah Chen');
|
||||||
flightDate: new Date(),
|
if (chen) {
|
||||||
|
flights.push(makeFlight(chen.id, {
|
||||||
|
segment: 1, flightNumber: 'AA234', departureAirport: 'JFK', arrivalAirport: 'ORD',
|
||||||
|
scheduledDeparture: this.relativeTime(-300), scheduledArrival: this.relativeTime(-180),
|
||||||
|
actualDeparture: this.relativeTime(-298), actualArrival: this.relativeTime(-175),
|
||||||
|
status: 'landed',
|
||||||
|
}));
|
||||||
|
flights.push(makeFlight(chen.id, {
|
||||||
|
segment: 2, flightNumber: 'AA1456', departureAirport: 'ORD', arrivalAirport: destination,
|
||||||
|
scheduledDeparture: this.relativeTime(-90), scheduledArrival: this.relativeTime(30),
|
||||||
|
actualDeparture: this.relativeTime(-88), actualArrival: null,
|
||||||
|
status: 'active',
|
||||||
|
arrivalTerminal: '1', arrivalGate: 'A12',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roberto Gonzalez: 2-segment, leg 1 DELAYED 45min - threatens connection
|
||||||
|
// LAX -> DFW -> SLC (90min layover scheduled, now ~45min WARNING)
|
||||||
|
const gonzalez = vipByName.get('Roberto Gonzalez');
|
||||||
|
if (gonzalez) {
|
||||||
|
flights.push(makeFlight(gonzalez.id, {
|
||||||
|
segment: 1, flightNumber: 'DL890', departureAirport: 'LAX', arrivalAirport: 'DFW',
|
||||||
|
scheduledDeparture: this.relativeTime(-240), scheduledArrival: this.relativeTime(-90),
|
||||||
|
estimatedArrival: this.relativeTime(-45), // 45min late
|
||||||
|
actualDeparture: this.relativeTime(-195), // departed 45min late
|
||||||
|
departureDelay: 45, arrivalDelay: 45,
|
||||||
|
status: 'active', // still in the air
|
||||||
|
}));
|
||||||
|
flights.push(makeFlight(gonzalez.id, {
|
||||||
|
segment: 2, flightNumber: 'DL1522', departureAirport: 'DFW', arrivalAirport: destination,
|
||||||
|
scheduledDeparture: this.relativeTime(0), scheduledArrival: this.relativeTime(150),
|
||||||
|
status: 'scheduled',
|
||||||
|
departureTerminal: 'E', departureGate: 'E14',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thomas Anderson: 2-segment, MISSED CONNECTION
|
||||||
|
// BOS -> ORD -> SLC (leg 1 arrived 25min late, leg 2 already departed)
|
||||||
|
const anderson = vipByName.get('Thomas Anderson');
|
||||||
|
if (anderson) {
|
||||||
|
flights.push(makeFlight(anderson.id, {
|
||||||
|
segment: 1, flightNumber: 'JB320', departureAirport: 'BOS', arrivalAirport: 'ORD',
|
||||||
|
scheduledDeparture: this.relativeTime(-240), scheduledArrival: this.relativeTime(-90),
|
||||||
|
actualDeparture: this.relativeTime(-215), actualArrival: this.relativeTime(-65),
|
||||||
|
departureDelay: 25, arrivalDelay: 25,
|
||||||
|
status: 'landed',
|
||||||
|
}));
|
||||||
|
flights.push(makeFlight(anderson.id, {
|
||||||
|
segment: 2, flightNumber: 'JB988', departureAirport: 'ORD', arrivalAirport: destination,
|
||||||
|
scheduledDeparture: this.relativeTime(-75), scheduledArrival: this.relativeTime(15),
|
||||||
|
actualDeparture: this.relativeTime(-75), // departed on time - before leg 1 landed
|
||||||
|
status: 'active', // plane left without him
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marcus Johnson: 2-segment, both landed cleanly
|
||||||
|
// ATL -> DEN -> SLC
|
||||||
|
const johnson = vipByName.get('Marcus Johnson');
|
||||||
|
if (johnson) {
|
||||||
|
flights.push(makeFlight(johnson.id, {
|
||||||
|
segment: 1, flightNumber: 'DL512', departureAirport: 'ATL', arrivalAirport: 'DEN',
|
||||||
|
scheduledDeparture: this.relativeTime(-360), scheduledArrival: this.relativeTime(-210),
|
||||||
|
actualDeparture: this.relativeTime(-358), actualArrival: this.relativeTime(-205),
|
||||||
|
status: 'landed',
|
||||||
|
}));
|
||||||
|
flights.push(makeFlight(johnson.id, {
|
||||||
|
segment: 2, flightNumber: 'DL1780', departureAirport: 'DEN', arrivalAirport: destination,
|
||||||
|
scheduledDeparture: this.relativeTime(-120), scheduledArrival: this.relativeTime(-30),
|
||||||
|
actualDeparture: this.relativeTime(-118), actualArrival: this.relativeTime(-25),
|
||||||
|
status: 'landed',
|
||||||
|
arrivalTerminal: '2', arrivalGate: 'C4', arrivalBaggage: '3',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// James O'Brien: 2-segment, both scheduled (future)
|
||||||
|
// DFW -> DEN -> SLC
|
||||||
|
const obrien = vipByName.get("James O'Brien");
|
||||||
|
if (obrien) {
|
||||||
|
flights.push(makeFlight(obrien.id, {
|
||||||
|
segment: 1, flightNumber: 'UA780', departureAirport: 'DFW', arrivalAirport: 'DEN',
|
||||||
|
scheduledDeparture: this.relativeTime(60), scheduledArrival: this.relativeTime(180),
|
||||||
|
status: 'scheduled',
|
||||||
|
}));
|
||||||
|
flights.push(makeFlight(obrien.id, {
|
||||||
|
segment: 2, flightNumber: 'UA1340', departureAirport: 'DEN', arrivalAirport: destination,
|
||||||
|
scheduledDeparture: this.relativeTime(240), scheduledArrival: this.relativeTime(330),
|
||||||
|
status: 'scheduled',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DIRECT FLIGHTS (single segment)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const directFlights: Array<{ name: string; airline: string; num: string; origin: string; offset: number; statusOverride?: string }> = [
|
||||||
|
{ name: 'Jennifer Wu', airline: 'AA', num: 'AA1023', origin: 'ORD', offset: 60 },
|
||||||
|
{ name: 'Priya Sharma', airline: 'UA', num: 'UA567', origin: 'SFO', offset: -15, statusOverride: 'active' },
|
||||||
|
{ name: 'David Okonkwo', airline: 'DL', num: 'DL1345', origin: 'SEA', offset: 120 },
|
||||||
|
{ name: 'Yuki Tanaka', airline: 'AA', num: 'AA890', origin: 'LAX', offset: 90 },
|
||||||
|
{ name: 'Isabella Costa', airline: 'SW', num: 'SW2210', origin: 'MIA', offset: -45, statusOverride: 'active' },
|
||||||
|
{ name: 'Fatima Al-Rahman', airline: 'AS', num: 'AS440', origin: 'SEA', offset: 180 },
|
||||||
|
{ name: 'William Zhang', airline: 'DL', num: 'DL1678', origin: 'ATL', offset: -90, statusOverride: 'landed' },
|
||||||
|
{ name: 'Alexander Volkov', airline: 'UA', num: 'UA2100', origin: 'DEN', offset: 45 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const df of directFlights) {
|
||||||
|
const vip = vipByName.get(df.name);
|
||||||
|
if (!vip) continue;
|
||||||
|
|
||||||
|
const scheduledArrival = this.relativeTime(df.offset);
|
||||||
|
const scheduledDeparture = new Date(scheduledArrival.getTime() - 3 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
let status = df.statusOverride || 'scheduled';
|
||||||
|
let actualArrival = null;
|
||||||
|
if (status === 'landed') {
|
||||||
|
actualArrival = new Date(scheduledArrival.getTime() + 5 * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
flights.push(makeFlight(vip.id, {
|
||||||
segment: 1,
|
segment: 1,
|
||||||
departureAirport: origin,
|
flightNumber: df.num,
|
||||||
|
departureAirport: df.origin,
|
||||||
arrivalAirport: destination,
|
arrivalAirport: destination,
|
||||||
scheduledDeparture,
|
scheduledDeparture,
|
||||||
scheduledArrival,
|
scheduledArrival,
|
||||||
|
actualDeparture: status !== 'scheduled' ? scheduledDeparture : null,
|
||||||
actualArrival,
|
actualArrival,
|
||||||
status,
|
status,
|
||||||
});
|
}));
|
||||||
|
|
||||||
// Some VIPs have connecting flights (segment 2)
|
|
||||||
if (index % 4 === 0) {
|
|
||||||
const connectOrigin = origins[(index + 3) % origins.length];
|
|
||||||
flights.push({
|
|
||||||
vipId: vip.id,
|
|
||||||
flightNumber: `${airline}${500 + index}`,
|
|
||||||
flightDate: new Date(),
|
|
||||||
segment: 2,
|
|
||||||
departureAirport: connectOrigin,
|
|
||||||
arrivalAirport: origin,
|
|
||||||
scheduledDeparture: new Date(scheduledDeparture.getTime() - 4 * 60 * 60 * 1000),
|
|
||||||
scheduledArrival: new Date(scheduledDeparture.getTime() - 1 * 60 * 60 * 1000),
|
|
||||||
status: 'landed',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return flights;
|
return flights;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,23 @@ export class SettingsController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get app timezone - any authenticated user can read this
|
||||||
|
*/
|
||||||
|
@Get('timezone')
|
||||||
|
getTimezone() {
|
||||||
|
return this.settingsService.getTimezone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update app timezone - admin only
|
||||||
|
*/
|
||||||
|
@Patch('timezone')
|
||||||
|
@CanUpdate('Settings')
|
||||||
|
updateTimezone(@Body() dto: { timezone: string }) {
|
||||||
|
return this.settingsService.updateTimezone(dto.timezone);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('pdf')
|
@Get('pdf')
|
||||||
@CanUpdate('Settings') // Admin-only (Settings subject is admin-only)
|
@CanUpdate('Settings') // Admin-only (Settings subject is admin-only)
|
||||||
getPdfSettings() {
|
getPdfSettings() {
|
||||||
|
|||||||
@@ -75,6 +75,37 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the app-wide timezone setting
|
||||||
|
*/
|
||||||
|
async getTimezone(): Promise<{ timezone: string }> {
|
||||||
|
const settings = await this.getPdfSettings();
|
||||||
|
return { timezone: settings.timezone };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the app-wide timezone setting
|
||||||
|
*/
|
||||||
|
async updateTimezone(timezone: string): Promise<{ timezone: string }> {
|
||||||
|
this.logger.log(`Updating timezone to: ${timezone}`);
|
||||||
|
|
||||||
|
// Validate the timezone string
|
||||||
|
try {
|
||||||
|
Intl.DateTimeFormat(undefined, { timeZone: timezone });
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(`Invalid timezone: ${timezone}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.getPdfSettings();
|
||||||
|
|
||||||
|
await this.prisma.pdfSettings.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { timezone },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { timezone };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload logo as base64 data URL
|
* Upload logo as base64 data URL
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { RolesGuard } from '../auth/guards/roles.guard';
|
|||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { Public } from '../auth/decorators/public.decorator';
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
import { MessagesService, SendMessageDto } from './messages.service';
|
import { MessagesService, SendMessageDto } from './messages.service';
|
||||||
|
import { toDateString } from '../common/utils/date.utils';
|
||||||
|
|
||||||
// DTO for incoming Signal webhook
|
// DTO for incoming Signal webhook
|
||||||
interface SignalWebhookPayload {
|
interface SignalWebhookPayload {
|
||||||
@@ -154,7 +155,7 @@ export class MessagesController {
|
|||||||
async exportMessages(@Res() res: Response) {
|
async exportMessages(@Res() res: Response) {
|
||||||
const exportData = await this.messagesService.exportAllMessages();
|
const exportData = await this.messagesService.exportAllMessages();
|
||||||
|
|
||||||
const filename = `signal-chats-${new Date().toISOString().split('T')[0]}.txt`;
|
const filename = `signal-chats-${toDateString(new Date())}.txt`;
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export class MessagesService {
|
|||||||
*/
|
*/
|
||||||
async getMessagesForDriver(driverId: string, limit: number = 50) {
|
async getMessagesForDriver(driverId: string, limit: number = 50) {
|
||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: { id: driverId, deletedAt: null },
|
where: { id: driverId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
@@ -55,7 +55,7 @@ export class MessagesService {
|
|||||||
*/
|
*/
|
||||||
async sendMessage(dto: SendMessageDto) {
|
async sendMessage(dto: SendMessageDto) {
|
||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: { id: dto.driverId, deletedAt: null },
|
where: { id: dto.driverId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
@@ -113,7 +113,6 @@ export class MessagesService {
|
|||||||
// Find driver by phone number
|
// Find driver by phone number
|
||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: {
|
where: {
|
||||||
deletedAt: null,
|
|
||||||
OR: [
|
OR: [
|
||||||
{ phone: fromNumber },
|
{ phone: fromNumber },
|
||||||
{ phone: normalizedPhone },
|
{ phone: normalizedPhone },
|
||||||
@@ -172,7 +171,6 @@ export class MessagesService {
|
|||||||
where: {
|
where: {
|
||||||
driverId: driver.id,
|
driverId: driver.id,
|
||||||
status: EventStatus.IN_PROGRESS,
|
status: EventStatus.IN_PROGRESS,
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
include: { vehicle: true },
|
include: { vehicle: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export class UsersService {
|
|||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
return this.prisma.user.findMany({
|
return this.prisma.user.findMany({
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { driver: true },
|
include: { driver: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
@@ -19,7 +18,7 @@ export class UsersService {
|
|||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: { id, deletedAt: null },
|
where: { id },
|
||||||
include: { driver: true },
|
include: { driver: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,7 +135,6 @@ export class UsersService {
|
|||||||
async getPendingUsers() {
|
async getPendingUsers() {
|
||||||
return this.prisma.user.findMany({
|
return this.prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
deletedAt: null,
|
|
||||||
isApproved: false,
|
isApproved: false,
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
|
|||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
|
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
|
||||||
|
import { ParseBooleanPipe } from '../common/pipes';
|
||||||
|
|
||||||
@Controller('vehicles')
|
@Controller('vehicles')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@@ -59,10 +60,9 @@ export class VehiclesController {
|
|||||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
remove(
|
remove(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Query('hard') hard?: string,
|
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||||
@CurrentUser() user?: any,
|
@CurrentUser() user?: any,
|
||||||
) {
|
) {
|
||||||
const isHardDelete = hard === 'true';
|
return this.vehiclesService.remove(id, hard, user?.role);
|
||||||
return this.vehiclesService.remove(id, isHardDelete, user?.role);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
|
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
|
||||||
|
import { executeHardDelete } from '../common/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VehiclesService {
|
export class VehiclesService {
|
||||||
private readonly logger = new Logger(VehiclesService.name);
|
private readonly logger = new Logger(VehiclesService.name);
|
||||||
|
|
||||||
|
private readonly vehicleInclude = {
|
||||||
|
currentDriver: true,
|
||||||
|
events: {
|
||||||
|
include: { driver: true, vehicle: true },
|
||||||
|
orderBy: { startTime: 'asc' as const },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async create(createVehicleDto: CreateVehicleDto) {
|
async create(createVehicleDto: CreateVehicleDto) {
|
||||||
@@ -13,27 +22,13 @@ export class VehiclesService {
|
|||||||
|
|
||||||
return this.prisma.vehicle.create({
|
return this.prisma.vehicle.create({
|
||||||
data: createVehicleDto,
|
data: createVehicleDto,
|
||||||
include: {
|
include: this.vehicleInclude,
|
||||||
currentDriver: true,
|
|
||||||
events: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { driver: true, vehicle: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
return this.prisma.vehicle.findMany({
|
return this.prisma.vehicle.findMany({
|
||||||
where: { deletedAt: null },
|
include: this.vehicleInclude,
|
||||||
include: {
|
|
||||||
currentDriver: true,
|
|
||||||
events: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { driver: true, vehicle: true },
|
|
||||||
orderBy: { startTime: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -41,7 +36,6 @@ export class VehiclesService {
|
|||||||
async findAvailable() {
|
async findAvailable() {
|
||||||
return this.prisma.vehicle.findMany({
|
return this.prisma.vehicle.findMany({
|
||||||
where: {
|
where: {
|
||||||
deletedAt: null,
|
|
||||||
status: 'AVAILABLE',
|
status: 'AVAILABLE',
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -53,15 +47,8 @@ export class VehiclesService {
|
|||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
const vehicle = await this.prisma.vehicle.findFirst({
|
const vehicle = await this.prisma.vehicle.findFirst({
|
||||||
where: { id, deletedAt: null },
|
where: { id },
|
||||||
include: {
|
include: this.vehicleInclude,
|
||||||
currentDriver: true,
|
|
||||||
events: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { driver: true, vehicle: true },
|
|
||||||
orderBy: { startTime: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!vehicle) {
|
if (!vehicle) {
|
||||||
@@ -79,34 +66,24 @@ export class VehiclesService {
|
|||||||
return this.prisma.vehicle.update({
|
return this.prisma.vehicle.update({
|
||||||
where: { id: vehicle.id },
|
where: { id: vehicle.id },
|
||||||
data: updateVehicleDto,
|
data: updateVehicleDto,
|
||||||
include: {
|
include: this.vehicleInclude,
|
||||||
currentDriver: true,
|
|
||||||
events: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { driver: true, vehicle: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string, hardDelete = false, userRole?: string) {
|
async remove(id: string, hardDelete = false, userRole?: string) {
|
||||||
if (hardDelete && userRole !== 'ADMINISTRATOR') {
|
return executeHardDelete({
|
||||||
throw new ForbiddenException('Only administrators can permanently delete records');
|
id,
|
||||||
}
|
hardDelete,
|
||||||
|
userRole,
|
||||||
const vehicle = await this.findOne(id);
|
findOne: (id) => this.findOne(id),
|
||||||
|
performHardDelete: (id) => this.prisma.vehicle.delete({ where: { id } }),
|
||||||
if (hardDelete) {
|
performSoftDelete: (id) =>
|
||||||
this.logger.log(`Hard deleting vehicle: ${vehicle.name}`);
|
this.prisma.vehicle.update({
|
||||||
return this.prisma.vehicle.delete({
|
where: { id },
|
||||||
where: { id: vehicle.id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Soft deleting vehicle: ${vehicle.name}`);
|
|
||||||
return this.prisma.vehicle.update({
|
|
||||||
where: { id: vehicle.id },
|
|
||||||
data: { deletedAt: new Date() },
|
data: { deletedAt: new Date() },
|
||||||
|
}),
|
||||||
|
entityName: 'Vehicle',
|
||||||
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,23 +91,32 @@ export class VehiclesService {
|
|||||||
* Get vehicle utilization statistics
|
* Get vehicle utilization statistics
|
||||||
*/
|
*/
|
||||||
async getUtilization() {
|
async getUtilization() {
|
||||||
const vehicles = await this.findAll();
|
const now = new Date();
|
||||||
|
|
||||||
const stats = vehicles.map((vehicle) => {
|
// Fetch vehicles with only upcoming events (filtered at database level)
|
||||||
const upcomingEvents = vehicle.events.filter(
|
const vehicles = await this.prisma.vehicle.findMany({
|
||||||
(event) => new Date(event.startTime) > new Date(),
|
include: {
|
||||||
);
|
currentDriver: true,
|
||||||
|
events: {
|
||||||
|
where: {
|
||||||
|
startTime: { gt: now }, // Only fetch upcoming events
|
||||||
|
},
|
||||||
|
include: { driver: true, vehicle: true },
|
||||||
|
orderBy: { startTime: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
const stats = vehicles.map((vehicle) => ({
|
||||||
id: vehicle.id,
|
id: vehicle.id,
|
||||||
name: vehicle.name,
|
name: vehicle.name,
|
||||||
type: vehicle.type,
|
type: vehicle.type,
|
||||||
seatCapacity: vehicle.seatCapacity,
|
seatCapacity: vehicle.seatCapacity,
|
||||||
status: vehicle.status,
|
status: vehicle.status,
|
||||||
upcomingTrips: upcomingEvents.length,
|
upcomingTrips: vehicle.events.length, // Already filtered at DB level
|
||||||
currentDriver: vehicle.currentDriver?.name,
|
currentDriver: vehicle.currentDriver?.name,
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalVehicles: vehicles.length,
|
totalVehicles: vehicles.length,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { AbilitiesGuard } from '../auth/guards/abilities.guard';
|
|||||||
import { CanCreate, CanRead, CanUpdate, CanDelete } from '../auth/decorators/check-ability.decorator';
|
import { CanCreate, CanRead, CanUpdate, CanDelete } from '../auth/decorators/check-ability.decorator';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { CreateVipDto, UpdateVipDto } from './dto';
|
import { CreateVipDto, UpdateVipDto } from './dto';
|
||||||
|
import { ParseBooleanPipe } from '../common/pipes';
|
||||||
|
|
||||||
@Controller('vips')
|
@Controller('vips')
|
||||||
@UseGuards(JwtAuthGuard, AbilitiesGuard)
|
@UseGuards(JwtAuthGuard, AbilitiesGuard)
|
||||||
@@ -49,11 +50,9 @@ export class VipsController {
|
|||||||
@CanDelete('VIP')
|
@CanDelete('VIP')
|
||||||
remove(
|
remove(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Query('hard') hard?: string,
|
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||||
@CurrentUser() user?: any,
|
@CurrentUser() user?: any,
|
||||||
) {
|
) {
|
||||||
// Only administrators can hard delete
|
return this.vipsService.remove(id, hard, user?.role);
|
||||||
const isHardDelete = hard === 'true';
|
|
||||||
return this.vipsService.remove(id, isHardDelete, user?.role);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateVipDto, UpdateVipDto } from './dto';
|
import { CreateVipDto, UpdateVipDto } from './dto';
|
||||||
|
import { executeHardDelete } from '../common/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VipsService {
|
export class VipsService {
|
||||||
@@ -21,7 +22,6 @@ export class VipsService {
|
|||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
return this.prisma.vIP.findMany({
|
return this.prisma.vIP.findMany({
|
||||||
where: { deletedAt: null },
|
|
||||||
include: {
|
include: {
|
||||||
flights: true,
|
flights: true,
|
||||||
},
|
},
|
||||||
@@ -31,7 +31,7 @@ export class VipsService {
|
|||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
const vip = await this.prisma.vIP.findFirst({
|
const vip = await this.prisma.vIP.findFirst({
|
||||||
where: { id, deletedAt: null },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
flights: true,
|
flights: true,
|
||||||
},
|
},
|
||||||
@@ -59,23 +59,19 @@ export class VipsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string, hardDelete = false, userRole?: string) {
|
async remove(id: string, hardDelete = false, userRole?: string) {
|
||||||
if (hardDelete && userRole !== 'ADMINISTRATOR') {
|
return executeHardDelete({
|
||||||
throw new ForbiddenException('Only administrators can permanently delete records');
|
id,
|
||||||
}
|
hardDelete,
|
||||||
|
userRole,
|
||||||
const vip = await this.findOne(id);
|
findOne: (id) => this.findOne(id),
|
||||||
|
performHardDelete: (id) => this.prisma.vIP.delete({ where: { id } }),
|
||||||
if (hardDelete) {
|
performSoftDelete: (id) =>
|
||||||
this.logger.log(`Hard deleting VIP: ${vip.name}`);
|
this.prisma.vIP.update({
|
||||||
return this.prisma.vIP.delete({
|
where: { id },
|
||||||
where: { id: vip.id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Soft deleting VIP: ${vip.name}`);
|
|
||||||
return this.prisma.vIP.update({
|
|
||||||
where: { id: vip.id },
|
|
||||||
data: { deletedAt: new Date() },
|
data: { deletedAt: new Date() },
|
||||||
|
}),
|
||||||
|
entityName: 'VIP',
|
||||||
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
630
docs/USER_GUIDE.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
3. You will be redirected to the Auth0 login screen. Enter your email address and password, then click **Continue**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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.*
|
||||||
10
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
BIN
frontend/public/help-assets/screenshots/01-login-page.png
Normal file
|
After Width: | Height: | Size: 468 KiB |
BIN
frontend/public/help-assets/screenshots/02-auth0-login.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/public/help-assets/screenshots/03-dashboard.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
frontend/public/help-assets/screenshots/04-war-room.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
frontend/public/help-assets/screenshots/05-vip-list.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
frontend/public/help-assets/screenshots/06-vip-edit-form.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 93 KiB |
BIN
frontend/public/help-assets/screenshots/08-fleet-drivers.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
frontend/public/help-assets/screenshots/09-fleet-vehicles.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
frontend/public/help-assets/screenshots/10-activities.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
frontend/public/help-assets/screenshots/11-flights.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
frontend/public/help-assets/screenshots/12-reports.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
frontend/public/help-assets/screenshots/13-users.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
frontend/public/help-assets/screenshots/14-admin-tools.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
frontend/public/help-assets/screenshots/15-gps-tracking.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
frontend/public/help-assets/screenshots/16-gps-devices.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
frontend/public/help-assets/screenshots/17-gps-settings.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
frontend/public/help-assets/screenshots/18-ai-assistant.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
@@ -4,6 +4,7 @@ import { Auth0Provider } from '@auth0/auth0-react';
|
|||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { AuthProvider } from '@/contexts/AuthContext';
|
import { AuthProvider } from '@/contexts/AuthContext';
|
||||||
import { AbilityProvider } from '@/contexts/AbilityContext';
|
import { AbilityProvider } from '@/contexts/AbilityContext';
|
||||||
|
import { TimezoneProvider } from '@/contexts/TimezoneContext';
|
||||||
import { ThemeProvider } from '@/contexts/ThemeContext';
|
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/Layout';
|
||||||
@@ -13,7 +14,7 @@ import { Callback } from '@/pages/Callback';
|
|||||||
import { PendingApproval } from '@/pages/PendingApproval';
|
import { PendingApproval } from '@/pages/PendingApproval';
|
||||||
import { Dashboard } from '@/pages/Dashboard';
|
import { Dashboard } from '@/pages/Dashboard';
|
||||||
import { CommandCenter } from '@/pages/CommandCenter';
|
import { CommandCenter } from '@/pages/CommandCenter';
|
||||||
import { VIPList } from '@/pages/VipList';
|
import { VIPList } from '@/pages/VIPList';
|
||||||
import { VIPSchedule } from '@/pages/VIPSchedule';
|
import { VIPSchedule } from '@/pages/VIPSchedule';
|
||||||
import { FleetPage } from '@/pages/FleetPage';
|
import { FleetPage } from '@/pages/FleetPage';
|
||||||
import { EventList } from '@/pages/EventList';
|
import { EventList } from '@/pages/EventList';
|
||||||
@@ -24,6 +25,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,10 +65,11 @@ 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>
|
||||||
|
<TimezoneProvider>
|
||||||
<AbilityProvider>
|
<AbilityProvider>
|
||||||
<BrowserRouter
|
<BrowserRouter
|
||||||
future={{
|
future={{
|
||||||
@@ -124,6 +127,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 />} />
|
||||||
@@ -136,6 +140,7 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AbilityProvider>
|
</AbilityProvider>
|
||||||
|
</TimezoneProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</Auth0Provider>
|
</Auth0Provider>
|
||||||
|
|||||||
524
frontend/src/components/AccountabilityRosterPDF.tsx
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
/**
|
||||||
|
* Accountability Roster PDF Generator
|
||||||
|
*
|
||||||
|
* Professional roster document for emergency preparedness.
|
||||||
|
* Follows VIPSchedulePDF patterns for consistent styling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Document,
|
||||||
|
Page,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
Font,
|
||||||
|
Image,
|
||||||
|
} from '@react-pdf/renderer';
|
||||||
|
import { PdfSettings } from '@/types/settings';
|
||||||
|
|
||||||
|
Font.register({
|
||||||
|
family: 'Helvetica',
|
||||||
|
fonts: [
|
||||||
|
{ src: 'Helvetica' },
|
||||||
|
{ src: 'Helvetica-Bold', fontWeight: 'bold' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
interface VIP {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
organization: string | null;
|
||||||
|
department: string;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
emergencyContactName: string | null;
|
||||||
|
emergencyContactPhone: string | null;
|
||||||
|
isRosterOnly: boolean;
|
||||||
|
partySize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountabilityRosterPDFProps {
|
||||||
|
vips: VIP[];
|
||||||
|
settings?: PdfSettings | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStyles = (accentColor: string = '#2c3e50', _pageSize: 'LETTER' | 'A4' = 'LETTER') =>
|
||||||
|
StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
padding: 40,
|
||||||
|
paddingBottom: 80,
|
||||||
|
fontSize: 9,
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
color: '#333333',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Watermark
|
||||||
|
watermark: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '40%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%) rotate(-45deg)',
|
||||||
|
fontSize: 72,
|
||||||
|
color: '#888888',
|
||||||
|
opacity: 0.2,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
logoContainer: {
|
||||||
|
marginBottom: 10,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
maxWidth: 130,
|
||||||
|
maxHeight: 50,
|
||||||
|
objectFit: 'contain',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Header
|
||||||
|
header: {
|
||||||
|
marginBottom: 20,
|
||||||
|
borderBottom: `2 solid ${accentColor}`,
|
||||||
|
paddingBottom: 15,
|
||||||
|
},
|
||||||
|
orgName: {
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#7f8c8d',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 2,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: accentColor,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#7f8c8d',
|
||||||
|
},
|
||||||
|
customMessage: {
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#7f8c8d',
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 8,
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderLeft: `3 solid ${accentColor}`,
|
||||||
|
},
|
||||||
|
timestampBar: {
|
||||||
|
marginTop: 10,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTop: '1 solid #ecf0f1',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
fontSize: 7,
|
||||||
|
color: '#95a5a6',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Summary stats row
|
||||||
|
summaryRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 15,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
summaryCard: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 10,
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderLeft: `3 solid ${accentColor}`,
|
||||||
|
},
|
||||||
|
summaryValue: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#2c3e50',
|
||||||
|
},
|
||||||
|
summaryLabel: {
|
||||||
|
fontSize: 8,
|
||||||
|
color: '#7f8c8d',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Section
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: accentColor,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 8,
|
||||||
|
paddingBottom: 4,
|
||||||
|
borderBottom: `2 solid ${accentColor}`,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: 18,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Table
|
||||||
|
table: {
|
||||||
|
borderLeft: '1 solid #dee2e6',
|
||||||
|
borderRight: '1 solid #dee2e6',
|
||||||
|
borderTop: '1 solid #dee2e6',
|
||||||
|
},
|
||||||
|
tableHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: accentColor,
|
||||||
|
minHeight: 24,
|
||||||
|
},
|
||||||
|
tableHeaderCell: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 7,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
padding: 6,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
tableRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderBottom: '1 solid #dee2e6',
|
||||||
|
minHeight: 28,
|
||||||
|
},
|
||||||
|
tableRowAlt: {
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
},
|
||||||
|
tableRowRoster: {
|
||||||
|
backgroundColor: '#fef9e7',
|
||||||
|
},
|
||||||
|
tableRowRosterAlt: {
|
||||||
|
backgroundColor: '#fdf3d0',
|
||||||
|
},
|
||||||
|
tableCell: {
|
||||||
|
padding: 5,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
cellName: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#2c3e50',
|
||||||
|
},
|
||||||
|
cellDept: {
|
||||||
|
fontSize: 7,
|
||||||
|
color: '#7f8c8d',
|
||||||
|
marginTop: 1,
|
||||||
|
},
|
||||||
|
cellText: {
|
||||||
|
fontSize: 8,
|
||||||
|
color: '#34495e',
|
||||||
|
},
|
||||||
|
cellSmall: {
|
||||||
|
fontSize: 7,
|
||||||
|
color: '#7f8c8d',
|
||||||
|
},
|
||||||
|
cellCenter: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#2c3e50',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
cellNoData: {
|
||||||
|
fontSize: 7,
|
||||||
|
color: '#bdc3c7',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Column widths
|
||||||
|
colName: { width: '22%' },
|
||||||
|
colOrg: { width: '18%' },
|
||||||
|
colContact: { width: '22%' },
|
||||||
|
colEmergency: { width: '22%' },
|
||||||
|
colParty: { width: '8%' },
|
||||||
|
colNotes: { width: '8%' },
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 25,
|
||||||
|
left: 40,
|
||||||
|
right: 40,
|
||||||
|
paddingTop: 10,
|
||||||
|
borderTop: '1 solid #dee2e6',
|
||||||
|
},
|
||||||
|
footerContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
footerLeft: {
|
||||||
|
maxWidth: '60%',
|
||||||
|
},
|
||||||
|
footerTitle: {
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#2c3e50',
|
||||||
|
marginBottom: 3,
|
||||||
|
},
|
||||||
|
footerContact: {
|
||||||
|
fontSize: 7,
|
||||||
|
color: '#7f8c8d',
|
||||||
|
marginBottom: 1,
|
||||||
|
},
|
||||||
|
footerRight: {
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
pageNumber: {
|
||||||
|
fontSize: 7,
|
||||||
|
color: '#95a5a6',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
emptyState: {
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: 30,
|
||||||
|
color: '#95a5a6',
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDepartment = (dept: string) => {
|
||||||
|
switch (dept) {
|
||||||
|
case 'OFFICE_OF_DEVELOPMENT':
|
||||||
|
return 'Office of Dev';
|
||||||
|
case 'ADMIN':
|
||||||
|
return 'Admin';
|
||||||
|
default:
|
||||||
|
return dept;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AccountabilityRosterPDF({
|
||||||
|
vips,
|
||||||
|
settings,
|
||||||
|
}: AccountabilityRosterPDFProps) {
|
||||||
|
const config = settings || {
|
||||||
|
organizationName: 'VIP Transportation Services',
|
||||||
|
accentColor: '#2c3e50',
|
||||||
|
contactEmail: 'coordinator@example.com',
|
||||||
|
contactPhone: '(555) 123-4567',
|
||||||
|
contactLabel: 'Questions or Changes?',
|
||||||
|
showDraftWatermark: false,
|
||||||
|
showConfidentialWatermark: false,
|
||||||
|
showTimestamp: true,
|
||||||
|
showAppUrl: false,
|
||||||
|
pageSize: 'LETTER' as const,
|
||||||
|
logoUrl: null,
|
||||||
|
tagline: null,
|
||||||
|
headerMessage: null,
|
||||||
|
footerMessage: null,
|
||||||
|
secondaryContactName: null,
|
||||||
|
secondaryContactPhone: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = createStyles(config.accentColor, config.pageSize);
|
||||||
|
|
||||||
|
const generatedAt = new Date().toLocaleString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeVips = vips.filter((v) => !v.isRosterOnly).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const rosterOnlyVips = vips.filter((v) => v.isRosterOnly).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const totalPeople = vips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||||
|
const activeCount = activeVips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||||
|
const rosterCount = rosterOnlyVips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||||
|
|
||||||
|
const renderTableHeader = () => (
|
||||||
|
<View style={styles.tableHeader}>
|
||||||
|
<View style={[styles.tableHeaderCell, styles.colName]}>
|
||||||
|
<Text>Name</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.tableHeaderCell, styles.colOrg]}>
|
||||||
|
<Text>Organization</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.tableHeaderCell, styles.colContact]}>
|
||||||
|
<Text>Contact</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.tableHeaderCell, styles.colEmergency]}>
|
||||||
|
<Text>Emergency Contact</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.tableHeaderCell, styles.colParty]}>
|
||||||
|
<Text>Party</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderVipRow = (vip: VIP, index: number, isRoster: boolean) => (
|
||||||
|
<View
|
||||||
|
key={vip.id}
|
||||||
|
style={[
|
||||||
|
styles.tableRow,
|
||||||
|
isRoster
|
||||||
|
? index % 2 === 1 ? styles.tableRowRosterAlt : styles.tableRowRoster
|
||||||
|
: index % 2 === 1 ? styles.tableRowAlt : {},
|
||||||
|
]}
|
||||||
|
wrap={false}
|
||||||
|
>
|
||||||
|
<View style={[styles.tableCell, styles.colName]}>
|
||||||
|
<Text style={styles.cellName}>{vip.name}</Text>
|
||||||
|
<Text style={styles.cellDept}>{formatDepartment(vip.department)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.tableCell, styles.colOrg]}>
|
||||||
|
{vip.organization ? (
|
||||||
|
<Text style={styles.cellText}>{vip.organization}</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.cellNoData}>-</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.tableCell, styles.colContact]}>
|
||||||
|
{vip.phone && <Text style={styles.cellText}>{vip.phone}</Text>}
|
||||||
|
{vip.email && <Text style={styles.cellSmall}>{vip.email}</Text>}
|
||||||
|
{!vip.phone && !vip.email && <Text style={styles.cellNoData}>No contact info</Text>}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.tableCell, styles.colEmergency]}>
|
||||||
|
{vip.emergencyContactName ? (
|
||||||
|
<>
|
||||||
|
<Text style={styles.cellText}>{vip.emergencyContactName}</Text>
|
||||||
|
{vip.emergencyContactPhone && (
|
||||||
|
<Text style={styles.cellSmall}>{vip.emergencyContactPhone}</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.cellNoData}>Not provided</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.tableCell, styles.colParty]}>
|
||||||
|
<Text style={styles.cellCenter}>{vip.partySize}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page size={config.pageSize} style={styles.page}>
|
||||||
|
{/* Watermarks */}
|
||||||
|
{config.showDraftWatermark && (
|
||||||
|
<View style={styles.watermark} fixed>
|
||||||
|
<Text>DRAFT</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{config.showConfidentialWatermark && (
|
||||||
|
<View style={styles.watermark} fixed>
|
||||||
|
<Text>CONFIDENTIAL</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
{config.logoUrl && (
|
||||||
|
<View style={styles.logoContainer}>
|
||||||
|
<Image src={config.logoUrl} style={styles.logo} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.orgName}>{config.organizationName}</Text>
|
||||||
|
<Text style={styles.title}>Accountability Roster</Text>
|
||||||
|
<Text style={styles.subtitle}>Emergency Preparedness & Personnel Tracking</Text>
|
||||||
|
|
||||||
|
{config.headerMessage && (
|
||||||
|
<Text style={styles.customMessage}>{config.headerMessage}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(config.showTimestamp || config.showAppUrl) && (
|
||||||
|
<View style={styles.timestampBar}>
|
||||||
|
{config.showTimestamp && (
|
||||||
|
<Text style={styles.timestamp}>Generated: {generatedAt}</Text>
|
||||||
|
)}
|
||||||
|
{config.showAppUrl && (
|
||||||
|
<Text style={styles.timestamp}>
|
||||||
|
Latest version: {typeof window !== 'undefined' ? window.location.origin : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<View style={styles.summaryCard}>
|
||||||
|
<Text style={styles.summaryValue}>{totalPeople}</Text>
|
||||||
|
<Text style={styles.summaryLabel}>Total People</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryCard}>
|
||||||
|
<Text style={styles.summaryValue}>{activeCount}</Text>
|
||||||
|
<Text style={styles.summaryLabel}>Active VIPs</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryCard}>
|
||||||
|
<Text style={styles.summaryValue}>{rosterCount}</Text>
|
||||||
|
<Text style={styles.summaryLabel}>Roster Only</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Active VIPs Table */}
|
||||||
|
{activeVips.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
Active VIPs ({activeVips.length} entries, {activeCount} people)
|
||||||
|
</Text>
|
||||||
|
<View style={styles.table}>
|
||||||
|
{renderTableHeader()}
|
||||||
|
{activeVips.map((vip, i) => renderVipRow(vip, i, false))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Roster Only Table */}
|
||||||
|
{rosterOnlyVips.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
Roster Only ({rosterOnlyVips.length} entries, {rosterCount} people)
|
||||||
|
</Text>
|
||||||
|
<View style={styles.table}>
|
||||||
|
{renderTableHeader()}
|
||||||
|
{rosterOnlyVips.map((vip, i) => renderVipRow(vip, i, true))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{vips.length === 0 && (
|
||||||
|
<Text style={styles.emptyState}>No personnel records found.</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom Footer Message */}
|
||||||
|
{config.footerMessage && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.customMessage}>{config.footerMessage}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View style={styles.footer} fixed>
|
||||||
|
<View style={styles.footerContent}>
|
||||||
|
<View style={styles.footerLeft}>
|
||||||
|
<Text style={styles.footerTitle}>{config.contactLabel}</Text>
|
||||||
|
<Text style={styles.footerContact}>{config.contactEmail}</Text>
|
||||||
|
<Text style={styles.footerContact}>{config.contactPhone}</Text>
|
||||||
|
{config.secondaryContactName && (
|
||||||
|
<Text style={styles.footerContact}>
|
||||||
|
{config.secondaryContactName}
|
||||||
|
{config.secondaryContactPhone ? ` - ${config.secondaryContactPhone}` : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.footerRight}>
|
||||||
|
<Text
|
||||||
|
style={styles.pageNumber}
|
||||||
|
render={({ pageNumber, totalPages }) =>
|
||||||
|
`Page ${pageNumber} of ${totalPages}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
frontend/src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
variant?: 'destructive' | 'warning' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmModal({
|
||||||
|
isOpen,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel = 'Delete',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
variant = 'destructive',
|
||||||
|
}: ConfirmModalProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const getConfirmButtonStyles = () => {
|
||||||
|
switch (variant) {
|
||||||
|
case 'destructive':
|
||||||
|
return 'bg-red-600 hover:bg-red-700 text-white';
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-yellow-600 hover:bg-yellow-700 text-white';
|
||||||
|
case 'default':
|
||||||
|
return 'bg-primary hover:bg-primary/90 text-white';
|
||||||
|
default:
|
||||||
|
return 'bg-red-600 hover:bg-red-700 text-white';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-card rounded-lg shadow-xl w-full max-w-md"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header with icon */}
|
||||||
|
<div className="flex items-start gap-4 p-6 pb-4">
|
||||||
|
<div className={`flex-shrink-0 ${
|
||||||
|
variant === 'destructive' ? 'text-red-600' :
|
||||||
|
variant === 'warning' ? 'text-yellow-600' :
|
||||||
|
'text-primary'
|
||||||
|
}`}>
|
||||||
|
<AlertTriangle className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 p-6 pt-4 border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 bg-card text-foreground py-2.5 px-4 rounded-md hover:bg-accent font-medium border border-input transition-colors"
|
||||||
|
style={{ minHeight: '44px' }}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onConfirm();
|
||||||
|
onCancel();
|
||||||
|
}}
|
||||||
|
className={`flex-1 py-2.5 px-4 rounded-md font-medium transition-colors ${getConfirmButtonStyles()}`}
|
||||||
|
style={{ minHeight: '44px' }}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { X, Send, Loader2 } from 'lucide-react';
|
import { X, Send, Loader2 } from 'lucide-react';
|
||||||
import { useDriverMessages, useSendMessage, useMarkMessagesAsRead } from '../hooks/useSignalMessages';
|
import { useDriverMessages, useSendMessage, useMarkMessagesAsRead } from '../hooks/useSignalMessages';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface Driver {
|
interface Driver {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +19,7 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
|
|||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const { formatDateTime } = useFormattedDate();
|
||||||
|
|
||||||
const { data: messages, isLoading } = useDriverMessages(driver?.id || null, isOpen);
|
const { data: messages, isLoading } = useDriverMessages(driver?.id || null, isOpen);
|
||||||
const sendMessage = useSendMessage();
|
const sendMessage = useSendMessage();
|
||||||
@@ -66,22 +68,6 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (timestamp: string) => {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
const now = new Date();
|
|
||||||
const isToday = date.toDateString() === now.toDateString();
|
|
||||||
|
|
||||||
if (isToday) {
|
|
||||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div
|
<div
|
||||||
@@ -126,7 +112,7 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
|
|||||||
<p className={`text-[10px] mt-1 ${
|
<p className={`text-[10px] mt-1 ${
|
||||||
msg.direction === 'OUTBOUND' ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
|
msg.direction === 'OUTBOUND' ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
|
||||||
}`}>
|
}`}>
|
||||||
{formatTime(msg.timestamp)}
|
{formatDateTime(msg.timestamp)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
import { DEPARTMENT_LABELS } from '@/lib/enum-labels';
|
||||||
|
|
||||||
interface DriverFormProps {
|
interface DriverFormProps {
|
||||||
driver?: Driver | null;
|
driver?: Driver | null;
|
||||||
@@ -112,8 +113,11 @@ export function DriverForm({ driver, onSubmit, onCancel, isSubmitting }: DriverF
|
|||||||
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="">Select Department</option>
|
<option value="">Select Department</option>
|
||||||
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
|
{Object.entries(DEPARTMENT_LABELS).map(([value, label]) => (
|
||||||
<option value="ADMIN">Admin</option>
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api } from '@/lib/api';
|
|||||||
import { X, Calendar, Clock, MapPin, Car, User, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { X, Calendar, Clock, MapPin, Car, User, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { Driver } from '@/types';
|
import { Driver } from '@/types';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface ScheduleEvent {
|
interface ScheduleEvent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,6 +37,7 @@ interface DriverScheduleModalProps {
|
|||||||
|
|
||||||
export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleModalProps) {
|
export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleModalProps) {
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||||
|
const { formatDate, formatTime } = useFormattedDate();
|
||||||
|
|
||||||
const dateString = selectedDate.toISOString().split('T')[0];
|
const dateString = selectedDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
@@ -85,23 +87,6 @@ export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleM
|
|||||||
|
|
||||||
const isToday = selectedDate.toDateString() === new Date().toDateString();
|
const isToday = selectedDate.toDateString() === new Date().toDateString();
|
||||||
|
|
||||||
const formatTime = (dateString: string) => {
|
|
||||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
weekday: 'long',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'COMPLETED':
|
case 'COMPLETED':
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { X, AlertTriangle, Users, Car, Link2 } from 'lucide-react';
|
import { X, AlertTriangle, Users, Car, Link2 } from 'lucide-react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { ScheduleEvent, VIP, Driver, Vehicle } from '@/types';
|
import { ScheduleEvent, VIP, Driver, Vehicle } from '@/types';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
import { toDatetimeLocal } from '@/lib/utils';
|
||||||
|
import { EVENT_TYPE_LABELS, EVENT_STATUS_LABELS } from '@/lib/enum-labels';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
interface EventFormProps {
|
interface EventFormProps {
|
||||||
event?: ScheduleEvent | null;
|
event?: ScheduleEvent | null;
|
||||||
@@ -39,17 +42,7 @@ interface ScheduleConflict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraActions }: EventFormProps) {
|
export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraActions }: EventFormProps) {
|
||||||
// Helper to convert ISO datetime to datetime-local format
|
const { formatDateTime } = useFormattedDate();
|
||||||
const toDatetimeLocal = (isoString: string | null | undefined) => {
|
|
||||||
if (!isoString) return '';
|
|
||||||
const date = new Date(isoString);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState<EventFormData>({
|
const [formData, setFormData] = useState<EventFormData>({
|
||||||
vipIds: event?.vipIds || [],
|
vipIds: event?.vipIds || [],
|
||||||
@@ -75,7 +68,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
|
|||||||
|
|
||||||
// Fetch VIPs for selection
|
// Fetch VIPs for selection
|
||||||
const { data: vips } = useQuery<VIP[]>({
|
const { data: vips } = useQuery<VIP[]>({
|
||||||
queryKey: ['vips'],
|
queryKey: queryKeys.vips.all,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/vips');
|
const { data } = await api.get('/vips');
|
||||||
return data;
|
return data;
|
||||||
@@ -84,7 +77,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
|
|||||||
|
|
||||||
// Fetch Drivers for dropdown
|
// Fetch Drivers for dropdown
|
||||||
const { data: drivers } = useQuery<Driver[]>({
|
const { data: drivers } = useQuery<Driver[]>({
|
||||||
queryKey: ['drivers'],
|
queryKey: queryKeys.drivers.all,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/drivers');
|
const { data } = await api.get('/drivers');
|
||||||
return data;
|
return data;
|
||||||
@@ -93,7 +86,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
|
|||||||
|
|
||||||
// Fetch Vehicles for dropdown
|
// Fetch Vehicles for dropdown
|
||||||
const { data: vehicles } = useQuery<Vehicle[]>({
|
const { data: vehicles } = useQuery<Vehicle[]>({
|
||||||
queryKey: ['vehicles'],
|
queryKey: queryKeys.vehicles.all,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/vehicles');
|
const { data } = await api.get('/vehicles');
|
||||||
return data;
|
return data;
|
||||||
@@ -102,7 +95,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
|
|||||||
|
|
||||||
// Fetch all events (for master event selector)
|
// Fetch all events (for master event selector)
|
||||||
const { data: allEvents } = useQuery<ScheduleEvent[]>({
|
const { data: allEvents } = useQuery<ScheduleEvent[]>({
|
||||||
queryKey: ['events'],
|
queryKey: queryKeys.events.all,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/events');
|
const { data } = await api.get('/events');
|
||||||
return data;
|
return data;
|
||||||
@@ -217,10 +210,12 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedVipNames = vips
|
const selectedVipNames = useMemo(() => {
|
||||||
|
return vips
|
||||||
?.filter(vip => formData.vipIds.includes(vip.id))
|
?.filter(vip => formData.vipIds.includes(vip.id))
|
||||||
.map(vip => vip.partySize > 1 ? `${vip.name} (+${vip.partySize - 1})` : vip.name)
|
.map(vip => vip.partySize > 1 ? `${vip.name} (+${vip.partySize - 1})` : vip.name)
|
||||||
.join(', ') || 'None selected';
|
.join(', ') || 'None selected';
|
||||||
|
}, [vips, formData.vipIds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -450,11 +445,11 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="TRANSPORT">Transport</option>
|
{Object.entries(EVENT_TYPE_LABELS).map(([value, label]) => (
|
||||||
<option value="MEETING">Meeting</option>
|
<option key={value} value={value}>
|
||||||
<option value="EVENT">Event</option>
|
{label}
|
||||||
<option value="MEAL">Meal</option>
|
</option>
|
||||||
<option value="ACCOMMODATION">Accommodation</option>
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -468,10 +463,11 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="SCHEDULED">Scheduled</option>
|
{Object.entries(EVENT_STATUS_LABELS).map(([value, label]) => (
|
||||||
<option value="IN_PROGRESS">In Progress</option>
|
<option key={value} value={value}>
|
||||||
<option value="COMPLETED">Completed</option>
|
{label}
|
||||||
<option value="CANCELLED">Cancelled</option>
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
467
frontend/src/components/FlightCard.tsx
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Plane,
|
||||||
|
RefreshCw,
|
||||||
|
Edit3,
|
||||||
|
Trash2,
|
||||||
|
AlertTriangle,
|
||||||
|
Clock,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Users,
|
||||||
|
CheckCircle,
|
||||||
|
Link2,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Flight, Journey, Layover } from '@/types';
|
||||||
|
import { FlightProgressBar } from './FlightProgressBar';
|
||||||
|
import { useRefreshFlight } from '@/hooks/useFlights';
|
||||||
|
import { formatLayoverDuration } from '@/lib/journeyUtils';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
|
interface FlightCardProps {
|
||||||
|
flight?: Flight;
|
||||||
|
journey?: Journey;
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSegmentStatusIcon(flight: Flight) {
|
||||||
|
const status = flight.status?.toLowerCase();
|
||||||
|
if (status === 'landed' || flight.actualArrival) {
|
||||||
|
return <CheckCircle className="w-3.5 h-3.5 text-emerald-500" />;
|
||||||
|
}
|
||||||
|
if (status === 'active') {
|
||||||
|
return <Plane className="w-3.5 h-3.5 text-purple-500" />;
|
||||||
|
}
|
||||||
|
if (status === 'cancelled' || status === 'diverted') {
|
||||||
|
return <XCircle className="w-3.5 h-3.5 text-red-500" />;
|
||||||
|
}
|
||||||
|
return <Clock className="w-3.5 h-3.5 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LayoverRow({ layover }: { layover: Layover }) {
|
||||||
|
const riskColors = {
|
||||||
|
none: 'text-muted-foreground',
|
||||||
|
ok: 'text-muted-foreground',
|
||||||
|
warning: 'bg-amber-50 dark:bg-amber-950/20 text-amber-700 dark:text-amber-400',
|
||||||
|
critical: 'bg-red-50 dark:bg-red-950/20 text-red-700 dark:text-red-400',
|
||||||
|
missed: 'bg-red-100 dark:bg-red-950/30 text-red-800 dark:text-red-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBadge = layover.risk === 'warning' || layover.risk === 'critical' || layover.risk === 'missed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 px-4 py-1.5 text-xs ${isBadge ? riskColors[layover.risk] : ''}`}>
|
||||||
|
<div className="flex items-center gap-1.5 ml-6">
|
||||||
|
<div className="w-px h-3 bg-border" />
|
||||||
|
<Link2 className="w-3 h-3 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<span className={isBadge ? 'font-medium' : 'text-muted-foreground'}>
|
||||||
|
{layover.risk === 'missed' ? (
|
||||||
|
<>CONNECTION MISSED at {layover.airport} - arrived {formatLayoverDuration(layover.effectiveMinutes)}</>
|
||||||
|
) : (
|
||||||
|
<>{formatLayoverDuration(layover.scheduledMinutes)} layover at {layover.airport}</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{layover.risk === 'warning' && layover.effectiveMinutes !== layover.scheduledMinutes && (
|
||||||
|
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300 text-[10px] font-semibold">
|
||||||
|
<AlertTriangle className="w-2.5 h-2.5" />
|
||||||
|
now {formatLayoverDuration(layover.effectiveMinutes)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{layover.risk === 'critical' && (
|
||||||
|
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300 text-[10px] font-semibold">
|
||||||
|
<AlertTriangle className="w-2.5 h-2.5" />
|
||||||
|
only {formatLayoverDuration(layover.effectiveMinutes)} remaining
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SINGLE FLIGHT CARD (original behavior)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function SingleFlightCard({ flight, onEdit, onDelete }: { flight: Flight; onEdit?: (f: Flight) => void; onDelete?: (f: Flight) => void }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const refreshMutation = useRefreshFlight();
|
||||||
|
const alert = getAlertBanner(flight);
|
||||||
|
const dotColor = getStatusDotColor(flight);
|
||||||
|
const { formatDateTime } = useFormattedDate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="px-4 pt-3 pb-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
|
||||||
|
<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>
|
||||||
|
{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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="px-4">
|
||||||
|
<FlightProgressBar flight={flight} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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: {formatDateTime(flight.scheduledDeparture)}</div>}
|
||||||
|
{flight.estimatedDeparture && <div>Estimated: {formatDateTime(flight.estimatedDeparture)}</div>}
|
||||||
|
{flight.actualDeparture && <div className="text-foreground">Actual: {formatDateTime(flight.actualDeparture)}</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: {formatDateTime(flight.scheduledArrival)}</div>}
|
||||||
|
{flight.estimatedArrival && <div>Estimated: {formatDateTime(flight.estimatedArrival)}</div>}
|
||||||
|
{flight.actualArrival && <div className="text-foreground">Actual: {formatDateTime(flight.actualArrival)}</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>
|
||||||
|
{flight.aircraftType && (
|
||||||
|
<div className="text-muted-foreground">Aircraft: {flight.aircraftType}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MULTI-SEGMENT JOURNEY CARD
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function JourneyCard({ journey, onEdit, onDelete }: { journey: Journey; onEdit?: (f: Flight) => void; onDelete?: (f: Flight) => void }) {
|
||||||
|
const [expandedSeg, setExpandedSeg] = useState<number | null>(null);
|
||||||
|
const refreshMutation = useRefreshFlight();
|
||||||
|
const currentFlight = journey.flights[journey.currentSegmentIndex];
|
||||||
|
const dotColor = getStatusDotColor(currentFlight);
|
||||||
|
const vip = journey.vip || currentFlight?.vip;
|
||||||
|
const { formatDateTime } = useFormattedDate();
|
||||||
|
|
||||||
|
// Route chain: BWI -> ORD -> SLC
|
||||||
|
const routeChain = [journey.origin, ...journey.flights.slice(1).map(f => f.departureAirport), journey.destination]
|
||||||
|
.filter((v, i, a) => a.indexOf(v) === i); // dedupe
|
||||||
|
|
||||||
|
// Connection risk banner
|
||||||
|
const worstLayover = journey.layovers.reduce<Layover | null>((worst, l) => {
|
||||||
|
if (!worst) return l;
|
||||||
|
if (l.risk === 'missed') return l;
|
||||||
|
if (l.risk === 'critical' && worst.risk !== 'missed') return l;
|
||||||
|
if (l.risk === 'warning' && worst.risk !== 'missed' && worst.risk !== 'critical') return l;
|
||||||
|
return worst;
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
const connectionBanner = worstLayover && (worstLayover.risk === 'warning' || worstLayover.risk === 'critical' || worstLayover.risk === 'missed')
|
||||||
|
? {
|
||||||
|
message: worstLayover.risk === 'missed'
|
||||||
|
? `CONNECTION MISSED at ${worstLayover.airport}`
|
||||||
|
: worstLayover.risk === 'critical'
|
||||||
|
? `CONNECTION AT RISK - only ${formatLayoverDuration(worstLayover.effectiveMinutes)} at ${worstLayover.airport}`
|
||||||
|
: `Connection tight - ${formatLayoverDuration(worstLayover.effectiveMinutes)} at ${worstLayover.airport}`,
|
||||||
|
color: worstLayover.risk === 'missed'
|
||||||
|
? 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400'
|
||||||
|
: worstLayover.risk === 'critical'
|
||||||
|
? 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400'
|
||||||
|
: 'bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400',
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
|
||||||
|
{/* Connection risk banner */}
|
||||||
|
{connectionBanner && (
|
||||||
|
<div className={`px-4 py-1.5 text-xs font-semibold border-b flex items-center gap-2 ${connectionBanner.color}`}>
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
|
{connectionBanner.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Journey header */}
|
||||||
|
<div className="px-4 pt-3 pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
|
||||||
|
{vip && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-bold text-foreground">{vip.name}</span>
|
||||||
|
{vip.partySize > 1 && (
|
||||||
|
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
+{vip.partySize - 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Route chain */}
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
{routeChain.map((code, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <span className="text-muted-foreground/40">{'→'}</span>}
|
||||||
|
<span className={i === 0 || i === routeChain.length - 1 ? 'font-bold text-foreground' : ''}>{code}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="ml-1 px-1.5 py-0.5 rounded bg-muted text-[10px] uppercase tracking-wider font-medium">
|
||||||
|
{journey.flights.length} legs
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Segment stack */}
|
||||||
|
{journey.flights.map((seg, i) => {
|
||||||
|
const isExpanded = expandedSeg === i;
|
||||||
|
const isCurrent = i === journey.currentSegmentIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={seg.id}>
|
||||||
|
{/* Layover row between segments */}
|
||||||
|
{i > 0 && journey.layovers[i - 1] && (
|
||||||
|
<LayoverRow layover={journey.layovers[i - 1]} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Segment row */}
|
||||||
|
<div className={`px-4 py-2 ${isCurrent ? 'bg-accent/30' : ''}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Leg label + status icon */}
|
||||||
|
<div className="flex items-center gap-1.5 min-w-[60px]">
|
||||||
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase">Leg {i + 1}</span>
|
||||||
|
{getSegmentStatusIcon(seg)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact progress bar */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<FlightProgressBar flight={seg} compact />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flight number + actions */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{seg.flightNumber}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => refreshMutation.mutate(seg.id)}
|
||||||
|
disabled={refreshMutation.isPending}
|
||||||
|
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3 h-3 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedSeg(isExpanded ? null : i)}
|
||||||
|
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground"
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal/gate info for current segment */}
|
||||||
|
{isCurrent && (seg.arrivalTerminal || seg.arrivalGate || seg.arrivalBaggage) && (
|
||||||
|
<div className="flex gap-3 mt-1 ml-[68px] text-[10px] text-muted-foreground">
|
||||||
|
{seg.arrivalTerminal && <span>Terminal {seg.arrivalTerminal}</span>}
|
||||||
|
{seg.arrivalGate && <span>Gate {seg.arrivalGate}</span>}
|
||||||
|
{seg.arrivalBaggage && <span>Baggage {seg.arrivalBaggage}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expanded details */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-border/50 text-xs">
|
||||||
|
<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">
|
||||||
|
{seg.scheduledDeparture && <div>Scheduled: {formatDateTime(seg.scheduledDeparture)}</div>}
|
||||||
|
{seg.actualDeparture && <div className="text-foreground">Actual: {formatDateTime(seg.actualDeparture)}</div>}
|
||||||
|
{seg.departureDelay != null && seg.departureDelay > 0 && (
|
||||||
|
<div className="text-amber-600 dark:text-amber-400">Delay: {seg.departureDelay} min</div>
|
||||||
|
)}
|
||||||
|
{seg.departureTerminal && <div>Terminal: {seg.departureTerminal}</div>}
|
||||||
|
{seg.departureGate && <div>Gate: {seg.departureGate}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground mb-1">Arrival</div>
|
||||||
|
<div className="space-y-0.5 text-muted-foreground">
|
||||||
|
{seg.scheduledArrival && <div>Scheduled: {formatDateTime(seg.scheduledArrival)}</div>}
|
||||||
|
{seg.actualArrival && <div className="text-foreground">Actual: {formatDateTime(seg.actualArrival)}</div>}
|
||||||
|
{seg.arrivalDelay != null && seg.arrivalDelay > 0 && (
|
||||||
|
<div className="text-amber-600 dark:text-amber-400">Delay: {seg.arrivalDelay} min</div>
|
||||||
|
)}
|
||||||
|
{seg.arrivalTerminal && <div>Terminal: {seg.arrivalTerminal}</div>}
|
||||||
|
{seg.arrivalGate && <div>Gate: {seg.arrivalGate}</div>}
|
||||||
|
{seg.arrivalBaggage && <div>Baggage: {seg.arrivalBaggage}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
{onEdit && (
|
||||||
|
<button onClick={() => onEdit(seg)} className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400">Edit</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button onClick={() => onDelete(seg)} className="text-xs text-red-600 hover:text-red-800 dark:text-red-400">Delete</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-4 py-1.5 border-t border-border/50 text-xs text-muted-foreground flex items-center gap-3">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Updated {formatRelativeTime(currentFlight?.lastPolledAt)}
|
||||||
|
</span>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-muted text-[10px] uppercase tracking-wider font-medium">
|
||||||
|
{journey.effectiveStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// EXPORT: Routes to single or journey card
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function FlightCard({ flight, journey, onEdit, onDelete }: FlightCardProps) {
|
||||||
|
if (journey) {
|
||||||
|
// Multi-segment journeys always use JourneyCard, single-segment journeys too when passed as journey
|
||||||
|
if (journey.isMultiSegment) {
|
||||||
|
return <JourneyCard journey={journey} onEdit={onEdit} onDelete={onDelete} />;
|
||||||
|
}
|
||||||
|
// Single-segment journey: render as single flight card
|
||||||
|
return <SingleFlightCard flight={journey.flights[0]} onEdit={onEdit} onDelete={onDelete} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flight) {
|
||||||
|
return <SingleFlightCard flight={flight} onEdit={onEdit} onDelete={onDelete} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
237
frontend/src/components/FlightProgressBar.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { useMemo, useEffect, useState } from 'react';
|
||||||
|
import { Plane } from 'lucide-react';
|
||||||
|
import { Flight } from '@/types';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlightProgressBar({ flight, compact = false }: FlightProgressBarProps) {
|
||||||
|
const { formatTime } = useFormattedDate();
|
||||||
|
const [progress, setProgress] = useState(() => calculateProgress(flight));
|
||||||
|
const status = flight.status?.toLowerCase();
|
||||||
|
const isActive = status === 'active';
|
||||||
|
const isLanded = status === 'landed' || !!flight.actualArrival;
|
||||||
|
const isCancelled = status === 'cancelled' || status === 'diverted' || status === 'incident';
|
||||||
|
|
||||||
|
// Update progress periodically for active flights
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) {
|
||||||
|
setProgress(calculateProgress(flight));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress(calculateProgress(flight));
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setProgress(calculateProgress(flight));
|
||||||
|
}, 30000); // Update every 30s for active flights
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [flight, isActive]);
|
||||||
|
|
||||||
|
const trackColor = useMemo(() => getTrackColor(flight), [flight]);
|
||||||
|
const trackBgColor = useMemo(() => getTrackBgColor(flight), [flight]);
|
||||||
|
|
||||||
|
const departureTime = flight.actualDeparture || flight.estimatedDeparture || flight.scheduledDeparture;
|
||||||
|
const arrivalTime = flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
|
||||||
|
const hasDelay = (flight.departureDelay || 0) > 0 || (flight.arrivalDelay || 0) > 0;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="font-bold text-foreground">{flight.departureAirport}</span>
|
||||||
|
<div className="flex-1 relative h-1.5 rounded-full overflow-hidden">
|
||||||
|
<div className={`absolute inset-0 ${trackBgColor} rounded-full`} />
|
||||||
|
<div
|
||||||
|
className={`absolute inset-y-0 left-0 ${trackColor} rounded-full transition-all duration-1000`}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
{isActive && (
|
||||||
|
<Plane
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 text-purple-500 transition-all duration-1000"
|
||||||
|
style={{ left: `calc(${progress}% - 6px)` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-foreground">{flight.arrivalAirport}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full py-2">
|
||||||
|
{/* Airport codes and progress track */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Departure airport */}
|
||||||
|
<div className="text-center min-w-[48px]">
|
||||||
|
<div className="text-lg font-bold text-foreground">{flight.departureAirport}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress track */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
{/* Track background */}
|
||||||
|
<div className={`h-2 rounded-full ${trackBgColor} relative overflow-visible`}>
|
||||||
|
{/* Filled progress */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-y-0 left-0 rounded-full ${trackColor} transition-all duration-1000 ease-in-out`}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Departure dot */}
|
||||||
|
<div className={`absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full border-2 border-background ${progress > 0 ? trackColor : 'bg-muted-foreground/40'}`} />
|
||||||
|
|
||||||
|
{/* Arrival dot */}
|
||||||
|
<div className={`absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-3 h-3 rounded-full border-2 border-background ${progress >= 100 ? trackColor : 'bg-muted-foreground/40'}`} />
|
||||||
|
|
||||||
|
{/* Airplane icon */}
|
||||||
|
{!isCancelled && (
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 transition-all duration-1000 ease-in-out z-10"
|
||||||
|
style={{ left: `${Math.max(2, Math.min(98, progress))}%`, transform: `translateX(-50%) translateY(-50%)` }}
|
||||||
|
>
|
||||||
|
<div className={`${isActive ? 'animate-bounce-subtle' : ''}`}>
|
||||||
|
<Plane
|
||||||
|
className={`w-5 h-5 ${
|
||||||
|
isLanded ? 'text-emerald-500' :
|
||||||
|
isActive ? 'text-purple-500' :
|
||||||
|
'text-muted-foreground'
|
||||||
|
} drop-shadow-sm`}
|
||||||
|
style={{ transform: 'rotate(0deg)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cancelled X */}
|
||||||
|
{isCancelled && (
|
||||||
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-red-500 font-bold text-sm">
|
||||||
|
✕
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrival airport */}
|
||||||
|
<div className="text-center min-w-[48px]">
|
||||||
|
<div className="text-lg font-bold text-foreground">{flight.arrivalAirport}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time and detail row */}
|
||||||
|
<div className="flex justify-between mt-2 text-xs">
|
||||||
|
{/* Departure details */}
|
||||||
|
<div className="text-left">
|
||||||
|
{departureTime && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{hasDelay && flight.scheduledDeparture && flight.scheduledDeparture !== departureTime ? (
|
||||||
|
<>
|
||||||
|
<span className="line-through text-muted-foreground">{formatTime(flight.scheduledDeparture)}</span>
|
||||||
|
<span className="text-amber-600 dark:text-amber-400 font-medium">{formatTime(departureTime)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{formatTime(departureTime)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(flight.departureTerminal || flight.departureGate) && (
|
||||||
|
<div className="text-muted-foreground mt-0.5">
|
||||||
|
{flight.departureTerminal && <span>T{flight.departureTerminal}</span>}
|
||||||
|
{flight.departureTerminal && flight.departureGate && <span> </span>}
|
||||||
|
{flight.departureGate && <span>Gate {flight.departureGate}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: flight duration or status */}
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
{isActive && flight.aircraftType && (
|
||||||
|
<span>{flight.aircraftType}</span>
|
||||||
|
)}
|
||||||
|
{isLanded && <span className="text-emerald-600 dark:text-emerald-400 font-medium">Landed</span>}
|
||||||
|
{isCancelled && <span className="text-red-600 dark:text-red-400 font-medium capitalize">{status}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrival details */}
|
||||||
|
<div className="text-right">
|
||||||
|
{arrivalTime && (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{hasDelay && flight.scheduledArrival && flight.scheduledArrival !== arrivalTime ? (
|
||||||
|
<>
|
||||||
|
<span className="line-through text-muted-foreground">{formatTime(flight.scheduledArrival)}</span>
|
||||||
|
<span className="text-amber-600 dark:text-amber-400 font-medium">{formatTime(arrivalTime)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{isLanded ? '' : 'ETA '}{formatTime(arrivalTime)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && (
|
||||||
|
<div className="text-muted-foreground mt-0.5">
|
||||||
|
{flight.arrivalTerminal && <span>T{flight.arrivalTerminal}</span>}
|
||||||
|
{flight.arrivalGate && <span> Gate {flight.arrivalGate}</span>}
|
||||||
|
{flight.arrivalBaggage && <span> Bag {flight.arrivalBaggage}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { useQuery, 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 { ChevronDown, AlertTriangle, X } from 'lucide-react';
|
import { ChevronDown, AlertTriangle, X } from 'lucide-react';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface Driver {
|
interface Driver {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,6 +32,7 @@ export function InlineDriverSelector({
|
|||||||
currentDriverName,
|
currentDriverName,
|
||||||
onDriverChange,
|
onDriverChange,
|
||||||
}: InlineDriverSelectorProps) {
|
}: InlineDriverSelectorProps) {
|
||||||
|
const { formatDateTime } = useFormattedDate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [showConflictDialog, setShowConflictDialog] = useState(false);
|
const [showConflictDialog, setShowConflictDialog] = useState(false);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
36
frontend/src/components/SortableHeader.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SortableHeaderProps<T extends string> {
|
||||||
|
column: T;
|
||||||
|
label: string;
|
||||||
|
currentSort: {
|
||||||
|
key: string;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
onSort: (key: T) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortableHeader<T extends string>({ column, label, currentSort, onSort, className = '' }: SortableHeaderProps<T>) {
|
||||||
|
const isActive = currentSort.key === column;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={`px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors select-none ${className}`}
|
||||||
|
onClick={() => onSort(column)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{label}
|
||||||
|
{isActive ? (
|
||||||
|
currentSort.direction === 'asc' ? (
|
||||||
|
<ArrowUp className="h-4 w-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="h-4 w-4 text-primary" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="h-4 w-4 text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { X, ChevronDown, ChevronUp, ClipboardList } from 'lucide-react';
|
import { X, ChevronDown, ChevronUp, ClipboardList } from 'lucide-react';
|
||||||
|
import { toDatetimeLocal } from '@/lib/utils';
|
||||||
|
import { DEPARTMENT_LABELS, ARRIVAL_MODE_LABELS } from '@/lib/enum-labels';
|
||||||
|
|
||||||
interface VIPFormProps {
|
interface VIPFormProps {
|
||||||
vip?: VIP | null;
|
vip?: VIP | null;
|
||||||
@@ -44,18 +46,6 @@ export interface VIPFormData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) {
|
export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) {
|
||||||
// Helper to convert ISO datetime to datetime-local format
|
|
||||||
const toDatetimeLocal = (isoString: string | null) => {
|
|
||||||
if (!isoString) return '';
|
|
||||||
const date = new Date(isoString);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState<VIPFormData>({
|
const [formData, setFormData] = useState<VIPFormData>({
|
||||||
name: vip?.name || '',
|
name: vip?.name || '',
|
||||||
organization: vip?.organization || '',
|
organization: vip?.organization || '',
|
||||||
@@ -194,8 +184,11 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
|
{Object.entries(DEPARTMENT_LABELS).map(([value, label]) => (
|
||||||
<option value="ADMIN">Admin</option>
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,8 +205,11 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<option value="FLIGHT">Flight</option>
|
{Object.entries(ARRIVAL_MODE_LABELS).map(([value, label]) => (
|
||||||
<option value="SELF_DRIVING">Self Driving</option>
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
71
frontend/src/contexts/TimezoneContext.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface TimezoneContextValue {
|
||||||
|
timezone: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
setTimezone: (tz: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimezoneContext = createContext<TimezoneContextValue>({
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
isLoading: false,
|
||||||
|
setTimezone: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function TimezoneProvider({ children }: { children: ReactNode }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<{ timezone: string }>({
|
||||||
|
queryKey: ['settings', 'timezone'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/settings/timezone');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (timezone: string) => {
|
||||||
|
const { data } = await api.patch('/settings/timezone', { timezone });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(['settings', 'timezone'], data);
|
||||||
|
toast.success('Timezone updated');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || 'Failed to update timezone');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const timezone = data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TimezoneContext.Provider
|
||||||
|
value={{
|
||||||
|
timezone,
|
||||||
|
isLoading,
|
||||||
|
setTimezone: (tz: string) => mutation.mutate(tz),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TimezoneContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the app-wide timezone string
|
||||||
|
*/
|
||||||
|
export function useTimezone(): string {
|
||||||
|
return useContext(TimezoneContext).timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full timezone context (timezone, isLoading, setTimezone)
|
||||||
|
*/
|
||||||
|
export function useTimezoneContext(): TimezoneContextValue {
|
||||||
|
return useContext(TimezoneContext);
|
||||||
|
}
|
||||||
66
frontend/src/hooks/useFlights.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { Flight, FlightBudget } from '@/types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
|
export function useFlights() {
|
||||||
|
return useQuery<Flight[]>({
|
||||||
|
queryKey: queryKeys.flights.all,
|
||||||
|
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: queryKeys.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: queryKeys.flights.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.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: queryKeys.flights.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.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');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
28
frontend/src/hooks/useFormattedDate.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useTimezone } from '@/contexts/TimezoneContext';
|
||||||
|
import { formatDate as fmtDate, formatDateTime as fmtDateTime, formatTime as fmtTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns format functions pre-bound with the app-wide timezone.
|
||||||
|
* Use this in components instead of importing formatDate/DateTime/Time directly.
|
||||||
|
*/
|
||||||
|
export function useFormattedDate() {
|
||||||
|
const timezone = useTimezone();
|
||||||
|
|
||||||
|
const formatDate = useCallback(
|
||||||
|
(date: string | Date) => fmtDate(date, timezone),
|
||||||
|
[timezone],
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatDateTime = useCallback(
|
||||||
|
(date: string | Date) => fmtDateTime(date, timezone),
|
||||||
|
[timezone],
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatTime = useCallback(
|
||||||
|
(date: string | Date) => fmtTime(date, timezone),
|
||||||
|
[timezone],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { formatDate, formatDateTime, formatTime, timezone };
|
||||||
|
}
|
||||||
@@ -8,8 +8,10 @@ import type {
|
|||||||
GpsSettings,
|
GpsSettings,
|
||||||
EnrollmentResponse,
|
EnrollmentResponse,
|
||||||
MyGpsStatus,
|
MyGpsStatus,
|
||||||
|
DeviceQrInfo,
|
||||||
} from '@/types/gps';
|
} from '@/types/gps';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Admin GPS Hooks
|
// Admin GPS Hooks
|
||||||
@@ -20,7 +22,7 @@ import toast from 'react-hot-toast';
|
|||||||
*/
|
*/
|
||||||
export function useGpsStatus() {
|
export function useGpsStatus() {
|
||||||
return useQuery<GpsStatus>({
|
return useQuery<GpsStatus>({
|
||||||
queryKey: ['gps', 'status'],
|
queryKey: queryKeys.gps.status,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/status');
|
const { data } = await api.get('/gps/status');
|
||||||
return data;
|
return data;
|
||||||
@@ -34,7 +36,7 @@ export function useGpsStatus() {
|
|||||||
*/
|
*/
|
||||||
export function useGpsSettings() {
|
export function useGpsSettings() {
|
||||||
return useQuery<GpsSettings>({
|
return useQuery<GpsSettings>({
|
||||||
queryKey: ['gps', 'settings'],
|
queryKey: queryKeys.gps.settings,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/settings');
|
const { data } = await api.get('/gps/settings');
|
||||||
return data;
|
return data;
|
||||||
@@ -54,8 +56,8 @@ export function useUpdateGpsSettings() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'settings'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.settings });
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
|
||||||
toast.success('GPS settings updated');
|
toast.success('GPS settings updated');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -69,7 +71,7 @@ export function useUpdateGpsSettings() {
|
|||||||
*/
|
*/
|
||||||
export function useGpsDevices() {
|
export function useGpsDevices() {
|
||||||
return useQuery<GpsDevice[]>({
|
return useQuery<GpsDevice[]>({
|
||||||
queryKey: ['gps', 'devices'],
|
queryKey: queryKeys.gps.devices,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/devices');
|
const { data } = await api.get('/gps/devices');
|
||||||
return data;
|
return data;
|
||||||
@@ -79,48 +81,30 @@ export function useGpsDevices() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all active driver locations (for map)
|
* Get QR code info for an enrolled device (on demand)
|
||||||
|
*/
|
||||||
|
export function useDeviceQr(driverId: string | null) {
|
||||||
|
return useQuery<DeviceQrInfo>({
|
||||||
|
queryKey: driverId ? queryKeys.gps.deviceQr(driverId) : ['gps', 'devices', null, 'qr'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/gps/devices/${driverId}/qr`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!driverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active driver locations (used by CommandCenter)
|
||||||
*/
|
*/
|
||||||
export function useDriverLocations() {
|
export function useDriverLocations() {
|
||||||
return useQuery<DriverLocation[]>({
|
return useQuery<DriverLocation[]>({
|
||||||
queryKey: ['gps', 'locations'],
|
queryKey: queryKeys.gps.locations.all,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/locations');
|
const { data } = await api.get('/gps/locations');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
refetchInterval: 30000, // Refresh every 30 seconds
|
refetchInterval: 15000, // Refresh every 15 seconds
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a specific driver's location
|
|
||||||
*/
|
|
||||||
export function useDriverLocation(driverId: string) {
|
|
||||||
return useQuery<DriverLocation>({
|
|
||||||
queryKey: ['gps', 'locations', driverId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await api.get(`/gps/locations/${driverId}`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!driverId,
|
|
||||||
refetchInterval: 30000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get driver stats
|
|
||||||
*/
|
|
||||||
export function useDriverStats(driverId: string, from?: string, to?: string) {
|
|
||||||
return useQuery<DriverStats>({
|
|
||||||
queryKey: ['gps', 'stats', driverId, from, to],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (from) params.append('from', from);
|
|
||||||
if (to) params.append('to', to);
|
|
||||||
const { data } = await api.get(`/gps/stats/${driverId}?${params.toString()}`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!driverId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +120,9 @@ export function useEnrollDriver() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'devices'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.devices });
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
|
||||||
queryClient.invalidateQueries({ queryKey: ['drivers'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.drivers.all });
|
||||||
if (data.signalMessageSent) {
|
if (data.signalMessageSent) {
|
||||||
toast.success('Driver enrolled! Setup instructions sent via Signal.');
|
toast.success('Driver enrolled! Setup instructions sent via Signal.');
|
||||||
} else {
|
} else {
|
||||||
@@ -163,10 +147,10 @@ export function useUnenrollDriver() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'devices'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.devices });
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'locations'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.locations.all });
|
||||||
queryClient.invalidateQueries({ queryKey: ['drivers'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.drivers.all });
|
||||||
toast.success('Driver unenrolled from GPS tracking');
|
toast.success('Driver unenrolled from GPS tracking');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -184,7 +168,7 @@ export function useUnenrollDriver() {
|
|||||||
*/
|
*/
|
||||||
export function useMyGpsStatus() {
|
export function useMyGpsStatus() {
|
||||||
return useQuery<MyGpsStatus>({
|
return useQuery<MyGpsStatus>({
|
||||||
queryKey: ['gps', 'me'],
|
queryKey: queryKeys.gps.me.status,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/me');
|
const { data } = await api.get('/gps/me');
|
||||||
return data;
|
return data;
|
||||||
@@ -197,7 +181,7 @@ export function useMyGpsStatus() {
|
|||||||
*/
|
*/
|
||||||
export function useMyGpsStats(from?: string, to?: string) {
|
export function useMyGpsStats(from?: string, to?: string) {
|
||||||
return useQuery<DriverStats>({
|
return useQuery<DriverStats>({
|
||||||
queryKey: ['gps', 'me', 'stats', from, to],
|
queryKey: queryKeys.gps.me.stats(from, to),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (from) params.append('from', from);
|
if (from) params.append('from', from);
|
||||||
@@ -213,7 +197,7 @@ export function useMyGpsStats(from?: string, to?: string) {
|
|||||||
*/
|
*/
|
||||||
export function useMyLocation() {
|
export function useMyLocation() {
|
||||||
return useQuery<DriverLocation>({
|
return useQuery<DriverLocation>({
|
||||||
queryKey: ['gps', 'me', 'location'],
|
queryKey: queryKeys.gps.me.location,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/me/location');
|
const { data } = await api.get('/gps/me/location');
|
||||||
return data;
|
return data;
|
||||||
@@ -234,7 +218,7 @@ export function useUpdateGpsConsent() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'me'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.me.status });
|
||||||
toast.success(data.message);
|
toast.success(data.message);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -252,7 +236,7 @@ export function useUpdateGpsConsent() {
|
|||||||
*/
|
*/
|
||||||
export function useTraccarSetupStatus() {
|
export function useTraccarSetupStatus() {
|
||||||
return useQuery<{ needsSetup: boolean; isAvailable: boolean }>({
|
return useQuery<{ needsSetup: boolean; isAvailable: boolean }>({
|
||||||
queryKey: ['gps', 'traccar', 'status'],
|
queryKey: queryKeys.gps.traccar.status,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/traccar/status');
|
const { data } = await api.get('/gps/traccar/status');
|
||||||
return data;
|
return data;
|
||||||
@@ -272,8 +256,8 @@ export function useTraccarSetup() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'traccar', 'status'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.traccar.status });
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
|
||||||
toast.success('Traccar setup complete!');
|
toast.success('Traccar setup complete!');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -305,7 +289,7 @@ export function useSyncAdminsToTraccar() {
|
|||||||
*/
|
*/
|
||||||
export function useTraccarAdminUrl() {
|
export function useTraccarAdminUrl() {
|
||||||
return useQuery<{ url: string; directAccess: boolean }>({
|
return useQuery<{ url: string; directAccess: boolean }>({
|
||||||
queryKey: ['gps', 'traccar', 'admin-url'],
|
queryKey: queryKeys.gps.traccar.adminUrl,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/traccar/admin-url');
|
const { data } = await api.get('/gps/traccar/admin-url');
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
55
frontend/src/hooks/useListPage.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useDebounce } from './useDebounce';
|
||||||
|
|
||||||
|
interface UseListPageOptions<T extends string> {
|
||||||
|
defaultSortKey: T;
|
||||||
|
defaultSortDirection?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useListPage<T extends string>(options: UseListPageOptions<T>) {
|
||||||
|
const { defaultSortKey, defaultSortDirection = 'asc' } = options;
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
|
||||||
|
// Sort state
|
||||||
|
const [sortKey, setSortKey] = useState<T>(defaultSortKey);
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(defaultSortDirection);
|
||||||
|
|
||||||
|
// Generic filter state (key-value pairs)
|
||||||
|
const [filters, setFiltersState] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
const handleSort = (key: T) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFilter = (key: string, value: any) => {
|
||||||
|
setFiltersState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearch('');
|
||||||
|
setFiltersState({});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
debouncedSearch,
|
||||||
|
sortKey,
|
||||||
|
sortDirection,
|
||||||
|
handleSort,
|
||||||
|
filters,
|
||||||
|
setFilter,
|
||||||
|
clearFilters,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { PdfSettings, UpdatePdfSettingsDto } from '../types/settings';
|
import { PdfSettings, UpdatePdfSettingsDto } from '../types/settings';
|
||||||
|
import { queryKeys } from '../lib/query-keys';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch PDF settings
|
* Fetch PDF settings
|
||||||
*/
|
*/
|
||||||
export function usePdfSettings() {
|
export function usePdfSettings() {
|
||||||
return useQuery<PdfSettings>({
|
return useQuery<PdfSettings>({
|
||||||
queryKey: ['settings', 'pdf'],
|
queryKey: queryKeys.settings.pdf,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/settings/pdf');
|
const { data } = await api.get('/settings/pdf');
|
||||||
return data;
|
return data;
|
||||||
@@ -27,7 +28,7 @@ export function useUpdatePdfSettings() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -51,7 +52,7 @@ export function useUploadLogo() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,7 @@ export function useDeleteLogo() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import { queryKeys } from '../lib/query-keys';
|
||||||
|
|
||||||
export interface SignalMessage {
|
export interface SignalMessage {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,7 +20,7 @@ export interface UnreadCounts {
|
|||||||
*/
|
*/
|
||||||
export function useDriverMessages(driverId: string | null, enabled = true) {
|
export function useDriverMessages(driverId: string | null, enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['signal-messages', driverId],
|
queryKey: driverId ? queryKeys.signal.messages(driverId) : ['signal-messages', null],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!driverId) return [];
|
if (!driverId) return [];
|
||||||
const { data } = await api.get<SignalMessage[]>(`/signal/messages/driver/${driverId}`);
|
const { data } = await api.get<SignalMessage[]>(`/signal/messages/driver/${driverId}`);
|
||||||
@@ -35,7 +36,7 @@ export function useDriverMessages(driverId: string | null, enabled = true) {
|
|||||||
*/
|
*/
|
||||||
export function useUnreadCounts() {
|
export function useUnreadCounts() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['signal-unread-counts'],
|
queryKey: queryKeys.signal.unreadCounts,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get<UnreadCounts>('/signal/messages/unread');
|
const { data } = await api.get<UnreadCounts>('/signal/messages/unread');
|
||||||
return data;
|
return data;
|
||||||
@@ -54,8 +55,10 @@ export function useDriverResponseCheck(
|
|||||||
// Only include events that have a driver
|
// Only include events that have a driver
|
||||||
const eventsWithDrivers = events.filter((e) => e.driver?.id);
|
const eventsWithDrivers = events.filter((e) => e.driver?.id);
|
||||||
|
|
||||||
|
const eventIds = eventsWithDrivers.map((e) => e.id).join(',');
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['signal-driver-responses', eventsWithDrivers.map((e) => e.id).join(',')],
|
queryKey: queryKeys.signal.driverResponses(eventIds),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (eventsWithDrivers.length === 0) {
|
if (eventsWithDrivers.length === 0) {
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
@@ -97,11 +100,11 @@ export function useSendMessage() {
|
|||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
// Add the new message to the cache immediately
|
// Add the new message to the cache immediately
|
||||||
queryClient.setQueryData<SignalMessage[]>(
|
queryClient.setQueryData<SignalMessage[]>(
|
||||||
['signal-messages', variables.driverId],
|
queryKeys.signal.messages(variables.driverId),
|
||||||
(old) => [...(old || []), data]
|
(old) => [...(old || []), data]
|
||||||
);
|
);
|
||||||
// Also invalidate to ensure consistency
|
// Also invalidate to ensure consistency
|
||||||
queryClient.invalidateQueries({ queryKey: ['signal-messages', variables.driverId] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.signal.messages(variables.driverId) });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -120,7 +123,7 @@ export function useMarkMessagesAsRead() {
|
|||||||
onSuccess: (_, driverId) => {
|
onSuccess: (_, driverId) => {
|
||||||
// Update the unread counts cache
|
// Update the unread counts cache
|
||||||
queryClient.setQueryData<UnreadCounts>(
|
queryClient.setQueryData<UnreadCounts>(
|
||||||
['signal-unread-counts'],
|
queryKeys.signal.unreadCounts,
|
||||||
(old) => {
|
(old) => {
|
||||||
if (!old) return {};
|
if (!old) return {};
|
||||||
const updated = { ...old };
|
const updated = { ...old };
|
||||||
@@ -130,7 +133,7 @@ export function useMarkMessagesAsRead() {
|
|||||||
);
|
);
|
||||||
// Mark messages as read in the messages cache
|
// Mark messages as read in the messages cache
|
||||||
queryClient.setQueryData<SignalMessage[]>(
|
queryClient.setQueryData<SignalMessage[]>(
|
||||||
['signal-messages', driverId],
|
queryKeys.signal.messages(driverId),
|
||||||
(old) => old?.map((msg) => ({ ...msg, isRead: true })) || []
|
(old) => old?.map((msg) => ({ ...msg, isRead: true })) || []
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
41
frontend/src/lib/enum-labels.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Enum Display Labels
|
||||||
|
* Centralized mapping of enum values to human-readable labels
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEPARTMENT_LABELS: Record<string, string> = {
|
||||||
|
OFFICE_OF_DEVELOPMENT: 'Office of Development',
|
||||||
|
ADMIN: 'Admin',
|
||||||
|
OTHER: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ARRIVAL_MODE_LABELS: Record<string, string> = {
|
||||||
|
FLIGHT: 'Flight',
|
||||||
|
SELF_DRIVING: 'Self Driving',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EVENT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
TRANSPORT: 'Transport',
|
||||||
|
MEETING: 'Meeting',
|
||||||
|
EVENT: 'Event',
|
||||||
|
MEAL: 'Meal',
|
||||||
|
ACCOMMODATION: 'Accommodation',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EVENT_STATUS_LABELS: Record<string, string> = {
|
||||||
|
SCHEDULED: 'Scheduled',
|
||||||
|
IN_PROGRESS: 'In Progress',
|
||||||
|
COMPLETED: 'Completed',
|
||||||
|
CANCELLED: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get a label for any enum value
|
||||||
|
* Falls back to the value itself if no mapping is found
|
||||||
|
*/
|
||||||
|
export function getEnumLabel(
|
||||||
|
value: string,
|
||||||
|
labels: Record<string, string>
|
||||||
|
): string {
|
||||||
|
return labels[value] || value;
|
||||||
|
}
|
||||||
118
frontend/src/lib/journeyUtils.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { Flight, Journey, Layover, LayoverRiskLevel } from '@/types';
|
||||||
|
|
||||||
|
function getEffectiveArrival(flight: Flight): Date | null {
|
||||||
|
const t = flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
|
||||||
|
return t ? new Date(t) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectiveDeparture(flight: Flight): Date | null {
|
||||||
|
const t = flight.actualDeparture || flight.estimatedDeparture || flight.scheduledDeparture;
|
||||||
|
return t ? new Date(t) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeLayoverRisk(effectiveMinutes: number, scheduledMinutes: number): LayoverRiskLevel {
|
||||||
|
if (effectiveMinutes < 0) return 'missed';
|
||||||
|
if (effectiveMinutes < 30) return 'critical';
|
||||||
|
if (effectiveMinutes < 60) return 'warning';
|
||||||
|
if (scheduledMinutes > 0) return 'ok';
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeLayover(arriving: Flight, departing: Flight, index: number): Layover {
|
||||||
|
const scheduledArr = arriving.scheduledArrival ? new Date(arriving.scheduledArrival) : null;
|
||||||
|
const scheduledDep = departing.scheduledDeparture ? new Date(departing.scheduledDeparture) : null;
|
||||||
|
const effectiveArr = getEffectiveArrival(arriving);
|
||||||
|
const effectiveDep = getEffectiveDeparture(departing);
|
||||||
|
|
||||||
|
const scheduledMinutes = scheduledArr && scheduledDep
|
||||||
|
? (scheduledDep.getTime() - scheduledArr.getTime()) / 60000
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const effectiveMinutes = effectiveArr && effectiveDep
|
||||||
|
? (effectiveDep.getTime() - effectiveArr.getTime()) / 60000
|
||||||
|
: scheduledMinutes;
|
||||||
|
|
||||||
|
return {
|
||||||
|
airport: arriving.arrivalAirport,
|
||||||
|
afterSegmentIndex: index,
|
||||||
|
scheduledMinutes: Math.round(scheduledMinutes),
|
||||||
|
effectiveMinutes: Math.round(effectiveMinutes),
|
||||||
|
risk: computeLayoverRisk(effectiveMinutes, scheduledMinutes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeEffectiveStatus(flights: Flight[]): { effectiveStatus: string; currentSegmentIndex: number } {
|
||||||
|
// Critical statuses on any segment take priority
|
||||||
|
for (let i = 0; i < flights.length; i++) {
|
||||||
|
const s = flights[i].status?.toLowerCase();
|
||||||
|
if (s === 'cancelled' || s === 'diverted' || s === 'incident') {
|
||||||
|
return { effectiveStatus: s, currentSegmentIndex: i };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find first non-terminal segment (the "active" one)
|
||||||
|
for (let i = 0; i < flights.length; i++) {
|
||||||
|
const s = flights[i].status?.toLowerCase();
|
||||||
|
const isTerminal = s === 'landed' || !!flights[i].actualArrival;
|
||||||
|
if (!isTerminal) {
|
||||||
|
return { effectiveStatus: s || 'scheduled', currentSegmentIndex: i };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All segments landed
|
||||||
|
const last = flights.length - 1;
|
||||||
|
return { effectiveStatus: flights[last].status?.toLowerCase() || 'landed', currentSegmentIndex: last };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupFlightsIntoJourneys(flights: Flight[]): Journey[] {
|
||||||
|
const byVip = new Map<string, Flight[]>();
|
||||||
|
for (const flight of flights) {
|
||||||
|
const group = byVip.get(flight.vipId) || [];
|
||||||
|
group.push(flight);
|
||||||
|
byVip.set(flight.vipId, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const journeys: Journey[] = [];
|
||||||
|
|
||||||
|
for (const [vipId, vipFlights] of byVip) {
|
||||||
|
// Sort chronologically by departure time, then segment as tiebreaker
|
||||||
|
const sorted = [...vipFlights].sort((a, b) => {
|
||||||
|
const depA = a.scheduledDeparture || a.flightDate;
|
||||||
|
const depB = b.scheduledDeparture || b.flightDate;
|
||||||
|
const timeDiff = new Date(depA).getTime() - new Date(depB).getTime();
|
||||||
|
if (timeDiff !== 0) return timeDiff;
|
||||||
|
return a.segment - b.segment;
|
||||||
|
});
|
||||||
|
|
||||||
|
const layovers: Layover[] = [];
|
||||||
|
for (let i = 0; i < sorted.length - 1; i++) {
|
||||||
|
layovers.push(computeLayover(sorted[i], sorted[i + 1], i));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { effectiveStatus, currentSegmentIndex } = computeEffectiveStatus(sorted);
|
||||||
|
|
||||||
|
journeys.push({
|
||||||
|
vipId,
|
||||||
|
vip: sorted[0]?.vip,
|
||||||
|
flights: sorted,
|
||||||
|
layovers,
|
||||||
|
effectiveStatus,
|
||||||
|
currentSegmentIndex,
|
||||||
|
hasLayoverRisk: layovers.some(l => l.risk === 'warning' || l.risk === 'critical' || l.risk === 'missed'),
|
||||||
|
origin: sorted[0]?.departureAirport,
|
||||||
|
destination: sorted[sorted.length - 1]?.arrivalAirport,
|
||||||
|
isMultiSegment: sorted.length > 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return journeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLayoverDuration(minutes: number): string {
|
||||||
|
if (minutes < 0) return `${Math.abs(minutes)}min late`;
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
if (h === 0) return `${m}min`;
|
||||||
|
if (m === 0) return `${h}h`;
|
||||||
|
return `${h}h ${m}min`;
|
||||||
|
}
|
||||||
93
frontend/src/lib/query-keys.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Query key factory constants for TanStack Query
|
||||||
|
*
|
||||||
|
* This file provides typed, centralized query key management for all entities.
|
||||||
|
* Using factory functions ensures consistent keys across the application.
|
||||||
|
*
|
||||||
|
* @see https://tkdodo.eu/blog/effective-react-query-keys
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const queryKeys = {
|
||||||
|
// VIPs
|
||||||
|
vips: {
|
||||||
|
all: ['vips'] as const,
|
||||||
|
detail: (id: string) => ['vip', id] as const,
|
||||||
|
forSchedule: (vipIds: string) => ['vips-for-schedule', vipIds] as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Drivers
|
||||||
|
drivers: {
|
||||||
|
all: ['drivers'] as const,
|
||||||
|
myProfile: ['my-driver-profile'] as const,
|
||||||
|
schedule: (driverId: string, date: string) => ['driver-schedule', driverId, date] as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Events/Schedule
|
||||||
|
events: {
|
||||||
|
all: ['events'] as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Vehicles
|
||||||
|
vehicles: {
|
||||||
|
all: ['vehicles'] as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Flights
|
||||||
|
flights: {
|
||||||
|
all: ['flights'] as const,
|
||||||
|
budget: ['flights', 'budget'] as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Users
|
||||||
|
users: {
|
||||||
|
all: ['users'] as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// GPS/Location Tracking
|
||||||
|
gps: {
|
||||||
|
status: ['gps', 'status'] as const,
|
||||||
|
settings: ['gps', 'settings'] as const,
|
||||||
|
devices: ['gps', 'devices'] as const,
|
||||||
|
deviceQr: (driverId: string) => ['gps', 'devices', driverId, 'qr'] as const,
|
||||||
|
locations: {
|
||||||
|
all: ['gps', 'locations'] as const,
|
||||||
|
detail: (driverId: string) => ['gps', 'locations', driverId] as const,
|
||||||
|
},
|
||||||
|
stats: (driverId: string, from?: string, to?: string) =>
|
||||||
|
['gps', 'stats', driverId, from, to] as const,
|
||||||
|
me: {
|
||||||
|
status: ['gps', 'me'] as const,
|
||||||
|
stats: (from?: string, to?: string) => ['gps', 'me', 'stats', from, to] as const,
|
||||||
|
location: ['gps', 'me', 'location'] as const,
|
||||||
|
},
|
||||||
|
traccar: {
|
||||||
|
status: ['gps', 'traccar', 'status'] as const,
|
||||||
|
adminUrl: ['gps', 'traccar', 'admin-url'] as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
settings: {
|
||||||
|
pdf: ['settings', 'pdf'] as const,
|
||||||
|
timezone: ['settings', 'timezone'] as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Signal Messages
|
||||||
|
signal: {
|
||||||
|
messages: (driverId: string) => ['signal-messages', driverId] as const,
|
||||||
|
unreadCounts: ['signal-unread-counts'] as const,
|
||||||
|
driverResponses: (eventIds: string) => ['signal-driver-responses', eventIds] as const,
|
||||||
|
status: ['signal-status'] as const,
|
||||||
|
messageStats: ['signal-message-stats'] as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
admin: {
|
||||||
|
stats: ['admin-stats'] as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Features
|
||||||
|
features: {
|
||||||
|
all: ['features'] as const,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -5,16 +5,17 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(date: string | Date): string {
|
export function formatDate(date: string | Date, timeZone?: string): string {
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
return d.toLocaleDateString('en-US', {
|
return d.toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
...(timeZone && { timeZone }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDateTime(date: string | Date): string {
|
export function formatDateTime(date: string | Date, timeZone?: string): string {
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
return d.toLocaleString('en-US', {
|
return d.toLocaleString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -22,13 +23,30 @@ export function formatDateTime(date: string | Date): string {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
...(timeZone && { timeZone }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTime(date: string | Date): string {
|
export function formatTime(date: string | Date, timeZone?: string): string {
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
return d.toLocaleTimeString('en-US', {
|
return d.toLocaleTimeString('en-US', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
...(timeZone && { timeZone }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ISO datetime string to datetime-local input format (YYYY-MM-DDTHH:mm)
|
||||||
|
* Used for populating datetime-local inputs in forms
|
||||||
|
*/
|
||||||
|
export function toDatetimeLocal(isoString: string | null | undefined): string {
|
||||||
|
if (!isoString) return '';
|
||||||
|
const date = new Date(isoString);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import toast from 'react-hot-toast';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { handleError } from '@/lib/errorHandler';
|
import { handleError } from '@/lib/errorHandler';
|
||||||
import { PdfSettingsSection } from '@/components/PdfSettingsSection';
|
import { PdfSettingsSection } from '@/components/PdfSettingsSection';
|
||||||
|
import { useTimezoneContext } from '@/contexts/TimezoneContext';
|
||||||
import {
|
import {
|
||||||
Database,
|
Database,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Shield,
|
Shield,
|
||||||
|
Globe,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
@@ -51,9 +53,27 @@ interface MessageStats {
|
|||||||
driversWithMessages: number;
|
driversWithMessages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMMON_TIMEZONES = [
|
||||||
|
{ value: 'America/New_York', label: 'Eastern (ET)' },
|
||||||
|
{ value: 'America/Chicago', label: 'Central (CT)' },
|
||||||
|
{ value: 'America/Denver', label: 'Mountain (MT)' },
|
||||||
|
{ value: 'America/Los_Angeles', label: 'Pacific (PT)' },
|
||||||
|
{ value: 'America/Anchorage', label: 'Alaska (AKT)' },
|
||||||
|
{ value: 'Pacific/Honolulu', label: 'Hawaii (HT)' },
|
||||||
|
{ value: 'America/Phoenix', label: 'Arizona (no DST)' },
|
||||||
|
{ value: 'UTC', label: 'UTC' },
|
||||||
|
{ value: 'Europe/London', label: 'London (GMT/BST)' },
|
||||||
|
{ value: 'Europe/Paris', label: 'Paris (CET/CEST)' },
|
||||||
|
{ value: 'Europe/Berlin', label: 'Berlin (CET/CEST)' },
|
||||||
|
{ value: 'Asia/Tokyo', label: 'Tokyo (JST)' },
|
||||||
|
{ value: 'Asia/Shanghai', label: 'Shanghai (CST)' },
|
||||||
|
{ value: 'Australia/Sydney', label: 'Sydney (AEST)' },
|
||||||
|
];
|
||||||
|
|
||||||
export function AdminTools() {
|
export function AdminTools() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { timezone, setTimezone } = useTimezoneContext();
|
||||||
|
|
||||||
// Signal state
|
// Signal state
|
||||||
const [showQRCode, setShowQRCode] = useState(false);
|
const [showQRCode, setShowQRCode] = useState(false);
|
||||||
@@ -433,6 +453,33 @@ export function AdminTools() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* App Timezone */}
|
||||||
|
<div className="bg-card border border-border shadow-soft rounded-lg p-6 mb-8 transition-colors">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<Globe className="h-5 w-5 text-blue-600 mr-2" />
|
||||||
|
<h2 className="text-lg font-medium text-foreground">App Timezone</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
All dates and times throughout the app will display in this timezone. Set this to match your event location.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<select
|
||||||
|
value={timezone}
|
||||||
|
onChange={(e) => setTimezone(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary w-full max-w-xs"
|
||||||
|
>
|
||||||
|
{COMMON_TIMEZONES.map((tz) => (
|
||||||
|
<option key={tz.value} value={tz.value}>
|
||||||
|
{tz.label} ({tz.value})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Current: {new Date().toLocaleTimeString('en-US', { timeZone: timezone, hour: '2-digit', minute: '2-digit', timeZoneName: 'short' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* PDF Customization Settings */}
|
{/* PDF Customization Settings */}
|
||||||
<PdfSettingsSection />
|
<PdfSettingsSection />
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import { DriverLocationModal } from '@/components/DriverLocationModal';
|
|||||||
import { useUnreadCounts, useDriverResponseCheck } from '@/hooks/useSignalMessages';
|
import { useUnreadCounts, useDriverResponseCheck } from '@/hooks/useSignalMessages';
|
||||||
import { useDriverLocations } from '@/hooks/useGps';
|
import { useDriverLocations } from '@/hooks/useGps';
|
||||||
import type { DriverLocation } from '@/types/gps';
|
import type { DriverLocation } from '@/types/gps';
|
||||||
|
import type { Flight } from '@/types';
|
||||||
|
import { groupFlightsIntoJourneys, formatLayoverDuration } from '@/lib/journeyUtils';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface Event {
|
interface Event {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -80,8 +83,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;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +108,7 @@ const SCROLL_PAUSE_AT_END = 2000; // pause 2 seconds at top/bottom
|
|||||||
const USER_INTERACTION_PAUSE = 20000; // pause 20 seconds after user interaction
|
const USER_INTERACTION_PAUSE = 20000; // pause 20 seconds after user interaction
|
||||||
|
|
||||||
export function CommandCenter() {
|
export function CommandCenter() {
|
||||||
|
const { formatTime, formatDateTime, timezone } = useFormattedDate();
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [lastRefresh, setLastRefresh] = useState(new Date());
|
const [lastRefresh, setLastRefresh] = useState(new Date());
|
||||||
const [chatDriver, setChatDriver] = useState<Driver | null>(null);
|
const [chatDriver, setChatDriver] = useState<Driver | null>(null);
|
||||||
@@ -184,6 +197,20 @@ export function CommandCenter() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: flights } = useQuery<Flight[]>({
|
||||||
|
queryKey: ['flights'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/flights');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group flights into journeys for connection risk detection
|
||||||
|
const journeys = useMemo(() => {
|
||||||
|
if (!flights || flights.length === 0) return [];
|
||||||
|
return groupFlightsIntoJourneys(flights);
|
||||||
|
}, [flights]);
|
||||||
|
|
||||||
// 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 +339,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: { actualArrival: string | null; estimatedArrival: string | null; scheduledArrival: string | null }) =>
|
||||||
|
flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
|
||||||
|
|
||||||
const upcomingArrivals = vips
|
const upcomingArrivals = vips
|
||||||
.filter((vip) => {
|
.filter((vip) => {
|
||||||
if (vip.expectedArrival) {
|
if (vip.expectedArrival) {
|
||||||
@@ -320,16 +350,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 +445,72 @@ 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) => {
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection risk alerts from journey analysis
|
||||||
|
journeys.forEach((journey) => {
|
||||||
|
if (!journey.hasLayoverRisk) return;
|
||||||
|
const vipName = journey.vip?.name || 'Unknown VIP';
|
||||||
|
journey.layovers.forEach((layover) => {
|
||||||
|
if (layover.risk === 'missed') {
|
||||||
|
alerts.push({
|
||||||
|
type: 'critical',
|
||||||
|
message: `${vipName}: Connection MISSED at ${layover.airport} - arrived ${formatLayoverDuration(Math.abs(layover.effectiveMinutes))} after departure`,
|
||||||
|
link: '/flights',
|
||||||
|
});
|
||||||
|
} else if (layover.risk === 'critical') {
|
||||||
|
alerts.push({
|
||||||
|
type: 'critical',
|
||||||
|
message: `${vipName}: Connection at ${layover.airport} critical - only ${formatLayoverDuration(layover.effectiveMinutes)} layover`,
|
||||||
|
link: '/flights',
|
||||||
|
});
|
||||||
|
} else if (layover.risk === 'warning') {
|
||||||
|
alerts.push({
|
||||||
|
type: 'warning',
|
||||||
|
message: `${vipName}: Connection at ${layover.airport} tight - ${formatLayoverDuration(layover.effectiveMinutes)} layover (was ${formatLayoverDuration(layover.scheduledMinutes)})`,
|
||||||
|
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);
|
||||||
@@ -500,10 +597,10 @@ export function CommandCenter() {
|
|||||||
{/* Live Clock */}
|
{/* Live Clock */}
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-4xl font-mono font-bold text-foreground tabular-nums">
|
<div className="text-4xl font-mono font-bold text-foreground tabular-nums">
|
||||||
{currentTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
{currentTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: timezone })}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{currentTime.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
{currentTime.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', timeZone: timezone })}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground mt-1">
|
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground mt-1">
|
||||||
<Radio className="h-3 w-3 text-green-500 animate-pulse" />
|
<Radio className="h-3 w-3 text-green-500 animate-pulse" />
|
||||||
@@ -708,7 +805,7 @@ export function CommandCenter() {
|
|||||||
<div className="text-right flex-shrink-0">
|
<div className="text-right flex-shrink-0">
|
||||||
<p className="text-xs text-muted-foreground">ETA</p>
|
<p className="text-xs text-muted-foreground">ETA</p>
|
||||||
<p className="text-lg font-bold text-green-600 dark:text-green-400">
|
<p className="text-lg font-bold text-green-600 dark:text-green-400">
|
||||||
{new Date(trip.endTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
{formatTime(new Date(trip.endTime))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -814,7 +911,7 @@ export function CommandCenter() {
|
|||||||
{getTimeUntil(trip.startTime)}
|
{getTimeUntil(trip.startTime)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{new Date(trip.startTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
{formatTime(new Date(trip.startTime))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -827,9 +924,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 +938,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 +947,94 @@ 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;
|
// Find this VIP's journey if it exists
|
||||||
const flight = vip.flights[0];
|
const journey = journeys.find(j => j.vipId === vip.id);
|
||||||
|
const flight = vip.flights.find(f => f.status?.toLowerCase() !== 'cancelled') || vip.flights[0];
|
||||||
|
const lastFlight = journey ? journey.flights[journey.flights.length - 1] : flight;
|
||||||
|
const finalArrival = lastFlight ? (lastFlight.actualArrival || lastFlight.estimatedArrival || lastFlight.scheduledArrival) : null;
|
||||||
|
const arrival = vip.expectedArrival || finalArrival;
|
||||||
|
const currentFlight = journey ? journey.flights[journey.currentSegmentIndex] : flight;
|
||||||
|
const delay = currentFlight ? Math.max(currentFlight.arrivalDelay || 0, currentFlight.departureDelay || 0) : 0;
|
||||||
|
const effectiveStatus = journey?.effectiveStatus || currentFlight?.status?.toLowerCase() || 'scheduled';
|
||||||
|
const isCancelled = effectiveStatus === 'cancelled';
|
||||||
|
const isActive = effectiveStatus === 'active';
|
||||||
|
const isLanded = effectiveStatus === 'landed';
|
||||||
|
|
||||||
|
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 = journey?.hasLayoverRisk
|
||||||
|
? 'border-l-orange-500'
|
||||||
|
: 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';
|
||||||
|
|
||||||
|
// Build route chain
|
||||||
|
const routeChain = journey && journey.isMultiSegment
|
||||||
|
? journey.flights.map(f => f.departureAirport).concat([journey.flights[journey.flights.length - 1].arrivalAirport]).join(' → ')
|
||||||
|
: flight ? `${flight.departureAirport} → ${flight.arrivalAirport}` : '';
|
||||||
|
|
||||||
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>}
|
{journey?.hasLayoverRisk && (
|
||||||
|
<span className="flex items-center gap-0.5 px-1 py-0 text-[10px] rounded bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
|
||||||
|
<AlertTriangle className="w-2.5 h-2.5" />
|
||||||
|
risk
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{delay > 15 && !journey?.hasLayoverRisk && (
|
||||||
|
<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">
|
||||||
|
<span>{routeChain}</span>
|
||||||
|
{journey?.isMultiSegment && (
|
||||||
|
<span className="text-muted-foreground/60">{journey.flights.length} legs</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{currentFlight && (currentFlight.arrivalTerminal || currentFlight.arrivalGate || currentFlight.arrivalBaggage) && (
|
||||||
|
<div className="flex gap-2 text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
{currentFlight.arrivalTerminal && <span>T{currentFlight.arrivalTerminal}</span>}
|
||||||
|
{currentFlight.arrivalGate && <span>Gate {currentFlight.arrivalGate}</span>}
|
||||||
|
{currentFlight.arrivalBaggage && <span>Bag {currentFlight.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">
|
||||||
|
{formatTime(new Date(arrival))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-bold text-purple-600 dark:text-purple-400">{getTimeUntil(arrival!)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,29 +1,14 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
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, Link2 } from 'lucide-react';
|
||||||
import { VIP, Driver, ScheduleEvent } from '@/types';
|
import { VIP, Driver, ScheduleEvent, Flight } from '@/types';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
import { FlightProgressBar } from '@/components/FlightProgressBar';
|
||||||
interface Flight {
|
import { groupFlightsIntoJourneys, formatLayoverDuration } from '@/lib/journeyUtils';
|
||||||
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 { formatDate, formatDateTime, formatTime } = useFormattedDate();
|
||||||
const { data: vips } = useQuery<VIP[]>({
|
const { data: vips } = useQuery<VIP[]>({
|
||||||
queryKey: ['vips'],
|
queryKey: ['vips'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -84,6 +69,25 @@ export function Dashboard() {
|
|||||||
.sort((a, b) => new Date(a.flightDate).getTime() - new Date(b.flightDate).getTime())
|
.sort((a, b) => new Date(a.flightDate).getTime() - new Date(b.flightDate).getTime())
|
||||||
.slice(0, 5) || [];
|
.slice(0, 5) || [];
|
||||||
|
|
||||||
|
const journeys = useMemo(() => {
|
||||||
|
if (!flights || flights.length === 0) return [];
|
||||||
|
return groupFlightsIntoJourneys(flights);
|
||||||
|
}, [flights]);
|
||||||
|
|
||||||
|
const upcomingJourneys = useMemo(() => {
|
||||||
|
return journeys
|
||||||
|
.filter(j => j.effectiveStatus !== 'cancelled' && j.effectiveStatus !== 'landed')
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Active journeys first, then by ETA
|
||||||
|
if (a.effectiveStatus === 'active' && b.effectiveStatus !== 'active') return -1;
|
||||||
|
if (b.effectiveStatus === 'active' && a.effectiveStatus !== 'active') return 1;
|
||||||
|
const etaA = a.flights[a.currentSegmentIndex]?.estimatedArrival || a.flights[a.currentSegmentIndex]?.scheduledArrival || '';
|
||||||
|
const etaB = b.flights[b.currentSegmentIndex]?.estimatedArrival || b.flights[b.currentSegmentIndex]?.scheduledArrival || '';
|
||||||
|
return etaA.localeCompare(etaB);
|
||||||
|
})
|
||||||
|
.slice(0, 5);
|
||||||
|
}, [journeys]);
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
name: 'Total VIPs',
|
name: 'Total VIPs',
|
||||||
@@ -195,55 +199,160 @@ 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>
|
||||||
{upcomingFlights.length > 0 ? (
|
|
||||||
<div className="space-y-4">
|
{/* Journey status summary */}
|
||||||
{upcomingFlights.map((flight) => (
|
{journeys.length > 0 && (
|
||||||
<div
|
<div className="flex flex-wrap gap-4 mb-4 pb-4 border-b border-border">
|
||||||
key={flight.id}
|
{(() => {
|
||||||
className="border-l-4 border-indigo-500 pl-4 py-2 hover:bg-accent transition-colors rounded-r"
|
const inFlight = journeys.filter(j => j.effectiveStatus === 'active').length;
|
||||||
>
|
const connectionRisk = journeys.filter(j => j.hasLayoverRisk).length;
|
||||||
<div className="flex justify-between items-start">
|
const cancelled = journeys.filter(j => j.effectiveStatus === 'cancelled').length;
|
||||||
<div>
|
const landed = journeys.filter(j => j.effectiveStatus === 'landed').length;
|
||||||
<h3 className="text-sm font-medium text-foreground flex items-center gap-2">
|
const scheduled = journeys.filter(j => !['active', 'landed', 'cancelled', 'diverted', 'incident'].includes(j.effectiveStatus)).length;
|
||||||
<Plane className="h-4 w-4" />
|
|
||||||
{flight.flightNumber}
|
return (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center gap-1.5 text-sm">
|
||||||
|
<span className="font-medium">{journeys.length}</span>
|
||||||
|
<span className="text-muted-foreground">journeys</span>
|
||||||
|
</span>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{connectionRisk > 0 && (
|
||||||
|
<span className="flex items-center gap-1.5 text-sm">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-orange-500" />
|
||||||
|
<span className="font-medium text-orange-600 dark:text-orange-400">{connectionRisk}</span>
|
||||||
|
<span className="text-muted-foreground">at risk</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upcoming journeys */}
|
||||||
|
{upcomingJourneys.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
Active & Upcoming Journeys
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
{upcomingJourneys.map((journey) => {
|
||||||
{flight.vip?.name} • {flight.departureAirport} → {flight.arrivalAirport}
|
const currentFlight = journey.flights[journey.currentSegmentIndex];
|
||||||
</p>
|
const lastFlight = journey.flights[journey.flights.length - 1];
|
||||||
{flight.scheduledDeparture && (
|
const delay = Math.max(currentFlight?.arrivalDelay || 0, currentFlight?.departureDelay || 0);
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
const routeChain = journey.flights.map(f => f.departureAirport).concat([lastFlight.arrivalAirport]).join(' → ');
|
||||||
Departs: {formatDateTime(flight.scheduledDeparture)}
|
|
||||||
</p>
|
const borderColor = journey.hasLayoverRisk ? 'border-orange-500' :
|
||||||
|
delay > 30 ? 'border-amber-500' :
|
||||||
|
journey.effectiveStatus === 'active' ? 'border-purple-500' :
|
||||||
|
journey.effectiveStatus === 'cancelled' ? 'border-red-500' :
|
||||||
|
'border-indigo-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={journey.vipId}
|
||||||
|
className={`border-l-4 ${borderColor} pl-4 py-2 hover:bg-accent transition-colors rounded-r`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">{journey.vip?.name || 'Unknown'}</span>
|
||||||
|
{journey.isMultiSegment && (
|
||||||
|
<span className="text-xs text-muted-foreground">{journey.flights.length} legs</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground block">
|
{journey.hasLayoverRisk && (
|
||||||
{new Date(flight.flightDate).toLocaleDateString('en-US', {
|
<span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
|
||||||
month: 'short',
|
<Link2 className="w-3 h-3" />
|
||||||
day: 'numeric',
|
Connection risk
|
||||||
year: 'numeric',
|
|
||||||
})}
|
|
||||||
</span>
|
</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' :
|
{delay > 15 && (
|
||||||
flight.status?.toLowerCase() === 'boarding' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950 dark:text-yellow-300' :
|
<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">
|
||||||
flight.status?.toLowerCase() === 'departed' ? 'bg-purple-100 text-purple-800 dark:bg-purple-950 dark:text-purple-300' :
|
<AlertTriangle className="w-3 h-3" />
|
||||||
flight.status?.toLowerCase() === 'landed' ? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300' :
|
+{delay}min
|
||||||
flight.status?.toLowerCase() === 'delayed' ? 'bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300' :
|
</span>
|
||||||
'bg-muted text-muted-foreground'
|
)}
|
||||||
|
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||||
|
journey.effectiveStatus === 'active' ? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' :
|
||||||
|
journey.effectiveStatus === 'landed' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' :
|
||||||
|
journey.effectiveStatus === '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 || 'Unknown'}
|
{journey.effectiveStatus}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Route chain */}
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">
|
||||||
|
{routeChain}
|
||||||
|
{currentFlight && (
|
||||||
|
<span className="ml-2 text-foreground/60">
|
||||||
|
{currentFlight.flightNumber}
|
||||||
|
{currentFlight.airlineName && ` · ${currentFlight.airlineName}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar for current segment */}
|
||||||
|
{currentFlight && <FlightProgressBar flight={currentFlight} compact />}
|
||||||
|
|
||||||
|
{/* Layover risk info */}
|
||||||
|
{journey.hasLayoverRisk && journey.layovers.filter(l => l.risk === 'warning' || l.risk === 'critical' || l.risk === 'missed').map((layover, idx) => (
|
||||||
|
<div key={idx} className={`flex items-center gap-1 mt-1 text-xs ${
|
||||||
|
layover.risk === 'missed' ? 'text-red-600 dark:text-red-400' :
|
||||||
|
layover.risk === 'critical' ? 'text-red-600 dark:text-red-400' :
|
||||||
|
'text-amber-600 dark:text-amber-400'
|
||||||
|
}`}>
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
{layover.airport}: {layover.risk === 'missed'
|
||||||
|
? `Connection missed (${formatLayoverDuration(layover.effectiveMinutes)})`
|
||||||
|
: `Layover ${formatLayoverDuration(layover.effectiveMinutes)} (was ${formatLayoverDuration(layover.scheduledMinutes)})`
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Terminal/gate info for arriving current segment */}
|
||||||
|
{currentFlight && (currentFlight.arrivalTerminal || currentFlight.arrivalGate || currentFlight.arrivalBaggage) && (
|
||||||
|
<div className="flex gap-3 mt-1 text-xs text-muted-foreground">
|
||||||
|
{currentFlight.arrivalTerminal && <span>Terminal {currentFlight.arrivalTerminal}</span>}
|
||||||
|
{currentFlight.arrivalGate && <span>Gate {currentFlight.arrivalGate}</span>}
|
||||||
|
{currentFlight.arrivalBaggage && <span>Baggage {currentFlight.arrivalBaggage}</span>}
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -3,16 +3,19 @@ import { useQuery, 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 { Driver } from '@/types';
|
import { Driver } from '@/types';
|
||||||
import { Plus, Edit, Trash2, Search, X, Filter, ArrowUpDown, Send, Eye } from 'lucide-react';
|
import { Plus, Edit, Trash2, Search, X, Filter, Send, Eye } from 'lucide-react';
|
||||||
import { DriverForm, DriverFormData } from '@/components/DriverForm';
|
import { DriverForm, DriverFormData } from '@/components/DriverForm';
|
||||||
import { TableSkeleton, CardSkeleton } from '@/components/Skeleton';
|
import { TableSkeleton, CardSkeleton } from '@/components/Skeleton';
|
||||||
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 { ConfirmModal } from '@/components/ConfirmModal';
|
||||||
|
import { SortableHeader } from '@/components/SortableHeader';
|
||||||
|
import { useListPage } from '@/hooks/useListPage';
|
||||||
import { DriverChatBubble } from '@/components/DriverChatBubble';
|
import { DriverChatBubble } from '@/components/DriverChatBubble';
|
||||||
import { DriverChatModal } from '@/components/DriverChatModal';
|
import { DriverChatModal } from '@/components/DriverChatModal';
|
||||||
import { DriverScheduleModal } from '@/components/DriverScheduleModal';
|
import { DriverScheduleModal } from '@/components/DriverScheduleModal';
|
||||||
import { useUnreadCounts } from '@/hooks/useSignalMessages';
|
import { useUnreadCounts } from '@/hooks/useSignalMessages';
|
||||||
|
import { DEPARTMENT_LABELS } from '@/lib/enum-labels';
|
||||||
|
|
||||||
export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -20,8 +23,19 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
|||||||
const [editingDriver, setEditingDriver] = useState<Driver | null>(null);
|
const [editingDriver, setEditingDriver] = useState<Driver | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
// Search and filter state
|
// List page state management
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const {
|
||||||
|
search: searchTerm,
|
||||||
|
setSearch: setSearchTerm,
|
||||||
|
debouncedSearch: debouncedSearchTerm,
|
||||||
|
sortKey: sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
handleSort,
|
||||||
|
} = useListPage<'name' | 'phone' | 'department'>({
|
||||||
|
defaultSortKey: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter state
|
||||||
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
|
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
|
||||||
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -31,12 +45,8 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
|||||||
// Schedule modal state
|
// Schedule modal state
|
||||||
const [scheduleDriver, setScheduleDriver] = useState<Driver | null>(null);
|
const [scheduleDriver, setScheduleDriver] = useState<Driver | null>(null);
|
||||||
|
|
||||||
// Sort state
|
// Confirm delete modal state
|
||||||
const [sortColumn, setSortColumn] = useState<'name' | 'phone' | 'department'>('name');
|
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null);
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
|
||||||
|
|
||||||
// Debounce search term
|
|
||||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
|
||||||
|
|
||||||
// Fetch unread message counts
|
// Fetch unread message counts
|
||||||
const { data: unreadCounts } = useUnreadCounts();
|
const { data: unreadCounts } = useUnreadCounts();
|
||||||
@@ -179,25 +189,12 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
|||||||
setSelectedDepartments([]);
|
setSelectedDepartments([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (column: typeof sortColumn) => {
|
|
||||||
if (sortColumn === column) {
|
|
||||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
|
||||||
} else {
|
|
||||||
setSortColumn(column);
|
|
||||||
setSortDirection('asc');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveDepartmentFilter = (dept: string) => {
|
const handleRemoveDepartmentFilter = (dept: string) => {
|
||||||
setSelectedDepartments((prev) => prev.filter((d) => d !== dept));
|
setSelectedDepartments((prev) => prev.filter((d) => d !== dept));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFilterLabel = (value: string) => {
|
const getFilterLabel = (value: string) => {
|
||||||
const labels = {
|
return DEPARTMENT_LABELS[value] || value;
|
||||||
'OFFICE_OF_DEVELOPMENT': 'Office of Development',
|
|
||||||
'ADMIN': 'Admin',
|
|
||||||
};
|
|
||||||
return labels[value as keyof typeof labels] || value;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
@@ -211,8 +208,13 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string, name: string) => {
|
const handleDelete = (id: string, name: string) => {
|
||||||
if (confirm(`Delete driver "${name}"? This action cannot be undone.`)) {
|
setDeleteConfirm({ id, name });
|
||||||
deleteMutation.mutate(id);
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (deleteConfirm) {
|
||||||
|
deleteMutation.mutate(deleteConfirm.id);
|
||||||
|
setDeleteConfirm(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -353,36 +355,24 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
|||||||
<table className="min-w-full divide-y divide-border">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-muted/30">
|
<thead className="bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<SortableHeader
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
|
column="name"
|
||||||
onClick={() => handleSort('name')}
|
label="Name"
|
||||||
>
|
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||||
<div className="flex items-center gap-2">
|
onSort={handleSort}
|
||||||
Name
|
/>
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
<SortableHeader
|
||||||
{sortColumn === 'name' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
column="phone"
|
||||||
</div>
|
label="Phone"
|
||||||
</th>
|
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||||
<th
|
onSort={handleSort}
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
|
/>
|
||||||
onClick={() => handleSort('phone')}
|
<SortableHeader
|
||||||
>
|
column="department"
|
||||||
<div className="flex items-center gap-2">
|
label="Department"
|
||||||
Phone
|
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
onSort={handleSort}
|
||||||
{sortColumn === 'phone' && <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 cursor-pointer hover:bg-accent transition-colors"
|
|
||||||
onClick={() => handleSort('department')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
Department
|
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
|
||||||
{sortColumn === 'department' && <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">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Assigned Events
|
Assigned Events
|
||||||
</th>
|
</th>
|
||||||
@@ -538,10 +528,7 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
|||||||
filterGroups={[
|
filterGroups={[
|
||||||
{
|
{
|
||||||
label: 'Department',
|
label: 'Department',
|
||||||
options: [
|
options: Object.entries(DEPARTMENT_LABELS).map(([value, label]) => ({ value, label })),
|
||||||
{ value: 'OFFICE_OF_DEVELOPMENT', label: 'Office of Development' },
|
|
||||||
{ value: 'ADMIN', label: 'Admin' },
|
|
||||||
],
|
|
||||||
selectedValues: selectedDepartments,
|
selectedValues: selectedDepartments,
|
||||||
onToggle: handleDepartmentToggle,
|
onToggle: handleDepartmentToggle,
|
||||||
},
|
},
|
||||||
@@ -563,6 +550,17 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
|||||||
isOpen={!!scheduleDriver}
|
isOpen={!!scheduleDriver}
|
||||||
onClose={() => setScheduleDriver(null)}
|
onClose={() => setScheduleDriver(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Confirm Delete Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!deleteConfirm}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setDeleteConfirm(null)}
|
||||||
|
title="Delete Driver"
|
||||||
|
description={`Are you sure you want to delete "${deleteConfirm?.name}"? This action cannot be undone.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
Phone,
|
Phone,
|
||||||
@@ -244,6 +245,7 @@ export function DriverProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GpsStatsSection() {
|
function GpsStatsSection() {
|
||||||
|
const { formatDate, formatDateTime } = useFormattedDate();
|
||||||
const { data: gpsStatus, isLoading: statusLoading } = useMyGpsStatus();
|
const { data: gpsStatus, isLoading: statusLoading } = useMyGpsStatus();
|
||||||
const { data: gpsStats, isLoading: statsLoading } = useMyGpsStats();
|
const { data: gpsStats, isLoading: statsLoading } = useMyGpsStats();
|
||||||
const updateConsent = useUpdateGpsConsent();
|
const updateConsent = useUpdateGpsConsent();
|
||||||
@@ -355,7 +357,7 @@ function GpsStatsSection() {
|
|||||||
) : gpsStats ? (
|
) : gpsStats ? (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Stats for the last 7 days ({new Date(gpsStats.period.from).toLocaleDateString()} - {new Date(gpsStats.period.to).toLocaleDateString()})
|
Stats for the last 7 days ({formatDate(gpsStats.period.from)} - {formatDate(gpsStats.period.to)})
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
@@ -386,7 +388,7 @@ function GpsStatsSection() {
|
|||||||
|
|
||||||
{gpsStats.stats.topSpeedTimestamp && (
|
{gpsStats.stats.topSpeedTimestamp && (
|
||||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||||
Top speed recorded on {new Date(gpsStats.stats.topSpeedTimestamp).toLocaleString()}
|
Top speed recorded on {formatDateTime(gpsStats.stats.topSpeedTimestamp)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { ScheduleEvent, EventType } from '@/types';
|
import { ScheduleEvent, EventType } from '@/types';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { Plus, Edit, Trash2, Search } from 'lucide-react';
|
||||||
import { Plus, Edit, Trash2, Search, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
|
||||||
import { EventForm, EventFormData } from '@/components/EventForm';
|
import { EventForm, EventFormData } from '@/components/EventForm';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
import { InlineDriverSelector } from '@/components/InlineDriverSelector';
|
import { InlineDriverSelector } from '@/components/InlineDriverSelector';
|
||||||
|
import { ConfirmModal } from '@/components/ConfirmModal';
|
||||||
|
import { SortableHeader } from '@/components/SortableHeader';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
type ActivityFilter = 'ALL' | EventType;
|
type ActivityFilter = 'ALL' | EventType;
|
||||||
type SortField = 'title' | 'type' | 'startTime' | 'status' | 'vips';
|
type SortField = 'title' | 'type' | 'startTime' | 'status' | 'vips';
|
||||||
@@ -18,16 +21,22 @@ export function EventList() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { formatDateTime } = useFormattedDate();
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
|
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [activeFilter, setActiveFilter] = useState<ActivityFilter>('ALL');
|
const [activeFilter, setActiveFilter] = useState<ActivityFilter>('ALL');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Confirm delete modal state
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; title: string } | null>(null);
|
||||||
|
|
||||||
|
// Sort state (inline instead of useListPage since search is not debounced here)
|
||||||
const [sortField, setSortField] = useState<SortField>('startTime');
|
const [sortField, setSortField] = useState<SortField>('startTime');
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
|
|
||||||
const { data: events, isLoading } = useQuery<ScheduleEvent[]>({
|
const { data: events, isLoading } = useQuery<ScheduleEvent[]>({
|
||||||
queryKey: ['events'],
|
queryKey: queryKeys.events.all,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/events');
|
const { data } = await api.get('/events');
|
||||||
return data;
|
return data;
|
||||||
@@ -53,7 +62,7 @@ export function EventList() {
|
|||||||
await api.post('/events', data);
|
await api.post('/events', data);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.events.all });
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
toast.success('Event created successfully');
|
toast.success('Event created successfully');
|
||||||
@@ -70,7 +79,7 @@ export function EventList() {
|
|||||||
await api.patch(`/events/${id}`, data);
|
await api.patch(`/events/${id}`, data);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.events.all });
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingEvent(null);
|
setEditingEvent(null);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -88,7 +97,7 @@ export function EventList() {
|
|||||||
await api.delete(`/events/${id}`);
|
await api.delete(`/events/${id}`);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.events.all });
|
||||||
toast.success('Event deleted successfully');
|
toast.success('Event deleted successfully');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -108,8 +117,13 @@ export function EventList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string, title: string) => {
|
const handleDelete = (id: string, title: string) => {
|
||||||
if (confirm(`Delete event "${title}"? This action cannot be undone.`)) {
|
setDeleteConfirm({ id, title });
|
||||||
deleteMutation.mutate(id);
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (deleteConfirm) {
|
||||||
|
deleteMutation.mutate(deleteConfirm.id);
|
||||||
|
setDeleteConfirm(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -311,77 +325,47 @@ export function EventList() {
|
|||||||
<table className="min-w-full divide-y divide-border">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-muted/30">
|
<thead className="bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<SortableHeader
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
column="title"
|
||||||
onClick={() => handleSort('title')}
|
label="Title"
|
||||||
>
|
currentSort={{ key: sortField, direction: sortDirection }}
|
||||||
<div className="flex items-center gap-1">
|
onSort={handleSort}
|
||||||
Title
|
className="gap-1"
|
||||||
{sortField === 'title' ? (
|
/>
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
<SortableHeader
|
||||||
) : (
|
column="type"
|
||||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
label="Type"
|
||||||
)}
|
currentSort={{ key: sortField, direction: sortDirection }}
|
||||||
</div>
|
onSort={handleSort}
|
||||||
</th>
|
className="gap-1"
|
||||||
<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"
|
<SortableHeader
|
||||||
onClick={() => handleSort('type')}
|
column="vips"
|
||||||
>
|
label="VIPs"
|
||||||
<div className="flex items-center gap-1">
|
currentSort={{ key: sortField, direction: sortDirection }}
|
||||||
Type
|
onSort={handleSort}
|
||||||
{sortField === 'type' ? (
|
className="gap-1"
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
/>
|
||||||
) : (
|
|
||||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</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('vips')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
VIPs
|
|
||||||
{sortField === 'vips' ? (
|
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Vehicle
|
Vehicle
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Driver
|
Driver
|
||||||
</th>
|
</th>
|
||||||
<th
|
<SortableHeader
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
column="startTime"
|
||||||
onClick={() => handleSort('startTime')}
|
label="Start Time"
|
||||||
>
|
currentSort={{ key: sortField, direction: sortDirection }}
|
||||||
<div className="flex items-center gap-1">
|
onSort={handleSort}
|
||||||
Start Time
|
className="gap-1"
|
||||||
{sortField === 'startTime' ? (
|
/>
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
<SortableHeader
|
||||||
) : (
|
column="status"
|
||||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
label="Status"
|
||||||
)}
|
currentSort={{ key: sortField, direction: sortDirection }}
|
||||||
</div>
|
onSort={handleSort}
|
||||||
</th>
|
className="gap-1"
|
||||||
<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-1">
|
|
||||||
Status
|
|
||||||
{sortField === 'status' ? (
|
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
@@ -503,6 +487,17 @@ export function EventList() {
|
|||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Confirm Delete Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!deleteConfirm}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setDeleteConfirm(null)}
|
||||||
|
title="Delete Activity"
|
||||||
|
description={`Are you sure you want to delete "${deleteConfirm?.title}"? This action cannot be undone.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,167 @@
|
|||||||
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,
|
||||||
|
Link2,
|
||||||
|
} 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, Journey } from '@/types';
|
||||||
|
import { groupFlightsIntoJourneys } from '@/lib/journeyUtils';
|
||||||
|
|
||||||
interface Flight {
|
type JourneyGroup = {
|
||||||
id: string;
|
key: string;
|
||||||
vipId: string;
|
label: string;
|
||||||
vip?: {
|
icon: typeof AlertTriangle;
|
||||||
name: string;
|
journeys: Journey[];
|
||||||
organization: string | null;
|
color: string;
|
||||||
};
|
defaultCollapsed?: boolean;
|
||||||
flightNumber: string;
|
};
|
||||||
flightDate: string;
|
|
||||||
segment: number;
|
function getJourneyEta(j: Journey): string {
|
||||||
departureAirport: string;
|
// Use the current/last segment's best available arrival time
|
||||||
arrivalAirport: string;
|
const seg = j.flights[j.currentSegmentIndex] || j.flights[j.flights.length - 1];
|
||||||
scheduledDeparture: string | null;
|
return seg.estimatedArrival || seg.scheduledArrival || '';
|
||||||
scheduledArrival: string | null;
|
}
|
||||||
actualDeparture: string | null;
|
|
||||||
actualArrival: string | null;
|
function getJourneyDeparture(j: Journey): string {
|
||||||
status: string | null;
|
// Use the first non-landed segment's departure, or first segment
|
||||||
|
for (const f of j.flights) {
|
||||||
|
if (f.status?.toLowerCase() !== 'landed' && !f.actualArrival) {
|
||||||
|
return f.estimatedDeparture || f.scheduledDeparture || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return j.flights[0]?.scheduledDeparture || j.flights[0]?.flightDate || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJourneyMaxDelay(j: Journey): number {
|
||||||
|
return Math.max(...j.flights.map(f => Math.max(f.arrivalDelay || 0, f.departureDelay || 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupJourneys(journeys: Journey[]): JourneyGroup[] {
|
||||||
|
const now = new Date();
|
||||||
|
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||||
|
const fourHoursFromNow = new Date(now.getTime() + 4 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const groups: JourneyGroup[] = [
|
||||||
|
{ key: 'alerts', label: 'Alerts', icon: AlertTriangle, journeys: [], color: 'text-red-500' },
|
||||||
|
{ key: 'connection-risk', label: 'Connection Risk', icon: Link2, journeys: [], color: 'text-orange-500' },
|
||||||
|
{ key: 'arriving', label: 'Arriving Soon', icon: Plane, journeys: [], color: 'text-purple-500' },
|
||||||
|
{ key: 'active', label: 'In Flight', icon: Plane, journeys: [], color: 'text-purple-500' },
|
||||||
|
{ key: 'departing', label: 'Departing Soon', icon: Clock, journeys: [], color: 'text-blue-500' },
|
||||||
|
{ key: 'scheduled', label: 'Scheduled', icon: Clock, journeys: [], color: 'text-muted-foreground' },
|
||||||
|
{ key: 'completed', label: 'Completed', icon: Plane, journeys: [], color: 'text-emerald-500', defaultCollapsed: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const journey of journeys) {
|
||||||
|
const status = journey.effectiveStatus;
|
||||||
|
|
||||||
|
// Connection risk: any journey with layover risk (separate from alerts)
|
||||||
|
if (journey.hasLayoverRisk) {
|
||||||
|
groups[1].journeys.push(journey);
|
||||||
|
// Also place in the appropriate status group below (don't continue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alerts: cancelled, diverted, incident
|
||||||
|
if (status === 'cancelled' || status === 'diverted' || status === 'incident') {
|
||||||
|
groups[0].journeys.push(journey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completed: all segments landed
|
||||||
|
if (status === 'landed') {
|
||||||
|
groups[6].journeys.push(journey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eta = getJourneyEta(journey);
|
||||||
|
const departure = getJourneyDeparture(journey);
|
||||||
|
|
||||||
|
// Arriving soon: active journey with final arrival within 2h
|
||||||
|
if (status === 'active' && eta && new Date(eta) <= twoHoursFromNow) {
|
||||||
|
groups[2].journeys.push(journey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In flight: active
|
||||||
|
if (status === 'active') {
|
||||||
|
groups[3].journeys.push(journey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Departing soon: next departure within 4h
|
||||||
|
if (departure && new Date(departure) <= fourHoursFromNow && new Date(departure) >= now) {
|
||||||
|
groups[4].journeys.push(journey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else is scheduled
|
||||||
|
groups[5].journeys.push(journey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort within groups
|
||||||
|
groups[0].journeys.sort((a, b) => getJourneyMaxDelay(b) - getJourneyMaxDelay(a));
|
||||||
|
groups[1].journeys.sort((a, b) => {
|
||||||
|
// Worst risk first: missed > critical > warning
|
||||||
|
const riskOrder = { missed: 0, critical: 1, warning: 2, ok: 3, none: 4 };
|
||||||
|
const worstA = Math.min(...a.layovers.map(l => riskOrder[l.risk] ?? 4));
|
||||||
|
const worstB = Math.min(...b.layovers.map(l => riskOrder[l.risk] ?? 4));
|
||||||
|
return worstA - worstB;
|
||||||
|
});
|
||||||
|
groups[2].journeys.sort((a, b) => getJourneyEta(a).localeCompare(getJourneyEta(b)));
|
||||||
|
groups[3].journeys.sort((a, b) => getJourneyEta(a).localeCompare(getJourneyEta(b)));
|
||||||
|
groups[4].journeys.sort((a, b) => getJourneyDeparture(a).localeCompare(getJourneyDeparture(b)));
|
||||||
|
groups[5].journeys.sort((a, b) => getJourneyDeparture(a).localeCompare(getJourneyDeparture(b)));
|
||||||
|
groups[6].journeys.sort((a, b) => {
|
||||||
|
const lastA = a.flights[a.flights.length - 1];
|
||||||
|
const lastB = b.flights[b.flights.length - 1];
|
||||||
|
const arrA = lastA.actualArrival || lastA.scheduledArrival || '';
|
||||||
|
const arrB = lastB.actualArrival || lastB.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 +169,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 +192,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 +209,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 +223,59 @@ 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, then group into journeys
|
||||||
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
|
// Group filtered flights into journeys - if any segment of a journey matches search,
|
||||||
filtered.sort((a, b) => {
|
// we include the full journey (all segments for that VIP)
|
||||||
let aValue = a[sortColumn] || '';
|
const journeys = useMemo(() => {
|
||||||
let bValue = b[sortColumn] || '';
|
if (!flights || filteredFlights.length === 0) return [];
|
||||||
|
|
||||||
if (typeof aValue === 'string') aValue = aValue.toLowerCase();
|
// Get VIP IDs that have at least one matching flight
|
||||||
if (typeof bValue === 'string') bValue = bValue.toLowerCase();
|
const matchingVipIds = new Set(filteredFlights.map(f => f.vipId));
|
||||||
|
|
||||||
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
|
// Build journeys from ALL flights for matching VIPs (so we don't lose segments)
|
||||||
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
|
const allMatchingFlights = flights.filter(f => matchingVipIds.has(f.vipId));
|
||||||
return 0;
|
return groupFlightsIntoJourneys(allMatchingFlights);
|
||||||
|
}, [flights, filteredFlights]);
|
||||||
|
|
||||||
|
const journeyGroups = useMemo(() => groupJourneys(journeys), [journeys]);
|
||||||
|
|
||||||
|
const toggleGroup = (key: string) => {
|
||||||
|
setCollapsedGroups(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
|
};
|
||||||
return filtered;
|
|
||||||
}, [flights, debouncedSearchTerm, selectedStatuses, sortColumn, sortDirection]);
|
|
||||||
|
|
||||||
const handleStatusToggle = (status: string) => {
|
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 +284,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 +291,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 +310,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 +331,28 @@ export function FlightList() {
|
|||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (isoString: string | null) => {
|
// Stats based on all journeys (not just filtered)
|
||||||
if (!isoString) return '-';
|
const allJourneys = useMemo(() => {
|
||||||
return new Date(isoString).toLocaleString('en-US', {
|
if (!flights) return [];
|
||||||
month: 'short',
|
return groupFlightsIntoJourneys(flights);
|
||||||
day: 'numeric',
|
}, [flights]);
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string | null) => {
|
const stats = useMemo(() => {
|
||||||
switch (status?.toLowerCase()) {
|
if (!allJourneys.length) return { active: 0, delayed: 0, connectionRisk: 0, landed: 0, total: 0 };
|
||||||
case 'scheduled':
|
return {
|
||||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
active: allJourneys.filter(j => j.effectiveStatus === 'active').length,
|
||||||
case 'boarding':
|
delayed: allJourneys.filter(j => j.flights.some(f => (f.arrivalDelay || f.departureDelay || 0) > 15)).length,
|
||||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
|
connectionRisk: allJourneys.filter(j => j.hasLayoverRisk).length,
|
||||||
case 'departed':
|
landed: allJourneys.filter(j => j.effectiveStatus === 'landed').length,
|
||||||
case 'en-route':
|
total: allJourneys.length,
|
||||||
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';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}, [allJourneys]);
|
||||||
|
|
||||||
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 +371,48 @@ 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>
|
||||||
|
{allJourneys.length > 0 && (
|
||||||
|
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
|
||||||
|
<span>{stats.total} journeys</span>
|
||||||
|
{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.connectionRisk > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-orange-500" />
|
||||||
|
{stats.connectionRisk} at risk
|
||||||
|
</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.landed} landed</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BudgetIndicator />
|
||||||
|
{allJourneys.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 +421,23 @@ export function FlightList() {
|
|||||||
Add Flight
|
Add Flight
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search and Filter Section */}
|
{/* Search and Filter */}
|
||||||
{flights && flights.length > 0 && (
|
{allJourneys.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 +453,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 +466,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">{journeys.length}</span> of <span className="font-medium text-foreground">{allJourneys.length}</span> journeys
|
||||||
{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 +483,53 @@ export function FlightList() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{flights && flights.length > 0 ? (
|
{/* Journey Groups */}
|
||||||
<div className="bg-card shadow-medium border border-border rounded-lg overflow-hidden">
|
{allJourneys.length > 0 ? (
|
||||||
<table className="min-w-full divide-y divide-border">
|
<div className="space-y-6">
|
||||||
<thead className="bg-muted/30">
|
{journeyGroups.map((group) => {
|
||||||
<tr>
|
if (group.journeys.length === 0) return null;
|
||||||
<th
|
const isCollapsed = collapsedGroups.has(group.key);
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
const Icon = group.icon;
|
||||||
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.journeys.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)}
|
{/* Journey 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.journeys.map((journey) => (
|
||||||
Delete
|
<FlightCard
|
||||||
</button>
|
key={journey.vipId}
|
||||||
</div>
|
journey={journey.isMultiSegment ? journey : undefined}
|
||||||
</td>
|
flight={journey.isMultiSegment ? undefined : journey.flights[0]}
|
||||||
</tr>
|
onEdit={handleEdit}
|
||||||
|
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 +554,6 @@ export function FlightList() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filter Modal */}
|
|
||||||
<FilterModal
|
<FilterModal
|
||||||
isOpen={filterModalOpen}
|
isOpen={filterModalOpen}
|
||||||
onClose={() => setFilterModalOpen(false)}
|
onClose={() => setFilterModalOpen(false)}
|
||||||
@@ -492,12 +562,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,
|
||||||
|
|||||||