14 Commits

Author SHA1 Message Date
139cb4aebe refactor: simplify GPS page, lean into Traccar for live map and trips
Remove ~2,300 lines of code that duplicated Traccar's native capabilities:
- Remove Leaflet live map, trip stats/playback, and OSRM route matching from frontend
- Delete osrm.service.ts entirely (415 lines)
- Remove 6 dead backend endpoints and unused service methods
- Clean up unused hooks and TypeScript types
- Keep device enrollment, QR codes, settings, and CommandCenter integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:53:57 +01:00
14c6c9506f fix: optimize Traccar Client QR code with iOS background GPS settings
QR enrollment was only setting id and interval, causing iOS to default
to medium accuracy with stop_detection enabled — which pauses GPS
updates when the phone appears stationary, causing 5-30 min gaps.

Now sets accuracy=highest, stop_detection=false, distance=0, angle=30,
heartbeat=300, buffer=true. Also updates driver instructions with
required iPhone settings (Always location, Background App Refresh).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:51:42 +01:00
53eb82c4d2 refactor: use Traccar trip API instead of custom detection (#23)
Replace custom trip detection (overlapping/micro-trip prone) with
Traccar's built-in trip report API. Remove merge/backfill UI and
endpoints. Add geocoded address display to trip cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 19:38:08 +01:00
b80ffd3ca1 fix: trip detection creating overlapping/micro trips (#23)
- Increase idle threshold from 5 to 10 minutes for sparse GPS data
- Only start new trips from positions AFTER the previous trip ended
- Prevent duplicate trips at same timestamp with existence check
- Auto-delete micro-trips (< 0.1 mi or < 60 seconds)
- Use GPS timestamps for idle detection instead of wall clock

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:57:11 +01:00
cc3375ef85 feat: add GPS trip detection, history panel, and playback (#23)
Auto-detect trips from GPS data (5-min idle threshold), pre-compute
OSRM routes on trip completion, add trip history side panel with
toggleable trips, and animated trip playback with speed controls.

- Add GpsTrip model with TripStatus enum and migration
- Trip detection in syncPositions cron (start on movement, end on idle)
- Trip finalization with OSRM route matching and stats computation
- API endpoints: list/detail/active/merge/backfill trips
- Stats tab overhaul: trip list panel + map with colored polylines
- Trip playback: animated marker, progressive trail, 1x-16x speed
- Live map shows active trip trail instead of full day history
- Historical backfill from existing GPS location data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:08:48 +01:00
cb4a070ad9 fix: OSRM sparse data handling, frontend type mismatch, map jumping
- Rewrite OsrmService with smart dense/sparse segmentation:
  dense GPS traces use Match API, sparse gaps use Route API
  (turn-by-turn directions between waypoints)
- Filter stationary points before OSRM processing
- Fix critical frontend bug: LocationHistoryResponse type didn't
  match backend response shape (matchedRoute vs matched), so OSRM
  routes were never actually displaying
- Fix double distance conversion (backend sends miles, frontend
  was dividing by 1609.34 again)
- Fix map jumping: disable popup autoPan on marker data refresh
- Extend default history window from 4h to 12h

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:39:00 +01:00
12b9361ae0 chore: add OSRM-related type definitions for GPS routes
Adds distanceMethod to DriverStatsDto and LocationHistoryResponse interface
to support the OSRM road-snapping feature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:05:43 +01:00
33fda57cc6 feat: add OSRM road-snapping for GPS routes and mileage (#21)
Routes now follow actual roads instead of cutting through buildings:
- New OsrmService calls free OSRM Match API to snap GPS points to roads
- Position history endpoint accepts ?matched=true for road-snapped geometry
- Stats use OSRM road distance instead of Haversine crow-flies distance
- Frontend shows solid blue polylines for matched routes, dashed for raw
- Handles chunking (100 coord limit), rate limiting, graceful fallback
- Distance badge shows accurate road miles on route trails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:03:47 +01:00
d93919910b fix: rewrite GPS stats to calculate from stored history (#22)
- Replace Traccar summary API dependency with local Haversine distance calculation
- Calculate mileage from GpsLocationHistory table (sum consecutive positions)
- Filter out GPS jitter (<0.01mi), gaps (>10min), and unrealistic speeds (>100mph)
- Calculate trips, driving time, average/top speed from position history
- Add detailed stats logging for debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:48:41 +01:00
4dbb899409 fix: improve GPS position sync reliability and add route trails (#21)
Backend:
- Increase sync overlap buffer from 5s to 30s to catch late-arriving positions
- Add position history endpoint GET /gps/locations/:driverId/history
- Add logging for position sync counts (returned vs inserted)

Frontend:
- Add useDriverLocationHistory hook for fetching position trails
- Draw Polyline route trails on GPS map for each tracked driver
- Historical positions shown as semi-transparent paths behind live markers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:42:41 +01:00
3bc9cd0bca refactor: complete code efficiency pass (Issues #10, #14, #16)
Backend:
- Add Prisma soft-delete middleware for automatic deletedAt filtering (#10)
- Split 2758-line copilot.service.ts into focused sub-services (#14):
  - copilot-schedule.service.ts (schedule/event tools)
  - copilot-reports.service.ts (reporting/analytics tools)
  - copilot-fleet.service.ts (vehicle/driver tools)
  - copilot-vip.service.ts (VIP management tools)
  - copilot.service.ts now thin orchestrator
- Remove manual deletedAt: null from 50+ queries

Frontend:
- Create SortableHeader component for reusable table sorting (#16)
- Create useListPage hook for shared search/filter/sort state (#16)
- Update VipList, DriverList, EventList to use shared infrastructure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:34:18 +01:00
f2b3f34a72 refactor: code efficiency improvements (Issues #9-13, #15, #17-20)
Backend:
- Extract shared hard-delete authorization utility (#9)
- Extract Prisma include constants per entity (#11)
- Fix N+1 query pattern in events findAll (#12)
- Extract shared date utility functions (#13)
- Move vehicle utilization filtering to DB query (#15)
- Add ParseBooleanPipe for query params
- Add CurrentDriver decorator + ResolveDriverInterceptor (#20)

Frontend:
- Extract shared form utilities (toDatetimeLocal) and enum labels (#17)
- Replace browser confirm() with styled ConfirmModal (#18)
- Add centralized query-keys.ts constants (#19)
- Clean up unused imports, add useMemo where needed (#19)
- Standardize filter button styling across list pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:07:19 +01:00
806b67954e feat: modernize login page with dark theme and breathing logo animation
- Dark gradient background (slate-950/blue-950) with ambient blur effects
- Circular logo centered with dual-ring frosted glass design
- Heartbeat breathing animation (3s cycle) with glow pulse on outer ring
- Gradient sign-in button with hover shadow effects
- Removed "first user" warning, replaced with subtle "authorized personnel" note
- Closes #5 and #6

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 07:58:04 +01:00
a4d360aae9 feat: add PDF reports, timezone management, GPS QR codes, and fix GPS tracking gaps
Issue #1: QR button on GPS Devices tab for re-enrollment
Issue #2: App-wide timezone setting with TimezoneContext, useFormattedDate hook,
  and admin timezone selector. All date displays now respect the configured timezone.
Issue #3: PDF export for Accountability Roster using @react-pdf/renderer with
  professional styling matching VIPSchedulePDF. Added Signal send button.
Issue #4: Fixed GPS "teleporting" gaps - syncPositions now fetches position history
  per device instead of only latest position. Changed cron to every 30s, added
  unique constraint on deviceId+timestamp for deduplication, lowered min interval to 10s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 07:36:51 +01:00
83 changed files with 6469 additions and 3660 deletions

View File

@@ -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");

View File

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

View File

@@ -405,6 +405,7 @@ model GpsDevice {
// Location history
locationHistory GpsLocationHistory[]
trips GpsTrip[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -420,7 +421,7 @@ model GpsLocationHistory {
latitude Float
longitude Float
altitude Float?
speed Float? // km/h
speed Float? // mph (converted from knots during sync)
course Float? // Bearing in degrees
accuracy Float? // Meters
battery Float? // Battery percentage (0-100)
@@ -430,10 +431,49 @@ model GpsLocationHistory {
createdAt DateTime @default(now())
@@map("gps_location_history")
@@index([deviceId, timestamp])
@@unique([deviceId, timestamp]) // Prevent duplicate position records
@@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 {
id String @id @default(uuid())

View File

@@ -18,9 +18,9 @@ export class AuthService {
const name = payload[`${namespace}/name`] || payload.name || 'Unknown User';
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({
where: { auth0Id, deletedAt: null },
where: { auth0Id },
include: { driver: true },
});
@@ -29,7 +29,7 @@ export class AuthService {
// where two simultaneous registrations both become admin
user = await this.prisma.$transaction(async (tx) => {
const approvedUserCount = await tx.user.count({
where: { isApproved: true, deletedAt: null },
where: { isApproved: true },
});
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) {
return this.prisma.user.findFirst({
where: { auth0Id, deletedAt: null },
where: { auth0Id },
include: { driver: true },
});
}

View File

@@ -0,0 +1 @@
export * from './parse-boolean.pipe';

View 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`,
);
}
}

View 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;
}

View 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);
}

View 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';

View 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.`,
};
}
}

View 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.`,
};
}
}

File diff suppressed because it is too large Load Diff

View 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 },
};
}
}

View File

@@ -1,6 +1,10 @@
import { Module } from '@nestjs/common';
import { CopilotController } from './copilot.controller';
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 { SignalModule } from '../signal/signal.module';
import { DriversModule } from '../drivers/drivers.module';
@@ -8,6 +12,12 @@ import { DriversModule } from '../drivers/drivers.module';
@Module({
imports: [PrismaModule, SignalModule, DriversModule],
controllers: [CopilotController],
providers: [CopilotService],
providers: [
CopilotService,
CopilotVipService,
CopilotScheduleService,
CopilotFleetService,
CopilotReportsService,
],
})
export class CopilotModule {}

File diff suppressed because it is too large Load Diff

View 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;
},
);

View File

@@ -0,0 +1 @@
export * from './current-driver.decorator';

View File

@@ -8,6 +8,7 @@ import {
Param,
Query,
UseGuards,
UseInterceptors,
NotFoundException,
} from '@nestjs/common';
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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CurrentDriver } from './decorators';
import { ResolveDriverInterceptor } from './interceptors';
import { Role } from '@prisma/client';
import { CreateDriverDto, UpdateDriverDto } from './dto';
import { toDateString } from '../common/utils/date.utils';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('drivers')
@UseGuards(JwtAuthGuard, RolesGuard)
@@ -41,11 +46,8 @@ export class DriversController {
@Get('me')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
async getMyDriverProfile(@CurrentUser() user: any) {
const driver = await this.driversService.findByUserId(user.id);
if (!driver) {
throw new NotFoundException('Driver profile not found for current user');
}
@UseInterceptors(ResolveDriverInterceptor)
getMyDriverProfile(@CurrentDriver() driver: any) {
return driver;
}
@@ -55,22 +57,19 @@ export class DriversController {
*/
@Get('me/schedule/ics')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
@UseInterceptors(ResolveDriverInterceptor)
async getMyScheduleICS(
@CurrentUser() user: any,
@CurrentDriver() driver: any,
@Query('date') dateStr?: 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();
// Default to full schedule (true) unless explicitly set to false
const fullSchedule = fullScheduleStr !== 'false';
const icsContent = await this.scheduleExportService.generateICS(driver.id, date, fullSchedule);
const filename = fullSchedule
? `full-schedule-${new Date().toISOString().split('T')[0]}.ics`
: `schedule-${date.toISOString().split('T')[0]}.ics`;
? `full-schedule-${toDateString(new Date())}.ics`
: `schedule-${toDateString(date)}.ics`;
return { ics: icsContent, filename };
}
@@ -80,22 +79,19 @@ export class DriversController {
*/
@Get('me/schedule/pdf')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
@UseInterceptors(ResolveDriverInterceptor)
async getMySchedulePDF(
@CurrentUser() user: any,
@CurrentDriver() driver: any,
@Query('date') dateStr?: 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();
// Default to full schedule (true) unless explicitly set to false
const fullSchedule = fullScheduleStr !== 'false';
const pdfBuffer = await this.scheduleExportService.generatePDF(driver.id, date, fullSchedule);
const filename = fullSchedule
? `full-schedule-${new Date().toISOString().split('T')[0]}.pdf`
: `schedule-${date.toISOString().split('T')[0]}.pdf`;
? `full-schedule-${toDateString(new Date())}.pdf`
: `schedule-${toDateString(date)}.pdf`;
return { pdf: pdfBuffer.toString('base64'), filename };
}
@@ -105,14 +101,11 @@ export class DriversController {
*/
@Post('me/send-schedule')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
@UseInterceptors(ResolveDriverInterceptor)
async sendMySchedule(
@CurrentUser() user: any,
@CurrentDriver() driver: any,
@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 format = body.format || 'both';
// Default to full schedule (true) unless explicitly set to false
@@ -122,11 +115,8 @@ export class DriversController {
@Patch('me')
@Roles(Role.DRIVER)
async updateMyProfile(@CurrentUser() user: any, @Body() updateDriverDto: UpdateDriverDto) {
const driver = await this.driversService.findByUserId(user.id);
if (!driver) {
throw new NotFoundException('Driver profile not found for current user');
}
@UseInterceptors(ResolveDriverInterceptor)
updateMyProfile(@CurrentDriver() driver: any, @Body() updateDriverDto: UpdateDriverDto) {
return this.driversService.update(driver.id, updateDriverDto);
}
@@ -219,10 +209,9 @@ export class DriversController {
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
@CurrentUser() user?: any,
) {
const isHardDelete = hard === 'true';
return this.driversService.remove(id, isHardDelete, user?.role);
return this.driversService.remove(id, hard, user?.role);
}
}

View File

@@ -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 { CreateDriverDto, UpdateDriverDto } from './dto';
import { executeHardDelete } from '../common/utils';
@Injectable()
export class DriversService {
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) {}
async create(createDriverDto: CreateDriverDto) {
@@ -19,30 +28,15 @@ export class DriversService {
async findAll() {
return this.prisma.driver.findMany({
where: { deletedAt: null },
include: {
user: true,
events: {
where: { deletedAt: null },
include: { vehicle: true, driver: true },
orderBy: { startTime: 'asc' },
},
},
include: this.driverInclude,
orderBy: { name: 'asc' },
});
}
async findOne(id: string) {
const driver = await this.prisma.driver.findFirst({
where: { id, deletedAt: null },
include: {
user: true,
events: {
where: { deletedAt: null },
include: { vehicle: true, driver: true },
orderBy: { startTime: 'asc' },
},
},
where: { id },
include: this.driverInclude,
});
if (!driver) {
@@ -54,15 +48,8 @@ export class DriversService {
async findByUserId(userId: string) {
return this.prisma.driver.findFirst({
where: { userId, deletedAt: null },
include: {
user: true,
events: {
where: { deletedAt: null },
include: { vehicle: true, driver: true },
orderBy: { startTime: 'asc' },
},
},
where: { userId },
include: this.driverInclude,
});
}
@@ -79,23 +66,19 @@ export class DriversService {
}
async remove(id: string, hardDelete = false, userRole?: string) {
if (hardDelete && userRole !== 'ADMINISTRATOR') {
throw new ForbiddenException('Only administrators can permanently delete records');
}
const driver = await this.findOne(id);
if (hardDelete) {
this.logger.log(`Hard deleting driver: ${driver.name}`);
return this.prisma.driver.delete({
where: { id: driver.id },
});
}
this.logger.log(`Soft deleting driver: ${driver.name}`);
return this.prisma.driver.update({
where: { id: driver.id },
return executeHardDelete({
id,
hardDelete,
userRole,
findOne: (id) => this.findOne(id),
performHardDelete: (id) => this.prisma.driver.delete({ where: { id } }),
performSoftDelete: (id) =>
this.prisma.driver.update({
where: { id },
data: { deletedAt: new Date() },
}),
entityName: 'Driver',
logger: this.logger,
});
}

View File

@@ -0,0 +1 @@
export * from './resolve-driver.interceptor';

View File

@@ -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();
}
}

View File

@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { SignalService } from '../signal/signal.service';
import * as ics from 'ics';
import * as PDFDocument from 'pdfkit';
import { toDateString, startOfDay } from '../common/utils/date.utils';
interface ScheduleEventWithDetails {
id: string;
@@ -36,8 +37,7 @@ export class ScheduleExportService {
driverId: string,
date: Date,
): Promise<ScheduleEventWithDetails[]> {
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const dayStart = startOfDay(date);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
@@ -45,9 +45,8 @@ export class ScheduleExportService {
const events = await this.prisma.scheduleEvent.findMany({
where: {
driverId,
deletedAt: null,
startTime: {
gte: startOfDay,
gte: dayStart,
lte: endOfDay,
},
status: {
@@ -71,13 +70,11 @@ export class ScheduleExportService {
async getDriverFullSchedule(
driverId: string,
): Promise<ScheduleEventWithDetails[]> {
const now = new Date();
now.setHours(0, 0, 0, 0); // Start of today
const now = startOfDay(new Date()); // Start of today
const events = await this.prisma.scheduleEvent.findMany({
where: {
driverId,
deletedAt: null,
endTime: {
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> {
const driver = await this.prisma.driver.findFirst({
where: { id: driverId, deletedAt: null },
where: { id: driverId },
});
if (!driver) {
@@ -211,7 +208,7 @@ export class ScheduleExportService {
*/
async generatePDF(driverId: string, date: Date, fullSchedule = false): Promise<Buffer> {
const driver = await this.prisma.driver.findFirst({
where: { id: driverId, deletedAt: null },
where: { id: driverId },
});
if (!driver) {
@@ -358,7 +355,7 @@ export class ScheduleExportService {
fullSchedule = false,
): Promise<{ success: boolean; message: string }> {
const driver = await this.prisma.driver.findFirst({
where: { id: driverId, deletedAt: null },
where: { id: driverId },
});
if (!driver) {
@@ -411,8 +408,8 @@ export class ScheduleExportService {
const icsContent = await this.generateICS(driverId, date, fullSchedule);
const icsBase64 = Buffer.from(icsContent).toString('base64');
const filename = fullSchedule
? `full-schedule-${new Date().toISOString().split('T')[0]}.ics`
: `schedule-${date.toISOString().split('T')[0]}.ics`;
? `full-schedule-${toDateString(new Date())}.ics`
: `schedule-${toDateString(date)}.ics`;
await this.signalService.sendMessageWithAttachment(
fromNumber,
@@ -435,8 +432,8 @@ export class ScheduleExportService {
const pdfBuffer = await this.generatePDF(driverId, date, fullSchedule);
const pdfBase64 = pdfBuffer.toString('base64');
const filename = fullSchedule
? `full-schedule-${new Date().toISOString().split('T')[0]}.pdf`
: `schedule-${date.toISOString().split('T')[0]}.pdf`;
? `full-schedule-${toDateString(new Date())}.pdf`
: `schedule-${toDateString(date)}.pdf`;
await this.signalService.sendMessageWithAttachment(
fromNumber,

View File

@@ -86,7 +86,6 @@ export class EventStatusService implements OnModuleInit, OnModuleDestroy {
startTime: { lte: twentyMinutesFromNow, gt: now },
reminder20MinSent: false,
driverId: { not: null },
deletedAt: null,
},
include: {
driver: true,
@@ -110,7 +109,6 @@ export class EventStatusService implements OnModuleInit, OnModuleDestroy {
startTime: { lte: fiveMinutesFromNow, gt: now },
reminder5MinSent: false,
driverId: { not: null },
deletedAt: null,
},
include: {
driver: true,
@@ -218,7 +216,6 @@ Reply:
where: {
status: EventStatus.SCHEDULED,
startTime: { lte: now },
deletedAt: null,
},
include: {
driver: true,
@@ -264,7 +261,6 @@ Reply:
where: {
status: EventStatus.IN_PROGRESS,
endTime: { lte: gracePeriodAgo },
deletedAt: null,
},
include: {
driver: true,
@@ -347,7 +343,6 @@ Reply with 1, 2, or 3`;
const driver = await this.prisma.driver.findFirst({
where: {
phone: { contains: driverPhone.replace(/\D/g, '').slice(-10) },
deletedAt: null,
},
});
@@ -360,7 +355,6 @@ Reply with 1, 2, or 3`;
where: {
driverId: driver.id,
status: EventStatus.IN_PROGRESS,
deletedAt: null,
},
include: { vehicle: true },
});

View File

@@ -16,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Role } from '@prisma/client';
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('events')
@UseGuards(JwtAuthGuard, RolesGuard)
@@ -59,10 +60,9 @@ export class EventsController {
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
@CurrentUser() user?: any,
) {
const isHardDelete = hard === 'true';
return this.eventsService.remove(id, isHardDelete, user?.role);
return this.eventsService.remove(id, hard, user?.role);
}
}

View File

@@ -7,11 +7,23 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
import { executeHardDelete } from '../common/utils';
@Injectable()
export class EventsService {
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) {}
async create(createEventDto: CreateEventDto) {
@@ -22,7 +34,6 @@ export class EventsService {
const vips = await this.prisma.vIP.findMany({
where: {
id: { in: createEventDto.vipIds },
deletedAt: null,
},
});
@@ -69,17 +80,7 @@ export class EventsService {
startTime: new Date(createEventDto.startTime),
endTime: new Date(createEventDto.endTime),
},
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 },
},
},
include: this.eventInclude,
});
return this.enrichEventWithVips(event);
@@ -87,38 +88,45 @@ export class EventsService {
async findAll() {
const events = await this.prisma.scheduleEvent.findMany({
where: { deletedAt: null },
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 },
},
},
include: this.eventInclude,
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) {
const event = await this.prisma.scheduleEvent.findFirst({
where: { id, deletedAt: null },
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 },
},
},
where: { id },
include: this.eventInclude,
});
if (!event) {
@@ -136,7 +144,6 @@ export class EventsService {
const vips = await this.prisma.vIP.findMany({
where: {
id: { in: updateEventDto.vipIds },
deletedAt: null,
},
});
@@ -207,17 +214,7 @@ export class EventsService {
const updatedEvent = await this.prisma.scheduleEvent.update({
where: { id: event.id },
data: updateData,
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 },
},
},
include: this.eventInclude,
});
return this.enrichEventWithVips(updatedEvent);
@@ -233,40 +230,27 @@ export class EventsService {
const updatedEvent = await this.prisma.scheduleEvent.update({
where: { id: event.id },
data: { status: updateEventStatusDto.status },
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 },
},
},
include: this.eventInclude,
});
return this.enrichEventWithVips(updatedEvent);
}
async remove(id: string, hardDelete = false, userRole?: string) {
if (hardDelete && userRole !== 'ADMINISTRATOR') {
throw new ForbiddenException('Only administrators can permanently delete records');
}
const event = await this.findOne(id);
if (hardDelete) {
this.logger.log(`Hard deleting event: ${event.title}`);
return this.prisma.scheduleEvent.delete({
where: { id: event.id },
});
}
this.logger.log(`Soft deleting event: ${event.title}`);
return this.prisma.scheduleEvent.update({
where: { id: event.id },
return executeHardDelete({
id,
hardDelete,
userRole,
findOne: (id) => this.findOne(id),
performHardDelete: (id) =>
this.prisma.scheduleEvent.delete({ where: { id } }),
performSoftDelete: (id) =>
this.prisma.scheduleEvent.update({
where: { id },
data: { deletedAt: new Date() },
}),
entityName: 'Event',
logger: this.logger,
});
}
@@ -275,7 +259,7 @@ export class EventsService {
*/
private async checkVehicleCapacity(vehicleId: string, vipIds: string[]) {
const vehicle = await this.prisma.vehicle.findFirst({
where: { id: vehicleId, deletedAt: null },
where: { id: vehicleId },
});
if (!vehicle) {
@@ -283,7 +267,7 @@ export class EventsService {
}
const vips = await this.prisma.vIP.findMany({
where: { id: { in: vipIds }, deletedAt: null },
where: { id: { in: vipIds } },
select: { partySize: true },
});
const totalPeople = vips.reduce((sum, v) => sum + v.partySize, 0);
@@ -313,7 +297,6 @@ export class EventsService {
return this.prisma.scheduleEvent.findMany({
where: {
driverId,
deletedAt: null,
id: excludeEventId ? { not: excludeEventId } : undefined,
OR: [
{
@@ -354,7 +337,6 @@ export class EventsService {
const vips = await this.prisma.vIP.findMany({
where: {
id: { in: event.vipIds },
deletedAt: null,
},
});

View File

@@ -5,6 +5,7 @@ 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 = {
@@ -319,7 +320,7 @@ export class FlightTrackingService {
private async callAviationStackAndUpdate(flight: Flight & { vip?: any }): Promise<Flight> {
const flightDate = flight.flightDate
? new Date(flight.flightDate).toISOString().split('T')[0]
? toDateString(new Date(flight.flightDate))
: undefined;
try {

View File

@@ -16,6 +16,7 @@ import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { CreateFlightDto, UpdateFlightDto } from './dto';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('flights')
@UseGuards(JwtAuthGuard, RolesGuard)
@@ -88,9 +89,8 @@ export class FlightsController {
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
) {
const isHardDelete = hard === 'true';
return this.flightsService.remove(id, isHardDelete);
return this.flightsService.remove(id, hard);
}
}

View File

@@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { CreateFlightDto, UpdateFlightDto } from './dto';
import { firstValueFrom } from 'rxjs';
import { convertOptionalDates } from '../common/utils/date.utils';
@Injectable()
export class FlightsService {
@@ -24,17 +25,16 @@ export class FlightsService {
`Creating flight: ${createFlightDto.flightNumber} for VIP ${createFlightDto.vipId}`,
);
return this.prisma.flight.create({
data: {
const data = convertOptionalDates(
{
...createFlightDto,
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 },
});
}
@@ -71,24 +71,13 @@ export class FlightsService {
this.logger.log(`Updating flight ${id}: ${flight.flightNumber}`);
const updateData: any = { ...updateFlightDto };
const dto = updateFlightDto as any; // Type assertion to work around PartialType
if (dto.flightDate) {
updateData.flightDate = new Date(dto.flightDate);
}
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);
}
const updateData = convertOptionalDates(updateFlightDto, [
'flightDate',
'scheduledDeparture',
'scheduledArrival',
'actualDeparture',
'actualArrival',
]);
return this.prisma.flight.update({
where: { id: flight.id },

View File

@@ -33,6 +33,7 @@ export class DriverStatsDto {
averageSpeedMph: number;
totalTrips: number;
totalDrivingMinutes: number;
distanceMethod?: string; // 'osrm' or 'haversine'
};
recentLocations: LocationDataDto[];
}

View File

@@ -78,6 +78,15 @@ export class GpsController {
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
*/
@@ -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')
@Roles(Role.ADMINISTRATOR)
@@ -108,34 +117,6 @@ export class GpsController {
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
// ============================================

View File

@@ -196,14 +196,18 @@ export class GpsService implements OnModuleInit {
const settings = await this.getSettings();
// 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 traccarPublicUrl = this.traccarClient.getTraccarUrl();
const qrUrl = new URL(traccarPublicUrl);
qrUrl.port = String(devicePort);
qrUrl.searchParams.set('id', actualDeviceId);
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();
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
- 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}
- Server URL: ${serverUrl}
- Location accuracy: Highest
- 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();
let signalMessageSent = false;
@@ -248,7 +258,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
return {
success: true,
deviceIdentifier: actualDeviceId, // Return what Traccar actually stored
deviceIdentifier: actualDeviceId,
serverUrl,
qrCodeUrl,
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
*/
@@ -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[]> {
const devices = await this.prisma.gpsDevice.findMany({
where: {
isActive: true,
driver: {
deletedAt: null,
},
},
include: {
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> {
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(
driverId: string,
@@ -464,58 +606,54 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
const to = toDate || new Date();
const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000);
// Get summary from Traccar
let totalMiles = 0;
const totalMiles = await this.calculateDistanceFromHistory(device.id, from, to);
// 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 topSpeedTimestamp: Date | null = null;
let totalTrips = 0;
let totalDrivingMinutes = 0;
let currentTripStart: Date | null = null;
let totalTrips = 0;
try {
const summary = await this.traccarClient.getSummaryReport(
device.traccarDeviceId,
from,
to,
);
for (const pos of allPositions) {
const speedMph = pos.speed || 0;
if (summary.length > 0) {
const report = summary[0];
// Distance is in meters, convert to miles
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);
if (speedMph > topSpeedMph) {
topSpeedMph = speedMph;
topSpeedTimestamp = pos.timestamp;
}
// Get trips for additional stats
const trips = await this.traccarClient.getTripReport(
device.traccarDeviceId,
from,
to,
);
totalTrips = trips.length;
// Find top speed timestamp from positions
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);
if (speedMph > 5) {
if (!currentTripStart) {
currentTripStart = pos.timestamp;
totalTrips++;
}
} else if (currentTripStart) {
const tripDurationMs = pos.timestamp.getTime() - currentTripStart.getTime();
totalDrivingMinutes += tripDurationMs / 60000;
currentTripStart = null;
}
}
topSpeedMph = maxSpeed;
} catch (error) {
this.logger.warn(`Failed to fetch stats from Traccar: ${error}`);
// Close last trip if still driving
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({
where: {
deviceId: device.id,
@@ -528,6 +666,11 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
take: 100,
});
const averageSpeedMph =
totalDrivingMinutes > 0
? totalMiles / (totalDrivingMinutes / 60)
: 0;
return {
driverId,
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,
topSpeedMph: Math.round(topSpeedMph),
topSpeedTimestamp,
averageSpeedMph: totalDrivingMinutes > 0
? Math.round((totalMiles / (totalDrivingMinutes / 60)) * 10) / 10
: 0,
averageSpeedMph: Math.round(averageSpeedMph * 10) / 10,
totalTrips,
totalDrivingMinutes,
totalDrivingMinutes: Math.round(totalDrivingMinutes),
},
recentLocations: recentLocations.map((loc) => ({
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)
* Called periodically via cron job
*/
@Cron(CronExpression.EVERY_MINUTE)
@Cron(CronExpression.EVERY_30_SECONDS)
async syncPositions(): Promise<void> {
const devices = await this.prisma.gpsDevice.findMany({
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 positions = await this.traccarClient.getAllPositions();
const now = new Date();
this.logger.log(`[GPS Sync] Starting sync for ${devices.length} active devices`);
for (const device of devices) {
const position = positions.find((p) => p.deviceId === device.traccarDeviceId);
if (!position) continue;
try {
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({
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) {
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)
*/
@@ -629,11 +796,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
// Traccar User Sync (VIP Admin -> Traccar Admin)
// ============================================
/**
* Generate a secure password for Traccar user
*/
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';
return crypto
.createHmac('sha256', secret)
@@ -642,11 +805,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
.substring(0, 24);
}
/**
* Generate a secure token for Traccar auto-login
*/
private generateTraccarToken(userId: string): string {
// Generate deterministic token for auto-login
const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-token';
return crypto
.createHmac('sha256', secret + '-token')
@@ -655,9 +814,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
.substring(0, 32);
}
/**
* Sync a VIP user to Traccar
*/
async syncUserToTraccar(user: User): Promise<boolean> {
if (!user.email) return false;
@@ -671,7 +827,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
user.name || user.email,
password,
isAdmin,
token, // Include token for auto-login
token,
);
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 }> {
const admins = await this.prisma.user.findMany({
where: {
role: 'ADMINISTRATOR',
isApproved: true,
deletedAt: null,
},
});
@@ -707,9 +859,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
return { synced, failed };
}
/**
* Get auto-login URL for Traccar (for admin users)
*/
async getTraccarAutoLoginUrl(user: User): Promise<{
url: string;
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');
}
// Ensure user is synced to Traccar (this also sets up their token)
await this.syncUserToTraccar(user);
// Get the token for auto-login
const token = this.generateTraccarToken(user.id);
const baseUrl = this.traccarClient.getTraccarUrl();
// Return URL with token parameter for auto-login
// Traccar supports ?token=xxx for direct authentication
return {
url: `${baseUrl}?token=${token}`,
directAccess: true,
};
}
/**
* Get Traccar session cookie for a user (for proxy/iframe auth)
*/
async getTraccarSessionForUser(user: User): Promise<string | null> {
if (user.role !== 'ADMINISTRATOR') {
return null;
}
// Ensure user is synced
await this.syncUserToTraccar(user);
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;
}
/**
* Check if Traccar needs initial setup
*/
async checkTraccarSetup(): Promise<{
needsSetup: boolean;
isAvailable: boolean;
@@ -766,11 +904,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
return { needsSetup, isAvailable };
}
/**
* Perform initial Traccar setup
*/
async performTraccarSetup(adminEmail: string): Promise<boolean> {
// Generate a secure password for the service account
const servicePassword = crypto.randomBytes(16).toString('hex');
const success = await this.traccarClient.performInitialSetup(
@@ -779,7 +913,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
);
if (success) {
// Save the service account credentials to settings
await this.updateSettings({
traccarAdminUser: adminEmail,
traccarAdminPassword: servicePassword,

View File

@@ -321,7 +321,7 @@ export class TraccarClientService implements OnModuleInit {
deviceId: number,
from: Date,
to: Date,
): Promise<any[]> {
): Promise<TraccarTrip[]> {
const fromStr = from.toISOString();
const toStr = to.toISOString();
return this.request('get', `/api/reports/trips?deviceId=${deviceId}&from=${fromStr}&to=${toStr}`);
@@ -567,3 +567,27 @@ export interface TraccarUser {
token: string | null;
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;
}

View File

@@ -1,6 +1,9 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
// Models that have soft delete (deletedAt field)
const SOFT_DELETE_MODELS = ['User', 'VIP', 'Driver', 'ScheduleEvent', 'Vehicle'];
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
@@ -9,18 +12,69 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
super({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
// Apply soft-delete middleware
this.applySoftDeleteMiddleware();
}
async onModuleInit() {
try {
await this.$connect();
this.logger.log('✅ Database connected successfully');
this.logger.log('✅ Soft-delete middleware active for: ' + SOFT_DELETE_MODELS.join(', '));
} catch (error) {
this.logger.error('❌ Database connection failed', 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() {
await this.$disconnect();
this.logger.log('Database disconnected');

View File

@@ -215,60 +215,181 @@ export class SeedService {
private getFlightData(vips: 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';
vips.forEach((vip, index) => {
const airline = airlines[index % airlines.length];
const flightNum = `${airline}${1000 + index * 123}`;
const origin = origins[index % origins.length];
// Build a name->vip lookup for named scenarios
const vipByName = new Map<string, any>();
vips.forEach(v => vipByName.set(v.name, v));
// Arrival flight - times relative to now
const arrivalOffset = (index % 8) * 30 - 60;
const scheduledArrival = this.relativeTime(arrivalOffset);
const scheduledDeparture = new Date(scheduledArrival.getTime() - 3 * 60 * 60 * 1000);
// Helper: create a flight record
const makeFlight = (vipId: string, opts: any) => ({
vipId,
flightDate: new Date(),
...opts,
});
let status = 'scheduled';
let actualArrival = null;
if (arrivalOffset < -30) {
status = 'landed';
actualArrival = new Date(scheduledArrival.getTime() + (Math.random() * 20 - 10) * 60000);
} else if (arrivalOffset < 0) {
status = 'landing';
} else if (index % 5 === 0) {
status = 'delayed';
// ============================================================
// NAMED MULTI-SEGMENT SCENARIOS
// ============================================================
// Roger Krone: 3-segment journey, all landed cleanly
// BWI -> ORD -> DEN -> SLC
const krone = vipByName.get('Roger A. Krone');
if (krone) {
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({
vipId: vip.id,
flightNumber: flightNum,
flightDate: new Date(),
// Sarah Chen: 2-segment, leg 1 landed, leg 2 active/arriving
// JFK -> ORD -> SLC
const chen = vipByName.get('Sarah Chen');
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,
departureAirport: origin,
flightNumber: df.num,
departureAirport: df.origin,
arrivalAirport: destination,
scheduledDeparture,
scheduledArrival,
actualDeparture: status !== 'scheduled' ? scheduledDeparture : null,
actualArrival,
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;
}

View File

@@ -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')
@CanUpdate('Settings') // Admin-only (Settings subject is admin-only)
getPdfSettings() {

View File

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

View File

@@ -17,6 +17,7 @@ import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { MessagesService, SendMessageDto } from './messages.service';
import { toDateString } from '../common/utils/date.utils';
// DTO for incoming Signal webhook
interface SignalWebhookPayload {
@@ -154,7 +155,7 @@ export class MessagesController {
async exportMessages(@Res() res: Response) {
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-Disposition', `attachment; filename="${filename}"`);

View File

@@ -36,7 +36,7 @@ export class MessagesService {
*/
async getMessagesForDriver(driverId: string, limit: number = 50) {
const driver = await this.prisma.driver.findFirst({
where: { id: driverId, deletedAt: null },
where: { id: driverId },
});
if (!driver) {
@@ -55,7 +55,7 @@ export class MessagesService {
*/
async sendMessage(dto: SendMessageDto) {
const driver = await this.prisma.driver.findFirst({
where: { id: dto.driverId, deletedAt: null },
where: { id: dto.driverId },
});
if (!driver) {
@@ -113,7 +113,6 @@ export class MessagesService {
// Find driver by phone number
const driver = await this.prisma.driver.findFirst({
where: {
deletedAt: null,
OR: [
{ phone: fromNumber },
{ phone: normalizedPhone },
@@ -172,7 +171,6 @@ export class MessagesService {
where: {
driverId: driver.id,
status: EventStatus.IN_PROGRESS,
deletedAt: null,
},
include: { vehicle: true },
});

View File

@@ -11,7 +11,6 @@ export class UsersService {
async findAll() {
return this.prisma.user.findMany({
where: { deletedAt: null },
include: { driver: true },
orderBy: { createdAt: 'desc' },
});
@@ -19,7 +18,7 @@ export class UsersService {
async findOne(id: string) {
const user = await this.prisma.user.findFirst({
where: { id, deletedAt: null },
where: { id },
include: { driver: true },
});
@@ -136,7 +135,6 @@ export class UsersService {
async getPendingUsers() {
return this.prisma.user.findMany({
where: {
deletedAt: null,
isApproved: false,
},
orderBy: { createdAt: 'asc' },

View File

@@ -16,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Role } from '@prisma/client';
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('vehicles')
@UseGuards(JwtAuthGuard, RolesGuard)
@@ -59,10 +60,9 @@ export class VehiclesController {
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
@CurrentUser() user?: any,
) {
const isHardDelete = hard === 'true';
return this.vehiclesService.remove(id, isHardDelete, user?.role);
return this.vehiclesService.remove(id, hard, user?.role);
}
}

View File

@@ -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 { CreateVehicleDto, UpdateVehicleDto } from './dto';
import { executeHardDelete } from '../common/utils';
@Injectable()
export class VehiclesService {
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) {}
async create(createVehicleDto: CreateVehicleDto) {
@@ -13,27 +22,13 @@ export class VehiclesService {
return this.prisma.vehicle.create({
data: createVehicleDto,
include: {
currentDriver: true,
events: {
where: { deletedAt: null },
include: { driver: true, vehicle: true },
},
},
include: this.vehicleInclude,
});
}
async findAll() {
return this.prisma.vehicle.findMany({
where: { deletedAt: null },
include: {
currentDriver: true,
events: {
where: { deletedAt: null },
include: { driver: true, vehicle: true },
orderBy: { startTime: 'asc' },
},
},
include: this.vehicleInclude,
orderBy: { name: 'asc' },
});
}
@@ -41,7 +36,6 @@ export class VehiclesService {
async findAvailable() {
return this.prisma.vehicle.findMany({
where: {
deletedAt: null,
status: 'AVAILABLE',
},
include: {
@@ -53,15 +47,8 @@ export class VehiclesService {
async findOne(id: string) {
const vehicle = await this.prisma.vehicle.findFirst({
where: { id, deletedAt: null },
include: {
currentDriver: true,
events: {
where: { deletedAt: null },
include: { driver: true, vehicle: true },
orderBy: { startTime: 'asc' },
},
},
where: { id },
include: this.vehicleInclude,
});
if (!vehicle) {
@@ -79,34 +66,24 @@ export class VehiclesService {
return this.prisma.vehicle.update({
where: { id: vehicle.id },
data: updateVehicleDto,
include: {
currentDriver: true,
events: {
where: { deletedAt: null },
include: { driver: true, vehicle: true },
},
},
include: this.vehicleInclude,
});
}
async remove(id: string, hardDelete = false, userRole?: string) {
if (hardDelete && userRole !== 'ADMINISTRATOR') {
throw new ForbiddenException('Only administrators can permanently delete records');
}
const vehicle = await this.findOne(id);
if (hardDelete) {
this.logger.log(`Hard deleting vehicle: ${vehicle.name}`);
return this.prisma.vehicle.delete({
where: { id: vehicle.id },
});
}
this.logger.log(`Soft deleting vehicle: ${vehicle.name}`);
return this.prisma.vehicle.update({
where: { id: vehicle.id },
return executeHardDelete({
id,
hardDelete,
userRole,
findOne: (id) => this.findOne(id),
performHardDelete: (id) => this.prisma.vehicle.delete({ where: { id } }),
performSoftDelete: (id) =>
this.prisma.vehicle.update({
where: { id },
data: { deletedAt: new Date() },
}),
entityName: 'Vehicle',
logger: this.logger,
});
}
@@ -114,23 +91,32 @@ export class VehiclesService {
* Get vehicle utilization statistics
*/
async getUtilization() {
const vehicles = await this.findAll();
const now = new Date();
const stats = vehicles.map((vehicle) => {
const upcomingEvents = vehicle.events.filter(
(event) => new Date(event.startTime) > new Date(),
);
// Fetch vehicles with only upcoming events (filtered at database level)
const vehicles = await this.prisma.vehicle.findMany({
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,
name: vehicle.name,
type: vehicle.type,
seatCapacity: vehicle.seatCapacity,
status: vehicle.status,
upcomingTrips: upcomingEvents.length,
upcomingTrips: vehicle.events.length, // Already filtered at DB level
currentDriver: vehicle.currentDriver?.name,
};
});
}));
return {
totalVehicles: vehicles.length,

View File

@@ -15,6 +15,7 @@ import { AbilitiesGuard } from '../auth/guards/abilities.guard';
import { CanCreate, CanRead, CanUpdate, CanDelete } from '../auth/decorators/check-ability.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CreateVipDto, UpdateVipDto } from './dto';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('vips')
@UseGuards(JwtAuthGuard, AbilitiesGuard)
@@ -49,11 +50,9 @@ export class VipsController {
@CanDelete('VIP')
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
@CurrentUser() user?: any,
) {
// Only administrators can hard delete
const isHardDelete = hard === 'true';
return this.vipsService.remove(id, isHardDelete, user?.role);
return this.vipsService.remove(id, hard, user?.role);
}
}

View File

@@ -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 { CreateVipDto, UpdateVipDto } from './dto';
import { executeHardDelete } from '../common/utils';
@Injectable()
export class VipsService {
@@ -21,7 +22,6 @@ export class VipsService {
async findAll() {
return this.prisma.vIP.findMany({
where: { deletedAt: null },
include: {
flights: true,
},
@@ -31,7 +31,7 @@ export class VipsService {
async findOne(id: string) {
const vip = await this.prisma.vIP.findFirst({
where: { id, deletedAt: null },
where: { id },
include: {
flights: true,
},
@@ -59,23 +59,19 @@ export class VipsService {
}
async remove(id: string, hardDelete = false, userRole?: string) {
if (hardDelete && userRole !== 'ADMINISTRATOR') {
throw new ForbiddenException('Only administrators can permanently delete records');
}
const vip = await this.findOne(id);
if (hardDelete) {
this.logger.log(`Hard deleting VIP: ${vip.name}`);
return this.prisma.vIP.delete({
where: { id: vip.id },
});
}
this.logger.log(`Soft deleting VIP: ${vip.name}`);
return this.prisma.vIP.update({
where: { id: vip.id },
return executeHardDelete({
id,
hardDelete,
userRole,
findOne: (id) => this.findOne(id),
performHardDelete: (id) => this.prisma.vIP.delete({ where: { id } }),
performSoftDelete: (id) =>
this.prisma.vIP.update({
where: { id },
data: { deletedAt: new Date() },
}),
entityName: 'VIP',
logger: this.logger,
});
}
}

View File

@@ -4,6 +4,7 @@ import { Auth0Provider } from '@auth0/auth0-react';
import { Toaster } from 'react-hot-toast';
import { AuthProvider } from '@/contexts/AuthContext';
import { AbilityProvider } from '@/contexts/AbilityContext';
import { TimezoneProvider } from '@/contexts/TimezoneContext';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { ProtectedRoute } from '@/components/ProtectedRoute';
import { Layout } from '@/components/Layout';
@@ -13,7 +14,7 @@ import { Callback } from '@/pages/Callback';
import { PendingApproval } from '@/pages/PendingApproval';
import { Dashboard } from '@/pages/Dashboard';
import { CommandCenter } from '@/pages/CommandCenter';
import { VIPList } from '@/pages/VipList';
import { VIPList } from '@/pages/VIPList';
import { VIPSchedule } from '@/pages/VIPSchedule';
import { FleetPage } from '@/pages/FleetPage';
import { EventList } from '@/pages/EventList';
@@ -68,6 +69,7 @@ function App() {
>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<TimezoneProvider>
<AbilityProvider>
<BrowserRouter
future={{
@@ -138,6 +140,7 @@ function App() {
</Routes>
</BrowserRouter>
</AbilityProvider>
</TimezoneProvider>
</AuthProvider>
</QueryClientProvider>
</Auth0Provider>

View 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>
);
}

View 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>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { X, Send, Loader2 } from 'lucide-react';
import { useDriverMessages, useSendMessage, useMarkMessagesAsRead } from '../hooks/useSignalMessages';
import { useFormattedDate } from '@/hooks/useFormattedDate';
interface Driver {
id: string;
@@ -18,6 +19,7 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
const [message, setMessage] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const { formatDateTime } = useFormattedDate();
const { data: messages, isLoading } = useDriverMessages(driver?.id || null, isOpen);
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
@@ -126,7 +112,7 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
<p className={`text-[10px] mt-1 ${
msg.direction === 'OUTBOUND' ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
}`}>
{formatTime(msg.timestamp)}
{formatDateTime(msg.timestamp)}
</p>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { DEPARTMENT_LABELS } from '@/lib/enum-labels';
interface DriverFormProps {
driver?: Driver | null;
@@ -112,9 +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"
>
<option value="">Select Department</option>
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
<option value="ADMIN">Admin</option>
<option value="OTHER">Other</option>
{Object.entries(DEPARTMENT_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>

View File

@@ -3,6 +3,7 @@ import { api } from '@/lib/api';
import { X, Calendar, Clock, MapPin, Car, User, ChevronLeft, ChevronRight } from 'lucide-react';
import { Driver } from '@/types';
import { useState } from 'react';
import { useFormattedDate } from '@/hooks/useFormattedDate';
interface ScheduleEvent {
id: string;
@@ -36,6 +37,7 @@ interface DriverScheduleModalProps {
export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleModalProps) {
const [selectedDate, setSelectedDate] = useState(new Date());
const { formatDate, formatTime } = useFormattedDate();
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 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) => {
switch (status) {
case 'COMPLETED':

View File

@@ -1,10 +1,13 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from '@tanstack/react-query';
import { X, AlertTriangle, Users, Car, Link2 } from 'lucide-react';
import { api } from '@/lib/api';
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 {
event?: ScheduleEvent | null;
@@ -39,17 +42,7 @@ interface ScheduleConflict {
}
export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraActions }: EventFormProps) {
// Helper to convert ISO datetime to datetime-local format
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 { formatDateTime } = useFormattedDate();
const [formData, setFormData] = useState<EventFormData>({
vipIds: event?.vipIds || [],
@@ -75,7 +68,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
// Fetch VIPs for selection
const { data: vips } = useQuery<VIP[]>({
queryKey: ['vips'],
queryKey: queryKeys.vips.all,
queryFn: async () => {
const { data } = await api.get('/vips');
return data;
@@ -84,7 +77,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
// Fetch Drivers for dropdown
const { data: drivers } = useQuery<Driver[]>({
queryKey: ['drivers'],
queryKey: queryKeys.drivers.all,
queryFn: async () => {
const { data } = await api.get('/drivers');
return data;
@@ -93,7 +86,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
// Fetch Vehicles for dropdown
const { data: vehicles } = useQuery<Vehicle[]>({
queryKey: ['vehicles'],
queryKey: queryKeys.vehicles.all,
queryFn: async () => {
const { data } = await api.get('/vehicles');
return data;
@@ -102,7 +95,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
// Fetch all events (for master event selector)
const { data: allEvents } = useQuery<ScheduleEvent[]>({
queryKey: ['events'],
queryKey: queryKeys.events.all,
queryFn: async () => {
const { data } = await api.get('/events');
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))
.map(vip => vip.partySize > 1 ? `${vip.name} (+${vip.partySize - 1})` : vip.name)
.join(', ') || 'None selected';
}, [vips, formData.vipIds]);
return (
<>
@@ -450,11 +445,11 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
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"
>
<option value="TRANSPORT">Transport</option>
<option value="MEETING">Meeting</option>
<option value="EVENT">Event</option>
<option value="MEAL">Meal</option>
<option value="ACCOMMODATION">Accommodation</option>
{Object.entries(EVENT_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div>
@@ -468,10 +463,11 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
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"
>
<option value="SCHEDULED">Scheduled</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="COMPLETED">Completed</option>
<option value="CANCELLED">Cancelled</option>
{Object.entries(EVENT_STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>

View File

@@ -6,18 +6,22 @@ import {
Trash2,
AlertTriangle,
Clock,
ToggleLeft,
ToggleRight,
ChevronDown,
ChevronUp,
Users,
CheckCircle,
Link2,
XCircle,
} from 'lucide-react';
import { Flight } from '@/types';
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;
flight?: Flight;
journey?: Journey;
onEdit?: (flight: Flight) => void;
onDelete?: (flight: Flight) => void;
}
@@ -58,16 +62,75 @@ function formatRelativeTime(isoString: string | null): string {
return `${Math.floor(hours / 24)}d ago`;
}
export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
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 isTerminal = ['landed', 'cancelled', 'diverted', 'incident'].includes(flight.status?.toLowerCase() || '');
const { formatDateTime } = useFormattedDate();
return (
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
{/* Alert banner */}
{alert && (
<div className={`px-4 py-1.5 text-xs font-semibold border-b flex items-center gap-2 ${alert.color}`}>
<AlertTriangle className="w-3.5 h-3.5" />
@@ -75,22 +138,16 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
</div>
)}
{/* Header */}
<div className="px-4 pt-3 pb-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
{/* Status dot */}
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
{/* Flight number + airline */}
<div className="flex items-center gap-2">
<span className="font-bold text-foreground">{flight.flightNumber}</span>
{flight.airlineName && (
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
)}
</div>
{/* VIP name */}
{flight.vip && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<span className="text-muted-foreground/50">|</span>
@@ -104,8 +161,6 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<button
onClick={() => refreshMutation.mutate(flight.id)}
@@ -116,20 +171,12 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
<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"
>
<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"
>
<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>
)}
@@ -137,12 +184,10 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
</div>
</div>
{/* Progress Bar */}
<div className="px-4">
<FlightProgressBar flight={flight} />
</div>
{/* Footer - expandable details */}
<div className="px-4 pb-2">
<button
onClick={() => setExpanded(!expanded)}
@@ -165,14 +210,13 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
{expanded && (
<div className="pt-2 pb-1 border-t border-border/50 space-y-2 text-xs">
{/* Detailed times */}
<div className="grid grid-cols-2 gap-4">
<div>
<div className="font-medium text-foreground mb-1">Departure</div>
<div className="space-y-0.5 text-muted-foreground">
{flight.scheduledDeparture && <div>Scheduled: {new Date(flight.scheduledDeparture).toLocaleString()}</div>}
{flight.estimatedDeparture && <div>Estimated: {new Date(flight.estimatedDeparture).toLocaleString()}</div>}
{flight.actualDeparture && <div className="text-foreground">Actual: {new Date(flight.actualDeparture).toLocaleString()}</div>}
{flight.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>
)}
@@ -183,9 +227,9 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
<div>
<div className="font-medium text-foreground mb-1">Arrival</div>
<div className="space-y-0.5 text-muted-foreground">
{flight.scheduledArrival && <div>Scheduled: {new Date(flight.scheduledArrival).toLocaleString()}</div>}
{flight.estimatedArrival && <div>Estimated: {new Date(flight.estimatedArrival).toLocaleString()}</div>}
{flight.actualArrival && <div className="text-foreground">Actual: {new Date(flight.actualArrival).toLocaleString()}</div>}
{flight.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>
)}
@@ -195,12 +239,8 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
</div>
</div>
</div>
{/* Aircraft info */}
{flight.aircraftType && (
<div className="text-muted-foreground">
Aircraft: {flight.aircraftType}
</div>
<div className="text-muted-foreground">Aircraft: {flight.aircraftType}</div>
)}
</div>
)}
@@ -208,3 +248,220 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
</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;
}

View File

@@ -1,6 +1,7 @@
import { useMemo, useEffect, useState } from 'react';
import { Plane } from 'lucide-react';
import { Flight } from '@/types';
import { useFormattedDate } from '@/hooks/useFormattedDate';
interface FlightProgressBarProps {
flight: Flight;
@@ -59,16 +60,8 @@ function getTrackBgColor(flight: Flight): string {
return 'bg-muted';
}
function formatTime(isoString: string | null): string {
if (!isoString) return '--:--';
return new Date(isoString).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
}
export function FlightProgressBar({ flight, compact = false }: FlightProgressBarProps) {
const { formatTime } = useFormattedDate();
const [progress, setProgress] = useState(() => calculateProgress(flight));
const status = flight.status?.toLowerCase();
const isActive = status === 'active';

View File

@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { ChevronDown, AlertTriangle, X } from 'lucide-react';
import { formatDateTime } from '@/lib/utils';
import { useFormattedDate } from '@/hooks/useFormattedDate';
interface Driver {
id: string;
@@ -32,6 +32,7 @@ export function InlineDriverSelector({
currentDriverName,
onDriverChange,
}: InlineDriverSelectorProps) {
const { formatDateTime } = useFormattedDate();
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [showConflictDialog, setShowConflictDialog] = useState(false);

View 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>
);
}

View File

@@ -1,5 +1,7 @@
import { useState } from '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 {
vip?: VIP | null;
@@ -44,18 +46,6 @@ export interface VIPFormData {
}
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>({
name: vip?.name || '',
organization: vip?.organization || '',
@@ -194,9 +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"
style={{ minHeight: '44px' }}
>
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
<option value="ADMIN">Admin</option>
<option value="OTHER">Other</option>
{Object.entries(DEPARTMENT_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
@@ -213,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"
style={{ minHeight: '44px' }}
>
<option value="FLIGHT">Flight</option>
<option value="SELF_DRIVING">Self Driving</option>
{Object.entries(ARRIVAL_MODE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>

View 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);
}

View File

@@ -2,10 +2,11 @@ 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: ['flights'],
queryKey: queryKeys.flights.all,
queryFn: async () => {
const { data } = await api.get('/flights');
return data;
@@ -16,7 +17,7 @@ export function useFlights() {
export function useFlightBudget() {
return useQuery<FlightBudget>({
queryKey: ['flights', 'budget'],
queryKey: queryKeys.flights.budget,
queryFn: async () => {
const { data } = await api.get('/flights/tracking/budget');
return data;
@@ -34,8 +35,8 @@ export function useRefreshFlight() {
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['flights'] });
queryClient.invalidateQueries({ queryKey: ['flights', 'budget'] });
queryClient.invalidateQueries({ queryKey: queryKeys.flights.all });
queryClient.invalidateQueries({ queryKey: queryKeys.flights.budget });
const status = data.status || 'unknown';
toast.success(`Flight updated: ${data.flightNumber} (${status})`);
},
@@ -54,8 +55,8 @@ export function useRefreshActiveFlights() {
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['flights'] });
queryClient.invalidateQueries({ queryKey: ['flights', 'budget'] });
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) => {

View 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 };
}

View File

@@ -8,8 +8,10 @@ import type {
GpsSettings,
EnrollmentResponse,
MyGpsStatus,
DeviceQrInfo,
} from '@/types/gps';
import toast from 'react-hot-toast';
import { queryKeys } from '@/lib/query-keys';
// ============================================
// Admin GPS Hooks
@@ -20,7 +22,7 @@ import toast from 'react-hot-toast';
*/
export function useGpsStatus() {
return useQuery<GpsStatus>({
queryKey: ['gps', 'status'],
queryKey: queryKeys.gps.status,
queryFn: async () => {
const { data } = await api.get('/gps/status');
return data;
@@ -34,7 +36,7 @@ export function useGpsStatus() {
*/
export function useGpsSettings() {
return useQuery<GpsSettings>({
queryKey: ['gps', 'settings'],
queryKey: queryKeys.gps.settings,
queryFn: async () => {
const { data } = await api.get('/gps/settings');
return data;
@@ -54,8 +56,8 @@ export function useUpdateGpsSettings() {
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gps', 'settings'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.settings });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
toast.success('GPS settings updated');
},
onError: (error: any) => {
@@ -69,7 +71,7 @@ export function useUpdateGpsSettings() {
*/
export function useGpsDevices() {
return useQuery<GpsDevice[]>({
queryKey: ['gps', 'devices'],
queryKey: queryKeys.gps.devices,
queryFn: async () => {
const { data } = await api.get('/gps/devices');
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() {
return useQuery<DriverLocation[]>({
queryKey: ['gps', 'locations'],
queryKey: queryKeys.gps.locations.all,
queryFn: async () => {
const { data } = await api.get('/gps/locations');
return data;
},
refetchInterval: 30000, // Refresh every 30 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,
refetchInterval: 15000, // Refresh every 15 seconds
});
}
@@ -136,9 +120,9 @@ export function useEnrollDriver() {
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['gps', 'devices'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
queryClient.invalidateQueries({ queryKey: ['drivers'] });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.devices });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
queryClient.invalidateQueries({ queryKey: queryKeys.drivers.all });
if (data.signalMessageSent) {
toast.success('Driver enrolled! Setup instructions sent via Signal.');
} else {
@@ -163,10 +147,10 @@ export function useUnenrollDriver() {
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gps', 'devices'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'locations'] });
queryClient.invalidateQueries({ queryKey: ['drivers'] });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.devices });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.locations.all });
queryClient.invalidateQueries({ queryKey: queryKeys.drivers.all });
toast.success('Driver unenrolled from GPS tracking');
},
onError: (error: any) => {
@@ -184,7 +168,7 @@ export function useUnenrollDriver() {
*/
export function useMyGpsStatus() {
return useQuery<MyGpsStatus>({
queryKey: ['gps', 'me'],
queryKey: queryKeys.gps.me.status,
queryFn: async () => {
const { data } = await api.get('/gps/me');
return data;
@@ -197,7 +181,7 @@ export function useMyGpsStatus() {
*/
export function useMyGpsStats(from?: string, to?: string) {
return useQuery<DriverStats>({
queryKey: ['gps', 'me', 'stats', from, to],
queryKey: queryKeys.gps.me.stats(from, to),
queryFn: async () => {
const params = new URLSearchParams();
if (from) params.append('from', from);
@@ -213,7 +197,7 @@ export function useMyGpsStats(from?: string, to?: string) {
*/
export function useMyLocation() {
return useQuery<DriverLocation>({
queryKey: ['gps', 'me', 'location'],
queryKey: queryKeys.gps.me.location,
queryFn: async () => {
const { data } = await api.get('/gps/me/location');
return data;
@@ -234,7 +218,7 @@ export function useUpdateGpsConsent() {
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['gps', 'me'] });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.me.status });
toast.success(data.message);
},
onError: (error: any) => {
@@ -252,7 +236,7 @@ export function useUpdateGpsConsent() {
*/
export function useTraccarSetupStatus() {
return useQuery<{ needsSetup: boolean; isAvailable: boolean }>({
queryKey: ['gps', 'traccar', 'status'],
queryKey: queryKeys.gps.traccar.status,
queryFn: async () => {
const { data } = await api.get('/gps/traccar/status');
return data;
@@ -272,8 +256,8 @@ export function useTraccarSetup() {
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gps', 'traccar', 'status'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.traccar.status });
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
toast.success('Traccar setup complete!');
},
onError: (error: any) => {
@@ -305,7 +289,7 @@ export function useSyncAdminsToTraccar() {
*/
export function useTraccarAdminUrl() {
return useQuery<{ url: string; directAccess: boolean }>({
queryKey: ['gps', 'traccar', 'admin-url'],
queryKey: queryKeys.gps.traccar.adminUrl,
queryFn: async () => {
const { data } = await api.get('/gps/traccar/admin-url');
return data;

View 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,
};
}

View File

@@ -1,13 +1,14 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
import { PdfSettings, UpdatePdfSettingsDto } from '../types/settings';
import { queryKeys } from '../lib/query-keys';
/**
* Fetch PDF settings
*/
export function usePdfSettings() {
return useQuery<PdfSettings>({
queryKey: ['settings', 'pdf'],
queryKey: queryKeys.settings.pdf,
queryFn: async () => {
const { data } = await api.get('/settings/pdf');
return data;
@@ -27,7 +28,7 @@ export function useUpdatePdfSettings() {
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
},
});
}
@@ -51,7 +52,7 @@ export function useUploadLogo() {
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
},
});
}
@@ -68,7 +69,7 @@ export function useDeleteLogo() {
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
},
});
}

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
import { queryKeys } from '../lib/query-keys';
export interface SignalMessage {
id: string;
@@ -19,7 +20,7 @@ export interface UnreadCounts {
*/
export function useDriverMessages(driverId: string | null, enabled = true) {
return useQuery({
queryKey: ['signal-messages', driverId],
queryKey: driverId ? queryKeys.signal.messages(driverId) : ['signal-messages', null],
queryFn: async () => {
if (!driverId) return [];
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() {
return useQuery({
queryKey: ['signal-unread-counts'],
queryKey: queryKeys.signal.unreadCounts,
queryFn: async () => {
const { data } = await api.get<UnreadCounts>('/signal/messages/unread');
return data;
@@ -54,8 +55,10 @@ export function useDriverResponseCheck(
// Only include events that have a driver
const eventsWithDrivers = events.filter((e) => e.driver?.id);
const eventIds = eventsWithDrivers.map((e) => e.id).join(',');
return useQuery({
queryKey: ['signal-driver-responses', eventsWithDrivers.map((e) => e.id).join(',')],
queryKey: queryKeys.signal.driverResponses(eventIds),
queryFn: async () => {
if (eventsWithDrivers.length === 0) {
return new Set<string>();
@@ -97,11 +100,11 @@ export function useSendMessage() {
onSuccess: (data, variables) => {
// Add the new message to the cache immediately
queryClient.setQueryData<SignalMessage[]>(
['signal-messages', variables.driverId],
queryKeys.signal.messages(variables.driverId),
(old) => [...(old || []), data]
);
// 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) => {
// Update the unread counts cache
queryClient.setQueryData<UnreadCounts>(
['signal-unread-counts'],
queryKeys.signal.unreadCounts,
(old) => {
if (!old) return {};
const updated = { ...old };
@@ -130,7 +133,7 @@ export function useMarkMessagesAsRead() {
);
// Mark messages as read in the messages cache
queryClient.setQueryData<SignalMessage[]>(
['signal-messages', driverId],
queryKeys.signal.messages(driverId),
(old) => old?.map((msg) => ({ ...msg, isRead: true })) || []
);
},

View 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;
}

View 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`;
}

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

View File

@@ -5,16 +5,17 @@ export function cn(...inputs: ClassValue[]) {
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;
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
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;
return d.toLocaleString('en-US', {
year: 'numeric',
@@ -22,13 +23,30 @@ export function formatDateTime(date: string | Date): string {
day: 'numeric',
hour: '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;
return d.toLocaleTimeString('en-US', {
hour: '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}`;
}

View File

@@ -4,6 +4,7 @@ import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { handleError } from '@/lib/errorHandler';
import { PdfSettingsSection } from '@/components/PdfSettingsSection';
import { useTimezoneContext } from '@/contexts/TimezoneContext';
import {
Database,
Trash2,
@@ -25,6 +26,7 @@ import {
Palette,
ExternalLink,
Shield,
Globe,
} from 'lucide-react';
interface Stats {
@@ -51,9 +53,27 @@ interface MessageStats {
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() {
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const { timezone, setTimezone } = useTimezoneContext();
// Signal state
const [showQRCode, setShowQRCode] = useState(false);
@@ -433,6 +453,33 @@ export function AdminTools() {
</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 */}
<PdfSettingsSection />

View File

@@ -27,6 +27,9 @@ import { DriverLocationModal } from '@/components/DriverLocationModal';
import { useUnreadCounts, useDriverResponseCheck } from '@/hooks/useSignalMessages';
import { useDriverLocations } from '@/hooks/useGps';
import type { DriverLocation } from '@/types/gps';
import type { Flight } from '@/types';
import { groupFlightsIntoJourneys, formatLayoverDuration } from '@/lib/journeyUtils';
import { useFormattedDate } from '@/hooks/useFormattedDate';
interface Event {
id: string;
@@ -105,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
export function CommandCenter() {
const { formatTime, formatDateTime, timezone } = useFormattedDate();
const [currentTime, setCurrentTime] = useState(new Date());
const [lastRefresh, setLastRefresh] = useState(new Date());
const [chatDriver, setChatDriver] = useState<Driver | null>(null);
@@ -193,7 +197,7 @@ export function CommandCenter() {
},
});
const { data: flights } = useQuery<VIP['flights']>({
const { data: flights } = useQuery<Flight[]>({
queryKey: ['flights'],
queryFn: async () => {
const { data } = await api.get('/flights');
@@ -201,6 +205,12 @@ export function CommandCenter() {
},
});
// 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)
const now = currentTime;
const awaitingConfirmation = (events || []).filter((event) => {
@@ -330,7 +340,7 @@ export function CommandCenter() {
});
// Upcoming arrivals (next 4 hours) - use best available time (estimated > scheduled)
const getFlightArrivalTime = (flight: VIP['flights'][0]) =>
const getFlightArrivalTime = (flight: { actualArrival: string | null; estimatedArrival: string | null; scheduledArrival: string | null }) =>
flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
const upcomingArrivals = vips
@@ -442,7 +452,7 @@ export function CommandCenter() {
const todayEnd = new Date(todayStart);
todayEnd.setDate(todayEnd.getDate() + 1);
flights.forEach((flight: any) => {
flights.forEach((flight) => {
const arrivalTime = flight.estimatedArrival || flight.scheduledArrival;
if (!arrivalTime) return;
const arrDate = new Date(arrivalTime);
@@ -474,6 +484,33 @@ export function CommandCenter() {
});
}
// 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
function getTimeUntil(dateStr: string) {
const eventTime = new Date(dateStr);
@@ -560,10 +597,10 @@ export function CommandCenter() {
{/* Live Clock */}
<div className="text-right">
<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 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 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" />
@@ -768,7 +805,7 @@ export function CommandCenter() {
<div className="text-right flex-shrink-0">
<p className="text-xs text-muted-foreground">ETA</p>
<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>
</div>
</div>
@@ -874,7 +911,7 @@ export function CommandCenter() {
{getTimeUntil(trip.startTime)}
</p>
<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>
</div>
</div>
@@ -910,15 +947,19 @@ export function CommandCenter() {
) : (
<div className="divide-y divide-border">
{upcomingArrivals.map((vip) => {
// Find this VIP's journey if it exists
const journey = journeys.find(j => j.vipId === vip.id);
const flight = vip.flights.find(f => f.status?.toLowerCase() !== 'cancelled') || vip.flights[0];
const arrival = vip.expectedArrival || (flight && getFlightArrivalTime(flight));
const delay = flight ? Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0) : 0;
const flightStatus = flight?.status?.toLowerCase();
const isCancelled = flightStatus === 'cancelled';
const isActive = flightStatus === 'active';
const isLanded = flightStatus === 'landed' || !!flight?.actualArrival;
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';
// Color-code: green (on time / landed), amber (delayed), red (cancelled), purple (in flight)
const timeColor = isCancelled
? 'text-red-600 dark:text-red-400'
: isLanded
@@ -929,7 +970,9 @@ export function CommandCenter() {
? 'text-purple-600 dark:text-purple-400'
: 'text-blue-600 dark:text-blue-400';
const borderColor = isCancelled
const borderColor = journey?.hasLayoverRisk
? 'border-l-orange-500'
: isCancelled
? 'border-l-red-500'
: delay > 30
? 'border-l-amber-500'
@@ -939,13 +982,24 @@ export function CommandCenter() {
? '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 (
<div key={vip.id} className={`px-3 py-2 border-l-4 ${borderColor}`}>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="font-medium text-foreground text-xs truncate">{vip.name}</p>
{delay > 15 && (
{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
@@ -958,18 +1012,16 @@ export function CommandCenter() {
)}
</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
{flight && (
<>
<span className="font-medium">{flight.flightNumber}</span>
<span>{flight.departureAirport} {flight.arrivalAirport}</span>
</>
<span>{routeChain}</span>
{journey?.isMultiSegment && (
<span className="text-muted-foreground/60">{journey.flights.length} legs</span>
)}
</div>
{flight && (flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && (
{currentFlight && (currentFlight.arrivalTerminal || currentFlight.arrivalGate || currentFlight.arrivalBaggage) && (
<div className="flex gap-2 text-[10px] text-muted-foreground mt-0.5">
{flight.arrivalTerminal && <span>T{flight.arrivalTerminal}</span>}
{flight.arrivalGate && <span>Gate {flight.arrivalGate}</span>}
{flight.arrivalBaggage && <span>Bag {flight.arrivalBaggage}</span>}
{currentFlight.arrivalTerminal && <span>T{currentFlight.arrivalTerminal}</span>}
{currentFlight.arrivalGate && <span>Gate {currentFlight.arrivalGate}</span>}
{currentFlight.arrivalBaggage && <span>Bag {currentFlight.arrivalBaggage}</span>}
</div>
)}
</div>
@@ -979,7 +1031,7 @@ export function CommandCenter() {
</p>
{arrival && !isCancelled && !isLanded && (
<p className="text-[10px] text-muted-foreground">
{new Date(arrival).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
{formatTime(new Date(arrival))}
</p>
)}
</div>

View File

@@ -1,11 +1,14 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Users, Car, Plane, Clock, AlertTriangle } from 'lucide-react';
import { Users, Car, Plane, Clock, AlertTriangle, Link2 } from 'lucide-react';
import { VIP, Driver, ScheduleEvent, Flight } from '@/types';
import { formatDateTime } from '@/lib/utils';
import { useFormattedDate } from '@/hooks/useFormattedDate';
import { FlightProgressBar } from '@/components/FlightProgressBar';
import { groupFlightsIntoJourneys, formatLayoverDuration } from '@/lib/journeyUtils';
export function Dashboard() {
const { formatDate, formatDateTime, formatTime } = useFormattedDate();
const { data: vips } = useQuery<VIP[]>({
queryKey: ['vips'],
queryFn: async () => {
@@ -66,6 +69,25 @@ export function Dashboard() {
.sort((a, b) => new Date(a.flightDate).getTime() - new Date(b.flightDate).getTime())
.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 = [
{
name: 'Total VIPs',
@@ -184,18 +206,22 @@ export function Dashboard() {
Flight Status
</h2>
{/* Status summary */}
{flights && flights.length > 0 && (
{/* Journey status summary */}
{journeys.length > 0 && (
<div className="flex flex-wrap gap-4 mb-4 pb-4 border-b border-border">
{(() => {
const inFlight = flights.filter(f => f.status?.toLowerCase() === 'active').length;
const delayed = flights.filter(f => (f.arrivalDelay || f.departureDelay || 0) > 15).length;
const cancelled = flights.filter(f => f.status?.toLowerCase() === 'cancelled').length;
const landed = flights.filter(f => f.status?.toLowerCase() === 'landed' || f.actualArrival).length;
const scheduled = flights.filter(f => !['active', 'landed', 'cancelled', 'diverted', 'incident'].includes(f.status?.toLowerCase() || '') && !f.actualArrival).length;
const inFlight = journeys.filter(j => j.effectiveStatus === 'active').length;
const connectionRisk = journeys.filter(j => j.hasLayoverRisk).length;
const cancelled = journeys.filter(j => j.effectiveStatus === 'cancelled').length;
const landed = journeys.filter(j => j.effectiveStatus === 'landed').length;
const scheduled = journeys.filter(j => !['active', 'landed', 'cancelled', 'diverted', 'incident'].includes(j.effectiveStatus)).length;
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" />
@@ -203,11 +229,11 @@ export function Dashboard() {
<span className="text-muted-foreground">in flight</span>
</span>
)}
{delayed > 0 && (
{connectionRisk > 0 && (
<span className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-amber-500" />
<span className="font-medium text-amber-600 dark:text-amber-400">{delayed}</span>
<span className="text-muted-foreground">delayed</span>
<span 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 && (
@@ -233,35 +259,43 @@ export function Dashboard() {
</div>
)}
{/* Arriving soon flights */}
{upcomingFlights.length > 0 ? (
{/* Upcoming journeys */}
{upcomingJourneys.length > 0 ? (
<div className="space-y-3">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Arriving Soon
Active & Upcoming Journeys
</h3>
{upcomingFlights.map((flight) => {
const delay = Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0);
const eta = flight.estimatedArrival || flight.scheduledArrival;
const borderColor = delay > 30 ? 'border-amber-500' :
flight.status?.toLowerCase() === 'active' ? 'border-purple-500' :
flight.status?.toLowerCase() === 'cancelled' ? 'border-red-500' :
{upcomingJourneys.map((journey) => {
const currentFlight = journey.flights[journey.currentSegmentIndex];
const lastFlight = journey.flights[journey.flights.length - 1];
const delay = Math.max(currentFlight?.arrivalDelay || 0, currentFlight?.departureDelay || 0);
const routeChain = journey.flights.map(f => f.departureAirport).concat([lastFlight.arrivalAirport]).join(' → ');
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={flight.id}
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">{flight.flightNumber}</span>
{flight.airlineName && (
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
<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>
)}
<span className="text-muted-foreground/50">|</span>
<span className="text-sm text-foreground/80">{flight.vip?.name}</span>
</div>
<div className="flex items-center gap-2">
{journey.hasLayoverRisk && (
<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">
<Link2 className="w-3 h-3" />
Connection risk
</span>
)}
{delay > 15 && (
<span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
<AlertTriangle className="w-3 h-3" />
@@ -269,24 +303,51 @@ export function Dashboard() {
</span>
)}
<span className={`px-2 py-0.5 text-xs rounded-full ${
flight.status?.toLowerCase() === 'active' ? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' :
flight.status?.toLowerCase() === 'landed' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' :
flight.status?.toLowerCase() === 'cancelled' ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' :
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 || 'scheduled'}
{journey.effectiveStatus}
</span>
</div>
</div>
<FlightProgressBar flight={flight} compact />
{/* 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>
{/* Terminal/gate info for arriving flights */}
{(flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && (
{/* 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>
))}
{/* 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">
{flight.arrivalTerminal && <span>Terminal {flight.arrivalTerminal}</span>}
{flight.arrivalGate && <span>Gate {flight.arrivalGate}</span>}
{flight.arrivalBaggage && <span>Baggage {flight.arrivalBaggage}</span>}
{currentFlight.arrivalTerminal && <span>Terminal {currentFlight.arrivalTerminal}</span>}
{currentFlight.arrivalGate && <span>Gate {currentFlight.arrivalGate}</span>}
{currentFlight.arrivalBaggage && <span>Baggage {currentFlight.arrivalBaggage}</span>}
</div>
)}
</div>

View File

@@ -3,16 +3,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
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 { TableSkeleton, CardSkeleton } from '@/components/Skeleton';
import { FilterModal } from '@/components/FilterModal';
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 { DriverChatModal } from '@/components/DriverChatModal';
import { DriverScheduleModal } from '@/components/DriverScheduleModal';
import { useUnreadCounts } from '@/hooks/useSignalMessages';
import { DEPARTMENT_LABELS } from '@/lib/enum-labels';
export function DriverList({ embedded = false }: { embedded?: boolean }) {
const queryClient = useQueryClient();
@@ -20,8 +23,19 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
const [editingDriver, setEditingDriver] = useState<Driver | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Search and filter state
const [searchTerm, setSearchTerm] = useState('');
// List page state management
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 [filterModalOpen, setFilterModalOpen] = useState(false);
@@ -31,12 +45,8 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
// Schedule modal state
const [scheduleDriver, setScheduleDriver] = useState<Driver | null>(null);
// Sort state
const [sortColumn, setSortColumn] = useState<'name' | 'phone' | 'department'>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
// Debounce search term
const debouncedSearchTerm = useDebounce(searchTerm, 300);
// Confirm delete modal state
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null);
// Fetch unread message counts
const { data: unreadCounts } = useUnreadCounts();
@@ -179,26 +189,12 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
setSelectedDepartments([]);
};
const handleSort = (column: typeof sortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
const handleRemoveDepartmentFilter = (dept: string) => {
setSelectedDepartments((prev) => prev.filter((d) => d !== dept));
};
const getFilterLabel = (value: string) => {
const labels = {
'OFFICE_OF_DEVELOPMENT': 'Office of Development',
'ADMIN': 'Admin',
'OTHER': 'Other',
};
return labels[value as keyof typeof labels] || value;
return DEPARTMENT_LABELS[value] || value;
};
const handleAdd = () => {
@@ -212,8 +208,13 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
};
const handleDelete = (id: string, name: string) => {
if (confirm(`Delete driver "${name}"? This action cannot be undone.`)) {
deleteMutation.mutate(id);
setDeleteConfirm({ id, name });
};
const confirmDelete = () => {
if (deleteConfirm) {
deleteMutation.mutate(deleteConfirm.id);
setDeleteConfirm(null);
}
};
@@ -354,36 +355,24 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/30">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
onClick={() => handleSort('name')}
>
<div className="flex items-center gap-2">
Name
<ArrowUpDown className="h-4 w-4" />
{sortColumn === 'name' && <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('phone')}
>
<div className="flex items-center gap-2">
Phone
<ArrowUpDown className="h-4 w-4" />
{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>
<SortableHeader
column="name"
label="Name"
currentSort={{ key: sortColumn, direction: sortDirection }}
onSort={handleSort}
/>
<SortableHeader
column="phone"
label="Phone"
currentSort={{ key: sortColumn, direction: sortDirection }}
onSort={handleSort}
/>
<SortableHeader
column="department"
label="Department"
currentSort={{ key: sortColumn, direction: sortDirection }}
onSort={handleSort}
/>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
Assigned Events
</th>
@@ -539,11 +528,7 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
filterGroups={[
{
label: 'Department',
options: [
{ value: 'OFFICE_OF_DEVELOPMENT', label: 'Office of Development' },
{ value: 'ADMIN', label: 'Admin' },
{ value: 'OTHER', label: 'Other' },
],
options: Object.entries(DEPARTMENT_LABELS).map(([value, label]) => ({ value, label })),
selectedValues: selectedDepartments,
onToggle: handleDepartmentToggle,
},
@@ -565,6 +550,17 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
isOpen={!!scheduleDriver}
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>
);
}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Loading } from '@/components/Loading';
import { useFormattedDate } from '@/hooks/useFormattedDate';
import {
User,
Phone,
@@ -244,6 +245,7 @@ export function DriverProfile() {
}
function GpsStatsSection() {
const { formatDate, formatDateTime } = useFormattedDate();
const { data: gpsStatus, isLoading: statusLoading } = useMyGpsStatus();
const { data: gpsStats, isLoading: statsLoading } = useMyGpsStats();
const updateConsent = useUpdateGpsConsent();
@@ -355,7 +357,7 @@ function GpsStatsSection() {
) : gpsStats ? (
<div className="p-6">
<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>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
@@ -386,7 +388,7 @@ function GpsStatsSection() {
{gpsStats.stats.topSpeedTimestamp && (
<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>
)}
</div>

View File

@@ -4,11 +4,14 @@ import { useLocation, useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { ScheduleEvent, EventType } from '@/types';
import { formatDateTime } from '@/lib/utils';
import { Plus, Edit, Trash2, Search, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import { Plus, Edit, Trash2, Search } from 'lucide-react';
import { EventForm, EventFormData } from '@/components/EventForm';
import { Loading } from '@/components/Loading';
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 SortField = 'title' | 'type' | 'startTime' | 'status' | 'vips';
@@ -18,16 +21,22 @@ export function EventList() {
const queryClient = useQueryClient();
const location = useLocation();
const navigate = useNavigate();
const { formatDateTime } = useFormattedDate();
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeFilter, setActiveFilter] = useState<ActivityFilter>('ALL');
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 [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const { data: events, isLoading } = useQuery<ScheduleEvent[]>({
queryKey: ['events'],
queryKey: queryKeys.events.all,
queryFn: async () => {
const { data } = await api.get('/events');
return data;
@@ -53,7 +62,7 @@ export function EventList() {
await api.post('/events', data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: queryKeys.events.all });
setShowForm(false);
setIsSubmitting(false);
toast.success('Event created successfully');
@@ -70,7 +79,7 @@ export function EventList() {
await api.patch(`/events/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: queryKeys.events.all });
setShowForm(false);
setEditingEvent(null);
setIsSubmitting(false);
@@ -88,7 +97,7 @@ export function EventList() {
await api.delete(`/events/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: queryKeys.events.all });
toast.success('Event deleted successfully');
},
onError: (error: any) => {
@@ -108,8 +117,13 @@ export function EventList() {
};
const handleDelete = (id: string, title: string) => {
if (confirm(`Delete event "${title}"? This action cannot be undone.`)) {
deleteMutation.mutate(id);
setDeleteConfirm({ id, title });
};
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">
<thead className="bg-muted/30">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
onClick={() => handleSort('title')}
>
<div className="flex items-center gap-1">
Title
{sortField === 'title' ? (
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('type')}
>
<div className="flex items-center gap-1">
Type
{sortField === 'type' ? (
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>
<SortableHeader
column="title"
label="Title"
currentSort={{ key: sortField, direction: sortDirection }}
onSort={handleSort}
className="gap-1"
/>
<SortableHeader
column="type"
label="Type"
currentSort={{ key: sortField, direction: sortDirection }}
onSort={handleSort}
className="gap-1"
/>
<SortableHeader
column="vips"
label="VIPs"
currentSort={{ key: sortField, direction: sortDirection }}
onSort={handleSort}
className="gap-1"
/>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
Vehicle
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
Driver
</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('startTime')}
>
<div className="flex items-center gap-1">
Start Time
{sortField === 'startTime' ? (
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('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>
<SortableHeader
column="startTime"
label="Start Time"
currentSort={{ key: sortField, direction: sortDirection }}
onSort={handleSort}
className="gap-1"
/>
<SortableHeader
column="status"
label="Status"
currentSort={{ key: sortField, direction: sortDirection }}
onSort={handleSort}
className="gap-1"
/>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
Actions
</th>
@@ -503,6 +487,17 @@ export function EventList() {
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>
);
}

View File

@@ -13,6 +13,7 @@ import {
Clock,
ChevronDown,
ChevronRight,
Link2,
} from 'lucide-react';
import { FlightForm, FlightFormData } from '@/components/FlightForm';
import { FlightCard } from '@/components/FlightCard';
@@ -22,95 +23,117 @@ import { FilterModal } from '@/components/FilterModal';
import { FilterChip } from '@/components/FilterChip';
import { useDebounce } from '@/hooks/useDebounce';
import { useFlights, useFlightBudget, useRefreshActiveFlights } from '@/hooks/useFlights';
import { Flight } from '@/types';
import { Flight, Journey } from '@/types';
import { groupFlightsIntoJourneys } from '@/lib/journeyUtils';
type FlightGroup = {
type JourneyGroup = {
key: string;
label: string;
icon: typeof AlertTriangle;
flights: Flight[];
journeys: Journey[];
color: string;
defaultCollapsed?: boolean;
};
function groupFlights(flights: Flight[]): FlightGroup[] {
function getJourneyEta(j: Journey): string {
// Use the current/last segment's best available arrival time
const seg = j.flights[j.currentSegmentIndex] || j.flights[j.flights.length - 1];
return seg.estimatedArrival || seg.scheduledArrival || '';
}
function getJourneyDeparture(j: Journey): string {
// 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: FlightGroup[] = [
{ key: 'alerts', label: 'Alerts', icon: AlertTriangle, flights: [], color: 'text-red-500' },
{ key: 'arriving', label: 'Arriving Soon', icon: Plane, flights: [], color: 'text-purple-500' },
{ key: 'active', label: 'In Flight', icon: Plane, flights: [], color: 'text-purple-500' },
{ key: 'departing', label: 'Departing Soon', icon: Clock, flights: [], color: 'text-blue-500' },
{ key: 'scheduled', label: 'Scheduled', icon: Clock, flights: [], color: 'text-muted-foreground' },
{ key: 'completed', label: 'Completed', icon: Plane, flights: [], color: 'text-emerald-500', defaultCollapsed: true },
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 flight of flights) {
const status = flight.status?.toLowerCase();
const eta = flight.estimatedArrival || flight.scheduledArrival;
const departure = flight.estimatedDeparture || flight.scheduledDeparture;
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].flights.push(flight);
groups[0].journeys.push(journey);
continue;
}
// Completed: landed
if (status === 'landed' || flight.actualArrival) {
groups[5].flights.push(flight);
// Completed: all segments landed
if (status === 'landed') {
groups[6].journeys.push(journey);
continue;
}
// Arriving soon: active flight landing within 2h
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[1].flights.push(flight);
groups[2].journeys.push(journey);
continue;
}
// In flight: active
if (status === 'active') {
groups[2].flights.push(flight);
groups[3].journeys.push(journey);
continue;
}
// Departing soon: departure within 4h
// Departing soon: next departure within 4h
if (departure && new Date(departure) <= fourHoursFromNow && new Date(departure) >= now) {
groups[3].flights.push(flight);
groups[4].journeys.push(journey);
continue;
}
// Everything else is scheduled
groups[4].flights.push(flight);
groups[5].journeys.push(journey);
}
// Sort within groups
groups[0].flights.sort((a, b) => (b.arrivalDelay || 0) - (a.arrivalDelay || 0)); // Worst first
groups[1].flights.sort((a, b) => {
const etaA = a.estimatedArrival || a.scheduledArrival || '';
const etaB = b.estimatedArrival || b.scheduledArrival || '';
return etaA.localeCompare(etaB);
groups[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].flights.sort((a, b) => {
const etaA = a.estimatedArrival || a.scheduledArrival || '';
const etaB = b.estimatedArrival || b.scheduledArrival || '';
return etaA.localeCompare(etaB);
});
groups[3].flights.sort((a, b) => {
const depA = a.estimatedDeparture || a.scheduledDeparture || '';
const depB = b.estimatedDeparture || b.scheduledDeparture || '';
return depA.localeCompare(depB);
});
groups[4].flights.sort((a, b) => {
const depA = a.scheduledDeparture || a.flightDate;
const depB = b.scheduledDeparture || b.flightDate;
return depA.localeCompare(depB);
});
groups[5].flights.sort((a, b) => {
const arrA = a.actualArrival || a.scheduledArrival || '';
const arrB = b.actualArrival || b.scheduledArrival || '';
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
});
@@ -204,7 +227,7 @@ export function FlightList() {
},
});
// Filter flights
// Filter flights, then group into journeys
const filteredFlights = useMemo(() => {
if (!flights) return [];
@@ -223,7 +246,20 @@ export function FlightList() {
});
}, [flights, debouncedSearchTerm, selectedStatuses]);
const flightGroups = useMemo(() => groupFlights(filteredFlights), [filteredFlights]);
// Group filtered flights into journeys - if any segment of a journey matches search,
// we include the full journey (all segments for that VIP)
const journeys = useMemo(() => {
if (!flights || filteredFlights.length === 0) return [];
// Get VIP IDs that have at least one matching flight
const matchingVipIds = new Set(filteredFlights.map(f => f.vipId));
// Build journeys from ALL flights for matching VIPs (so we don't lose segments)
const allMatchingFlights = flights.filter(f => matchingVipIds.has(f.vipId));
return groupFlightsIntoJourneys(allMatchingFlights);
}, [flights, filteredFlights]);
const journeyGroups = useMemo(() => groupJourneys(journeys), [journeys]);
const toggleGroup = (key: string) => {
setCollapsedGroups(prev => {
@@ -295,17 +331,23 @@ export function FlightList() {
setIsSubmitting(false);
};
// Stats
const stats = useMemo(() => {
if (!flights) return { active: 0, delayed: 0, onTime: 0, landed: 0 };
return {
active: flights.filter(f => f.status?.toLowerCase() === 'active').length,
delayed: flights.filter(f => (f.arrivalDelay || f.departureDelay || 0) > 15).length,
onTime: flights.filter(f => f.status === 'scheduled' && !(f.departureDelay && f.departureDelay > 15)).length,
landed: flights.filter(f => f.status?.toLowerCase() === 'landed' || f.actualArrival).length,
};
// Stats based on all journeys (not just filtered)
const allJourneys = useMemo(() => {
if (!flights) return [];
return groupFlightsIntoJourneys(flights);
}, [flights]);
const stats = useMemo(() => {
if (!allJourneys.length) return { active: 0, delayed: 0, connectionRisk: 0, landed: 0, total: 0 };
return {
active: allJourneys.filter(j => j.effectiveStatus === 'active').length,
delayed: allJourneys.filter(j => j.flights.some(f => (f.arrivalDelay || f.departureDelay || 0) > 15)).length,
connectionRisk: allJourneys.filter(j => j.hasLayoverRisk).length,
landed: allJourneys.filter(j => j.effectiveStatus === 'landed').length,
total: allJourneys.length,
};
}, [allJourneys]);
if (isLoading) {
return (
<div>
@@ -333,21 +375,27 @@ export function FlightList() {
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Flight Tracking</h1>
{flights && flights.length > 0 && (
{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.onTime} scheduled</span>
<span>{stats.landed} landed</span>
</div>
)}
@@ -355,7 +403,7 @@ export function FlightList() {
<div className="flex items-center gap-3">
<BudgetIndicator />
{flights && flights.length > 0 && (
{allJourneys.length > 0 && (
<button
onClick={() => refreshActiveMutation.mutate()}
disabled={refreshActiveMutation.isPending}
@@ -376,7 +424,7 @@ export function FlightList() {
</div>
{/* 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="flex gap-3">
<div className="flex-1 relative">
@@ -420,7 +468,7 @@ export function FlightList() {
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border">
<div className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{filteredFlights.length}</span> of <span className="font-medium text-foreground">{flights?.length || 0}</span> flights
Showing <span className="font-medium text-foreground">{journeys.length}</span> of <span className="font-medium text-foreground">{allJourneys.length}</span> journeys
</div>
{(searchTerm || selectedStatuses.length > 0) && (
<button
@@ -435,11 +483,11 @@ export function FlightList() {
</div>
)}
{/* Flight Groups */}
{flights && flights.length > 0 ? (
{/* Journey Groups */}
{allJourneys.length > 0 ? (
<div className="space-y-6">
{flightGroups.map((group) => {
if (group.flights.length === 0) return null;
{journeyGroups.map((group) => {
if (group.journeys.length === 0) return null;
const isCollapsed = collapsedGroups.has(group.key);
const Icon = group.icon;
@@ -460,18 +508,19 @@ export function FlightList() {
{group.label}
</span>
<span className="text-xs text-muted-foreground font-normal">
({group.flights.length})
({group.journeys.length})
</span>
<div className="flex-1 border-t border-border/50 ml-2" />
</button>
{/* Flight cards */}
{/* Journey cards */}
{!isCollapsed && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{group.flights.map((flight) => (
{group.journeys.map((journey) => (
<FlightCard
key={flight.id}
flight={flight}
key={journey.vipId}
journey={journey.isMultiSegment ? journey : undefined}
flight={journey.isMultiSegment ? undefined : journey.flights[0]}
onEdit={handleEdit}
onDelete={handleDelete}
/>

View File

@@ -1,29 +1,20 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import React, { useState, useMemo } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import {
MapPin,
Settings,
Users,
RefreshCw,
Navigation,
Battery,
Clock,
ExternalLink,
X,
Search,
AlertCircle,
CheckCircle,
XCircle,
UserPlus,
UserMinus,
Gauge,
Activity,
Smartphone,
Route,
Car,
Clock,
Copy,
QrCode,
} from 'lucide-react';
@@ -31,14 +22,13 @@ import {
useGpsStatus,
useGpsSettings,
useUpdateGpsSettings,
useDriverLocations,
useGpsDevices,
useEnrollDriver,
useUnenrollDriver,
useDriverStats,
useTraccarSetupStatus,
useTraccarSetup,
useOpenTraccarAdmin,
useDeviceQr,
} from '@/hooks/useGps';
import { Loading } from '@/components/Loading';
import { ErrorMessage } from '@/components/ErrorMessage';
@@ -46,73 +36,16 @@ import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/AuthContext';
import type { Driver } from '@/types';
import type { DriverLocation } from '@/types/gps';
import toast from 'react-hot-toast';
import { formatDistanceToNow } from 'date-fns';
// Fix Leaflet default marker icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
});
// Custom driver marker icon
const createDriverIcon = (isActive: boolean) => {
return L.divIcon({
className: 'custom-driver-marker',
html: `
<div style="
background-color: ${isActive ? '#22c55e' : '#94a3b8'};
width: 32px;
height: 32px;
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
">
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32],
});
};
// Map auto-fit component — only fits bounds on initial load, not on every refresh
function MapFitBounds({ locations }: { locations: DriverLocation[] }) {
const map = useMap();
const hasFitted = useRef(false);
useEffect(() => {
if (hasFitted.current) return;
const validLocations = locations.filter(d => d.location);
if (validLocations.length > 0) {
const bounds = L.latLngBounds(
validLocations.map(loc => [loc.location!.latitude, loc.location!.longitude])
);
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
hasFitted.current = true;
}
}, [locations, map]);
return null;
}
export function GpsTracking() {
const { backendUser } = useAuth();
const [activeTab, setActiveTab] = useState<'map' | 'devices' | 'settings' | 'stats'>('map');
const [activeTab, setActiveTab] = useState<'devices' | 'settings'>('devices');
const [showEnrollModal, setShowEnrollModal] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [selectedDriverId, setSelectedDriverId] = useState<string>('');
const [enrollmentResult, setEnrollmentResult] = useState<any>(null);
const [showQrDriverId, setShowQrDriverId] = useState<string | null>(null);
// Check admin access
if (backendUser?.role !== 'ADMINISTRATOR') {
@@ -130,10 +63,9 @@ export function GpsTracking() {
// Data hooks
const { data: status, isLoading: statusLoading } = useGpsStatus();
const { data: settings, isLoading: settingsLoading } = useGpsSettings();
const { data: locations, isLoading: locationsLoading, refetch: refetchLocations } = useDriverLocations();
const { data: devices, isLoading: devicesLoading } = useGpsDevices();
const { data: traccarStatus } = useTraccarSetupStatus();
const { data: driverStats } = useDriverStats(selectedDriverId);
const { data: qrInfo, isLoading: qrLoading } = useDeviceQr(showQrDriverId);
// Mutations
const updateSettings = useUpdateGpsSettings();
@@ -158,18 +90,6 @@ export function GpsTracking() {
d.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// Calculate center for map
const mapCenter: [number, number] = useMemo(() => {
const validLocations = locations?.filter(l => l.location) || [];
if (validLocations.length > 0) {
return [
validLocations.reduce((sum, loc) => sum + loc.location!.latitude, 0) / validLocations.length,
validLocations.reduce((sum, loc) => sum + loc.location!.longitude, 0) / validLocations.length,
];
}
return [36.0, -79.0]; // Default to North Carolina area
}, [locations]);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
@@ -229,17 +149,9 @@ export function GpsTracking() {
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">GPS Tracking</h1>
<p className="text-muted-foreground mt-1">Monitor driver locations in real-time</p>
<p className="text-muted-foreground mt-1">Manage driver devices and tracking settings</p>
</div>
<div className="flex gap-2">
<button
onClick={() => refetchLocations()}
className="inline-flex items-center px-4 py-2 border border-input rounded-md text-foreground bg-card hover:bg-accent transition-colors"
style={{ minHeight: '44px' }}
>
<RefreshCw className="h-5 w-5 mr-2" />
Refresh
</button>
{status?.traccarAvailable && (
<button
onClick={() => openTraccar.mutate()}
@@ -248,7 +160,7 @@ export function GpsTracking() {
style={{ minHeight: '44px' }}
>
<ExternalLink className="h-5 w-5 mr-2" />
Traccar Admin
Open Traccar Dashboard
</button>
)}
</div>
@@ -267,6 +179,31 @@ export function GpsTracking() {
</div>
)}
{/* Traccar CTA Banner */}
{status?.traccarAvailable && (
<div className="bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<MapPin className="h-6 w-6 text-blue-600 dark:text-blue-400" />
<div>
<h3 className="font-semibold text-blue-800 dark:text-blue-200">Live Tracking, Trips & Reports</h3>
<p className="text-sm text-blue-700 dark:text-blue-300">
Use the Traccar dashboard for live map, trip history, route playback, and driver reports.
</p>
</div>
</div>
<button
onClick={() => openTraccar.mutate()}
disabled={openTraccar.isPending}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors whitespace-nowrap"
>
<ExternalLink className="h-4 w-4 mr-2" />
Open Traccar
</button>
</div>
</div>
)}
{/* Status Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-card border border-border rounded-lg shadow-soft p-4">
@@ -322,9 +259,7 @@ export function GpsTracking() {
<div className="border-b border-border">
<div className="flex gap-1 -mb-px">
{[
{ id: 'map', label: 'Live Map', icon: MapPin },
{ id: 'devices', label: 'Devices', icon: Smartphone },
{ id: 'stats', label: 'Stats', icon: Gauge },
{ id: 'settings', label: 'Settings', icon: Settings },
].map(tab => (
<button
@@ -345,104 +280,6 @@ export function GpsTracking() {
{/* Tab Content */}
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
{/* Map Tab */}
{activeTab === 'map' && (
<div>
<div className="h-[500px] relative">
{locationsLoading ? (
<div className="h-full flex items-center justify-center">
<Loading message="Loading driver locations..." />
</div>
) : (
<MapContainer
center={mapCenter}
zoom={10}
style={{ height: '100%', width: '100%' }}
>
<TileLayer
attribution='Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
maxZoom={19}
/>
{locations && <MapFitBounds locations={locations} />}
{locations?.filter(l => l.location).map((driver) => (
<Marker
key={driver.driverId}
position={[driver.location!.latitude, driver.location!.longitude]}
icon={createDriverIcon(true)}
>
<Popup>
<div className="min-w-[180px]">
<h3 className="font-semibold text-base">{driver.driverName}</h3>
{driver.driverPhone && (
<p className="text-xs text-gray-600">{driver.driverPhone}</p>
)}
<hr className="my-2" />
<div className="space-y-1 text-sm">
<div className="flex items-center gap-2">
<Navigation className="h-4 w-4 text-blue-500" />
<span>{driver.location?.speed?.toFixed(1) || 0} mph</span>
</div>
{driver.location?.battery && (
<div className="flex items-center gap-2">
<Battery className="h-4 w-4 text-green-500" />
<span>{Math.round(driver.location.battery)}%</span>
</div>
)}
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-gray-500" />
<span>{formatDistanceToNow(new Date(driver.location!.timestamp), { addSuffix: true })}</span>
</div>
</div>
</div>
</Popup>
</Marker>
))}
</MapContainer>
)}
{/* Map Legend */}
<div className="absolute bottom-4 left-4 bg-card/90 backdrop-blur-sm border border-border rounded-lg p-3 z-[1000]">
<h4 className="text-sm font-medium text-foreground mb-2">Legend</h4>
<div className="space-y-1 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500"></div>
<span className="text-muted-foreground">Active</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-gray-400"></div>
<span className="text-muted-foreground">Inactive</span>
</div>
</div>
</div>
</div>
{/* Active Drivers List */}
<div className="p-4 border-t border-border">
<h3 className="font-semibold text-foreground mb-3">Active Drivers ({locations?.filter(l => l.location).length || 0})</h3>
{!locations || locations.filter(l => l.location).length === 0 ? (
<p className="text-muted-foreground text-center py-4">No active drivers reporting location</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{locations.filter(l => l.location).map((driver) => (
<div key={driver.driverId} className="bg-muted/30 rounded-lg p-3 flex items-center justify-between">
<div>
<p className="font-medium text-foreground">{driver.driverName}</p>
<p className="text-sm text-muted-foreground">
{driver.location?.speed?.toFixed(0) || 0} mph
</p>
</div>
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
Online
</span>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Devices Tab */}
{activeTab === 'devices' && (
<div className="p-6">
@@ -491,7 +328,15 @@ export function GpsTracking() {
<td className="px-4 py-3 text-sm text-muted-foreground">
{device.lastActive ? formatDistanceToNow(new Date(device.lastActive), { addSuffix: true }) : 'Never'}
</td>
<td className="px-4 py-3">
<td className="px-4 py-3 flex items-center gap-2">
<button
onClick={() => setShowQrDriverId(device.driverId)}
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80 transition-colors"
title="Show QR code"
>
<QrCode className="h-4 w-4 mr-1" />
QR
</button>
<button
onClick={() => {
if (confirm(`Unenroll ${device.driver?.name} from GPS tracking?`)) {
@@ -520,74 +365,6 @@ export function GpsTracking() {
</div>
)}
{/* Stats Tab */}
{activeTab === 'stats' && (
<div className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">Driver Statistics</h3>
<div className="mb-6">
<label className="block text-sm font-medium text-foreground mb-2">Select Driver</label>
<select
value={selectedDriverId}
onChange={(e) => setSelectedDriverId(e.target.value)}
className="w-full max-w-xs px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary"
>
<option value="">Choose a driver...</option>
{devices?.map((device: any) => (
<option key={device.driverId} value={device.driverId}>
{device.driver?.name || 'Unknown'}
</option>
))}
</select>
</div>
{selectedDriverId && driverStats ? (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-muted/30 rounded-lg p-4">
<div className="flex items-center gap-3">
<Route className="h-8 w-8 text-blue-500" />
<div>
<p className="text-2xl font-bold text-foreground">{driverStats.stats?.totalMiles || 0}</p>
<p className="text-sm text-muted-foreground">Miles Driven</p>
</div>
</div>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<div className="flex items-center gap-3">
<Gauge className="h-8 w-8 text-red-500" />
<div>
<p className="text-2xl font-bold text-foreground">{driverStats.stats?.topSpeedMph || 0} mph</p>
<p className="text-sm text-muted-foreground">Top Speed</p>
</div>
</div>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<div className="flex items-center gap-3">
<Navigation className="h-8 w-8 text-green-500" />
<div>
<p className="text-2xl font-bold text-foreground">{driverStats.stats?.averageSpeedMph || 0} mph</p>
<p className="text-sm text-muted-foreground">Avg Speed</p>
</div>
</div>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<div className="flex items-center gap-3">
<Car className="h-8 w-8 text-purple-500" />
<div>
<p className="text-2xl font-bold text-foreground">{driverStats.stats?.totalTrips || 0}</p>
<p className="text-sm text-muted-foreground">Total Trips</p>
</div>
</div>
</div>
</div>
) : selectedDriverId ? (
<Loading message="Loading stats..." />
) : (
<p className="text-muted-foreground text-center py-8">Select a driver to view their statistics</p>
)}
</div>
)}
{/* Settings Tab */}
{activeTab === 'settings' && (
<div className="p-6 space-y-6">
@@ -603,18 +380,21 @@ export function GpsTracking() {
</label>
<input
type="number"
min={30}
min={10}
max={300}
defaultValue={settings.updateIntervalSeconds}
onBlur={(e) => {
const value = parseInt(e.target.value);
if (value >= 30 && value <= 300 && value !== settings.updateIntervalSeconds) {
if (value >= 10 && value <= 300 && value !== settings.updateIntervalSeconds) {
updateSettings.mutate({ updateIntervalSeconds: value });
}
}}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary"
/>
<p className="mt-1 text-xs text-muted-foreground">How often drivers report their location (30-300 seconds)</p>
<p className="mt-1 text-xs text-muted-foreground">How often drivers report their location (10-300 seconds)</p>
<div className="mt-2 p-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded text-xs text-amber-800 dark:text-amber-200">
<strong>Tip:</strong> 15-30s recommended for active events (smooth routes), 60s for routine use (saves battery). Changing this only affects new QR code enrollments.
</div>
</div>
<div className="bg-muted/30 rounded-lg p-4">
@@ -788,7 +568,7 @@ export function GpsTracking() {
/>
</div>
<p className="text-xs text-muted-foreground">
Open Traccar Client app tap the QR icon scan this code
Open Traccar Client app {'→'} tap the QR icon {'→'} scan this code
</p>
</div>
@@ -871,6 +651,129 @@ export function GpsTracking() {
</div>
</div>
)}
{/* Device QR Code Modal */}
{showQrDriverId && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-lg shadow-lg w-full max-w-md">
<div className="flex justify-between items-center p-4 border-b border-border">
<h3 className="text-lg font-semibold text-foreground">
{qrInfo ? `${qrInfo.driverName} - Setup QR` : 'Device QR Code'}
</h3>
<button
onClick={() => setShowQrDriverId(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4 space-y-4">
{qrLoading ? (
<Loading message="Loading QR code..." />
) : qrInfo ? (
<>
{/* QR Code */}
<div className="bg-white dark:bg-gray-900 p-4 rounded-lg border border-border text-center">
<div className="flex items-center justify-center gap-2 mb-3">
<QrCode className="h-5 w-5 text-primary" />
<span className="text-sm font-medium">Scan with Traccar Client</span>
</div>
<div className="flex justify-center mb-3">
<QRCodeSVG
value={qrInfo.qrCodeUrl}
size={200}
level="M"
includeMargin
/>
</div>
<p className="text-xs text-muted-foreground">
Open Traccar Client app {''} tap the QR icon {''} scan this code
</p>
</div>
{/* Download links */}
<div className="bg-blue-50 dark:bg-blue-950/30 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2 mb-2">
<Smartphone className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-800 dark:text-blue-200">Download Traccar Client</span>
</div>
<div className="flex gap-2 justify-center">
<a
href="https://apps.apple.com/app/traccar-client/id843156974"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
>
iOS App Store
</a>
<a
href="https://play.google.com/store/apps/details?id=org.traccar.client"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700"
>
Google Play
</a>
</div>
</div>
{/* Manual fallback */}
<details className="bg-muted/30 rounded-lg border border-border">
<summary className="p-3 text-sm font-medium cursor-pointer hover:bg-muted/50 transition-colors">
Manual Setup (if QR doesn't work)
</summary>
<div className="px-3 pb-3 space-y-2">
<div>
<label className="text-xs text-muted-foreground">Device ID</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted p-2 rounded text-sm font-mono">
{qrInfo.deviceIdentifier}
</code>
<button
onClick={() => copyToClipboard(qrInfo.deviceIdentifier)}
className="p-2 hover:bg-muted rounded transition-colors"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Server URL</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted p-2 rounded text-sm font-mono text-xs">
{qrInfo.serverUrl}
</code>
<button
onClick={() => copyToClipboard(qrInfo.serverUrl)}
className="p-2 hover:bg-muted rounded transition-colors"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
<ol className="text-xs space-y-1 list-decimal list-inside text-muted-foreground mt-2">
<li>Open Traccar Client and enter Device ID and Server URL above</li>
<li>Set frequency to {qrInfo.updateIntervalSeconds} seconds</li>
<li>Tap "Service Status" to start tracking</li>
</ol>
</div>
</details>
</>
) : (
<ErrorMessage message="Failed to load QR code info" />
)}
<button
onClick={() => setShowQrDriverId(null)}
className="w-full px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors"
>
Done
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { Plane } from 'lucide-react';
import { Plane, LogIn, Shield } from 'lucide-react';
export function Login() {
const { isAuthenticated, loginWithRedirect } = useAuth();
@@ -14,30 +14,51 @@ export function Login() {
}, [isAuthenticated, navigate]);
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-background to-primary/10">
<div className="max-w-md w-full bg-card border border-border rounded-lg shadow-xl p-8">
<div className="text-center mb-8">
<div className="inline-block p-3 bg-primary/10 rounded-full mb-4">
<Plane className="h-12 w-12 text-primary" />
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950">
{/* Background ambient effects */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-1/2 -left-1/2 w-full h-full bg-gradient-radial from-blue-500/10 to-transparent rounded-full blur-3xl" />
<div className="absolute -bottom-1/2 -right-1/2 w-full h-full bg-gradient-radial from-indigo-500/8 to-transparent rounded-full blur-3xl" />
<div className="absolute top-1/4 right-1/4 w-96 h-96 bg-blue-500/5 rounded-full blur-3xl" />
</div>
<h1 className="text-3xl font-bold text-foreground mb-2">
{/* Content */}
<div className="relative z-10 flex flex-col items-center gap-10 px-4 w-full max-w-sm">
{/* Breathing logo circle */}
<div className="animate-heartbeat">
<div className="relative w-36 h-36 rounded-full bg-gradient-to-br from-blue-500/20 to-indigo-600/20 backdrop-blur-sm border border-white/10 flex items-center justify-center animate-glow-pulse">
{/* Inner circle */}
<div className="w-28 h-28 rounded-full bg-gradient-to-br from-blue-500/30 to-indigo-600/30 border border-white/10 flex items-center justify-center shadow-2xl">
<Plane className="h-14 w-14 text-blue-400 drop-shadow-lg" />
</div>
</div>
</div>
{/* Title */}
<div className="text-center">
<h1 className="text-4xl font-bold text-white tracking-tight mb-2">
VIP Coordinator
</h1>
<p className="text-muted-foreground">
Transportation logistics and event coordination
<p className="text-blue-200/60 text-sm tracking-wide">
Transportation logistics & event coordination
</p>
</div>
{/* Sign in card */}
<div className="w-full">
<button
onClick={() => loginWithRedirect()}
className="w-full bg-primary text-primary-foreground py-3 px-4 rounded-lg font-medium hover:bg-primary/90 transition-colors"
className="w-full group relative flex items-center justify-center gap-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white py-4 px-6 rounded-2xl font-semibold text-lg shadow-lg shadow-blue-500/25 hover:shadow-blue-500/40 hover:from-blue-500 hover:to-indigo-500 active:scale-[0.98] transition-all duration-200"
>
Sign In with Auth0
<LogIn className="h-5 w-5 transition-transform group-hover:-translate-x-0.5" />
Sign In
</button>
</div>
<div className="mt-6 text-center text-sm text-muted-foreground">
<p>First user becomes administrator</p>
<p>Subsequent users require admin approval</p>
{/* Footer note */}
<div className="flex items-center gap-2 text-blue-300/40 text-xs">
<Shield className="h-3.5 w-3.5" />
<span>Authorized personnel only</span>
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import { useQuery, useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Loading } from '@/components/Loading';
import toast from 'react-hot-toast';
import { useFormattedDate } from '@/hooks/useFormattedDate';
import {
Calendar,
Clock,
@@ -44,6 +45,8 @@ interface DriverWithSchedule {
}
export function MySchedule() {
const { formatDate, formatTime } = useFormattedDate();
const { data: profile, isLoading, error } = useQuery<DriverWithSchedule>({
queryKey: ['my-driver-profile'],
queryFn: async () => {
@@ -123,31 +126,6 @@ export function MySchedule() {
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())
.slice(0, 5); // Last 5 completed
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date.toDateString() === today.toDateString()) {
return 'Today';
} else if (date.toDateString() === tomorrow.toDateString()) {
return 'Tomorrow';
}
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'IN_PROGRESS':

View File

@@ -1,7 +1,11 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { pdf } from '@react-pdf/renderer';
import { api } from '@/lib/api';
import { Loading } from '@/components/Loading';
import { AccountabilityRosterPDF } from '@/components/AccountabilityRosterPDF';
import { usePdfSettings } from '@/hooks/useSettings';
import toast from 'react-hot-toast';
import {
FileText,
Users,
@@ -13,6 +17,10 @@ import {
Download,
UserCheck,
ClipboardList,
Send,
MessageCircle,
X,
Loader2,
} from 'lucide-react';
interface VIP {
@@ -36,6 +44,10 @@ export function Reports() {
const [activeReport, setActiveReport] = useState<ReportType>('accountability');
const [searchTerm, setSearchTerm] = useState('');
const [departmentFilter, setDepartmentFilter] = useState<string>('all');
const [showSignalModal, setShowSignalModal] = useState(false);
const [signalPhoneNumber, setSignalPhoneNumber] = useState('');
const [signalMessage, setSignalMessage] = useState('');
const [isSendingSignal, setIsSendingSignal] = useState(false);
const { data: vips, isLoading } = useQuery<VIP[]>({
queryKey: ['vips'],
@@ -45,6 +57,8 @@ export function Reports() {
},
});
const { data: pdfSettings } = usePdfSettings();
const reports = [
{
id: 'accountability' as const,
@@ -52,9 +66,6 @@ export function Reports() {
icon: ClipboardList,
description: 'Complete list of all personnel for emergency preparedness',
},
// Future reports can be added here:
// { id: 'schedule-summary', name: 'Schedule Summary', icon: Calendar, description: '...' },
// { id: 'driver-assignments', name: 'Driver Assignments', icon: Car, description: '...' },
];
// Filter VIPs based on search and department
@@ -125,6 +136,90 @@ export function Reports() {
link.click();
};
// Export to PDF
const handleExportPDF = async () => {
if (!filteredVips) return;
try {
const blob = await pdf(
<AccountabilityRosterPDF
vips={filteredVips}
settings={pdfSettings}
/>
).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const now = new Date();
const timestamp = now.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }).replace(' ', '') +
'_' + now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }).replace(':', '');
link.download = `Accountability_Roster_${timestamp}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success('PDF downloaded');
} catch (error) {
console.error('[PDF] Generation failed:', error);
toast.error('Failed to generate PDF. Please try again.');
}
};
// Send PDF via Signal
const handleSendViaSignal = async () => {
if (!filteredVips || !signalPhoneNumber.trim()) return;
setIsSendingSignal(true);
try {
const pdfBlob = await pdf(
<AccountabilityRosterPDF
vips={filteredVips}
settings={pdfSettings}
/>
).toBlob();
// Convert blob to base64
const reader = new FileReader();
const base64Promise = new Promise<string>((resolve, reject) => {
reader.onloadend = () => {
const base64 = (reader.result as string).split(',')[1];
resolve(base64);
};
reader.onerror = reject;
});
reader.readAsDataURL(pdfBlob);
const base64Data = await base64Promise;
const now = new Date();
const timestamp = now.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }).replace(' ', '') +
'_' + now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }).replace(':', '');
const filename = `Accountability_Roster_${timestamp}.pdf`;
const response = await api.post('/signal/send-attachment', {
to: signalPhoneNumber,
message: signalMessage || 'Accountability Roster attached',
attachment: base64Data,
filename,
mimeType: 'application/pdf',
});
if (response.data.success) {
toast.success('Roster sent via Signal!');
setShowSignalModal(false);
setSignalPhoneNumber('');
setSignalMessage('');
} else {
toast.error(response.data.error || 'Failed to send via Signal');
}
} catch (error: any) {
console.error('[Signal] Failed to send:', error);
toast.error(error.response?.data?.message || 'Failed to send via Signal');
} finally {
setIsSendingSignal(false);
}
};
if (isLoading) {
return <Loading message="Loading report data..." />;
}
@@ -237,13 +332,29 @@ export function Reports() {
<option value="OTHER">Other</option>
</select>
</div>
<div className="flex gap-2">
<button
onClick={handleExportCSV}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
className="inline-flex items-center gap-2 px-4 py-2 border border-input rounded-lg text-foreground hover:bg-accent transition-colors"
>
<Download className="h-4 w-4" />
Export CSV
CSV
</button>
<button
onClick={handleExportPDF}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<FileText className="h-4 w-4" />
PDF
</button>
<button
onClick={() => setShowSignalModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Send className="h-4 w-4" />
Signal
</button>
</div>
</div>
{/* Active VIPs Table */}
@@ -452,6 +563,101 @@ export function Reports() {
)}
</div>
)}
{/* Signal Send Modal */}
{showSignalModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
Send Roster via Signal
</h2>
<button
onClick={() => {
setShowSignalModal(false);
setSignalPhoneNumber('');
setSignalMessage('');
}}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4 space-y-4">
<p className="text-sm text-muted-foreground">
Send the Accountability Roster PDF directly to a phone via Signal.
</p>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Phone Number *
</label>
<input
type="tel"
value={signalPhoneNumber}
onChange={(e) => setSignalPhoneNumber(e.target.value)}
placeholder="+1 (555) 123-4567"
className="w-full px-3 py-2 border border-input rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<p className="text-xs text-muted-foreground mt-1">
Include country code (e.g., +1 for US)
</p>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Message (optional)
</label>
<textarea
value={signalMessage}
onChange={(e) => setSignalMessage(e.target.value)}
placeholder="Accountability Roster attached"
rows={2}
className="w-full px-3 py-2 border border-input rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:ring-2 focus:ring-primary focus:border-transparent resize-none"
/>
</div>
<div className="bg-muted/50 p-3 rounded-lg">
<p className="text-xs text-muted-foreground">
<strong>Attachment:</strong> Accountability_Roster_[timestamp].pdf
</p>
</div>
</div>
<div className="flex gap-3 p-4 border-t border-border">
<button
onClick={() => {
setShowSignalModal(false);
setSignalPhoneNumber('');
setSignalMessage('');
}}
className="flex-1 px-4 py-2 border border-input rounded-lg text-foreground hover:bg-accent transition-colors"
>
Cancel
</button>
<button
onClick={handleSendViaSignal}
disabled={!signalPhoneNumber.trim() || isSendingSignal}
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
{isSendingSignal ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="h-4 w-4" />
Send PDF
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { useFormattedDate } from '@/hooks/useFormattedDate';
import { Check, X, UserCheck, UserX, Shield, Trash2, Car } from 'lucide-react';
import { useState } from 'react';
import { Loading } from '@/components/Loading';
import { ConfirmModal } from '@/components/ConfirmModal';
interface User {
id: string;
@@ -18,10 +20,21 @@ interface User {
} | null;
}
type ConfirmAction = 'approve' | 'delete' | 'changeRole';
export function UserList() {
const queryClient = useQueryClient();
const { formatDate } = useFormattedDate();
const [processingUser, setProcessingUser] = useState<string | null>(null);
// Confirm modal state
const [confirmState, setConfirmState] = useState<{
action: ConfirmAction;
userId: string;
userName: string;
newRole?: string;
} | null>(null);
const { data: users, isLoading } = useQuery<User[]>({
queryKey: ['users'],
queryFn: async () => {
@@ -91,23 +104,84 @@ export function UserList() {
},
});
const handleRoleChange = (userId: string, newRole: string) => {
if (confirm(`Change user role to ${newRole}?`)) {
changeRoleMutation.mutate({ userId, role: newRole });
}
const handleRoleChange = (userId: string, userName: string, newRole: string) => {
setConfirmState({
action: 'changeRole',
userId,
userName,
newRole,
});
};
const handleApprove = (userId: string) => {
if (confirm('Approve this user?')) {
const handleApprove = (userId: string, userName: string) => {
setConfirmState({
action: 'approve',
userId,
userName,
});
};
const handleDeny = (userId: string, userName: string) => {
setConfirmState({
action: 'delete',
userId,
userName,
});
};
const handleConfirm = () => {
if (!confirmState) return;
const { action, userId, newRole } = confirmState;
switch (action) {
case 'approve':
setProcessingUser(userId);
approveMutation.mutate(userId);
}
};
const handleDeny = (userId: string) => {
if (confirm('Delete this user? This action cannot be undone.')) {
break;
case 'delete':
setProcessingUser(userId);
deleteUserMutation.mutate(userId);
break;
case 'changeRole':
if (newRole) {
changeRoleMutation.mutate({ userId, role: newRole });
}
break;
}
setConfirmState(null);
};
const getConfirmModalProps = () => {
if (!confirmState) return null;
const { action, userName, newRole } = confirmState;
switch (action) {
case 'approve':
return {
title: 'Approve User',
description: `Are you sure you want to approve ${userName}?`,
confirmLabel: 'Approve',
variant: 'default' as const,
};
case 'delete':
return {
title: 'Delete User',
description: `Are you sure you want to delete ${userName}? This action cannot be undone.`,
confirmLabel: 'Delete',
variant: 'destructive' as const,
};
case 'changeRole':
return {
title: 'Change User Role',
description: `Are you sure you want to change ${userName}'s role to ${newRole}?`,
confirmLabel: 'Change Role',
variant: 'warning' as const,
};
default:
return null;
}
};
@@ -168,12 +242,12 @@ export function UserList() {
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
{formatDate(user.createdAt)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex gap-2">
<button
onClick={() => handleApprove(user.id)}
onClick={() => handleApprove(user.id, user.name || user.email)}
disabled={processingUser === user.id}
className="inline-flex items-center px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 transition-colors"
>
@@ -181,7 +255,7 @@ export function UserList() {
Approve
</button>
<button
onClick={() => handleDeny(user.id)}
onClick={() => handleDeny(user.id, user.name || user.email)}
disabled={processingUser === user.id}
className="inline-flex items-center px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 transition-colors"
>
@@ -284,7 +358,7 @@ export function UserList() {
<div className="flex items-center gap-2">
<select
value={user.role}
onChange={(e) => handleRoleChange(user.id, e.target.value)}
onChange={(e) => handleRoleChange(user.id, user.name || user.email, e.target.value)}
className="text-sm border border-input bg-background rounded px-2 py-1 focus:ring-primary focus:border-primary transition-colors"
>
<option value="DRIVER">Driver</option>
@@ -292,7 +366,7 @@ export function UserList() {
<option value="ADMINISTRATOR">Administrator</option>
</select>
<button
onClick={() => handleDeny(user.id)}
onClick={() => handleDeny(user.id, user.name || user.email)}
className="inline-flex items-center px-2 py-1 text-red-600 hover:text-red-800 hover:bg-red-50 dark:hover:bg-red-950/20 rounded transition-colors"
title="Delete user"
>
@@ -307,6 +381,16 @@ export function UserList() {
</table>
</div>
</div>
{/* Confirm Modal */}
{confirmState && getConfirmModalProps() && (
<ConfirmModal
isOpen={true}
onConfirm={handleConfirm}
onCancel={() => setConfirmState(null)}
{...getConfirmModalProps()!}
/>
)}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { EventForm, EventFormData } from '@/components/EventForm';
import { VIPSchedulePDF } from '@/components/VIPSchedulePDF';
import { ScheduleEvent } from '@/types';
import { usePdfSettings } from '@/hooks/useSettings';
import { useFormattedDate } from '@/hooks/useFormattedDate';
import {
ArrowLeft,
Calendar,
@@ -52,6 +53,7 @@ export function VIPSchedule() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { formatDate, formatDateTime, formatTime } = useFormattedDate();
// State for edit modal
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
@@ -169,12 +171,7 @@ export function VIPSchedule() {
// Group events by day
const eventsByDay = sortedEvents.reduce((acc, event) => {
const date = new Date(event.startTime).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
const date = formatDate(event.startTime);
if (!acc[date]) {
acc[date] = [];
}
@@ -211,13 +208,6 @@ export function VIPSchedule() {
}
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
});
};
const handleExport = async () => {
if (!vip) return;
@@ -362,13 +352,7 @@ export function VIPSchedule() {
<div>
<p className="text-sm text-muted-foreground mb-1">Expected Arrival</p>
<p className="font-medium text-foreground">
{new Date(vip.expectedArrival).toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}
{formatDateTime(vip.expectedArrival)}
</p>
</div>
)}
@@ -399,12 +383,7 @@ export function VIPSchedule() {
{flight.scheduledArrival && (
<p className="text-sm text-blue-900 dark:text-blue-100">
Arrives:{' '}
{new Date(flight.scheduledArrival).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}
{formatDateTime(flight.scheduledArrival)}
</p>
)}
{flight.status && (

View File

@@ -4,12 +4,15 @@ import { useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { VIP } from '@/types';
import { Plus, Edit, Trash2, Search, X, Calendar, Filter, ArrowUpDown, ClipboardList } from 'lucide-react';
import { Plus, Edit, Trash2, Search, X, Calendar, Filter, ClipboardList } from 'lucide-react';
import { VIPForm, VIPFormData } from '@/components/VIPForm';
import { VIPCardSkeleton, TableSkeleton } from '@/components/Skeleton';
import { FilterModal } from '@/components/FilterModal';
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 { DEPARTMENT_LABELS, ARRIVAL_MODE_LABELS } from '@/lib/enum-labels';
export function VIPList() {
const queryClient = useQueryClient();
@@ -18,21 +21,28 @@ export function VIPList() {
const [editingVIP, setEditingVIP] = useState<VIP | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Search and filter state
const [searchTerm, setSearchTerm] = useState('');
// List page state management
const {
search: searchTerm,
setSearch: setSearchTerm,
debouncedSearch: debouncedSearchTerm,
sortKey: sortColumn,
sortDirection,
handleSort,
} = useListPage<'name' | 'organization' | 'department' | 'arrivalMode'>({
defaultSortKey: 'name',
});
// Filter state
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
const [selectedArrivalModes, setSelectedArrivalModes] = useState<string[]>([]);
const [filterModalOpen, setFilterModalOpen] = useState(false);
// Sort state
const [sortColumn, setSortColumn] = useState<'name' | 'organization' | 'department' | 'arrivalMode'>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
// Roster-only toggle (hidden by default)
const [showRosterOnly, setShowRosterOnly] = useState(false);
// Debounce search term for better performance
const debouncedSearchTerm = useDebounce(searchTerm, 300);
// Confirm delete modal state
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null);
const { data: vips, isLoading } = useQuery<VIP[]>({
queryKey: ['vips'],
@@ -170,15 +180,6 @@ export function VIPList() {
setSelectedArrivalModes([]);
};
const handleSort = (column: typeof sortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
const handleRemoveDepartmentFilter = (dept: string) => {
setSelectedDepartments((prev) => prev.filter((d) => d !== dept));
};
@@ -188,18 +189,8 @@ export function VIPList() {
};
const getFilterLabel = (value: string, type: 'department' | 'arrivalMode') => {
const labels = {
department: {
'OFFICE_OF_DEVELOPMENT': 'Office of Development',
'ADMIN': 'Admin',
'OTHER': 'Other',
},
arrivalMode: {
'FLIGHT': 'Flight',
'SELF_DRIVING': 'Self Driving',
},
};
return labels[type][value as keyof typeof labels[typeof type]] || value;
const labels = type === 'department' ? DEPARTMENT_LABELS : ARRIVAL_MODE_LABELS;
return labels[value] || value;
};
const handleAdd = () => {
@@ -213,8 +204,13 @@ export function VIPList() {
};
const handleDelete = (id: string, name: string) => {
if (confirm(`Delete VIP "${name}"? This action cannot be undone.`)) {
deleteMutation.mutate(id);
setDeleteConfirm({ id, name });
};
const confirmDelete = () => {
if (deleteConfirm) {
deleteMutation.mutate(deleteConfirm.id);
setDeleteConfirm(null);
}
};
@@ -290,7 +286,7 @@ export function VIPList() {
{/* Filter Button */}
<button
onClick={() => setFilterModalOpen(true)}
className="inline-flex items-center px-4 py-2 border border-input rounded-md text-foreground bg-background hover:bg-accent font-medium transition-colors"
className="inline-flex items-center px-4 py-2 border border-input rounded-md text-foreground bg-card hover:bg-accent hover:text-accent-foreground font-medium transition-colors"
style={{ minHeight: '44px' }}
>
<Filter className="h-5 w-5 mr-2" />
@@ -363,46 +359,30 @@ export function VIPList() {
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/30">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
onClick={() => handleSort('name')}
>
<div className="flex items-center gap-2">
Name
<ArrowUpDown className="h-4 w-4" />
{sortColumn === 'name' && <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 tracking-wider cursor-pointer hover:bg-accent transition-colors"
onClick={() => handleSort('organization')}
>
<div className="flex items-center gap-2">
Organization
<ArrowUpDown className="h-4 w-4" />
{sortColumn === 'organization' && <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 tracking-wider 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 tracking-wider cursor-pointer hover:bg-accent transition-colors"
onClick={() => handleSort('arrivalMode')}
>
<div className="flex items-center gap-2">
Arrival Mode
<ArrowUpDown className="h-4 w-4" />
{sortColumn === 'arrivalMode' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
</div>
</th>
<SortableHeader
column="name"
label="Name"
currentSort={{ key: sortColumn, direction: sortDirection }}
onSort={handleSort}
/>
<SortableHeader
column="organization"
label="Organization"
currentSort={{ key: sortColumn, direction: sortDirection }}
onSort={handleSort}
/>
<SortableHeader
column="department"
label="Department"
currentSort={{ key: sortColumn, direction: sortDirection }}
onSort={handleSort}
/>
<SortableHeader
column="arrivalMode"
label="Arrival Mode"
currentSort={{ key: sortColumn, direction: sortDirection }}
onSort={handleSort}
/>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Actions
</th>
@@ -544,20 +524,13 @@ export function VIPList() {
filterGroups={[
{
label: 'Department',
options: [
{ value: 'OFFICE_OF_DEVELOPMENT', label: 'Office of Development' },
{ value: 'ADMIN', label: 'Admin' },
{ value: 'OTHER', label: 'Other' },
],
options: Object.entries(DEPARTMENT_LABELS).map(([value, label]) => ({ value, label })),
selectedValues: selectedDepartments,
onToggle: handleDepartmentToggle,
},
{
label: 'Arrival Mode',
options: [
{ value: 'FLIGHT', label: 'Flight' },
{ value: 'SELF_DRIVING', label: 'Self Driving' },
],
options: Object.entries(ARRIVAL_MODE_LABELS).map(([value, label]) => ({ value, label })),
selectedValues: selectedArrivalModes,
onToggle: handleArrivalModeToggle,
},
@@ -565,6 +538,17 @@ export function VIPList() {
onClear={handleClearFilters}
onApply={() => {}}
/>
{/* Confirm Delete Modal */}
<ConfirmModal
isOpen={!!deleteConfirm}
onConfirm={confirmDelete}
onCancel={() => setDeleteConfirm(null)}
title="Delete VIP"
description={`Are you sure you want to delete "${deleteConfirm?.name}"? This action cannot be undone.`}
confirmLabel="Delete"
variant="destructive"
/>
</div>
);
}

View File

@@ -88,6 +88,14 @@ export interface EnrollmentResponse {
signalMessageSent?: boolean;
}
export interface DeviceQrInfo {
driverName: string;
deviceIdentifier: string;
serverUrl: string;
qrCodeUrl: string;
updateIntervalSeconds: number;
}
export interface MyGpsStatus {
enrolled: boolean;
driverId?: string;

View File

@@ -210,3 +210,27 @@ export interface FlightBudget {
remaining: number;
month: string;
}
// Multi-segment journey types
export type LayoverRiskLevel = 'none' | 'ok' | 'warning' | 'critical' | 'missed';
export interface Layover {
airport: string;
afterSegmentIndex: number;
scheduledMinutes: number;
effectiveMinutes: number;
risk: LayoverRiskLevel;
}
export interface Journey {
vipId: string;
vip: VIP | undefined;
flights: Flight[];
layovers: Layover[];
effectiveStatus: string;
currentSegmentIndex: number;
hasLayoverRisk: boolean;
origin: string;
destination: string;
isMultiSegment: boolean;
}

View File

@@ -66,12 +66,25 @@ export default {
},
animation: {
'bounce-subtle': 'bounce-subtle 2s ease-in-out infinite',
'heartbeat': 'heartbeat 3s ease-in-out infinite',
'glow-pulse': 'glow-pulse 3s ease-in-out infinite',
},
keyframes: {
'bounce-subtle': {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-2px)' },
},
'heartbeat': {
'0%, 100%': { transform: 'scale(1)' },
'15%': { transform: 'scale(1.08)' },
'30%': { transform: 'scale(1)' },
'45%': { transform: 'scale(1.05)' },
'60%': { transform: 'scale(1)' },
},
'glow-pulse': {
'0%, 100%': { boxShadow: '0 0 20px rgba(59, 130, 246, 0.15), 0 0 60px rgba(59, 130, 246, 0.05)' },
'50%': { boxShadow: '0 0 30px rgba(59, 130, 246, 0.3), 0 0 80px rgba(59, 130, 246, 0.1)' },
},
},
},
},